raw request propagation in tools - implementation, unit tests, types

This commit is contained in:
Konstantin Konstantinov 2025-06-14 01:08:02 +03:00
commit db9ba7b419
No known key found for this signature in database
14 changed files with 641 additions and 226 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@modelcontextprotocol/sdk",
"version": "1.11.4",
"version": "1.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@modelcontextprotocol/sdk",
"version": "1.11.4",
"version": "1.12.0",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/sdk",
"version": "1.12.0",
"version": "1.12.2",
"description": "Model Context Protocol implementation for TypeScript",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View file

@ -20,7 +20,14 @@ import {
import { Transport } from "../shared/transport.js";
import { Server } from "../server/index.js";
import { InMemoryTransport } from "../inMemory.js";
import { RequestInfo } from "../server/types/types.js";
const mockRequestInfo: RequestInfo = {
headers: {
'content-type': 'application/json',
'accept': 'application/json',
},
};
/***
* Test: Initialize with Matching Protocol Version
*/
@ -42,7 +49,7 @@ test("should initialize with matching protocol version", async () => {
},
instructions: "test instructions",
},
});
}, { requestInfo: mockRequestInfo });
}
return Promise.resolve();
}),
@ -100,7 +107,7 @@ test("should initialize with supported older protocol version", async () => {
version: "1.0",
},
},
});
}, { requestInfo: mockRequestInfo });
}
return Promise.resolve();
}),
@ -150,7 +157,7 @@ test("should reject unsupported protocol version", async () => {
version: "1.0",
},
},
});
}, { requestInfo: mockRequestInfo });
}
return Promise.resolve();
}),

View file

@ -19,6 +19,14 @@ import {
import { Transport } from "../shared/transport.js";
import { InMemoryTransport } from "../inMemory.js";
import { Client } from "../client/index.js";
import { RequestInfo } from "./types/types.js";
const mockRequestInfo: RequestInfo = {
headers: {
'content-type': 'application/json',
'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
},
};
test("should accept latest protocol version", async () => {
let sendPromiseResolve: (value: unknown) => void;
@ -77,7 +85,7 @@ test("should accept latest protocol version", async () => {
version: "1.0",
},
},
});
}, { requestInfo: mockRequestInfo });
await expect(sendPromise).resolves.toBeUndefined();
});
@ -138,7 +146,7 @@ test("should accept supported older protocol version", async () => {
version: "1.0",
},
},
});
}, { requestInfo: mockRequestInfo });
await expect(sendPromise).resolves.toBeUndefined();
});
@ -198,7 +206,7 @@ test("should handle unsupported protocol version", async () => {
version: "1.0",
},
},
});
}, { requestInfo: mockRequestInfo });
await expect(sendPromise).resolves.toBeUndefined();
});

View file

@ -18,6 +18,14 @@ import {
import { ResourceTemplate } from "./mcp.js";
import { completable } from "./completable.js";
import { UriTemplate } from "../shared/uriTemplate.js";
import { RequestInfo } from "./types/types.js";
const mockRequestInfo: RequestInfo = {
headers: {
'content-type': 'application/json',
'accept': 'application/json',
},
};
describe("McpServer", () => {
/***
@ -212,7 +220,8 @@ describe("ResourceTemplate", () => {
signal: abortController.signal,
requestId: 'not-implemented',
sendRequest: () => { throw new Error("Not implemented") },
sendNotification: () => { throw new Error("Not implemented") }
sendNotification: () => { throw new Error("Not implemented") },
requestInfo: mockRequestInfo
});
expect(result?.resources).toHaveLength(1);
expect(list).toHaveBeenCalled();
@ -913,18 +922,10 @@ describe("tool()", () => {
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.tool(
"test",
@ -1056,17 +1057,10 @@ describe("tool()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
// Register a tool with outputSchema
mcpServer.registerTool(
@ -1169,17 +1163,10 @@ describe("tool()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
// Register a tool with outputSchema that returns only content without structuredContent
mcpServer.registerTool(
@ -1233,17 +1220,10 @@ describe("tool()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
// Register a tool with outputSchema that returns invalid data
mcpServer.registerTool(
@ -1308,17 +1288,10 @@ describe("tool()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
let receivedSessionId: string | undefined;
mcpServer.tool("test-tool", async (extra) => {
@ -1364,17 +1337,10 @@ describe("tool()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
let receivedRequestId: string | number | undefined;
mcpServer.tool("request-id-test", async (extra) => {
@ -1423,17 +1389,10 @@ describe("tool()", () => {
{ capabilities: { logging: {} } },
);
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
let receivedLogMessage: string | undefined;
const loggingMessage = "hello here is log message 1";
@ -1480,17 +1439,10 @@ describe("tool()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.tool(
"test",
@ -1546,17 +1498,10 @@ describe("tool()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.tool("error-test", async () => {
throw new Error("Tool execution failed");
@ -1598,17 +1543,10 @@ describe("tool()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.tool("test-tool", async () => ({
content: [
@ -2392,6 +2330,48 @@ describe("resource()", () => {
).rejects.toThrow(/Resource test:\/\/nonexistent not found/);
});
/***
* Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion
*/
test("should advertise support for completion when a resource template with a complete callback is defined", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.resource(
"test",
new ResourceTemplate("test://resource/{category}", {
list: undefined,
complete: {
category: () => ["books", "movies", "music"],
},
}),
async () => ({
contents: [
{
uri: "test://resource/test",
text: "Test content",
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
expect(client.getServerCapabilities()).toMatchObject({ completions: {} })
})
/***
* Test: Resource Template Parameter Completion
*/
@ -2401,17 +2381,10 @@ describe("resource()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
resources: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.resource(
"test",
@ -2469,17 +2442,10 @@ describe("resource()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
resources: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.resource(
"test",
@ -2540,17 +2506,10 @@ describe("resource()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
resources: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
let receivedRequestId: string | number | undefined;
mcpServer.resource("request-id-test", "test://resource", async (_uri, extra) => {
@ -3052,17 +3011,10 @@ describe("prompt()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
prompts: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.prompt(
"test",
@ -3258,17 +3210,10 @@ describe("prompt()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
prompts: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.prompt("test-prompt", async () => ({
messages: [
@ -3303,6 +3248,49 @@ describe("prompt()", () => {
).rejects.toThrow(/Prompt nonexistent-prompt not found/);
});
/***
* Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion
*/
test("should advertise support for completion when a prompt with a completable argument is defined", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.prompt(
"test-prompt",
{
name: completable(z.string(), () => ["Alice", "Bob", "Charlie"]),
},
async ({ name }) => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: `Hello ${name}`,
},
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
expect(client.getServerCapabilities()).toMatchObject({ completions: {} })
})
/***
* Test: Prompt Argument Completion
*/
@ -3312,17 +3300,10 @@ describe("prompt()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
prompts: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.prompt(
"test-prompt",
@ -3380,17 +3361,10 @@ describe("prompt()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
prompts: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.prompt(
"test-prompt",
@ -3450,17 +3424,10 @@ describe("prompt()", () => {
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
prompts: {},
},
},
);
const client = new Client({
name: "test client",
version: "1.0",
});
let receivedRequestId: string | number | undefined;
mcpServer.prompt("request-id-test", async (extra) => {

View file

@ -236,6 +236,10 @@ export class McpServer {
CompleteRequestSchema.shape.method.value,
);
this.server.registerCapabilities({
completions: {},
});
this.server.setRequestHandler(
CompleteRequestSchema,
async (request): Promise<CompleteResult> => {

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

@ -5,6 +5,7 @@ import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import getRawBody from "raw-body";
import contentType from "content-type";
import { AuthInfo } from "./auth/types.js";
import { MessageExtraInfo, RequestInfo } from "./types/types.js";
import { URL } from 'url';
const MAXIMUM_MESSAGE_SIZE = "4mb";
@ -20,7 +21,7 @@ export class SSEServerTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
onmessage?: (message: JSONRPCMessage, extra: { authInfo?: AuthInfo, requestInfo: RequestInfo }) => void;
/**
* Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`.
@ -87,6 +88,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 {
@ -106,7 +108,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;
@ -118,7 +120,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

@ -206,6 +206,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;
@ -214,6 +215,7 @@ describe("StreamableHTTPServerTransport", () => {
const result = await createTestServer();
server = result.server;
transport = result.transport;
mcpServer = result.mcpServer;
baseUrl = result.baseUrl;
});
@ -345,6 +347,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

@ -5,6 +5,7 @@ import getRawBody from "raw-body";
import contentType from "content-type";
import { randomUUID } from "node:crypto";
import { AuthInfo } from "./auth/types.js";
import { MessageExtraInfo, RequestInfo } from "./types/types.js";
const MAXIMUM_MESSAGE_SIZE = "4mb";
@ -113,7 +114,7 @@ export class StreamableHTTPServerTransport implements Transport {
sessionId?: string | undefined;
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;
@ -318,6 +319,7 @@ export class StreamableHTTPServerTransport implements Transport {
}
const authInfo: AuthInfo | undefined = req.auth;
const requestInfo: RequestInfo = { headers: req.headers };
let rawMessage;
if (parsedBody !== undefined) {
@ -395,7 +397,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
@ -430,7 +432,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

31
src/server/types/types.ts Normal file
View file

@ -0,0 +1,31 @@
import { AuthInfo } from "../auth/types.js";
/**
* 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;
}

View file

@ -27,9 +27,11 @@ class MockTransport implements Transport {
describe("protocol tests", () => {
let protocol: Protocol<Request, Notification, Result>;
let transport: MockTransport;
let sendSpy: jest.SpyInstance;
beforeEach(() => {
transport = new MockTransport();
sendSpy = jest.spyOn(transport, 'send');
protocol = new (class extends Protocol<Request, Notification, Result> {
protected assertCapabilityForMethod(): void {}
protected assertNotificationCapability(): void {}
@ -63,6 +65,130 @@ describe("protocol tests", () => {
expect(oncloseMock).toHaveBeenCalled();
});
describe("_meta preservation with onprogress", () => {
test("should preserve existing _meta when adding progressToken", async () => {
await protocol.connect(transport);
const request = {
method: "example",
params: {
data: "test",
_meta: {
customField: "customValue",
anotherField: 123
}
}
};
const mockSchema: ZodType<{ result: string }> = z.object({
result: z.string(),
});
const onProgressMock = jest.fn();
protocol.request(request, mockSchema, {
onprogress: onProgressMock,
});
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
method: "example",
params: {
data: "test",
_meta: {
customField: "customValue",
anotherField: 123,
progressToken: expect.any(Number)
}
},
jsonrpc: "2.0",
id: expect.any(Number)
}), expect.any(Object));
});
test("should create _meta with progressToken when no _meta exists", async () => {
await protocol.connect(transport);
const request = {
method: "example",
params: {
data: "test"
}
};
const mockSchema: ZodType<{ result: string }> = z.object({
result: z.string(),
});
const onProgressMock = jest.fn();
protocol.request(request, mockSchema, {
onprogress: onProgressMock,
});
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
method: "example",
params: {
data: "test",
_meta: {
progressToken: expect.any(Number)
}
},
jsonrpc: "2.0",
id: expect.any(Number)
}), expect.any(Object));
});
test("should not modify _meta when onprogress is not provided", async () => {
await protocol.connect(transport);
const request = {
method: "example",
params: {
data: "test",
_meta: {
customField: "customValue"
}
}
};
const mockSchema: ZodType<{ result: string }> = z.object({
result: z.string(),
});
protocol.request(request, mockSchema);
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
method: "example",
params: {
data: "test",
_meta: {
customField: "customValue"
}
},
jsonrpc: "2.0",
id: expect.any(Number)
}), expect.any(Object));
});
test("should handle params being undefined with onprogress", async () => {
await protocol.connect(transport);
const request = {
method: "example"
};
const mockSchema: ZodType<{ result: string }> = z.object({
result: z.string(),
});
const onProgressMock = jest.fn();
protocol.request(request, mockSchema, {
onprogress: onProgressMock,
});
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
method: "example",
params: {
_meta: {
progressToken: expect.any(Number)
}
},
jsonrpc: "2.0",
id: expect.any(Number)
}), expect.any(Object));
});
});
describe("progress notification timeout behavior", () => {
beforeEach(() => {
jest.useFakeTimers();

View file

@ -25,6 +25,7 @@ import {
} from "../types.js";
import { Transport, TransportSendOptions } from "./transport.js";
import { AuthInfo } from "../server/auth/types.js";
import { MessageExtraInfo, RequestInfo } from "../server/types/types.js";
/**
* Callback for progress notifications.
@ -127,6 +128,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 +345,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 +381,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.
@ -541,7 +548,10 @@ export abstract class Protocol<
this._progressHandlers.set(messageId, options.onprogress);
jsonrpcRequest.params = {
...request.params,
_meta: { progressToken: messageId },
_meta: {
...(request.params?._meta || {}),
progressToken: messageId
},
};
}

View file

@ -1,4 +1,4 @@
import { AuthInfo } from "../server/auth/types.js";
import { MessageExtraInfo } from "../server/types/types.js";
import { JSONRPCMessage, RequestId } from "../types.js";
/**
@ -66,10 +66,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.