feat: wip gateway mcp work

pull/715/head
Travis Fischer 2025-06-10 03:25:25 +07:00
rodzic b4d6af670e
commit 52101d15a4
13 zmienionych plików z 265 dodań i 11 usunięć

41
.vscode/launch.json vendored
Wyświetl plik

@ -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": ["<node_internals>/**"],
// "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
// }
// ]
}

Wyświetl plik

@ -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:"
}
}

Wyświetl plik

@ -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()
}

Wyświetl plik

@ -26,7 +26,7 @@ export type E2ETestFixture = {
contentType?: string
headers?: Record<string, string>
body?: any
validate?: (body: any) => void
validate?: (body: any) => void | Promise<void>
/** @default true */
snapshot?: boolean
}

Wyświetl plik

@ -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)
}
}
)
}
})
}

Wyświetl plik

@ -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<string, unknown>
}
response?: {
isError?: boolean
body?: any
validate?: (result: any) => void | Promise<void>
/** @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}`
// })
// }
// ]
// }
// }
// ]
// }
]

Wyświetl plik

@ -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:*",

Wyświetl plik

@ -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
)

Wyświetl plik

@ -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()
}
}

Wyświetl plik

@ -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"]

Wyświetl plik

@ -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

Wyświetl plik

@ -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,

Wyświetl plik

@ -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