diff --git a/.changeset/raw-request-params.md b/.changeset/raw-request-params.md new file mode 100644 index 0000000000..9832b214ab --- /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 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 eb04933fee..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 } } }); @@ -145,6 +146,29 @@ 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 params before handler parsing + +For compatibility with an existing non-standard caller, set +`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. + +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 +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..ebba3f2a63 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -70,6 +70,18 @@ export type ProgressCallback = (progress: Progress) => void; * Additional initialization options. */ export type ProtocolOptions = { + /** + * 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 + * 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 +353,15 @@ export type BaseContext = { * Information about the MCP request being handled. */ mcpReq: { + /** + * 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. + */ + rawParams?: Readonly>; + /** * The JSON-RPC ID of the request being handled. */ @@ -1070,6 +1091,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..617068acc6 --- /dev/null +++ b/packages/server/test/server/rawRequestParams.test.ts @@ -0,0 +1,183 @@ +import { PassThrough } from 'node:stream'; + +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'; +import { StdioServerTransport } from '../../src/server/stdio'; + +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) } }; + // 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 }; + } + + 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('preserves unknown siblings 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('preserves siblings after stdio transport metadata normalization', async () => { + 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) => { + 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 => { + 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); + }); +});