diff --git a/.vscode/launch.json b/.vscode/launch.json index 2e7d0338..eae7297a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,7 +22,7 @@ * Useful to see console.logs */ "console": "integratedTerminal", - "internalConsoleOptions": "openOnFirstSessionStart", + "internalConsoleOptions": "neverOpen", // Files to exclude from debugger (e.g. call stack) "skipFiles": [ @@ -33,5 +33,44 @@ // "${workspaceFolder}/node_modules/**" ] } + // Wrangler's vscode support seems to be extremely buggy. It sometimes works + // 1/10th of the time, but nothing I tried could improve that consistency. + // Will use browser debugger instead for now. + // { + // "name": "gateway", + // "type": "node", + // "request": "attach", + // "port": 9229, + // "cwd": "${workspaceFolder}/apps/gateway", + // // "cwd": "${workspaceFolder}", + // // "cwd": "/", + // "attachExistingChildren": false, + // "autoAttachChildProcesses": false, + // "sourceMaps": true, + // "outFiles": ["${workspaceFolder}/apps/gateway/.wrangler/tmp/**/*"], + // "resolveSourceMapLocations": null, + // // "resolveSourceMapLocations": ["**", "!**/node_modules/**"], + // "skipFiles": ["/**"], + // "internalConsoleOptions": "neverOpen", + // "restart": true + // }, + // { + // "name": "Wrangler", + // "type": "node", + // "request": "attach", + // "port": 9229, + // "cwd": "/", + // "resolveSourceMapLocations": null, + // "attachExistingChildren": false, + // "autoAttachChildProcesses": false, + // "sourceMaps": true // works with or without this line (supposedly) + // } ] + // "compounds": [ + // { + // "name": "Debug Workers", + // "configurations": ["gateway"], + // "stopAll": true + // } + // ] } diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 6be39f84..2c30aece 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -15,7 +15,9 @@ "test": "run-s test:*", "test:lint": "eslint .", "test:typecheck": "tsc --noEmit", - "test-e2e": "vitest run" + "test-e2e": "vitest run", + "test-http-e2e": "vitest run src/http-e2e.test.ts", + "test-mcp-e2e": "vitest run src/mcp-e2e.test.ts" }, "dependencies": { "ky": "catalog:", @@ -26,6 +28,7 @@ "@agentic/platform-api-client": "workspace:*", "@agentic/platform-core": "workspace:*", "@agentic/platform-fixtures": "workspace:*", - "fast-content-type-parse": "catalog:" + "fast-content-type-parse": "catalog:", + "@modelcontextprotocol/sdk": "catalog:" } } diff --git a/apps/e2e/src/e2e.test.ts b/apps/e2e/src/http-e2e.test.ts similarity index 94% rename from apps/e2e/src/e2e.test.ts rename to apps/e2e/src/http-e2e.test.ts index 75984f74..1c934781 100644 --- a/apps/e2e/src/e2e.test.ts +++ b/apps/e2e/src/http-e2e.test.ts @@ -3,7 +3,7 @@ import defaultKy from 'ky' import { describe, expect, test } from 'vitest' import { env } from './env' -import { fixtureSuites } from './fixtures' +import { fixtureSuites } from './http-fixtures' const ky = defaultKy.extend({ prefixUrl: env.AGENTIC_GATEWAY_BASE_URL, @@ -28,7 +28,8 @@ for (const [i, fixtureSuite] of fixtureSuites.entries()) { status = 200, contentType: expectedContentType = 'application/json', headers: expectedHeaders, - body: expectedBody + body: expectedBody, + validate } = fixture.response ?? {} const snapshot = fixture.response?.snapshot ?? @@ -85,6 +86,10 @@ for (const [i, fixtureSuite] of fixtureSuites.entries()) { expect(body).toEqual(expectedBody) } + if (validate) { + await Promise.resolve(validate(body)) + } + if (snapshot) { expect(body).toMatchSnapshot() } diff --git a/apps/e2e/src/fixtures.ts b/apps/e2e/src/http-fixtures.ts similarity index 99% rename from apps/e2e/src/fixtures.ts rename to apps/e2e/src/http-fixtures.ts index 48843740..a14a07a2 100644 --- a/apps/e2e/src/fixtures.ts +++ b/apps/e2e/src/http-fixtures.ts @@ -26,7 +26,7 @@ export type E2ETestFixture = { contentType?: string headers?: Record body?: any - validate?: (body: any) => void + validate?: (body: any) => void | Promise /** @default true */ snapshot?: boolean } diff --git a/apps/e2e/src/mcp-e2e.test.ts b/apps/e2e/src/mcp-e2e.test.ts new file mode 100644 index 00000000..3a7290c1 --- /dev/null +++ b/apps/e2e/src/mcp-e2e.test.ts @@ -0,0 +1,89 @@ +import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' + +import { env } from './env' +import { fixtureSuites } from './mcp-fixtures' + +for (const [i, fixtureSuite] of fixtureSuites.entries()) { + const { title, fixtures, compareResponseBodies = false } = fixtureSuite + + const describeFn = fixtureSuite.only ? describe.only : describe + describeFn(title, () => { + let fixtureResult: any | undefined + + let client: McpClient + beforeAll(async () => { + client = new McpClient({ + name: fixtureSuite.path, + version: '0.0.0' + }) + + const transport = new StreamableHTTPClientTransport( + new URL(fixtureSuite.path, env.AGENTIC_GATEWAY_BASE_URL) + ) + await client.connect(transport) + }, 120_000) + + afterAll(async () => { + await client.close() + }) + + for (const [j, fixture] of fixtures.entries()) { + const { isError, body: expectedBody, validate } = fixture.response ?? {} + const snapshot = + fixture.response?.snapshot ?? fixtureSuite.snapshot ?? !isError + const debugFixture = !!(fixture.debug ?? fixtureSuite.debug) + const fixtureName = `${i}.${j}: ${fixtureSuite.path} ${fixture.request.name}` + + let testFn = fixture.only ? test.only : test + if (fixtureSuite.sequential) { + testFn = testFn.sequential + } + + testFn( + fixtureName, + { + timeout: fixture.timeout ?? 60_000 + }, + // eslint-disable-next-line no-loop-func + async () => { + const { tools } = await client.listTools() + console.log('tools', tools) + expect(tools.map((t) => t.name)).toContain(fixture.request.name) + + const result = await client.callTool(fixture.request) + if (isError) { + expect(result.isError).toBeTruthy() + } else { + expect(result.isError).toBeFalsy() + } + + if (expectedBody) { + expect(result).toEqual(expectedBody) + } + + if (snapshot) { + expect(result).toMatchSnapshot() + } + + if (validate) { + await Promise.resolve(validate(result)) + } + + if (compareResponseBodies && !isError) { + if (!fixtureResult) { + fixtureResult = result + } else { + expect(result).toEqual(fixtureResult) + } + } + + if (debugFixture) { + console.log(fixtureName, '=>', fixtureResult) + } + } + ) + } + }) +} diff --git a/apps/e2e/src/mcp-fixtures.ts b/apps/e2e/src/mcp-fixtures.ts new file mode 100644 index 00000000..75f3dc8c --- /dev/null +++ b/apps/e2e/src/mcp-fixtures.ts @@ -0,0 +1,110 @@ +export type MCPE2ETestFixture = { + /** @default 60_000 milliseconds */ + timeout?: number + + /** @default false */ + only?: boolean + + /** @default false */ + debug?: boolean + + request: { + name: string + args: Record + } + + response?: { + isError?: boolean + body?: any + validate?: (result: any) => void | Promise + /** @default true */ + snapshot?: boolean + } +} + +export type MCPE2ETestFixtureSuite = { + title: string + path: string + fixtures: MCPE2ETestFixture[] + + /** @default false */ + only?: boolean + + /** @default false */ + sequential?: boolean + + /** @default false */ + compareResponseBodies?: boolean + + /** @default false */ + debug?: boolean + + /** @default undefined */ + snapshot?: boolean +} + +const now = Date.now() + +export const fixtureSuites: MCPE2ETestFixtureSuite[] = [ + { + title: 'Basic MCP => OpenAPI get_post success', + path: '@dev/test-basic-openapi/mcp', + fixtures: [ + { + request: { + name: 'get_post', + args: { + postId: 1 + } + } + } + ] + } + // { + // title: 'Basic MCP => MCP "echo" tool call success', + // path: '@dev/test-basic-mcp/mcp', + // snapshot: false, + // fixtures: [ + // { + // request: { + // name: 'echo', + // args: { + // nala: 'kitten', + // num: 123, + // now + // } + // }, + // response: { + // body: [ + // { + // type: 'text', + // text: JSON.stringify({ nala: 'kitten', num: 123, now }) + // } + // ] + // } + // }, + // { + // request: { + // name: 'echo', + // args: { + // nala: 'kitten', + // num: 123, + // now: `${now}` + // } + // }, + // response: { + // body: [ + // { + // type: 'text', + // text: JSON.stringify({ + // nala: 'kitten', + // num: '123', + // now: `${now}` + // }) + // } + // ] + // } + // } + // ] + // } +] diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 80bc340e..3f23830c 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -20,7 +20,6 @@ "scripts": { "deploy": "wrangler deploy", "dev": "wrangler dev", - "start": "wrangler dev", "cf-clear-cache": "del .wrangler", "clean": "del dist", "test": "run-s test:*", diff --git a/apps/gateway/src/lib/resolve-mcp-edge-request.ts b/apps/gateway/src/lib/resolve-mcp-edge-request.ts index 55c9d8f9..2e2e3e63 100644 --- a/apps/gateway/src/lib/resolve-mcp-edge-request.ts +++ b/apps/gateway/src/lib/resolve-mcp-edge-request.ts @@ -1,6 +1,6 @@ import type { AdminDeployment, PricingPlan } from '@agentic/platform-types' import { assert } from '@agentic/platform-core' -import { parseDeploymentIdentifier } from '@agentic/platform-validators' +import { parseToolIdentifier } from '@agentic/platform-validators' import type { AdminConsumer, GatewayHonoContext } from './types' import { getAdminConsumer } from './get-admin-consumer' @@ -16,7 +16,7 @@ export async function resolveMcpEdgeRequest(ctx: GatewayHonoContext): Promise<{ const requestedDeploymentIdentifier = pathname .replace(/^\//, '') .replace(/\/$/, '') - const { deploymentIdentifier } = parseDeploymentIdentifier( + const { deploymentIdentifier } = parseToolIdentifier( requestedDeploymentIdentifier ) diff --git a/apps/gateway/src/mcp.ts b/apps/gateway/src/mcp.ts index ee77362a..6eeae2a3 100644 --- a/apps/gateway/src/mcp.ts +++ b/apps/gateway/src/mcp.ts @@ -231,6 +231,9 @@ export async function handleMcpRequest(ctx: GatewayHonoContext) { // eslint-disable-next-line unicorn/prefer-add-event-listener transport.onmessage = async (message) => { + // eslint-disable-next-line no-console + console.log('onmessage', message) + // validate that the message is a valid JSONRPC message const result = JSONRPCMessageSchema.safeParse(message) if (!result.success) { @@ -251,6 +254,7 @@ export async function handleMcpRequest(ctx: GatewayHonoContext) { // If we have received all the responses, close the connection if (!requestIds.size) { ctx.executionCtx.waitUntil(transport.close()) + await writer.close() } } diff --git a/apps/gateway/tsconfig.json b/apps/gateway/tsconfig.json index 36350e80..f48bb956 100644 --- a/apps/gateway/tsconfig.json +++ b/apps/gateway/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@fisch0920/config/tsconfig-node", "compilerOptions": { - "types": ["@cloudflare/workers-types/experimental"] + "types": ["@cloudflare/workers-types"] }, "include": ["src", "*.config.ts"], "exclude": ["node_modules", "dist"] diff --git a/apps/gateway/wrangler.jsonc b/apps/gateway/wrangler.jsonc index c381e4ae..4002029c 100644 --- a/apps/gateway/wrangler.jsonc +++ b/apps/gateway/wrangler.jsonc @@ -43,6 +43,8 @@ */ "placement": { "mode": "smart" }, + "upload_source_maps": true, + /** * Bindings * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including diff --git a/packages/hono/src/error-handler.ts b/packages/hono/src/error-handler.ts index 8a90683a..e0bf6069 100644 --- a/packages/hono/src/error-handler.ts +++ b/packages/hono/src/error-handler.ts @@ -68,7 +68,7 @@ export function errorHandler( } /** Error codes defined by the JSON-RPC specification. */ -export declare enum JsonRpcErrorCodes { +export enum JsonRpcErrorCodes { ConnectionClosed = -32_000, RequestTimeout = -32_001, ParseError = -32_700, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d47f718b..15f09f89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -397,6 +397,9 @@ importers: '@agentic/platform-fixtures': specifier: workspace:* version: link:../../packages/fixtures + '@modelcontextprotocol/sdk': + specifier: 'catalog:' + version: 1.12.1 fast-content-type-parse: specifier: 'catalog:' version: 3.0.0