diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/connect-direct-client.mjs b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/connect-direct-client.mjs new file mode 100644 index 000000000..204a3bf20 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/connect-direct-client.mjs @@ -0,0 +1,36 @@ +// Imports an SDK entry, connects a direct client, calls a tool and prints the MCP client modules loaded before and after connecting: node connect-direct-client.mjs +import { registerHooks } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const CLIENT_MODULE = + /\/@modelcontextprotocol\/sdk\/dist\/(esm|cjs)\/client\/|\/protocol\/dist\/(esm\/)?(clients?|lazy-client)\.m?js$/; +const loadedClientModules = []; +registerHooks({ + load(url, context, nextLoad) { + if (CLIENT_MODULE.test(url)) loadedClientModules.push(url); + return nextLoad(url, context); + }, +}); + +const [sdkEntry] = process.argv.slice(2); +const { app, connect, LogLevel, Tool, ToolContext, z } = await import(pathToFileURL(sdkEntry).href); +const beforeConnect = [...loadedClientModules]; + +class GreetTool extends ToolContext { + async execute({ name }) { + return `Hello, ${name}`; + } +} +Tool({ name: 'greet', description: 'Greet someone', inputSchema: { name: z.string() } })(GreetTool); + +const client = await connect({ + info: { name: 'esm-client', version: '1.0.0' }, + apps: [app({ id: 'greetings', name: 'Greetings', tools: [GreetTool] })], + logging: { level: LogLevel.Off }, +}); +try { + const result = await client.callTool('greet', { name: 'FrontMCP' }); + process.stdout.write(JSON.stringify({ beforeConnect, afterConnect: loadedClientModules, result })); +} finally { + await client.close(); +} diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs new file mode 100644 index 000000000..41c1b8cbd --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs @@ -0,0 +1,15 @@ +// Imports a module and prints, as JSON, the URLs of the modules that loaded with it: node loaded-modules.mjs +import { registerHooks } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const [entry] = process.argv.slice(2); +const loaded = []; +registerHooks({ + load(url, context, nextLoad) { + loaded.push(url); + return nextLoad(url, context); + }, +}); + +await import(pathToFileURL(entry).href); +process.stdout.write(JSON.stringify(loaded)); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-client.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-client.e2e.spec.ts new file mode 100644 index 000000000..25de25b81 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-client.e2e.spec.ts @@ -0,0 +1,33 @@ +/** + * An ESM process loads the MCP client the first time a direct client or a remote app connects, as a CommonJS one + * does (#951): importing the SDK's ESM build loads none of it. + * + * Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`. + */ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +import { workspaceRoot } from './helpers/browser-bundle'; + +type ClientLoad = { beforeConnect: string[]; afterConnect: string[]; result: unknown }; + +function connectDirectClient(sdkEntry: string): ClientLoad { + const runner = resolve(__dirname, 'helpers', 'connect-direct-client.mjs'); + return JSON.parse( + execFileSync(process.execPath, [runner, sdkEntry], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }), + ); +} + +describe('the MCP client of the SDK under ESM', () => { + it('loads with the first connect(), not with the SDK', () => { + const { beforeConnect, afterConnect, result } = connectDirectClient( + resolve(workspaceRoot, 'libs/sdk/dist/esm/index.mjs'), + ); + + expect(beforeConnect).toEqual([]); + expect(afterConnect).toEqual( + expect.arrayContaining([expect.stringMatching(/@modelcontextprotocol\/sdk\/dist\/esm\/client\/index\.js$/)]), + ); + expect(result).toMatchObject({ structuredContent: { value: 'Hello, FrontMCP' } }); + }); +}); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts new file mode 100644 index 000000000..666044167 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts @@ -0,0 +1,34 @@ +/** + * An ESM process loads the MCP SDK's JSON Schema validator from the build its MCP server comes from, and no second + * copy from the other build (#951). + * + * Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`. + */ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +import { workspaceRoot } from './helpers/browser-bundle'; + +function modulesLoadedBy(entry: string, pattern: RegExp): string[] { + const runner = resolve(__dirname, 'helpers', 'loaded-modules.mjs'); + const loaded: string[] = JSON.parse( + execFileSync(process.execPath, [runner, entry], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }), + ); + return loaded.filter((url) => pattern.test(url)); +} + +describe("the MCP server's JSON Schema validator under ESM", () => { + it('loads from the same build as the MCP server', () => { + const loaded = modulesLoadedBy( + resolve(workspaceRoot, 'libs/sdk/dist/esm/index.mjs'), + /\/@modelcontextprotocol\/sdk\/dist\/(esm|cjs)\/(server\/index|validation\/ajv-provider)\.js$/, + ); + + const buildsOf = (module: string) => + loaded + .filter((url) => url.endsWith(`/${module}`)) + .map((url) => url.split('/@modelcontextprotocol/sdk/dist/')[1].split('/')[0]); + expect(buildsOf('server/index.js')).toHaveLength(1); + expect(buildsOf('validation/ajv-provider.js')).toEqual(buildsOf('server/index.js')); + }); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 59ef400da..6e3660ba0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -66,7 +66,7 @@ export default [ '@frontmcp/protocol/client', '@frontmcp/testing', '@frontmcp/observability', - '^#(mcp-.+|crypto-.+|async-.+|event-.+|langchain-.+|sse-.+|express-.+|server-.+|stdio-.+|env|runtime-context|path|client|import-computed|js-yaml|semver|uipack-render)$', + '^#(mcp-.+|crypto-.+|async-.+|event-.+|langchain-.+|sse-.+|express-.+|server-.+|stdio-.+|env|runtime-context|path|client|import-computed|js-yaml|semver|uipack-render|protocol-client)$', ], depConstraints: [ { diff --git a/jest.imports-mapper.js b/jest.imports-mapper.js index 5b61f2e83..e514afab5 100644 --- a/jest.imports-mapper.js +++ b/jest.imports-mapper.js @@ -29,4 +29,5 @@ module.exports = { '^#js-yaml$': path.resolve(root, 'libs/sdk/src/third-party/js-yaml.ts'), '^#semver$': path.resolve(root, 'libs/sdk/src/third-party/semver.ts'), '^#uipack-render$': path.resolve(root, 'libs/sdk/src/third-party/uipack-render.ts'), + '^#protocol-client$': path.resolve(root, 'libs/sdk/src/third-party/protocol-client.ts'), }; diff --git a/libs/protocol/src/__tests__/exports.spec.ts b/libs/protocol/src/__tests__/exports.spec.ts index b8ccdc67d..6ab2bc315 100644 --- a/libs/protocol/src/__tests__/exports.spec.ts +++ b/libs/protocol/src/__tests__/exports.spec.ts @@ -6,8 +6,12 @@ import { StdioServerTransport as SdkStdioServerTransport } from '@modelcontextpr import { StreamableHTTPServerTransport as SdkStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { WebStandardStreamableHTTPServerTransport as SdkWebStandardTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js'; +import { AjvJsonSchemaValidator as SdkAjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js'; -import { Server as BrowserServer } from '../browser-mcp-server'; +import { + AjvJsonSchemaValidator as BrowserAjvJsonSchemaValidator, + Server as BrowserServer, +} from '../browser-mcp-server'; import * as browserStreamableHttp from '../browser-mcp-streamable-http'; import * as browserServerTypes from '../browser-server-types'; import { StdioClientTransport as BrowserStdioClientTransport } from '../browser-stdio-client'; @@ -15,7 +19,7 @@ import { StdioServerTransport as BrowserStdioServerTransport } from '../browser- import * as clients from '../clients'; import { InMemoryTransport } from '../in-memory'; import * as protocol from '../index'; -import { Server as NodeServer } from '../node-mcp-server'; +import { AjvJsonSchemaValidator as NodeAjvJsonSchemaValidator, Server as NodeServer } from '../node-mcp-server'; import * as nodeStreamableHttp from '../node-mcp-streamable-http'; import * as nodeServerTypes from '../node-server-types'; import { StdioServerTransport as NodeStdioServerTransport } from '../node-stdio-server'; @@ -26,6 +30,8 @@ describe('@frontmcp/protocol exports', () => { it('re-exports the MCP SDK server, transports and types for Node', () => { expect(NodeServer).toBe(SdkServer); expect(BrowserServer).toBe(SdkServer); + expect(NodeAjvJsonSchemaValidator).toBe(SdkAjvJsonSchemaValidator); + expect(BrowserAjvJsonSchemaValidator).toBe(SdkAjvJsonSchemaValidator); expect(nodeStreamableHttp.StreamableHTTPServerTransport).toBe(SdkStreamableHTTPServerTransport); expect(nodeStreamableHttp.WebStandardStreamableHTTPServerTransport).toBe(SdkWebStandardTransport); expect(NodeStdioServerTransport).toBe(SdkStdioServerTransport); @@ -37,6 +43,7 @@ describe('@frontmcp/protocol exports', () => { it('exports the protocol surface from the package root', () => { expect(protocol.LATEST_PROTOCOL_VERSION).toBe(LATEST_PROTOCOL_VERSION); + expect(protocol.AjvJsonSchemaValidator).toBe(SdkAjvJsonSchemaValidator); expect(protocol.PROTOCOL_2026_07_28).toBe('2026-07-28'); expect(protocol.StdioServerTransport).toBe(SdkStdioServerTransport); }); diff --git a/libs/protocol/src/browser-mcp-server.ts b/libs/protocol/src/browser-mcp-server.ts index 9db31349a..e6080c816 100644 --- a/libs/protocol/src/browser-mcp-server.ts +++ b/libs/protocol/src/browser-mcp-server.ts @@ -1,3 +1,4 @@ // Re-export the real MCP Server for browser builds. // The Server class and InMemoryTransport do not depend on Node.js streams. export { Server, type ServerOptions } from '@modelcontextprotocol/sdk/server/index.js'; +export { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js'; diff --git a/libs/protocol/src/index.ts b/libs/protocol/src/index.ts index 821280807..ff75d2160 100644 --- a/libs/protocol/src/index.ts +++ b/libs/protocol/src/index.ts @@ -26,7 +26,7 @@ export * from './stdio'; export * from './in-memory'; // Server (browser/node conditional via #imports) -export { Server as McpServer, type ServerOptions as McpServerOptions } from '#mcp-server'; +export { Server as McpServer, type ServerOptions as McpServerOptions, AjvJsonSchemaValidator } from '#mcp-server'; export { StreamableHTTPServerTransport, WebStandardStreamableHTTPServerTransport, diff --git a/libs/protocol/src/node-mcp-server.ts b/libs/protocol/src/node-mcp-server.ts index c76893221..1524b840b 100644 --- a/libs/protocol/src/node-mcp-server.ts +++ b/libs/protocol/src/node-mcp-server.ts @@ -1 +1,2 @@ export { Server, type ServerOptions } from '@modelcontextprotocol/sdk/server/index.js'; +export { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js'; diff --git a/libs/sdk/package.json b/libs/sdk/package.json index 14d660015..3b6c6b0d6 100644 --- a/libs/sdk/package.json +++ b/libs/sdk/package.json @@ -54,6 +54,13 @@ "types": "./src/third-party/uipack-render.ts", "require": "./src/third-party/lazy-uipack-render.ts", "default": "./src/third-party/uipack-render.ts" + }, + "#protocol-client": { + "types": "./src/third-party/protocol-client.ts", + "worker": "./src/third-party/protocol-client.ts", + "browser": "./src/third-party/protocol-client.ts", + "import": "./src/third-party/import-protocol-client.ts", + "default": "./src/third-party/protocol-client.ts" } }, "type": "commonjs", diff --git a/libs/sdk/project.json b/libs/sdk/project.json index 2f6e4c2a8..1cc00470f 100644 --- a/libs/sdk/project.json +++ b/libs/sdk/project.json @@ -26,7 +26,9 @@ "libs/sdk/src/third-party/semver.ts", "libs/sdk/src/third-party/lazy-semver.ts", "libs/sdk/src/third-party/uipack-render.ts", - "libs/sdk/src/third-party/lazy-uipack-render.ts" + "libs/sdk/src/third-party/lazy-uipack-render.ts", + "libs/sdk/src/third-party/protocol-client.ts", + "libs/sdk/src/third-party/import-protocol-client.ts" ], "assets": ["libs/sdk/README.md", "LICENSE", "libs/sdk/package.json"], "esbuildOptions": { @@ -36,6 +38,7 @@ "#js-yaml", "#semver", "#uipack-render", + "#protocol-client", "@frontmcp/sdk", "@frontmcp/utils", "@frontmcp/di", diff --git a/libs/sdk/src/direct/direct-client.ts b/libs/sdk/src/direct/direct-client.ts index c96058fcc..63b1df5c7 100644 --- a/libs/sdk/src/direct/direct-client.ts +++ b/libs/sdk/src/direct/direct-client.ts @@ -19,9 +19,10 @@ import { type ReadResourceResult, type ServerCapabilities, } from '@frontmcp/protocol'; -import { Client } from '@frontmcp/protocol/client'; import { fileExists, pathResolve, randomUUID } from '@frontmcp/utils'; +import { loadProtocolClient } from '#protocol-client'; + import { listAllPages } from '../common/utils/list-all-pages.utils'; import { ELICITATION_META_KEY } from '../elicitation/elicitation-meta'; import { PublicMcpError, ToolCallError } from '../errors'; @@ -173,6 +174,7 @@ export class DirectClientImpl implements DirectClient { }, }; + const { Client } = await loadProtocolClient(); const mcpClient = new Client(clientInfo, clientCapabilities); await mcpClient.connect(clientTransport); diff --git a/libs/sdk/src/front-mcp/__tests__/streamable-http-preload.entry-points.spec.ts b/libs/sdk/src/front-mcp/__tests__/streamable-http-preload.entry-points.spec.ts new file mode 100644 index 000000000..36ea3840b --- /dev/null +++ b/libs/sdk/src/front-mcp/__tests__/streamable-http-preload.entry-points.spec.ts @@ -0,0 +1,130 @@ +/** + * Only a server that listens loads the Streamable HTTP transport ahead of its first session (#951). A serverless + * handler, a fetch handler and a stdio server never listen, so their first session loads it, as before. + */ +import 'reflect-metadata'; + +import { App, frontMcpMetadataSchema, LogLevel, Tool, ToolContext } from '../../common'; +import { FrontMcpServerInstance } from '../../server/server.instance'; +import { loadStreamableHttpTransport } from '../../transport/adapters/transport.streamable-http.adapter'; +import { FrontMcpInstance } from '../front-mcp'; + +jest.mock('../../transport/adapters/transport.streamable-http.adapter', () => ({ + ...jest.requireActual('../../transport/adapters/transport.streamable-http.adapter'), + loadStreamableHttpTransport: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@frontmcp/protocol', () => { + const actual = jest.requireActual('@frontmcp/protocol'); + class IdleMcpServer { + setRequestHandler() { + return undefined; + } + setNotificationHandler() { + return undefined; + } + async connect() { + return undefined; + } + async close() { + return undefined; + } + } + class IdleStdioServerTransport { + async close() { + return undefined; + } + } + return { ...actual, McpServer: IdleMcpServer, StdioServerTransport: IdleStdioServerTransport }; +}); + +const loadTransport = loadStreamableHttpTransport as jest.MockedFunction; +const flushImmediates = () => new Promise((resolve) => setImmediate(resolve)); + +@Tool({ name: 'ping', inputSchema: {} }) +class PingTool extends ToolContext { + async execute() { + return { pong: true }; + } +} + +@App({ id: 'ping', name: 'Ping', tools: [PingTool] }) +class PingApp {} + +const config = (name: string) => ({ + info: { name, version: '1.0.0' }, + apps: [PingApp], + logging: { level: LogLevel.Off, enableConsole: false }, +}); + +describe('loading the Streamable HTTP transport ahead of the first session', () => { + const savedSentinel = process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL']; + + beforeEach(() => loadTransport.mockClear()); + + afterAll(() => { + if (savedSentinel === undefined) delete process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL']; + else process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL'] = savedSentinel; + }); + + it('happens after a listening server has started and reported ready', async () => { + process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL'] = '1'; + const instance = new FrontMcpInstance(frontMcpMetadataSchema.parse(config('listening'))); + await instance.ready; + await flushImmediates(); + expect(loadTransport).not.toHaveBeenCalled(); + const start = jest.spyOn(FrontMcpServerInstance.prototype, 'start').mockResolvedValue(undefined); + const write = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await instance.start(); + expect(write).toHaveBeenCalledWith(expect.stringContaining('__FRONTMCP_BOOTSTRAP_COMPLETE__')); + expect(loadTransport).not.toHaveBeenCalled(); + + await flushImmediates(); + expect(loadTransport).toHaveBeenCalledTimes(1); + } finally { + write.mockRestore(); + start.mockRestore(); + } + }); + + it('does not happen for a serverless handler', async () => { + await FrontMcpInstance.createHandler(config('serverless')); + await flushImmediates(); + expect(loadTransport).not.toHaveBeenCalled(); + }); + + it('does not happen for a fetch handler', async () => { + const handler = await FrontMcpInstance.createFetchHandler(config('fetch')); + expect((await handler(new Request('https://example.com/healthz'))).status).toBe(200); + await flushImmediates(); + expect(loadTransport).not.toHaveBeenCalled(); + }); + + describe('for a stdio server', () => { + const savedConsole = { ...console }; + const savedStdioEnv = process.env['FRONTMCP_STDIO']; + const savedLogsMax = process.env['FRONTMCP_LOGS_MAX']; + const signalsBefore = { SIGINT: process.listeners('SIGINT'), SIGTERM: process.listeners('SIGTERM') }; + + afterAll(() => { + Object.assign(console, savedConsole); + if (savedStdioEnv === undefined) delete process.env['FRONTMCP_STDIO']; + else process.env['FRONTMCP_STDIO'] = savedStdioEnv; + if (savedLogsMax === undefined) delete process.env['FRONTMCP_LOGS_MAX']; + else process.env['FRONTMCP_LOGS_MAX'] = savedLogsMax; + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + for (const listener of process.listeners(signal)) { + if (!signalsBefore[signal].includes(listener)) process.removeListener(signal, listener); + } + } + }); + + it('does not happen', async () => { + process.env['FRONTMCP_LOGS_MAX'] = '0'; + await FrontMcpInstance.runStdio(config('stdio')); + await flushImmediates(); + expect(loadTransport).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/sdk/src/front-mcp/front-mcp.ts b/libs/sdk/src/front-mcp/front-mcp.ts index 0ab198a3a..a9e19a6e8 100644 --- a/libs/sdk/src/front-mcp/front-mcp.ts +++ b/libs/sdk/src/front-mcp/front-mcp.ts @@ -1,4 +1,4 @@ -import { McpServer, StdioServerTransport } from '@frontmcp/protocol'; +import { StdioServerTransport } from '@frontmcp/protocol'; import { getEnv, isEdgeRuntime, randomUUID } from '@frontmcp/utils'; import { @@ -25,6 +25,7 @@ import { ScopeRegistry } from '../scope/scope.registry'; import { type FrontMcpServerInstance } from '../server/server.instance'; import { composeCallerInstructions } from '../skill/skill-instructions.helper'; import { computeTaskCapabilities } from '../task/task-scope.helper'; +import { createMcpServer } from '../transport/mcp-server.factory'; import type { FetchHandlerCtx, WebFetchHandler } from '../transport/web-fetch-handler'; import { markDirectOnly } from './direct-only.flag'; import { createMcpGlobalProviders } from './front-mcp.providers'; @@ -894,7 +895,7 @@ export class FrontMcpInstance implements FrontMcpInterface { }; // Create MCP server - const mcpServer = new McpServer(scope.metadata.info, serverOptions); + const mcpServer = createMcpServer(scope.metadata.info, serverOptions); startupCleanups.push(() => mcpServer.close()); // Generate session ID for stdio connection diff --git a/libs/sdk/src/remote-mcp/__tests__/mcp-client.service.lazy-transports.spec.ts b/libs/sdk/src/remote-mcp/__tests__/mcp-client.service.lazy-transports.spec.ts new file mode 100644 index 000000000..e246297b4 --- /dev/null +++ b/libs/sdk/src/remote-mcp/__tests__/mcp-client.service.lazy-transports.spec.ts @@ -0,0 +1,63 @@ +// A remote connection reads only the MCP client exports it uses, so the CommonJS build loads no unused transport (#951) +import { createMockLogger } from '../../__test-utils__/fixtures/flow.fixtures'; +import { McpClientService } from '../mcp-client.service'; + +const mockReadExports: string[] = []; + +jest.mock('#protocol-client', () => { + const actual = jest.requireActual('@frontmcp/protocol/client'); + const recording = {}; + for (const name of ['Client', 'StreamableHTTPClientTransport', 'SSEClientTransport', 'StdioClientTransport']) { + Object.defineProperty(recording, name, { + enumerable: true, + get: () => { + mockReadExports.push(name); + return actual[name]; + }, + }); + } + return { loadProtocolClient: async () => recording }; +}); + +function fakeMcpServer(): void { + jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + if (init?.method !== 'POST') return new Response(null, { status: 405 }); + const message = JSON.parse(String(init.body)) as { id?: number; method: string }; + if (message.id === undefined) return new Response(null, { status: 202 }); + const result = + message.method === 'initialize' + ? { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'u', version: '1' } } + : { tools: [], resources: [], resourceTemplates: [], prompts: [] }; + return new Response(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); +} + +describe('McpClientService — MCP client exports read on connect', () => { + let service: McpClientService; + + beforeEach(() => { + mockReadExports.length = 0; + service = new McpClientService(createMockLogger(), { enableHealthCheck: false, enableAutoReconnect: false }); + }); + + afterEach(async () => { + await service.dispose(); + jest.restoreAllMocks(); + }); + + it('reads the Streamable HTTP transport, not the SSE one, for an HTTP remote that connects', async () => { + fakeMcpServer(); + + await service.connect({ + appId: 'upstream', + name: 'upstream', + transportType: 'http', + url: 'http://remote.test/mcp', + }); + + expect(new Set(mockReadExports)).toEqual(new Set(['Client', 'StreamableHTTPClientTransport'])); + }); +}); diff --git a/libs/sdk/src/remote-mcp/mcp-client.service.ts b/libs/sdk/src/remote-mcp/mcp-client.service.ts index b105e0164..00c11eddd 100644 --- a/libs/sdk/src/remote-mcp/mcp-client.service.ts +++ b/libs/sdk/src/remote-mcp/mcp-client.service.ts @@ -15,7 +15,8 @@ import { type Tool, type Transport, } from '@frontmcp/protocol'; -import { Client, SSEClientTransport, StreamableHTTPClientTransport } from '@frontmcp/protocol/client'; + +import { loadProtocolClient, type ProtocolClientModule } from '#protocol-client'; import { type FrontMcpLogger } from '../common'; import { listAllPages } from '../common/utils/list-all-pages.utils'; @@ -214,10 +215,11 @@ export class McpClientService { } // Create transport based on type - let transport = this.createTransport(request, headers); + const protocolClient = await loadProtocolClient(); + let transport = this.createTransport(protocolClient, request, headers); // Create MCP client with configurable name/version - const client = new Client( + const client = new protocolClient.Client( { name: this.options.clientName, version: this.options.clientVersion, @@ -239,7 +241,7 @@ export class McpClientService { `Streamable HTTP connection failed for ${appId}: ${(connectError as Error).message}. ` + `Falling back to SSE transport.`, ); - transport = this.createFallbackSSETransport(request, headers); + transport = this.createFallbackSSETransport(protocolClient, request, headers); await client.connect(transport); } else { throw connectError; @@ -879,7 +881,11 @@ export class McpClientService { }; } - private createTransport(request: McpConnectRequest, headers: Record | undefined): Transport { + private createTransport( + protocolClient: ProtocolClientModule, + request: McpConnectRequest, + headers: Record | undefined, + ): Transport { const { transportType, url } = request; const options = { requestInit: headers ? { headers } : undefined, @@ -889,10 +895,10 @@ export class McpClientService { switch (transportType) { case 'http': // Start with Streamable HTTP - fallback to SSE happens during connect() if needed - return new StreamableHTTPClientTransport(new URL(url), options); + return new protocolClient.StreamableHTTPClientTransport(new URL(url), options); case 'sse': - return new SSEClientTransport(new URL(url), options); + return new protocolClient.SSEClientTransport(new URL(url), options); case 'worker': case 'npm': @@ -909,6 +915,7 @@ export class McpClientService { * Create fallback SSE transport when Streamable HTTP fails */ private createFallbackSSETransport( + { SSEClientTransport }: ProtocolClientModule, request: McpConnectRequest, headers: Record | undefined, ): Transport { diff --git a/libs/sdk/src/scope/http-scope.helper.ts b/libs/sdk/src/scope/http-scope.helper.ts index ba4e40393..b9cc1b349 100644 --- a/libs/sdk/src/scope/http-scope.helper.ts +++ b/libs/sdk/src/scope/http-scope.helper.ts @@ -2,5 +2,6 @@ export { default as HttpRequestFlow } from './flows/http.request.flow'; export { default as HttpIpFilterFlow } from './flows/http.ip-filter.flow'; export { TransportService } from '../transport/transport.registry'; +export { loadStreamableHttpTransportOnStart } from '../transport/streamable-http-preload'; export { registerCustomHttpRoutes } from '../server/custom-routes.helper'; export { HealthService, servedHealthPaths } from '../health'; diff --git a/libs/sdk/src/scope/scope.instance.ts b/libs/sdk/src/scope/scope.instance.ts index 1fc527aee..2b83a993d 100644 --- a/libs/sdk/src/scope/scope.instance.ts +++ b/libs/sdk/src/scope/scope.instance.ts @@ -323,6 +323,7 @@ export class Scope extends ScopeEntry { } if (httpServing) { this.transportService = new httpServing.TransportService(this, effectivePersistence, transportBus); + httpServing.loadStreamableHttpTransportOnStart(this); } warnIfRequestStateKeyNotShared({ logger: this.logger, metadata: this.metadata }); warnIfSessionModeIgnored({ logger: this.logger, transport: transportConfig, session: this.metadata.session }); diff --git a/libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts b/libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts new file mode 100644 index 000000000..82d5c06e5 --- /dev/null +++ b/libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts @@ -0,0 +1,322 @@ +import * as http from 'node:http'; +import { brotliCompressSync, deflateSync, gzipSync } from 'node:zlib'; + +import type express from 'express'; + +import { ExpressHostAdapter } from '../express.host.adapter'; + +// Mock base.host.adapter to break the import chain into ../../common (which pulls @frontmcp/auth) +jest.mock('../base.host.adapter', () => { + class HostServerAdapter {} + return { HostServerAdapter }; +}); + +type BodyRequest = { + contentType?: string; + contentEncoding?: string; + body: Buffer; + chunked?: boolean; +}; + +type Answer = { status: number; contentType?: string; body: string }; + +const BOM = Buffer.from([0xef, 0xbb, 0xbf]); +const ERROR_PAGE = (message: string) => + `\n\n\n\nError\n\n\n
${message}
\n\n\n`; +const TOO_LARGE = (length?: number) => + JSON.stringify({ + jsonrpc: '2.0', + id: null, + error: { code: -32600, message: 'Payload Too Large', data: { limit: 1024, length } }, + }); + +const send = (url: string, request: BodyRequest): Promise => { + const { hostname, port, pathname } = new URL(url); + const headers: Record = {}; + if (request.contentType) headers['content-type'] = request.contentType; + if (request.contentEncoding) headers['content-encoding'] = request.contentEncoding; + if (!request.chunked) headers['content-length'] = request.body.length; + return new Promise((resolve, reject) => { + const req = http.request({ agent: false, method: 'POST', hostname, port, path: pathname, headers }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + contentType: res.headers['content-type'], + body: Buffer.concat(chunks).toString('utf8'), + }), + ); + }); + req.on('error', reject); + if (request.chunked) { + for (const byte of request.body) req.write(Buffer.from([byte])); + } else { + req.write(request.body); + } + req.end(); + }); +}; + +const startServer = async ( + extend?: (app: express.Application) => void, +): Promise<{ url: string; close: () => Promise }> => { + const adapter = new ExpressHostAdapter({ bodyLimit: '1kb' }); + adapter.registerRoute('POST', '/echo', (req: unknown, res: unknown) => { + const body = (req as { body: unknown }).body; + (res as express.Response).json({ body: body === undefined ? '' : body }); + }); + const app = adapter.getHandler(); + app.set('env', 'production'); + extend?.(app); + const server = http.createServer(app as http.RequestListener); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const { port } = server.address() as { port: number }; + return { + url: `http://127.0.0.1:${port}/echo`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +}; + +const text = (body: string, contentType = 'application/json'): BodyRequest => ({ + contentType, + body: Buffer.from(body), +}); + +const REQUESTS: Record = { + 'a JSON object': text('{"a":1,"b":[true,null,"x"]}'), + 'charset=utf-8': text('{"word":"caf\u00e9 \u{1f600}"}', 'application/json; charset=utf-8'), + 'charset=UTF-8 without a space': text('{"a":1}', 'application/json;charset=UTF-8'), + 'a quoted charset': text('{"a":1}', 'application/json; charset="utf-8"'), + 'an upper-case type': text('{"a":1}', 'APPLICATION/JSON'), + 'a byte order mark': { contentType: 'application/json', body: Buffer.concat([BOM, Buffer.from('{"a":1}')]) }, + 'two byte order marks': { contentType: 'application/json', body: Buffer.concat([BOM, BOM, Buffer.from('{"a":1}')]) }, + 'whitespace before an array': text(' \n\t[1,2]'), + 'an empty body': text(''), + 'only whitespace': text(' \n'), + 'a string literal': text('"text"'), + 'a number literal': text('42'), + 'a true literal': text('true'), + 'a bare word': text(' x{}'), + 'a truncated object': text('{"a":'), + 'an unquoted key': text('{a:1}'), + 'trailing garbage': text('{"a":1}x'), + 'a __proto__ key': text('{"__proto__":{"polluted":true}}'), + 'invalid UTF-8 inside a string': { + contentType: 'application/json', + body: Buffer.from([0x7b, 0x22, 0x61, 0x22, 0x3a, 0x22, 0xff, 0xe2, 0x82, 0x22, 0x7d]), + }, + 'a continuation byte first': { contentType: 'application/json', body: Buffer.from([0x80, 0x7b, 0x7d]) }, + 'multibyte characters one byte per chunk': { + contentType: 'application/json', + body: Buffer.from('{"word":"\u00e9\u20ac\u{1f600}"}'), + chunked: true, + }, + 'charset=utf-16le': { + contentType: 'application/json; charset=utf-16le', + body: Buffer.from('{"word":"caf\u00e9"}', 'utf16le'), + }, + 'charset=latin1': text('{"a":1}', 'application/json; charset=latin1'), + 'charset=utf8': text('{"a":1}', 'application/json; charset=utf8'), + 'charset=utf-9': text('{"a":1}', 'application/json; charset=utf-9'), + 'a parameter other than charset': text('{"a":1}', 'application/json; foo=bar'), + 'a +json suffix type': text('[1]', 'application/json-patch+json'), + 'a vendor +json type': text('[1]', 'application/vnd.api+json'), + 'JSON listed with text': text('{"a":1}', 'application/json, text/plain'), + 'JSON listed with a form': text('a=1', 'application/json, application/x-www-form-urlencoded'), + 'a form': text('a=1&b[c]=2', 'application/x-www-form-urlencoded'), + 'text/plain': text('{"a":1}', 'text/plain'), + 'no content type': { body: Buffer.from('{"a":1}') }, + gzip: { contentType: 'application/json', contentEncoding: 'gzip', body: gzipSync('{"a":1}') }, + deflate: { contentType: 'application/json', contentEncoding: 'deflate', body: deflateSync('{"a":1}') }, + brotli: { contentType: 'application/json', contentEncoding: 'br', body: brotliCompressSync('{"a":1}') }, + 'corrupt gzip': { contentType: 'application/json', contentEncoding: 'gzip', body: Buffer.from('{"a":1}') }, + 'an unsupported encoding': { contentType: 'application/json', contentEncoding: 'compress', body: Buffer.from('{}') }, + 'a body at the limit': text(`{"a":"${'x'.repeat(1016)}"}`), + 'a body over the limit': text(`{"a":"${'x'.repeat(1017)}"}`), + 'a chunked body over the limit': { + contentType: 'application/json', + body: Buffer.from(`{"a":"${'x'.repeat(1017)}"}`), + chunked: true, + }, + 'gzip that inflates over the limit': { + contentType: 'application/json', + contentEncoding: 'gzip', + body: gzipSync(`{"a":"${'x'.repeat(4096)}"}`), + }, +}; + +const echo = (body: unknown): Answer => ({ + status: 200, + contentType: 'application/json; charset=utf-8', + body: JSON.stringify({ body }), +}); +const errorPage = (status: number, message: string): Answer => ({ + status, + contentType: 'text/html; charset=utf-8', + body: ERROR_PAGE(message), +}); +const tooLarge = (length?: number): Answer => ({ + status: 413, + contentType: 'application/json; charset=utf-8', + body: TOO_LARGE(length), +}); + +const ANSWERS: Record = { + 'a JSON object': echo({ a: 1, b: [true, null, 'x'] }), + 'charset=utf-8': echo({ word: 'caf\u00e9 \u{1f600}' }), + 'charset=UTF-8 without a space': echo({ a: 1 }), + 'a quoted charset': echo({ a: 1 }), + 'an upper-case type': echo({ a: 1 }), + 'a byte order mark': echo({ a: 1 }), + 'two byte order marks': errorPage(400, 'Bad Request'), + 'whitespace before an array': echo([1, 2]), + 'an empty body': echo({}), + 'only whitespace': errorPage(400, 'Bad Request'), + 'a string literal': errorPage(400, 'Bad Request'), + 'a number literal': errorPage(400, 'Bad Request'), + 'a true literal': errorPage(400, 'Bad Request'), + 'a bare word': errorPage(400, 'Bad Request'), + 'a truncated object': errorPage(400, 'Bad Request'), + 'an unquoted key': errorPage(400, 'Bad Request'), + 'trailing garbage': errorPage(400, 'Bad Request'), + 'a __proto__ key': { ...echo({}), body: '{"body":{"__proto__":{"polluted":true}}}' }, + 'invalid UTF-8 inside a string': echo({ a: '\ufffd\ufffd' }), + 'a continuation byte first': errorPage(400, 'Bad Request'), + 'multibyte characters one byte per chunk': echo({ word: '\u00e9\u20ac\u{1f600}' }), + 'charset=utf-16le': echo({ word: 'caf\u00e9' }), + 'charset=latin1': errorPage(415, 'Unsupported Media Type'), + 'charset=utf8': errorPage(415, 'Unsupported Media Type'), + 'charset=utf-9': errorPage(415, 'Unsupported Media Type'), + 'a parameter other than charset': echo({ a: 1 }), + 'a +json suffix type': echo([1]), + 'a vendor +json type': echo(''), + 'JSON listed with text': echo({ a: 1 }), + 'JSON listed with a form': echo({ a: '1' }), + 'a form': echo({ a: '1', b: { c: '2' } }), + 'text/plain': echo(''), + 'no content type': echo(''), + gzip: echo({ a: 1 }), + deflate: echo({ a: 1 }), + brotli: echo({ a: 1 }), + 'corrupt gzip': errorPage(400, 'Bad Request'), + 'an unsupported encoding': errorPage(415, 'Unsupported Media Type'), + 'a body at the limit': echo({ a: 'x'.repeat(1016) }), + 'a body over the limit': tooLarge(1025), + 'a chunked body over the limit': tooLarge(), + 'gzip that inflates over the limit': tooLarge(), +}; + +type ErrorShape = Record; + +const describeError = (error: unknown): ErrorShape => { + const shape: ErrorShape = { ...(error as Record) }; + const { name, message, stack } = error as Error; + return { name, message, stackHead: stack?.split('\n')[0], keys: Object.keys(error as object), ...shape }; +}; + +const parseFailed = (name: string, message: string, body: string): ErrorShape => ({ + name, + message, + stackHead: `${name}: ${message}`, + keys: ['expose', 'statusCode', 'status', 'body', 'type'], + expose: true, + statusCode: 400, + status: 400, + body, + type: 'entity.parse.failed', +}); + +const unsupported = (key: string, value: string, message: string, type: string): ErrorShape => ({ + name: 'UnsupportedMediaTypeError', + message, + stackHead: `UnsupportedMediaTypeError: ${message}`, + keys: ['message', key, 'type'], + [key]: value, + type, +}); + +const ERRORS: Record = { + 'only whitespace': parseFailed('SyntaxError', 'Unexpected end of JSON input', ' \n'), + 'a string literal': parseFailed('SyntaxError', 'Unexpected token \'"\', ""text"" is not valid JSON', '"text"'), + 'a number literal': parseFailed('SyntaxError', 'Unexpected token \'4\', "42" is not valid JSON', '42'), + 'a true literal': parseFailed('SyntaxError', 'Unexpected token \'t\', "true" is not valid JSON', 'true'), + 'a bare word': parseFailed('SyntaxError', 'Unexpected token \'x\', " x{}" is not valid JSON', ' x{}'), + 'two byte order marks': parseFailed( + 'SyntaxError', + 'Unexpected token \'\ufeff\', "\ufeff{"a":1}" is not valid JSON', + '\ufeff{"a":1}', + ), + 'a truncated object': parseFailed('SyntaxError', 'Unexpected end of JSON input', '{"a":'), + 'an unquoted key': parseFailed( + 'SyntaxError', + "Expected property name or '}' in JSON at position 1 (line 1 column 2)", + '{a:1}', + ), + 'trailing garbage': parseFailed( + 'SyntaxError', + 'Unexpected non-whitespace character after JSON at position 7 (line 1 column 8)', + '{"a":1}x', + ), + 'a continuation byte first': parseFailed( + 'SyntaxError', + 'Unexpected token \'\ufffd\', "\ufffd{}" is not valid JSON', + '\ufffd{}', + ), + 'charset=latin1': unsupported('charset', 'latin1', 'unsupported charset "LATIN1"', 'charset.unsupported'), + 'charset=utf8': unsupported('charset', 'utf8', 'unsupported charset "UTF8"', 'charset.unsupported'), + 'charset=utf-9': unsupported('charset', 'utf-9', 'unsupported charset "UTF-9"', 'charset.unsupported'), + 'an unsupported encoding': unsupported( + 'encoding', + 'compress', + 'unsupported content encoding "compress"', + 'encoding.unsupported', + ), + 'corrupt gzip': { + name: 'BadRequestError', + message: 'Bad Request', + stackHead: 'BadRequestError: Bad Request', + keys: ['message', 'errno', 'code'], + errno: -3, + code: 'Z_DATA_ERROR', + }, +}; + +describe('ExpressHostAdapter request bodies', () => { + let server: { url: string; close: () => Promise }; + let errorLog: jest.SpyInstance; + + beforeAll(async () => { + errorLog = jest.spyOn(console, 'error').mockImplementation(() => undefined); + server = await startServer(); + }); + + afterAll(async () => { + await server.close(); + errorLog.mockRestore(); + }); + + it.each(Object.keys(REQUESTS))('answers %s as before', async (name) => { + expect(await send(server.url, REQUESTS[name])).toEqual(ANSWERS[name]); + }); +}); + +describe('ExpressHostAdapter request body errors', () => { + let server: { url: string; close: () => Promise }; + + beforeAll(async () => { + server = await startServer((app) => { + app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(299).json(describeError(error)); + }); + }); + }); + + afterAll(() => server.close()); + + it.each(Object.keys(ERRORS))('passes on the same error for %s', async (name) => { + const answer = await send(server.url, REQUESTS[name]); + expect(answer.status).toBe(299); + expect(JSON.parse(answer.body)).toEqual(ERRORS[name]); + }); +}); diff --git a/libs/sdk/src/server/adapters/__tests__/express.host.adapter.utf8-json.spec.ts b/libs/sdk/src/server/adapters/__tests__/express.host.adapter.utf8-json.spec.ts new file mode 100644 index 000000000..e3c1e90c2 --- /dev/null +++ b/libs/sdk/src/server/adapters/__tests__/express.host.adapter.utf8-json.spec.ts @@ -0,0 +1,66 @@ +import * as http from 'node:http'; +import * as path from 'node:path'; + +import type express from 'express'; + +import { ExpressHostAdapter } from '../express.host.adapter'; + +// Mock base.host.adapter to break the import chain into ../../common (which pulls @frontmcp/auth) +jest.mock('../base.host.adapter', () => { + class HostServerAdapter {} + return { HostServerAdapter }; +}); + +const resolveFrom = (name: string, from: string): string => require.resolve(name, { paths: [path.dirname(from)] }); + +// The iconv-lite that body-parser's raw-body decodes with. +const iconvLite = (): { encodings: object | null } => { + const bodyParser = resolveFrom('body-parser', require.resolve('express')); + return require(resolveFrom('iconv-lite', resolveFrom('raw-body', bodyParser))); +}; + +const post = (url: string, contentType: string, body: Buffer): Promise<{ status: number; body: string }> => + new Promise((resolve, reject) => { + const req = http.request( + url, + { agent: false, method: 'POST', headers: { 'content-type': contentType, 'content-length': body.length } }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); + }, + ); + req.on('error', reject); + req.end(body); + }); + +describe('ExpressHostAdapter JSON bodies and iconv-lite', () => { + let server: http.Server; + let url: string; + + beforeAll(async () => { + const adapter = new ExpressHostAdapter(); + adapter.registerRoute('POST', '/echo', (req: unknown, res: unknown) => { + (res as express.Response).json((req as { body: unknown }).body); + }); + server = http.createServer(adapter.getHandler() as http.RequestListener); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + url = `http://127.0.0.1:${(server.address() as { port: number }).port}/echo`; + }); + + afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + + it('parses a UTF-8 JSON body without loading the encodings of iconv-lite', async () => { + expect(await post(url, 'application/json', Buffer.from('{"a":"caf\u00e9"}'))).toEqual({ + status: 200, + body: '{"a":"caf\u00e9"}', + }); + expect(iconvLite().encodings).toBeNull(); + }); + + it('decodes a UTF-16 JSON body with iconv-lite', async () => { + const body = Buffer.from('{"a":1}', 'utf16le'); + expect(await post(url, 'application/json; charset=utf-16le', body)).toEqual({ status: 200, body: '{"a":1}' }); + expect(iconvLite().encodings).not.toBeNull(); + }); +}); diff --git a/libs/sdk/src/server/adapters/__tests__/utf8-json.parser.spec.ts b/libs/sdk/src/server/adapters/__tests__/utf8-json.parser.spec.ts new file mode 100644 index 000000000..518fa3989 --- /dev/null +++ b/libs/sdk/src/server/adapters/__tests__/utf8-json.parser.spec.ts @@ -0,0 +1,157 @@ +import * as http from 'node:http'; + +import express from 'express'; + +import { utf8JsonParser } from '../utf8-json.parser'; + +type Outcome = Record; + +const describeError = (error: unknown): Outcome => { + const { name, message, stack } = error as Error; + return { name, message, stackHead: stack?.split('\n')[0], keys: Object.keys(error as object), ...(error as object) }; +}; + +const listen = async (parsers: express.RequestHandler[]): Promise<{ url: string; server: http.Server }> => { + const app = express(); + app.use(...parsers); + app.post('/', (req, res) => { + res.json({ parsed: req.body === undefined ? '' : req.body }); + }); + app.use((error: unknown, req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.json({ error: describeError(error), body: req.body === undefined ? '' : req.body }); + }); + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + return { url: `http://127.0.0.1:${(server.address() as { port: number }).port}/`, server }; +}; + +const post = (url: string, contentType: string, body: Buffer, chunked: boolean): Promise => + new Promise((resolve, reject) => { + const headers: Record = { 'content-type': contentType }; + if (!chunked) headers['content-length'] = body.length; + const req = http.request(url, { agent: false, method: 'POST', headers }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode, ...JSON.parse(Buffer.concat(chunks).toString('utf8')) })); + }); + req.on('error', reject); + if (chunked) { + for (let offset = 0; offset < body.length; offset += 3) req.write(body.subarray(offset, offset + 3)); + } else { + req.write(body); + } + req.end(); + }); + +const random = (seed: number) => () => { + seed = (seed * 1103515245 + 12345) >>> 0; + return seed / 4294967296; +}; + +const FRAGMENTS = [ + '{', + '}', + '[', + ']', + '"', + ':', + ',', + ' ', + '\n', + '\t', + '\r', + 'a', + '1', + '-', + '.', + 'e', + 'true', + 'null', + '\\', + 'u00', + 'é', + '€', + '\u{1f600}', + '', + '#', +].map((fragment) => Buffer.from(fragment)); +const BYTES = [0xff, 0xc3, 0x80, 0xe2, 0xef, 0xbb, 0xbf].map((byte) => Buffer.from([byte])); + +const generatedBodies = (count: number): Buffer[] => { + const next = random(951); + const pick = (items: T[]): T => items[Math.floor(next() * items.length)]; + const bodies: Buffer[] = []; + for (let i = 0; i < count; i++) { + if (i % 3 === 0) { + const value = { text: pick(['', 'café', '\u{1f600}', 'x'.repeat(Math.floor(next() * 40))]), list: [i, null] }; + const prefix = pick(['', ' ', '', ' \n']); + bodies.push(Buffer.from(prefix + JSON.stringify(i % 2 ? value : [value]))); + } else { + const length = Math.floor(next() * 12); + bodies.push(Buffer.concat(Array.from({ length }, () => (next() < 0.8 ? pick(FRAGMENTS) : pick(BYTES))))); + } + } + return bodies; +}; + +describe('utf8JsonParser', () => { + let stock: { url: string; server: http.Server }; + let utf8: { url: string; server: http.Server }; + + beforeAll(async () => { + stock = await listen([express.json({ limit: '1kb' })]); + utf8 = await listen([utf8JsonParser('1kb'), express.json({ limit: '1kb' })]); + }); + + afterAll(async () => { + for (const { server } of [stock, utf8]) await new Promise((resolve) => server.close(() => resolve())); + }); + + it.each([ + ['application/json', false], + ['application/json; charset=utf-8', true], + ])('parses %s bodies (chunked: %s) as express.json() does', async (contentType, chunked) => { + for (const body of generatedBodies(150)) { + const expected = await post(stock.url, contentType, body, chunked); + expect({ body: body.toString('hex'), outcome: await post(utf8.url, contentType, body, chunked) }).toEqual({ + body: body.toString('hex'), + outcome: expected, + }); + } + }); + + it('leaves alone a body an earlier middleware read, as express.json() does', async () => { + const readEarlier: express.RequestHandler = (req, _res, next) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + req.body = Buffer.concat(chunks); + next(); + }); + }; + const earlier = await listen([readEarlier, utf8JsonParser('1kb'), express.json({ limit: '1kb' })]); + const stockEarlier = await listen([readEarlier, express.json({ limit: '1kb' })]); + try { + const body = Buffer.from('{"a":1}'); + const expected = await post(stockEarlier.url, 'application/json', body, false); + expect(expected).toEqual({ status: 200, parsed: { type: 'Buffer', data: [...body] } }); + expect(await post(earlier.url, 'application/json', body, false)).toEqual(expected); + } finally { + for (const { server } of [earlier, stockEarlier]) + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('leaves a body in another charset to express.json()', async () => { + const body = Buffer.from('{"a":"café"}', 'utf16le'); + expect(await post(utf8.url, 'application/json; charset=utf-16le', body, false)).toEqual({ + status: 200, + parsed: { a: 'café' }, + }); + }); + + it('passes on a read error such as a body over the limit', async () => { + const outcome = await post(utf8.url, 'application/json', Buffer.from(`["${'x'.repeat(1100)}"]`), false); + expect(outcome).toMatchObject({ error: { type: 'entity.too.large', limit: 1024, length: 1104 } }); + }); +}); diff --git a/libs/sdk/src/server/adapters/express.host.adapter.ts b/libs/sdk/src/server/adapters/express.host.adapter.ts index 1dbb10a49..831c6e1bb 100644 --- a/libs/sdk/src/server/adapters/express.host.adapter.ts +++ b/libs/sdk/src/server/adapters/express.host.adapter.ts @@ -32,6 +32,7 @@ import { listenOwnerOnly, } from '../security/unix-socket-guard'; import { HostServerAdapter } from './base.host.adapter'; +import { utf8JsonParser } from './utf8-json.parser'; const SERVER_DRAIN_MS = 5_000; @@ -169,6 +170,7 @@ export class ExpressHostAdapter extends HostServerAdapter { const isJson = (req: http.IncomingMessage): boolean => headerContains(req, 'application/json') && !isUrlencoded(req); this.app.use(express.urlencoded({ extended: true, limit: formLimit, type: isUrlencoded })); + this.app.use(utf8JsonParser(jsonLimit)); this.app.use(express.json({ limit: jsonLimit, type: isJson })); // Translate body-parser's `entity.too.large` (raised when a request body diff --git a/libs/sdk/src/server/adapters/utf8-json.parser.ts b/libs/sdk/src/server/adapters/utf8-json.parser.ts new file mode 100644 index 000000000..ffbe5bc98 --- /dev/null +++ b/libs/sdk/src/server/adapters/utf8-json.parser.ts @@ -0,0 +1,73 @@ +import type * as http from 'node:http'; + +import express from 'express'; + +// The `application/json` bodies body-parser's `json()` reads as UTF-8: no charset, or `charset=utf-8`. +const UTF8_JSON_TYPE = /^application\/json(?:; ?charset=utf-8)?$/i; +const FIRST_NON_WHITESPACE = /^[ \t\n\r]*([^ \t\n\r])/; + +const isUtf8Json = (req: http.IncomingMessage): boolean => UTF8_JSON_TYPE.test(req.headers['content-type'] ?? ''); + +// body-parser's `json()` for UTF-8 bodies, read by its `raw()` but decoded without iconv-lite, which loads all its encodings. +export function utf8JsonParser(limit: number | string): express.RequestHandler { + const readBody = express.raw({ limit, type: isUtf8Json }); + return (req, res, next) => { + const unparsedBody: unknown = req.body; + readBody(req, res, (readError?: unknown) => { + if (readError || req.body === unparsedBody || !Buffer.isBuffer(req.body)) { + next(readError); + return; + } + const text = decodeUtf8(req.body); + try { + req.body = parseStrictJson(text); + } catch (error) { + req.body = unparsedBody; + next(Object.assign(error as SyntaxError, parseFailure(text))); + return; + } + next(); + }); + }; +} + +// What http-errors' `createError(400, error, { body, type })` adds, in its order. +const parseFailure = (body: string) => ({ + expose: true, + statusCode: 400, + status: 400, + body, + type: 'entity.parse.failed', +}); + +// iconv-lite decodes UTF-8 with Node's StringDecoder and drops a leading byte order mark. +function decodeUtf8(body: Buffer): string { + const text = body.toString('utf8'); + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + +function parseStrictJson(text: string): unknown { + if (text.length === 0) return {}; + const first = FIRST_NON_WHITESPACE.exec(text)?.[1]; + if (first !== '{' && first !== '[') throw strictSyntaxError(text, first); + return JSON.parse(text); +} + +// body-parser's strict-mode error: what JSON.parse says about the text with everything from its first character masked. +function strictSyntaxError(text: string, first: string | undefined): SyntaxError { + const index = first === undefined ? -1 : text.indexOf(first); + const error = jsonParseError(index === -1 ? '' : text.slice(0, index).padEnd(text.length, '#')); + const message = error.message.replace(/#+/g, (mask) => text.substring(index, index + mask.length)); + error.stack = error.stack?.replace(error.message, message); + error.message = message; + return error; +} + +function jsonParseError(invalidJson: string): SyntaxError { + try { + JSON.parse(invalidJson); + } catch (error) { + return error as SyntaxError; + } + return new SyntaxError(`Expected invalid JSON: ${invalidJson}`); +} diff --git a/libs/sdk/src/third-party/__tests__/protocol-client.spec.ts b/libs/sdk/src/third-party/__tests__/protocol-client.spec.ts new file mode 100644 index 000000000..889c27635 --- /dev/null +++ b/libs/sdk/src/third-party/__tests__/protocol-client.spec.ts @@ -0,0 +1,15 @@ +import * as client from '@frontmcp/protocol/client'; + +describe('#protocol-client', () => { + it.each([ + ['the static variant', '../protocol-client'], + ['the import variant', '../import-protocol-client'], + ])('%s loads the MCP client and its transports from @frontmcp/protocol/client', async (_variant, path) => { + const { loadProtocolClient } = await import(path); + const loaded = await loadProtocolClient(); + + expect(loaded.Client).toBe(client.Client); + expect(loaded.StreamableHTTPClientTransport).toBe(client.StreamableHTTPClientTransport); + expect(loaded.SSEClientTransport).toBe(client.SSEClientTransport); + }); +}); diff --git a/libs/sdk/src/third-party/import-protocol-client.ts b/libs/sdk/src/third-party/import-protocol-client.ts new file mode 100644 index 000000000..291216410 --- /dev/null +++ b/libs/sdk/src/third-party/import-protocol-client.ts @@ -0,0 +1,4 @@ +// The `import` variant of `#protocol-client`: an ESM build loads the MCP client the first time it connects. +import type { ProtocolClientModule } from './protocol-client'; + +export const loadProtocolClient = (): Promise => import('@frontmcp/protocol/client'); diff --git a/libs/sdk/src/third-party/protocol-client.ts b/libs/sdk/src/third-party/protocol-client.ts new file mode 100644 index 000000000..9e2103442 --- /dev/null +++ b/libs/sdk/src/third-party/protocol-client.ts @@ -0,0 +1,6 @@ +// `#protocol-client` everywhere but an ESM import: `@frontmcp/protocol/client`, whose require() variant loads on first read. +import * as protocolClient from '@frontmcp/protocol/client'; + +export type ProtocolClientModule = typeof protocolClient; + +export const loadProtocolClient = async (): Promise => protocolClient; diff --git a/libs/sdk/src/transport/__tests__/mcp-server.factory.spec.ts b/libs/sdk/src/transport/__tests__/mcp-server.factory.spec.ts new file mode 100644 index 000000000..255469665 --- /dev/null +++ b/libs/sdk/src/transport/__tests__/mcp-server.factory.spec.ts @@ -0,0 +1,79 @@ +import { ElicitRequestSchema, InMemoryTransport, McpServer } from '@frontmcp/protocol'; +import { Client } from '@frontmcp/protocol/client'; + +import { createMcpServer } from '../mcp-server.factory'; + +type AjvConstructor = new (...args: unknown[]) => unknown; + +// The Ajv module the MCP SDK's default JSON Schema validator builds its instance from. +const ajv: { default: AjvConstructor } = require('ajv'); + +const info = { name: 'factory-test', version: '1.0.0' }; +const requestedSchema = { + type: 'object' as const, + properties: { age: { type: 'number' as const } }, + required: ['age'], +}; + +async function connectElicitingClient(server: McpServer, content: Record): Promise { + const client = new Client({ name: 'client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); + client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'accept', content })); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return client; +} + +describe('createMcpServer', () => { + let ajvConstructed: jest.SpyInstance; + + beforeEach(() => { + const OriginalAjv = ajv.default; + ajvConstructed = jest.spyOn(ajv, 'default').mockImplementation(function (...args: unknown[]) { + return new OriginalAjv(...args); + }); + }); + + afterEach(() => ajvConstructed.mockRestore()); + + it('builds no Ajv instance until it validates an elicitation result', async () => { + new McpServer(info, { capabilities: {} }); + expect(ajvConstructed).toHaveBeenCalledTimes(1); + ajvConstructed.mockClear(); + + const server = createMcpServer(info, { capabilities: {} }); + expect(ajvConstructed).not.toHaveBeenCalled(); + const client = await connectElicitingClient(server, { age: 30 }); + ajvConstructed.mockClear(); + + await server.elicitInput({ message: 'Age?', requestedSchema }); + await server.elicitInput({ message: 'Age?', requestedSchema }); + expect(ajvConstructed).toHaveBeenCalledTimes(1); + await client.close(); + }); + + it('accepts an elicitation result that matches the requested schema', async () => { + const server = createMcpServer(info, { capabilities: {} }); + const client = await connectElicitingClient(server, { age: 30 }); + + await expect(server.elicitInput({ message: 'Age?', requestedSchema })).resolves.toEqual({ + action: 'accept', + content: { age: 30 }, + }); + await client.close(); + }); + + it('rejects an elicitation result that does not match the requested schema, as the MCP server does', async () => { + const results = await Promise.all( + [createMcpServer(info, { capabilities: {} }), new McpServer(info, { capabilities: {} })].map(async (server) => { + const client = await connectElicitingClient(server, { age: 'old' }); + const result = await server.elicitInput({ message: 'Age?', requestedSchema }).catch((error: Error) => error); + await client.close(); + return result; + }), + ); + + expect(results[0]).toBeInstanceOf(Error); + expect(results[0]).toEqual(results[1]); + expect((results[0] as Error).message).toContain('Elicitation response content does not match requested schema'); + }); +}); diff --git a/libs/sdk/src/transport/__tests__/streamable-http-preload.spec.ts b/libs/sdk/src/transport/__tests__/streamable-http-preload.spec.ts new file mode 100644 index 000000000..72ea8c274 --- /dev/null +++ b/libs/sdk/src/transport/__tests__/streamable-http-preload.spec.ts @@ -0,0 +1,64 @@ +import { loadStreamableHttpTransport } from '../adapters/transport.streamable-http.adapter'; +import { loadStreamableHttpTransportOnStart } from '../streamable-http-preload'; + +jest.mock('../adapters/transport.streamable-http.adapter', () => ({ + loadStreamableHttpTransport: jest.fn().mockResolvedValue(undefined), +})); + +const loadTransport = loadStreamableHttpTransport as jest.MockedFunction; +const flushImmediates = () => new Promise((resolve) => setImmediate(resolve)); + +function serverWith(protocol: unknown) { + const onStarted: Array<() => void | Promise> = []; + const scope = { + metadata: { transport: { protocol } }, + onServerStarted: (callback: () => void | Promise) => onStarted.push(callback), + logger: { verbose: jest.fn() }, + }; + loadStreamableHttpTransportOnStart(scope as never); + const listen = async () => { + for (const callback of onStarted) await callback(); + await flushImmediates(); + }; + return { scope, listen }; +} + +const ONLY = { sse: false, streamable: false, json: false, stateless: false, legacy: false }; + +describe('loadStreamableHttpTransportOnStart', () => { + beforeEach(() => loadTransport.mockReset().mockResolvedValue(undefined)); + + it.each([ + ['the default', undefined], + ['legacy', 'legacy'], + ['modern', 'modern'], + ['stateless-api', 'stateless-api'], + ['full', 'full'], + ['streamable', { ...ONLY, streamable: true }], + ['stateless', { ...ONLY, stateless: true }], + ['json', { ...ONLY, json: true }], + ])('loads the Streamable HTTP transport once a server with %s protocol listens', async (_name, protocol) => { + const server = serverWith(protocol); + expect(loadTransport).not.toHaveBeenCalled(); + + await server.listen(); + expect(loadTransport).toHaveBeenCalledTimes(1); + }); + + it('leaves it to the first session on a server that serves only legacy SSE', async () => { + await serverWith({ ...ONLY, legacy: true, sse: true }).listen(); + expect(loadTransport).not.toHaveBeenCalled(); + }); + + it('keeps serving when the transport fails to load, which the first session then reports', async () => { + loadTransport.mockRejectedValue(new Error('missing module')); + const server = serverWith('modern'); + + await server.listen(); + await flushImmediates(); + expect(server.scope.logger.verbose).toHaveBeenCalledWith( + 'Streamable HTTP transport preload failed', + expect.objectContaining({ error: expect.any(Error) }), + ); + }); +}); diff --git a/libs/sdk/src/transport/adapters/transport.local.adapter.ts b/libs/sdk/src/transport/adapters/transport.local.adapter.ts index cd97ff25d..8f5261256 100644 --- a/libs/sdk/src/transport/adapters/transport.local.adapter.ts +++ b/libs/sdk/src/transport/adapters/transport.local.adapter.ts @@ -2,7 +2,7 @@ import { type ZodType } from '@frontmcp/lazy-zod'; import { ElicitResultSchema, EmptyResultSchema, - McpServer, + type McpServer, type RequestId, type StreamableHTTPServerTransport, } from '@frontmcp/protocol'; @@ -23,6 +23,7 @@ import { type AuthenticatedServerRequest, type SdkAuthInfo } from '../../server/ import { composeCallerInstructions } from '../../skill/skill-instructions.helper'; import { computeTaskCapabilities } from '../../task/task-scope.helper'; import { createMcpHandlers } from '../mcp-handlers'; +import { createMcpServer } from '../mcp-server.factory'; import { type TransportKey, type TransportType } from '../transport.types'; import { type RecreateableSSEServerTransport } from './sse-transport'; import { type RecreateableStreamableHTTPServerTransport } from './streamable-http-transport'; @@ -249,7 +250,7 @@ export abstract class LocalTransportAdapter { serverInfo: JSON.stringify(serverOptions.serverInfo), }); - this.server = new McpServer(info, serverOptions); + this.server = createMcpServer(info, serverOptions); const handlers = createMcpHandlers({ scope: this.scope, serverOptions, diff --git a/libs/sdk/src/transport/in-memory-server.ts b/libs/sdk/src/transport/in-memory-server.ts index 1ff9b7fe7..09a3e92a9 100644 --- a/libs/sdk/src/transport/in-memory-server.ts +++ b/libs/sdk/src/transport/in-memory-server.ts @@ -11,7 +11,6 @@ import { ErrorCode, InMemoryTransport, McpError, - McpServer, type AuthInfo, type Transport, } from '@frontmcp/protocol'; @@ -23,6 +22,7 @@ import { ElicitationTimeoutError } from '../errors'; import { type Scope } from '../scope/scope.instance'; import { composeCallerInstructions } from '../skill/skill-instructions.helper'; import { buildScopedServerOptions } from './build-scoped-server-options'; +import { createMcpServer } from './mcp-server.factory'; /** * Options for creating an in-memory MCP server. @@ -166,7 +166,7 @@ export async function createInMemoryServer( const serverOptions = buildScopedServerOptions(scope); // Create MCP server - const mcpServer = new McpServer(scope.metadata.info, serverOptions); + const mcpServer = createMcpServer(scope.metadata.info, serverOptions); // Register handlers with auth context injection const composeInstructions = (caller?: { authInfo?: unknown }, clientName?: string) => diff --git a/libs/sdk/src/transport/mcp-server.factory.ts b/libs/sdk/src/transport/mcp-server.factory.ts new file mode 100644 index 000000000..27cd95578 --- /dev/null +++ b/libs/sdk/src/transport/mcp-server.factory.ts @@ -0,0 +1,12 @@ +import { AjvJsonSchemaValidator, McpServer, type Implementation, type McpServerOptions } from '@frontmcp/protocol'; + +// The MCP server builds Ajv to check the elicitation results it requests itself, which FrontMCP never does; build it on first use. +export function createMcpServer(info: Implementation, options: McpServerOptions): McpServer { + let validator: AjvJsonSchemaValidator | undefined; + return new McpServer(info, { + ...options, + jsonSchemaValidator: { + getValidator: (schema) => (validator ??= new AjvJsonSchemaValidator()).getValidator(schema), + }, + }); +} diff --git a/libs/sdk/src/transport/streamable-http-preload.ts b/libs/sdk/src/transport/streamable-http-preload.ts new file mode 100644 index 000000000..ece831d18 --- /dev/null +++ b/libs/sdk/src/transport/streamable-http-preload.ts @@ -0,0 +1,18 @@ +import { toLegacyProtocolFlags } from '../common/types/options/transport/schema'; +import type { Scope } from '../scope/scope.instance'; +import { loadStreamableHttpTransport } from './adapters/transport.streamable-http.adapter'; + +// A listening server loads the Streamable HTTP transport while it waits for clients, not on the first initialize. +export function loadStreamableHttpTransportOnStart(scope: Scope): void { + const { enableStreamableHttp, enableStatelessHttp, enableStatefulHttp } = toLegacyProtocolFlags( + scope.metadata.transport?.protocol, + ); + if (!enableStreamableHttp && !enableStatelessHttp && !enableStatefulHttp) return; + scope.onServerStarted(() => { + setImmediate(() => { + loadStreamableHttpTransport().catch((error: unknown) => + scope.logger.verbose('Streamable HTTP transport preload failed', { error }), + ); + }); + }); +} diff --git a/libs/sdk/src/transport/web-standard-mcp.ts b/libs/sdk/src/transport/web-standard-mcp.ts index 66249d49a..88bf354f8 100644 --- a/libs/sdk/src/transport/web-standard-mcp.ts +++ b/libs/sdk/src/transport/web-standard-mcp.ts @@ -16,13 +16,14 @@ * Auth / quota / router / audit / metrics + hooks have already run in the flow * around this call; this only translates the MCP request↔response. */ -import { McpServer, WebStandardStreamableHTTPServerTransport, type AuthInfo } from '@frontmcp/protocol'; +import { WebStandardStreamableHTTPServerTransport, type AuthInfo } from '@frontmcp/protocol'; import { randomUUID } from '@frontmcp/utils'; import { type Scope } from '../scope/scope.instance'; import { composeCallerInstructions } from '../skill/skill-instructions.helper'; import { mcpTransportBodyLimit } from './body-limit'; import { buildScopedServerOptions, type ScopedServerOptions } from './build-scoped-server-options'; +import { createMcpServer } from './mcp-server.factory'; import { type PersistentSessionOwnerStore } from './persistent-session-owner'; const STATELESS_METHODS = ['POST', 'DELETE']; @@ -65,7 +66,7 @@ async function wireServer( ): Promise { const { createMcpHandlers } = await import('./mcp-handlers/index.js'); - const mcpServer = new McpServer(scope.metadata.info, serverOptions); + const mcpServer = createMcpServer(scope.metadata.info, serverOptions); const composeInstructions = (caller?: { authInfo?: unknown }, clientName?: string) => composeCallerInstructions(scope, { ctx: caller, clientName, skillUriHints: true }); for (const handler of createMcpHandlers({ scope, serverOptions, composeInstructions })) { diff --git a/tsconfig.base.json b/tsconfig.base.json index 2fa0e12e8..3ae01d944 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -123,7 +123,8 @@ "#import-computed": ["libs/sdk/src/esm-loader/import-computed.ts"], "#js-yaml": ["libs/sdk/src/third-party/js-yaml.ts"], "#semver": ["libs/sdk/src/third-party/semver.ts"], - "#uipack-render": ["libs/sdk/src/third-party/uipack-render.ts"] + "#uipack-render": ["libs/sdk/src/third-party/uipack-render.ts"], + "#protocol-client": ["libs/sdk/src/third-party/protocol-client.ts"] } }, "exclude": ["node_modules", "tmp"]