diff --git a/.changeset/mcp-operation-interceptor.md b/.changeset/mcp-operation-interceptor.md new file mode 100644 index 0000000000..87dde3f9cc --- /dev/null +++ b/.changeset/mcp-operation-interceptor.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add a high-level `aroundMcpRequest` interceptor for tool, resource, and prompt operations. The interceptor runs after routing and input validation and before output validation, result projection, cache hints, and protocol result processing. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d2263d5829..f8fd7c8995 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -55,7 +55,7 @@ export { PerRequestHTTPServerTransport } from './server/perRequestTransport'; // convenience codec consumers drop into ServerOptions.requestState.verify. export type { RequestStateCodec, RequestStateCodecOptions } from './server/requestStateCodec'; export { createRequestStateCodec } from './server/requestStateCodec'; -export type { ServerOptions } from './server/server'; +export type { AroundMcpRequest, McpRequestMethod, ServerOptions } from './server/server'; export { Server } from './server/server'; // subscriptions/listen change-event sourcing seam (protocol revision 2026-07-28). export type { ServerEvent, ServerEventBus, ServerNotifier } from './server/serverEventBus'; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index d2e40181e4..1244979c2a 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -6,6 +6,7 @@ import type { CompleteRequestResourceTemplate, CompleteResult, GetPromptResult, + HandlerResultTypeMap, Icon, Implementation, InputRequiredResult, @@ -16,6 +17,7 @@ import type { Prompt, PromptReference, ReadResourceResult, + RequestTypeMap, Resource, ResourceTemplateReference, Result, @@ -47,7 +49,7 @@ import { import type * as z from 'zod/v4'; import { getCompleter, isCompletable } from './completable'; -import type { ServerOptions } from './server'; +import type { AroundMcpRequest, McpRequestMethod, ServerOptions } from './server'; import { Server } from './server'; /** @@ -69,12 +71,15 @@ export class McpServer { */ public readonly server: Server; + private readonly _aroundMcpRequest?: AroundMcpRequest; + private _registeredResources: { [uri: string]: RegisteredResource } = {}; private _registeredResourceTemplates: { [name: string]: RegisteredResourceTemplate; } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + private readonly _promptExecutors = new WeakMap(); /** * Per-tool JSON-converted `inputSchema`, memoized so the SEP-2243 * registration-time scan and the pre-dispatch validation step share one @@ -116,6 +121,7 @@ export class McpServer { constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); + this._aroundMcpRequest = options?.aroundMcpRequest; // Per the MCP spec, a server that declares a primitive capability MUST respond to its // list method (potentially with an empty result) rather than "Method not found" — even @@ -156,6 +162,16 @@ export class McpServer { await this.server.close(); } + /** Invokes the configured high-level operation interceptor, when present. */ + private async invokeAroundMcpRequest( + method: M, + request: RequestTypeMap[M], + ctx: ServerContext, + next: () => Promise + ): Promise { + return this._aroundMcpRequest ? this._aroundMcpRequest(method, request, ctx, next) : next(); + } + private _toolHandlersInitialized = false; private setToolRequestHandlers() { @@ -177,34 +193,38 @@ export class McpServer { // recommended by the spec. this.server.setRequestHandler( 'tools/list', - (): ListToolsResult => ({ - tools: Object.entries(this._registeredTools) - .filter(([, tool]) => tool.enabled) - .map(([name, tool]): Tool => { - const toolDefinition: Tool = { - name, - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema - ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) - : EMPTY_OBJECT_JSON_SCHEMA, - annotations: tool.annotations, - icons: tool.icons, - execution: tool.execution, - _meta: tool._meta - }; - - if (tool.outputSchema) { - // SEP-2106 legacy interop (non-object outputSchema roots wrapped in - // `{type:'object',properties:{result:},required:['result']}` toward - // 2025-era clients) lives in the 2025 wire codec's `encodeResult('tools/list', …)` - // — this handler is era-blind and emits the natural converted schema. - toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; - } - - return toolDefinition; - }) - }) + (request, ctx): Promise => + this.invokeAroundMcpRequest('tools/list', request, ctx, async () => ({ + tools: Object.entries(this._registeredTools) + .filter(([, tool]) => tool.enabled) + .map(([name, tool]): Tool => { + const toolDefinition: Tool = { + name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema + ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) + : EMPTY_OBJECT_JSON_SCHEMA, + annotations: tool.annotations, + icons: tool.icons, + execution: tool.execution, + _meta: tool._meta + }; + + if (tool.outputSchema) { + // SEP-2106 legacy interop (non-object outputSchema roots wrapped in + // `{type:'object',properties:{result:},required:['result']}` toward + // 2025-era clients) lives in the 2025 wire codec's `encodeResult('tools/list', …)` + // — this handler is era-blind and emits the natural converted schema. + toolDefinition.outputSchema = standardSchemaToJsonSchema( + tool.outputSchema, + 'output' + ) as Tool['outputSchema']; + } + + return toolDefinition; + }) + })) ); this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { @@ -218,7 +238,13 @@ export class McpServer { try { const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); - const result = await this.executeToolHandler(tool, args, ctx); + const validatedRequest: RequestTypeMap['tools/call'] = { + ...request, + params: { ...request.params, arguments: args } + }; + const result = await this.invokeAroundMcpRequest('tools/call', validatedRequest, ctx, () => + this.executeToolHandler(tool, args, ctx) + ); await this.validateToolOutput(tool, result, request.params.name); if (isInputRequiredResult(result)) return result; // SEP-2106 result-side projection (the era-agnostic TextContent auto-append; the @@ -437,43 +463,47 @@ export class McpServer { } }); - this.server.setRequestHandler('resources/list', async (_request, ctx) => { - const resources = Object.entries(this._registeredResources) - .filter(([_, resource]) => resource.enabled) - .map(([uri, resource]) => ({ - uri, - name: resource.name, - ...resource.metadata - })); - - const templateResources: Resource[] = []; - for (const template of Object.values(this._registeredResourceTemplates)) { - if (!template.resourceTemplate.listCallback) { - continue; - } + this.server.setRequestHandler('resources/list', (request, ctx) => + this.invokeAroundMcpRequest('resources/list', request, ctx, async () => { + const resources = Object.entries(this._registeredResources) + .filter(([_, resource]) => resource.enabled) + .map(([uri, resource]) => ({ + uri, + name: resource.name, + ...resource.metadata + })); + + const templateResources: Resource[] = []; + for (const template of Object.values(this._registeredResourceTemplates)) { + if (!template.resourceTemplate.listCallback) { + continue; + } - const result = await template.resourceTemplate.listCallback(ctx); - for (const resource of result.resources) { - templateResources.push({ - ...template.metadata, - // the defined resource metadata should override the template metadata if present - ...resource - }); + const result = await template.resourceTemplate.listCallback(ctx); + for (const resource of result.resources) { + templateResources.push({ + ...template.metadata, + // the defined resource metadata should override the template metadata if present + ...resource + }); + } } - } - return { resources: [...resources, ...templateResources] }; - }); + return { resources: [...resources, ...templateResources] }; + }) + ); - this.server.setRequestHandler('resources/templates/list', async () => { - const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name, template]) => ({ - name, - uriTemplate: template.resourceTemplate.uriTemplate.toString(), - ...template.metadata - })); + this.server.setRequestHandler('resources/templates/list', (request, ctx) => + this.invokeAroundMcpRequest('resources/templates/list', request, ctx, async () => { + const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name, template]) => ({ + name, + uriTemplate: template.resourceTemplate.uriTemplate.toString(), + ...template.metadata + })); - return { resourceTemplates }; - }); + return { resourceTemplates }; + }) + ); this.server.setRequestHandler('resources/read', async (request, ctx) => { let uri: URL; @@ -495,14 +525,26 @@ export class McpServer { // A per-resource cache hint is the most specific configured // author for this result's 2026-07-28 cache fields; it rides a // never-serialized carrier and is resolved at the encode seam. - return attachCacheHintFallback(await resource.readCallback(uri, ctx), resource.cacheHint); + const result = await this.invokeAroundMcpRequest( + 'resources/read', + request, + ctx, + async () => await resource.readCallback(uri, ctx) + ); + return attachCacheHintFallback(result, resource.cacheHint); } // Then check templates for (const template of Object.values(this._registeredResourceTemplates)) { const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); if (variables) { - return attachCacheHintFallback(await template.readCallback(uri, variables, ctx), template.cacheHint); + const result = await this.invokeAroundMcpRequest( + 'resources/read', + request, + ctx, + async () => await template.readCallback(uri, variables, ctx) + ); + return attachCacheHintFallback(result, template.cacheHint); } } @@ -533,20 +575,21 @@ export class McpServer { this.server.setRequestHandler( 'prompts/list', - (): ListPromptsResult => ({ - prompts: Object.entries(this._registeredPrompts) - .filter(([, prompt]) => prompt.enabled) - .map(([name, prompt]): Prompt => { - return { - name, - title: prompt.title, - description: prompt.description, - arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined, - icons: prompt.icons, - _meta: prompt._meta - }; - }) - }) + (request, ctx): Promise => + this.invokeAroundMcpRequest('prompts/list', request, ctx, async () => ({ + prompts: Object.entries(this._registeredPrompts) + .filter(([, prompt]) => prompt.enabled) + .map(([name, prompt]): Prompt => { + return { + name, + title: prompt.title, + description: prompt.description, + arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined, + icons: prompt.icons, + _meta: prompt._meta + }; + }) + })) ); this.server.setRequestHandler('prompts/get', async (request, ctx): Promise => { @@ -559,13 +602,34 @@ export class McpServer { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`); } - // Handler encapsulates parsing and callback invocation with proper types - return prompt.handler(request.params.arguments, ctx); + const args = await this.validatePromptInput(prompt, request.params.arguments, request.params.name); + const executor = this._promptExecutors.get(prompt); + if (!executor) { + throw new ProtocolError(ProtocolErrorCode.InternalError, `Prompt ${request.params.name} has no executor`); + } + return this.invokeAroundMcpRequest('prompts/get', request, ctx, () => executor(args, ctx)); }); this._promptHandlersInitialized = true; } + /** Validates prompt arguments before routing through the operation interceptor. */ + private async validatePromptInput( + prompt: RegisteredPrompt, + args: RequestTypeMap['prompts/get']['params']['arguments'], + promptName: string + ): Promise { + if (!prompt.argsSchema) { + return undefined; + } + + const parseResult = await validateStandardSchema(prompt.argsSchema, args); + if (!parseResult.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${promptName}: ${parseResult.error}`); + } + return parseResult.data; + } + /** * Registers a resource with a config object and callback. * For static resources, use a URI string. For dynamic resources, use a {@linkcode ResourceTemplate}. @@ -742,7 +806,6 @@ export class McpServer { // Track current schema and callback for handler regeneration let currentArgsSchema = argsSchema; let currentCallback = callback; - const registeredPrompt: RegisteredPrompt = { title, description, @@ -777,6 +840,7 @@ export class McpServer { } if (needsHandlerRegen) { registeredPrompt.handler = createPromptHandler(name, currentArgsSchema, currentCallback); + this._promptExecutors.set(registeredPrompt, createPromptExecutor(currentArgsSchema, currentCallback)); } if (updates.enabled !== undefined) registeredPrompt.enabled = updates.enabled; @@ -784,6 +848,7 @@ export class McpServer { } }; this._registeredPrompts[name] = registeredPrompt; + this._promptExecutors.set(registeredPrompt, createPromptExecutor(argsSchema, callback)); // If any argument uses a Completable schema, enable completions capability if (argsSchema) { @@ -1423,6 +1488,9 @@ export type PromptCallback | undefined, ctx: ServerContext) => Promise; +/** Invokes a prompt callback with arguments that have already been validated. */ +type PromptExecutor = (args: unknown, ctx: ServerContext) => Promise; + type ToolCallbackInternal = ( args: unknown, ctx: ServerContext @@ -1461,28 +1529,37 @@ function createPromptHandler( argsSchema: StandardSchemaWithJSON | undefined, callback: PromptCallback ): PromptHandler { + const executor = createPromptExecutor(argsSchema, callback); if (argsSchema) { - const typedCallback = callback as ( - args: unknown, - ctx: ServerContext - ) => GetPromptResult | InputRequiredResult | Promise; - return async (args, ctx) => { const parseResult = await validateStandardSchema(argsSchema, args); if (!parseResult.success) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: ${parseResult.error}`); } - return typedCallback(parseResult.data, ctx); + return executor(parseResult.data, ctx); }; - } else { + } + + return async (_args, ctx) => executor(undefined, ctx); +} + +/** Creates an executor for prompt arguments that were validated by the caller. */ +function createPromptExecutor( + argsSchema: StandardSchemaWithJSON | undefined, + callback: PromptCallback +): PromptExecutor { + if (argsSchema) { const typedCallback = callback as ( + args: unknown, ctx: ServerContext ) => GetPromptResult | InputRequiredResult | Promise; - - return async (_args, ctx) => { - return typedCallback(ctx); - }; + return async (args, ctx) => typedCallback(args, ctx); } + + const typedCallback = callback as ( + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise; + return async (_args, ctx) => typedCallback(ctx); } function createCompletionResult(suggestions: readonly unknown[]): CompleteResult { diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index d69e01c1b2..72c982448a 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -14,6 +14,7 @@ import type { ElicitRequestURLParams, ElicitResult, EmptyResult, + HandlerResultTypeMap, Implementation, InitializeRequest, InitializeResult, @@ -30,6 +31,7 @@ import type { ProtocolOptions, RequestMethod, RequestOptions, + RequestTypeMap, ResourceUpdatedNotification, Result, ServerCapabilities, @@ -72,6 +74,35 @@ import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimO */ const INPUT_REQUIRED_CAPABLE_METHODS: ReadonlySet = new Set(['tools/call', 'prompts/get', 'resources/read']); +/** + * High-level MCP operations that can be intercepted through + * {@linkcode ServerOptions.aroundMcpRequest}. + */ +export type McpRequestMethod = + | 'tools/list' + | 'tools/call' + | 'resources/list' + | 'resources/templates/list' + | 'resources/read' + | 'prompts/list' + | 'prompts/get'; + +/** + * Intercepts a validated high-level MCP operation around its core handler. + * + * The interceptor runs after method-specific input validation and routing. Its + * result then passes through the SDK's normal output validation, projection, + * cache-hint, input-required, and protocol-result processing. Calling + * {@linkcode next} invokes the registered operation handler; omitting it + * short-circuits the operation. + */ +export type AroundMcpRequest = ( + method: M, + request: RequestTypeMap[M], + ctx: ServerContext, + next: () => Promise +) => Promise; + export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. @@ -100,6 +131,15 @@ export type ServerOptions = ProtocolOptions & { */ jsonSchemaValidator?: jsonSchemaValidator; + /** + * Optional interceptor for primitive operations owned by + * {@linkcode server/mcp.McpServer | McpServer}. + * + * The low-level {@linkcode Server} does not apply this callback to handlers + * registered directly with `setRequestHandler`. + */ + aroundMcpRequest?: AroundMcpRequest; + /** * Cache hints for the cacheable results of the 2026-07-28 protocol * revision (`ttlMs` / `cacheScope`), keyed by operation. The cacheable diff --git a/packages/server/test/server/aroundMcpRequest.test.ts b/packages/server/test/server/aroundMcpRequest.test.ts new file mode 100644 index 0000000000..fdb5c293f9 --- /dev/null +++ b/packages/server/test/server/aroundMcpRequest.test.ts @@ -0,0 +1,253 @@ +import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCRequest, JSONRPCResultResponse } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { AroundMcpRequest, HandlerResultTypeMap, McpRequestMethod, RequestTypeMap } from '../../src/index'; +import { McpServer, ResourceTemplate, Server } from '../../src/index'; + +async function wire(server: McpServer | Server) { + const [peerTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const waiters = new Map void>(); + peerTransport.onmessage = message => { + if ('id' in message && message.id !== undefined) { + waiters.get(message.id)?.(message); + waiters.delete(message.id); + } + }; + await server.connect(serverTransport); + await peerTransport.start(); + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTransport.send(message); + }); + + await request({ + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'around-test-client', version: '1.0.0' } + } + }); + await peerTransport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + return { request, close: () => server.close() }; +} + +function resultOf(message: JSONRPCMessage): Record { + return (message as JSONRPCResultResponse).result as Record; +} + +function errorOf(message: JSONRPCMessage): JSONRPCErrorResponse['error'] { + return (message as JSONRPCErrorResponse).error; +} + +function isNamedToolArray(value: unknown): value is Array<{ name: string }> { + return ( + Array.isArray(value) && + value.every(tool => typeof tool === 'object' && tool !== null && 'name' in tool && typeof tool.name === 'string') + ); +} + +describe('ServerOptions.aroundMcpRequest', () => { + it('intercepts exactly the seven high-level primitive operations', async () => { + const methods: McpRequestMethod[] = []; + const server = new McpServer( + { name: 'around-test', version: '1.0.0' }, + { + aroundMcpRequest: async (method, request, _ctx, next) => { + methods.push(method); + const result = await next(); + if (request.method === 'tools/list' && 'tools' in result && isNamedToolArray(result.tools)) { + result.tools = result.tools.filter(tool => !tool.name.startsWith('_')); + } + return result; + } + } + ); + server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'ok' }] })); + server.registerTool('_hidden', {}, async () => ({ content: [{ type: 'text', text: 'hidden' }] })); + server.registerPrompt('hello', {}, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }] + })); + server.registerResource('static', 'file:///static', {}, async uri => ({ + contents: [{ uri: uri.href, text: 'static' }] + })); + server.registerResource('template', new ResourceTemplate('file:///{name}', { list: undefined }), {}, async uri => ({ + contents: [{ uri: uri.href, text: 'template' }] + })); + + const connection = await wire(server); + const requests: JSONRPCRequest[] = [ + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: {} } }, + { jsonrpc: '2.0', id: 3, method: 'resources/list', params: {} }, + { jsonrpc: '2.0', id: 4, method: 'resources/templates/list', params: {} }, + { jsonrpc: '2.0', id: 5, method: 'resources/read', params: { uri: 'file:///static' } }, + { jsonrpc: '2.0', id: 6, method: 'prompts/list', params: {} }, + { jsonrpc: '2.0', id: 7, method: 'prompts/get', params: { name: 'hello' } }, + { jsonrpc: '2.0', id: 8, method: 'ping', params: {} } + ]; + let listResult: Record | undefined; + for (const request of requests) { + const response = await connection.request(request); + if (request.method === 'tools/list') listResult = resultOf(response); + } + + expect(methods).toEqual([ + 'tools/list', + 'tools/call', + 'resources/list', + 'resources/templates/list', + 'resources/read', + 'prompts/list', + 'prompts/get' + ]); + expect(listResult?.tools).toEqual([expect.objectContaining({ name: 'echo' })]); + await connection.close(); + }); + + it('runs after tool routing and input transforms, and before output validation', async () => { + const interceptedArguments: unknown[] = []; + const server = new McpServer( + { name: 'around-test', version: '1.0.0' }, + { + aroundMcpRequest: async (method, request, _ctx, next) => { + if (request.method === 'tools/call') { + interceptedArguments.push(request.params.arguments); + const result = await next(); + if ('structuredContent' in result && request.params.name === 'corrupt') { + result.structuredContent = { value: 42 }; + } + return result; + } + return next(); + } + } + ); + const inputSchema = z.object({ value: z.string().transform(value => Number(value)) }); + const outputSchema = z.object({ value: z.string() }); + server.registerTool('corrupt', { inputSchema, outputSchema }, async ({ value }) => ({ + content: [{ type: 'text', text: String(value) }], + structuredContent: { value: String(value) } + })); + + const connection = await wire(server); + const invalid = await connection.request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'missing', arguments: { value: '3' } } + }); + expect(errorOf(invalid).code).toBe(-32602); + expect(interceptedArguments).toEqual([]); + + const response = resultOf( + await connection.request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'corrupt', arguments: { value: '3' } } + }) + ); + expect(interceptedArguments).toEqual([{ value: 3 }]); + expect(response.isError).toBe(true); + expect(response.content).toEqual([expect.objectContaining({ text: expect.stringContaining('Output validation error') })]); + await connection.close(); + }); + + it('keeps interceptor errors inside the normal tools/call isError conversion', async () => { + const server = new McpServer( + { name: 'around-test', version: '1.0.0' }, + { + aroundMcpRequest: async (method, _request, _ctx, next) => { + if (method === 'tools/call') throw new Error('blocked by interceptor'); + return next(); + } + } + ); + server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'unreachable' }] })); + + const connection = await wire(server); + const result = resultOf( + await connection.request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'echo', arguments: {} } + }) + ); + expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'blocked by interceptor' }] }); + await connection.close(); + }); + + it('validates and routes prompts and resources before interception', async () => { + const intercepted: McpRequestMethod[] = []; + const server = new McpServer( + { name: 'around-test', version: '1.0.0' }, + { + aroundMcpRequest: async (method, _request, _ctx, next) => { + intercepted.push(method); + return next(); + } + } + ); + server.registerPrompt('hello', { argsSchema: z.object({ topic: z.string().min(2) }) }, async ({ topic }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: topic } }] + })); + server.registerResource('static', 'file:///static', {}, async uri => ({ contents: [{ uri: uri.href, text: 'ok' }] })); + + const connection = await wire(server); + const invalidPrompt = await connection.request({ + jsonrpc: '2.0', + id: 1, + method: 'prompts/get', + params: { name: 'hello', arguments: { topic: 'x' } } + }); + const missingResource = await connection.request({ + jsonrpc: '2.0', + id: 2, + method: 'resources/read', + params: { uri: 'file:///missing' } + }); + + expect(errorOf(invalidPrompt).code).toBe(-32602); + expect(errorOf(missingResource).code).toBe(-32602); + expect(intercepted).toEqual([]); + await connection.close(); + }); + + it('does not apply the high-level interceptor to low-level handlers', async () => { + let intercepted = false; + const server = new Server( + { name: 'low-level', version: '1.0.0' }, + { + capabilities: { tools: {} }, + aroundMcpRequest: async (_method, _request, _ctx, next) => { + intercepted = true; + return next(); + } + } + ); + server.setRequestHandler('tools/list', async () => ({ tools: [] })); + + const connection = await wire(server); + expect(resultOf(await connection.request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }))).toEqual({ + tools: [] + }); + expect(intercepted).toBe(false); + await connection.close(); + }); + + it('exports an SDK-derived generic callback contract', () => { + const around: AroundMcpRequest = async (_method, _request, _ctx, next) => next(); + expectTypeOf(around).toEqualTypeOf(); + expectTypeOf().toMatchObjectType<{ method: 'tools/call' }>(); + expectTypeOf().not.toEqualTypeOf(); + }); +});