Skip to content

Commit 2fd7f5f

Browse files
v2: Web standards Request object in ctx (#1822)
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
1 parent f73a5af commit 2fd7f5f

File tree

9 files changed

+81
-42
lines changed

9 files changed

+81
-42
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ When a request arrives from the remote side:
164164
3. **`Protocol._onrequest()`**:
165165
- Looks up handler in `_requestHandlers` map (keyed by method name)
166166
- Creates `BaseContext` with `signal`, `sessionId`, `sendNotification`, `sendRequest`, etc.
167-
- Calls `buildContext()` to let subclasses enrich the context (e.g., Server adds `requestInfo`)
167+
- Calls `buildContext()` to let subclasses enrich the context (e.g., Server adds HTTP request info)
168168
- Invokes handler, sends JSON-RPC response back via transport
169169
4. **Handler** was registered via `setRequestHandler('method', handler)`
170170

docs/migration-SKILL.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,16 +298,17 @@ Note: the third argument (`metadata`) is required — pass `{}` if no metadata.
298298

299299
## 7. Headers API
300300

301-
Transport constructors and `RequestInfo.headers` now use the Web Standard `Headers` object instead of plain objects.
301+
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.
302302

303303
```typescript
304-
// v1: plain object, bracket access
304+
// v1: plain object, bracket access, custom RequestInfo
305305
headers: { 'Authorization': 'Bearer token' }
306306
extra.requestInfo?.headers['mcp-session-id']
307307

308-
// v2: Headers object, .get() access
308+
// v2: Headers object, .get() access, standard Web Request
309309
headers: new Headers({ 'Authorization': 'Bearer token' })
310310
ctx.http?.req?.headers.get('mcp-session-id')
311+
new URL(ctx.http?.req?.url).searchParams.get('debug')
311312
```
312313

313314
## 8. Removed Server Features
@@ -391,7 +392,7 @@ Request/notification params remain fully typed. Remove unused schema imports aft
391392
| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` |
392393
| `extra.authInfo` | `ctx.http?.authInfo` |
393394
| `extra.sessionId` | `ctx.sessionId` |
394-
| `extra.requestInfo` | `ctx.http?.req` (only `ServerContext`) |
395+
| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only `ServerContext`) |
395396
| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only `ServerContext`) |
396397
| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only `ServerContext`) |
397398
| `extra.taskStore` | `ctx.task?.store` |

docs/migration.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,12 @@ const transport = new StreamableHTTPClientTransport(url, {
174174
}
175175
});
176176

177-
// Reading headers in a request handler
177+
// Reading headers in a request handler (ctx.http.req is the standard Web Request object)
178178
const sessionId = ctx.http?.req?.headers.get('mcp-session-id');
179+
180+
// Reading query parameters
181+
const url = new URL(ctx.http!.req!.url);
182+
const debug = url.searchParams.get('debug');
179183
```
180184

181185
### `McpServer.tool()`, `.prompt()`, `.resource()` removed
@@ -512,7 +516,7 @@ The `RequestHandlerExtra` type has been replaced with a structured context type
512516
| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` |
513517
| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` |
514518
| `extra.authInfo` | `ctx.http?.authInfo` |
515-
| `extra.requestInfo` | `ctx.http?.req` (only on `ServerContext`) |
519+
| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) |
516520
| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) |
517521
| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) |
518522
| `extra.sessionId` | `ctx.sessionId` |
@@ -535,7 +539,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
535539

536540
```typescript
537541
server.setRequestHandler('tools/call', async (request, ctx) => {
538-
const headers = ctx.http?.req?.headers;
542+
const headers = ctx.http?.req?.headers; // standard Web Request object
539543
const taskStore = ctx.task?.store;
540544
await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } });
541545
return { content: [{ type: 'text', text: 'result' }] };

packages/core/src/shared/protocol.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import type {
2424
RelatedTaskMetadata,
2525
Request,
2626
RequestId,
27-
RequestInfo,
2827
RequestMeta,
2928
RequestMethod,
3029
RequestTypeMap,
@@ -257,9 +256,9 @@ export type ServerContext = BaseContext & {
257256

258257
http?: {
259258
/**
260-
* The original HTTP request information.
259+
* The original HTTP request.
261260
*/
262-
req?: RequestInfo;
261+
req?: globalThis.Request;
263262

264263
/**
265264
* Closes the SSE stream for this request, triggering client reconnection.
@@ -392,7 +391,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
392391

393392
/**
394393
* Builds the context object for request handlers. Subclasses must override
395-
* to return the appropriate context type (e.g., ServerContext adds requestInfo).
394+
* to return the appropriate context type (e.g., ServerContext adds HTTP request info).
396395
*/
397396
protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT;
398397

packages/core/src/shared/transport.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,9 @@ export interface Transport {
110110
/**
111111
* Callback for when a message (request or response) is received over the connection.
112112
*
113-
* Includes the {@linkcode MessageExtraInfo.requestInfo | requestInfo} and {@linkcode MessageExtraInfo.authInfo | authInfo} if the transport is authenticated.
113+
* Includes the {@linkcode MessageExtraInfo.request | request} and {@linkcode MessageExtraInfo.authInfo | authInfo} if the transport is authenticated.
114114
*
115-
* The {@linkcode MessageExtraInfo.requestInfo | requestInfo} can be used to get the original request information (headers, etc.)
115+
* The {@linkcode MessageExtraInfo.request | request} can be used to get the original request information (headers, etc.)
116116
*/
117117
onmessage?: (<T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void) | undefined;
118118

packages/core/src/types/types.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -513,24 +513,14 @@ export type ListChangedHandlers = {
513513
resources?: ListChangedOptions<Resource>;
514514
};
515515

516-
/**
517-
* Information about the incoming request.
518-
*/
519-
export interface RequestInfo {
520-
/**
521-
* The headers of the request.
522-
*/
523-
headers: Headers;
524-
}
525-
526516
/**
527517
* Extra information about a message.
528518
*/
529519
export interface MessageExtraInfo {
530520
/**
531-
* The request information.
521+
* The original HTTP request.
532522
*/
533-
requestInfo?: RequestInfo;
523+
request?: globalThis.Request;
534524

535525
/**
536526
* The authentication information.

packages/middleware/node/test/streamableHttp.test.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ describe('Zod v4', () => {
396396
/***
397397
* Test: Tool With Request Info
398398
*/
399-
it('should pass request info to tool callback', async () => {
399+
it('should expose the full Request object to tool handlers', async () => {
400400
sessionId = await initializeServer();
401401

402402
mcpServer.registerTool(
@@ -406,10 +406,11 @@ describe('Zod v4', () => {
406406
inputSchema: z.object({ name: z.string().describe('Name to greet') })
407407
},
408408
async ({ name }, ctx): Promise<CallToolResult> => {
409-
// Convert Headers object to plain object for JSON serialization
410-
// Headers is a Web API class that doesn't serialize with JSON.stringify
409+
const req = ctx.http?.req;
411410
const serializedRequestInfo = {
412-
headers: Object.fromEntries(ctx.http?.req?.headers ?? new Headers())
411+
headers: Object.fromEntries(req?.headers ?? new Headers()),
412+
url: req?.url,
413+
method: req?.method
413414
};
414415
return {
415416
content: [
@@ -464,10 +465,58 @@ describe('Zod v4', () => {
464465
'user-agent': expect.any(String),
465466
'accept-encoding': expect.any(String),
466467
'content-length': expect.any(String)
467-
}
468+
},
469+
url: expect.stringContaining(baseUrl.pathname),
470+
method: 'POST'
468471
});
469472
});
470473

474+
it('should expose query parameters via the Request object', async () => {
475+
sessionId = await initializeServer();
476+
477+
mcpServer.registerTool(
478+
'test-query-params',
479+
{
480+
description: 'A tool that reads query params',
481+
inputSchema: z.object({})
482+
},
483+
async (_args, ctx): Promise<CallToolResult> => {
484+
const req = ctx.http?.req;
485+
const url = new URL(req!.url);
486+
const params = Object.fromEntries(url.searchParams);
487+
return {
488+
content: [{ type: 'text', text: JSON.stringify(params) }]
489+
};
490+
}
491+
);
492+
493+
const toolCallMessage: JSONRPCMessage = {
494+
jsonrpc: '2.0',
495+
method: 'tools/call',
496+
params: {
497+
name: 'test-query-params',
498+
arguments: {}
499+
},
500+
id: 'call-2'
501+
};
502+
503+
// Send to a URL with query parameters
504+
const urlWithParams = new URL(baseUrl.toString());
505+
urlWithParams.searchParams.set('foo', 'bar');
506+
urlWithParams.searchParams.set('debug', 'true');
507+
508+
const response = await sendPostRequest(urlWithParams, toolCallMessage, sessionId);
509+
expect(response.status).toBe(200);
510+
511+
const text = await readSSEEvent(response);
512+
const dataLine = text.split('\n').find(line => line.startsWith('data:'));
513+
expect(dataLine).toBeDefined();
514+
515+
const eventData = JSON.parse(dataLine!.slice(5));
516+
const queryParams = JSON.parse(eventData.result.content[0].text);
517+
expect(queryParams).toEqual({ foo: 'bar', debug: 'true' });
518+
});
519+
471520
it('should reject requests without a valid session ID', async () => {
472521
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList);
473522

packages/server/src/server/server.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,7 @@ export class Server extends Protocol<ServerContext> {
155155

156156
protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext {
157157
// Only create http when there's actual HTTP transport info or auth info
158-
const hasHttpInfo =
159-
ctx.http || transportInfo?.requestInfo || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream;
158+
const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream;
160159
return {
161160
...ctx,
162161
mcpReq: {
@@ -168,7 +167,7 @@ export class Server extends Protocol<ServerContext> {
168167
http: hasHttpInfo
169168
? {
170169
...ctx.http,
171-
req: transportInfo?.requestInfo,
170+
req: transportInfo?.request,
172171
closeSSE: transportInfo?.closeSSEStream,
173172
closeStandaloneSSE: transportInfo?.closeStandaloneSSEStream
174173
}

packages/server/src/server/streamableHttp.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport.
88
*/
99

10-
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, RequestInfo, Transport } from '@modelcontextprotocol/core';
10+
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core';
1111
import {
1212
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
1313
isInitializeRequest,
@@ -634,10 +634,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
634634
return this.createJsonErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json');
635635
}
636636

637-
// Build request info from headers
638-
const requestInfo: RequestInfo = {
639-
headers: req.headers
640-
};
637+
const request = req;
641638

642639
let rawMessage;
643640
if (options?.parsedBody === undefined) {
@@ -707,7 +704,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
707704
if (!hasRequests) {
708705
// if it only contains notifications or responses, return 202
709706
for (const message of messages) {
710-
this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo });
707+
this.onmessage?.(message, { authInfo: options?.authInfo, request });
711708
}
712709
return new Response(null, { status: 202 });
713710
}
@@ -741,7 +738,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
741738
}
742739

743740
for (const message of messages) {
744-
this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo });
741+
this.onmessage?.(message, { authInfo: options?.authInfo, request });
745742
}
746743
});
747744
}
@@ -811,7 +808,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
811808
};
812809
}
813810

814-
this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo, closeSSEStream, closeStandaloneSSEStream });
811+
this.onmessage?.(message, { authInfo: options?.authInfo, request, closeSSEStream, closeStandaloneSSEStream });
815812
}
816813
// The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses
817814
// This will be handled by the send() method when responses are ready

0 commit comments

Comments
 (0)