From 3a51f34706f6b381d2ec6191cb45e59ad65f3663 Mon Sep 17 00:00:00 2001 From: Martin Kessler Date: Thu, 15 Oct 2026 15:24:36 -0700 Subject: [PATCH 1/6] feat: expose original request params through opt-in context --- .changeset/raw-request-params.md | 6 + docs/advanced/low-level-server.md | 16 +++ packages/core-internal/src/shared/protocol.ts | 19 +++ .../test/server/rawRequestParams.test.ts | 135 ++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 .changeset/raw-request-params.md create mode 100644 packages/server/test/server/rawRequestParams.test.ts diff --git a/.changeset/raw-request-params.md b/.changeset/raw-request-params.md new file mode 100644 index 0000000000..2450491729 --- /dev/null +++ b/.changeset/raw-request-params.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/client': minor +--- + +Add the opt-in `includeRawRequestParams` protocol option, exposing original inbound request params through `ctx.mcpReq.rawParams` before wire-field lifting and schema parsing. Default validation and parsed handler arguments remain unchanged. Raw values are untrusted and must be validated separately by applications that need compatibility with non-standard request extensions. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index eb04933fee..767f321fac 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -145,6 +145,22 @@ mcp.server.setRequestHandler('resources/subscribe', async request => { `registerTool` still answers `tools/list` and `tools/call`; `resources/subscribe` reaches the handler you wrote. On the 2026-07-28 revision resource subscriptions arrive on a `subscriptions/listen` stream the serving entries answer for you — see [Protocol versions](../protocol-versions.md). +## Inspect original request params + +For compatibility with an existing non-standard caller, set +`includeRawRequestParams: true` in the `Server` options to expose the original +inbound params as `ctx.mcpReq.rawParams`. The option defaults to `false` and works +on both supported wire eras. `setRequestHandler` still validates the request and +passes the parsed request as its first argument; unknown params members remain +stripped from that argument. + +Raw params are untrusted and include `_meta`, `inputResponses`, and `requestState` +exactly as received. Validate only the application extension fields you intend to +consume. Use the parsed request for known fields and the existing context accessors +for protocol metadata and verified state. Never merge raw params over the parsed +request: if a future protocol revision assigns an extension's name, that revision's +schema governs the field. `_meta` remains the standard place for extensions. + ## Decide which layer to build on Default to `McpServer`. `registerTool`, `registerResource`, and `registerPrompt` cover everything this page rebuilt — schema derivation, argument validation, typed handler arguments — plus the bookkeeping it skipped: `listChanged` notifications, [completions](../servers/completion.md), and the list/read/get dispatch for every registry. diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 9d60be8efe..59ef74b5ff 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -70,6 +70,16 @@ export type ProgressCallback = (progress: Progress) => void; * Additional initialization options. */ export type ProtocolOptions = { + /** + * Expose original inbound request params as `ctx.mcpReq.rawParams`. + * This does not change request validation or the parsed handler argument. + * Raw params are untrusted and include protocol metadata and retry fields; + * validate application extensions explicitly rather than merging them into + * the parsed request. Known fields always remain governed by the era schema. + * @default false + */ + includeRawRequestParams?: boolean; + /** * Protocol versions supported. The legacy `initialize` handshake offers and * falls back to the first 2025-era entry in the list (the client sends it, @@ -341,6 +351,14 @@ export type BaseContext = { * Information about the MCP request being handled. */ mcpReq: { + /** + * Original inbound params, before wire-field lifting and schema parsing. + * Present only with `includeRawRequestParams` enabled and params supplied. + * Values are unvalidated; use the parsed request and existing context + * accessors for known fields, metadata, and verified request state. + */ + rawParams?: Readonly>; + /** * The JSON-RPC ID of the request being handled. */ @@ -1070,6 +1088,7 @@ export abstract class Protocol { mcpReq: { id: request.id, method: request.method, + ...(this._options?.includeRawRequestParams && rawRequest.params !== undefined && { rawParams: rawRequest.params }), _meta: request.params?._meta, ...(lifted.envelope !== undefined && { envelope: lifted.envelope }), ...(partitionedInputResponses !== undefined && { inputResponses: partitionedInputResponses.accepted }), diff --git a/packages/server/test/server/rawRequestParams.test.ts b/packages/server/test/server/rawRequestParams.test.ts new file mode 100644 index 0000000000..60f19bb64c --- /dev/null +++ b/packages/server/test/server/rawRequestParams.test.ts @@ -0,0 +1,135 @@ +import type { JSONRPCMessage, JSONRPCRequest, ServerContext } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + InMemoryTransport, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core-internal'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { Server } from '../../src/server/server'; + +const servers: Server[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => server.close())); +}); + +describe.each(['2025-11-25', '2026-07-28'])('raw request params on %s', revision => { + const modern = revision === '2026-07-28'; + const envelope = modern ? { [PROTOCOL_VERSION_META_KEY]: revision, [CLIENT_CAPABILITIES_META_KEY]: {} } : {}; + + async function call( + params: Record, + handler: (request: { params: { name: string; arguments?: Record } }, ctx: ServerContext) => void, + includeRawRequestParams?: boolean + ) { + const server = new Server({ name: 'raw-params-test', version: '1' }, { capabilities: { tools: {} }, includeRawRequestParams }); + servers.push(server); + setNegotiatedProtocolVersion(server, revision); + server.setRequestHandler('tools/call', (request, ctx) => { + handler(request, ctx); + return { content: [] }; + }); + const [peer, transport] = InMemoryTransport.createLinkedPair(); + await server.connect(transport); + await peer.start(); + const response = new Promise(resolve => { + peer.onmessage = resolve; + server.onerror = error => resolve({ transportError: error.message }); + }); + const wireParams = { ...params, _meta: { ...envelope, ...(params._meta as Record | undefined) } }; + await peer.send(JSON.parse(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: wireParams })) as JSONRPCRequest); + return { response: await response, wireParams }; + } + + it.each([undefined, false])('keeps default stripping and omits raw params when opt-in is %s', async enabled => { + const handler = vi.fn((request, ctx) => { + expect(request.params).not.toHaveProperty('sentinel'); + expect(ctx.mcpReq).not.toHaveProperty('rawParams'); + }); + const { response } = await call({ name: 'echo', sentinel: { exact: [1, 'two'] } }, handler, enabled); + expect(response).toHaveProperty('result'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('exposes exact original params separately from the validated request', async () => { + let observed: unknown; + const handler = vi.fn((request, ctx) => { + observed = ctx.mcpReq.rawParams; + expect(request.params).not.toHaveProperty('sentinel'); + expect(request.params.arguments).toEqual({ text: 'wrapped' }); + }); + const { response, wireParams } = await call( + { name: 'echo', arguments: { text: 'wrapped' }, sentinel: { exact: [1, 'two'] }, _meta: { traceparent: 'synthetic-trace' } }, + handler, + true + ); + expect(response).toHaveProperty('result'); + expect(observed).toEqual(wireParams); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it.each([{ name: 12 }, { name: 'echo', arguments: [] }, { name: 'echo', _meta: { progressToken: {} } }])( + 'does not bypass known-field validation: %j', + async params => { + const baseline = vi.fn(); + const enabled = vi.fn(); + const before = await call(params, baseline); + const after = await call(params, enabled, true); + expect(before.response).toHaveProperty('_meta' in params ? 'transportError' : 'error'); + expect(after.response).toEqual(before.response); + expect(baseline).not.toHaveBeenCalled(); + expect(enabled).not.toHaveBeenCalled(); + } + ); + + it.each(['accepted', 42])('lets an author schema accept or reject the raw sibling: %s', async sentinel => { + const accepted: string[] = []; + const { response } = await call( + { name: 'echo', sentinel }, + (_request, ctx) => accepted.push(z.object({ sentinel: z.string() }).parse(ctx.mcpReq.rawParams).sentinel), + true + ); + expect(response).toHaveProperty(typeof sentinel === 'string' ? 'result' : 'error'); + expect(accepted).toEqual(typeof sentinel === 'string' ? [sentinel] : []); + }); + + it('keeps era-specific task and input-response handling separate from raw params', async () => { + const handler = vi.fn((request, ctx) => { + expect(ctx.mcpReq.rawParams).toHaveProperty('task', { ttl: 123, extension: 'unparsed' }); + if (modern) { + expect(request.params).not.toHaveProperty('task'); + expect(request.params).not.toHaveProperty('inputResponses'); + expect(request.params).not.toHaveProperty('requestState'); + expect(ctx.mcpReq.inputResponses).toEqual({ answer: { action: 'accept' } }); + expect(ctx.mcpReq.droppedInputResponseKeys).toEqual(['invalid']); + expect(ctx.mcpReq.requestState()).toBe('opaque-state'); + } else { + expect(request.params).toHaveProperty('task', { ttl: 123 }); + } + }); + const { response } = await call( + { + name: 'echo', + task: { ttl: 123, extension: 'unparsed' }, + ...(modern && { inputResponses: { answer: { action: 'accept' }, invalid: 1 }, requestState: 'opaque-state' }) + }, + handler, + true + ); + expect(response).toHaveProperty('result'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('keeps a known-field collision governed by its schema rather than merging raw values', async () => { + const params = { name: 'echo', task: { ttl: 'invalid' } }; + const baseline = vi.fn(); + const enabled = vi.fn(); + const before = await call(params, baseline); + const after = await call(params, enabled, true); + expect(after.response).toEqual(before.response); + expect(after.response).toHaveProperty(modern ? 'result' : 'error'); + expect(enabled).toHaveBeenCalledTimes(modern ? 1 : 0); + }); +}); From 9dd900363dd24ebe2778e3b1d83b872acd768a92 Mon Sep 17 00:00:00 2001 From: Martin Kessler Date: Thu, 15 Oct 2026 15:52:21 -0700 Subject: [PATCH 2/6] no-mistakes(lint): Fix formatting and preserve intentional JSON serialization --- docs/advanced/low-level-server.md | 12 ++++-------- packages/server/test/server/rawRequestParams.test.ts | 2 ++ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 767f321fac..373743fd61 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -127,14 +127,10 @@ Every `McpServer` owns its `Server` as `mcp.server`, so drop down per method, ne ```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_escapeHatch" const mcp = new McpServer({ name: 'catalog', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); -mcp.registerTool( - 'search', - { description: 'Search the product catalog', inputSchema: z.object({ query: z.string() }) }, - async ({ query }) => { - const names = catalog.filter(product => product.name.includes(query)).map(product => product.name); - return { content: [{ type: 'text', text: names.join('\n') }] }; - } -); +mcp.registerTool('search', { description: 'Search the product catalog', inputSchema: z.object({ query: z.string() }) }, async ({ query }) => { + const names = catalog.filter(product => product.name.includes(query)).map(product => product.name); + return { content: [{ type: 'text', text: names.join('\n') }] }; +}); const subscriptions = new Set(); mcp.server.setRequestHandler('resources/subscribe', async request => { diff --git a/packages/server/test/server/rawRequestParams.test.ts b/packages/server/test/server/rawRequestParams.test.ts index 60f19bb64c..f1956b5f07 100644 --- a/packages/server/test/server/rawRequestParams.test.ts +++ b/packages/server/test/server/rawRequestParams.test.ts @@ -39,6 +39,8 @@ describe.each(['2025-11-25', '2026-07-28'])('raw request params on %s', revision server.onerror = error => resolve({ transportError: error.message }); }); const wireParams = { ...params, _meta: { ...envelope, ...(params._meta as Record | undefined) } }; + // Simulate JSON wire serialization, including omission of undefined values. + // eslint-disable-next-line unicorn/prefer-structured-clone await peer.send(JSON.parse(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: wireParams })) as JSONRPCRequest); return { response: await response, wireParams }; } From 322b9769195693f8180567e7b914753e213dd2f3 Mon Sep 17 00:00:00 2001 From: Martin Kessler Date: Thu, 15 Oct 2026 15:57:55 -0700 Subject: [PATCH 3/6] no-mistakes(ci): Fixed snippet drift in docs/advanced/low-level-server.md using the repository generator. Reproduced the failure before fixing; repository-wide snippet verification and git diff --check now pass. Full check:all was not run because this worktree lacks project dependencies, including tsx --- docs/advanced/low-level-server.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 373743fd61..767f321fac 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -127,10 +127,14 @@ Every `McpServer` owns its `Server` as `mcp.server`, so drop down per method, ne ```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_escapeHatch" const mcp = new McpServer({ name: 'catalog', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); -mcp.registerTool('search', { description: 'Search the product catalog', inputSchema: z.object({ query: z.string() }) }, async ({ query }) => { - const names = catalog.filter(product => product.name.includes(query)).map(product => product.name); - return { content: [{ type: 'text', text: names.join('\n') }] }; -}); +mcp.registerTool( + 'search', + { description: 'Search the product catalog', inputSchema: z.object({ query: z.string() }) }, + async ({ query }) => { + const names = catalog.filter(product => product.name.includes(query)).map(product => product.name); + return { content: [{ type: 'text', text: names.join('\n') }] }; + } +); const subscriptions = new Set(); mcp.server.setRequestHandler('resources/subscribe', async request => { From 643c8376dd2832e80cd611c58c1542be62184532 Mon Sep 17 00:00:00 2001 From: Martin Kessler Date: Thu, 15 Oct 2026 16:13:33 -0700 Subject: [PATCH 4/6] no-mistakes(review): Clarify raw params boundary and add stdio regression --- .changeset/raw-request-params.md | 2 +- docs/advanced/low-level-server.md | 17 ++++--- packages/core-internal/src/shared/protocol.ts | 7 ++- .../test/server/rawRequestParams.test.ts | 45 ++++++++++++++++++- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/.changeset/raw-request-params.md b/.changeset/raw-request-params.md index 2450491729..9832b214ab 100644 --- a/.changeset/raw-request-params.md +++ b/.changeset/raw-request-params.md @@ -3,4 +3,4 @@ '@modelcontextprotocol/client': minor --- -Add the opt-in `includeRawRequestParams` protocol option, exposing original inbound request params through `ctx.mcpReq.rawParams` before wire-field lifting and schema parsing. Default validation and parsed handler arguments remain unchanged. Raw values are untrusted and must be validated separately by applications that need compatibility with non-standard request extensions. +Add the opt-in `includeRawRequestParams` protocol option, exposing post-transport-parse request params through `ctx.mcpReq.rawParams` before wire-field lifting and handler parsing. Unknown siblings at the `tools/call` params level are preserved; nested objects such as `_meta` may already be normalized by transport schemas. Default validation and parsed handler arguments remain unchanged. Raw values are untrusted and must be validated separately by applications that need compatibility with non-standard request extensions. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 767f321fac..4d3060815e 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -145,17 +145,24 @@ mcp.server.setRequestHandler('resources/subscribe', async request => { `registerTool` still answers `tools/list` and `tools/call`; `resources/subscribe` reaches the handler you wrote. On the 2026-07-28 revision resource subscriptions arrive on a `subscriptions/listen` stream the serving entries answer for you — see [Protocol versions](../protocol-versions.md). -## Inspect original request params +## Inspect params before handler parsing For compatibility with an existing non-standard caller, set -`includeRawRequestParams: true` in the `Server` options to expose the original -inbound params as `ctx.mcpReq.rawParams`. The option defaults to `false` and works +`includeRawRequestParams: true` in the `Server` options to expose post-transport-parse +params as `ctx.mcpReq.rawParams`. The option defaults to `false` and works on both supported wire eras. `setRequestHandler` still validates the request and passes the parsed request as its first argument; unknown params members remain stripped from that argument. -Raw params are untrusted and include `_meta`, `inputResponses`, and `requestState` -exactly as received. Validate only the application extension fields you intend to +Unknown siblings at the `tools/call` params level, such as `resource_type` and +`record_id`, are preserved before handler parsing. This does not preserve raw wire +bytes or guarantee unchanged nested metadata: transport schemas may normalize +objects such as `_meta`. For example, an extra `extension` subfield in +`_meta['io.modelcontextprotocol/related-task']` is stripped before capture, while +`taskId` survives. + +Raw params are untrusted and include protocol metadata and retry fields. +Validate only the application extension fields you intend to consume. Use the parsed request for known fields and the existing context accessors for protocol metadata and verified state. Never merge raw params over the parsed request: if a future protocol revision assigns an extension's name, that revision's diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 59ef74b5ff..ebba3f2a63 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -71,7 +71,9 @@ export type ProgressCallback = (progress: Progress) => void; */ export type ProtocolOptions = { /** - * Expose original inbound request params as `ctx.mcpReq.rawParams`. + * Expose post-transport-parse params as `ctx.mcpReq.rawParams` before handler parsing. + * Preserves unknown siblings at the tools/call params level; nested metadata + * may already have been normalized by transport schemas. * This does not change request validation or the parsed handler argument. * Raw params are untrusted and include protocol metadata and retry fields; * validate application extensions explicitly rather than merging them into @@ -352,7 +354,8 @@ export type BaseContext = { */ mcpReq: { /** - * Original inbound params, before wire-field lifting and schema parsing. + * Post-transport-parse params, before wire-field lifting and handler parsing. + * Unknown tools/call siblings are preserved; nested metadata may be normalized. * Present only with `includeRawRequestParams` enabled and params supplied. * Values are unvalidated; use the parsed request and existing context * accessors for known fields, metadata, and verified request state. diff --git a/packages/server/test/server/rawRequestParams.test.ts b/packages/server/test/server/rawRequestParams.test.ts index f1956b5f07..dd3a28d165 100644 --- a/packages/server/test/server/rawRequestParams.test.ts +++ b/packages/server/test/server/rawRequestParams.test.ts @@ -1,3 +1,5 @@ +import { PassThrough } from 'node:stream'; + import type { JSONRPCMessage, JSONRPCRequest, ServerContext } from '@modelcontextprotocol/core-internal'; import { CLIENT_CAPABILITIES_META_KEY, @@ -9,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import * as z from 'zod/v4'; import { Server } from '../../src/server/server'; +import { StdioServerTransport } from '../../src/server/stdio'; const servers: Server[] = []; afterEach(async () => { @@ -55,7 +58,7 @@ describe.each(['2025-11-25', '2026-07-28'])('raw request params on %s', revision expect(handler).toHaveBeenCalledTimes(1); }); - it('exposes exact original params separately from the validated request', async () => { + it('preserves unknown siblings separately from the validated request', async () => { let observed: unknown; const handler = vi.fn((request, ctx) => { observed = ctx.mcpReq.rawParams; @@ -72,6 +75,46 @@ describe.each(['2025-11-25', '2026-07-28'])('raw request params on %s', revision expect(handler).toHaveBeenCalledTimes(1); }); + it('preserves siblings after stdio transport metadata normalization', async () => { + const server = new Server({ name: 'raw-params-stdio-test', version: '1' }, { includeRawRequestParams: true }); + servers.push(server); + setNegotiatedProtocolVersion(server, revision); + const handler = vi.fn((request, ctx) => { + expect(request.params).not.toHaveProperty('resource_type'); + expect(request.params).not.toHaveProperty('record_id'); + expect(ctx.mcpReq.rawParams).toEqual({ + name: 'echo', + resource_type: 'people', + record_id: 'synthetic-record', + _meta: { ...envelope, 'io.modelcontextprotocol/related-task': { taskId: 't' } } + }); + return { content: [] }; + }); + server.setRequestHandler('tools/call', handler); + const input = new PassThrough(); + const output = new PassThrough(); + const response = new Promise((resolve, reject) => { + output.once('data', chunk => resolve(JSON.parse(chunk.toString()))); + server.onerror = reject; + }); + await server.connect(new StdioServerTransport(input, output)); + input.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'echo', + resource_type: 'people', + record_id: 'synthetic-record', + _meta: { ...envelope, 'io.modelcontextprotocol/related-task': { taskId: 't', extension: 'sentinel' } } + } + }) + '\n' + ); + expect(await response).toHaveProperty('result'); + expect(handler).toHaveBeenCalledTimes(1); + }); + it.each([{ name: 12 }, { name: 'echo', arguments: [] }, { name: 'echo', _meta: { progressToken: {} } }])( 'does not bypass known-field validation: %j', async params => { From c7b3a57661f6ab75ec700ba5331bb8ed3dcd1a7b Mon Sep 17 00:00:00 2001 From: Martin Kessler Date: Thu, 15 Oct 2026 16:14:58 -0700 Subject: [PATCH 5/6] no-mistakes(review): Declare tools capability in stdio regression fixture --- packages/server/test/server/rawRequestParams.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/server/test/server/rawRequestParams.test.ts b/packages/server/test/server/rawRequestParams.test.ts index dd3a28d165..617068acc6 100644 --- a/packages/server/test/server/rawRequestParams.test.ts +++ b/packages/server/test/server/rawRequestParams.test.ts @@ -76,7 +76,10 @@ describe.each(['2025-11-25', '2026-07-28'])('raw request params on %s', revision }); it('preserves siblings after stdio transport metadata normalization', async () => { - const server = new Server({ name: 'raw-params-stdio-test', version: '1' }, { includeRawRequestParams: true }); + const server = new Server( + { name: 'raw-params-stdio-test', version: '1' }, + { capabilities: { tools: {} }, includeRawRequestParams: true } + ); servers.push(server); setNegotiatedProtocolVersion(server, revision); const handler = vi.fn((request, ctx) => { From c91c25189996e9fa3b19a2e3017f1b2c2d6b7736 Mon Sep 17 00:00:00 2001 From: Martin Kessler Date: Thu, 15 Oct 2026 16:19:28 -0700 Subject: [PATCH 6/6] no-mistakes(document): Preserve generated snippet formatting during lint checks --- docs/advanced/low-level-server.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 4d3060815e..0743b798fb 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -124,6 +124,7 @@ Every serving recipe — [stdio](../serving/stdio.md), [HTTP](../serving/http.md Every `McpServer` owns its `Server` as `mcp.server`, so drop down per method, never per program. Declare the extra capability in the constructor, keep `registerTool` for the tools, and hand-register the one method `McpServer` has no API for. + ```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_escapeHatch" const mcp = new McpServer({ name: 'catalog', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });