v2: Web standards Request object in ctx (#1822)

Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
This commit is contained in:
Konstantin Konstantinov 2026-03-31 14:02:37 +03:00 • committed by GitHub
commit 2fd7f5ff26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 81 additions and 42 deletions

View file

@ -164,7 +164,7 @@ When a request arrives from the remote side:
3. **`Protocol._onrequest()`**:
- Looks up handler in `_requestHandlers` map (keyed by method name)
- Creates `BaseContext` with `signal`, `sessionId`, `sendNotification`, `sendRequest`, etc.
- Calls `buildContext()` to let subclasses enrich the context (e.g., Server adds `requestInfo`)
- Calls `buildContext()` to let subclasses enrich the context (e.g., Server adds HTTP request info)
- Invokes handler, sends JSON-RPC response back via transport
4. **Handler** was registered via `setRequestHandler('method', handler)`

View file

@ -298,16 +298,17 @@ Note: the third argument (`metadata`) is required — pass `{}` if no metadata.
## 7. Headers API
Transport constructors and `RequestInfo.headers` now use the Web Standard `Headers` object instead of plain objects.
Transport constructors now use the Web Standard `Headers` object instead of plain objects. The custom `RequestInfo` type has been replaced with the standard Web `Request` object, giving access to headers, URL, query parameters, and method.
```typescript
// v1: plain object, bracket access
// v1: plain object, bracket access, custom RequestInfo
headers: { 'Authorization': 'Bearer token' }
extra.requestInfo?.headers['mcp-session-id']
// v2: Headers object, .get() access
// v2: Headers object, .get() access, standard Web Request
headers: new Headers({ 'Authorization': 'Bearer token' })
ctx.http?.req?.headers.get('mcp-session-id')
new URL(ctx.http?.req?.url).searchParams.get('debug')
```
## 8. Removed Server Features
@ -391,7 +392,7 @@ Request/notification params remain fully typed. Remove unused schema imports aft
| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` |
| `extra.authInfo` | `ctx.http?.authInfo` |
| `extra.sessionId` | `ctx.sessionId` |
| `extra.requestInfo` | `ctx.http?.req` (only `ServerContext`) |
| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only `ServerContext`) |
| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only `ServerContext`) |
| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only `ServerContext`) |
| `extra.taskStore` | `ctx.task?.store` |

View file

@ -174,8 +174,12 @@ const transport = new StreamableHTTPClientTransport(url, {
}
});
// Reading headers in a request handler
// Reading headers in a request handler (ctx.http.req is the standard Web Request object)
const sessionId = ctx.http?.req?.headers.get('mcp-session-id');
// Reading query parameters
const url = new URL(ctx.http!.req!.url);
const debug = url.searchParams.get('debug');
```
### `McpServer.tool()`, `.prompt()`, `.resource()` removed
@ -512,7 +516,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` (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` |
@ -535,7 +539,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
```typescript
server.setRequestHandler('tools/call', async (request, ctx) => {
const headers = ctx.http?.req?.headers;
const headers = ctx.http?.req?.headers; // standard Web Request object
const taskStore = ctx.task?.store;
await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } });
return { content: [{ type: 'text', text: 'result' }] };

View file

@ -24,7 +24,6 @@ import type {
RelatedTaskMetadata,
Request,
RequestId,
RequestInfo,
RequestMeta,
RequestMethod,
RequestTypeMap,
@ -257,9 +256,9 @@ export type ServerContext = BaseContext & {
http?: {
/**
* The original HTTP request information.
* The original HTTP request.
*/
req?: RequestInfo;
req?: globalThis.Request;
/**
* Closes the SSE stream for this request, triggering client reconnection.
@ -392,7 +391,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
/**
* Builds the context object for request handlers. Subclasses must override
* to return the appropriate context type (e.g., ServerContext adds requestInfo).
* to return the appropriate context type (e.g., ServerContext adds HTTP request info).
*/
protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT;

View file

@ -110,9 +110,9 @@ export interface Transport {
/**
* Callback for when a message (request or response) is received over the connection.
*
* Includes the {@linkcode MessageExtraInfo.requestInfo | requestInfo} and {@linkcode MessageExtraInfo.authInfo | authInfo} if the transport is authenticated.
* Includes the {@linkcode MessageExtraInfo.request | request} and {@linkcode MessageExtraInfo.authInfo | authInfo} if the transport is authenticated.
*
* The {@linkcode MessageExtraInfo.requestInfo | requestInfo} can be used to get the original request information (headers, etc.)
* The {@linkcode MessageExtraInfo.request | request} can be used to get the original request information (headers, etc.)
*/
onmessage?: (<T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void) | undefined;

View file

@ -513,24 +513,14 @@ export type ListChangedHandlers = {
resources?: ListChangedOptions<Resource>;
};
/**
* Information about the incoming request.
*/
export interface RequestInfo {
/**
* The headers of the request.
*/
headers: Headers;
}
/**
* Extra information about a message.
*/
export interface MessageExtraInfo {
/**
* The request information.
* The original HTTP request.
*/
requestInfo?: RequestInfo;
request?: globalThis.Request;
/**
* The authentication information.

View file

@ -396,7 +396,7 @@ describe('Zod v4', () => {
/***
* Test: Tool With Request Info
*/
it('should pass request info to tool callback', async () => {
it('should expose the full Request object to tool handlers', async () => {
sessionId = await initializeServer();
mcpServer.registerTool(
@ -406,10 +406,11 @@ describe('Zod v4', () => {
inputSchema: z.object({ name: z.string().describe('Name to greet') })
},
async ({ name }, ctx): Promise<CallToolResult> => {
// Convert Headers object to plain object for JSON serialization
// Headers is a Web API class that doesn't serialize with JSON.stringify
const req = ctx.http?.req;
const serializedRequestInfo = {
headers: Object.fromEntries(ctx.http?.req?.headers ?? new Headers())
headers: Object.fromEntries(req?.headers ?? new Headers()),
url: req?.url,
method: req?.method
};
return {
content: [
@ -464,10 +465,58 @@ describe('Zod v4', () => {
'user-agent': expect.any(String),
'accept-encoding': expect.any(String),
'content-length': expect.any(String)
}
},
url: expect.stringContaining(baseUrl.pathname),
method: 'POST'
});
});
it('should expose query parameters via the Request object', async () => {
sessionId = await initializeServer();
mcpServer.registerTool(
'test-query-params',
{
description: 'A tool that reads query params',
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult> => {
const req = ctx.http?.req;
const url = new URL(req!.url);
const params = Object.fromEntries(url.searchParams);
return {
content: [{ type: 'text', text: JSON.stringify(params) }]
};
}
);
const toolCallMessage: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'tools/call',
params: {
name: 'test-query-params',
arguments: {}
},
id: 'call-2'
};
// Send to a URL with query parameters
const urlWithParams = new URL(baseUrl.toString());
urlWithParams.searchParams.set('foo', 'bar');
urlWithParams.searchParams.set('debug', 'true');
const response = await sendPostRequest(urlWithParams, toolCallMessage, sessionId);
expect(response.status).toBe(200);
const text = await readSSEEvent(response);
const dataLine = text.split('\n').find(line => line.startsWith('data:'));
expect(dataLine).toBeDefined();
const eventData = JSON.parse(dataLine!.slice(5));
const queryParams = JSON.parse(eventData.result.content[0].text);
expect(queryParams).toEqual({ foo: 'bar', debug: 'true' });
});
it('should reject requests without a valid session ID', async () => {
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList);

View file

@ -155,8 +155,7 @@ export class Server extends Protocol<ServerContext> {
protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext {
// Only create http when there's actual HTTP transport info or auth info
const hasHttpInfo =
ctx.http || transportInfo?.requestInfo || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream;
const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream;
return {
...ctx,
mcpReq: {
@ -168,7 +167,7 @@ export class Server extends Protocol<ServerContext> {
http: hasHttpInfo
? {
...ctx.http,
req: transportInfo?.requestInfo,
req: transportInfo?.request,
closeSSE: transportInfo?.closeSSEStream,
closeStandaloneSSE: transportInfo?.closeStandaloneSSEStream
}

View file

@ -7,7 +7,7 @@
* For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport.
*/
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, RequestInfo, Transport } from '@modelcontextprotocol/core';
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core';
import {
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
isInitializeRequest,
@ -634,10 +634,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
return this.createJsonErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json');
}
// Build request info from headers
const requestInfo: RequestInfo = {
headers: req.headers
};
const request = req;
let rawMessage;
if (options?.parsedBody === undefined) {
@ -707,7 +704,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
if (!hasRequests) {
// if it only contains notifications or responses, return 202
for (const message of messages) {
this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo });
this.onmessage?.(message, { authInfo: options?.authInfo, request });
}
return new Response(null, { status: 202 });
}
@ -741,7 +738,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
}
for (const message of messages) {
this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo });
this.onmessage?.(message, { authInfo: options?.authInfo, request });
}
});
}
@ -811,7 +808,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
};
}
this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo, closeSSEStream, closeStandaloneSSEStream });
this.onmessage?.(message, { authInfo: options?.authInfo, request, closeSSEStream, closeStandaloneSSEStream });
}
// The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses
// This will be handled by the send() method when responses are ready