diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml index bb34bc32..0702bece 100644 --- a/.github/workflows/eval.yml +++ b/.github/workflows/eval.yml @@ -191,19 +191,4 @@ jobs: }); console.log(`✅ Check run created with conclusion: ${conclusion}`); - console.log(` Average Score: ${avgScore.toFixed(2)}`); - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - with: - flags: evals - name: codecov-evals - fail_ci_if_error: false - - - name: Upload results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 - with: - token: ${{ secrets.CODECOV_TOKEN }} + console.log(` Average Score: ${avgScore.toFixed(2)}`); \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 39f0b41b..9f05ccf9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,21 +50,6 @@ jobs: - name: Run tests run: pnpm test:ci - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - with: - flags: unittests - name: codecov-unittests - fail_ci_if_error: false - - - name: Upload results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 - with: - token: ${{ secrets.CODECOV_TOKEN }} - - name: Publish Test Report uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857 if: ${{ !cancelled() }} diff --git a/README.md b/README.md index de2127b1..6d1e1fe2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # sentry-mcp -[![codecov](https://codecov.io/gh/getsentry/sentry-mcp/graph/badge.svg?token=khVKvJP5Ig)](https://codecov.io/gh/getsentry/sentry-mcp) - Sentry's MCP service is primarily designed for human-in-the-loop coding agents. Our tool selection and priorities are focused on developer workflows and debugging use cases, rather than providing a general-purpose MCP server for all Sentry functionality. This remote MCP server acts as middleware to the upstream Sentry API, optimized for coding assistants like Cursor, Claude Code, and similar development tools. It's based on [Cloudflare's work towards remote MCPs](https://blog.cloudflare.com/remote-model-context-protocol-servers-mcp/). diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 03f06fc7..00000000 --- a/codecov.yml +++ /dev/null @@ -1,10 +0,0 @@ -coverage: - status: - project: - default: - informational: true - patch: - default: - informational: true - -comment: false diff --git a/package.json b/package.json index 97493e32..b678fb51 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "dependencies": { "@biomejs/biome": "catalog:", "@types/node": "catalog:", - "@vitest/coverage-v8": "catalog:", "dotenv": "catalog:", "dotenv-cli": "catalog:", "lint-staged": "catalog:", diff --git a/packages/mcp-cloudflare/package.json b/packages/mcp-cloudflare/package.json index f55b21ad..c588012a 100644 --- a/packages/mcp-cloudflare/package.json +++ b/packages/mcp-cloudflare/package.json @@ -4,9 +4,7 @@ "private": true, "type": "module", "license": "FSL-1.1-ALv2", - "files": [ - "./dist/*" - ], + "files": ["./dist/*"], "exports": { ".": { "types": "./dist/index.ts", @@ -21,12 +19,13 @@ "preview": "vite preview", "cf-typegen": "wrangler types", "test": "vitest run", - "test:ci": "vitest run --coverage --reporter=default --reporter=junit --outputFile=tests.junit.xml", + "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml", "test:watch": "vitest", "tsc": "tsc --noEmit" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.13.15", + "@cloudflare/vitest-pool-workers": "catalog:", "@cloudflare/workers-types": "catalog:", "@sentry/mcp-core": "workspace:*", "@sentry/mcp-server-mocks": "workspace:*", diff --git a/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts b/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts index 6e8e339a..8d8d1c07 100644 --- a/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts +++ b/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts @@ -1,163 +1,280 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import "urlpattern-polyfill"; import type { Env } from "../types"; -import type { ExecutionContext } from "@cloudflare/workers-types"; -import handler from "./mcp-handler.js"; - -// Mock Sentry to avoid actual telemetry -vi.mock("@sentry/cloudflare", () => ({ - flush: vi.fn(() => Promise.resolve(true)), -})); - -// Mock the MCP handler creation - we're testing the wrapper logic, not the MCP protocol -vi.mock("agents/mcp", () => ({ - createMcpHandler: vi.fn(() => { - return vi.fn(() => { - return Promise.resolve(new Response("OK", { status: 200 })); - }); - }), -})); - -describe("mcp-handler", () => { - let env: Env; - let ctx: ExecutionContext & { props?: Record }; - +import mcpHandler from "./mcp-handler"; + +/** + * Tests for the MCP handler. + * + * These tests exercise the MCP handler authentication, URL parsing, + * and integration with the MCP server. + * + * Note: fetchMock is set up globally in test-setup.ts with persistent interceptors + * for Sentry API endpoints. Tests here add specific interceptors as needed. + */ + +/** + * OAuth props that would be injected by the OAuth provider into ctx.props + */ +interface OAuthProps { + id: string; + clientId: string; + accessToken: string; + grantedSkills: string[]; +} + +/** + * Default OAuth props for testing authenticated MCP requests + */ +const DEFAULT_OAUTH_PROPS: OAuthProps = { + id: "test-user-123", + clientId: "test-client", + accessToken: "test-access-token", + grantedSkills: ["inspect", "docs"], +}; + +/** + * Create an ExecutionContext with OAuth props for testing the MCP handler. + */ +function createMcpContext( + props: Partial = {}, +): ExecutionContext & { props: OAuthProps } { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: { + ...DEFAULT_OAUTH_PROPS, + ...props, + }, + } as ExecutionContext & { props: OAuthProps }; +} + +/** + * Create an MCP JSON-RPC request. + * + * Note: The MCP handler requires Accept header to include both + * application/json and text/event-stream for streaming responses. + */ +function createMcpRequest( + method: string, + params: Record = {}, + options: { + path?: string; + id?: number | string; + } = {}, +): Request { + const { path = "/mcp", id = 1 } = options; + + return new Request(`http://localhost${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "CF-Connecting-IP": "192.0.2.1", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method, + params, + id, + }), + }); +} + +/** + * Parse an SSE response to extract JSON-RPC response. + * SSE format: "event: message\ndata: {...JSON...}\n\n" + */ +async function parseSSEResponse(response: Response): Promise { + const text = await response.text(); + // Extract JSON from "data: {...}" line + const dataLine = text.split("\n").find((line) => line.startsWith("data: ")); + if (!dataLine) { + throw new Error(`No data line found in SSE response: ${text}`); + } + return JSON.parse(dataLine.slice(6)) as T; +} + +/** + * Create a mock Env for testing. + */ +function createTestEnv(): Env { + return { + COOKIE_SECRET: "test-cookie-secret-32-characters", + SENTRY_CLIENT_ID: "test-client-id", + SENTRY_CLIENT_SECRET: "test-client-secret", + SENTRY_HOST: "sentry.io", + OPENAI_API_KEY: "test-openai-key", + OAUTH_KV: {} as KVNamespace, + OAUTH_PROVIDER: { + listUserGrants: vi.fn().mockResolvedValue({ items: [] }), + revokeGrant: vi.fn().mockResolvedValue(undefined), + } as unknown as Env["OAUTH_PROVIDER"], + } as Env; +} + +describe("MCP Handler", () => { beforeEach(() => { vi.clearAllMocks(); - - env = { - SENTRY_HOST: "sentry.io", - COOKIE_SECRET: "test-secret", - } as Env; - - // ExecutionContext with OAuth props (set by OAuth provider) - ctx = { - waitUntil: vi.fn(), - passThroughOnException: vi.fn(), - props: { - id: "test-user-123", - clientId: "test-client", - accessToken: "test-token", - grantedSkills: ["inspect", "docs"], - }, - }; }); - it("successfully handles request with org constraint", async () => { - const request = new Request( - "https://test.mcp.sentry.io/mcp/sentry-mcp-evals", - ); + // Note: fetchMock lifecycle is managed by test-setup.ts - const response = await handler.fetch!(request as any, env, ctx); + describe("authentication", () => { + it("should reject requests without auth context", async () => { + const request = createMcpRequest("tools/list"); + const ctx = { + waitUntil: () => {}, + passThroughOnException: () => {}, + // No props = no auth + } as unknown as ExecutionContext; - // Verify successful response - expect(response.status).toBe(200); - }); + await expect( + mcpHandler.fetch!(request, createTestEnv(), ctx), + ).rejects.toThrow("No authentication context"); + }); - it("returns 404 for invalid organization", async () => { - const request = new Request( - "https://test.mcp.sentry.io/mcp/nonexistent-org", - ); + it("should reject legacy tokens without grantedSkills", async () => { + const request = createMcpRequest("tools/list"); + const ctx = createMcpContext({ + grantedSkills: undefined as unknown as string[], + }); + // Simulate legacy token with grantedScopes but no grantedSkills + (ctx.props as Record).grantedScopes = [ + "org:read", + "project:read", + ]; + (ctx.props as Record).grantedSkills = undefined; + + const response = await mcpHandler.fetch!(request, createTestEnv(), ctx); + + expect(response.status).toBe(401); + expect(await response.text()).toContain("re-authorize"); + expect(response.headers.get("WWW-Authenticate")).toContain( + "invalid_token", + ); + }); - const response = await handler.fetch!(request as any, env, ctx); + it("should reject tokens with no valid skills", async () => { + const request = createMcpRequest("tools/list"); + const ctx = createMcpContext({ + grantedSkills: [], // Empty skills + }); - expect(response.status).toBe(404); - expect(await response.text()).toContain("not found"); - }); + const response = await mcpHandler.fetch!(request, createTestEnv(), ctx); - it("returns 404 for invalid project", async () => { - const request = new Request( - "https://test.mcp.sentry.io/mcp/sentry-mcp-evals/nonexistent-project", - ); + expect(response.status).toBe(400); + expect(await response.text()).toContain("No valid skills"); + }); + }); - const response = await handler.fetch!(request as any, env, ctx); + describe("URL constraints", () => { + it("should handle /mcp without constraints", async () => { + const request = createMcpRequest( + "initialize", + { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + { path: "/mcp" }, + ); + const ctx = createMcpContext(); + + const response = await mcpHandler.fetch!(request, createTestEnv(), ctx); + + expect(response.status).toBe(200); + const body = await parseSSEResponse<{ + result?: { protocolVersion: string }; + }>(response); + expect(body.result?.protocolVersion).toBeDefined(); + }); - expect(response.status).toBe(404); - expect(await response.text()).toContain("not found"); - }); + it("should return 404 for invalid URL pattern", async () => { + const request = new Request("http://localhost/invalid-path", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }), + }); + const ctx = createMcpContext(); - it("returns error when authentication context is missing", async () => { - const ctxWithoutAuth = { - waitUntil: vi.fn(), - passThroughOnException: vi.fn(), - props: undefined, - }; + const response = await mcpHandler.fetch!(request, createTestEnv(), ctx); - const request = new Request("https://test.mcp.sentry.io/mcp"); + expect(response.status).toBe(404); + }); - await expect( - handler.fetch!(request as any, env, ctxWithoutAuth as any), - ).rejects.toThrow("No authentication context"); - }); + it("should handle /mcp/:org with valid organization", async () => { + // Uses global mock for sentry-mcp-evals organization + const request = createMcpRequest( + "initialize", + { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + { path: "/mcp/sentry-mcp-evals" }, + ); + const ctx = createMcpContext(); + + const response = await mcpHandler.fetch!(request, createTestEnv(), ctx); + + expect(response.status).toBe(200); + const body = await parseSSEResponse<{ + result?: { protocolVersion: string }; + }>(response); + expect(body.result?.protocolVersion).toBeDefined(); + }); - it("successfully handles request with org and project constraints", async () => { - const request = new Request( - "https://test.mcp.sentry.io/mcp/sentry-mcp-evals/cloudflare-mcp", - ); + it("should return 404 for non-existent organization", async () => { + // Uses global mock for nonexistent-org (returns 404) + const request = createMcpRequest( + "initialize", + {}, + { path: "/mcp/nonexistent-org" }, + ); + const ctx = createMcpContext(); - const response = await handler.fetch!(request as any, env, ctx); + const response = await mcpHandler.fetch!(request, createTestEnv(), ctx); - // Verify successful response - expect(response.status).toBe(200); + expect(response.status).toBe(404); + expect(await response.text()).toContain("not found"); + }); }); - it("successfully handles request without constraints", async () => { - const request = new Request("https://test.mcp.sentry.io/mcp"); + describe("MCP protocol", () => { + it("should respond to tools/list with available tools", async () => { + const request = createMcpRequest("tools/list"); + const ctx = createMcpContext(); + + const response = await mcpHandler.fetch!(request, createTestEnv(), ctx); + + expect(response.status).toBe(200); + const body = await parseSSEResponse<{ + result?: { tools: Array<{ name: string }> }; + }>(response); + expect(body.result?.tools).toBeDefined(); + expect(Array.isArray(body.result?.tools)).toBe(true); + // Should have tools based on granted skills (inspect, docs) + expect(body.result?.tools.length).toBeGreaterThan(0); + }); - const response = await handler.fetch!(request as any, env, ctx); + it("should filter tools based on granted skills", async () => { + const request = createMcpRequest("tools/list"); + // Only grant "docs" skill + const ctx = createMcpContext({ grantedSkills: ["docs"] }); - // Verify successful response - expect(response.status).toBe(200); - }); + const response = await mcpHandler.fetch!(request, createTestEnv(), ctx); + + expect(response.status).toBe(200); + const body = await parseSSEResponse<{ + result?: { tools: Array<{ name: string }> }; + }>(response); + const toolNames = body.result?.tools.map((t) => t.name) || []; + + // Should have docs tools + expect(toolNames).toContain("search_docs"); - it("returns 401 and revokes grant for legacy tokens without grantedSkills", async () => { - const legacyCtx = { - waitUntil: vi.fn(), - passThroughOnException: vi.fn(), - props: { - id: "test-user-123", - clientId: "test-client", - accessToken: "test-token", - // Legacy token: has grantedScopes but no grantedSkills - grantedScopes: ["org:read", "project:read"], - }, - }; - - // Mock the OAuth provider for grant revocation - const mockRevokeGrant = vi.fn(); - const mockListUserGrants = vi.fn().mockResolvedValue({ - items: [{ id: "grant-123", clientId: "test-client" }], + // Should NOT have inspect-only tools + expect(toolNames).not.toContain("get_issue_details"); }); - const envWithOAuth = { - ...env, - OAUTH_PROVIDER: { - listUserGrants: mockListUserGrants, - revokeGrant: mockRevokeGrant, - }, - } as unknown as Env; - - const request = new Request("https://test.mcp.sentry.io/mcp"); - - const response = await handler.fetch!( - request as any, - envWithOAuth, - legacyCtx as any, - ); - - // Verify 401 response with re-auth message and WWW-Authenticate header - expect(response.status).toBe(401); - expect(await response.text()).toContain("re-authorize"); - expect(response.headers.get("WWW-Authenticate")).toContain("invalid_token"); - - // Verify waitUntil was called for background grant revocation - expect(legacyCtx.waitUntil).toHaveBeenCalled(); - - // Wait for the background task to complete - const waitUntilPromise = legacyCtx.waitUntil.mock.calls[0][0]; - await waitUntilPromise; - - // Verify grant was looked up and revoked - expect(mockListUserGrants).toHaveBeenCalledWith("test-user-123"); - expect(mockRevokeGrant).toHaveBeenCalledWith("grant-123", "test-user-123"); }); }); diff --git a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts index e1231e28..501b4c89 100644 --- a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts +++ b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts @@ -13,6 +13,7 @@ import { buildServer } from "@sentry/mcp-core/server"; import { parseSkills } from "@sentry/mcp-core/skills"; import { logWarn } from "@sentry/mcp-core/telem/logging"; import type { ServerContext } from "@sentry/mcp-core/types"; +import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; import type { Env } from "../types"; import { verifyConstraintsAccess } from "./constraint-utils"; import type { ExportedHandler } from "@cloudflare/workers-types"; @@ -157,9 +158,11 @@ const mcpHandler: ExportedHandler = { // Create and configure MCP server with tools filtered by context // Context is captured in tool handler closures during buildServer() + // Use CfWorkerJsonSchemaValidator for Cloudflare Workers (ajv is not compatible with workerd) const server = buildServer({ context: serverContext, agentMode: isAgentMode, + jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), }); // Run MCP handler - context already captured in closures diff --git a/packages/mcp-cloudflare/src/server/routes/__tests__/api-auth.test.ts b/packages/mcp-cloudflare/src/server/routes/__tests__/api-auth.test.ts new file mode 100644 index 00000000..8f6cfe79 --- /dev/null +++ b/packages/mcp-cloudflare/src/server/routes/__tests__/api-auth.test.ts @@ -0,0 +1,128 @@ +import { env } from "cloudflare:test"; +import { describe, it, expect } from "vitest"; +import app from "../../app"; + +describe("/api/auth", () => { + describe("GET /api/auth/status", () => { + it("should return 401 when not authenticated", async () => { + const res = await app.request( + "/api/auth/status", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + }, + }, + env, + ); + + expect(res.status).toBe(401); + const json = (await res.json()) as { authenticated: boolean }; + expect(json.authenticated).toBe(false); + }); + + it("should return 401 with invalid auth cookie", async () => { + const res = await app.request( + "/api/auth/status", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + Cookie: "sentry_auth_data=invalid-json", + }, + }, + env, + ); + + expect(res.status).toBe(401); + const json = (await res.json()) as { authenticated: boolean }; + expect(json.authenticated).toBe(false); + }); + + it("should return 401 with expired token", async () => { + const expiredAuthData = { + access_token: "test-token", + refresh_token: "test-refresh", + expires_at: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago + token_type: "Bearer", + }; + + const res = await app.request( + "/api/auth/status", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + Cookie: `sentry_auth_data=${JSON.stringify(expiredAuthData)}`, + }, + }, + env, + ); + + expect(res.status).toBe(401); + const json = (await res.json()) as { authenticated: boolean }; + expect(json.authenticated).toBe(false); + }); + + it("should return 200 with valid auth cookie", async () => { + const validAuthData = { + access_token: "test-token", + refresh_token: "test-refresh", + expires_at: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now + token_type: "Bearer", + }; + + const res = await app.request( + "/api/auth/status", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + Cookie: `sentry_auth_data=${encodeURIComponent(JSON.stringify(validAuthData))}`, + }, + }, + env, + ); + + expect(res.status).toBe(200); + const json = (await res.json()) as { authenticated: boolean }; + expect(json.authenticated).toBe(true); + }); + }); + + describe("POST /api/auth/logout", () => { + it("should return success", async () => { + const res = await app.request( + "/api/auth/logout", + { + method: "POST", + headers: { + "CF-Connecting-IP": "192.0.2.1", + Origin: "http://localhost", // Required for CSRF check + }, + }, + env, + ); + + expect(res.status).toBe(200); + const json = (await res.json()) as { success: boolean }; + expect(json.success).toBe(true); + }); + + it("should clear auth cookie", async () => { + const res = await app.request( + "/api/auth/logout", + { + method: "POST", + headers: { + "CF-Connecting-IP": "192.0.2.1", + Origin: "http://localhost", // Required for CSRF check + Cookie: "sentry_auth_data=some-token", + }, + }, + env, + ); + + expect(res.status).toBe(200); + // Check that Set-Cookie header is present to clear the cookie + const setCookie = res.headers.get("Set-Cookie"); + expect(setCookie).toContain("sentry_auth_data="); + }); + }); +}); diff --git a/packages/mcp-cloudflare/src/server/routes/__tests__/api-chat.test.ts b/packages/mcp-cloudflare/src/server/routes/__tests__/api-chat.test.ts new file mode 100644 index 00000000..31ed6073 --- /dev/null +++ b/packages/mcp-cloudflare/src/server/routes/__tests__/api-chat.test.ts @@ -0,0 +1,22 @@ +import { env } from "cloudflare:test"; +import { describe, it, expect } from "vitest"; +import app from "../../app"; + +describe("/api/chat", () => { + it("should return 401 without authorization", async () => { + const res = await app.request( + "/api/chat", + { + method: "POST", + headers: { + "CF-Connecting-IP": "192.0.2.1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ messages: [] }), + }, + env, + ); + + expect(res.status).toBe(401); + }); +}); diff --git a/packages/mcp-cloudflare/src/server/routes/__tests__/api-metadata.test.ts b/packages/mcp-cloudflare/src/server/routes/__tests__/api-metadata.test.ts new file mode 100644 index 00000000..e878b469 --- /dev/null +++ b/packages/mcp-cloudflare/src/server/routes/__tests__/api-metadata.test.ts @@ -0,0 +1,22 @@ +import { env } from "cloudflare:test"; +import { describe, it, expect } from "vitest"; +import app from "../../app"; + +describe("/api/metadata", () => { + it("should return 401 without authorization", async () => { + const res = await app.request( + "/api/metadata", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + }, + }, + env, + ); + + expect(res.status).toBe(401); + const json = (await res.json()) as { error: string; name: string }; + expect(json.error).toBe("Authorization required"); + expect(json.name).toBe("MISSING_AUTH_TOKEN"); + }); +}); diff --git a/packages/mcp-cloudflare/src/server/routes/__tests__/api-search.test.ts b/packages/mcp-cloudflare/src/server/routes/__tests__/api-search.test.ts new file mode 100644 index 00000000..e0567b05 --- /dev/null +++ b/packages/mcp-cloudflare/src/server/routes/__tests__/api-search.test.ts @@ -0,0 +1,62 @@ +import { env } from "cloudflare:test"; +import { describe, it, expect } from "vitest"; +import app from "../../app"; + +describe("/api/search", () => { + it("should return 400 for invalid request body", async () => { + const res = await app.request( + "/api/search", + { + method: "POST", + headers: { + "CF-Connecting-IP": "192.0.2.1", + "Content-Type": "application/json", + }, + body: JSON.stringify({}), // Missing required 'query' field + }, + env, + ); + + expect(res.status).toBe(400); + const json = (await res.json()) as { error: string; details: unknown[] }; + expect(json.error).toBe("Invalid request"); + expect(json.details).toBeDefined(); + }); + + it("should return 400 for empty query", async () => { + const res = await app.request( + "/api/search", + { + method: "POST", + headers: { + "CF-Connecting-IP": "192.0.2.1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ query: "" }), + }, + env, + ); + + expect(res.status).toBe(400); + }); + + it("should return 503 when AI binding is not available", async () => { + const res = await app.request( + "/api/search", + { + method: "POST", + headers: { + "CF-Connecting-IP": "192.0.2.1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ query: "how to install sentry" }), + }, + env, // env doesn't have AI binding in test config + ); + + expect(res.status).toBe(503); + const json = (await res.json()) as { error: string; name: string }; + expect(json.error).toBe("AI service not available"); + expect(json.name).toBe("AI_SERVICE_UNAVAILABLE"); + }); +}); diff --git a/packages/mcp-cloudflare/src/server/routes/__tests__/chat-oauth.test.ts b/packages/mcp-cloudflare/src/server/routes/__tests__/chat-oauth.test.ts new file mode 100644 index 00000000..015cfbd7 --- /dev/null +++ b/packages/mcp-cloudflare/src/server/routes/__tests__/chat-oauth.test.ts @@ -0,0 +1,61 @@ +import { env } from "cloudflare:test"; +import { describe, it, expect } from "vitest"; +import app from "../../app"; + +describe("/api/auth OAuth flow", () => { + describe("GET /api/auth/callback", () => { + it("should return 400 when state parameter is missing", async () => { + const res = await app.request( + "/api/auth/callback?code=test-code", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + }, + }, + env, + ); + + expect(res.status).toBe(400); + const html = await res.text(); + expect(html).toContain("Authentication Failed"); + expect(html).toContain("Invalid state parameter"); + }); + + it("should return 400 when state does not match stored state", async () => { + const res = await app.request( + "/api/auth/callback?code=test-code&state=wrong-state", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + Cookie: "chat_oauth_state=different-state", + }, + }, + env, + ); + + expect(res.status).toBe(400); + const html = await res.text(); + expect(html).toContain("Authentication Failed"); + expect(html).toContain("Invalid state parameter"); + }); + + it("should return 400 when code is missing but state is valid", async () => { + const state = "valid-state-12345"; + const res = await app.request( + `/api/auth/callback?state=${state}`, + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + Cookie: `chat_oauth_state=${state}`, + }, + }, + env, + ); + + expect(res.status).toBe(400); + const html = await res.text(); + expect(html).toContain("Authentication Failed"); + expect(html).toContain("No authorization code received"); + }); + }); +}); diff --git a/packages/mcp-cloudflare/src/server/routes/__tests__/mcp-discovery.test.ts b/packages/mcp-cloudflare/src/server/routes/__tests__/mcp-discovery.test.ts new file mode 100644 index 00000000..19e45d56 --- /dev/null +++ b/packages/mcp-cloudflare/src/server/routes/__tests__/mcp-discovery.test.ts @@ -0,0 +1,51 @@ +import { env } from "cloudflare:test"; +import { describe, it, expect } from "vitest"; +import app from "../../app"; + +describe("/.mcp discovery routes", () => { + it("GET /.mcp should return available endpoints", async () => { + const res = await app.request( + "/.mcp", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + }, + }, + env, + ); + + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Cache-Control")).toBe("public, max-age=300"); + + const json = await res.json(); + expect(json).toEqual({ + endpoints: ["/.mcp/tools.json"], + }); + }); + + it("GET /.mcp/tools.json should return tool definitions", async () => { + const res = await app.request( + "/.mcp/tools.json", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + }, + }, + env, + ); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/json"); + + const json = (await res.json()) as Array<{ name: string }>; + expect(Array.isArray(json)).toBe(true); + expect(json.length).toBeGreaterThan(0); + + // Verify tool structure + const toolNames = json.map((t) => t.name); + expect(toolNames).toContain("find_organizations"); + expect(toolNames).toContain("get_issue_details"); + expect(toolNames).toContain("search_events"); + }); +}); diff --git a/packages/mcp-cloudflare/src/server/app.test.ts b/packages/mcp-cloudflare/src/server/routes/__tests__/static.test.ts similarity index 65% rename from packages/mcp-cloudflare/src/server/app.test.ts rename to packages/mcp-cloudflare/src/server/routes/__tests__/static.test.ts index 2fc7f523..277cbbbb 100644 --- a/packages/mcp-cloudflare/src/server/app.test.ts +++ b/packages/mcp-cloudflare/src/server/routes/__tests__/static.test.ts @@ -1,17 +1,21 @@ +import { env } from "cloudflare:test"; import { describe, it, expect } from "vitest"; -import app from "./app"; +import app from "../../app"; -describe("app", () => { +describe("static routes", () => { describe("GET /robots.txt", () => { it("should return correct robots.txt content", async () => { - const res = await app.request("/robots.txt", { - headers: { - "CF-Connecting-IP": "192.0.2.1", + const res = await app.request( + "/robots.txt", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + }, }, - }); + env, + ); expect(res.status).toBe(200); - const text = await res.text(); expect(text).toBe( ["User-agent: *", "Allow: /$", "Disallow: /"].join("\n"), @@ -21,14 +25,17 @@ describe("app", () => { describe("GET /llms.txt", () => { it("should return correct llms.txt content", async () => { - const res = await app.request("/llms.txt", { - headers: { - "CF-Connecting-IP": "192.0.2.1", + const res = await app.request( + "/llms.txt", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + }, }, - }); + env, + ); expect(res.status).toBe(200); - const text = await res.text(); expect(text).toContain("# sentry-mcp"); expect(text).toContain("Model Context Protocol"); @@ -37,14 +44,17 @@ describe("app", () => { describe("GET /sse", () => { it("should return deprecation message with 410 status", async () => { - const res = await app.request("/sse", { - headers: { - "CF-Connecting-IP": "192.0.2.1", + const res = await app.request( + "/sse", + { + headers: { + "CF-Connecting-IP": "192.0.2.1", + }, }, - }); + env, + ); expect(res.status).toBe(410); - const json = await res.json(); expect(json).toEqual({ error: "SSE transport has been removed", diff --git a/packages/mcp-cloudflare/src/test-setup.ts b/packages/mcp-cloudflare/src/test-setup.ts index 63c39d1b..16211852 100644 --- a/packages/mcp-cloudflare/src/test-setup.ts +++ b/packages/mcp-cloudflare/src/test-setup.ts @@ -1,3 +1,17 @@ -import { startMockServer } from "@sentry/mcp-server-mocks"; +/** + * Test setup for Cloudflare Workers tests. + * + * Uses fetchMock from cloudflare:test to mock Sentry API responses. + * This runs in the workerd runtime, not Node.js. + */ +import "urlpattern-polyfill"; +import { setupFetchMock, resetFetchMock } from "./test-utils/fetch-mock-setup"; +import { afterEach, beforeEach } from "vitest"; -startMockServer({ ignoreOpenAI: true }); +beforeEach(() => { + setupFetchMock(); +}); + +afterEach(() => { + resetFetchMock(); +}); diff --git a/packages/mcp-cloudflare/src/test-utils/ajv-stub.ts b/packages/mcp-cloudflare/src/test-utils/ajv-stub.ts new file mode 100644 index 00000000..547825d9 --- /dev/null +++ b/packages/mcp-cloudflare/src/test-utils/ajv-stub.ts @@ -0,0 +1,26 @@ +/** + * Stub for ajv module to bypass CJS require() issues in workerd runtime. + * + * The MCP SDK imports ajv at module level (even when using CfWorkerJsonSchemaValidator). + * ajv uses CJS require() for JSON files which fails in workerd. + * See: https://github.com/cloudflare/workers-sdk/issues/9822 + * + * This stub provides the minimal API surface that the SDK imports, + * but is never actually used since we use CfWorkerJsonSchemaValidator. + */ + +export class Ajv { + compile() { + return () => true; + } + + getSchema() { + return undefined; + } + + errorsText() { + return ""; + } +} + +export default Ajv; diff --git a/packages/mcp-cloudflare/src/test-utils/fetch-mock-setup.ts b/packages/mcp-cloudflare/src/test-utils/fetch-mock-setup.ts new file mode 100644 index 00000000..414a0965 --- /dev/null +++ b/packages/mcp-cloudflare/src/test-utils/fetch-mock-setup.ts @@ -0,0 +1,449 @@ +/** + * fetchMock setup for Cloudflare Workers tests + * + * Uses undici MockAgent via cloudflare:test to mock Sentry API responses. + * This replaces MSW for tests that need to run in the workerd runtime. + */ +import { fetchMock } from "cloudflare:test"; +import { + organizationFixture, + releaseFixture, + clientKeyFixture, + userFixture, + eventsErrorsFixture, + eventsErrorsEmptyFixture, + eventsSpansFixture, + eventsSpansEmptyFixture, + issueFixture, + eventsFixture, + projectFixture, + teamFixture, + tagsFixture, + traceMetaFixture, + traceFixture, + performanceEventFixture, + autofixStateFixture, + traceItemsAttributesSpansStringFixture, + traceItemsAttributesSpansNumberFixture, + traceItemsAttributesLogsStringFixture, + traceItemsAttributesLogsNumberFixture, +} from "@sentry/mcp-server-mocks/payloads"; + +// Second issue fixture for tests that need multiple issues +const issueFixture2 = { + ...issueFixture, + id: 6507376926, + shortId: "CLOUDFLARE-MCP-42", + count: 1, + title: "Error: Tool list_issues is already registered", + firstSeen: "2025-04-11T22:51:19.403000Z", + lastSeen: "2025-04-12T11:34:11Z", +}; + +// Sentry hosts to mock +const SENTRY_HOSTS = ["https://sentry.io", "https://us.sentry.io"]; + +// Standard JSON response headers +const JSON_HEADERS = { "Content-Type": "application/json" }; + +/** + * Set up fetchMock for Sentry API mocking in Cloudflare Workers tests. + * + * IMPORTANT: undici MockAgent matches interceptors in registration order + * (first match wins). Always register specific path handlers BEFORE + * general pattern handlers to ensure correct matching. + * + * Call this in beforeAll() and call resetFetchMock() in afterEach(). + */ +export function setupFetchMock() { + fetchMock.activate(); + fetchMock.disableNetConnect(); + + for (const host of SENTRY_HOSTS) { + const pool = fetchMock.get(host); + + // ===== Auth Endpoints (control only - sentry.io only) ===== + if (host === "https://sentry.io") { + pool + .intercept({ path: "/api/0/auth/" }) + .reply(200, userFixture, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ path: "/api/0/users/me/regions/" }) + .reply( + 200, + { regions: [{ name: "us", url: "https://us.sentry.io" }] }, + { headers: JSON_HEADERS }, + ) + .persist(); + } + + // ===== Organizations ===== + pool + .intercept({ path: "/api/0/organizations/" }) + .reply(200, [organizationFixture], { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ path: "/api/0/organizations/sentry-mcp-evals/" }) + .reply(200, organizationFixture, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ path: "/api/0/organizations/nonexistent-org/" }) + .reply( + 404, + { detail: "The requested resource does not exist" }, + { headers: JSON_HEADERS }, + ) + .persist(); + + // ===== Teams ===== + pool + .intercept({ path: "/api/0/organizations/sentry-mcp-evals/teams/" }) + .reply(200, [teamFixture], { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/teams/", + method: "POST", + }) + .reply( + 201, + { + ...teamFixture, + id: "4509109078196224", + dateCreated: "2025-04-07T00:05:48.196710Z", + }, + { headers: JSON_HEADERS }, + ) + .persist(); + + // ===== Projects ===== + pool + .intercept({ path: "/api/0/organizations/sentry-mcp-evals/projects/" }) + .reply(200, [{ ...projectFixture, id: "4509106749636608" }], { + headers: JSON_HEADERS, + }) + .persist(); + + pool + .intercept({ + path: "/api/0/teams/sentry-mcp-evals/the-goats/projects/", + method: "POST", + }) + .reply(200, projectFixture, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/" }) + .reply(200, projectFixture, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/", + method: "PUT", + }) + .reply(200, projectFixture, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: "/api/0/projects/sentry-mcp-evals/nonexistent-project/", + }) + .reply( + 404, + { detail: "The requested resource does not exist" }, + { headers: JSON_HEADERS }, + ) + .persist(); + + // ===== Client Keys ===== + pool + .intercept({ + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + method: "POST", + }) + .reply(200, clientKeyFixture, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + }) + .reply(200, [clientKeyFixture], { headers: JSON_HEADERS }) + .persist(); + + // ===== Issues ===== + // Project-scoped + pool + .intercept({ path: "/api/0/projects/sentry-mcp-evals/foobar/issues/" }) + .reply(200, [], { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: (p: string) => + p.startsWith( + "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/issues/", + ), + }) + .reply(200, [issueFixture2, issueFixture], { headers: JSON_HEADERS }) + .persist(); + + // Org-scoped (excludes specific issue IDs that have their own handlers) + pool + .intercept({ + path: (p: string) => + p.startsWith("/api/0/organizations/sentry-mcp-evals/issues/") && + !p.includes("CLOUDFLARE-MCP") && + !p.includes("6507376") && + !p.includes("PERF-N1") && + !p.includes("PEATED"), + }) + .reply(200, [issueFixture2, issueFixture], { headers: JSON_HEADERS }) + .persist(); + + // Specific issue details + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + }) + .reply(200, issueFixture, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/issues/6507376925/", + }) + .reply(200, issueFixture, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-42/", + }) + .reply(200, issueFixture2, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/issues/6507376926/", + }) + .reply(200, issueFixture2, { headers: JSON_HEADERS }) + .persist(); + + // Issue updates (PUT) + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + method: "PUT", + }) + .reply(200, issueFixture, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/issues/6507376925/", + method: "PUT", + }) + .reply(200, issueFixture, { headers: JSON_HEADERS }) + .persist(); + + // ===== Issue Events ===== + // IMPORTANT: Specific handlers must come before general patterns + + // Performance issue events - specific handler + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/events/latest/", + }) + .reply(200, performanceEventFixture, { headers: JSON_HEADERS }) + .persist(); + + // General events - catch-all for remaining + pool + .intercept({ + path: (p: string) => + p.includes("/events/7ca573c0f4814912aaa9bdc77d1a7d51") || + p.includes("/events/latest"), + }) + .reply(200, eventsFixture, { headers: JSON_HEADERS }) + .persist(); + + // ===== Traces ===== + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/trace-meta/a4d1aae7216b47ff8117cf4e09ce9d0a/", + }) + .reply(200, traceMetaFixture, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/trace/a4d1aae7216b47ff8117cf4e09ce9d0a/", + }) + .reply(200, traceFixture, { headers: JSON_HEADERS }) + .persist(); + + // ===== Releases ===== + pool + .intercept({ path: "/api/0/organizations/sentry-mcp-evals/releases/" }) + .reply(200, [releaseFixture], { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/releases/", + }) + .reply(200, [releaseFixture], { headers: JSON_HEADERS }) + .persist(); + + // ===== Tags ===== + pool + .intercept({ path: "/api/0/organizations/sentry-mcp-evals/tags/" }) + .reply(200, tagsFixture, { headers: JSON_HEADERS }) + .persist(); + + // ===== Trace Items Attributes ===== + pool + .intercept({ + path: (p: string) => + p.startsWith( + "/api/0/organizations/sentry-mcp-evals/trace-items/attributes/", + ), + }) + .reply((req) => { + const url = new URL(req.path, "https://sentry.io"); + const itemType = url.searchParams.get("itemType"); + const attributeType = url.searchParams.get("attributeType"); + + if (!itemType || !attributeType) { + return { + statusCode: 400, + data: { detail: "Missing parameters" }, + responseOptions: { headers: JSON_HEADERS }, + }; + } + + const normalizedItemType = itemType === "spans" ? "span" : itemType; + + if (normalizedItemType === "span") { + return { + statusCode: 200, + data: + attributeType === "string" + ? traceItemsAttributesSpansStringFixture + : traceItemsAttributesSpansNumberFixture, + responseOptions: { headers: JSON_HEADERS }, + }; + } + + if (normalizedItemType === "logs") { + return { + statusCode: 200, + data: + attributeType === "string" + ? traceItemsAttributesLogsStringFixture + : traceItemsAttributesLogsNumberFixture, + responseOptions: { headers: JSON_HEADERS }, + }; + } + + return { + statusCode: 400, + data: { detail: "Invalid itemType" }, + responseOptions: { headers: JSON_HEADERS }, + }; + }) + .persist(); + + // ===== Events Search (for search_events) ===== + pool + .intercept({ + path: (p: string) => + p.startsWith("/api/0/organizations/sentry-mcp-evals/events/"), + }) + .reply((req) => { + const url = new URL(req.path, "https://sentry.io"); + const dataset = url.searchParams.get("dataset"); + const query = url.searchParams.get("query") || ""; + + if (dataset === "spans") { + if (query !== "is_transaction:true") { + return { + statusCode: 200, + data: eventsSpansEmptyFixture, + responseOptions: { headers: JSON_HEADERS }, + }; + } + return { + statusCode: 200, + data: eventsSpansFixture, + responseOptions: { headers: JSON_HEADERS }, + }; + } + + if (dataset === "errors") { + // Return empty for queries that don't match expected patterns + const validQueries = [ + "", + "error.handled:false", + "error.unhandled:true", + "error.handled:false is:unresolved", + "error.unhandled:true is:unresolved", + "is:unresolved project:cloudflare-mcp", + "project:cloudflare-mcp", + "user.email:david@sentry.io", + ]; + const sortedQuery = query.split(" ").sort().join(" "); + if (!validQueries.includes(sortedQuery)) { + return { + statusCode: 200, + data: eventsErrorsEmptyFixture, + responseOptions: { headers: JSON_HEADERS }, + }; + } + return { + statusCode: 200, + data: eventsErrorsFixture, + responseOptions: { headers: JSON_HEADERS }, + }; + } + + return { + statusCode: 400, + data: "Invalid dataset", + responseOptions: { headers: JSON_HEADERS }, + }; + }) + .persist(); + + // ===== Autofix ===== + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/autofix/", + }) + .reply(200, { autofix: null }, { headers: JSON_HEADERS }) + .persist(); + + pool + .intercept({ + path: "/api/0/organizations/sentry-mcp-evals/issues/PEATED-A8/autofix/", + }) + .reply(200, autofixStateFixture, { headers: JSON_HEADERS }) + .persist(); + } +} + +/** + * Reset fetchMock state between tests + */ +export function resetFetchMock() { + // assertNoPendingInterceptors can be called but may throw if there are unused mocks + // We reset silently for flexibility in tests that don't use all endpoints + fetchMock.deactivate(); +} diff --git a/packages/mcp-cloudflare/tsconfig.server.json b/packages/mcp-cloudflare/tsconfig.server.json index 99d64ad7..1e0cc672 100644 --- a/packages/mcp-cloudflare/tsconfig.server.json +++ b/packages/mcp-cloudflare/tsconfig.server.json @@ -3,10 +3,16 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", "types": [ - "@cloudflare/workers-types" + "@cloudflare/workers-types", + "@types/node" ] }, "include": [ "src/server" + ], + "exclude": [ + "src/**/*.test.ts", + "src/test-setup.ts", + "src/test-utils" ] } diff --git a/packages/mcp-cloudflare/tsconfig.test.json b/packages/mcp-cloudflare/tsconfig.test.json new file mode 100644 index 00000000..3223dbbe --- /dev/null +++ b/packages/mcp-cloudflare/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.server.json", + "compilerOptions": { + "types": [ + "@cloudflare/workers-types", + "@cloudflare/vitest-pool-workers" + ] + }, + "include": [ + "src/**/*.test.ts", + "src/test-setup.ts", + "src/test-utils/**/*.ts" + ] +} diff --git a/packages/mcp-cloudflare/vitest.config.ts b/packages/mcp-cloudflare/vitest.config.ts index 7fa13f4d..48cbd6b6 100644 --- a/packages/mcp-cloudflare/vitest.config.ts +++ b/packages/mcp-cloudflare/vitest.config.ts @@ -1,25 +1,44 @@ -/// -import { defineConfig } from "vitest/config"; +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; +import path from "node:path"; -export default defineConfig({ +/** + * Unified vitest config using vitest-pool-workers. + * + * All tests run in the Cloudflare Workers runtime (workerd) which enables: + * - Testing with cloudflare:test bindings (KV, AI, etc.) + * - Using fetchMock from cloudflare:test for HTTP mocking + * + * Bindings (KV, vars, compatibility flags) are defined in wrangler.test.jsonc + * to keep test config aligned with production wrangler.jsonc. + */ +export default defineWorkersConfig({ test: { - // Use thread-based workers to avoid process-kill issues in sandboxed environments - pool: "threads", + include: ["src/**/*.test.ts"], + setupFiles: ["src/test-setup.ts"], poolOptions: { workers: { - miniflare: {}, - wrangler: { configPath: "./wrangler.jsonc" }, + wrangler: { configPath: "./wrangler.test.jsonc" }, }, }, - deps: { - interopDefault: true, - }, - include: ["**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], - coverage: { - provider: "v8", - reporter: ["text", "json", "html"], - include: ["**/*.ts"], + }, + /** + * Workaround for ajv CJS compatibility in workerd runtime. + * + * The MCP SDK imports ajv at module level (even when using CfWorkerJsonSchemaValidator). + * ajv uses CJS require() for JSON files which fails in workerd. + * See: https://github.com/cloudflare/workers-sdk/issues/9822 + * + * This is TEST-ONLY - production uses CfWorkerJsonSchemaValidator which + * doesn't actually invoke ajv, but the import still triggers the CJS issue. + */ + resolve: { + alias: { + ajv: path.resolve(__dirname, "src/test-utils/ajv-stub.ts"), + "ajv-formats": path.resolve(__dirname, "src/test-utils/ajv-stub.ts"), }, - setupFiles: ["dotenv/config", "src/test-setup.ts"], + }, + // Force bundling to apply the ajv alias during module resolution + ssr: { + noExternal: ["@modelcontextprotocol/sdk", "agents", "zod-to-json-schema"], }, }); diff --git a/packages/mcp-cloudflare/wrangler.test.jsonc b/packages/mcp-cloudflare/wrangler.test.jsonc new file mode 100644 index 00000000..5caf3310 --- /dev/null +++ b/packages/mcp-cloudflare/wrangler.test.jsonc @@ -0,0 +1,29 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "sentry-mcp-test", + // Note: We don't specify "main" - vitest-pool-workers handles entry points + "compatibility_date": "2025-03-21", + // Keep flags in sync with wrangler.jsonc for production parity + "compatibility_flags": [ + "nodejs_compat", + "nodejs_compat_populate_process_env", + "global_fetch_strictly_public" + ], + "vars": { + "COOKIE_SECRET": "test-cookie-secret-32-characters", + "SENTRY_CLIENT_ID": "test-client-id", + "SENTRY_CLIENT_SECRET": "test-client-secret", + "SENTRY_HOST": "sentry.io", + "OPENAI_API_KEY": "test-openai-key" + }, + "kv_namespaces": [ + { + "binding": "OAUTH_KV", + "id": "test-oauth-kv" + } + ], + // Disable observability to avoid OTel issues in tests + "observability": { + "enabled": false + } +} diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index f77d1df2..f6e2b0e9 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -111,7 +111,7 @@ "build": "tsdown", "pretest": "pnpm run generate-definitions", "test": "vitest run", - "test:ci": "pnpm run generate-definitions && vitest run --coverage --reporter=default --reporter=junit --outputFile=tests.junit.xml", + "test:ci": "pnpm run generate-definitions && vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml", "test:watch": "pnpm run generate-definitions && vitest", "tsc": "tsc --noEmit", "generate-definitions": "tsx scripts/generate-definitions.ts", diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index a881cb62..cdf15676 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -19,6 +19,7 @@ * ``` */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ServerOptions } from "@modelcontextprotocol/sdk/server/index.js"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { ServerRequest, @@ -70,10 +71,15 @@ import { * ```typescript * import { buildServer } from "@sentry/mcp-core/server"; * import { createMcpHandler } from "agents/mcp"; + * import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; * * const serverContext = buildContextFromOAuth(); * // Context is captured in closures during buildServer() - * const server = buildServer({ context: serverContext }); + * // Use CfWorkerJsonSchemaValidator for Cloudflare Workers (ajv is not compatible) + * const server = buildServer({ + * context: serverContext, + * jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), + * }); * * // Context already available to tool handlers via closures * return createMcpHandler(server, { route: "/mcp" })(request, env, ctx); @@ -83,15 +89,31 @@ export function buildServer({ context, agentMode = false, tools: customTools, + jsonSchemaValidator, }: { context: ServerContext; agentMode?: boolean; tools?: Record>; + /** + * JSON Schema validator for MCP protocol validation. + * + * By default, uses AjvJsonSchemaValidator which requires Node.js. + * For Cloudflare Workers or other edge runtimes, use CfWorkerJsonSchemaValidator: + * + * ```typescript + * import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; + * buildServer({ context, jsonSchemaValidator: new CfWorkerJsonSchemaValidator() }); + * ``` + */ + jsonSchemaValidator?: ServerOptions["jsonSchemaValidator"]; }): McpServer { - const server = new McpServer({ - name: MCP_SERVER_NAME, - version: LIB_VERSION, - }); + const server = new McpServer( + { + name: MCP_SERVER_NAME, + version: LIB_VERSION, + }, + { jsonSchemaValidator }, + ); configureServer({ server, diff --git a/packages/mcp-server-evals/package.json b/packages/mcp-server-evals/package.json index 9cf84945..887a1650 100644 --- a/packages/mcp-server-evals/package.json +++ b/packages/mcp-server-evals/package.json @@ -12,7 +12,7 @@ "dev": "tsc -w", "start": "tsx src/bin/start-mock-stdio.ts", "eval": "vitest --config=vitest.config.ts", - "eval:ci": "vitest run --coverage --reporter=vitest-evals/reporter --reporter=junit --reporter=json --outputFile.json=eval-results.json --outputFile.junit=eval.junit.xml" + "eval:ci": "vitest run --reporter=vitest-evals/reporter --reporter=junit --reporter=json --outputFile.json=eval-results.json --outputFile.junit=eval.junit.xml" }, "dependencies": { "@ai-sdk/openai": "catalog:", diff --git a/packages/mcp-server-mocks/package.json b/packages/mcp-server-mocks/package.json index 922cd0a5..cb8c946c 100644 --- a/packages/mcp-server-mocks/package.json +++ b/packages/mcp-server-mocks/package.json @@ -12,6 +12,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./payloads": { + "types": "./dist/payloads.d.ts", + "default": "./dist/payloads.js" + }, "./utils": { "types": "./dist/utils.d.ts", "default": "./dist/utils.js" diff --git a/packages/mcp-server-mocks/src/fixtures/client-key.json b/packages/mcp-server-mocks/src/fixtures/client-key.json new file mode 100644 index 00000000..58411ffe --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/client-key.json @@ -0,0 +1,35 @@ +{ + "id": "d20df0a1ab5031c7f3c7edca9c02814d", + "name": "Default", + "label": "Default", + "public": "d20df0a1ab5031c7f3c7edca9c02814d", + "secret": "154001fd3dfe38130e1c7948a323fad8", + "projectId": 4509109104082945, + "isActive": true, + "rateLimit": null, + "dsn": { + "secret": "https://d20df0a1ab5031c7f3c7edca9c02814d:154001fd3dfe38130e1c7948a323fad8@o4509106732793856.ingest.us.sentry.io/4509109104082945", + "public": "https://d20df0a1ab5031c7f3c7edca9c02814d@o4509106732793856.ingest.us.sentry.io/4509109104082945", + "csp": "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/csp-report/?sentry_key=d20df0a1ab5031c7f3c7edca9c02814d", + "security": "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/security/?sentry_key=d20df0a1ab5031c7f3c7edca9c02814d", + "minidump": "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/minidump/?sentry_key=d20df0a1ab5031c7f3c7edca9c02814d", + "nel": "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/nel/?sentry_key=d20df0a1ab5031c7f3c7edca9c02814d", + "unreal": "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/unreal/d20df0a1ab5031c7f3c7edca9c02814d/", + "crons": "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/cron/___MONITOR_SLUG___/d20df0a1ab5031c7f3c7edca9c02814d/", + "cdn": "https://js.sentry-cdn.com/d20df0a1ab5031c7f3c7edca9c02814d.min.js" + }, + "browserSdkVersion": "8.x", + "browserSdk": { + "choices": [ + ["9.x", "9.x"], + ["8.x", "8.x"], + ["7.x", "7.x"] + ] + }, + "dateCreated": "2025-04-07T00:12:25.139394Z", + "dynamicSdkLoaderOptions": { + "hasReplay": true, + "hasPerformance": true, + "hasDebug": false + } +} diff --git a/packages/mcp-server-mocks/src/fixtures/events-errors-empty.json b/packages/mcp-server-mocks/src/fixtures/events-errors-empty.json new file mode 100644 index 00000000..05155ed8 --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/events-errors-empty.json @@ -0,0 +1,24 @@ +{ + "data": [], + "meta": { + "fields": { + "issue.id": "integer", + "title": "string", + "project": "string", + "count()": "integer", + "last_seen()": "date" + }, + "units": { + "issue.id": null, + "title": null, + "project": null, + "count()": null, + "last_seen()": null + }, + "isMetricsData": false, + "isMetricsExtractedData": false, + "tips": { "query": null, "columns": null }, + "datasetReason": "unchanged", + "dataset": "errors" + } +} diff --git a/packages/mcp-server-mocks/src/fixtures/events-errors.json b/packages/mcp-server-mocks/src/fixtures/events-errors.json new file mode 100644 index 00000000..b7f2ae05 --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/events-errors.json @@ -0,0 +1,33 @@ +{ + "data": [ + { + "issue.id": 6114575469, + "title": "Error: Tool list_organizations is already registered", + "project": "test-suite", + "count()": 2, + "last_seen()": "2025-04-07T12:23:39+00:00", + "issue": "CLOUDFLARE-MCP-41" + } + ], + "meta": { + "fields": { + "issue.id": "integer", + "title": "string", + "project": "string", + "count()": "integer", + "last_seen()": "date" + }, + "units": { + "issue.id": null, + "title": null, + "project": null, + "count()": null, + "last_seen()": null + }, + "isMetricsData": false, + "isMetricsExtractedData": false, + "tips": { "query": null, "columns": null }, + "datasetReason": "unchanged", + "dataset": "errors" + } +} diff --git a/packages/mcp-server-mocks/src/fixtures/events-spans-empty.json b/packages/mcp-server-mocks/src/fixtures/events-spans-empty.json new file mode 100644 index 00000000..11ea1d4b --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/events-spans-empty.json @@ -0,0 +1,40 @@ +{ + "data": [], + "meta": { + "fields": { + "id": "string", + "span.op": "string", + "span.description": "string", + "span.duration": "duration", + "transaction": "string", + "timestamp": "string", + "is_transaction": "boolean", + "project": "string", + "trace": "string", + "transaction.span_id": "string", + "project.name": "string" + }, + "units": { + "id": null, + "span.op": null, + "span.description": null, + "span.duration": "millisecond", + "transaction": null, + "timestamp": null, + "is_transaction": null, + "project": null, + "trace": null, + "transaction.span_id": null, + "project.name": null + }, + "isMetricsData": false, + "isMetricsExtractedData": false, + "tips": {}, + "datasetReason": "unchanged", + "dataset": "spans", + "dataScanned": "full", + "accuracy": { + "confidence": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] + } + } +} diff --git a/packages/mcp-server-mocks/src/fixtures/events-spans.json b/packages/mcp-server-mocks/src/fixtures/events-spans.json new file mode 100644 index 00000000..7ccff24a --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/events-spans.json @@ -0,0 +1,68 @@ +{ + "data": [ + { + "id": "07752c6aeb027c8f", + "span.op": "http.server", + "span.description": "GET /trpc/bottleList", + "span.duration": 12.0, + "transaction": "GET /trpc/bottleList", + "timestamp": "2025-04-13T14:19:18+00:00", + "is_transaction": true, + "project": "peated", + "trace": "6a477f5b0f31ef7b6b9b5e1dea66c91d", + "transaction.span_id": "07752c6aeb027c8f", + "project.name": "peated" + }, + { + "id": "7ab5edf5b3ba42c9", + "span.op": "http.server", + "span.description": "GET /trpc/bottleList", + "span.duration": 18.0, + "transaction": "GET /trpc/bottleList", + "timestamp": "2025-04-13T14:19:17+00:00", + "is_transaction": true, + "project": "peated", + "trace": "54177131c7b192a446124daba3136045", + "transaction.span_id": "7ab5edf5b3ba42c9", + "project.name": "peated" + } + ], + "meta": { + "fields": { + "id": "string", + "span.op": "string", + "span.description": "string", + "span.duration": "duration", + "transaction": "string", + "timestamp": "string", + "is_transaction": "boolean", + "project": "string", + "trace": "string", + "transaction.span_id": "string", + "project.name": "string" + }, + "units": { + "id": null, + "span.op": null, + "span.description": null, + "span.duration": "millisecond", + "transaction": null, + "timestamp": null, + "is_transaction": null, + "project": null, + "trace": null, + "transaction.span_id": null, + "project.name": null + }, + "isMetricsData": false, + "isMetricsExtractedData": false, + "tips": {}, + "datasetReason": "unchanged", + "dataset": "spans", + "dataScanned": "full", + "accuracy": { + "confidence": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] + } + }, + "confidence": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] +} diff --git a/packages/mcp-server-mocks/src/fixtures/organization.json b/packages/mcp-server-mocks/src/fixtures/organization.json new file mode 100644 index 00000000..daaf990c --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/organization.json @@ -0,0 +1,9 @@ +{ + "id": "4509106740723712", + "slug": "sentry-mcp-evals", + "name": "sentry-mcp-evals", + "links": { + "regionUrl": "https://us.sentry.io", + "organizationUrl": "https://sentry.io/sentry-mcp-evals" + } +} diff --git a/packages/mcp-server-mocks/src/fixtures/release.json b/packages/mcp-server-mocks/src/fixtures/release.json new file mode 100644 index 00000000..78ac7d8a --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/release.json @@ -0,0 +1,39 @@ +{ + "id": 1402755016, + "version": "8ce89484-0fec-4913-a2cd-e8e2d41dee36", + "status": "open", + "shortVersion": "8ce89484-0fec-4913-a2cd-e8e2d41dee36", + "versionInfo": { + "package": null, + "version": { "raw": "8ce89484-0fec-4913-a2cd-e8e2d41dee36" }, + "description": "8ce89484-0fec-4913-a2cd-e8e2d41dee36", + "buildHash": null + }, + "ref": null, + "url": null, + "dateReleased": null, + "dateCreated": "2025-04-13T19:54:21.764000Z", + "data": {}, + "newGroups": 0, + "owner": null, + "commitCount": 0, + "lastCommit": null, + "deployCount": 0, + "lastDeploy": null, + "authors": [], + "projects": [ + { + "id": 4509062593708032, + "slug": "cloudflare-mcp", + "name": "cloudflare-mcp", + "newGroups": 0, + "platform": "bun", + "platforms": ["javascript"], + "hasHealthData": false + } + ], + "firstEvent": "2025-04-13T19:54:21Z", + "lastEvent": "2025-04-13T20:28:23Z", + "currentProjectMeta": {}, + "userAgent": null +} diff --git a/packages/mcp-server-mocks/src/fixtures/user.json b/packages/mcp-server-mocks/src/fixtures/user.json new file mode 100644 index 00000000..0b94e905 --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/user.json @@ -0,0 +1,22 @@ +{ + "id": "123456", + "name": "Test User", + "email": "test@example.com", + "username": "testuser", + "avatarUrl": "https://example.com/avatar.jpg", + "dateJoined": "2024-01-01T00:00:00Z", + "isActive": true, + "isManaged": false, + "isStaff": false, + "isSuperuser": false, + "lastLogin": "2024-12-01T00:00:00Z", + "has2fa": false, + "hasPasswordAuth": true, + "emails": [ + { + "id": "1", + "email": "test@example.com", + "is_verified": true + } + ] +} diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index ee468b8f..be8db0fd 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -64,105 +64,23 @@ import traceMixedFixture from "./fixtures/trace-mixed.json" with { import traceEventFixture from "./fixtures/trace-event.json" with { type: "json", }; - -/** - * Standard organization payload for mock responses. - * Used across multiple endpoints for consistency. - */ -const OrganizationPayload = { - id: "4509106740723712", - slug: "sentry-mcp-evals", - name: "sentry-mcp-evals", - links: { - regionUrl: "https://us.sentry.io", - organizationUrl: "https://sentry.io/sentry-mcp-evals", - }, +import organizationFixture from "./fixtures/organization.json" with { + type: "json", }; - -/** - * Standard release payload for mock responses. - * Includes typical metadata and project associations. - */ -const ReleasePayload = { - id: 1402755016, - version: "8ce89484-0fec-4913-a2cd-e8e2d41dee36", - status: "open", - shortVersion: "8ce89484-0fec-4913-a2cd-e8e2d41dee36", - versionInfo: { - package: null, - version: { raw: "8ce89484-0fec-4913-a2cd-e8e2d41dee36" }, - description: "8ce89484-0fec-4913-a2cd-e8e2d41dee36", - buildHash: null, - }, - ref: null, - url: null, - dateReleased: null, - dateCreated: "2025-04-13T19:54:21.764000Z", - data: {}, - newGroups: 0, - owner: null, - commitCount: 0, - lastCommit: null, - deployCount: 0, - lastDeploy: null, - authors: [], - projects: [ - { - id: 4509062593708032, - slug: "cloudflare-mcp", - name: "cloudflare-mcp", - newGroups: 0, - platform: "bun", - platforms: ["javascript"], - hasHealthData: false, - }, - ], - firstEvent: "2025-04-13T19:54:21Z", - lastEvent: "2025-04-13T20:28:23Z", - currentProjectMeta: {}, - userAgent: null, +import releaseFixture from "./fixtures/release.json" with { type: "json" }; +import clientKeyFixture from "./fixtures/client-key.json" with { type: "json" }; +import userFixture from "./fixtures/user.json" with { type: "json" }; +import eventsErrorsFixture from "./fixtures/events-errors.json" with { + type: "json", }; - -const ClientKeyPayload = { - id: "d20df0a1ab5031c7f3c7edca9c02814d", - name: "Default", - label: "Default", - public: "d20df0a1ab5031c7f3c7edca9c02814d", - secret: "154001fd3dfe38130e1c7948a323fad8", - projectId: 4509109104082945, - isActive: true, - rateLimit: null, - dsn: { - secret: - "https://d20df0a1ab5031c7f3c7edca9c02814d:154001fd3dfe38130e1c7948a323fad8@o4509106732793856.ingest.us.sentry.io/4509109104082945", - public: - "https://d20df0a1ab5031c7f3c7edca9c02814d@o4509106732793856.ingest.us.sentry.io/4509109104082945", - csp: "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/csp-report/?sentry_key=d20df0a1ab5031c7f3c7edca9c02814d", - security: - "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/security/?sentry_key=d20df0a1ab5031c7f3c7edca9c02814d", - minidump: - "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/minidump/?sentry_key=d20df0a1ab5031c7f3c7edca9c02814d", - nel: "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/nel/?sentry_key=d20df0a1ab5031c7f3c7edca9c02814d", - unreal: - "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/unreal/d20df0a1ab5031c7f3c7edca9c02814d/", - crons: - "https://o4509106732793856.ingest.us.sentry.io/api/4509109104082945/cron/___MONITOR_SLUG___/d20df0a1ab5031c7f3c7edca9c02814d/", - cdn: "https://js.sentry-cdn.com/d20df0a1ab5031c7f3c7edca9c02814d.min.js", - }, - browserSdkVersion: "8.x", - browserSdk: { - choices: [ - ["9.x", "9.x"], - ["8.x", "8.x"], - ["7.x", "7.x"], - ], - }, - dateCreated: "2025-04-07T00:12:25.139394Z", - dynamicSdkLoaderOptions: { - hasReplay: true, - hasPerformance: true, - hasDebug: false, - }, +import eventsErrorsEmptyFixture from "./fixtures/events-errors-empty.json" with { + type: "json", +}; +import eventsSpansFixture from "./fixtures/events-spans.json" with { + type: "json", +}; +import eventsSpansEmptyFixture from "./fixtures/events-spans-empty.json" with { + type: "json", }; // a newer issue, seen less recently @@ -176,177 +94,6 @@ const issueFixture2 = { lastSeen: "2025-04-12T11:34:11Z", }; -const EventsErrorsMeta = { - fields: { - "issue.id": "integer", - title: "string", - project: "string", - "count()": "integer", - "last_seen()": "date", - }, - units: { - "issue.id": null, - title: null, - project: null, - "count()": null, - "last_seen()": null, - }, - isMetricsData: false, - isMetricsExtractedData: false, - tips: { query: null, columns: null }, - datasetReason: "unchanged", - dataset: "errors", -}; - -const EmptyEventsErrorsPayload = { - data: [], - meta: EventsErrorsMeta, -}; - -const EventsErrorsPayload = { - data: [ - { - "issue.id": 6114575469, - title: "Error: Tool list_organizations is already registered", - project: "test-suite", - "count()": 2, - "last_seen()": "2025-04-07T12:23:39+00:00", - issue: "CLOUDFLARE-MCP-41", - }, - ], - meta: EventsErrorsMeta, -}; - -const EventsSpansMeta = { - fields: { - id: "string", - "span.op": "string", - "span.description": "string", - "span.duration": "duration", - transaction: "string", - timestamp: "string", - is_transaction: "boolean", - project: "string", - trace: "string", - "transaction.span_id": "string", - "project.name": "string", - }, - units: { - id: null, - "span.op": null, - "span.description": null, - "span.duration": "millisecond", - transaction: null, - timestamp: null, - is_transaction: null, - project: null, - trace: null, - "transaction.span_id": null, - "project.name": null, - }, - isMetricsData: false, - isMetricsExtractedData: false, - tips: {}, - datasetReason: "unchanged", - dataset: "spans", - dataScanned: "full", - accuracy: { - confidence: [ - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - ], - }, -}; - -const EmptyEventsSpansPayload = { - data: [], - meta: EventsSpansMeta, -}; - -const EventsSpansPayload = { - data: [ - { - id: "07752c6aeb027c8f", - "span.op": "http.server", - "span.description": "GET /trpc/bottleList", - "span.duration": 12.0, - transaction: "GET /trpc/bottleList", - timestamp: "2025-04-13T14:19:18+00:00", - is_transaction: true, - project: "peated", - trace: "6a477f5b0f31ef7b6b9b5e1dea66c91d", - "transaction.span_id": "07752c6aeb027c8f", - "project.name": "peated", - }, - { - id: "7ab5edf5b3ba42c9", - "span.op": "http.server", - "span.description": "GET /trpc/bottleList", - "span.duration": 18.0, - transaction: "GET /trpc/bottleList", - timestamp: "2025-04-13T14:19:17+00:00", - is_transaction: true, - project: "peated", - trace: "54177131c7b192a446124daba3136045", - "transaction.span_id": "7ab5edf5b3ba42c9", - "project.name": "peated", - }, - ], - meta: EventsSpansMeta, - confidence: [ - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - ], -}; - /** * Builds MSW handlers for both SaaS and self-hosted Sentry instances. * @@ -418,28 +165,7 @@ export const restHandlers = buildHandlers([ path: "/api/0/auth/", controlOnly: true, fetch: () => { - return HttpResponse.json({ - id: "123456", - name: "Test User", - email: "test@example.com", - username: "testuser", - avatarUrl: "https://example.com/avatar.jpg", - dateJoined: "2024-01-01T00:00:00Z", - isActive: true, - isManaged: false, - isStaff: false, - isSuperuser: false, - lastLogin: "2024-12-01T00:00:00Z", - has2fa: false, - hasPasswordAuth: true, - emails: [ - { - id: "1", - email: "test@example.com", - is_verified: true, - }, - ], - }); + return HttpResponse.json(userFixture); }, }, { @@ -457,14 +183,14 @@ export const restHandlers = buildHandlers([ method: "get", path: "/api/0/organizations/", fetch: () => { - return HttpResponse.json([OrganizationPayload]); + return HttpResponse.json([organizationFixture]); }, }, { method: "get", path: "/api/0/organizations/sentry-mcp-evals/", fetch: () => { - return HttpResponse.json(OrganizationPayload); + return HttpResponse.json(organizationFixture); }, }, // 404 handlers for test scenarios @@ -578,14 +304,14 @@ export const restHandlers = buildHandlers([ path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", fetch: () => { // TODO: validate payload (only accept 'Default' for key name) - return HttpResponse.json(ClientKeyPayload); + return HttpResponse.json(clientKeyFixture); }, }, { method: "get", path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", fetch: () => { - return HttpResponse.json([ClientKeyPayload]); + return HttpResponse.json([clientKeyFixture]); }, }, { @@ -600,7 +326,7 @@ export const restHandlers = buildHandlers([ if (dataset === "spans") { //[sentryApi] GET https://sentry.io/api/0/organizations/sentry-mcp-evals/events/?dataset=spans&per_page=10&referrer=sentry-mcp&sort=-span.duration&allowAggregateConditions=0&useRpc=1&field=id&field=trace&field=span.op&field=span.description&field=span.duration&field=transaction&field=project&field=timestamp&query=is_transaction%3Atrue if (query !== "is_transaction:true") { - return HttpResponse.json(EmptyEventsSpansPayload); + return HttpResponse.json(eventsSpansEmptyFixture); } if (url.searchParams.get("useRpc") !== "1") { @@ -616,7 +342,7 @@ export const restHandlers = buildHandlers([ ) { return HttpResponse.json("Invalid fields", { status: 400 }); } - return HttpResponse.json(EventsSpansPayload); + return HttpResponse.json(eventsSpansFixture); } if (dataset === "errors") { //https://sentry.io/api/0/organizations/sentry-mcp-evals/events/?dataset=errors&per_page=10&referrer=sentry-mcp&sort=-count&statsPeriod=1w&field=issue&field=title&field=project&field=last_seen%28%29&field=count%28%29&query= @@ -654,10 +380,10 @@ export const restHandlers = buildHandlers([ "user.email:david@sentry.io", ].includes(sortedQuery) ) { - return HttpResponse.json(EmptyEventsErrorsPayload); + return HttpResponse.json(eventsErrorsEmptyFixture); } - return HttpResponse.json(EventsErrorsPayload); + return HttpResponse.json(eventsErrorsFixture); } return HttpResponse.json("Invalid dataset", { status: 400 }); @@ -866,12 +592,12 @@ export const restHandlers = buildHandlers([ { method: "get", path: "/api/0/organizations/sentry-mcp-evals/releases/", - fetch: () => HttpResponse.json([ReleasePayload]), + fetch: () => HttpResponse.json([releaseFixture]), }, { method: "get", path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/releases/", - fetch: () => HttpResponse.json([ReleasePayload]), + fetch: () => HttpResponse.json([releaseFixture]), }, { method: "get", @@ -1405,6 +1131,24 @@ export { traceFixture, traceMixedFixture, traceEventFixture, + organizationFixture, + releaseFixture, + clientKeyFixture, + userFixture, + eventsErrorsFixture, + eventsErrorsEmptyFixture, + eventsSpansFixture, + eventsSpansEmptyFixture, + issueFixture, + eventsFixture, + projectFixture, + teamFixture, + tagsFixture, + traceItemsAttributesSpansStringFixture, + traceItemsAttributesSpansNumberFixture, + traceItemsAttributesLogsStringFixture, + traceItemsAttributesLogsNumberFixture, + eventAttachmentsFixture, }; // Export fixture factories diff --git a/packages/mcp-server-mocks/src/payloads.ts b/packages/mcp-server-mocks/src/payloads.ts new file mode 100644 index 00000000..5d1f3cc4 --- /dev/null +++ b/packages/mcp-server-mocks/src/payloads.ts @@ -0,0 +1,109 @@ +/** + * Pure fixture exports without MSW dependencies. + * + * Use this module in environments where MSW is not available (e.g., Cloudflare Workers). + * For MSW mocking, import from the main package entry point instead. + */ + +// Import JSON fixtures +import autofixStateFixture from "./fixtures/autofix-state.json" with { + type: "json", +}; +import issueFixture from "./fixtures/issue.json" with { type: "json" }; +import eventsFixture from "./fixtures/event.json" with { type: "json" }; +import performanceEventFixture from "./fixtures/performance-event.json" with { + type: "json", +}; +import eventAttachmentsFixture from "./fixtures/event-attachments.json" with { + type: "json", +}; +import tagsFixture from "./fixtures/tags.json" with { type: "json" }; +import projectFixture from "./fixtures/project.json" with { type: "json" }; +import teamFixture from "./fixtures/team.json" with { type: "json" }; +import traceItemsAttributesFixture from "./fixtures/trace-items-attributes.json" with { + type: "json", +}; +import traceItemsAttributesSpansStringFixture from "./fixtures/trace-items-attributes-spans-string.json" with { + type: "json", +}; +import traceItemsAttributesSpansNumberFixture from "./fixtures/trace-items-attributes-spans-number.json" with { + type: "json", +}; +import traceItemsAttributesLogsStringFixture from "./fixtures/trace-items-attributes-logs-string.json" with { + type: "json", +}; +import traceItemsAttributesLogsNumberFixture from "./fixtures/trace-items-attributes-logs-number.json" with { + type: "json", +}; +import traceMetaFixture from "./fixtures/trace-meta.json" with { type: "json" }; +import traceMetaWithNullsFixture from "./fixtures/trace-meta-with-nulls.json" with { + type: "json", +}; +import traceFixture from "./fixtures/trace.json" with { type: "json" }; +import traceMixedFixture from "./fixtures/trace-mixed.json" with { + type: "json", +}; +import traceEventFixture from "./fixtures/trace-event.json" with { + type: "json", +}; +import organizationFixture from "./fixtures/organization.json" with { + type: "json", +}; +import releaseFixture from "./fixtures/release.json" with { type: "json" }; +import clientKeyFixture from "./fixtures/client-key.json" with { type: "json" }; +import userFixture from "./fixtures/user.json" with { type: "json" }; +import eventsErrorsFixture from "./fixtures/events-errors.json" with { + type: "json", +}; +import eventsErrorsEmptyFixture from "./fixtures/events-errors-empty.json" with { + type: "json", +}; +import eventsSpansFixture from "./fixtures/events-spans.json" with { + type: "json", +}; +import eventsSpansEmptyFixture from "./fixtures/events-spans-empty.json" with { + type: "json", +}; + +// Export all fixtures +export { + autofixStateFixture, + issueFixture, + eventsFixture, + performanceEventFixture, + eventAttachmentsFixture, + tagsFixture, + projectFixture, + teamFixture, + traceItemsAttributesFixture, + traceItemsAttributesSpansStringFixture, + traceItemsAttributesSpansNumberFixture, + traceItemsAttributesLogsStringFixture, + traceItemsAttributesLogsNumberFixture, + traceMetaFixture, + traceMetaWithNullsFixture, + traceFixture, + traceMixedFixture, + traceEventFixture, + organizationFixture, + releaseFixture, + clientKeyFixture, + userFixture, + eventsErrorsFixture, + eventsErrorsEmptyFixture, + eventsSpansFixture, + eventsSpansEmptyFixture, +}; + +// Re-export fixture factories +export { + createDefaultEvent, + createGenericEvent, + createUnknownEvent, + createPerformanceEvent, + createPerformanceIssue, + createRegressedIssue, + createUnsupportedIssue, + createCspIssue, + createCspEvent, +} from "./fixtures"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7eff5d6..9095a774 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ catalogs: '@biomejs/biome': specifier: ^1.9.4 version: 1.9.4 + '@cloudflare/vitest-pool-workers': + specifier: ^0.8.47 + version: 0.8.71 '@cloudflare/workers-oauth-provider': specifier: ^0.0.12 version: 0.0.12 @@ -63,9 +66,6 @@ catalogs: '@vitejs/plugin-react': specifier: ^4.6.0 version: 4.6.0 - '@vitest/coverage-v8': - specifier: ^3.2.4 - version: 3.2.4 agents: specifier: ^0.2.23 version: 0.2.23 @@ -173,9 +173,6 @@ importers: '@types/node': specifier: 'catalog:' version: 22.16.0 - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(tsx@4.20.3)(yaml@2.8.0)) dotenv: specifier: 'catalog:' version: 16.6.1 @@ -292,6 +289,9 @@ importers: '@cloudflare/vite-plugin': specifier: ^1.13.15 version: 1.13.15(vite@6.3.5(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))(workerd@1.20251011.0)(wrangler@4.45.0(@cloudflare/workers-types@4.20251014.0)) + '@cloudflare/vitest-pool-workers': + specifier: 'catalog:' + version: 0.8.71(@cloudflare/workers-types@4.20251014.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.10.2(@types/node@24.0.10)(typescript@5.8.3))(tsx@4.20.3)(yaml@2.8.0)) '@cloudflare/workers-types': specifier: 'catalog:' version: 4.20251014.0 @@ -707,10 +707,6 @@ packages: resolution: {integrity: sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==} engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@1.0.2': - resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} - engines: {node: '>=18'} - '@biomejs/biome@1.9.4': resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} engines: {node: '>=14.21.3'} @@ -786,6 +782,15 @@ packages: resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} engines: {node: '>=18.0.0'} + '@cloudflare/unenv-preset@2.7.3': + resolution: {integrity: sha512-tsQQagBKjvpd9baa6nWVIv399ejiqcrUBBW6SZx6Z22+ymm+Odv5+cFimyuCsD/fC1fQTwfRmwXBNpzvHSeGCw==} + peerDependencies: + unenv: 2.0.0-rc.21 + workerd: ^1.20250828.1 + peerDependenciesMeta: + workerd: + optional: true + '@cloudflare/unenv-preset@2.7.8': resolution: {integrity: sha512-Ky929MfHh+qPhwCapYrRPwPVHtA2Ioex/DbGZyskGyNRDe9Ru3WThYZivyNVaPy5ergQSgMs9OKrM9Ajtz9F6w==} peerDependencies: @@ -801,30 +806,67 @@ packages: vite: ^6.1.0 || ^7.0.0 wrangler: ^4.45.0 + '@cloudflare/vitest-pool-workers@0.8.71': + resolution: {integrity: sha512-keu2HCLQfRNwbmLBCDXJgCFpANTaYnQpE01fBOo4CNwiWHUT7SZGN7w64RKiSWRHyYppStXBuE5Ng7F42+flpg==} + peerDependencies: + '@vitest/runner': 2.0.x - 3.2.x + '@vitest/snapshot': 2.0.x - 3.2.x + vitest: 2.0.x - 3.2.x + + '@cloudflare/workerd-darwin-64@1.20250906.0': + resolution: {integrity: sha512-E+X/YYH9BmX0ew2j/mAWFif2z05NMNuhCTlNYEGLkqMe99K15UewBqajL9pMcMUKxylnlrEoK3VNxl33DkbnPA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-64@1.20251011.0': resolution: {integrity: sha512-0DirVP+Z82RtZLlK2B+VhLOkk+ShBqDYO/jhcRw4oVlp0TOvk3cOVZChrt3+y3NV8Y/PYgTEywzLKFSziK4wCg==} engines: {node: '>=16'} cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20250906.0': + resolution: {integrity: sha512-X5apsZ1SFW4FYTM19ISHf8005FJMPfrcf4U5rO0tdj+TeJgQgXuZ57IG0WeW7SpLVeBo8hM6WC8CovZh41AfnA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20251011.0': resolution: {integrity: sha512-1WuFBGwZd15p4xssGN/48OE2oqokIuc51YvHvyNivyV8IYnAs3G9bJNGWth1X7iMDPe4g44pZrKhRnISS2+5dA==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] + '@cloudflare/workerd-linux-64@1.20250906.0': + resolution: {integrity: sha512-rlKzWgsLnlQ5Nt9W69YBJKcmTmZbOGu0edUsenXPmc6wzULUxoQpi7ZE9k3TfTonJx4WoQsQlzCUamRYFsX+0Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-64@1.20251011.0': resolution: {integrity: sha512-BccMiBzFlWZyFghIw2szanmYJrJGBGHomw2y/GV6pYXChFzMGZkeCEMfmCyJj29xczZXxcZmUVJxNy4eJxO8QA==} engines: {node: '>=16'} cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20250906.0': + resolution: {integrity: sha512-DdedhiQ+SeLzpg7BpcLrIPEZ33QKioJQ1wvL4X7nuLzEB9rWzS37NNNahQzc1+44rhG4fyiHbXBPOeox4B9XVA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20251011.0': resolution: {integrity: sha512-79o/216lsbAbKEVDZYXR24ivEIE2ysDL9jvo0rDTkViLWju9dAp3CpyetglpJatbSi3uWBPKZBEOqN68zIjVsQ==} engines: {node: '>=16'} cpu: [arm64] os: [linux] + '@cloudflare/workerd-windows-64@1.20250906.0': + resolution: {integrity: sha512-Q8Qjfs8jGVILnZL6vUpQ90q/8MTCYaGR3d1LGxZMBqte8Vr7xF3KFHPEy7tFs0j0mMjnqCYzlofmPNY+9ZaDRg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workerd-windows-64@1.20251011.0': resolution: {integrity: sha512-RIXUQRchFdqEvaUqn1cXZXSKjpqMaSaVAkI5jNZ8XzAw/bw2bcdOVUtakrflgxDprltjFb0PTNtuss1FKtH9Jg==} engines: {node: '>=16'} @@ -1329,10 +1371,6 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} @@ -2299,15 +2337,6 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 - '@vitest/coverage-v8@3.2.4': - resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} - peerDependencies: - '@vitest/browser': 3.2.4 - vitest: 3.2.4 - peerDependenciesMeta: - '@vitest/browser': - optional: true - '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -2454,9 +2483,6 @@ packages: resolution: {integrity: sha512-mfh6a7gKXE8pDlxTvqIc/syH/P3RkzbOF6LeHdcKztLEzYe6IMsRCL7N8vI7hqTGWNxpkCuuRTpT21xNWqhRtQ==} engines: {node: '>=20.18.0'} - ast-v8-to-istanbul@0.3.3: - resolution: {integrity: sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==} - babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -2480,6 +2506,9 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + birpc@0.2.14: + resolution: {integrity: sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==} + birpc@2.4.0: resolution: {integrity: sha512-5IdNxTyhXHv2UlgnPHQ0h+5ypVmkrYHzL8QT+DwFZ//2N/oNV8Ch+BCRmTJ3x6/z9Axo/cXYBc9eprsUVK/Jsg==} @@ -2772,6 +2801,9 @@ packages: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} + devalue@5.6.1: + resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -3089,10 +3121,6 @@ packages: resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -3120,9 +3148,6 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -3250,22 +3275,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - - istanbul-reports@3.1.7: - resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} - engines: {node: '>=8'} - jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -3479,13 +3488,6 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} - magicast@0.3.5: - resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - markdown-it-anchor@8.6.7: resolution: {integrity: sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==} peerDependencies: @@ -3698,6 +3700,11 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + miniflare@4.20250906.0: + resolution: {integrity: sha512-T/RWn1sa0ien80s6NjU+Un/tj12gR6wqScZoiLeMJDD4/fK0UXfnbWXJDubnUED8Xjm7RPQ5ESYdE+mhPmMtuQ==} + engines: {node: '>=18.0.0'} + hasBin: true + miniflare@4.20251011.1: resolution: {integrity: sha512-Qbw1Z8HTYM1adWl6FAtzhrj34/6dPRDPwdYOx21dkae8a/EaxbMzRIPbb4HKVGMVvtqbK1FaRCgDLVLolNzGHg==} engines: {node: '>=18.0.0'} @@ -4361,10 +4368,6 @@ packages: resolution: {integrity: sha512-GBuewsPrhJPftT+fqDa9oI/zc5HNsG9nREqwzoSFDOIqf0NggOZbHQj2TE1P1CDJK8ZogFnlZY9hWoUiur7I/A==} engines: {node: '>=18'} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -4395,10 +4398,6 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} - test-exclude@7.0.1: - resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} - engines: {node: '>=18'} - throttleit@2.1.0: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} @@ -4753,6 +4752,11 @@ packages: engines: {node: '>=8'} hasBin: true + workerd@1.20250906.0: + resolution: {integrity: sha512-ryVyEaqXPPsr/AxccRmYZZmDAkfQVjhfRqrNTlEeN8aftBk6Ca1u7/VqmfOayjCXrA+O547TauebU+J3IpvFXw==} + engines: {node: '>=16'} + hasBin: true + workerd@1.20251011.0: resolution: {integrity: sha512-Dq35TLPEJAw7BuYQMkN3p9rge34zWMU2Gnd4DSJFeVqld4+DAO2aPG7+We2dNIAyM97S8Y9BmHulbQ00E0HC7Q==} engines: {node: '>=16'} @@ -4763,6 +4767,16 @@ packages: engines: {node: '>=16.17.0'} hasBin: true + wrangler@4.35.0: + resolution: {integrity: sha512-HbyXtbrh4Fi3mU8ussY85tVdQ74qpVS1vctUgaPc+bPrXBTqfDLkZ6VRtHAVF/eBhz4SFmhJtCQpN1caY2Ak8A==} + engines: {node: '>=18.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20250906.0 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrangler@4.45.0: resolution: {integrity: sha512-2qM6bHw8l7r89Z9Y5A7Wn4L9U+dFoLjYgEUVpqy7CcmXpppL3QIYqU6rU5lre7/SRzBuPu/H93Vwfh538gZ3iw==} engines: {node: '>=18.0.0'} @@ -5084,8 +5098,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@bcoe/v8-coverage@1.0.2': {} - '@biomejs/biome@1.9.4': optionalDependencies: '@biomejs/cli-darwin-arm64': 1.9.4 @@ -5151,6 +5163,12 @@ snapshots: dependencies: mime: 3.0.0 + '@cloudflare/unenv-preset@2.7.3(unenv@2.0.0-rc.21)(workerd@1.20250906.0)': + dependencies: + unenv: 2.0.0-rc.21 + optionalDependencies: + workerd: 1.20250906.0 + '@cloudflare/unenv-preset@2.7.8(unenv@2.0.0-rc.21)(workerd@1.20251011.0)': dependencies: unenv: 2.0.0-rc.21 @@ -5174,18 +5192,50 @@ snapshots: - utf-8-validate - workerd + '@cloudflare/vitest-pool-workers@0.8.71(@cloudflare/workers-types@4.20251014.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.10.2(@types/node@24.0.10)(typescript@5.8.3))(tsx@4.20.3)(yaml@2.8.0))': + dependencies: + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + birpc: 0.2.14 + cjs-module-lexer: 1.4.3 + devalue: 5.6.1 + miniflare: 4.20250906.0 + semver: 7.7.2 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.10.2(@types/node@24.0.10)(typescript@5.8.3))(tsx@4.20.3)(yaml@2.8.0) + wrangler: 4.35.0(@cloudflare/workers-types@4.20251014.0) + zod: 3.25.76 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate + + '@cloudflare/workerd-darwin-64@1.20250906.0': + optional: true + '@cloudflare/workerd-darwin-64@1.20251011.0': optional: true + '@cloudflare/workerd-darwin-arm64@1.20250906.0': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20251011.0': optional: true + '@cloudflare/workerd-linux-64@1.20250906.0': + optional: true + '@cloudflare/workerd-linux-64@1.20251011.0': optional: true + '@cloudflare/workerd-linux-arm64@1.20250906.0': + optional: true + '@cloudflare/workerd-linux-arm64@1.20251011.0': optional: true + '@cloudflare/workerd-windows-64@1.20250906.0': + optional: true + '@cloudflare/workerd-windows-64@1.20251011.0': optional: true @@ -5560,8 +5610,6 @@ snapshots: dependencies: minipass: 7.1.2 - '@istanbuljs/schema@0.1.3': {} - '@jridgewell/gen-mapping@0.3.12': dependencies: '@jridgewell/sourcemap-codec': 1.5.4 @@ -6549,25 +6597,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(tsx@4.20.3)(yaml@2.8.0))': - dependencies: - '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 1.0.2 - ast-v8-to-istanbul: 0.3.3 - debug: 4.4.1 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.1.7 - magic-string: 0.30.17 - magicast: 0.3.5 - std-env: 3.9.0 - test-exclude: 7.0.1 - tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(tsx@4.20.3)(yaml@2.8.0) - transitivePeerDependencies: - - supports-color - '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.2 @@ -6736,12 +6765,6 @@ snapshots: '@babel/parser': 7.28.0 pathe: 2.0.3 - ast-v8-to-istanbul@0.3.3: - dependencies: - '@jridgewell/trace-mapping': 0.3.29 - estree-walker: 3.0.3 - js-tokens: 9.0.1 - babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.28.4 @@ -6765,6 +6788,8 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 + birpc@0.2.14: {} + birpc@2.4.0: {} bl@4.1.0: @@ -7024,6 +7049,8 @@ snapshots: detect-libc@2.0.4: {} + devalue@5.6.1: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -7366,8 +7393,6 @@ snapshots: graphql@16.11.0: {} - has-flag@4.0.0: {} - has-symbols@1.1.0: {} hasown@2.0.2: @@ -7408,8 +7433,6 @@ snapshots: hookable@5.5.3: {} - html-escaper@2.0.2: {} - html-url-attributes@3.0.1: {} http-errors@2.0.0: @@ -7518,27 +7541,6 @@ snapshots: isexe@2.0.0: {} - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.29 - debug: 4.4.1 - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.1.7: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -7756,16 +7758,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.4 - magicast@0.3.5: - dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.0 - source-map-js: 1.2.1 - - make-dir@4.0.0: - dependencies: - semver: 7.7.2 - markdown-it-anchor@8.6.7(@types/markdown-it@14.1.2)(markdown-it@14.1.0): dependencies: '@types/markdown-it': 14.1.2 @@ -8174,6 +8166,24 @@ snapshots: mimic-response@3.1.0: {} + miniflare@4.20250906.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + acorn: 8.14.0 + acorn-walk: 8.3.2 + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + sharp: 0.33.5 + stoppable: 1.1.0 + undici: 7.14.0 + workerd: 1.20250906.0 + ws: 8.18.0 + youch: 4.1.0-beta.10 + zod: 3.22.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + miniflare@4.20251011.1: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -8945,10 +8955,6 @@ snapshots: supports-color@10.1.0: {} - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} swr@2.3.4(react@19.1.0): @@ -8987,12 +8993,6 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 - test-exclude@7.0.1: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 10.5.0 - minimatch: 9.0.5 - throttleit@2.1.0: {} tiktoken@1.0.22: {} @@ -9421,6 +9421,14 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + workerd@1.20250906.0: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20250906.0 + '@cloudflare/workerd-darwin-arm64': 1.20250906.0 + '@cloudflare/workerd-linux-64': 1.20250906.0 + '@cloudflare/workerd-linux-arm64': 1.20250906.0 + '@cloudflare/workerd-windows-64': 1.20250906.0 + workerd@1.20251011.0: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20251011.0 @@ -9449,6 +9457,23 @@ snapshots: - '@cfworker/json-schema' - supports-color + wrangler@4.35.0(@cloudflare/workers-types@4.20251014.0): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.0 + '@cloudflare/unenv-preset': 2.7.3(unenv@2.0.0-rc.21)(workerd@1.20250906.0) + blake3-wasm: 2.1.5 + esbuild: 0.25.4 + miniflare: 4.20250906.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.21 + workerd: 1.20250906.0 + optionalDependencies: + '@cloudflare/workers-types': 4.20251014.0 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrangler@4.45.0(@cloudflare/workers-types@4.20251014.0): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2d8bf88f..a007ea23 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,7 +23,6 @@ catalog: "@types/react": ^19.1.8 "@types/react-dom": ^19.1.6 "@vitejs/plugin-react": ^4.6.0 - "@vitest/coverage-v8": ^3.2.4 agents: ^0.2.23 ai: ^4.3.16 better-sqlite3: ^11.10.0