raw request propagation in tools - implementation, unit tests, types
This commit is contained in:
parent
df0d9c4ffc
commit
db9ba7b419
14 changed files with 641 additions and 226 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -236,6 +236,10 @@ export class McpServer {
|
|||
CompleteRequestSchema.shape.method.value,
|
||||
);
|
||||
|
||||
this.server.registerCapabilities({
|
||||
completions: {},
|
||||
});
|
||||
|
||||
this.server.setRequestHandler(
|
||||
CompleteRequestSchema,
|
||||
async (request): Promise<CompleteResult> => {
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
31
src/server/types/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue