Merge branch 'main' into ochafik/spec-type-tests
This commit is contained in:
commit
c72b52b7f1
16 changed files with 359 additions and 44 deletions
35
README.md
35
README.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
==============================================
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue