Merge branch 'main' into feat/structured-content-with-type-safety
This commit is contained in:
commit
a40b353fb6
13 changed files with 2054 additions and 136 deletions
|
|
@ -99,7 +99,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
|
|||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error looking up client:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
|
@ -146,7 +145,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
|
|||
if (error instanceof OAuthError) {
|
||||
res.redirect(302, createErrorRedirect(redirect_uri, error, state));
|
||||
} else {
|
||||
console.error("Unexpected error during authorization:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.redirect(302, createErrorRedirect(redirect_uri, serverError, state));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ export function clientRegistrationHandler({
|
|||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error registering client:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
InvalidRequestError,
|
||||
ServerError,
|
||||
TooManyRequestsError,
|
||||
OAuthError
|
||||
OAuthError,
|
||||
} from "../errors.js";
|
||||
|
||||
export type RevocationHandlerOptions = {
|
||||
|
|
@ -21,7 +21,10 @@ export type RevocationHandlerOptions = {
|
|||
rateLimit?: Partial<RateLimitOptions> | false;
|
||||
};
|
||||
|
||||
export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler {
|
||||
export function revocationHandler({
|
||||
provider,
|
||||
rateLimit: rateLimitConfig,
|
||||
}: RevocationHandlerOptions): RequestHandler {
|
||||
if (!provider.revokeToken) {
|
||||
throw new Error("Auth provider does not support revoking tokens");
|
||||
}
|
||||
|
|
@ -37,21 +40,25 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
|
|||
|
||||
// Apply rate limiting unless explicitly disabled
|
||||
if (rateLimitConfig !== false) {
|
||||
router.use(rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 50, // 50 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(),
|
||||
...rateLimitConfig
|
||||
}));
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 50, // 50 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError(
|
||||
"You have exceeded the rate limit for token revocation requests"
|
||||
).toResponseObject(),
|
||||
...rateLimitConfig,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate and extract client details
|
||||
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
try {
|
||||
const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body);
|
||||
|
|
@ -62,7 +69,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
|
|||
const client = req.client;
|
||||
if (!client) {
|
||||
// This should never happen
|
||||
console.error("Missing client information after authentication");
|
||||
throw new ServerError("Internal Server Error");
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +79,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
|
|||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error revoking token:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
const client = req.client;
|
||||
if (!client) {
|
||||
// This should never happen
|
||||
console.error("Missing client information after authentication");
|
||||
throw new ServerError("Internal Server Error");
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +142,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error exchanging token:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,6 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad
|
|||
} else if (error instanceof OAuthError) {
|
||||
res.status(400).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error authenticating bearer token:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew
|
|||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error authenticating client:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
SetLevelRequestSchema,
|
||||
ErrorCode,
|
||||
ErrorCode
|
||||
} from "../types.js";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
import { InMemoryTransport } from "../inMemory.js";
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
LoggingMessageNotificationSchema,
|
||||
Notification,
|
||||
TextContent,
|
||||
ElicitRequestSchema,
|
||||
ElicitRequestSchema
|
||||
} from "../types.js";
|
||||
import { ResourceTemplate } from "./mcp.js";
|
||||
import { completable } from "./completable.js";
|
||||
|
|
@ -267,6 +267,7 @@ describe("tool()", () => {
|
|||
expect(result.tools[0].name).toBe("test");
|
||||
expect(result.tools[0].inputSchema).toEqual({
|
||||
type: "object",
|
||||
properties: {},
|
||||
});
|
||||
|
||||
// Adding the tool before the connection was established means no notification was sent
|
||||
|
|
@ -1203,6 +1204,68 @@ describe("tool()", () => {
|
|||
}),
|
||||
).rejects.toThrow(/Tool test has an output schema but no structured content was provided/);
|
||||
});
|
||||
/***
|
||||
* Test: Tool with Output Schema Must Provide Structured Content
|
||||
*/
|
||||
test("should skip outputSchema validation when isError is true", async () => {
|
||||
const mcpServer = new McpServer({
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.registerTool(
|
||||
"test",
|
||||
{
|
||||
description: "Test tool with output schema but missing structured content",
|
||||
inputSchema: {
|
||||
input: z.string(),
|
||||
},
|
||||
outputSchema: {
|
||||
processedInput: z.string(),
|
||||
resultType: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ input }) => ({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Processed: ${input}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
})
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
mcpServer.server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
client.callTool({
|
||||
name: "test",
|
||||
arguments: {
|
||||
input: "hello",
|
||||
},
|
||||
}),
|
||||
).resolves.toStrictEqual({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Processed: hello`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
/***
|
||||
* Test: Schema Validation Failure for Invalid Structured Content
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ export class McpServer {
|
|||
}
|
||||
}
|
||||
|
||||
if (tool.outputSchema) {
|
||||
if (tool.outputSchema && !result.isError) {
|
||||
if (!result.structuredContent) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
|
|
@ -613,6 +613,18 @@ export class McpServer {
|
|||
* Registers a resource with a config object and callback.
|
||||
* For static resources, use a URI string. For dynamic resources, use a ResourceTemplate.
|
||||
*/
|
||||
registerResource(
|
||||
name: string,
|
||||
uriOrTemplate: string,
|
||||
config: ResourceMetadata,
|
||||
readCallback: ReadResourceCallback
|
||||
): RegisteredResource;
|
||||
registerResource(
|
||||
name: string,
|
||||
uriOrTemplate: ResourceTemplate,
|
||||
config: ResourceMetadata,
|
||||
readCallback: ReadResourceTemplateCallback
|
||||
): RegisteredResourceTemplate;
|
||||
registerResource(
|
||||
name: string,
|
||||
uriOrTemplate: string | ResourceTemplate,
|
||||
|
|
@ -1190,6 +1202,7 @@ export type RegisteredTool = {
|
|||
|
||||
const EMPTY_OBJECT_JSON_SCHEMA = {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
};
|
||||
|
||||
// Helper to check if an object is a Zod schema (ZodRawShape)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,176 @@
|
|||
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>;
|
||||
};
|
||||
|
||||
const createMockRequest = ({ headers = {}, body }: { headers?: Record<string, string>, body?: string } = {}) => {
|
||||
const mockReq = {
|
||||
headers,
|
||||
body: body ? body : undefined,
|
||||
auth: {
|
||||
token: 'test-token',
|
||||
},
|
||||
on: jest.fn<http.IncomingMessage['on']>().mockImplementation((event, listener) => {
|
||||
const mockListener = listener as unknown as (...args: unknown[]) => void;
|
||||
if (event === 'data') {
|
||||
mockListener(Buffer.from(body || '') as unknown as Error);
|
||||
}
|
||||
if (event === 'error') {
|
||||
mockListener(new Error('test'));
|
||||
}
|
||||
if (event === 'end') {
|
||||
mockListener();
|
||||
}
|
||||
if (event === 'close') {
|
||||
setTimeout(listener, 100);
|
||||
}
|
||||
return mockReq;
|
||||
}),
|
||||
listeners: jest.fn<http.IncomingMessage['listeners']>(),
|
||||
removeListener: jest.fn<http.IncomingMessage['removeListener']>(),
|
||||
} as unknown as http.IncomingMessage;
|
||||
|
||||
return mockReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 +261,456 @@ 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`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePostMessage method', () => {
|
||||
it('should return 500 if server has not started', async () => {
|
||||
const mockReq = createMockRequest();
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
|
||||
const error = 'SSE connection not established';
|
||||
await expect(transport.handlePostMessage(mockReq, mockRes))
|
||||
.rejects.toThrow(error);
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.end).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('should return 400 if content-type is not application/json', async () => {
|
||||
const mockReq = createMockRequest({ headers: { 'content-type': 'text/plain' } });
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
await transport.start();
|
||||
|
||||
transport.onerror = jest.fn();
|
||||
const error = 'Unsupported content-type: text/plain';
|
||||
await expect(transport.handlePostMessage(mockReq, mockRes))
|
||||
.resolves.toBe(undefined);
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.end).toHaveBeenCalledWith(expect.stringContaining(error));
|
||||
expect(transport.onerror).toHaveBeenCalledWith(new Error(error));
|
||||
});
|
||||
|
||||
it('should return 400 if message has not a valid schema', async () => {
|
||||
const invalidMessage = JSON.stringify({
|
||||
// missing jsonrpc field
|
||||
method: 'call',
|
||||
params: [1, 2, 3],
|
||||
id: 1,
|
||||
})
|
||||
const mockReq = createMockRequest({
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: invalidMessage,
|
||||
});
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
await transport.start();
|
||||
|
||||
transport.onmessage = jest.fn();
|
||||
await transport.handlePostMessage(mockReq, mockRes);
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(400);
|
||||
expect(transport.onmessage).not.toHaveBeenCalled();
|
||||
expect(mockRes.end).toHaveBeenCalledWith(`Invalid message: ${invalidMessage}`);
|
||||
});
|
||||
|
||||
it('should return 202 if message has a valid schema', async () => {
|
||||
const validMessage = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: 'call',
|
||||
params: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
},
|
||||
id: 1
|
||||
})
|
||||
const mockReq = createMockRequest({
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: validMessage,
|
||||
});
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
await transport.start();
|
||||
|
||||
transport.onmessage = jest.fn();
|
||||
await transport.handlePostMessage(mockReq, mockRes);
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockRes.end).toHaveBeenCalledWith('Accepted');
|
||||
expect(transport.onmessage).toHaveBeenCalledWith({
|
||||
jsonrpc: "2.0",
|
||||
method: 'call',
|
||||
params: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
},
|
||||
id: 1
|
||||
}, {
|
||||
authInfo: {
|
||||
token: 'test-token',
|
||||
},
|
||||
requestInfo: {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('close method', () => {
|
||||
it('should call onclose', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
await transport.start();
|
||||
transport.onclose = jest.fn();
|
||||
await transport.close();
|
||||
expect(transport.onclose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('send method', () => {
|
||||
it('should call onsend', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
await transport.start();
|
||||
expect(mockRes.write).toHaveBeenCalledTimes(1);
|
||||
expect(mockRes.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('event: endpoint'));
|
||||
expect(mockRes.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`data: /messages?sessionId=${transport.sessionId}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNS rebinding protection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Host header validation', () => {
|
||||
it('should accept requests with allowed host headers', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedHosts: ['localhost:3000', 'example.com'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
|
||||
});
|
||||
|
||||
it('should reject requests with disallowed host headers', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedHosts: ['localhost:3000'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
host: 'evil.com',
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com');
|
||||
});
|
||||
|
||||
it('should reject requests without host header when allowedHosts is configured', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedHosts: ['localhost:3000'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Origin header validation', () => {
|
||||
it('should accept requests with allowed origin headers', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedOrigins: ['http://localhost:3000', 'https://example.com'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
origin: 'http://localhost:3000',
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
|
||||
});
|
||||
|
||||
it('should reject requests with disallowed origin headers', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedOrigins: ['http://localhost:3000'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
origin: 'http://evil.com',
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content-Type validation', () => {
|
||||
it('should accept requests with application/json content-type', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes);
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
|
||||
});
|
||||
|
||||
it('should accept requests with application/json with charset', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes);
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
|
||||
});
|
||||
|
||||
it('should reject requests with non-application/json content-type when protection is enabled', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes);
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'text/plain',
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableDnsRebindingProtection option', () => {
|
||||
it('should skip all validations when enableDnsRebindingProtection is false', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedHosts: ['localhost:3000'],
|
||||
allowedOrigins: ['http://localhost:3000'],
|
||||
enableDnsRebindingProtection: false,
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
host: 'evil.com',
|
||||
origin: 'http://evil.com',
|
||||
'content-type': 'text/plain',
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
// Should pass even with invalid headers because protection is disabled
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400);
|
||||
// The error should be from content-type parsing, not DNS rebinding protection
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combined validations', () => {
|
||||
it('should validate both host and origin when both are configured', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedHosts: ['localhost:3000'],
|
||||
allowedOrigins: ['http://localhost:3000'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
// Valid host, invalid origin
|
||||
const mockReq1 = createMockRequest({
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
origin: 'http://evil.com',
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
});
|
||||
const mockHandleRes1 = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq1, mockHandleRes1, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes1.writeHead).toHaveBeenCalledWith(403);
|
||||
expect(mockHandleRes1.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com');
|
||||
|
||||
// Invalid host, valid origin
|
||||
const mockReq2 = createMockRequest({
|
||||
headers: {
|
||||
host: 'evil.com',
|
||||
origin: 'http://localhost:3000',
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
});
|
||||
const mockHandleRes2 = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes2.writeHead).toHaveBeenCalledWith(403);
|
||||
expect(mockHandleRes2.end).toHaveBeenCalledWith('Invalid Host header: evil.com');
|
||||
|
||||
// Both valid
|
||||
const mockReq3 = createMockRequest({
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
origin: 'http://localhost:3000',
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
});
|
||||
const mockHandleRes3 = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -9,6 +9,29 @@ import { URL } from 'url';
|
|||
|
||||
const MAXIMUM_MESSAGE_SIZE = "4mb";
|
||||
|
||||
/**
|
||||
* Configuration options for SSEServerTransport.
|
||||
*/
|
||||
export interface SSEServerTransportOptions {
|
||||
/**
|
||||
* List of allowed host header values for DNS rebinding protection.
|
||||
* If not specified, host validation is disabled.
|
||||
*/
|
||||
allowedHosts?: string[];
|
||||
|
||||
/**
|
||||
* List of allowed origin header values for DNS rebinding protection.
|
||||
* If not specified, origin validation is disabled.
|
||||
*/
|
||||
allowedOrigins?: string[];
|
||||
|
||||
/**
|
||||
* Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured).
|
||||
* Default is false for backwards compatibility.
|
||||
*/
|
||||
enableDnsRebindingProtection?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests.
|
||||
*
|
||||
|
|
@ -17,9 +40,10 @@ const MAXIMUM_MESSAGE_SIZE = "4mb";
|
|||
export class SSEServerTransport implements Transport {
|
||||
private _sseResponse?: ServerResponse;
|
||||
private _sessionId: string;
|
||||
private _options: SSEServerTransportOptions;
|
||||
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`.
|
||||
|
|
@ -27,8 +51,39 @@ export class SSEServerTransport implements Transport {
|
|||
constructor(
|
||||
private _endpoint: string,
|
||||
private res: ServerResponse,
|
||||
options?: SSEServerTransportOptions,
|
||||
) {
|
||||
this._sessionId = randomUUID();
|
||||
this._options = options || {enableDnsRebindingProtection: false};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates request headers for DNS rebinding protection.
|
||||
* @returns Error message if validation fails, undefined if validation passes.
|
||||
*/
|
||||
private validateRequestHeaders(req: IncomingMessage): string | undefined {
|
||||
// Skip validation if protection is not enabled
|
||||
if (!this._options.enableDnsRebindingProtection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Validate Host header if allowedHosts is configured
|
||||
if (this._options.allowedHosts && this._options.allowedHosts.length > 0) {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) {
|
||||
return `Invalid Host header: ${hostHeader}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Origin header if allowedOrigins is configured
|
||||
if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) {
|
||||
const originHeader = req.headers.origin;
|
||||
if (!originHeader || !this._options.allowedOrigins.includes(originHeader)) {
|
||||
return `Invalid Origin header: ${originHeader}`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -85,13 +140,23 @@ export class SSEServerTransport implements Transport {
|
|||
res.writeHead(500).end(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Validate request headers for DNS rebinding protection
|
||||
const validationError = this.validateRequestHeaders(req);
|
||||
if (validationError) {
|
||||
res.writeHead(403).end(validationError);
|
||||
this.onerror?.(new Error(validationError));
|
||||
return;
|
||||
}
|
||||
|
||||
const authInfo: AuthInfo | undefined = req.auth;
|
||||
const requestInfo: RequestInfo = { headers: req.headers };
|
||||
|
||||
let body: string | unknown;
|
||||
try {
|
||||
const ct = contentType.parse(req.headers["content-type"] ?? "");
|
||||
if (ct.type !== "application/json") {
|
||||
throw new Error(`Unsupported content-type: ${ct}`);
|
||||
throw new Error(`Unsupported content-type: ${ct.type}`);
|
||||
}
|
||||
|
||||
body = parsedBody ?? await getRawBody(req, {
|
||||
|
|
@ -105,7 +170,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 +182,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);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createServer, type Server, IncomingMessage, ServerResponse } from "node:http";
|
||||
import { AddressInfo } from "node:net";
|
||||
import { createServer as netCreateServer, AddressInfo } from "node:net";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from "./streamableHttp.js";
|
||||
import { McpServer } from "./mcp.js";
|
||||
|
|
@ -7,6 +7,20 @@ import { CallToolResult, JSONRPCMessage } from "../types.js";
|
|||
import { z } from "zod";
|
||||
import { AuthInfo } from "./auth/types.js";
|
||||
|
||||
async function getFreePort() {
|
||||
return new Promise(res => {
|
||||
const srv = netCreateServer();
|
||||
srv.listen(0, () => {
|
||||
const address = srv.address()!
|
||||
if (typeof address === "string") {
|
||||
throw new Error("Unexpected address type: " + typeof address);
|
||||
}
|
||||
const port = (address as AddressInfo).port;
|
||||
srv.close((_err) => res(port))
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Test server configuration for StreamableHTTPServerTransport tests
|
||||
*/
|
||||
|
|
@ -208,6 +222,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 +231,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
const result = await createTestServer();
|
||||
server = result.server;
|
||||
transport = result.transport;
|
||||
mcpServer = result.mcpServer;
|
||||
baseUrl = result.baseUrl;
|
||||
});
|
||||
|
||||
|
|
@ -347,6 +363,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);
|
||||
|
||||
|
|
@ -749,7 +828,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
|
||||
// Send request with matching protocol version
|
||||
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId);
|
||||
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
|
|
@ -767,7 +846,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.toolsList),
|
||||
});
|
||||
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
|
|
@ -785,7 +864,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.toolsList),
|
||||
});
|
||||
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
|
||||
|
|
@ -793,13 +872,13 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
|
||||
it("should accept when protocol version differs from negotiated version", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
|
||||
// Spy on console.warn to verify warning is logged
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
// Send request with different but supported protocol version
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
|
|
@ -808,10 +887,10 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.toolsList),
|
||||
});
|
||||
|
||||
|
||||
// Request should still succeed
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
|
@ -827,7 +906,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
"mcp-protocol-version": "invalid-version",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
|
||||
|
|
@ -844,7 +923,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
"mcp-protocol-version": "invalid-version",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
|
||||
|
|
@ -886,12 +965,12 @@ describe("StreamableHTTPServerTransport with AuthInfo", () => {
|
|||
method: "tools/call",
|
||||
params: {
|
||||
name: "profile",
|
||||
arguments: {active: true},
|
||||
arguments: { active: true },
|
||||
},
|
||||
id: "call-1",
|
||||
};
|
||||
|
||||
const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, {'authorization': 'Bearer test-token'});
|
||||
const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, { 'authorization': 'Bearer test-token' });
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const text = await readSSEEvent(response);
|
||||
|
|
@ -913,7 +992,7 @@ describe("StreamableHTTPServerTransport with AuthInfo", () => {
|
|||
id: "call-1",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("should calls tool without authInfo when it is optional", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
|
|
@ -922,7 +1001,7 @@ describe("StreamableHTTPServerTransport with AuthInfo", () => {
|
|||
method: "tools/call",
|
||||
params: {
|
||||
name: "profile",
|
||||
arguments: {active: false},
|
||||
arguments: { active: false },
|
||||
},
|
||||
id: "call-1",
|
||||
};
|
||||
|
|
@ -1406,7 +1485,7 @@ describe("StreamableHTTPServerTransport in stateless mode", () => {
|
|||
// Open first SSE stream
|
||||
const stream1 = await fetch(baseUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-protocol-version": "2025-03-26"
|
||||
},
|
||||
|
|
@ -1416,11 +1495,282 @@ describe("StreamableHTTPServerTransport in stateless mode", () => {
|
|||
// Open second SSE stream - should still be rejected, stateless mode still only allows one
|
||||
const stream2 = await fetch(baseUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-protocol-version": "2025-03-26"
|
||||
},
|
||||
});
|
||||
expect(stream2.status).toBe(409); // Conflict - only one stream allowed
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test DNS rebinding protection
|
||||
describe("StreamableHTTPServerTransport DNS rebinding protection", () => {
|
||||
let server: Server;
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server && transport) {
|
||||
await stopTestServer({ server, transport });
|
||||
}
|
||||
});
|
||||
|
||||
describe("Host header validation", () => {
|
||||
it("should accept requests with allowed host headers", async () => {
|
||||
const result = await createTestServerWithDnsProtection({
|
||||
sessionIdGenerator: undefined,
|
||||
allowedHosts: ['localhost'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
server = result.server;
|
||||
transport = result.transport;
|
||||
baseUrl = result.baseUrl;
|
||||
|
||||
// Note: fetch() automatically sets Host header to match the URL
|
||||
// Since we're connecting to localhost:3001 and that's in allowedHosts, this should work
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.initialize),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should reject requests with disallowed host headers", async () => {
|
||||
// Test DNS rebinding protection by creating a server that only allows example.com
|
||||
// but we're connecting via localhost, so it should be rejected
|
||||
const result = await createTestServerWithDnsProtection({
|
||||
sessionIdGenerator: undefined,
|
||||
allowedHosts: ['example.com:3001'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
server = result.server;
|
||||
transport = result.transport;
|
||||
baseUrl = result.baseUrl;
|
||||
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.initialize),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.error.message).toContain("Invalid Host header:");
|
||||
});
|
||||
|
||||
it("should reject GET requests with disallowed host headers", async () => {
|
||||
const result = await createTestServerWithDnsProtection({
|
||||
sessionIdGenerator: undefined,
|
||||
allowedHosts: ['example.com:3001'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
server = result.server;
|
||||
transport = result.transport;
|
||||
baseUrl = result.baseUrl;
|
||||
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Origin header validation", () => {
|
||||
it("should accept requests with allowed origin headers", async () => {
|
||||
const result = await createTestServerWithDnsProtection({
|
||||
sessionIdGenerator: undefined,
|
||||
allowedOrigins: ['http://localhost:3000', 'https://example.com'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
server = result.server;
|
||||
transport = result.transport;
|
||||
baseUrl = result.baseUrl;
|
||||
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
Origin: "http://localhost:3000",
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.initialize),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should reject requests with disallowed origin headers", async () => {
|
||||
const result = await createTestServerWithDnsProtection({
|
||||
sessionIdGenerator: undefined,
|
||||
allowedOrigins: ['http://localhost:3000'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
server = result.server;
|
||||
transport = result.transport;
|
||||
baseUrl = result.baseUrl;
|
||||
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
Origin: "http://evil.com",
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.initialize),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.error.message).toBe("Invalid Origin header: http://evil.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("enableDnsRebindingProtection option", () => {
|
||||
it("should skip all validations when enableDnsRebindingProtection is false", async () => {
|
||||
const result = await createTestServerWithDnsProtection({
|
||||
sessionIdGenerator: undefined,
|
||||
allowedHosts: ['localhost'],
|
||||
allowedOrigins: ['http://localhost:3000'],
|
||||
enableDnsRebindingProtection: false,
|
||||
});
|
||||
server = result.server;
|
||||
transport = result.transport;
|
||||
baseUrl = result.baseUrl;
|
||||
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
Host: "evil.com",
|
||||
Origin: "http://evil.com",
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.initialize),
|
||||
});
|
||||
|
||||
// Should pass even with invalid headers because protection is disabled
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Combined validations", () => {
|
||||
it("should validate both host and origin when both are configured", async () => {
|
||||
const result = await createTestServerWithDnsProtection({
|
||||
sessionIdGenerator: undefined,
|
||||
allowedHosts: ['localhost'],
|
||||
allowedOrigins: ['http://localhost:3001'],
|
||||
enableDnsRebindingProtection: true,
|
||||
});
|
||||
server = result.server;
|
||||
transport = result.transport;
|
||||
baseUrl = result.baseUrl;
|
||||
|
||||
// Test with invalid origin (host will be automatically correct via fetch)
|
||||
const response1 = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
Origin: "http://evil.com",
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.initialize),
|
||||
});
|
||||
|
||||
expect(response1.status).toBe(403);
|
||||
const body1 = await response1.json();
|
||||
expect(body1.error.message).toBe("Invalid Origin header: http://evil.com");
|
||||
|
||||
// Test with valid origin
|
||||
const response2 = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
Origin: "http://localhost:3001",
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.initialize),
|
||||
});
|
||||
|
||||
expect(response2.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create test server with DNS rebinding protection options
|
||||
*/
|
||||
async function createTestServerWithDnsProtection(config: {
|
||||
sessionIdGenerator: (() => string) | undefined;
|
||||
allowedHosts?: string[];
|
||||
allowedOrigins?: string[];
|
||||
enableDnsRebindingProtection?: boolean;
|
||||
}): Promise<{
|
||||
server: Server;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
mcpServer: McpServer;
|
||||
baseUrl: URL;
|
||||
}> {
|
||||
const mcpServer = new McpServer(
|
||||
{ name: "test-server", version: "1.0.0" },
|
||||
{ capabilities: { logging: {} } }
|
||||
);
|
||||
|
||||
const port = await getFreePort();
|
||||
|
||||
if (config.allowedHosts) {
|
||||
config.allowedHosts = config.allowedHosts.map(host => {
|
||||
if (host.includes(':')) {
|
||||
return host;
|
||||
}
|
||||
return `localhost:${port}`;
|
||||
});
|
||||
}
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: config.sessionIdGenerator,
|
||||
allowedHosts: config.allowedHosts,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
enableDnsRebindingProtection: config.enableDnsRebindingProtection,
|
||||
});
|
||||
|
||||
await mcpServer.connect(transport);
|
||||
|
||||
const httpServer = createServer(async (req, res) => {
|
||||
if (req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => (body += chunk));
|
||||
req.on("end", async () => {
|
||||
const parsedBody = JSON.parse(body);
|
||||
await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res, parsedBody);
|
||||
});
|
||||
} else {
|
||||
await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res);
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.listen(port, () => resolve());
|
||||
});
|
||||
|
||||
const serverUrl = new URL(`http://localhost:${port}/`);
|
||||
|
||||
return {
|
||||
server: httpServer,
|
||||
transport,
|
||||
mcpServer,
|
||||
baseUrl: serverUrl,
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -61,6 +61,24 @@ export interface StreamableHTTPServerTransportOptions {
|
|||
* If provided, resumability will be enabled, allowing clients to reconnect and resume messages
|
||||
*/
|
||||
eventStore?: EventStore;
|
||||
|
||||
/**
|
||||
* List of allowed host header values for DNS rebinding protection.
|
||||
* If not specified, host validation is disabled.
|
||||
*/
|
||||
allowedHosts?: string[];
|
||||
|
||||
/**
|
||||
* List of allowed origin header values for DNS rebinding protection.
|
||||
* If not specified, origin validation is disabled.
|
||||
*/
|
||||
allowedOrigins?: string[];
|
||||
|
||||
/**
|
||||
* Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured).
|
||||
* Default is false for backwards compatibility.
|
||||
*/
|
||||
enableDnsRebindingProtection?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -109,17 +127,23 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
private _standaloneSseStreamId: string = '_GET_stream';
|
||||
private _eventStore?: EventStore;
|
||||
private _onsessioninitialized?: (sessionId: string) => void;
|
||||
private _allowedHosts?: string[];
|
||||
private _allowedOrigins?: string[];
|
||||
private _enableDnsRebindingProtection: boolean;
|
||||
|
||||
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;
|
||||
this._enableJsonResponse = options.enableJsonResponse ?? false;
|
||||
this._eventStore = options.eventStore;
|
||||
this._onsessioninitialized = options.onsessioninitialized;
|
||||
this._allowedHosts = options.allowedHosts;
|
||||
this._allowedOrigins = options.allowedOrigins;
|
||||
this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -133,10 +157,54 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
this._started = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates request headers for DNS rebinding protection.
|
||||
* @returns Error message if validation fails, undefined if validation passes.
|
||||
*/
|
||||
private validateRequestHeaders(req: IncomingMessage): string | undefined {
|
||||
// Skip validation if protection is not enabled
|
||||
if (!this._enableDnsRebindingProtection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Validate Host header if allowedHosts is configured
|
||||
if (this._allowedHosts && this._allowedHosts.length > 0) {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader || !this._allowedHosts.includes(hostHeader)) {
|
||||
return `Invalid Host header: ${hostHeader}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Origin header if allowedOrigins is configured
|
||||
if (this._allowedOrigins && this._allowedOrigins.length > 0) {
|
||||
const originHeader = req.headers.origin;
|
||||
if (!originHeader || !this._allowedOrigins.includes(originHeader)) {
|
||||
return `Invalid Origin header: ${originHeader}`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an incoming HTTP request, whether GET or POST
|
||||
*/
|
||||
async handleRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise<void> {
|
||||
// Validate request headers for DNS rebinding protection
|
||||
const validationError = this.validateRequestHeaders(req);
|
||||
if (validationError) {
|
||||
res.writeHead(403).end(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: validationError
|
||||
},
|
||||
id: null
|
||||
}));
|
||||
this.onerror?.(new Error(validationError));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
await this.handlePostRequest(req, res, parsedBody);
|
||||
} else if (req.method === "GET") {
|
||||
|
|
@ -321,6 +389,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 +473,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 +508,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
|
||||
|
|
|
|||
Loading…
Reference in a new issue