Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/raw-request-params.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- prettier-ignore -->
```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 } } });

Expand All @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Record<string, unknown>>;

/**
* The JSON-RPC ID of the request being handled.
*/
Expand Down Expand Up @@ -1070,6 +1091,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
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 }),
Expand Down
183 changes: 183 additions & 0 deletions packages/server/test/server/rawRequestParams.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
handler: (request: { params: { name: string; arguments?: Record<string, unknown> } }, 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<JSONRPCMessage | { transportError: string }>(resolve => {
peer.onmessage = resolve;
server.onerror = error => resolve({ transportError: error.message });
});
const wireParams = { ...params, _meta: { ...envelope, ...(params._meta as Record<string, unknown> | 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<unknown>((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);
});
});
Loading