Merge branch 'main' into main

This commit is contained in:
Achintha Isuru 2025-06-25 11:20:55 -04:00 • committed by GitHub
commit 4954cd7294
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 319 additions and 21 deletions

View file

@ -15,7 +15,7 @@ import {
ListResourcesRequestSchema,
ListToolsRequestSchema,
SetLevelRequestSchema,
ErrorCode,
ErrorCode
} from "../types.js";
import { Transport } from "../shared/transport.js";
import { InMemoryTransport } from "../inMemory.js";

View file

@ -14,7 +14,7 @@ import {
LoggingMessageNotificationSchema,
Notification,
TextContent,
ElicitRequestSchema,
ElicitRequestSchema
} from "../types.js";
import { ResourceTemplate } from "./mcp.js";
import { completable } from "./completable.js";

View file

@ -1,20 +1,146 @@
import http from 'http';
import { jest } from '@jest/globals';
import { SSEServerTransport } from './sse.js';
import { McpServer } from './mcp.js';
import { createServer, type Server } from "node:http";
import { AddressInfo } from "node:net";
import { z } from 'zod';
import { CallToolResult, JSONRPCMessage } from 'src/types.js';
const createMockResponse = () => {
const res = {
writeHead: jest.fn<http.ServerResponse['writeHead']>(),
write: jest.fn<http.ServerResponse['write']>().mockReturnValue(true),
on: jest.fn<http.ServerResponse['on']>(),
writeHead: jest.fn<http.ServerResponse['writeHead']>().mockReturnThis(),
write: jest.fn<http.ServerResponse['write']>().mockReturnThis(),
on: jest.fn<http.ServerResponse['on']>().mockReturnThis(),
end: jest.fn<http.ServerResponse['end']>().mockReturnThis(),
};
res.writeHead.mockReturnThis();
res.on.mockReturnThis();
return res as unknown as http.ServerResponse;
return res as unknown as jest.Mocked<http.ServerResponse>;
};
/**
* Helper to create and start test HTTP server with MCP setup
*/
async function createTestServerWithSse(args: {
mockRes: http.ServerResponse;
}): Promise<{
server: Server;
transport: SSEServerTransport;
mcpServer: McpServer;
baseUrl: URL;
sessionId: string
serverPort: number;
}> {
const mcpServer = new McpServer(
{ name: "test-server", version: "1.0.0" },
{ capabilities: { logging: {} } }
);
mcpServer.tool(
"greet",
"A simple greeting tool",
{ name: z.string().describe("Name to greet") },
async ({ name }): Promise<CallToolResult> => {
return { content: [{ type: "text", text: `Hello, ${name}!` }] };
}
);
const endpoint = '/messages';
const transport = new SSEServerTransport(endpoint, args.mockRes);
const sessionId = transport.sessionId;
await mcpServer.connect(transport);
const server = createServer(async (req, res) => {
try {
await transport.handlePostMessage(req, res);
} catch (error) {
console.error("Error handling request:", error);
if (!res.headersSent) res.writeHead(500).end();
}
});
const baseUrl = await new Promise<URL>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://127.0.0.1:${addr.port}`));
});
});
const port = (server.address() as AddressInfo).port;
return { server, transport, mcpServer, baseUrl, sessionId, serverPort: port };
}
async function readAllSSEEvents(response: Response): Promise<string[]> {
const reader = response.body?.getReader();
if (!reader) throw new Error('No readable stream');
const events: string[] = [];
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
events.push(decoder.decode(value));
}
}
} finally {
reader.releaseLock();
}
return events;
}
/**
* Helper to send JSON-RPC request
*/
async function sendSsePostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string, extraHeaders?: Record<string, string>): Promise<Response> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
...extraHeaders
};
if (sessionId) {
baseUrl.searchParams.set('sessionId', sessionId);
}
return fetch(baseUrl, {
method: "POST",
headers,
body: JSON.stringify(message),
});
}
describe('SSEServerTransport', () => {
async function initializeServer(baseUrl: URL): Promise<void> {
const response = await sendSsePostRequest(baseUrl, {
jsonrpc: "2.0",
method: "initialize",
params: {
clientInfo: { name: "test-client", version: "1.0" },
protocolVersion: "2025-03-26",
capabilities: {
},
},
id: "init-1",
} as JSONRPCMessage);
expect(response.status).toBe(202);
const text = await readAllSSEEvents(response);
expect(text).toHaveLength(1);
expect(text[0]).toBe('Accepted');
}
describe('start method', () => {
it('should correctly append sessionId to a simple relative endpoint', async () => {
const mockRes = createMockResponse();
@ -105,5 +231,71 @@ describe('SSEServerTransport', () => {
`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`
);
});
/**
* Test: Tool With Request Info
*/
it("should pass request info to tool callback", async () => {
const mockRes = createMockResponse();
const { mcpServer, baseUrl, sessionId, serverPort } = await createTestServerWithSse({ mockRes });
await initializeServer(baseUrl);
mcpServer.tool(
"test-request-info",
"A simple test tool with request info",
{ name: z.string().describe("Name to greet") },
async ({ name }, { requestInfo }): Promise<CallToolResult> => {
return { content: [{ type: "text", text: `Hello, ${name}!` }, { type: "text", text: `${JSON.stringify(requestInfo)}` }] };
}
);
const toolCallMessage: JSONRPCMessage = {
jsonrpc: "2.0",
method: "tools/call",
params: {
name: "test-request-info",
arguments: {
name: "Test User",
},
},
id: "call-1",
};
const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId);
expect(response.status).toBe(202);
expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`);
const expectedMessage = {
result: {
content: [
{
type: "text",
text: "Hello, Test User!",
},
{
type: "text",
text: JSON.stringify({
headers: {
host: `127.0.0.1:${serverPort}`,
connection: 'keep-alive',
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'accept-language': '*',
'sec-fetch-mode': 'cors',
'user-agent': 'node',
'accept-encoding': 'gzip, deflate',
'content-length': '124'
},
})
},
],
},
jsonrpc: "2.0",
id: "call-1",
};
expect(mockRes.write).toHaveBeenCalledWith(`event: message\ndata: ${JSON.stringify(expectedMessage)}\n\n`);
});
});
});

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { IncomingMessage, ServerResponse } from "node:http";
import { Transport } from "../shared/transport.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from "../types.js";
import getRawBody from "raw-body";
import contentType from "content-type";
import { AuthInfo } from "./auth/types.js";
@ -19,7 +19,7 @@ export class SSEServerTransport implements Transport {
private _sessionId: string;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
/**
* Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`.
@ -86,6 +86,7 @@ export class SSEServerTransport implements Transport {
throw new Error(message);
}
const authInfo: AuthInfo | undefined = req.auth;
const requestInfo: RequestInfo = { headers: req.headers };
let body: string | unknown;
try {
@ -105,7 +106,7 @@ export class SSEServerTransport implements Transport {
}
try {
await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { authInfo });
await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { requestInfo, authInfo });
} catch {
res.writeHead(400).end(`Invalid message: ${body}`);
return;
@ -117,7 +118,7 @@ export class SSEServerTransport implements Transport {
/**
* Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST.
*/
async handleMessage(message: unknown, extra?: { authInfo?: AuthInfo }): Promise<void> {
async handleMessage(message: unknown, extra?: MessageExtraInfo): Promise<void> {
let parsedMessage: JSONRPCMessage;
try {
parsedMessage = JSONRPCMessageSchema.parse(message);

View file

@ -208,6 +208,7 @@ function expectErrorResponse(data: unknown, expectedCode: number, expectedMessag
describe("StreamableHTTPServerTransport", () => {
let server: Server;
let mcpServer: McpServer;
let transport: StreamableHTTPServerTransport;
let baseUrl: URL;
let sessionId: string;
@ -216,6 +217,7 @@ describe("StreamableHTTPServerTransport", () => {
const result = await createTestServer();
server = result.server;
transport = result.transport;
mcpServer = result.mcpServer;
baseUrl = result.baseUrl;
});
@ -347,6 +349,69 @@ describe("StreamableHTTPServerTransport", () => {
});
});
/***
* Test: Tool With Request Info
*/
it("should pass request info to tool callback", async () => {
sessionId = await initializeServer();
mcpServer.tool(
"test-request-info",
"A simple test tool with request info",
{ name: z.string().describe("Name to greet") },
async ({ name }, { requestInfo }): Promise<CallToolResult> => {
return { content: [{ type: "text", text: `Hello, ${name}!` }, { type: "text", text: `${JSON.stringify(requestInfo)}` }] };
}
);
const toolCallMessage: JSONRPCMessage = {
jsonrpc: "2.0",
method: "tools/call",
params: {
name: "test-request-info",
arguments: {
name: "Test User",
},
},
id: "call-1",
};
const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId);
expect(response.status).toBe(200);
const text = await readSSEEvent(response);
const eventLines = text.split("\n");
const dataLine = eventLines.find(line => line.startsWith("data:"));
expect(dataLine).toBeDefined();
const eventData = JSON.parse(dataLine!.substring(5));
expect(eventData).toMatchObject({
jsonrpc: "2.0",
result: {
content: [
{ type: "text", text: "Hello, Test User!" },
{ type: "text", text: expect.any(String) }
],
},
id: "call-1",
});
const requestInfo = JSON.parse(eventData.result.content[1].text);
expect(requestInfo).toMatchObject({
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
connection: 'keep-alive',
'mcp-session-id': sessionId,
'accept-language': '*',
'user-agent': expect.any(String),
'accept-encoding': expect.any(String),
'content-length': expect.any(String),
},
});
});
it("should reject requests without a valid session ID", async () => {
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList);

View file

@ -1,6 +1,6 @@
import { IncomingMessage, ServerResponse } from "node:http";
import { Transport } from "../shared/transport.js";
import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js";
import { MessageExtraInfo, RequestInfo, isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js";
import getRawBody from "raw-body";
import contentType from "content-type";
import { randomUUID } from "node:crypto";
@ -113,7 +113,7 @@ export class StreamableHTTPServerTransport implements Transport {
sessionId?: string;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
constructor(options: StreamableHTTPServerTransportOptions) {
this.sessionIdGenerator = options.sessionIdGenerator;
@ -321,6 +321,7 @@ export class StreamableHTTPServerTransport implements Transport {
}
const authInfo: AuthInfo | undefined = req.auth;
const requestInfo: RequestInfo = { headers: req.headers };
let rawMessage;
if (parsedBody !== undefined) {
@ -404,7 +405,7 @@ export class StreamableHTTPServerTransport implements Transport {
// handle each message
for (const message of messages) {
this.onmessage?.(message, { authInfo });
this.onmessage?.(message, { authInfo, requestInfo });
}
} else if (hasRequests) {
// The default behavior is to use SSE streaming
@ -439,7 +440,7 @@ export class StreamableHTTPServerTransport implements Transport {
// handle each message
for (const message of messages) {
this.onmessage?.(message, { authInfo });
this.onmessage?.(message, { authInfo, requestInfo });
}
// 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

View file

@ -22,6 +22,8 @@ import {
Result,
ServerCapabilities,
RequestMeta,
MessageExtraInfo,
RequestInfo,
} from "../types.js";
import { Transport, TransportSendOptions } from "./transport.js";
import { AuthInfo } from "../server/auth/types.js";
@ -127,6 +129,11 @@ export type RequestHandlerExtra<SendRequestT extends Request,
*/
requestId: RequestId;
/**
* The original HTTP request.
*/
requestInfo?: RequestInfo;
/**
* Sends a notification that relates to the current request being handled.
*
@ -339,7 +346,7 @@ export abstract class Protocol<
);
}
private _onrequest(request: JSONRPCRequest, extra?: { authInfo?: AuthInfo }): void {
private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): void {
const handler =
this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
@ -375,6 +382,7 @@ export abstract class Protocol<
this.request(r, resultSchema, { ...options, relatedRequestId: request.id }),
authInfo: extra?.authInfo,
requestId: request.id,
requestInfo: extra?.requestInfo
};
// Starting with Promise.resolve() puts any synchronous errors into the monad as well.

View file

@ -1,5 +1,4 @@
import { AuthInfo } from "../server/auth/types.js";
import { JSONRPCMessage, RequestId } from "../types.js";
import { JSONRPCMessage, MessageExtraInfo, RequestId } from "../types.js";
/**
* Options for sending a JSON-RPC message.
@ -66,10 +65,11 @@ export interface Transport {
/**
* Callback for when a message (request or response) is received over the connection.
*
* Includes the authInfo if the transport is authenticated.
* Includes the requestInfo and authInfo if the transport is authenticated.
*
* The requestInfo can be used to get the original request information (headers, etc.)
*/
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
/**
* The session ID generated for this connection.

View file

@ -1,4 +1,5 @@
import { z, ZodTypeAny } from "zod";
import { AuthInfo } from "./server/auth/types.js";
export const LATEST_PROTOCOL_VERSION = "2025-06-18";
export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26";
@ -1463,6 +1464,36 @@ type Flatten<T> = T extends Primitive
type Infer<Schema extends ZodTypeAny> = Flatten<z.infer<Schema>>;
/**
* Headers that are compatible with both Node.js and the browser.
*/
export type IsomorphicHeaders = Record<string, string | string[] | undefined>;
/**
* Information about the incoming request.
*/
export interface RequestInfo {
/**
* The headers of the request.
*/
headers: IsomorphicHeaders;
}
/**
* Extra information about a message.
*/
export interface MessageExtraInfo {
/**
* The request information.
*/
requestInfo?: RequestInfo;
/**
* The authentication information.
*/
authInfo?: AuthInfo;
}
/* JSON-RPC types */
export type ProgressToken = Infer<typeof ProgressTokenSchema>;
export type Cursor = Infer<typeof CursorSchema>;