v2: Web standards Request object in ctx (#1822)
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
This commit is contained in:
parent
f73a5af4bc
commit
2fd7f5ff26
6 changed files with 81 additions and 42 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue