From f8289daee55342560243f28a1c1e3d284c0b3efa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 03:50:35 +0000 Subject: [PATCH 1/2] Reduce and harden MCP E2E coverage Co-authored-by: Kent C. Dodds --- docs/contributing/adding-capabilities.md | 12 +- docs/contributing/end-to-end-testing.md | 8 + docs/contributing/testing-principles.md | 3 + .../apps/saved-app-capabilities.node.test.ts | 205 ++++++ .../apps/ui-save-app.node.test.ts | 249 +++++++ .../worker/src/mcp/mcp-server.mcp-e2e.test.ts | 658 ++---------------- tools/mcp-test-support.ts | 2 +- vitest.mcp-e2e.config.ts | 7 +- 8 files changed, 553 insertions(+), 591 deletions(-) create mode 100644 packages/worker/src/mcp/capabilities/apps/saved-app-capabilities.node.test.ts create mode 100644 packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts diff --git a/docs/contributing/adding-capabilities.md b/docs/contributing/adding-capabilities.md index b66ace018c..3ab9a83868 100644 --- a/docs/contributing/adding-capabilities.md +++ b/docs/contributing/adding-capabilities.md @@ -219,8 +219,11 @@ the domain modules. `capabilities: [..., yourCapability]`. 5. If the domain uses `index.ts`, ensure it still exports `domain` / `codingCapabilities`-style aliases as needed for local imports. -6. Add or update tests in `packages/worker/src/mcp/*.mcp-e2e.test.ts` for - MCP-visible behavior. +6. Add or update focused `*.node.test.ts` or `*.workers.test.ts` coverage beside + the implementation for most MCP-visible behavior. Touch + `packages/worker/src/mcp/*.mcp-e2e.test.ts` only when the behavior truly + depends on the real MCP transport, OAuth handshake, or saved-app session + wiring. Example (assuming `example` exists in `capabilityDomainNames`): @@ -317,8 +320,9 @@ Public MCP behavior should be verified through the compact tool surface: ranked results - use `execute` to confirm the capability runs correctly -Prefer E2E tests in `packages/worker/src/mcp/*.mcp-e2e.test.ts` for the real MCP -contract. +Prefer `*.node.test.ts` and `*.workers.test.ts` for capability behavior. Reserve +`packages/worker/src/mcp/*.mcp-e2e.test.ts` for a very small number of real MCP +contract smoke tests. Registry invariants (duplicate capability names, domain/capability mismatches, duplicate domain registration) are covered in diff --git a/docs/contributing/end-to-end-testing.md b/docs/contributing/end-to-end-testing.md index 324ce2f8fd..293c0fef11 100644 --- a/docs/contributing/end-to-end-testing.md +++ b/docs/contributing/end-to-end-testing.md @@ -31,6 +31,10 @@ that are unlikely to recur. cases. - If a bug is unlikely to show up again, do not add an E2E test just to lock in the fix. +- For MCP specifically, treat `*.mcp-e2e.test.ts` as a tiny transport smoke + suite. Do not add capability-by-capability coverage there unless the failure + mode depends on the real MCP HTTP transport, OAuth flow, or saved-app session + wiring. ## Structure and style @@ -108,6 +112,10 @@ Common commands: - `npx playwright test` - `npx playwright test e2e/login.spec.ts` +For MCP capability work, prefer `*.node.test.ts` or `*.workers.test.ts` beside +the implementation and keep `npm run test:mcp` limited to a couple of +high-signal smoke journeys. + If `packages/worker/.env` is missing, the E2E server startup path copies `packages/worker/.env.example` to `packages/worker/.env` before Wrangler starts. diff --git a/docs/contributing/testing-principles.md b/docs/contributing/testing-principles.md index 602ddc75e6..54f857cc7b 100644 --- a/docs/contributing/testing-principles.md +++ b/docs/contributing/testing-principles.md @@ -30,6 +30,9 @@ when that makes a single test longer and more assertion-heavy. tests. - Prefer fast unit tests for server logic; keep e2e tests focused on a very small number of important happy-path journeys. +- Treat `packages/worker/src/mcp/*.mcp-e2e.test.ts` as a tiny MCP transport + smoke suite. Do not add capability-specific cases there unless they require + the real MCP HTTP transport, OAuth flow, and saved-app session wiring. - Prefer asserting intermediate states inside the broader workflow that causes them rather than adding isolated tests that only check an incidental loading or transition state. diff --git a/packages/worker/src/mcp/capabilities/apps/saved-app-capabilities.node.test.ts b/packages/worker/src/mcp/capabilities/apps/saved-app-capabilities.node.test.ts new file mode 100644 index 0000000000..38aeb42d0e --- /dev/null +++ b/packages/worker/src/mcp/capabilities/apps/saved-app-capabilities.node.test.ts @@ -0,0 +1,205 @@ +import { expect, test, vi } from 'vitest' +import { createMcpCallerContext } from '#mcp/context.ts' + +const mockModule = vi.hoisted(() => ({ + syncSavedAppRunnerFromDb: vi.fn(), + execSavedAppRunnerServer: vi.fn(), + exportSavedAppRunnerStorage: vi.fn(), + getUiArtifactById: vi.fn(), +})) + +vi.mock('#mcp/app-runner.ts', () => ({ + syncSavedAppRunnerFromDb: (...args: Array) => + mockModule.syncSavedAppRunnerFromDb(...args), + execSavedAppRunnerServer: (...args: Array) => + mockModule.execSavedAppRunnerServer(...args), + exportSavedAppRunnerStorage: (...args: Array) => + mockModule.exportSavedAppRunnerStorage(...args), +})) + +vi.mock('#mcp/ui-artifacts-repo.ts', () => ({ + getUiArtifactById: (...args: Array) => + mockModule.getUiArtifactById(...args), +})) + +const { appServerExecCapability } = await import('./app-server-exec.ts') +const { appStorageExportCapability } = await import('./app-storage-export.ts') +const { uiLoadAppSourceCapability } = await import('./ui-load-app-source.ts') + +test('app_server_exec syncs the runner and normalizes the runner response', async () => { + mockModule.syncSavedAppRunnerFromDb.mockReset() + mockModule.execSavedAppRunnerServer.mockReset() + mockModule.exportSavedAppRunnerStorage.mockReset() + mockModule.getUiArtifactById.mockReset() + + mockModule.syncSavedAppRunnerFromDb.mockResolvedValueOnce({ + id: 'app-1', + }) + mockModule.execSavedAppRunnerServer.mockResolvedValueOnce({ + appId: 'app-1', + facetName: 'main', + result: { count: 3 }, + }) + + const result = await appServerExecCapability.handler( + { + app_id: 'app-1', + code: 'return await app.call("incrementBy", params.amount ?? 1)', + params: { amount: 3 }, + }, + { + env: {} as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-1', email: 'user@example.com' }, + }), + }, + ) + + expect(mockModule.syncSavedAppRunnerFromDb).toHaveBeenCalledWith({ + env: {}, + appId: 'app-1', + userId: 'user-1', + baseUrl: 'https://heykody.dev', + }) + expect(mockModule.execSavedAppRunnerServer).toHaveBeenCalledWith({ + env: {}, + appId: 'app-1', + facetName: 'main', + code: 'return await app.call("incrementBy", params.amount ?? 1)', + params: { amount: 3 }, + }) + expect(result).toEqual({ + ok: true, + app_id: 'app-1', + facet_name: 'main', + result: { count: 3 }, + }) +}) + +test('app_storage_export forwards pagination options to the runner export helper', async () => { + mockModule.syncSavedAppRunnerFromDb.mockReset() + mockModule.execSavedAppRunnerServer.mockReset() + mockModule.exportSavedAppRunnerStorage.mockReset() + mockModule.getUiArtifactById.mockReset() + + mockModule.syncSavedAppRunnerFromDb.mockResolvedValueOnce({ + id: 'app-1', + }) + mockModule.exportSavedAppRunnerStorage.mockResolvedValueOnce({ + appId: 'app-1', + facetName: 'analytics', + export: { + entries: [{ key: 'count', value: 3 }], + estimatedBytes: 128, + truncated: true, + nextStartAfter: 'count', + pageSize: 1, + }, + }) + + const result = await appStorageExportCapability.handler( + { + app_id: 'app-1', + facet_name: 'analytics', + page_size: 1, + start_after: 'count', + }, + { + env: {} as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-1', email: 'user@example.com' }, + }), + }, + ) + + expect(mockModule.syncSavedAppRunnerFromDb).toHaveBeenCalledWith({ + env: {}, + appId: 'app-1', + userId: 'user-1', + baseUrl: 'https://heykody.dev', + }) + expect(mockModule.exportSavedAppRunnerStorage).toHaveBeenCalledWith({ + env: {}, + appId: 'app-1', + facetName: 'analytics', + pageSize: 1, + startAfter: 'count', + }) + expect(result).toEqual({ + ok: true, + app_id: 'app-1', + facet_name: 'analytics', + export: { + entries: [{ key: 'count', value: 3 }], + estimatedBytes: 128, + truncated: true, + nextStartAfter: 'count', + pageSize: 1, + }, + }) +}) + +test('ui_load_app_source returns saved source for the authenticated user', async () => { + mockModule.syncSavedAppRunnerFromDb.mockReset() + mockModule.execSavedAppRunnerServer.mockReset() + mockModule.exportSavedAppRunnerStorage.mockReset() + mockModule.getUiArtifactById.mockReset() + + mockModule.getUiArtifactById.mockResolvedValueOnce({ + id: 'app-1', + title: 'Patchable App', + description: 'Saved app source', + clientCode: '

Saved

', + serverCode: + 'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject {}', + serverCodeId: 'server-code-v1', + parameters: JSON.stringify([ + { + name: 'team', + description: 'Team slug', + type: 'string', + required: true, + }, + ]), + hidden: true, + }) + + const result = await uiLoadAppSourceCapability.handler( + { + app_id: 'app-1', + }, + { + env: { APP_DB: {} } as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-1', email: 'user@example.com' }, + }), + }, + ) + + expect(mockModule.getUiArtifactById).toHaveBeenCalledWith( + {}, + 'user-1', + 'app-1', + ) + expect(result).toEqual({ + app_id: 'app-1', + title: 'Patchable App', + description: 'Saved app source', + client_code: '

Saved

', + server_code: + 'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject {}', + server_code_id: 'server-code-v1', + parameters: [ + { + name: 'team', + description: 'Team slug', + type: 'string', + required: true, + }, + ], + hidden: true, + }) +}) diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts new file mode 100644 index 0000000000..54822fa118 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts @@ -0,0 +1,249 @@ +import { expect, test, vi } from 'vitest' +import { createMcpCallerContext } from '#mcp/context.ts' + +const mockModule = vi.hoisted(() => ({ + getUiArtifactById: vi.fn(), + updateUiArtifact: vi.fn(), + insertUiArtifact: vi.fn(), + deleteUiArtifact: vi.fn(), + configureSavedAppRunner: vi.fn(), + deleteSavedAppRunner: vi.fn(), + upsertUiArtifactVector: vi.fn(), + deleteUiArtifactVector: vi.fn(), +})) + +vi.mock('#mcp/ui-artifacts-repo.ts', () => ({ + getUiArtifactById: (...args: Array) => + mockModule.getUiArtifactById(...args), + updateUiArtifact: (...args: Array) => + mockModule.updateUiArtifact(...args), + insertUiArtifact: (...args: Array) => + mockModule.insertUiArtifact(...args), + deleteUiArtifact: (...args: Array) => + mockModule.deleteUiArtifact(...args), +})) + +vi.mock('#mcp/app-runner.ts', () => ({ + configureSavedAppRunner: (...args: Array) => + mockModule.configureSavedAppRunner(...args), + deleteSavedAppRunner: (...args: Array) => + mockModule.deleteSavedAppRunner(...args), +})) + +vi.mock('#mcp/ui-artifacts-vectorize.ts', () => ({ + upsertUiArtifactVector: (...args: Array) => + mockModule.upsertUiArtifactVector(...args), + deleteUiArtifactVector: (...args: Array) => + mockModule.deleteUiArtifactVector(...args), +})) + +const { uiSaveAppCapability } = await import('./ui-save-app.ts') + +test('ui_save_app updates preserve backend code unless the caller clears or replaces it', async () => { + mockModule.getUiArtifactById.mockReset() + mockModule.updateUiArtifact.mockReset() + mockModule.insertUiArtifact.mockReset() + mockModule.deleteUiArtifact.mockReset() + mockModule.configureSavedAppRunner.mockReset() + mockModule.deleteSavedAppRunner.mockReset() + mockModule.upsertUiArtifactVector.mockReset() + mockModule.deleteUiArtifactVector.mockReset() + + const initialServerCode = + 'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject { async readVersion() { return "v1" } }' + const replacementServerCode = + 'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject { async readVersion() { return "v2" } }' + + let currentApp = { + id: 'app-1', + user_id: 'user-1', + title: 'Patchable App', + description: 'Saved app used to verify partial ui_save_app updates.', + clientCode: '

Patchable v1

', + serverCode: initialServerCode, + serverCodeId: 'server-code-v1', + parameters: JSON.stringify([ + { + name: 'team', + description: 'Team slug', + type: 'string', + required: true, + }, + ]), + hidden: true, + } + + mockModule.getUiArtifactById.mockImplementation(async () => ({ + ...currentApp, + })) + mockModule.updateUiArtifact.mockImplementation( + async ( + _db: unknown, + _userId: string, + _appId: string, + updates: Record, + ) => { + currentApp = { + ...currentApp, + ...(updates['title'] !== undefined + ? { title: updates['title'] as string } + : {}), + ...(updates['description'] !== undefined + ? { description: updates['description'] as string } + : {}), + ...(updates['clientCode'] !== undefined + ? { clientCode: updates['clientCode'] as string } + : {}), + ...(updates['hidden'] !== undefined + ? { hidden: updates['hidden'] as boolean } + : {}), + ...(updates['parameters'] !== undefined + ? { parameters: updates['parameters'] as string | null } + : {}), + ...(updates['serverCode'] !== undefined + ? { serverCode: updates['serverCode'] as string | null } + : {}), + ...(updates['serverCodeId'] !== undefined + ? { serverCodeId: updates['serverCodeId'] as string } + : {}), + } + return { ...currentApp } + }, + ) + + const randomUuidSpy = vi.spyOn(crypto, 'randomUUID') + randomUuidSpy.mockReturnValueOnce('server-code-v2') + randomUuidSpy.mockReturnValueOnce('server-code-v3') + + try { + const callerContext = createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-1', email: 'user@example.com' }, + }) + + const preservedResult = await uiSaveAppCapability.handler( + { + app_id: 'app-1', + clientCode: '

Patchable v2

', + }, + { + env: { APP_DB: {} } as Env, + callerContext, + }, + ) + expect(preservedResult).toEqual({ + app_id: 'app-1', + server_code_id: 'server-code-v1', + has_server_code: true, + hosted_url: 'https://heykody.dev/ui/app-1', + parameters: [ + { + name: 'team', + description: 'Team slug', + type: 'string', + required: true, + }, + ], + hidden: true, + }) + expect(mockModule.updateUiArtifact.mock.calls[0]?.[3]).toEqual({ + title: undefined, + description: undefined, + clientCode: '

Patchable v2

', + hidden: undefined, + }) + expect(mockModule.configureSavedAppRunner.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + appId: 'app-1', + serverCode: initialServerCode, + serverCodeId: 'server-code-v1', + }), + ) + + const clearedResult = await uiSaveAppCapability.handler( + { + app_id: 'app-1', + serverCode: null, + }, + { + env: { APP_DB: {} } as Env, + callerContext, + }, + ) + expect(clearedResult).toEqual({ + app_id: 'app-1', + server_code_id: 'server-code-v2', + has_server_code: false, + hosted_url: 'https://heykody.dev/ui/app-1', + parameters: [ + { + name: 'team', + description: 'Team slug', + type: 'string', + required: true, + }, + ], + hidden: true, + }) + expect(mockModule.updateUiArtifact.mock.calls[1]?.[3]).toEqual({ + title: undefined, + description: undefined, + clientCode: undefined, + hidden: undefined, + serverCode: null, + serverCodeId: 'server-code-v2', + }) + expect(mockModule.configureSavedAppRunner.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + appId: 'app-1', + serverCode: null, + serverCodeId: 'server-code-v2', + }), + ) + + const replacedResult = await uiSaveAppCapability.handler( + { + app_id: 'app-1', + serverCode: replacementServerCode, + }, + { + env: { APP_DB: {} } as Env, + callerContext, + }, + ) + expect(replacedResult).toEqual({ + app_id: 'app-1', + server_code_id: 'server-code-v3', + has_server_code: true, + hosted_url: 'https://heykody.dev/ui/app-1', + parameters: [ + { + name: 'team', + description: 'Team slug', + type: 'string', + required: true, + }, + ], + hidden: true, + }) + expect(mockModule.updateUiArtifact.mock.calls[2]?.[3]).toEqual({ + title: undefined, + description: undefined, + clientCode: undefined, + hidden: undefined, + serverCode: replacementServerCode, + serverCodeId: 'server-code-v3', + }) + expect(mockModule.configureSavedAppRunner.mock.calls[2]?.[0]).toEqual( + expect.objectContaining({ + appId: 'app-1', + serverCode: replacementServerCode, + serverCodeId: 'server-code-v3', + }), + ) + expect(randomUuidSpy).toHaveBeenCalledTimes(2) + expect(mockModule.deleteUiArtifactVector).toHaveBeenCalledTimes(3) + } finally { + randomUuidSpy.mockRestore() + } +}) diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts index 0e055b8305..26ca85faf0 100644 --- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts +++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts @@ -1,12 +1,20 @@ import { expect, test } from 'vitest' import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js' -import { setTimeout as delay } from 'node:timers/promises' import { createMcpClient, createTestDatabase, startDevServer, } from '../../../../tools/mcp-test-support.ts' +/** + * MCP E2E is intentionally tiny. + * + * Do not add cases here unless the thing being tested genuinely requires the + * real MCP HTTP transport, OAuth flow, and saved-app session wiring all at + * once. Most capability behavior belongs in faster node/workers tests beside + * the implementation. Keep this file to a couple of smoke journeys. + */ + test('mcp endpoint requires OAuth bearer auth', async () => { await using database = await createTestDatabase() await using server = await startDevServer(database.persistDir) @@ -25,7 +33,7 @@ test('mcp endpoint requires OAuth bearer auth', async () => { ) }) -test('authenticated mcp client can list tools, execute codemode, and search memories', async () => { +test('authenticated MCP smoke covers core tools, inline UI, and saved app backends', async () => { await using database = await createTestDatabase() await using server = await startDevServer(database.persistDir) await using mcpClient = await createMcpClient(server.origin, database.user) @@ -55,85 +63,30 @@ test('authenticated mcp client can list tools, execute codemode, and search memo name: 'execute', arguments: { code: `async () => { - return await codemode.meta_memory_upsert({ - subject: 'User prefers npm over pnpm', - summary: 'Always use npm commands in this repository.', - category: 'preference', - tags: ['package-manager', 'repo-workflow'], - source_uris: [ - 'https://docs.npmjs.com/cli/v11/commands/npm-install', - 'https://github.com/kentcdodds/kody/blob/main/AGENTS.md', - ], - verified_by_agent: true, - verification_reference: 'verify-search-fallback-1', - }) - }`, + return await codemode.meta_memory_upsert({ + subject: 'User prefers npm over pnpm', + summary: 'Always use npm commands in this repository.', + category: 'preference', + tags: ['package-manager', 'repo-workflow'], + source_uris: [ + 'https://docs.npmjs.com/cli/v11/commands/npm-install', + 'https://github.com/kentcdodds/kody/blob/main/AGENTS.md', + ], + verified_by_agent: true, + verification_reference: 'verify-search-fallback-1', + }) + }`, }, }) const upsertStructured = (upsertResult as CallToolResult).structuredContent as - | { result?: { memory?: { id?: string; source_uris?: Array } } } + | { result?: { memory?: { id?: string } } } | undefined expect(typeof upsertStructured?.result?.memory?.id).toBe('string') - expect(upsertStructured?.result?.memory?.source_uris).toEqual([ - 'https://docs.npmjs.com/cli/v11/commands/npm-install', - 'https://github.com/kentcdodds/kody/blob/main/AGENTS.md', - ]) - - const memoryId = upsertStructured?.result?.memory?.id - if (!memoryId) { - throw new Error('missing memoryId') - } - - const getResult = await mcpClient.client.callTool({ - name: 'execute', - arguments: { - code: `async () => { - return await codemode.meta_memory_get({ - memory_id: ${JSON.stringify(memoryId)}, - }) - }`, - }, - }) - const getStructured = (getResult as CallToolResult).structuredContent as - | { result?: { source_uris?: Array } } - | undefined - expect(getStructured?.result?.source_uris).toEqual([ - 'https://docs.npmjs.com/cli/v11/commands/npm-install', - 'https://github.com/kentcdodds/kody/blob/main/AGENTS.md', - ]) - - const memoryCapabilitySearchResult = await mcpClient.client.callTool({ - name: 'execute', - arguments: { - code: `async () => { - return await codemode.meta_memory_search({ - query: 'npm over pnpm', - limit: 3, - }) - }`, - }, - }) - const memoryCapabilitySearchStructured = ( - memoryCapabilitySearchResult as CallToolResult - ).structuredContent as - | { - result?: { - matches?: Array<{ source_uris?: Array }> - } - } - | undefined - expect( - memoryCapabilitySearchStructured?.result?.matches?.[0]?.source_uris, - ).toEqual([ - 'https://docs.npmjs.com/cli/v11/commands/npm-install', - 'https://github.com/kentcdodds/kody/blob/main/AGENTS.md', - ]) - const query = 'npm over pnpm' const memorySearchResult = await mcpClient.client.callTool({ name: 'search', arguments: { - query, + query: 'npm over pnpm', }, }) const memorySearchStructured = (memorySearchResult as CallToolResult) @@ -147,8 +100,9 @@ test('authenticated mcp client can list tools, execute codemode, and search memo } } | undefined - - expect(memorySearchStructured?.result?.memories?.retrievalQuery).toBe(query) + expect(memorySearchStructured?.result?.memories?.retrievalQuery).toBe( + 'npm over pnpm', + ) expect(memorySearchStructured?.result?.memories?.surfaced).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -156,20 +110,15 @@ test('authenticated mcp client can list tools, execute codemode, and search memo }), ]), ) -}) -test('authenticated mcp client can open generated ui and reopen a saved app', async () => { - await using database = await createTestDatabase() - await using server = await startDevServer(database.persistDir) - await using mcpClient = await createMcpClient(server.origin, database.user) - - const inlineResult = await mcpClient.client.callTool({ + const inlineUiResult = await mcpClient.client.callTool({ name: 'open_generated_ui', arguments: { code: '

Storage Context

', }, }) - const inlineStructured = (inlineResult as CallToolResult).structuredContent as + const inlineUiStructured = (inlineUiResult as CallToolResult) + .structuredContent as | { renderSource?: string appSession?: { @@ -178,104 +127,58 @@ test('authenticated mcp client can open generated ui and reopen a saved app', as } | null } | undefined - const executeEndpoint = inlineStructured?.appSession?.endpoints?.execute - const executeToken = inlineStructured?.appSession?.token - expect(inlineStructured?.renderSource).toBe('inline_code') - expect(typeof executeEndpoint).toBe('string') - expect(typeof executeToken).toBe('string') - - const setValueResponse = await fetch(executeEndpoint!, { - method: 'POST', - headers: { - Authorization: `Bearer ${executeToken}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - code: `async () => { - await codemode.value_set({ - name: 'example', - value: 'value', - scope: 'session', - }) - return { ok: true } - }`, - }), - }) - expect(setValueResponse.ok).toBe(true) - const setValuePayload = (await setValueResponse.json()) as { - ok?: boolean - result?: { ok?: boolean } - } - expect(setValuePayload.ok).toBe(true) - expect(setValuePayload.result).toEqual({ ok: true }) - - // Repeated POSTs to the generated UI execute endpoint can hit Wrangler local - // dev proxy restarts mid-request in CI. The storage-backed execute behavior is - // covered by focused unit/workers tests, so this E2E keeps the generated UI - // flow coverage without asserting a second POST round-trip here. - await delay(2500) - - // The generated UI runtime executes out-of-band HTTP requests with its own app - // session. Reconnect the MCP client before resuming tool calls so the test - // exercises a fresh MCP session after that browser-style interaction. - await using resumedMcpClient = await createMcpClient( - server.origin, - database.user, + expect(inlineUiStructured?.renderSource).toBe('inline_code') + expect(typeof inlineUiStructured?.appSession?.token).toBe('string') + expect(typeof inlineUiStructured?.appSession?.endpoints?.execute).toBe( + 'string', ) - const saveResult = await resumedMcpClient.client.callTool({ + const saveResult = await mcpClient.client.callTool({ name: 'execute', arguments: { code: `async () => { - return await codemode.ui_save_app({ - title: 'Persistent UI', - description: 'Saved from test', - clientCode: '

Saved

', - hidden: false, - }) - }`, + return await codemode.ui_save_app({ + title: 'Facet Counter', + description: 'Saved app with a backend facet counter', + clientCode: '

Facet Counter

', + serverCode: \` + import { DurableObject } from 'cloudflare:workers' + + export class App extends DurableObject { + async fetch(request) { + const url = new URL(request.url) + if (url.pathname !== '/api/counter') { + return new Response('Not found', { status: 404 }) + } + const current = (await this.ctx.storage.get('count')) ?? 0 + const next = Number(current) + 1 + await this.ctx.storage.put('count', next) + return Response.json({ count: next }) + } + } + \`, + hidden: true, + }) + }`, }, }) const saveStructured = (saveResult as CallToolResult).structuredContent as - | { result?: { app_id?: string } } - | undefined - const savedAppId = saveStructured?.result?.app_id - expect(typeof savedAppId).toBe('string') - - const savedSearchResult = await resumedMcpClient.client.callTool({ - name: 'search', - arguments: { - query: 'Persistent UI', - limit: 10, - maxResponseSize: 20_000, - }, - }) - const savedSearchStructured = (savedSearchResult as CallToolResult) - .structuredContent as | { result?: { - matches?: Array<{ - type?: string - id?: string - hostedUrl?: string - }> + app_id?: string + has_server_code?: boolean + hosted_url?: string } } | undefined - expect( - savedSearchStructured?.result?.matches?.find( - (match) => match.type === 'app' && match.id === savedAppId, - ), - ).toEqual( - expect.objectContaining({ - type: 'app', - id: savedAppId, - hostedUrl: `${server.origin}/ui/${savedAppId}`, - }), + const savedAppId = saveStructured?.result?.app_id + expect(typeof savedAppId).toBe('string') + expect(saveStructured?.result?.has_server_code).toBe(true) + expect(saveStructured?.result?.hosted_url).toBe( + `${server.origin}/ui/${savedAppId}`, ) - const savedOpenResult = await resumedMcpClient.client.callTool({ + const savedOpenResult = await mcpClient.client.callTool({ name: 'open_generated_ui', arguments: { app_id: savedAppId, @@ -301,7 +204,9 @@ test('authenticated mcp client can open generated ui and reopen a saved app', as expect(savedOpenStructured?.hostedUrl).toBe( `${server.origin}/ui/${savedAppId}`, ) - expect(savedOpenStructured?.appBackend).toBeNull() + expect(savedOpenStructured?.appBackend?.basePath).toBe(`/app/${savedAppId}`) + expect(typeof savedOpenStructured?.appSession?.token).toBe('string') + const sourceResponse = await fetch( savedOpenStructured!.appSession!.endpoints!.source!, { @@ -312,102 +217,13 @@ test('authenticated mcp client can open generated ui and reopen a saved app', as }, ) expect(sourceResponse.ok).toBe(true) - expect(sourceResponse.headers.get('Set-Cookie')).toBeNull() - const sourcePayload = (await sourceResponse.json()) as { - app?: { app_backend?: unknown } - } - expect(sourcePayload.app?.app_backend).toBeUndefined() -}, 20_000) - -test('saved apps with server code expose isolated backend storage', async () => { - await using database = await createTestDatabase() - await using server = await startDevServer(database.persistDir) - await using mcpClient = await createMcpClient(server.origin, database.user) - - const saveResult = await mcpClient.client.callTool({ - name: 'execute', - arguments: { - code: `async () => { - return await codemode.ui_save_app({ - title: 'Facet Counter', - description: 'Saved app with a backend facet counter', - clientCode: '

Facet Counter

', - serverCode: \` - import { DurableObject } from 'cloudflare:workers' - - export class App extends DurableObject { - async fetch(request) { - const url = new URL(request.url) - if (url.pathname !== '/api/counter') { - return new Response('Not found', { status: 404 }) - } - const current = (await this.ctx.storage.get('count')) ?? 0 - const next = Number(current) + 1 - await this.ctx.storage.put('count', next) - return Response.json({ count: next }) - } - } - \`, - hidden: true, - }) - }`, - }, - }) - - const saveStructured = (saveResult as CallToolResult).structuredContent as - | { - result?: { - app_id?: string - server_code_id?: string - has_server_code?: boolean - } - } - | undefined - const savedAppId = saveStructured?.result?.app_id - expect(typeof savedAppId).toBe('string') - expect(saveStructured?.result?.has_server_code).toBe(true) - expect(typeof saveStructured?.result?.server_code_id).toBe('string') - - const openResult = await mcpClient.client.callTool({ - name: 'open_generated_ui', - arguments: { - app_id: savedAppId, - }, - }) - const openStructured = (openResult as CallToolResult).structuredContent as - | { - appId?: string | null - appSession?: { - token?: string - expiresAt?: string - endpoints?: { source?: string } - } | null - appBackend?: { - basePath?: string - } | null - } - | undefined - expect(openStructured?.appId).toBe(savedAppId) - expect(openStructured?.appBackend?.basePath).toBe(`/app/${savedAppId}`) - expect(typeof openStructured?.appSession?.token).toBe('string') - - const sourceResponse = await fetch( - openStructured!.appSession!.endpoints!.source!, - { - headers: { - Authorization: `Bearer ${openStructured!.appSession!.token}`, - Accept: 'application/json', - }, - }, - ) - expect(sourceResponse.ok).toBe(true) const backendCookie = sourceResponse.headers.get('Set-Cookie') expect(backendCookie).toContain('kody_generated_ui_app=') const scopedCookieHeader = backendCookie?.split(';')[0] ?? '' const firstResponse = await fetch( new URL( - `${openStructured!.appBackend!.basePath}/api/counter`, + `${savedOpenStructured!.appBackend!.basePath}/api/counter`, server.origin, ), { @@ -422,7 +238,7 @@ test('saved apps with server code expose isolated backend storage', async () => const secondResponse = await fetch( new URL( - `${openStructured!.appBackend!.basePath}/api/counter`, + `${savedOpenStructured!.appBackend!.basePath}/api/counter`, server.origin, ), { @@ -437,7 +253,7 @@ test('saved apps with server code expose isolated backend storage', async () => const unauthenticatedResponse = await fetch( new URL( - `${openStructured!.appBackend!.basePath}/api/counter`, + `${savedOpenStructured!.appBackend!.basePath}/api/counter`, server.origin, ), { @@ -447,328 +263,4 @@ test('saved apps with server code expose isolated backend storage', async () => }, ) expect(unauthenticatedResponse.status).toBe(401) -}) - -test('app_server_exec runs snippets in a throwaway worker with app RPC access', async () => { - await using database = await createTestDatabase() - await using server = await startDevServer(database.persistDir) - await using mcpClient = await createMcpClient(server.origin, database.user) - - const serverCode = - 'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject { async incrementBy(amount = 1) { const current = Number((await this.ctx.storage.get("count")) ?? 0); const next = current + Number(amount); await this.ctx.storage.put("count", next); return { count: next } } }' - - const saveResult = await mcpClient.client.callTool({ - name: 'execute', - arguments: { - code: `async () => { - return await codemode.ui_save_app({ - title: 'Facet Exec App', - description: 'Saved app used to verify app_server_exec.', - clientCode: '

Facet Exec App

', - serverCode: ${JSON.stringify(serverCode)}, - hidden: true, - }) - }`, - }, - }) - const saveStructured = (saveResult as CallToolResult).structuredContent as - | { result?: { app_id?: string } } - | undefined - const savedAppId = saveStructured?.result?.app_id - expect(typeof savedAppId).toBe('string') - - await using trivialExecClient = await createMcpClient( - server.origin, - database.user, - ) - - const trivialExecResult = await trivialExecClient.client.callTool({ - name: 'execute', - arguments: { - code: `async () => { - return await codemode.app_server_exec({ - app_id: ${JSON.stringify(savedAppId)}, - code: ${JSON.stringify(`return { hello: 'world' }`)}, - }) - }`, - }, - }) - const trivialExecStructured = (trivialExecResult as CallToolResult) - .structuredContent as - | { - result?: { - ok?: boolean - app_id?: string - facet_name?: string - result?: { hello?: string } - } - } - | undefined - expect(trivialExecStructured?.result).toEqual({ - ok: true, - app_id: savedAppId, - facet_name: 'main', - result: { hello: 'world' }, - }) - - await using rpcExecClient = await createMcpClient( - server.origin, - database.user, - ) - - const rpcExecResult = await rpcExecClient.client.callTool({ - name: 'execute', - arguments: { - code: `async () => { - return await codemode.app_server_exec({ - app_id: ${JSON.stringify(savedAppId)}, - params: { amount: 3 }, - code: ${JSON.stringify(`return await app.call("incrementBy", params.amount ?? 1)`)}, - }) - }`, - }, - }) - const rpcExecStructured = (rpcExecResult as CallToolResult) - .structuredContent as - | { - result?: { - result?: { count?: number } - } - } - | undefined - expect(rpcExecStructured?.result?.result).toEqual({ count: 3 }) - - await using forbiddenExecClient = await createMcpClient( - server.origin, - database.user, - ) - - const forbiddenExecResult = await forbiddenExecClient.client.callTool({ - name: 'execute', - arguments: { - code: `async () => { - return await codemode.app_server_exec({ - app_id: ${JSON.stringify(savedAppId)}, - code: ${JSON.stringify(`return await app.call("__kody_resetStorage")`)}, - }) - }`, - }, - }) - const forbiddenExecStructured = (forbiddenExecResult as CallToolResult) - .structuredContent as - | { - error?: string - } - | undefined - expect(forbiddenExecResult.isError).toBe(true) - expect(forbiddenExecStructured?.error).toContain( - 'Saved app RPC method "__kody_resetStorage" is not allowed.', - ) - - await using exportClient = await createMcpClient(server.origin, database.user) - - const exportResult = await exportClient.client.callTool({ - name: 'execute', - arguments: { - code: `async () => { - return await codemode.app_storage_export({ - app_id: ${JSON.stringify(savedAppId)}, - }) - }`, - }, - }) - const exportStructured = (exportResult as CallToolResult).structuredContent as - | { - result?: { - export?: { - entries?: Array<{ key?: string; value?: unknown }> - } - } - } - | undefined - expect(exportStructured?.result?.export?.entries).toEqual([ - { key: 'count', value: 3 }, - ]) -}) - -test('ui_save_app preserves omitted backend code and requires explicit clearing', async () => { - await using database = await createTestDatabase() - await using server = await startDevServer(database.persistDir) - await using mcpClient = await createMcpClient(server.origin, database.user) - - const initialServerCode = - 'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject { async readVersion() { return "v1" } }' - const replacementServerCode = - 'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject { async readVersion() { return "v2" } }' - - const flowResult = await mcpClient.client.callTool({ - name: 'execute', - arguments: { - code: `async () => { - const saved = await codemode.ui_save_app({ - title: 'Patchable App', - description: 'Saved app used to verify partial ui_save_app updates.', - clientCode: '

Patchable v1

', - serverCode: ${JSON.stringify(initialServerCode)}, - parameters: [ - { - name: 'team', - description: 'Team slug', - type: 'string', - required: true, - }, - ], - hidden: true, - }) - const appId = saved.app_id - const initialServerCodeId = saved.server_code_id - const clientOnlyUpdate = await codemode.ui_save_app({ - app_id: appId, - clientCode: '

Patchable v2

', - }) - const preservedSource = await codemode.ui_load_app_source({ - app_id: appId, - }) - const clearedServerCode = await codemode.ui_save_app({ - app_id: appId, - serverCode: null, - }) - const clearedSource = await codemode.ui_load_app_source({ - app_id: appId, - }) - const replacedServerCode = await codemode.ui_save_app({ - app_id: appId, - serverCode: ${JSON.stringify(replacementServerCode)}, - }) - const replacedSource = await codemode.ui_load_app_source({ - app_id: appId, - }) - return { - saved, - initialServerCodeId, - clientOnlyUpdate, - preservedSource, - clearedServerCode, - clearedSource, - replacedServerCode, - replacedSource, - } - }`, - }, - }) - const flowStructured = (flowResult as CallToolResult).structuredContent as - | { - result?: { - saved?: { - app_id?: string - server_code_id?: string - has_server_code?: boolean - } - initialServerCodeId?: string - clientOnlyUpdate?: { - server_code_id?: string - has_server_code?: boolean - } - preservedSource?: { - app_id?: string - title?: string - description?: string - client_code?: string - server_code?: string | null - server_code_id?: string - parameters?: Array<{ - name?: string - description?: string - type?: string - required?: boolean - }> | null - hidden?: boolean - } - clearedServerCode?: { - server_code_id?: string - has_server_code?: boolean - } - clearedSource?: { - app_id?: string - client_code?: string - server_code?: string | null - server_code_id?: string - hidden?: boolean - } - replacedServerCode?: { - server_code_id?: string - has_server_code?: boolean - } - replacedSource?: { - app_id?: string - client_code?: string - server_code?: string | null - server_code_id?: string - hidden?: boolean - } - } - } - | undefined - const savedAppId = flowStructured?.result?.saved?.app_id - const initialServerCodeId = flowStructured?.result?.initialServerCodeId - expect(typeof savedAppId).toBe('string') - expect(typeof initialServerCodeId).toBe('string') - expect(flowStructured?.result?.saved?.has_server_code).toBe(true) - - expect(flowStructured?.result?.clientOnlyUpdate?.server_code_id).toBe( - initialServerCodeId, - ) - expect(flowStructured?.result?.clientOnlyUpdate?.has_server_code).toBe(true) - - expect(flowStructured?.result?.preservedSource).toEqual( - expect.objectContaining({ - app_id: savedAppId, - title: 'Patchable App', - description: 'Saved app used to verify partial ui_save_app updates.', - client_code: '

Patchable v2

', - server_code: initialServerCode, - server_code_id: initialServerCodeId, - parameters: [ - { - name: 'team', - description: 'Team slug', - type: 'string', - required: true, - }, - ], - hidden: true, - }), - ) - - const clearedServerCodeId = - flowStructured?.result?.clearedServerCode?.server_code_id - expect(typeof clearedServerCodeId).toBe('string') - expect(clearedServerCodeId).not.toBe(initialServerCodeId) - expect(flowStructured?.result?.clearedServerCode?.has_server_code).toBe(false) - - expect(flowStructured?.result?.clearedSource).toEqual( - expect.objectContaining({ - app_id: savedAppId, - client_code: '

Patchable v2

', - server_code: null, - server_code_id: clearedServerCodeId, - hidden: true, - }), - ) - - const replacementServerCodeId = - flowStructured?.result?.replacedServerCode?.server_code_id - expect(typeof replacementServerCodeId).toBe('string') - expect(replacementServerCodeId).not.toBe(clearedServerCodeId) - expect(flowStructured?.result?.replacedServerCode?.has_server_code).toBe(true) - - expect(flowStructured?.result?.replacedSource).toEqual( - expect.objectContaining({ - app_id: savedAppId, - client_code: '

Patchable v2

', - server_code: replacementServerCode, - server_code_id: replacementServerCodeId, - hidden: true, - }), - ) -}) +}, 45_000) diff --git a/tools/mcp-test-support.ts b/tools/mcp-test-support.ts index 7fac4a813e..115a992c4b 100644 --- a/tools/mcp-test-support.ts +++ b/tools/mcp-test-support.ts @@ -17,7 +17,7 @@ const projectRoot = process.cwd() const primaryUserEmail = 'me@kentcdodds.com' const testUserPassword = 'secret' const localhost = '127.0.0.1' -const defaultWaitTimeoutMs = 25_000 +const defaultWaitTimeoutMs = process.env.CI ? 60_000 : 45_000 type TestUser = { email: string diff --git a/vitest.mcp-e2e.config.ts b/vitest.mcp-e2e.config.ts index d6a6f10d4f..cfebef71d8 100644 --- a/vitest.mcp-e2e.config.ts +++ b/vitest.mcp-e2e.config.ts @@ -1,9 +1,10 @@ import { defineProject, mergeConfig } from 'vitest/config' import { sharedProjectConfig } from './vitest-shared.ts' -// Wrangler dev + OAuth + multiple MCP round-trips per test routinely exceed 30s on -// cold CI runners; keep local runs snappy but allow headroom in CI. -const mcpE2eTimeout = process.env.CI ? 120_000 : 10_000 +// This suite is intentionally just a couple of smoke journeys, but each one +// still boots Wrangler and runs a real OAuth + MCP handshake. Give local cloud +// runs enough headroom instead of failing at the old 10s ceiling. +const mcpE2eTimeout = process.env.CI ? 120_000 : 45_000 export default mergeConfig( sharedProjectConfig, From e404d4905ad133c3f017995fc98426bbc91191c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 04:14:49 +0000 Subject: [PATCH 2/2] Remove per-test timeout override Co-authored-by: Kent C. Dodds --- packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts index 26ca85faf0..cdd96c3547 100644 --- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts +++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts @@ -263,4 +263,4 @@ test('authenticated MCP smoke covers core tools, inline UI, and saved app backen }, ) expect(unauthenticatedResponse.status).toBe(401) -}, 45_000) +})