Merge branch 'main' into main

This commit is contained in:
David Dworken 2025-06-26 16:10:00 -07:00 • committed by GitHub
commit 185fb23f34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1759 additions and 143 deletions

View file

@ -276,6 +276,34 @@ describe('Authorization Handler', () => {
});
});
describe('Resource parameter validation', () => {
it('propagates resource parameter', async () => {
const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize');
const response = await supertest(app)
.get('/authorize')
.query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
resource: 'https://api.example.com/resource'
});
expect(response.status).toBe(302);
expect(mockProviderWithResource).toHaveBeenCalledWith(
validClient,
expect.objectContaining({
resource: new URL('https://api.example.com/resource'),
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge123'
}),
expect.any(Object)
);
});
});
describe('Successful authorization', () => {
it('handles successful authorization with all parameters', async () => {
const response = await supertest(app)

View file

@ -35,6 +35,7 @@ const RequestAuthorizationParamsSchema = z.object({
code_challenge_method: z.literal("S256"),
scope: z.string().optional(),
state: z.string().optional(),
resource: z.string().url().optional(),
});
export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
@ -98,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());
}
@ -115,7 +115,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
throw new InvalidRequestError(parseResult.error.message);
}
const { scope, code_challenge } = parseResult.data;
const { scope, code_challenge, resource } = parseResult.data;
state = parseResult.data.state;
// Validate scopes
@ -138,13 +138,13 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
scopes: requestedScopes,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
resource: resource ? new URL(resource) : undefined,
}, res);
} catch (error) {
// Post-redirect errors - redirect with error parameters
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));
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -264,12 +264,14 @@ describe('Token Handler', () => {
});
it('returns tokens for valid code exchange', async () => {
const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode');
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
resource: 'https://api.example.com/resource',
grant_type: 'authorization_code',
code: 'valid_code',
code_verifier: 'valid_verifier'
@ -280,6 +282,13 @@ describe('Token Handler', () => {
expect(response.body.token_type).toBe('bearer');
expect(response.body.expires_in).toBe(3600);
expect(response.body.refresh_token).toBe('mock_refresh_token');
expect(mockExchangeCode).toHaveBeenCalledWith(
validClient,
'valid_code',
undefined, // code_verifier is undefined after PKCE validation
undefined, // redirect_uri
new URL('https://api.example.com/resource') // resource parameter
);
});
it('passes through code verifier when using proxy provider', async () => {
@ -440,12 +449,14 @@ describe('Token Handler', () => {
});
it('returns new tokens for valid refresh token', async () => {
const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken');
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
resource: 'https://api.example.com/resource',
grant_type: 'refresh_token',
refresh_token: 'valid_refresh_token'
});
@ -455,6 +466,12 @@ describe('Token Handler', () => {
expect(response.body.token_type).toBe('bearer');
expect(response.body.expires_in).toBe(3600);
expect(response.body.refresh_token).toBe('new_mock_refresh_token');
expect(mockExchangeRefresh).toHaveBeenCalledWith(
validClient,
'valid_refresh_token',
undefined, // scopes
new URL('https://api.example.com/resource') // resource parameter
);
});
it('respects requested scopes on refresh', async () => {

View file

@ -32,11 +32,13 @@ const AuthorizationCodeGrantSchema = z.object({
code: z.string(),
code_verifier: z.string(),
redirect_uri: z.string().optional(),
resource: z.string().url().optional(),
});
const RefreshTokenGrantSchema = z.object({
refresh_token: z.string(),
scope: z.string().optional(),
resource: z.string().url().optional(),
});
export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler {
@ -78,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");
}
@ -89,7 +90,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
throw new InvalidRequestError(parseResult.error.message);
}
const { code, code_verifier, redirect_uri } = parseResult.data;
const { code, code_verifier, redirect_uri, resource } = parseResult.data;
const skipLocalPkceValidation = provider.skipLocalPkceValidation;
@ -107,7 +108,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
client,
code,
skipLocalPkceValidation ? code_verifier : undefined,
redirect_uri
redirect_uri,
resource ? new URL(resource) : undefined
);
res.status(200).json(tokens);
break;
@ -119,10 +121,10 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
throw new InvalidRequestError(parseResult.error.message);
}
const { refresh_token, scope } = parseResult.data;
const { refresh_token, scope, resource } = parseResult.data;
const scopes = scope?.split(" ");
const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes);
const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource ? new URL(resource) : undefined);
res.status(200).json(tokens);
break;
}
@ -140,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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -8,6 +8,7 @@ export type AuthorizationParams = {
scopes?: string[];
codeChallenge: string;
redirectUri: string;
resource?: URL;
};
/**
@ -40,13 +41,14 @@ export interface OAuthServerProvider {
client: OAuthClientInformationFull,
authorizationCode: string,
codeVerifier?: string,
redirectUri?: string
redirectUri?: string,
resource?: URL
): Promise<OAuthTokens>;
/**
* Exchanges a refresh token for an access token.
*/
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens>;
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise<OAuthTokens>;
/**
* Verifies an access token and returns information about it.

View file

@ -88,6 +88,7 @@ describe("Proxy OAuth Server Provider", () => {
codeChallenge: "test-challenge",
state: "test-state",
scopes: ["read", "write"],
resource: new URL('https://api.example.com/resource'),
},
mockResponse
);
@ -100,6 +101,7 @@ describe("Proxy OAuth Server Provider", () => {
expectedUrl.searchParams.set("code_challenge_method", "S256");
expectedUrl.searchParams.set("state", "test-state");
expectedUrl.searchParams.set("scope", "read write");
expectedUrl.searchParams.set('resource', 'https://api.example.com/resource');
expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString());
});
@ -164,6 +166,41 @@ describe("Proxy OAuth Server Provider", () => {
expect(tokens).toEqual(mockTokenResponse);
});
it('includes resource parameter in authorization code exchange', async () => {
const tokens = await provider.exchangeAuthorizationCode(
validClient,
'test-code',
'test-verifier',
'https://example.com/callback',
new URL('https://api.example.com/resource')
);
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
})
);
expect(tokens).toEqual(mockTokenResponse);
});
it('handles authorization code exchange without resource parameter', async () => {
const tokens = await provider.exchangeAuthorizationCode(
validClient,
'test-code',
'test-verifier'
);
const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
const body = fetchCall[1].body as string;
expect(body).not.toContain('resource=');
expect(tokens).toEqual(mockTokenResponse);
});
it("exchanges refresh token for new tokens", async () => {
const tokens = await provider.exchangeRefreshToken(
validClient,
@ -184,6 +221,26 @@ describe("Proxy OAuth Server Provider", () => {
expect(tokens).toEqual(mockTokenResponse);
});
it('includes resource parameter in refresh token exchange', async () => {
const tokens = await provider.exchangeRefreshToken(
validClient,
'test-refresh-token',
['read', 'write'],
new URL('https://api.example.com/resource')
);
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
})
);
expect(tokens).toEqual(mockTokenResponse);
});
});
describe("client registration", () => {

View file

@ -134,6 +134,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
// Add optional standard OAuth parameters
if (params.state) searchParams.set("state", params.state);
if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" "));
if (params.resource) searchParams.set("resource", params.resource.href);
targetUrl.search = searchParams.toString();
res.redirect(targetUrl.toString());
@ -152,7 +153,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
client: OAuthClientInformationFull,
authorizationCode: string,
codeVerifier?: string,
redirectUri?: string
redirectUri?: string,
resource?: URL
): Promise<OAuthTokens> {
const params = new URLSearchParams({
grant_type: "authorization_code",
@ -172,6 +174,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
params.append("redirect_uri", redirectUri);
}
if (resource) {
params.append("resource", resource.href);
}
const response = await fetch(this._endpoints.tokenUrl, {
method: "POST",
headers: {
@ -192,7 +198,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
async exchangeRefreshToken(
client: OAuthClientInformationFull,
refreshToken: string,
scopes?: string[]
scopes?: string[],
resource?: URL
): Promise<OAuthTokens> {
const params = new URLSearchParams({
@ -209,6 +216,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
params.set("scope", scopes.join(" "));
}
if (resource) {
params.set("resource", resource.href);
}
const response = await fetch(this._endpoints.tokenUrl, {
method: "POST",
headers: {

View file

@ -22,6 +22,12 @@ export interface AuthInfo {
*/
expiresAt?: number;
/**
* The RFC 8707 resource server identifier for which this token is valid.
* If set, this MUST match the MCP server's resource identifier (minus hash fragment).
*/
resource?: URL;
/**
* Additional data associated with the token.
* This field should be used for any additional data that needs to be attached to the auth info.

View file

@ -15,7 +15,7 @@ import {
ListResourcesRequestSchema,
ListToolsRequestSchema,
SetLevelRequestSchema,
ErrorCode,
ErrorCode
} from "../types.js";
import { Transport } from "../shared/transport.js";
import { InMemoryTransport } from "../inMemory.js";
@ -505,7 +505,7 @@ test("should reject elicitation response with invalid data", async () => {
).rejects.toThrow(/does not match requested schema/);
});
test("should allow elicitation decline and cancel without validation", async () => {
test("should allow elicitation reject and cancel without validation", async () => {
const server = new Server(
{
name: "test server",
@ -524,7 +524,7 @@ test("should allow elicitation decline and cancel without validation", async ()
const client = new Client(
{
name: "test client",
name: "test client",
version: "1.0",
},
{
@ -538,7 +538,7 @@ test("should allow elicitation decline and cancel without validation", async ()
client.setRequestHandler(ElicitRequestSchema, (request) => {
requestCount++;
if (requestCount === 1) {
return { action: "decline" };
return { action: "reject" };
} else {
return { action: "cancel" };
}
@ -559,14 +559,14 @@ test("should allow elicitation decline and cancel without validation", async ()
required: ["name"],
};
// Test decline - should not validate
// Test reject - should not validate
await expect(
server.elicitInput({
message: "Please provide your name",
requestedSchema: schema,
}),
).resolves.toEqual({
action: "decline",
action: "reject",
});
// Test cancel - should not validate

View file

@ -14,7 +14,7 @@ import {
LoggingMessageNotificationSchema,
Notification,
TextContent,
ElicitRequestSchema,
ElicitRequestSchema
} from "../types.js";
import { ResourceTemplate } from "./mcp.js";
import { completable } from "./completable.js";
@ -1203,6 +1203,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
@ -4157,7 +4219,7 @@ describe("elicitInput()", () => {
// Mock availability check to return false
checkAvailability.mockResolvedValue(false);
// Set up client to decline alternative date checking
// Set up client to reject alternative date checking
client.setRequestHandler(ElicitRequestSchema, async () => {
return {
action: "accept",

View file

@ -200,7 +200,7 @@ export class McpServer {
}
}
if (tool.outputSchema) {
if (tool.outputSchema && !result.isError) {
if (!result.structuredContent) {
throw new McpError(
ErrorCode.InvalidParams,

View file

@ -1,28 +1,176 @@
import http from 'http';
import { jest } from '@jest/globals';
import { SSEServerTransport } from './sse.js';
import { AuthInfo } from './auth/types.js';
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: Record<string, string> = {}) => {
return {
const createMockRequest = ({ headers = {}, body }: { headers?: Record<string, string>, body?: string } = {}) => {
const mockReq = {
headers,
} as unknown as http.IncomingMessage & { auth?: AuthInfo };
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();
@ -113,6 +261,197 @@ 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', () => {

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { IncomingMessage, ServerResponse } from "node:http";
import { Transport } from "../shared/transport.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from "../types.js";
import getRawBody from "raw-body";
import contentType from "content-type";
import { AuthInfo } from "./auth/types.js";
@ -43,7 +43,7 @@ export class SSEServerTransport implements Transport {
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`.
@ -150,6 +150,7 @@ export class SSEServerTransport implements Transport {
}
const authInfo: AuthInfo | undefined = req.auth;
const requestInfo: RequestInfo = { headers: req.headers };
let body: string | unknown;
try {
@ -169,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;
@ -181,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);

View file

@ -222,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;
@ -230,6 +231,7 @@ describe("StreamableHTTPServerTransport", () => {
const result = await createTestServer();
server = result.server;
transport = result.transport;
mcpServer = result.mcpServer;
baseUrl = result.baseUrl;
});
@ -361,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);

View file

@ -1,6 +1,6 @@
import { IncomingMessage, ServerResponse } from "node:http";
import { Transport } from "../shared/transport.js";
import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js";
import { MessageExtraInfo, RequestInfo, isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js";
import getRawBody from "raw-body";
import contentType from "content-type";
import { randomUUID } from "node:crypto";
@ -134,7 +134,7 @@ export class StreamableHTTPServerTransport implements Transport {
sessionId?: string;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
constructor(options: StreamableHTTPServerTransportOptions) {
this.sessionIdGenerator = options.sessionIdGenerator;
@ -389,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) {
@ -472,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
@ -507,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