From 141b69b4ec2a4752721ce28cf59f68df62488086 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Apr 2026 11:58:05 +0000 Subject: [PATCH 01/11] feat(core): add custom request/notification handler API to Protocol Adds setCustomRequestHandler, setCustomNotificationHandler, sendCustomRequest, sendCustomNotification (plus remove* variants) to the Protocol class. These allow registering handlers for vendor-specific methods outside the standard RequestMethod/NotificationMethod unions, with user-provided Zod schemas for param/result validation. Custom handlers share the existing _requestHandlers map and dispatch path, so they receive full context (cancellation, task support, send/notify) for free. Capability checks are skipped for custom methods. Also exports InMemoryTransport from core/public so examples and tests can use createLinkedPair() without depending on the internal core barrel, and adds examples/server/src/customMethodExample.ts demonstrating the API. --- examples/server/package.json | 1 + examples/server/src/customMethodExample.ts | 85 ++++++++++++++++++++ packages/core/src/exports/public/index.ts | 3 + packages/core/src/shared/protocol.ts | 90 ++++++++++++++++++++++ pnpm-lock.yaml | 50 +----------- 5 files changed, 182 insertions(+), 47 deletions(-) create mode 100644 examples/server/src/customMethodExample.ts diff --git a/examples/server/package.json b/examples/server/package.json index fcff95d9a..00de54cfa 100644 --- a/examples/server/package.json +++ b/examples/server/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@hono/node-server": "catalog:runtimeServerOnly", + "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/examples-shared": "workspace:^", "@modelcontextprotocol/express": "workspace:^", "@modelcontextprotocol/hono": "workspace:^", diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts new file mode 100644 index 000000000..ba9c4e840 --- /dev/null +++ b/examples/server/src/customMethodExample.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Demonstrates custom (non-standard) request and notification methods. + * + * The Protocol class exposes setCustomRequestHandler / setCustomNotificationHandler / + * sendCustomRequest / sendCustomNotification for vendor-specific methods that are not + * part of the MCP spec. Params and results are validated against user-provided Zod + * schemas, and handlers receive the same context (cancellation, task support, + * bidirectional send/notify) as standard handlers. + */ + +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, Server } from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +const SearchParamsSchema = z.object({ + query: z.string(), + limit: z.number().int().positive().optional() +}); + +const SearchResultSchema = z.object({ + results: z.array(z.object({ id: z.string(), title: z.string() })), + total: z.number() +}); + +const AnalyticsParamsSchema = z.object({ + event: z.string(), + properties: z.record(z.string(), z.unknown()).optional() +}); + +const AnalyticsResultSchema = z.object({ recorded: z.boolean() }); + +const StatusUpdateParamsSchema = z.object({ + status: z.enum(['idle', 'busy', 'error']), + detail: z.string().optional() +}); + +async function main() { + const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} }); + const client = new Client({ name: 'custom-method-client', version: '1.0.0' }, { capabilities: {} }); + + server.setCustomRequestHandler('acme/search', SearchParamsSchema, async (params, ctx) => { + console.log(`[server] acme/search query="${params.query}" limit=${params.limit ?? 'unset'} (req ${ctx.mcpReq.id})`); + return { + results: [ + { id: 'r1', title: `Result for "${params.query}"` }, + { id: 'r2', title: 'Another result' } + ], + total: 2 + }; + }); + + server.setCustomRequestHandler('acme/analytics', AnalyticsParamsSchema, async params => { + console.log(`[server] acme/analytics event="${params.event}"`); + return { recorded: true }; + }); + + client.setCustomNotificationHandler('acme/statusUpdate', StatusUpdateParamsSchema, params => { + console.log(`[client] acme/statusUpdate status=${params.status} detail=${params.detail ?? ''}`); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + const searchResult = await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 5 }, SearchResultSchema); + console.log(`[client] received ${searchResult.total} results, first: "${searchResult.results[0]?.title}"`); + + const analyticsResult = await client.sendCustomRequest('acme/analytics', { event: 'page_view' }, AnalyticsResultSchema); + console.log(`[client] analytics recorded=${analyticsResult.recorded}`); + + await server.sendCustomNotification('acme/statusUpdate', { status: 'busy', detail: 'indexing' }); + + // Validation error: wrong param type (limit must be a number) + try { + await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 'five' }, SearchResultSchema); + console.error('[client] expected validation error but request succeeded'); + } catch (error) { + console.log(`[client] validation error (expected): ${(error as Error).message}`); + } + + await client.close(); + await server.close(); +} + +await main(); diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 353567ba5..f7a7baf08 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -133,6 +133,9 @@ export type { export { isTerminal } from '../../experimental/tasks/interfaces.js'; export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js'; +// Transport utilities +export { InMemoryTransport } from '../../util/inMemory.js'; + // Validator types and classes export type { StandardSchemaWithJSON } from '../../util/standardSchema.js'; export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 57eab6932..315729350 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1057,6 +1057,96 @@ export abstract class Protocol { removeNotificationHandler(method: NotificationMethod): void { this._notificationHandlers.delete(method); } + + /** + * Registers a handler for a custom (non-standard) request method. + * + * Unlike {@linkcode Protocol.setRequestHandler | setRequestHandler}, this accepts any method + * string and validates incoming params against a user-provided schema instead of an SDK-defined + * one. Capability checks are skipped. The handler receives the same {@linkcode BaseContext | context} + * as standard handlers, including cancellation, task support, and bidirectional send/notify. + */ + setCustomRequestHandler

( + method: string, + paramsSchema: P, + handler: (params: SchemaOutput

, ctx: ContextT) => Result | Promise + ): void { + this._requestHandlers.set(method, (request, ctx) => { + const parsed = parseSchema(paramsSchema, request.params); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); + } + return Promise.resolve(handler(parsed.data, ctx)); + }); + } + + /** + * Removes a custom request handler previously registered with + * {@linkcode Protocol.setCustomRequestHandler | setCustomRequestHandler}. + */ + removeCustomRequestHandler(method: string): void { + this._requestHandlers.delete(method); + } + + /** + * Registers a handler for a custom (non-standard) notification method. + * + * Unlike {@linkcode Protocol.setNotificationHandler | setNotificationHandler}, this accepts any + * method string and validates incoming params against a user-provided schema instead of an + * SDK-defined one. + */ + setCustomNotificationHandler

( + method: string, + paramsSchema: P, + handler: (params: SchemaOutput

) => void | Promise + ): void { + this._notificationHandlers.set(method, notification => { + const parsed = parseSchema(paramsSchema, notification.params); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); + } + return Promise.resolve(handler(parsed.data)); + }); + } + + /** + * Removes a custom notification handler previously registered with + * {@linkcode Protocol.setCustomNotificationHandler | setCustomNotificationHandler}. + */ + removeCustomNotificationHandler(method: string): void { + this._notificationHandlers.delete(method); + } + + /** + * Sends a custom (non-standard) request and waits for a response, validating the result against + * the provided schema. + * + * Unlike {@linkcode Protocol.request | request}, this accepts any method string. Capability + * checks are bypassed when {@linkcode ProtocolOptions.enforceStrictCapabilities} is disabled + * (the default). + */ + sendCustomRequest( + method: string, + params: Record | undefined, + resultSchema: T, + options?: RequestOptions + ): Promise> { + return this._requestWithSchema({ method, params } as Request, resultSchema, options); + } + + /** + * Sends a custom (non-standard) notification. + * + * Unlike {@linkcode Protocol.notification | notification}, this accepts any method string and + * bypasses capability checks by sending directly via the transport. + */ + async sendCustomNotification(method: string, params?: Record, options?: NotificationOptions): Promise { + if (!this._transport) { + throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + } + const jsonrpcNotification: JSONRPCNotification = { jsonrpc: '2.0', method, params }; + await this._transport.send(jsonrpcNotification, options); + } } function isPlainObject(value: unknown): value is Record { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 899586750..42503183b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,6 +339,9 @@ importers: '@hono/node-server': specifier: catalog:runtimeServerOnly version: 1.19.11(hono@4.12.9) + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../../packages/client '@modelcontextprotocol/examples-shared': specifier: workspace:^ version: link:../shared @@ -1706,105 +1709,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2053,70 +2040,60 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.57': resolution: {integrity: sha512-d0kIVezTQtazpyWjiJIn5to8JlwfKITDqwsFv0Xc6s31N16CD2PC/Pl2OtKgS7n8WLOJbfqgIp5ixYzTAxCqMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.57': resolution: {integrity: sha512-E199LPijo98yrLjPCmETx8EF43sZf9t3guSrLee/ej1rCCc3zDVTR4xFfN9BRAapGVl7/8hYqbbiQPTkv73kUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.57': resolution: {integrity: sha512-++EQDpk/UJ33kY/BNsh7A7/P1sr/jbMuQ8cE554ZIy+tCUWCivo9zfyjDUoiMdnxqX6HLJEqqGnbGQOvzm2OMQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-beta.57': resolution: {integrity: sha512-voDEBcNqxbUv/GeXKFtxXVWA+H45P/8Dec4Ii/SbyJyGvCqV1j+nNHfnFUIiRQ2Q40DwPe/djvgYBs9PpETiMA==} @@ -2204,79 +2181,66 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -2560,49 +2524,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} From ab148e5512c99bbb739248d387792bd743fa60fd Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Apr 2026 12:18:34 +0000 Subject: [PATCH 02/11] feat(core): harden custom method handlers for production - Guard setCustom*/removeCustom* against standard MCP method names (throws directing users to setRequestHandler/setNotificationHandler) - Add isRequestMethod/isNotificationMethod runtime predicates - Add comprehensive unit tests (15 cases) for all 6 custom-method APIs - Add ext-apps style example demonstrating mcp-ui/* methods and DOM-style event listeners built on setCustomNotificationHandler - Add @modelcontextprotocol/client path mapping to examples/server tsconfig so the example resolves source instead of dist --- .../server/src/customMethodExtAppsExample.ts | 204 ++++++++++++++++++ examples/server/tsconfig.json | 2 + packages/core/src/shared/protocol.ts | 14 ++ packages/core/src/types/schemas.ts | 14 ++ .../core/test/shared/customMethods.test.ts | 185 ++++++++++++++++ 5 files changed, 419 insertions(+) create mode 100644 examples/server/src/customMethodExtAppsExample.ts create mode 100644 packages/core/test/shared/customMethods.test.ts diff --git a/examples/server/src/customMethodExtAppsExample.ts b/examples/server/src/customMethodExtAppsExample.ts new file mode 100644 index 000000000..764475e7b --- /dev/null +++ b/examples/server/src/customMethodExtAppsExample.ts @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/** + * Demonstrates that the ext-apps (mcp-ui) pattern is fully implementable on top of the v2 + * SDK's custom-method-handler API, without extending Protocol or relying on the v1 generic + * type parameters. + * + * In v1, ext-apps defined `class ProtocolWithEvents<...> extends Protocol` to + * widen the request/notification type unions. In v2, the same is achieved by composing + * setCustomRequestHandler / setCustomNotificationHandler / sendCustomRequest / sendCustomNotification + * on top of the standard Client and Server classes. + */ + +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, Server } from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +// ─────────────────────────────────────────────────────────────────────────────── +// Custom method schemas (mirror the ext-apps spec.types.ts pattern) +// ─────────────────────────────────────────────────────────────────────────────── + +const InitializeParams = z.object({ + protocolVersion: z.string(), + appInfo: z.object({ name: z.string(), version: z.string() }) +}); +const InitializeResult = z.object({ + protocolVersion: z.string(), + hostInfo: z.object({ name: z.string(), version: z.string() }), + hostContext: z.object({ theme: z.enum(['light', 'dark']), locale: z.string() }) +}); + +const OpenLinkParams = z.object({ url: z.url() }); +const OpenLinkResult = z.object({ opened: z.boolean() }); + +const TeardownParams = z.object({ reason: z.string().optional() }); + +const SizeChangedParams = z.object({ width: z.number(), height: z.number() }); +const ToolResultParams = z.object({ toolName: z.string(), content: z.array(z.object({ type: z.string(), text: z.string() })) }); +const HostContextChangedParams = z.object({ theme: z.enum(['light', 'dark']).optional(), locale: z.string().optional() }); + +type AppEventMap = { + toolresult: z.infer; + hostcontextchanged: z.infer; +}; + +// ─────────────────────────────────────────────────────────────────────────────── +// App: wraps Client, exposes typed mcp-ui/* methods + DOM-style events +// (replaces v1's `class App extends ProtocolWithEvents`) +// ─────────────────────────────────────────────────────────────────────────────── + +class App { + readonly client: Client; + private _listeners: { [K in keyof AppEventMap]: ((p: AppEventMap[K]) => void)[] } = { + toolresult: [], + hostcontextchanged: [] + }; + private _hostContext?: z.infer['hostContext']; + + onTeardown?: (params: z.infer) => void | Promise; + + constructor(appInfo: { name: string; version: string }) { + this.client = new Client(appInfo, { capabilities: {} }); + + // Incoming custom request from host + this.client.setCustomRequestHandler('mcp-ui/resourceTeardown', TeardownParams, async params => { + await this.onTeardown?.(params); + return {}; + }); + + // Incoming custom notifications from host -> DOM-style event slots + this.client.setCustomNotificationHandler('mcp-ui/toolResult', ToolResultParams, p => this._dispatch('toolresult', p)); + this.client.setCustomNotificationHandler('mcp-ui/hostContextChanged', HostContextChangedParams, p => { + this._hostContext = { ...this._hostContext!, ...p }; + this._dispatch('hostcontextchanged', p); + }); + } + + addEventListener(event: K, listener: (p: AppEventMap[K]) => void): void { + this._listeners[event].push(listener); + } + + removeEventListener(event: K, listener: (p: AppEventMap[K]) => void): void { + const arr = this._listeners[event]; + const i = arr.indexOf(listener); + if (i !== -1) arr.splice(i, 1); + } + + private _dispatch(event: K, params: AppEventMap[K]): void { + for (const l of this._listeners[event]) l(params); + } + + async connect(transport: Parameters[0]): Promise { + await this.client.connect(transport); + const result = await this.client.sendCustomRequest( + 'mcp-ui/initialize', + { protocolVersion: '2026-01-26', appInfo: { name: 'demo-app', version: '1.0.0' } }, + InitializeResult + ); + this._hostContext = result.hostContext; + await this.client.sendCustomNotification('mcp-ui/initialized', {}); + } + + getHostContext() { + return this._hostContext; + } + + openLink(url: string) { + return this.client.sendCustomRequest('mcp-ui/openLink', { url }, OpenLinkResult); + } + + notifySizeChanged(width: number, height: number) { + return this.client.sendCustomNotification('mcp-ui/sizeChanged', { width, height }); + } +} + +// ─────────────────────────────────────────────────────────────────────────────── +// Host: wraps Server, handles mcp-ui/* requests and emits mcp-ui/* notifications +// ─────────────────────────────────────────────────────────────────────────────── + +class Host { + readonly server: Server; + onSizeChanged?: (p: z.infer) => void; + + constructor() { + this.server = new Server({ name: 'demo-host', version: '1.0.0' }, { capabilities: {} }); + + this.server.setCustomRequestHandler('mcp-ui/initialize', InitializeParams, params => { + console.log(`[host] mcp-ui/initialize from ${params.appInfo.name}@${params.appInfo.version}`); + return { + protocolVersion: params.protocolVersion, + hostInfo: { name: 'demo-host', version: '1.0.0' }, + hostContext: { theme: 'dark', locale: 'en-US' } + }; + }); + + this.server.setCustomRequestHandler('mcp-ui/openLink', OpenLinkParams, params => { + console.log(`[host] mcp-ui/openLink url=${params.url}`); + return { opened: true }; + }); + + this.server.setCustomNotificationHandler('mcp-ui/initialized', z.object({}).optional(), () => { + console.log('[host] mcp-ui/initialized'); + }); + + this.server.setCustomNotificationHandler('mcp-ui/sizeChanged', SizeChangedParams, p => { + console.log(`[host] mcp-ui/sizeChanged ${p.width}x${p.height}`); + this.onSizeChanged?.(p); + }); + } + + notifyToolResult(toolName: string, text: string) { + return this.server.sendCustomNotification('mcp-ui/toolResult', { + toolName, + content: [{ type: 'text', text }] + }); + } + + notifyHostContextChanged(patch: z.infer) { + return this.server.sendCustomNotification('mcp-ui/hostContextChanged', patch); + } + + requestTeardown(reason: string) { + return this.server.sendCustomRequest('mcp-ui/resourceTeardown', { reason }, z.object({})); + } +} + +// ─────────────────────────────────────────────────────────────────────────────── +// Demo +// ─────────────────────────────────────────────────────────────────────────────── + +async function main() { + const host = new Host(); + const app = new App({ name: 'demo-app', version: '1.0.0' }); + + app.addEventListener('toolresult', p => console.log(`[app] toolresult: ${p.toolName} -> "${p.content[0]?.text}"`)); + app.addEventListener('hostcontextchanged', p => console.log(`[app] hostcontextchanged: ${JSON.stringify(p)}`)); + app.onTeardown = p => console.log(`[app] teardown: ${p.reason}`); + host.onSizeChanged = p => console.log(`[host] app resized to ${p.width}x${p.height}`); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await host.server.connect(serverTransport); + await app.connect(clientTransport); + + console.log(`[app] hostContext after init: ${JSON.stringify(app.getHostContext())}`); + + // App -> Host: custom request + const { opened } = await app.openLink('https://example.com'); + console.log(`[app] openLink -> opened=${opened}`); + + // App -> Host: custom notification + await app.notifySizeChanged(800, 600); + + // Host -> App: custom notifications (DOM-style event listeners fire) + await host.notifyToolResult('search', 'found 3 widgets'); + await host.notifyHostContextChanged({ theme: 'light' }); + console.log(`[app] hostContext after change: ${JSON.stringify(app.getHostContext())}`); + + // Host -> App: custom request + await host.requestTeardown('navigation'); + + await app.client.close(); + await host.server.close(); +} + +await main(); diff --git a/examples/server/tsconfig.json b/examples/server/tsconfig.json index e3c0e9477..bf41c2d43 100644 --- a/examples/server/tsconfig.json +++ b/examples/server/tsconfig.json @@ -7,6 +7,8 @@ "*": ["./*"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 315729350..569ded59f 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -40,6 +40,8 @@ import { isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse, + isNotificationMethod, + isRequestMethod, ProtocolError, ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS @@ -1071,6 +1073,9 @@ export abstract class Protocol { paramsSchema: P, handler: (params: SchemaOutput

, ctx: ContextT) => Result | Promise ): void { + if (isRequestMethod(method)) { + throw new Error(`"${method}" is a standard MCP request method. Use setRequestHandler() instead.`); + } this._requestHandlers.set(method, (request, ctx) => { const parsed = parseSchema(paramsSchema, request.params); if (!parsed.success) { @@ -1085,6 +1090,9 @@ export abstract class Protocol { * {@linkcode Protocol.setCustomRequestHandler | setCustomRequestHandler}. */ removeCustomRequestHandler(method: string): void { + if (isRequestMethod(method)) { + throw new Error(`"${method}" is a standard MCP request method. Use removeRequestHandler() instead.`); + } this._requestHandlers.delete(method); } @@ -1100,6 +1108,9 @@ export abstract class Protocol { paramsSchema: P, handler: (params: SchemaOutput

) => void | Promise ): void { + if (isNotificationMethod(method)) { + throw new Error(`"${method}" is a standard MCP notification method. Use setNotificationHandler() instead.`); + } this._notificationHandlers.set(method, notification => { const parsed = parseSchema(paramsSchema, notification.params); if (!parsed.success) { @@ -1114,6 +1125,9 @@ export abstract class Protocol { * {@linkcode Protocol.setCustomNotificationHandler | setCustomNotificationHandler}. */ removeCustomNotificationHandler(method: string): void { + if (isNotificationMethod(method)) { + throw new Error(`"${method}" is a standard MCP notification method. Use removeNotificationHandler() instead.`); + } this._notificationHandlers.delete(method); } diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index 86acf11d7..5f21c4998 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -2209,6 +2209,20 @@ const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options, NotificationSchemaType >; +/** + * Type predicate: returns true if `method` is a standard MCP request method. + */ +export function isRequestMethod(method: string): method is RequestMethod { + return method in requestSchemas; +} + +/** + * Type predicate: returns true if `method` is a standard MCP notification method. + */ +export function isNotificationMethod(method: string): method is NotificationMethod { + return method in notificationSchemas; +} + /** * Gets the Zod schema for a given request method. * The return type is a ZodType that parses to RequestTypeMap[M], allowing callers diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts new file mode 100644 index 000000000..ef795784f --- /dev/null +++ b/packages/core/test/shared/customMethods.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import { ProtocolError, ProtocolErrorCode } from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +async function linkedPair(): Promise<[TestProtocol, TestProtocol]> { + const a = new TestProtocol(); + const b = new TestProtocol(); + const [ta, tb] = InMemoryTransport.createLinkedPair(); + await Promise.all([a.connect(ta), b.connect(tb)]); + return [a, b]; +} + +const SearchParams = z.object({ query: z.string(), limit: z.number().optional() }); +const SearchResult = z.object({ hits: z.array(z.string()), total: z.number() }); +const StatusParams = z.object({ status: z.enum(['idle', 'busy']) }); + +describe('custom request handlers', () => { + let client: TestProtocol; + let server: TestProtocol; + + beforeEach(async () => { + [client, server] = await linkedPair(); + }); + + test('happy path: typed params and result', async () => { + server.setCustomRequestHandler('acme/search', SearchParams, params => { + return { hits: [`result:${params.query}`], total: 1 }; + }); + + const result = await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 5 }, SearchResult); + expect(result.hits).toEqual(['result:widgets']); + expect(result.total).toBe(1); + }); + + test('handler receives full context (signal, mcpReq id)', async () => { + let received: BaseContext | undefined; + server.setCustomRequestHandler('acme/ctx', z.object({}), (_params, ctx) => { + received = ctx; + return {}; + }); + + await client.sendCustomRequest('acme/ctx', {}, z.object({})); + expect(received).toBeDefined(); + expect(received?.mcpReq.signal).toBeInstanceOf(AbortSignal); + expect(received?.mcpReq.id).toBeDefined(); + expect(received?.mcpReq.method).toBe('acme/ctx'); + }); + + test('invalid params -> InvalidParams ProtocolError', async () => { + server.setCustomRequestHandler('acme/search', SearchParams, () => ({ hits: [], total: 0 })); + + await expect(client.sendCustomRequest('acme/search', { query: 123 }, SearchResult)).rejects.toSatisfy( + (e: unknown) => e instanceof ProtocolError && e.code === ProtocolErrorCode.InvalidParams + ); + }); + + test('collision guard: throws on standard request method', () => { + expect(() => server.setCustomRequestHandler('ping', z.object({}), () => ({}))).toThrow(/standard MCP request method/); + expect(() => server.setCustomRequestHandler('tools/call', z.object({}), () => ({}))).toThrow(/standard MCP request method/); + expect(() => server.removeCustomRequestHandler('tools/list')).toThrow(/standard MCP request method/); + }); + + test('removeCustomRequestHandler -> subsequent request fails MethodNotFound', async () => { + server.setCustomRequestHandler('acme/search', SearchParams, () => ({ hits: [], total: 0 })); + await client.sendCustomRequest('acme/search', { query: 'x' }, SearchResult); + + server.removeCustomRequestHandler('acme/search'); + await expect(client.sendCustomRequest('acme/search', { query: 'x' }, SearchResult)).rejects.toSatisfy( + (e: unknown) => e instanceof ProtocolError && e.code === ProtocolErrorCode.MethodNotFound + ); + }); + + test('double-register -> last wins', async () => { + server.setCustomRequestHandler('acme/v', z.object({}), () => ({ v: 1 })); + server.setCustomRequestHandler('acme/v', z.object({}), () => ({ v: 2 })); + const result = await client.sendCustomRequest('acme/v', {}, z.object({ v: z.number() })); + expect(result.v).toBe(2); + }); +}); + +describe('custom notification handlers', () => { + let client: TestProtocol; + let server: TestProtocol; + + beforeEach(async () => { + [client, server] = await linkedPair(); + }); + + test('handler invoked with typed params', async () => { + const received: string[] = []; + client.setCustomNotificationHandler('acme/status', StatusParams, params => { + received.push(params.status); + }); + + await server.sendCustomNotification('acme/status', { status: 'busy' }); + await server.sendCustomNotification('acme/status', { status: 'idle' }); + await vi.waitFor(() => expect(received).toEqual(['busy', 'idle'])); + }); + + test('collision guard: throws on standard notification method', () => { + expect(() => client.setCustomNotificationHandler('notifications/cancelled', z.object({}), () => {})).toThrow( + /standard MCP notification method/ + ); + expect(() => client.setCustomNotificationHandler('notifications/progress', z.object({}), () => {})).toThrow( + /standard MCP notification method/ + ); + expect(() => client.removeCustomNotificationHandler('notifications/initialized')).toThrow(/standard MCP notification method/); + }); + + test('removeCustomNotificationHandler -> subsequent notifications not delivered', async () => { + const handler = vi.fn(); + client.setCustomNotificationHandler('acme/status', StatusParams, handler); + await server.sendCustomNotification('acme/status', { status: 'busy' }); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + + client.removeCustomNotificationHandler('acme/status'); + await server.sendCustomNotification('acme/status', { status: 'idle' }); + // Give the event loop a tick; handler should not be called again. + await new Promise(r => setTimeout(r, 10)); + expect(handler).toHaveBeenCalledTimes(1); + }); + + test('invalid params -> handler not invoked, error surfaced via onerror', async () => { + const handler = vi.fn(); + const errors: Error[] = []; + client.setCustomNotificationHandler('acme/status', StatusParams, handler); + client.onerror = e => errors.push(e); + + await server.sendCustomNotification('acme/status', { status: 'unknown' }); + await vi.waitFor(() => expect(errors.length).toBeGreaterThan(0)); + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe('sendCustomRequest', () => { + test('not connected -> rejects', async () => { + const proto = new TestProtocol(); + await expect(proto.sendCustomRequest('acme/x', {}, z.object({}))).rejects.toThrow(/Not connected/); + }); + + test('undefined params accepted', async () => { + const [client, server] = await linkedPair(); + server.setCustomRequestHandler('acme/noargs', z.undefined().or(z.object({})), () => ({ ok: true })); + const result = await client.sendCustomRequest('acme/noargs', undefined, z.object({ ok: z.boolean() })); + expect(result.ok).toBe(true); + }); + + test('result validated against resultSchema', async () => { + const [client, server] = await linkedPair(); + server.setCustomRequestHandler('acme/badresult', z.object({}), () => ({ hits: 'not-an-array', total: 0 })); + await expect(client.sendCustomRequest('acme/badresult', {}, SearchResult)).rejects.toThrow(); + }); +}); + +describe('sendCustomNotification', () => { + test('not connected -> throws SdkError NotConnected', async () => { + const proto = new TestProtocol(); + await expect(proto.sendCustomNotification('acme/x', {})).rejects.toSatisfy( + (e: unknown) => e instanceof SdkError && e.code === SdkErrorCode.NotConnected + ); + }); + + test('delivered to peer with no handler -> no error thrown on sender', async () => { + const [client, server] = await linkedPair(); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await expect(server.sendCustomNotification('acme/unhandled', { x: 1 })).resolves.toBeUndefined(); + }); +}); From a12b19fcece6ae9a43029c138f228642a1956a70 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Apr 2026 12:23:26 +0000 Subject: [PATCH 03/11] fix: use Object.hasOwn in isRequestMethod/isNotificationMethod to avoid prototype-chain false positives --- packages/core/src/types/schemas.ts | 4 ++-- packages/core/test/shared/customMethods.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index 5f21c4998..4743f4f25 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -2213,14 +2213,14 @@ const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options, * Type predicate: returns true if `method` is a standard MCP request method. */ export function isRequestMethod(method: string): method is RequestMethod { - return method in requestSchemas; + return Object.hasOwn(requestSchemas, method); } /** * Type predicate: returns true if `method` is a standard MCP notification method. */ export function isNotificationMethod(method: string): method is NotificationMethod { - return method in notificationSchemas; + return Object.hasOwn(notificationSchemas, method); } /** diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index ef795784f..38df2adfa 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -76,6 +76,13 @@ describe('custom request handlers', () => { expect(() => server.removeCustomRequestHandler('tools/list')).toThrow(/standard MCP request method/); }); + test('collision guard: does NOT trigger on Object.prototype keys', () => { + for (const m of ['toString', 'constructor', 'hasOwnProperty', '__proto__']) { + expect(() => server.setCustomRequestHandler(m, z.object({}), () => ({}))).not.toThrow(); + expect(() => server.setCustomNotificationHandler(m, z.object({}), () => {})).not.toThrow(); + } + }); + test('removeCustomRequestHandler -> subsequent request fails MethodNotFound', async () => { server.setCustomRequestHandler('acme/search', SearchParams, () => ({ hits: [], total: 0 })); await client.sendCustomRequest('acme/search', { query: 'x' }, SearchResult); From 96bbd2f88202e9db930f8f40357a85db7941f141 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Apr 2026 12:34:36 +0000 Subject: [PATCH 04/11] feat(core): route sendCustomNotification through notification(); add typed-params overloads; migration docs - sendCustomNotification now delegates to notification() so debouncing and task-queued delivery apply to custom methods - sendCustomRequest/sendCustomNotification gain a {params, result}/{params} schema-bundle overload that validates outbound params before sending - clarify JSDoc: capability checks are a no-op for custom methods regardless of enforceStrictCapabilities - add migration.md / migration-SKILL.md sections for custom protocol methods --- docs/migration-SKILL.md | 93 +++++++++++-------- docs/migration.md | 89 ++++++++++++++---- packages/core/src/shared/protocol.ts | 79 +++++++++++++--- .../core/test/shared/customMethods.test.ts | 56 +++++++++++ 4 files changed, 248 insertions(+), 69 deletions(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index f581c0cb6..76919bad9 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -36,13 +36,13 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table. ### Client imports -| v1 import path | v2 package | -| ---------------------------------------------------- | ------------------------------ | -| `@modelcontextprotocol/sdk/client/index.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/auth.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/streamableHttp.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/sse.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/stdio.js` | `@modelcontextprotocol/client` | +| v1 import path | v2 package | +| ---------------------------------------------------- | ------------------------------------------------------------------------------ | +| `@modelcontextprotocol/sdk/client/index.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/auth.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/streamableHttp.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/sse.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/stdio.js` | `@modelcontextprotocol/client` | | `@modelcontextprotocol/sdk/client/websocket.js` | REMOVED (use Streamable HTTP or stdio; implement `Transport` for custom needs) | ### Server imports @@ -59,8 +59,8 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table. ### Types / shared imports -| v1 import path | v2 package | -| ------------------------------------------------- | ---------------------------- | +| v1 import path | v2 package | +| ------------------------------------------------- | ---------------------------------------------------------------- | | `@modelcontextprotocol/sdk/types.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | | `@modelcontextprotocol/sdk/shared/protocol.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | | `@modelcontextprotocol/sdk/shared/transport.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | @@ -81,22 +81,22 @@ Notes: ## 5. Removed / Renamed Type Aliases and Symbols -| v1 (removed) | v2 (replacement) | -| ---------------------------------------- | -------------------------------------------------------- | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | -| `isJSONRPCResponse` | `isJSONRPCResultResponse` | -| `ResourceReference` | `ResourceTemplateReference` | -| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | -| `IsomorphicHeaders` | REMOVED (use Web Standard `Headers`) | +| v1 (removed) | v2 (replacement) | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` | `isJSONRPCResultResponse` | +| `ResourceReference` | `ResourceTemplateReference` | +| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | +| `IsomorphicHeaders` | REMOVED (use Web Standard `Headers`) | | `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | -| `McpError` | `ProtocolError` | -| `ErrorCode` | `ProtocolErrorCode` | -| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | -| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | -| `StreamableHTTPError` | REMOVED (use `SdkError` with `SdkErrorCode.ClientHttp*`) | -| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | +| `McpError` | `ProtocolError` | +| `ErrorCode` | `ProtocolErrorCode` | +| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | +| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | +| `StreamableHTTPError` | REMOVED (use `SdkError` with `SdkErrorCode.ClientHttp*`) | +| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | All other symbols from `@modelcontextprotocol/sdk/types.js` retain their original names (e.g., `CallToolResultSchema`, `ListToolsResultSchema`, etc.). @@ -210,7 +210,8 @@ Zod schemas, all callback return types. Note: `callTool()` and `request()` signa The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object. -**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with `fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. +**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with +`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. ### Tools @@ -280,20 +281,20 @@ Note: the third argument (`metadata`) is required — pass `{}` if no metadata. ### Schema Migration Quick Reference -| v1 (raw shape) | v2 (Standard Schema object) | -|----------------|-----------------| -| `{ name: z.string() }` | `z.object({ name: z.string() })` | +| v1 (raw shape) | v2 (Standard Schema object) | +| ---------------------------------- | -------------------------------------------- | +| `{ name: z.string() }` | `z.object({ name: z.string() })` | | `{ count: z.number().optional() }` | `z.object({ count: z.number().optional() })` | | `{}` (empty) | `z.object({})` | | `undefined` (no schema) | `undefined` or omit the field | ### Removed core exports -| Removed from `@modelcontextprotocol/core` | Replacement | -|---|---| -| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | -| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | -| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | +| Removed from `@modelcontextprotocol/core` | Replacement | +| ------------------------------------------------------------------------------------ | ----------------------------------------- | +| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | +| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | +| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | | `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | none (internal Zod introspection helpers) | ## 7. Headers API @@ -377,6 +378,18 @@ Schema to method string mapping: Request/notification params remain fully typed. Remove unused schema imports after migration. +**Custom (non-standard) methods** — vendor extensions or sub-protocols whose method strings are not in the MCP spec — are no longer accepted by `setRequestHandler`/`setNotificationHandler`. Use the `*Custom*` API instead: + +| v1 | v2 | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------ | +| `setRequestHandler(CustomReqSchema, (req, extra) => ...)` | `setCustomRequestHandler('vendor/method', ParamsSchema, (params, ctx) => ...)` | +| `setNotificationHandler(CustomNotifSchema, n => ...)` | `setCustomNotificationHandler('vendor/method', ParamsSchema, params => ...)` | +| `this.request({ method: 'vendor/x', params }, ResultSchema)` | `this.sendCustomRequest('vendor/x', params, ResultSchema)` | +| `this.notification({ method: 'vendor/x', params })` | `this.sendCustomNotification('vendor/x', params)` | +| `class X extends Protocol` | `class X extends Client` (or `Server`), or compose a `Client` instance | + +The v1 schema's `.shape.params` becomes the `ParamsSchema` argument; the `method: z.literal('...')` value becomes the string argument. + ## 10. Request Handler Context Types `RequestHandlerExtra` → structured context types with nested groups. Rename `extra` → `ctx` in all handler callbacks. @@ -439,18 +452,18 @@ Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResu `TaskCreationParams.ttl` changed from `z.union([z.number(), z.null()]).optional()` to `z.number().optional()`. Per the MCP spec, `null` TTL (unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Omit `ttl` to let the server decide. -| v1 | v2 | -|---|---| -| `task: { ttl: null }` | `task: {}` (omit ttl) | +| v1 | v2 | +| ---------------------- | ---------------------------------- | +| `task: { ttl: null }` | `task: {}` (omit ttl) | | `task: { ttl: 60000 }` | `task: { ttl: 60000 }` (unchanged) | Type changes in handler context: -| Type | v1 | v2 | -|---|---|---| -| `TaskContext.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | +| Type | v1 | v2 | +| ------------------------------------------- | ----------------------------- | --------------------- | +| `TaskContext.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | | `CreateTaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | -| `TaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | +| `TaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | > These task APIs are `@experimental` and may change without notice. diff --git a/docs/migration.md b/docs/migration.md index 14fc719db..3d61629a3 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -57,7 +57,8 @@ import { McpServer, StdioServerTransport, WebStandardStreamableHTTPServerTranspo import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; ``` -Note: `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so you can import types and error classes from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly — it is an internal package. +Note: `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so you can import types and error classes from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly +— it is an internal package. ### Dropped Node.js 18 and CommonJS @@ -294,11 +295,11 @@ This applies to: **Removed Zod-specific helpers** from `@modelcontextprotocol/core` (use Standard Schema equivalents): -| Removed | Replacement | -|---|---| -| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | -| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | -| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | +| Removed | Replacement | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | +| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | +| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | | `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | No replacement — these are now internal Zod introspection helpers | ### Host header validation moved @@ -382,6 +383,59 @@ Common method string replacements: | `ResourceListChangedNotificationSchema` | `'notifications/resources/list_changed'` | | `PromptListChangedNotificationSchema` | `'notifications/prompts/list_changed'` | +### Custom (non-standard) protocol methods + +In v1, `setRequestHandler` accepted any Zod schema with a `method: z.literal('...')` shape, so vendor-specific methods (e.g. `mcp-ui/initialize`) could be registered the same way as spec methods. The `Protocol` generics widened the +send-side types to match. + +In v2, `setRequestHandler`/`setNotificationHandler` accept only standard MCP method strings, and the class-level send-side generics have been removed. For methods outside the MCP spec, use the dedicated `*Custom*` methods on `Client` and `Server` (inherited from `Protocol`): + +**Before (v1):** + +```typescript +import { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js'; + +const SearchRequestSchema = z.object({ + method: z.literal('acme/search'), + params: z.object({ query: z.string() }) +}); + +class App extends Protocol { + constructor() { + super(); + this.setRequestHandler(SearchRequestSchema, req => ({ hits: [req.params.query] })); + } + search(query: string) { + return this.request({ method: 'acme/search', params: { query } }, SearchResultSchema); + } +} +``` + +**After (v2):** + +```typescript +import { Client } from '@modelcontextprotocol/client'; + +const SearchParams = z.object({ query: z.string() }); +const SearchResult = z.object({ hits: z.array(z.string()) }); + +class App extends Client { + constructor() { + super({ name: 'app', version: '1.0.0' }); + this.setCustomRequestHandler('acme/search', SearchParams, params => ({ hits: [params.query] })); + } + search(query: string) { + return this.sendCustomRequest('acme/search', { query }, { params: SearchParams, result: SearchResult }); + } +} +``` + +Custom handlers share the same dispatch path as standard handlers — context, cancellation, task delivery, and error wrapping all apply. Passing a `{ params, result }` schema bundle to `sendCustomRequest` (or `{ params }` to `sendCustomNotification`) validates outbound params +before sending and gives typed `params`; passing a bare result schema sends params unvalidated. + +For larger sub-protocols where neither side is semantically an MCP client or server, prefer composition: hold a `Client` (or `Server`) instance, register custom handlers on it, and expose typed facade methods. See `examples/server/src/customMethodExtAppsExample.ts` for a worked +example. + ### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer take a schema parameter The public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer accept a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to import result schemas @@ -477,15 +531,15 @@ For **production in-process connections**, use `StreamableHTTPClientTransport` w The following deprecated type aliases have been removed from `@modelcontextprotocol/core`: -| Removed | Replacement | -| ---------------------------------------- | ------------------------------------------------ | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | -| `isJSONRPCResponse` | `isJSONRPCResultResponse` | -| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | -| `ResourceReference` | `ResourceTemplateReference` | -| `IsomorphicHeaders` | Use Web Standard `Headers` | +| Removed | Replacement | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` | `isJSONRPCResultResponse` | +| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | +| `ResourceReference` | `ResourceTemplateReference` | +| `IsomorphicHeaders` | Use Web Standard `Headers` | | `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | All other types and schemas exported from `@modelcontextprotocol/sdk/types.js` retain their original names — import them from `@modelcontextprotocol/client` or `@modelcontextprotocol/server`. @@ -516,7 +570,7 @@ The `RequestHandlerExtra` type has been replaced with a structured context type | `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | `extra.authInfo` | `ctx.http?.authInfo` | -| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) | +| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) | | `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) | | `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) | | `extra.sessionId` | `ctx.sessionId` | @@ -783,7 +837,8 @@ try { ### Experimental: `TaskCreationParams.ttl` no longer accepts `null` -The `ttl` field in `TaskCreationParams` (used when requesting the server to create a task) no longer accepts `null`. Per the MCP spec, `null` TTL (meaning unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Clients should omit `ttl` to let the server decide the lifetime. +The `ttl` field in `TaskCreationParams` (used when requesting the server to create a task) no longer accepts `null`. Per the MCP spec, `null` TTL (meaning unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Clients should omit `ttl` to let +the server decide the lifetime. This also narrows the type of `requestedTtl` in `TaskContext`, `CreateTaskServerContext`, and `TaskServerContext` from `number | null | undefined` to `number | undefined`. diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 569ded59f..504118f88 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1136,33 +1136,88 @@ export abstract class Protocol { * the provided schema. * * Unlike {@linkcode Protocol.request | request}, this accepts any method string. Capability - * checks are bypassed when {@linkcode ProtocolOptions.enforceStrictCapabilities} is disabled - * (the default). + * checks do not apply to custom methods regardless of + * {@linkcode ProtocolOptions.enforceStrictCapabilities}, since + * {@linkcode Protocol.assertCapabilityForMethod | assertCapabilityForMethod} only covers + * standard MCP methods. + * + * Pass a `{ params, result }` schema bundle as the third argument to get typed `params` and + * pre-send validation; pass a bare result schema for loose, unvalidated params. */ - sendCustomRequest( + sendCustomRequest

( + method: string, + params: SchemaOutput

, + schemas: { params: P; result: R }, + options?: RequestOptions + ): Promise>; + sendCustomRequest( method: string, params: Record | undefined, - resultSchema: T, + resultSchema: R, options?: RequestOptions - ): Promise> { + ): Promise>; + async sendCustomRequest( + method: string, + params: Record | undefined, + schemaOrBundle: AnySchema | { params: AnySchema; result: AnySchema }, + options?: RequestOptions + ): Promise { + let resultSchema: AnySchema; + if (isSchemaBundle(schemaOrBundle)) { + const parsed = parseSchema(schemaOrBundle.params, params); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); + } + resultSchema = schemaOrBundle.result; + } else { + resultSchema = schemaOrBundle; + } return this._requestWithSchema({ method, params } as Request, resultSchema, options); } /** * Sends a custom (non-standard) notification. * - * Unlike {@linkcode Protocol.notification | notification}, this accepts any method string and - * bypasses capability checks by sending directly via the transport. + * Unlike {@linkcode Protocol.notification | notification}, this accepts any method string. It + * routes through {@linkcode Protocol.notification | notification}, so debouncing and task-queued + * delivery apply. Capability checks are a no-op for custom methods since + * {@linkcode Protocol.assertNotificationCapability | assertNotificationCapability} only covers + * standard MCP notifications. + * + * Pass a `{ params }` schema bundle as the third argument to get typed `params` and pre-send + * validation. */ - async sendCustomNotification(method: string, params?: Record, options?: NotificationOptions): Promise { - if (!this._transport) { - throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + sendCustomNotification

( + method: string, + params: SchemaOutput

, + schemas: { params: P }, + options?: NotificationOptions + ): Promise; + sendCustomNotification(method: string, params?: Record, options?: NotificationOptions): Promise; + async sendCustomNotification( + method: string, + params?: Record, + schemasOrOptions?: { params: AnySchema } | NotificationOptions, + maybeOptions?: NotificationOptions + ): Promise { + let options: NotificationOptions | undefined; + if (schemasOrOptions && 'params' in schemasOrOptions) { + const parsed = parseSchema(schemasOrOptions.params, params); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); + } + options = maybeOptions; + } else { + options = schemasOrOptions; } - const jsonrpcNotification: JSONRPCNotification = { jsonrpc: '2.0', method, params }; - await this._transport.send(jsonrpcNotification, options); + return this.notification({ method, params } as Notification, options); } } +function isSchemaBundle(value: AnySchema | { params: AnySchema; result: AnySchema }): value is { params: AnySchema; result: AnySchema } { + return 'params' in value && 'result' in value; +} + function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index 38df2adfa..0fb346374 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -173,6 +173,24 @@ describe('sendCustomRequest', () => { server.setCustomRequestHandler('acme/badresult', z.object({}), () => ({ hits: 'not-an-array', total: 0 })); await expect(client.sendCustomRequest('acme/badresult', {}, SearchResult)).rejects.toThrow(); }); + + test('schema bundle overload: typed params and result', async () => { + const [client, server] = await linkedPair(); + server.setCustomRequestHandler('acme/search', SearchParams, p => ({ hits: [p.query], total: 1 })); + const result = await client.sendCustomRequest('acme/search', { query: 'q' }, { params: SearchParams, result: SearchResult }); + expect(result.hits).toEqual(['q']); + }); + + test('schema bundle overload: invalid params rejects InvalidParams before transport', async () => { + const proto = new TestProtocol(); // not connected + // InvalidParams (pre-send validation) — proves it does NOT reach the NotConnected path + await expect( + proto.sendCustomRequest('acme/search', { query: 123 } as unknown as z.output, { + params: SearchParams, + result: SearchResult + }) + ).rejects.toSatisfy((e: unknown) => e instanceof ProtocolError && e.code === ProtocolErrorCode.InvalidParams); + }); }); describe('sendCustomNotification', () => { @@ -189,4 +207,42 @@ describe('sendCustomNotification', () => { client.onerror = e => errors.push(e); await expect(server.sendCustomNotification('acme/unhandled', { x: 1 })).resolves.toBeUndefined(); }); + + test('schema bundle overload: invalid params throws InvalidParams before transport', async () => { + const proto = new TestProtocol(); // not connected + await expect( + proto.sendCustomNotification('acme/status', { status: 'bad' } as unknown as z.output, { + params: StatusParams + }) + ).rejects.toSatisfy((e: unknown) => e instanceof ProtocolError && e.code === ProtocolErrorCode.InvalidParams); + }); + + test('schema bundle overload: valid params delivered, options as 4th arg', async () => { + const [client, server] = await linkedPair(); + const received: string[] = []; + client.setCustomNotificationHandler('acme/status', StatusParams, p => { + received.push(p.status); + }); + await server.sendCustomNotification('acme/status', { status: 'busy' }, { params: StatusParams }, {}); + await vi.waitFor(() => expect(received).toEqual(['busy'])); + }); + + test('routes through notification(): debouncing applies to custom methods', async () => { + const a = new TestProtocol({ debouncedNotificationMethods: ['acme/tick'] }); + const b = new TestProtocol(); + const [ta, tb] = InMemoryTransport.createLinkedPair(); + await Promise.all([a.connect(ta), b.connect(tb)]); + + let count = 0; + b.setCustomNotificationHandler('acme/tick', z.undefined().or(z.object({})), () => { + count++; + }); + + // Three synchronous sends should coalesce to one delivery. + void a.sendCustomNotification('acme/tick'); + void a.sendCustomNotification('acme/tick'); + void a.sendCustomNotification('acme/tick'); + await new Promise(r => setTimeout(r, 10)); + expect(count).toBe(1); + }); }); From fc5d10cd6a9f39cc66b0f7e80e7a42df901e8193 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Apr 2026 13:35:27 +0000 Subject: [PATCH 05/11] docs: fix typedoc link warnings in custom method handler JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setRequestHandler is overridden in Client/Server, so {@linkcode Protocol.setRequestHandler} resolves to the undocumented base. Use unqualified {@linkcode setRequestHandler} instead. assertCapabilityForMethod/assertNotificationCapability are protected — use plain backticks. --- packages/core/src/shared/protocol.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 504118f88..464944913 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1063,7 +1063,7 @@ export abstract class Protocol { /** * Registers a handler for a custom (non-standard) request method. * - * Unlike {@linkcode Protocol.setRequestHandler | setRequestHandler}, this accepts any method + * Unlike {@linkcode setRequestHandler}, this accepts any method * string and validates incoming params against a user-provided schema instead of an SDK-defined * one. Capability checks are skipped. The handler receives the same {@linkcode BaseContext | context} * as standard handlers, including cancellation, task support, and bidirectional send/notify. @@ -1138,7 +1138,7 @@ export abstract class Protocol { * Unlike {@linkcode Protocol.request | request}, this accepts any method string. Capability * checks do not apply to custom methods regardless of * {@linkcode ProtocolOptions.enforceStrictCapabilities}, since - * {@linkcode Protocol.assertCapabilityForMethod | assertCapabilityForMethod} only covers + * `assertCapabilityForMethod` only covers * standard MCP methods. * * Pass a `{ params, result }` schema bundle as the third argument to get typed `params` and @@ -1181,7 +1181,7 @@ export abstract class Protocol { * Unlike {@linkcode Protocol.notification | notification}, this accepts any method string. It * routes through {@linkcode Protocol.notification | notification}, so debouncing and task-queued * delivery apply. Capability checks are a no-op for custom methods since - * {@linkcode Protocol.assertNotificationCapability | assertNotificationCapability} only covers + * `assertNotificationCapability` only covers * standard MCP notifications. * * Pass a `{ params }` schema bundle as the third argument to get typed `params` and pre-send From 4a2bf03fd28361ce2495da83163e00e18f828664 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Apr 2026 13:38:10 +0000 Subject: [PATCH 06/11] fix(core): exclude StandardSchema values from isSchemaBundle discriminator --- packages/core/src/shared/protocol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 464944913..482094b27 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1215,7 +1215,7 @@ export abstract class Protocol { } function isSchemaBundle(value: AnySchema | { params: AnySchema; result: AnySchema }): value is { params: AnySchema; result: AnySchema } { - return 'params' in value && 'result' in value; + return !('~standard' in value) && 'params' in value && 'result' in value; } function isPlainObject(value: unknown): value is Record { From 07c5491c4a6b727b9807a246b070fe975ca629b4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Apr 2026 12:55:30 +0000 Subject: [PATCH 07/11] docs: minimize migration doc diff to custom-methods sections only --- docs/migration-SKILL.md | 82 ++++++++++++++++++++--------------------- docs/migration.md | 37 +++++++++---------- 2 files changed, 59 insertions(+), 60 deletions(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 76919bad9..76c1307cc 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -36,13 +36,13 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table. ### Client imports -| v1 import path | v2 package | -| ---------------------------------------------------- | ------------------------------------------------------------------------------ | -| `@modelcontextprotocol/sdk/client/index.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/auth.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/streamableHttp.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/sse.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/stdio.js` | `@modelcontextprotocol/client` | +| v1 import path | v2 package | +| ---------------------------------------------------- | ------------------------------ | +| `@modelcontextprotocol/sdk/client/index.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/auth.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/streamableHttp.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/sse.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/stdio.js` | `@modelcontextprotocol/client` | | `@modelcontextprotocol/sdk/client/websocket.js` | REMOVED (use Streamable HTTP or stdio; implement `Transport` for custom needs) | ### Server imports @@ -59,8 +59,8 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table. ### Types / shared imports -| v1 import path | v2 package | -| ------------------------------------------------- | ---------------------------------------------------------------- | +| v1 import path | v2 package | +| ------------------------------------------------- | ---------------------------- | | `@modelcontextprotocol/sdk/types.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | | `@modelcontextprotocol/sdk/shared/protocol.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | | `@modelcontextprotocol/sdk/shared/transport.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | @@ -81,22 +81,22 @@ Notes: ## 5. Removed / Renamed Type Aliases and Symbols -| v1 (removed) | v2 (replacement) | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | -| `isJSONRPCResponse` | `isJSONRPCResultResponse` | -| `ResourceReference` | `ResourceTemplateReference` | -| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | -| `IsomorphicHeaders` | REMOVED (use Web Standard `Headers`) | +| v1 (removed) | v2 (replacement) | +| ---------------------------------------- | -------------------------------------------------------- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` | `isJSONRPCResultResponse` | +| `ResourceReference` | `ResourceTemplateReference` | +| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | +| `IsomorphicHeaders` | REMOVED (use Web Standard `Headers`) | | `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | -| `McpError` | `ProtocolError` | -| `ErrorCode` | `ProtocolErrorCode` | -| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | -| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | -| `StreamableHTTPError` | REMOVED (use `SdkError` with `SdkErrorCode.ClientHttp*`) | -| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | +| `McpError` | `ProtocolError` | +| `ErrorCode` | `ProtocolErrorCode` | +| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | +| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | +| `StreamableHTTPError` | REMOVED (use `SdkError` with `SdkErrorCode.ClientHttp*`) | +| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | All other symbols from `@modelcontextprotocol/sdk/types.js` retain their original names (e.g., `CallToolResultSchema`, `ListToolsResultSchema`, etc.). @@ -210,8 +210,7 @@ Zod schemas, all callback return types. Note: `callTool()` and `request()` signa The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object. -**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with -`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. +**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with `fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. ### Tools @@ -281,20 +280,20 @@ Note: the third argument (`metadata`) is required — pass `{}` if no metadata. ### Schema Migration Quick Reference -| v1 (raw shape) | v2 (Standard Schema object) | -| ---------------------------------- | -------------------------------------------- | -| `{ name: z.string() }` | `z.object({ name: z.string() })` | +| v1 (raw shape) | v2 (Standard Schema object) | +|----------------|-----------------| +| `{ name: z.string() }` | `z.object({ name: z.string() })` | | `{ count: z.number().optional() }` | `z.object({ count: z.number().optional() })` | | `{}` (empty) | `z.object({})` | | `undefined` (no schema) | `undefined` or omit the field | ### Removed core exports -| Removed from `@modelcontextprotocol/core` | Replacement | -| ------------------------------------------------------------------------------------ | ----------------------------------------- | -| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | -| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | -| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | +| Removed from `@modelcontextprotocol/core` | Replacement | +|---|---| +| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | +| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | +| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | | `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | none (internal Zod introspection helpers) | ## 7. Headers API @@ -390,6 +389,7 @@ Request/notification params remain fully typed. Remove unused schema imports aft The v1 schema's `.shape.params` becomes the `ParamsSchema` argument; the `method: z.literal('...')` value becomes the string argument. + ## 10. Request Handler Context Types `RequestHandlerExtra` → structured context types with nested groups. Rename `extra` → `ctx` in all handler callbacks. @@ -452,18 +452,18 @@ Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResu `TaskCreationParams.ttl` changed from `z.union([z.number(), z.null()]).optional()` to `z.number().optional()`. Per the MCP spec, `null` TTL (unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Omit `ttl` to let the server decide. -| v1 | v2 | -| ---------------------- | ---------------------------------- | -| `task: { ttl: null }` | `task: {}` (omit ttl) | +| v1 | v2 | +|---|---| +| `task: { ttl: null }` | `task: {}` (omit ttl) | | `task: { ttl: 60000 }` | `task: { ttl: 60000 }` (unchanged) | Type changes in handler context: -| Type | v1 | v2 | -| ------------------------------------------- | ----------------------------- | --------------------- | -| `TaskContext.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | +| Type | v1 | v2 | +|---|---|---| +| `TaskContext.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | | `CreateTaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | -| `TaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | +| `TaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | > These task APIs are `@experimental` and may change without notice. diff --git a/docs/migration.md b/docs/migration.md index 3d61629a3..6aeb45601 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -57,8 +57,7 @@ import { McpServer, StdioServerTransport, WebStandardStreamableHTTPServerTranspo import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; ``` -Note: `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so you can import types and error classes from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly -— it is an internal package. +Note: `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so you can import types and error classes from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly — it is an internal package. ### Dropped Node.js 18 and CommonJS @@ -295,11 +294,11 @@ This applies to: **Removed Zod-specific helpers** from `@modelcontextprotocol/core` (use Standard Schema equivalents): -| Removed | Replacement | -| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | -| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | -| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | +| Removed | Replacement | +|---|---| +| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | +| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | +| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | | `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | No replacement — these are now internal Zod introspection helpers | ### Host header validation moved @@ -436,6 +435,7 @@ before sending and gives typed `params`; passing a bare result schema sends para For larger sub-protocols where neither side is semantically an MCP client or server, prefer composition: hold a `Client` (or `Server`) instance, register custom handlers on it, and expose typed facade methods. See `examples/server/src/customMethodExtAppsExample.ts` for a worked example. + ### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer take a schema parameter The public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer accept a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to import result schemas @@ -531,15 +531,15 @@ For **production in-process connections**, use `StreamableHTTPClientTransport` w The following deprecated type aliases have been removed from `@modelcontextprotocol/core`: -| Removed | Replacement | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | -| `isJSONRPCResponse` | `isJSONRPCResultResponse` | -| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | -| `ResourceReference` | `ResourceTemplateReference` | -| `IsomorphicHeaders` | Use Web Standard `Headers` | +| Removed | Replacement | +| ---------------------------------------- | ------------------------------------------------ | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` | `isJSONRPCResultResponse` | +| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | +| `ResourceReference` | `ResourceTemplateReference` | +| `IsomorphicHeaders` | Use Web Standard `Headers` | | `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | All other types and schemas exported from `@modelcontextprotocol/sdk/types.js` retain their original names — import them from `@modelcontextprotocol/client` or `@modelcontextprotocol/server`. @@ -570,7 +570,7 @@ The `RequestHandlerExtra` type has been replaced with a structured context type | `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | `extra.authInfo` | `ctx.http?.authInfo` | -| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) | +| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) | | `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) | | `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) | | `extra.sessionId` | `ctx.sessionId` | @@ -837,8 +837,7 @@ try { ### Experimental: `TaskCreationParams.ttl` no longer accepts `null` -The `ttl` field in `TaskCreationParams` (used when requesting the server to create a task) no longer accepts `null`. Per the MCP spec, `null` TTL (meaning unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Clients should omit `ttl` to let -the server decide the lifetime. +The `ttl` field in `TaskCreationParams` (used when requesting the server to create a task) no longer accepts `null`. Per the MCP spec, `null` TTL (meaning unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Clients should omit `ttl` to let the server decide the lifetime. This also narrows the type of `requestedTtl` in `TaskContext`, `CreateTaskServerContext`, and `TaskServerContext` from `number | null | undefined` to `number | undefined`. From 98d774292982f2f7f4905b9d9472f06bf6a9e82c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Apr 2026 13:29:24 +0000 Subject: [PATCH 08/11] docs(core): clarify schema-bundle overload validates only; add test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The {params, result} schema bundle in sendCustomRequest/sendCustomNotification is a type guard, not a transformer — the caller-provided value is sent as-is, matching request()/v1 behavior. Transforms/defaults on the params schema are not applied outbound (parsed.data is intentionally unused on the send path). Adds JSDoc and a test asserting params are sent verbatim. --- packages/core/src/shared/protocol.ts | 7 ++++++- packages/core/test/shared/customMethods.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 482094b27..6767f928f 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1143,6 +1143,10 @@ export abstract class Protocol { * * Pass a `{ params, result }` schema bundle as the third argument to get typed `params` and * pre-send validation; pass a bare result schema for loose, unvalidated params. + * + * The `params` schema is used only for validation — the value you pass is sent as-is. + * Transforms (e.g. `.trim()`) and defaults (e.g. `.default(n)`) on the schema are not + * applied to outbound data, matching the behavior of {@linkcode Protocol.request | request}. */ sendCustomRequest

( method: string, @@ -1185,7 +1189,8 @@ export abstract class Protocol { * standard MCP notifications. * * Pass a `{ params }` schema bundle as the third argument to get typed `params` and pre-send - * validation. + * validation. The schema validates only — transforms and defaults are not applied to + * outbound data; the value you pass is sent as-is. */ sendCustomNotification

( method: string, diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index 0fb346374..4f7a1eb8e 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -191,6 +191,18 @@ describe('sendCustomRequest', () => { }) ).rejects.toSatisfy((e: unknown) => e instanceof ProtocolError && e.code === ProtocolErrorCode.InvalidParams); }); + + test('schema bundle overload: params sent as-is (validate-only, no outbound transforms)', async () => { + const [client, server] = await linkedPair(); + const P = z.object({ query: z.string().transform(s => s.trim()), page: z.number() }); + let received: unknown; + server.setCustomRequestHandler('acme/q', z.unknown(), p => { + received = p; + return {}; + }); + await client.sendCustomRequest('acme/q', { query: ' hi ', page: 1 }, { params: P, result: z.object({}) }); + expect(received).toEqual({ query: ' hi ', page: 1 }); + }); }); describe('sendCustomNotification', () => { From 89db8c65f608b492efc6c9427e973ff6e94fcde0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Apr 2026 14:41:30 +0000 Subject: [PATCH 09/11] refactor(examples): split customMethod example into server/client pair, drop ext-apps demo --- docs/migration.md | 4 +- examples/client/README.md | 1 + examples/client/src/customMethodExample.ts | 80 +++++++ examples/server/README.md | 1 + examples/server/package.json | 1 - examples/server/src/customMethodExample.ts | 129 ++++++----- .../server/src/customMethodExtAppsExample.ts | 204 ------------------ examples/server/tsconfig.json | 2 - packages/core/src/exports/public/index.ts | 3 - pnpm-lock.yaml | 50 ++++- 10 files changed, 211 insertions(+), 264 deletions(-) create mode 100644 examples/client/src/customMethodExample.ts delete mode 100644 examples/server/src/customMethodExtAppsExample.ts diff --git a/docs/migration.md b/docs/migration.md index 6aeb45601..8a63a1162 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -432,9 +432,7 @@ class App extends Client { Custom handlers share the same dispatch path as standard handlers — context, cancellation, task delivery, and error wrapping all apply. Passing a `{ params, result }` schema bundle to `sendCustomRequest` (or `{ params }` to `sendCustomNotification`) validates outbound params before sending and gives typed `params`; passing a bare result schema sends params unvalidated. -For larger sub-protocols where neither side is semantically an MCP client or server, prefer composition: hold a `Client` (or `Server`) instance, register custom handlers on it, and expose typed facade methods. See `examples/server/src/customMethodExtAppsExample.ts` for a worked -example. - +For larger sub-protocols where neither side is semantically an MCP client or server, prefer composition: hold a `Client` (or `Server`) instance, register custom handlers on it, and expose typed facade methods. See `examples/server/src/customMethodExample.ts` and `examples/client/src/customMethodExample.ts` for runnable examples. ### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer take a schema parameter diff --git a/examples/client/README.md b/examples/client/README.md index 12a2b0d68..8eca78879 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -36,6 +36,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md | Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | | URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | | Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) | +| Custom (non-standard) methods client | Sends `acme/*` custom requests and handles custom server notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) | ## URL elicitation example (server + client) diff --git a/examples/client/src/customMethodExample.ts b/examples/client/src/customMethodExample.ts new file mode 100644 index 000000000..46b8413b4 --- /dev/null +++ b/examples/client/src/customMethodExample.ts @@ -0,0 +1,80 @@ +// Run with: pnpm tsx src/customMethodExample.ts +// +// Demonstrates sending custom (non-standard) requests and receiving custom +// notifications from the server. +// +// The Protocol class exposes sendCustomRequest / setCustomNotificationHandler for +// vendor-specific methods that are not part of the MCP spec. The schema-bundle +// overload of sendCustomRequest gives typed params with pre-send validation. +// +// Pair with: examples/server/src/customMethodExample.ts (start the server first). + +import { Client, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { z } from 'zod'; + +const SearchParamsSchema = z.object({ + query: z.string(), + limit: z.number().int().positive().optional() +}); + +const SearchResultSchema = z.object({ + results: z.array(z.object({ id: z.string(), title: z.string() })), + total: z.number() +}); + +const AnalyticsResultSchema = z.object({ recorded: z.boolean() }); + +const StatusUpdateParamsSchema = z.object({ + status: z.enum(['idle', 'busy', 'error']), + detail: z.string().optional() +}); + +const serverUrl = process.argv[2] ?? 'http://localhost:3000/mcp'; + +async function main(): Promise { + const client = new Client({ name: 'custom-method-client', version: '1.0.0' }); + + // Register handler for custom server→client notifications before connecting. + client.setCustomNotificationHandler('acme/statusUpdate', StatusUpdateParamsSchema, params => { + console.log(`[client] acme/statusUpdate status=${params.status} detail=${params.detail ?? ''}`); + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await client.connect(transport); + console.log(`[client] connected to ${serverUrl}`); + + // Schema-bundle overload: typed params + pre-send validation, typed result. + const searchResult = await client.sendCustomRequest( + 'acme/search', + { query: 'widgets', limit: 5 }, + { params: SearchParamsSchema, result: SearchResultSchema } + ); + console.log(`[client] acme/search → ${searchResult.total} results, first: "${searchResult.results[0]?.title}"`); + + // Loose overload: bare result schema, untyped params. + const analyticsResult = await client.sendCustomRequest('acme/analytics', { event: 'page_view' }, AnalyticsResultSchema); + console.log(`[client] acme/analytics → recorded=${analyticsResult.recorded}`); + + // Pre-send validation: schema-bundle overload rejects bad params before the round-trip. + try { + await client.sendCustomRequest( + 'acme/search', + { query: 'widgets', limit: 'five' } as unknown as z.output, + { params: SearchParamsSchema, result: SearchResultSchema } + ); + console.error('[client] expected validation error but request succeeded'); + } catch (error) { + const code = error instanceof ProtocolError && error.code === ProtocolErrorCode.InvalidParams ? 'InvalidParams' : 'unknown'; + console.log(`[client] pre-send validation error (expected, ${code}): ${(error as Error).message}`); + } + + await transport.close(); +} + +try { + await main(); +} catch (error) { + console.error('[client] error:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/server/README.md b/examples/server/README.md index 384e4f2c2..1a217de0e 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -38,6 +38,7 @@ pnpm tsx src/simpleStreamableHttp.ts | Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) | | Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | | SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | +| Custom (non-standard) methods server | Registers `acme/*` custom request handlers and sends custom notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) | ## OAuth demo flags (Streamable HTTP server) diff --git a/examples/server/package.json b/examples/server/package.json index 00de54cfa..fcff95d9a 100644 --- a/examples/server/package.json +++ b/examples/server/package.json @@ -33,7 +33,6 @@ }, "dependencies": { "@hono/node-server": "catalog:runtimeServerOnly", - "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/examples-shared": "workspace:^", "@modelcontextprotocol/express": "workspace:^", "@modelcontextprotocol/hono": "workspace:^", diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts index ba9c4e840..0f9c48ee2 100644 --- a/examples/server/src/customMethodExample.ts +++ b/examples/server/src/customMethodExample.ts @@ -1,16 +1,21 @@ -#!/usr/bin/env node -/** - * Demonstrates custom (non-standard) request and notification methods. - * - * The Protocol class exposes setCustomRequestHandler / setCustomNotificationHandler / - * sendCustomRequest / sendCustomNotification for vendor-specific methods that are not - * part of the MCP spec. Params and results are validated against user-provided Zod - * schemas, and handlers receive the same context (cancellation, task support, - * bidirectional send/notify) as standard handlers. - */ - -import { Client } from '@modelcontextprotocol/client'; -import { InMemoryTransport, Server } from '@modelcontextprotocol/server'; +// Run with: pnpm tsx src/customMethodExample.ts +// +// Demonstrates registering handlers for custom (non-standard) request methods +// and sending custom notifications back to the client. +// +// The Protocol class exposes setCustomRequestHandler / sendCustomNotification for +// vendor-specific methods that are not part of the MCP spec. Params are validated +// against user-provided Zod schemas, and handlers receive the same context +// (cancellation, bidirectional send/notify) as standard handlers. +// +// Pair with: examples/client/src/customMethodExample.ts + +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { isInitializeRequest, Server } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; import { z } from 'zod'; const SearchParamsSchema = z.object({ @@ -18,29 +23,20 @@ const SearchParamsSchema = z.object({ limit: z.number().int().positive().optional() }); -const SearchResultSchema = z.object({ - results: z.array(z.object({ id: z.string(), title: z.string() })), - total: z.number() -}); - const AnalyticsParamsSchema = z.object({ event: z.string(), properties: z.record(z.string(), z.unknown()).optional() }); -const AnalyticsResultSchema = z.object({ recorded: z.boolean() }); - -const StatusUpdateParamsSchema = z.object({ - status: z.enum(['idle', 'busy', 'error']), - detail: z.string().optional() -}); - -async function main() { +const getServer = () => { const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} }); - const client = new Client({ name: 'custom-method-client', version: '1.0.0' }, { capabilities: {} }); server.setCustomRequestHandler('acme/search', SearchParamsSchema, async (params, ctx) => { console.log(`[server] acme/search query="${params.query}" limit=${params.limit ?? 'unset'} (req ${ctx.mcpReq.id})`); + + // Send a custom server→client notification on the same SSE stream as this response. + await server.sendCustomNotification('acme/statusUpdate', { status: 'busy', detail: `searching "${params.query}"` }); + return { results: [ { id: 'r1', title: `Result for "${params.query}"` }, @@ -55,31 +51,68 @@ async function main() { return { recorded: true }; }); - client.setCustomNotificationHandler('acme/statusUpdate', StatusUpdateParamsSchema, params => { - console.log(`[client] acme/statusUpdate status=${params.status} detail=${params.detail ?? ''}`); - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return server; +}; - const searchResult = await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 5 }, SearchResultSchema); - console.log(`[client] received ${searchResult.total} results, first: "${searchResult.results[0]?.title}"`); +const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; +const app = createMcpExpressApp(); +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - const analyticsResult = await client.sendCustomRequest('acme/analytics', { event: 'page_view' }, AnalyticsResultSchema); - console.log(`[client] analytics recorded=${analyticsResult.recorded}`); - - await server.sendCustomNotification('acme/statusUpdate', { status: 'busy', detail: 'indexing' }); - - // Validation error: wrong param type (limit must be a number) +app.post('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; try { - await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 'five' }, SearchResultSchema); - console.error('[client] expected validation error but request succeeded'); + let transport: NodeStreamableHTTPServerTransport; + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: sid => { + transports[sid] = transport; + } + }); + transport.onclose = () => { + const sid = transport.sessionId; + if (sid) delete transports[sid]; + }; + const server = getServer(); + await server.connect(transport); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'No valid session ID' }, id: null }); + return; + } + await transport.handleRequest(req, res, req.body); } catch (error) { - console.log(`[client] validation error (expected): ${(error as Error).message}`); + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ jsonrpc: '2.0', error: { code: -32_603, message: 'Internal server error' }, id: null }); + } + } +}); + +const handleSessionRequest = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; } + await transports[sessionId].handleRequest(req, res); +}; - await client.close(); - await server.close(); -} +app.get('/mcp', handleSessionRequest); +app.delete('/mcp', handleSessionRequest); -await main(); +app.listen(PORT, error => { + if (error) { + console.error('Failed to start server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`Custom-method example server listening on http://localhost:${PORT}/mcp`); + console.log('Custom methods: acme/search, acme/analytics'); +}); + +process.on('SIGINT', async () => { + for (const sid in transports) await transports[sid]!.close(); + process.exit(0); +}); diff --git a/examples/server/src/customMethodExtAppsExample.ts b/examples/server/src/customMethodExtAppsExample.ts deleted file mode 100644 index 764475e7b..000000000 --- a/examples/server/src/customMethodExtAppsExample.ts +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env node -/** - * Demonstrates that the ext-apps (mcp-ui) pattern is fully implementable on top of the v2 - * SDK's custom-method-handler API, without extending Protocol or relying on the v1 generic - * type parameters. - * - * In v1, ext-apps defined `class ProtocolWithEvents<...> extends Protocol` to - * widen the request/notification type unions. In v2, the same is achieved by composing - * setCustomRequestHandler / setCustomNotificationHandler / sendCustomRequest / sendCustomNotification - * on top of the standard Client and Server classes. - */ - -import { Client } from '@modelcontextprotocol/client'; -import { InMemoryTransport, Server } from '@modelcontextprotocol/server'; -import { z } from 'zod'; - -// ─────────────────────────────────────────────────────────────────────────────── -// Custom method schemas (mirror the ext-apps spec.types.ts pattern) -// ─────────────────────────────────────────────────────────────────────────────── - -const InitializeParams = z.object({ - protocolVersion: z.string(), - appInfo: z.object({ name: z.string(), version: z.string() }) -}); -const InitializeResult = z.object({ - protocolVersion: z.string(), - hostInfo: z.object({ name: z.string(), version: z.string() }), - hostContext: z.object({ theme: z.enum(['light', 'dark']), locale: z.string() }) -}); - -const OpenLinkParams = z.object({ url: z.url() }); -const OpenLinkResult = z.object({ opened: z.boolean() }); - -const TeardownParams = z.object({ reason: z.string().optional() }); - -const SizeChangedParams = z.object({ width: z.number(), height: z.number() }); -const ToolResultParams = z.object({ toolName: z.string(), content: z.array(z.object({ type: z.string(), text: z.string() })) }); -const HostContextChangedParams = z.object({ theme: z.enum(['light', 'dark']).optional(), locale: z.string().optional() }); - -type AppEventMap = { - toolresult: z.infer; - hostcontextchanged: z.infer; -}; - -// ─────────────────────────────────────────────────────────────────────────────── -// App: wraps Client, exposes typed mcp-ui/* methods + DOM-style events -// (replaces v1's `class App extends ProtocolWithEvents`) -// ─────────────────────────────────────────────────────────────────────────────── - -class App { - readonly client: Client; - private _listeners: { [K in keyof AppEventMap]: ((p: AppEventMap[K]) => void)[] } = { - toolresult: [], - hostcontextchanged: [] - }; - private _hostContext?: z.infer['hostContext']; - - onTeardown?: (params: z.infer) => void | Promise; - - constructor(appInfo: { name: string; version: string }) { - this.client = new Client(appInfo, { capabilities: {} }); - - // Incoming custom request from host - this.client.setCustomRequestHandler('mcp-ui/resourceTeardown', TeardownParams, async params => { - await this.onTeardown?.(params); - return {}; - }); - - // Incoming custom notifications from host -> DOM-style event slots - this.client.setCustomNotificationHandler('mcp-ui/toolResult', ToolResultParams, p => this._dispatch('toolresult', p)); - this.client.setCustomNotificationHandler('mcp-ui/hostContextChanged', HostContextChangedParams, p => { - this._hostContext = { ...this._hostContext!, ...p }; - this._dispatch('hostcontextchanged', p); - }); - } - - addEventListener(event: K, listener: (p: AppEventMap[K]) => void): void { - this._listeners[event].push(listener); - } - - removeEventListener(event: K, listener: (p: AppEventMap[K]) => void): void { - const arr = this._listeners[event]; - const i = arr.indexOf(listener); - if (i !== -1) arr.splice(i, 1); - } - - private _dispatch(event: K, params: AppEventMap[K]): void { - for (const l of this._listeners[event]) l(params); - } - - async connect(transport: Parameters[0]): Promise { - await this.client.connect(transport); - const result = await this.client.sendCustomRequest( - 'mcp-ui/initialize', - { protocolVersion: '2026-01-26', appInfo: { name: 'demo-app', version: '1.0.0' } }, - InitializeResult - ); - this._hostContext = result.hostContext; - await this.client.sendCustomNotification('mcp-ui/initialized', {}); - } - - getHostContext() { - return this._hostContext; - } - - openLink(url: string) { - return this.client.sendCustomRequest('mcp-ui/openLink', { url }, OpenLinkResult); - } - - notifySizeChanged(width: number, height: number) { - return this.client.sendCustomNotification('mcp-ui/sizeChanged', { width, height }); - } -} - -// ─────────────────────────────────────────────────────────────────────────────── -// Host: wraps Server, handles mcp-ui/* requests and emits mcp-ui/* notifications -// ─────────────────────────────────────────────────────────────────────────────── - -class Host { - readonly server: Server; - onSizeChanged?: (p: z.infer) => void; - - constructor() { - this.server = new Server({ name: 'demo-host', version: '1.0.0' }, { capabilities: {} }); - - this.server.setCustomRequestHandler('mcp-ui/initialize', InitializeParams, params => { - console.log(`[host] mcp-ui/initialize from ${params.appInfo.name}@${params.appInfo.version}`); - return { - protocolVersion: params.protocolVersion, - hostInfo: { name: 'demo-host', version: '1.0.0' }, - hostContext: { theme: 'dark', locale: 'en-US' } - }; - }); - - this.server.setCustomRequestHandler('mcp-ui/openLink', OpenLinkParams, params => { - console.log(`[host] mcp-ui/openLink url=${params.url}`); - return { opened: true }; - }); - - this.server.setCustomNotificationHandler('mcp-ui/initialized', z.object({}).optional(), () => { - console.log('[host] mcp-ui/initialized'); - }); - - this.server.setCustomNotificationHandler('mcp-ui/sizeChanged', SizeChangedParams, p => { - console.log(`[host] mcp-ui/sizeChanged ${p.width}x${p.height}`); - this.onSizeChanged?.(p); - }); - } - - notifyToolResult(toolName: string, text: string) { - return this.server.sendCustomNotification('mcp-ui/toolResult', { - toolName, - content: [{ type: 'text', text }] - }); - } - - notifyHostContextChanged(patch: z.infer) { - return this.server.sendCustomNotification('mcp-ui/hostContextChanged', patch); - } - - requestTeardown(reason: string) { - return this.server.sendCustomRequest('mcp-ui/resourceTeardown', { reason }, z.object({})); - } -} - -// ─────────────────────────────────────────────────────────────────────────────── -// Demo -// ─────────────────────────────────────────────────────────────────────────────── - -async function main() { - const host = new Host(); - const app = new App({ name: 'demo-app', version: '1.0.0' }); - - app.addEventListener('toolresult', p => console.log(`[app] toolresult: ${p.toolName} -> "${p.content[0]?.text}"`)); - app.addEventListener('hostcontextchanged', p => console.log(`[app] hostcontextchanged: ${JSON.stringify(p)}`)); - app.onTeardown = p => console.log(`[app] teardown: ${p.reason}`); - host.onSizeChanged = p => console.log(`[host] app resized to ${p.width}x${p.height}`); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await host.server.connect(serverTransport); - await app.connect(clientTransport); - - console.log(`[app] hostContext after init: ${JSON.stringify(app.getHostContext())}`); - - // App -> Host: custom request - const { opened } = await app.openLink('https://example.com'); - console.log(`[app] openLink -> opened=${opened}`); - - // App -> Host: custom notification - await app.notifySizeChanged(800, 600); - - // Host -> App: custom notifications (DOM-style event listeners fire) - await host.notifyToolResult('search', 'found 3 widgets'); - await host.notifyHostContextChanged({ theme: 'light' }); - console.log(`[app] hostContext after change: ${JSON.stringify(app.getHostContext())}`); - - // Host -> App: custom request - await host.requestTeardown('navigation'); - - await app.client.close(); - await host.server.close(); -} - -await main(); diff --git a/examples/server/tsconfig.json b/examples/server/tsconfig.json index bf41c2d43..e3c0e9477 100644 --- a/examples/server/tsconfig.json +++ b/examples/server/tsconfig.json @@ -7,8 +7,6 @@ "*": ["./*"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], - "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], - "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index f7a7baf08..353567ba5 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -133,9 +133,6 @@ export type { export { isTerminal } from '../../experimental/tasks/interfaces.js'; export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js'; -// Transport utilities -export { InMemoryTransport } from '../../util/inMemory.js'; - // Validator types and classes export type { StandardSchemaWithJSON } from '../../util/standardSchema.js'; export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42503183b..899586750 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,9 +339,6 @@ importers: '@hono/node-server': specifier: catalog:runtimeServerOnly version: 1.19.11(hono@4.12.9) - '@modelcontextprotocol/client': - specifier: workspace:^ - version: link:../../packages/client '@modelcontextprotocol/examples-shared': specifier: workspace:^ version: link:../shared @@ -1709,89 +1706,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2040,60 +2053,70 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.57': resolution: {integrity: sha512-d0kIVezTQtazpyWjiJIn5to8JlwfKITDqwsFv0Xc6s31N16CD2PC/Pl2OtKgS7n8WLOJbfqgIp5ixYzTAxCqMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.57': resolution: {integrity: sha512-E199LPijo98yrLjPCmETx8EF43sZf9t3guSrLee/ej1rCCc3zDVTR4xFfN9BRAapGVl7/8hYqbbiQPTkv73kUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.57': resolution: {integrity: sha512-++EQDpk/UJ33kY/BNsh7A7/P1sr/jbMuQ8cE554ZIy+tCUWCivo9zfyjDUoiMdnxqX6HLJEqqGnbGQOvzm2OMQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-beta.57': resolution: {integrity: sha512-voDEBcNqxbUv/GeXKFtxXVWA+H45P/8Dec4Ii/SbyJyGvCqV1j+nNHfnFUIiRQ2Q40DwPe/djvgYBs9PpETiMA==} @@ -2181,66 +2204,79 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -2524,41 +2560,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} From 3575410f83bae9f080bf2587c42912fe33487afa Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Apr 2026 15:47:37 +0000 Subject: [PATCH 10/11] fix(core): strip _meta before custom-handler schema validation; route example notification to request stream; add changeset - setCustomRequestHandler/setCustomNotificationHandler now strip _meta from params before validating against the user schema, so .strict() schemas do not reject SDK-injected fields like progressToken. _meta remains available via ctx.mcpReq._meta. Adds regression test. - examples/server/src/customMethodExample.ts: pass relatedRequestId so the acme/statusUpdate notification routes to the request response stream as the comment claims (was going to the standalone SSE stream). - Add .changeset/custom-method-handlers.md (minor bump for client+server). --- .changeset/custom-method-handlers.md | 6 ++++++ examples/server/src/customMethodExample.ts | 9 +++++++-- packages/core/src/shared/protocol.ts | 8 ++++++-- packages/core/test/shared/customMethods.test.ts | 13 +++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 .changeset/custom-method-handlers.md diff --git a/.changeset/custom-method-handlers.md b/.changeset/custom-method-handlers.md new file mode 100644 index 000000000..498d585d2 --- /dev/null +++ b/.changeset/custom-method-handlers.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Add `setCustomRequestHandler` / `setCustomNotificationHandler` / `sendCustomRequest` / `sendCustomNotification` (plus `remove*` variants) on `Protocol` for non-standard JSON-RPC methods. Restores typed registration for vendor-specific methods (e.g. `mcp-ui/*`) that #1446/#1451 closed off, without reintroducing class-level generics. Handlers share the standard dispatch path (context, cancellation, tasks); a collision guard rejects standard MCP methods. diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts index 0f9c48ee2..6afc1af21 100644 --- a/examples/server/src/customMethodExample.ts +++ b/examples/server/src/customMethodExample.ts @@ -34,8 +34,13 @@ const getServer = () => { server.setCustomRequestHandler('acme/search', SearchParamsSchema, async (params, ctx) => { console.log(`[server] acme/search query="${params.query}" limit=${params.limit ?? 'unset'} (req ${ctx.mcpReq.id})`); - // Send a custom server→client notification on the same SSE stream as this response. - await server.sendCustomNotification('acme/statusUpdate', { status: 'busy', detail: `searching "${params.query}"` }); + // Send a custom server→client notification on the same SSE stream as this response + // (relatedRequestId routes it to the request's stream rather than the standalone SSE stream). + await server.sendCustomNotification( + 'acme/statusUpdate', + { status: 'busy', detail: `searching "${params.query}"` }, + { relatedRequestId: ctx.mcpReq.id } + ); return { results: [ diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 6767f928f..3b9fea8c6 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1077,7 +1077,9 @@ export abstract class Protocol { throw new Error(`"${method}" is a standard MCP request method. Use setRequestHandler() instead.`); } this._requestHandlers.set(method, (request, ctx) => { - const parsed = parseSchema(paramsSchema, request.params); + const { _meta, ...userParams } = (request.params ?? {}) as Record; + void _meta; + const parsed = parseSchema(paramsSchema, userParams); if (!parsed.success) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); } @@ -1112,7 +1114,9 @@ export abstract class Protocol { throw new Error(`"${method}" is a standard MCP notification method. Use setNotificationHandler() instead.`); } this._notificationHandlers.set(method, notification => { - const parsed = parseSchema(paramsSchema, notification.params); + const { _meta, ...userParams } = (notification.params ?? {}) as Record; + void _meta; + const parsed = parseSchema(paramsSchema, userParams); if (!parsed.success) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); } diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index 4f7a1eb8e..11e1ffbc1 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -62,6 +62,19 @@ describe('custom request handlers', () => { expect(received?.mcpReq.method).toBe('acme/ctx'); }); + test('strict schema: SDK-injected _meta is stripped before validation', async () => { + let receivedQ: string | undefined; + let receivedMeta: unknown; + server.setCustomRequestHandler('acme/strict', z.object({ q: z.string() }).strict(), (params, ctx) => { + receivedQ = params.q; + receivedMeta = ctx.mcpReq._meta; + return {}; + }); + await expect(client.sendCustomRequest('acme/strict', { q: 'hi' }, z.object({}), { onprogress: () => {} })).resolves.toEqual({}); + expect(receivedQ).toBe('hi'); + expect(receivedMeta).toMatchObject({ progressToken: expect.anything() }); + }); + test('invalid params -> InvalidParams ProtocolError', async () => { server.setCustomRequestHandler('acme/search', SearchParams, () => ({ hits: [], total: 0 })); From b52bc34cecd7c8dee03c5a8373cad5d04674b2fe Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Apr 2026 17:57:51 +0000 Subject: [PATCH 11/11] fix(core): use SdkError(NotConnected) in request() to match notification path --- packages/core/src/shared/protocol.ts | 2 +- packages/core/test/shared/customMethods.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 3b9fea8c6..1fa079992 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -811,7 +811,7 @@ export abstract class Protocol { }; if (!this._transport) { - earlyReject(new Error('Not connected')); + earlyReject(new SdkError(SdkErrorCode.NotConnected, 'Not connected')); return; } diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index 11e1ffbc1..7cf076a98 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -169,9 +169,11 @@ describe('custom notification handlers', () => { }); describe('sendCustomRequest', () => { - test('not connected -> rejects', async () => { + test('not connected -> throws SdkError NotConnected', async () => { const proto = new TestProtocol(); - await expect(proto.sendCustomRequest('acme/x', {}, z.object({}))).rejects.toThrow(/Not connected/); + await expect(proto.sendCustomRequest('acme/x', {}, z.object({}))).rejects.toSatisfy( + (e: unknown) => e instanceof SdkError && e.code === SdkErrorCode.NotConnected + ); }); test('undefined params accepted', async () => {