first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
import type { CompressionResponse, McpTransport, TransportType } from './Transport';
|
||||
|
||||
export class SSETransport extends SSEServerTransport implements McpTransport {
|
||||
readonly transportType: TransportType = 'sse';
|
||||
|
||||
constructor(
|
||||
endpoint: string,
|
||||
private response: CompressionResponse,
|
||||
) {
|
||||
super(endpoint, response);
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
await super.send(message);
|
||||
this.response.flush?.();
|
||||
}
|
||||
|
||||
async handleRequest(
|
||||
req: IncomingMessage,
|
||||
resp: ServerResponse,
|
||||
body: IncomingMessage,
|
||||
): Promise<void> {
|
||||
await super.handlePostMessage(req, resp, body);
|
||||
this.response.flush?.();
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import type { StreamableHTTPServerTransportOptions } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
import type { CompressionResponse, McpTransport, TransportType } from './Transport';
|
||||
|
||||
interface WebStandardTransportInternal {
|
||||
_initialized: boolean;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface StreamableHTTPTransportInternal {
|
||||
_webStandardTransport?: WebStandardTransportInternal;
|
||||
}
|
||||
|
||||
function getWebStandardTransport(transport: unknown): WebStandardTransportInternal | undefined {
|
||||
if (typeof transport === 'object' && transport !== null && '_webStandardTransport' in transport) {
|
||||
const internal = (transport as StreamableHTTPTransportInternal)._webStandardTransport;
|
||||
if (typeof internal === 'object' && internal !== null) {
|
||||
return internal;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class StreamableHttpTransport extends StreamableHTTPServerTransport implements McpTransport {
|
||||
readonly transportType: TransportType = 'streamableHttp';
|
||||
|
||||
private response: CompressionResponse;
|
||||
|
||||
constructor(options: StreamableHTTPServerTransportOptions, response: CompressionResponse) {
|
||||
super(options);
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
markAsInitialized(sessionId: string): void {
|
||||
const webStandardTransport = getWebStandardTransport(this);
|
||||
if (!webStandardTransport) {
|
||||
throw new Error(
|
||||
'Failed to initialize StreamableHttpTransport: internal transport state not found. ' +
|
||||
'This may indicate an incompatible SDK version.',
|
||||
);
|
||||
}
|
||||
webStandardTransport._initialized = true;
|
||||
webStandardTransport.sessionId = sessionId;
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
await super.send(message);
|
||||
this.response.flush?.();
|
||||
}
|
||||
|
||||
async handleRequest(
|
||||
req: IncomingMessage,
|
||||
resp: ServerResponse,
|
||||
parsedBody?: unknown,
|
||||
): Promise<void> {
|
||||
await super.handleRequest(req, resp, parsedBody);
|
||||
this.response.flush?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Response } from 'express';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
export type CompressionResponse = Response & {
|
||||
flush?: () => void;
|
||||
};
|
||||
|
||||
export type TransportType = 'sse' | 'streamableHttp';
|
||||
|
||||
export interface McpTransport {
|
||||
readonly transportType: TransportType;
|
||||
readonly sessionId: string | undefined;
|
||||
|
||||
send(message: JSONRPCMessage): Promise<void>;
|
||||
handleRequest(req: IncomingMessage, resp: ServerResponse, body?: unknown): Promise<void>;
|
||||
close?(): Promise<void>;
|
||||
|
||||
onclose?: () => void | Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { SSETransport } from './SSETransport';
|
||||
import { StreamableHttpTransport } from './StreamableHttpTransport';
|
||||
import type { CompressionResponse } from './Transport';
|
||||
|
||||
export interface StreamableHttpOptions {
|
||||
sessionIdGenerator?: () => string;
|
||||
onsessioninitialized?: (sessionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class TransportFactory {
|
||||
createSSE(postUrl: string, response: CompressionResponse): SSETransport {
|
||||
return new SSETransport(postUrl, response);
|
||||
}
|
||||
|
||||
createStreamableHttp(
|
||||
options: StreamableHttpOptions,
|
||||
response: CompressionResponse,
|
||||
): StreamableHttpTransport {
|
||||
return new StreamableHttpTransport(
|
||||
{
|
||||
sessionIdGenerator: options.sessionIdGenerator ?? (() => randomUUID()),
|
||||
onsessioninitialized: options.onsessioninitialized,
|
||||
},
|
||||
response,
|
||||
);
|
||||
}
|
||||
|
||||
recreateStreamableHttp(
|
||||
sessionId: string,
|
||||
response: CompressionResponse,
|
||||
): StreamableHttpTransport {
|
||||
const transport = new StreamableHttpTransport(
|
||||
{ sessionIdGenerator: () => sessionId },
|
||||
response,
|
||||
);
|
||||
transport.markAsInitialized(sessionId);
|
||||
return transport;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { createMockResponse } from '../../__tests__/helpers';
|
||||
import { SSETransport } from '../SSETransport';
|
||||
|
||||
describe('SSETransport', () => {
|
||||
describe('constructor', () => {
|
||||
it('should set transportType to sse', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = new SSETransport('/messages', response);
|
||||
|
||||
expect(transport.transportType).toBe('sse');
|
||||
});
|
||||
|
||||
it('should pass endpoint to parent SSEServerTransport', () => {
|
||||
const response = createMockResponse();
|
||||
const endpoint = '/api/messages';
|
||||
const transport = new SSETransport(endpoint, response);
|
||||
|
||||
// Verify the transport is properly initialized with endpoint
|
||||
// The endpoint is used by SSEServerTransport for message routing
|
||||
expect(transport.transportType).toBe('sse');
|
||||
expect(typeof transport.send).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should have flush available on response', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = new SSETransport('/messages', response);
|
||||
|
||||
// Verify flush is available - actual send requires full SSE connection setup
|
||||
expect(transport.transportType).toBe('sse');
|
||||
expect(typeof response.flush).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRequest', () => {
|
||||
it('should have handleRequest method', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = new SSETransport('/messages', response);
|
||||
|
||||
expect(typeof transport.handleRequest).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpTransport interface', () => {
|
||||
it('should implement McpTransport interface', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = new SSETransport('/messages', response);
|
||||
|
||||
expect(transport.transportType).toBe('sse');
|
||||
expect(typeof transport.send).toBe('function');
|
||||
expect(typeof transport.handleRequest).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { createMockResponse } from '../../__tests__/helpers';
|
||||
import { StreamableHttpTransport } from '../StreamableHttpTransport';
|
||||
|
||||
describe('StreamableHttpTransport', () => {
|
||||
describe('constructor', () => {
|
||||
it('should set transportType to streamableHttp', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = new StreamableHttpTransport(
|
||||
{ sessionIdGenerator: () => 'test-id' },
|
||||
response,
|
||||
);
|
||||
|
||||
expect(transport.transportType).toBe('streamableHttp');
|
||||
});
|
||||
|
||||
it('should accept sessionIdGenerator option and use it for transport type', () => {
|
||||
const response = createMockResponse();
|
||||
const generator = jest.fn().mockReturnValue('custom-session');
|
||||
|
||||
const transport = new StreamableHttpTransport({ sessionIdGenerator: generator }, response);
|
||||
|
||||
expect(transport.transportType).toBe('streamableHttp');
|
||||
expect(typeof transport.send).toBe('function');
|
||||
});
|
||||
|
||||
it('should accept onsessioninitialized callback option', () => {
|
||||
const response = createMockResponse();
|
||||
const onInit = jest.fn();
|
||||
|
||||
const transport = new StreamableHttpTransport(
|
||||
{
|
||||
sessionIdGenerator: () => 'test-id',
|
||||
onsessioninitialized: onInit,
|
||||
},
|
||||
response,
|
||||
);
|
||||
|
||||
// Verify transport is properly configured with callback option
|
||||
expect(transport.transportType).toBe('streamableHttp');
|
||||
expect(typeof transport.handleRequest).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsInitialized', () => {
|
||||
it('should set sessionId when marked as initialized', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = new StreamableHttpTransport(
|
||||
{ sessionIdGenerator: () => 'test-id' },
|
||||
response,
|
||||
);
|
||||
|
||||
transport.markAsInitialized('specific-session-id');
|
||||
|
||||
expect(transport.sessionId).toBe('specific-session-id');
|
||||
});
|
||||
|
||||
it('should allow setting different sessionId than generator would produce', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = new StreamableHttpTransport(
|
||||
{ sessionIdGenerator: () => 'generator-id' },
|
||||
response,
|
||||
);
|
||||
|
||||
transport.markAsInitialized('override-id');
|
||||
|
||||
expect(transport.sessionId).toBe('override-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should have send method', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = new StreamableHttpTransport(
|
||||
{ sessionIdGenerator: () => 'test-id' },
|
||||
response,
|
||||
);
|
||||
|
||||
expect(typeof transport.send).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRequest', () => {
|
||||
it('should have handleRequest method', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = new StreamableHttpTransport(
|
||||
{ sessionIdGenerator: () => 'test-id' },
|
||||
response,
|
||||
);
|
||||
|
||||
expect(typeof transport.handleRequest).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpTransport interface', () => {
|
||||
it('should implement McpTransport interface', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = new StreamableHttpTransport(
|
||||
{ sessionIdGenerator: () => 'test-id' },
|
||||
response,
|
||||
);
|
||||
|
||||
expect(transport.transportType).toBe('streamableHttp');
|
||||
expect(typeof transport.send).toBe('function');
|
||||
expect(typeof transport.handleRequest).toBe('function');
|
||||
});
|
||||
|
||||
it('should have onclose property', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = new StreamableHttpTransport(
|
||||
{ sessionIdGenerator: () => 'test-id' },
|
||||
response,
|
||||
);
|
||||
|
||||
expect('onclose' in transport).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { createMockResponse } from '../../__tests__/helpers';
|
||||
import { SSETransport } from '../SSETransport';
|
||||
import { StreamableHttpTransport } from '../StreamableHttpTransport';
|
||||
import { TransportFactory } from '../TransportFactory';
|
||||
|
||||
describe('TransportFactory', () => {
|
||||
let factory: TransportFactory;
|
||||
|
||||
beforeEach(() => {
|
||||
factory = new TransportFactory();
|
||||
});
|
||||
|
||||
describe('createSSE', () => {
|
||||
it('should create SSETransport with endpoint and response', () => {
|
||||
const response = createMockResponse();
|
||||
const transport = factory.createSSE('/messages', response);
|
||||
|
||||
expect(transport).toBeInstanceOf(SSETransport);
|
||||
expect(transport.transportType).toBe('sse');
|
||||
});
|
||||
|
||||
it('should create SSETransport with different endpoints', () => {
|
||||
const response1 = createMockResponse();
|
||||
const response2 = createMockResponse();
|
||||
|
||||
const transport1 = factory.createSSE('/api/mcp/messages', response1);
|
||||
const transport2 = factory.createSSE('/custom/endpoint', response2);
|
||||
|
||||
expect(transport1).toBeInstanceOf(SSETransport);
|
||||
expect(transport2).toBeInstanceOf(SSETransport);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStreamableHttp', () => {
|
||||
it('should create StreamableHttpTransport', () => {
|
||||
const response = createMockResponse();
|
||||
|
||||
const transport = factory.createStreamableHttp({}, response);
|
||||
|
||||
expect(transport).toBeInstanceOf(StreamableHttpTransport);
|
||||
expect(transport.transportType).toBe('streamableHttp');
|
||||
});
|
||||
|
||||
it('should pass sessionIdGenerator option', () => {
|
||||
const response = createMockResponse();
|
||||
const customGenerator = jest.fn().mockReturnValue('custom-session-id');
|
||||
|
||||
const transport = factory.createStreamableHttp(
|
||||
{ sessionIdGenerator: customGenerator },
|
||||
response,
|
||||
);
|
||||
|
||||
expect(transport).toBeInstanceOf(StreamableHttpTransport);
|
||||
});
|
||||
|
||||
it('should pass onsessioninitialized callback', () => {
|
||||
const response = createMockResponse();
|
||||
const onSessionInit = jest.fn();
|
||||
|
||||
const transport = factory.createStreamableHttp(
|
||||
{ onsessioninitialized: onSessionInit },
|
||||
response,
|
||||
);
|
||||
|
||||
expect(transport).toBeInstanceOf(StreamableHttpTransport);
|
||||
});
|
||||
|
||||
it('should use default sessionIdGenerator when not provided', () => {
|
||||
const response = createMockResponse();
|
||||
|
||||
const transport = factory.createStreamableHttp({}, response);
|
||||
|
||||
expect(transport).toBeInstanceOf(StreamableHttpTransport);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recreateStreamableHttp', () => {
|
||||
it('should create transport with fixed sessionId', () => {
|
||||
const response = createMockResponse();
|
||||
|
||||
const transport = factory.recreateStreamableHttp('existing-session-123', response);
|
||||
|
||||
expect(transport).toBeInstanceOf(StreamableHttpTransport);
|
||||
expect(transport.sessionId).toBe('existing-session-123');
|
||||
});
|
||||
|
||||
it('should mark transport as initialized', () => {
|
||||
const response = createMockResponse();
|
||||
|
||||
const transport = factory.recreateStreamableHttp('session-id', response);
|
||||
|
||||
// The sessionId being set indicates markAsInitialized was called
|
||||
expect(transport.sessionId).toBe('session-id');
|
||||
});
|
||||
|
||||
it('should create unique transports for different sessions', () => {
|
||||
const response1 = createMockResponse();
|
||||
const response2 = createMockResponse();
|
||||
|
||||
const transport1 = factory.recreateStreamableHttp('session-1', response1);
|
||||
const transport2 = factory.recreateStreamableHttp('session-2', response2);
|
||||
|
||||
expect(transport1).not.toBe(transport2);
|
||||
expect(transport1.sessionId).toBe('session-1');
|
||||
expect(transport2.sessionId).toBe('session-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('factory independence', () => {
|
||||
it('should create independent transports from same factory', () => {
|
||||
const response1 = createMockResponse();
|
||||
const response2 = createMockResponse();
|
||||
|
||||
const sseTransport = factory.createSSE('/messages', response1);
|
||||
const httpTransport = factory.createStreamableHttp({}, response2);
|
||||
|
||||
expect(sseTransport).not.toBe(httpTransport);
|
||||
expect(sseTransport.transportType).toBe('sse');
|
||||
expect(httpTransport.transportType).toBe('streamableHttp');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { CompressionResponse, TransportType, McpTransport } from './Transport';
|
||||
export * from './SSETransport';
|
||||
export * from './StreamableHttpTransport';
|
||||
export * from './TransportFactory';
|
||||
Reference in New Issue
Block a user