Merge branch 'main' into ochafik/spec-type-tests

This commit is contained in:
Olivier Chafik 2025-07-09 17:44:44 +01:00 • committed by GitHub
commit c72b52b7f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 359 additions and 44 deletions

View file

@ -570,20 +570,31 @@ app.listen(3000);
```
> [!TIP]
> When using this in a remote environment, make sure to allow the header parameter `mcp-session-id` in CORS. Otherwise, it may result in a `Bad Request: No valid session ID provided` error.
>
> For example, in Node.js you can configure it like this:
>
> ```ts
> app.use(
> cors({
> origin: ['https://your-remote-domain.com, https://your-other-remote-domain.com'],
> exposedHeaders: ['mcp-session-id'],
> allowedHeaders: ['Content-Type', 'mcp-session-id'],
> })
> );
> When using this in a remote environment, make sure to allow the header parameter `mcp-session-id` in CORS. Otherwise, it may result in a `Bad Request: No valid session ID provided` error. Read the following section for examples.
> ```
#### CORS Configuration for Browser-Based Clients
If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it:
```typescript
import cors from 'cors';
// Add CORS middleware before your MCP routes
app.use(cors({
origin: '*', // Configure appropriately for production, for example:
// origin: ['https://your-remote-domain.com, https://your-other-remote-domain.com'],
exposedHeaders: ['Mcp-Session-Id']
allowedHeaders: ['Content-Type', 'mcp-session-id'],
}));
```
This configuration is necessary because:
- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management
- Browsers restrict access to response headers unless explicitly exposed via CORS
- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses
#### Without Session Management (Stateless)
For simpler use cases where session management isn't needed:

View file

@ -102,7 +102,11 @@ async function runServer(port: number | null) {
await transport.handlePostMessage(req, res);
});
app.listen(port, () => {
app.listen(port, (error) => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
console.log(`Server running on http://localhost:${port}/sse`);
});
} else {

View file

@ -382,6 +382,29 @@ describe("SSEClientTransport", () => {
expect(mockAuthProvider.tokens).toHaveBeenCalled();
});
it("attaches custom header from provider on initial SSE connection", async () => {
mockAuthProvider.tokens.mockResolvedValue({
access_token: "test-token",
token_type: "Bearer"
});
const customHeaders = {
"X-Custom-Header": "custom-value",
};
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
requestInit: {
headers: customHeaders,
},
});
await transport.start();
expect(lastServerRequest.headers.authorization).toBe("Bearer test-token");
expect(lastServerRequest.headers["x-custom-header"]).toBe("custom-value");
expect(mockAuthProvider.tokens).toHaveBeenCalled();
});
it("attaches auth header from provider on POST requests", async () => {
mockAuthProvider.tokens.mockResolvedValue({
access_token: "test-token",

View file

@ -106,10 +106,8 @@ export class SSEClientTransport implements Transport {
return await this._startOrAuth();
}
private async _commonHeaders(): Promise<HeadersInit> {
const headers = {
...this._requestInit?.headers,
} as HeadersInit & Record<string, string>;
private async _commonHeaders(): Promise<Headers> {
const headers: HeadersInit = {};
if (this._authProvider) {
const tokens = await this._authProvider.tokens();
if (tokens) {
@ -120,24 +118,24 @@ export class SSEClientTransport implements Transport {
headers["mcp-protocol-version"] = this._protocolVersion;
}
return headers;
return new Headers(
{ ...headers, ...this._requestInit?.headers }
);
}
private _startOrAuth(): Promise<void> {
const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch
const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch
return new Promise((resolve, reject) => {
this._eventSource = new EventSource(
this._url.href,
{
...this._eventSourceInit,
fetch: async (url, init) => {
const headers = await this._commonHeaders()
const headers = await this._commonHeaders();
headers.set("Accept", "text/event-stream");
const response = await fetchImpl(url, {
...init,
headers: new Headers({
...headers,
Accept: "text/event-stream"
})
headers,
})
if (response.status === 401 && response.headers.has('www-authenticate')) {
@ -238,8 +236,7 @@ const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typ
}
try {
const commonHeaders = await this._commonHeaders();
const headers = new Headers(commonHeaders);
const headers = await this._commonHeaders();
headers.set("content-type", "application/json");
const init = {
...this._requestInit,

View file

@ -1,10 +1,17 @@
import { JSONRPCMessage } from "../types.js";
import { StdioClientTransport, StdioServerParameters } from "./stdio.js";
const serverParameters: StdioServerParameters = {
command: "/usr/bin/tee",
// Configure default server parameters based on OS
// Uses 'more' command for Windows and 'tee' command for Unix/Linux
const getDefaultServerParameters = (): StdioServerParameters => {
if (process.platform === "win32") {
return { command: "more" };
}
return { command: "/usr/bin/tee" };
};
const serverParameters = getDefaultServerParameters();
test("should start then close cleanly", async () => {
const client = new StdioClientTransport(serverParameters);
client.onerror = (error) => {

View file

@ -200,7 +200,11 @@ export const setupAuthServer = ({authServerUrl, mcpServerUrl, strictResource}: {
const auth_port = authServerUrl.port;
// Start the auth server
authApp.listen(auth_port, () => {
authApp.listen(auth_port, (error) => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
console.log(`OAuth Authorization Server listening on port ${auth_port}`);
});

View file

@ -4,6 +4,7 @@ import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import { z } from 'zod';
import { CallToolResult, isInitializeRequest } from '../../types.js';
import cors from 'cors';
// Create an MCP server with implementation details
@ -81,6 +82,12 @@ const getServer = () => {
const app = express();
app.use(express.json());
// Configure CORS to expose Mcp-Session-Id header for browser-based clients
app.use(cors({
origin: '*', // Allow all origins - adjust as needed for production
exposedHeaders: ['Mcp-Session-Id']
}));
// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
@ -151,7 +158,11 @@ app.get('/mcp', async (req: Request, res: Response) => {
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
app.listen(PORT, (error) => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
});

View file

@ -145,7 +145,11 @@ app.post('/messages', async (req: Request, res: Response) => {
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
app.listen(PORT, (error) => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`);
});

View file

@ -3,6 +3,7 @@ import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import { z } from 'zod';
import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js';
import cors from 'cors';
const getServer = () => {
// Create an MCP server with implementation details
@ -96,6 +97,12 @@ const getServer = () => {
const app = express();
app.use(express.json());
// Configure CORS to expose Mcp-Session-Id header for browser-based clients
app.use(cors({
origin: '*', // Allow all origins - adjust as needed for production
exposedHeaders: ['Mcp-Session-Id']
}));
app.post('/mcp', async (req: Request, res: Response) => {
const server = getServer();
try {
@ -151,7 +158,11 @@ app.delete('/mcp', async (req: Request, res: Response) => {
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
app.listen(PORT, (error) => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`);
});

View file

@ -11,6 +11,8 @@ import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
import { OAuthMetadata } from 'src/shared/auth.js';
import { checkResourceAllowed } from 'src/shared/auth-utils.js';
import cors from 'cors';
// Check for OAuth flag
const useOAuth = process.argv.includes('--oauth');
const strictOAuth = process.argv.includes('--oauth-strict');
@ -420,12 +422,18 @@ const getServer = () => {
return server;
};
const MCP_PORT = 3000;
const AUTH_PORT = 3001;
const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000;
const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001;
const app = express();
app.use(express.json());
// Allow CORS all domains, expose the Mcp-Session-Id header
app.use(cors({
origin: '*', // Allow all origins
exposedHeaders: ["Mcp-Session-Id"]
}));
// Set up OAuth if enabled
let authMiddleware = null;
if (useOAuth) {
@ -640,7 +648,11 @@ if (useOAuth && authMiddleware) {
app.delete('/mcp', mcpDeleteHandler);
}
app.listen(MCP_PORT, () => {
app.listen(MCP_PORT, (error) => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
});

View file

@ -6,6 +6,7 @@ import { SSEServerTransport } from '../../server/sse.js';
import { z } from 'zod';
import { CallToolResult, isInitializeRequest } from '../../types.js';
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
import cors from 'cors';
/**
* This example server demonstrates backwards compatibility with both:
@ -71,6 +72,12 @@ const getServer = () => {
const app = express();
app.use(express.json());
// Configure CORS to expose Mcp-Session-Id header for browser-based clients
app.use(cors({
origin: '*', // Allow all origins - adjust as needed for production
exposedHeaders: ['Mcp-Session-Id']
}));
// Store transports by session ID
const transports: Record<string, StreamableHTTPServerTransport | SSEServerTransport> = {};
@ -203,7 +210,11 @@ app.post("/messages", async (req: Request, res: Response) => {
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
app.listen(PORT, (error) => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
console.log(`Backwards compatible MCP server listening on port ${PORT}`);
console.log(`
==============================================

View file

@ -112,7 +112,11 @@ app.get('/mcp', async (req: Request, res: Response) => {
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
app.listen(PORT, (error) => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
console.log(`Server listening on port ${PORT}`);
});

View file

@ -37,6 +37,7 @@ describe("requireBearerAuth middleware", () => {
token: "valid-token",
clientId: "client-123",
scopes: ["read", "write"],
expiresAt: Math.floor(Date.now() / 1000) + 3600, // Token expires in an hour
};
mockVerifyAccessToken.mockResolvedValue(validAuthInfo);
@ -53,13 +54,17 @@ describe("requireBearerAuth middleware", () => {
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
it("should reject expired tokens", async () => {
it.each([
[100], // Token expired 100 seconds ago
[0], // Token expires at the same time as now
])("should reject expired tokens (expired %s seconds ago)", async (expiredSecondsAgo: number) => {
const expiresAt = Math.floor(Date.now() / 1000) - expiredSecondsAgo;
const expiredAuthInfo: AuthInfo = {
token: "expired-token",
clientId: "client-123",
scopes: ["read", "write"],
expiresAt: Math.floor(Date.now() / 1000) - 100, // Token expired 100 seconds ago
expiresAt
};
mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo);
@ -82,6 +87,37 @@ describe("requireBearerAuth middleware", () => {
expect(nextFunction).not.toHaveBeenCalled();
});
it.each([
[undefined], // Token has no expiration time
[NaN], // Token has no expiration time
])("should reject tokens with no expiration time (expiresAt: %s)", async (expiresAt: number | undefined) => {
const noExpirationAuthInfo: AuthInfo = {
token: "no-expiration-token",
clientId: "client-123",
scopes: ["read", "write"],
expiresAt
};
mockVerifyAccessToken.mockResolvedValue(noExpirationAuthInfo);
mockRequest.headers = {
authorization: "Bearer expired-token",
};
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token");
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
"WWW-Authenticate",
expect.stringContaining('Bearer error="invalid_token"')
);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: "invalid_token", error_description: "Token has no expiration time" })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should accept non-expired tokens", async () => {
const nonExpiredAuthInfo: AuthInfo = {
token: "valid-token",
@ -141,6 +177,7 @@ describe("requireBearerAuth middleware", () => {
token: "valid-token",
clientId: "client-123",
scopes: ["read", "write", "admin"],
expiresAt: Math.floor(Date.now() / 1000) + 3600, // Token expires in an hour
};
mockVerifyAccessToken.mockResolvedValue(authInfo);

View file

@ -63,8 +63,10 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad
}
}
// Check if the token is expired
if (!!authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1000) {
// Check if the token is set to expire or if it is expired
if (typeof authInfo.expiresAt !== 'number' || isNaN(authInfo.expiresAt)) {
throw new InvalidTokenError("Token has no expiration time");
} else if (authInfo.expiresAt < Date.now() / 1000) {
throw new InvalidTokenError("Token has expired");
}

View file

@ -29,6 +29,7 @@ interface TestServerConfig {
enableJsonResponse?: boolean;
customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise<void>;
eventStore?: EventStore;
onsessionclosed?: (sessionId: string) => void;
}
/**
@ -57,7 +58,8 @@ async function createTestServer(config: TestServerConfig = { sessionIdGenerator:
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: config.sessionIdGenerator,
enableJsonResponse: config.enableJsonResponse ?? false,
eventStore: config.eventStore
eventStore: config.eventStore,
onsessionclosed: config.onsessionclosed
});
await mcpServer.connect(transport);
@ -111,7 +113,8 @@ async function createTestAuthServer(config: TestServerConfig = { sessionIdGenera
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: config.sessionIdGenerator,
enableJsonResponse: config.enableJsonResponse ?? false,
eventStore: config.eventStore
eventStore: config.eventStore,
onsessionclosed: config.onsessionclosed
});
await mcpServer.connect(transport);
@ -1504,6 +1507,165 @@ describe("StreamableHTTPServerTransport in stateless mode", () => {
});
});
// Test onsessionclosed callback
describe("StreamableHTTPServerTransport onsessionclosed callback", () => {
it("should call onsessionclosed callback when session is closed via DELETE", async () => {
const mockCallback = jest.fn();
// Create server with onsessionclosed callback
const result = await createTestServer({
sessionIdGenerator: () => randomUUID(),
onsessionclosed: mockCallback,
});
const tempServer = result.server;
const tempUrl = result.baseUrl;
// Initialize to get a session ID
const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize);
const tempSessionId = initResponse.headers.get("mcp-session-id");
expect(tempSessionId).toBeDefined();
// DELETE the session
const deleteResponse = await fetch(tempUrl, {
method: "DELETE",
headers: {
"mcp-session-id": tempSessionId || "",
"mcp-protocol-version": "2025-03-26",
},
});
expect(deleteResponse.status).toBe(200);
expect(mockCallback).toHaveBeenCalledWith(tempSessionId);
expect(mockCallback).toHaveBeenCalledTimes(1);
// Clean up
tempServer.close();
});
it("should not call onsessionclosed callback when not provided", async () => {
// Create server without onsessionclosed callback
const result = await createTestServer({
sessionIdGenerator: () => randomUUID(),
});
const tempServer = result.server;
const tempUrl = result.baseUrl;
// Initialize to get a session ID
const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize);
const tempSessionId = initResponse.headers.get("mcp-session-id");
// DELETE the session - should not throw error
const deleteResponse = await fetch(tempUrl, {
method: "DELETE",
headers: {
"mcp-session-id": tempSessionId || "",
"mcp-protocol-version": "2025-03-26",
},
});
expect(deleteResponse.status).toBe(200);
// Clean up
tempServer.close();
});
it("should not call onsessionclosed callback for invalid session DELETE", async () => {
const mockCallback = jest.fn();
// Create server with onsessionclosed callback
const result = await createTestServer({
sessionIdGenerator: () => randomUUID(),
onsessionclosed: mockCallback,
});
const tempServer = result.server;
const tempUrl = result.baseUrl;
// Initialize to get a valid session
await sendPostRequest(tempUrl, TEST_MESSAGES.initialize);
// Try to DELETE with invalid session ID
const deleteResponse = await fetch(tempUrl, {
method: "DELETE",
headers: {
"mcp-session-id": "invalid-session-id",
"mcp-protocol-version": "2025-03-26",
},
});
expect(deleteResponse.status).toBe(404);
expect(mockCallback).not.toHaveBeenCalled();
// Clean up
tempServer.close();
});
it("should call onsessionclosed callback with correct session ID when multiple sessions exist", async () => {
const mockCallback = jest.fn();
// Create first server
const result1 = await createTestServer({
sessionIdGenerator: () => randomUUID(),
onsessionclosed: mockCallback,
});
const server1 = result1.server;
const url1 = result1.baseUrl;
// Create second server
const result2 = await createTestServer({
sessionIdGenerator: () => randomUUID(),
onsessionclosed: mockCallback,
});
const server2 = result2.server;
const url2 = result2.baseUrl;
// Initialize both servers
const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize);
const sessionId1 = initResponse1.headers.get("mcp-session-id");
const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize);
const sessionId2 = initResponse2.headers.get("mcp-session-id");
expect(sessionId1).toBeDefined();
expect(sessionId2).toBeDefined();
expect(sessionId1).not.toBe(sessionId2);
// DELETE first session
const deleteResponse1 = await fetch(url1, {
method: "DELETE",
headers: {
"mcp-session-id": sessionId1 || "",
"mcp-protocol-version": "2025-03-26",
},
});
expect(deleteResponse1.status).toBe(200);
expect(mockCallback).toHaveBeenCalledWith(sessionId1);
expect(mockCallback).toHaveBeenCalledTimes(1);
// DELETE second session
const deleteResponse2 = await fetch(url2, {
method: "DELETE",
headers: {
"mcp-session-id": sessionId2 || "",
"mcp-protocol-version": "2025-03-26",
},
});
expect(deleteResponse2.status).toBe(200);
expect(mockCallback).toHaveBeenCalledWith(sessionId2);
expect(mockCallback).toHaveBeenCalledTimes(2);
// Clean up
server1.close();
server2.close();
});
});
// Test DNS rebinding protection
describe("StreamableHTTPServerTransport DNS rebinding protection", () => {
let server: Server;

View file

@ -49,6 +49,18 @@ export interface StreamableHTTPServerTransportOptions {
*/
onsessioninitialized?: (sessionId: string) => void;
/**
* A callback for session close events
* This is called when the server closes a session due to a DELETE request.
* Useful in cases when you need to clean up resources associated with the session.
* Note that this is different from the transport closing, if you are handling
* HTTP requests from multiple nodes you might want to close each
* StreamableHTTPServerTransport after a request is completed while still keeping the
* session open/running.
* @param sessionId The session ID that was closed
*/
onsessionclosed?: (sessionId: string) => void;
/**
* If true, the server will return JSON responses instead of starting an SSE stream.
* This can be useful for simple request/response scenarios without streaming.
@ -127,6 +139,7 @@ export class StreamableHTTPServerTransport implements Transport {
private _standaloneSseStreamId: string = '_GET_stream';
private _eventStore?: EventStore;
private _onsessioninitialized?: (sessionId: string) => void;
private _onsessionclosed?: (sessionId: string) => void;
private _allowedHosts?: string[];
private _allowedOrigins?: string[];
private _enableDnsRebindingProtection: boolean;
@ -141,6 +154,7 @@ export class StreamableHTTPServerTransport implements Transport {
this._enableJsonResponse = options.enableJsonResponse ?? false;
this._eventStore = options.eventStore;
this._onsessioninitialized = options.onsessioninitialized;
this._onsessionclosed = options.onsessionclosed;
this._allowedHosts = options.allowedHosts;
this._allowedOrigins = options.allowedOrigins;
this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false;
@ -538,6 +552,7 @@ export class StreamableHTTPServerTransport implements Transport {
if (!this.validateProtocolVersion(req, res)) {
return;
}
this._onsessionclosed?.(this.sessionId!);
await this.close();
res.writeHead(200).end();
}