Merge branch 'main' into patch-1
This commit is contained in:
commit
3048a6ffcf
21 changed files with 3522 additions and 1674 deletions
|
|
@ -126,7 +126,11 @@ export class Client<
|
|||
|
||||
override async connect(transport: Transport, options?: RequestOptions): Promise<void> {
|
||||
await super.connect(transport);
|
||||
|
||||
// When transport sessionId is already set this means we are trying to reconnect.
|
||||
// In this case we don't need to initialize again.
|
||||
if (transport.sessionId !== undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await this.request(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { StreamableHTTPClientTransport } from "./streamableHttp.js";
|
||||
import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js";
|
||||
import { JSONRPCMessage } from "../types.js";
|
||||
|
||||
|
||||
|
|
@ -101,6 +101,77 @@ describe("StreamableHTTPClientTransport", () => {
|
|||
expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id");
|
||||
});
|
||||
|
||||
it("should terminate session with DELETE request", async () => {
|
||||
// First, simulate getting a session ID
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
clientInfo: { name: "test-client", version: "1.0" },
|
||||
protocolVersion: "2025-03-26"
|
||||
},
|
||||
id: "init-id"
|
||||
};
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }),
|
||||
});
|
||||
|
||||
await transport.send(message);
|
||||
expect(transport.sessionId).toBe("test-session-id");
|
||||
|
||||
// Now terminate the session
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers()
|
||||
});
|
||||
|
||||
await transport.terminateSession();
|
||||
|
||||
// Verify the DELETE request was sent with the session ID
|
||||
const calls = (global.fetch as jest.Mock).mock.calls;
|
||||
const lastCall = calls[calls.length - 1];
|
||||
expect(lastCall[1].method).toBe("DELETE");
|
||||
expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id");
|
||||
|
||||
// The session ID should be cleared after successful termination
|
||||
expect(transport.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle 405 response when server doesn't support session termination", async () => {
|
||||
// First, simulate getting a session ID
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
clientInfo: { name: "test-client", version: "1.0" },
|
||||
protocolVersion: "2025-03-26"
|
||||
},
|
||||
id: "init-id"
|
||||
};
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }),
|
||||
});
|
||||
|
||||
await transport.send(message);
|
||||
|
||||
// Now terminate the session, but server responds with 405
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 405,
|
||||
statusText: "Method Not Allowed",
|
||||
headers: new Headers()
|
||||
});
|
||||
|
||||
await expect(transport.terminateSession()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should handle 404 response when session expires", async () => {
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
|
|
@ -164,7 +235,7 @@ describe("StreamableHTTPClientTransport", () => {
|
|||
// We expect the 405 error to be caught and handled gracefully
|
||||
// This should not throw an error that breaks the transport
|
||||
await transport.start();
|
||||
await expect(transport["_startOrAuthStandaloneSSE"]()).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed");
|
||||
await expect(transport["_startOrAuthSse"]({})).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed");
|
||||
// Check that GET was attempted
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
|
@ -208,7 +279,7 @@ describe("StreamableHTTPClientTransport", () => {
|
|||
transport.onmessage = messageSpy;
|
||||
|
||||
await transport.start();
|
||||
await transport["_startOrAuthStandaloneSSE"]();
|
||||
await transport["_startOrAuthSse"]({});
|
||||
|
||||
// Give time for the SSE event to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
|
@ -275,45 +346,62 @@ describe("StreamableHTTPClientTransport", () => {
|
|||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("should include last-event-id header when resuming a broken connection", async () => {
|
||||
// First make a successful connection that provides an event ID
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const event = "id: event-123\nevent: message\ndata: {\"jsonrpc\": \"2.0\", \"method\": \"serverNotification\", \"params\": {}}\n\n";
|
||||
controller.enqueue(encoder.encode(event));
|
||||
controller.close();
|
||||
it("should support custom reconnection options", () => {
|
||||
// Create a transport with custom reconnection options
|
||||
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), {
|
||||
reconnectionOptions: {
|
||||
initialReconnectionDelay: 500,
|
||||
maxReconnectionDelay: 10000,
|
||||
reconnectionDelayGrowFactor: 2,
|
||||
maxRetries: 5,
|
||||
}
|
||||
});
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
// Verify options were set correctly (checking implementation details)
|
||||
// Access private properties for testing
|
||||
const transportInstance = transport as unknown as {
|
||||
_reconnectionOptions: StreamableHTTPReconnectionOptions;
|
||||
};
|
||||
expect(transportInstance._reconnectionOptions.initialReconnectionDelay).toBe(500);
|
||||
expect(transportInstance._reconnectionOptions.maxRetries).toBe(5);
|
||||
});
|
||||
|
||||
it("should pass lastEventId when reconnecting", async () => {
|
||||
// Create a fresh transport
|
||||
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"));
|
||||
|
||||
// Mock fetch to verify headers sent
|
||||
const fetchSpy = global.fetch as jest.Mock;
|
||||
fetchSpy.mockReset();
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "content-type": "text/event-stream" }),
|
||||
body: stream
|
||||
body: new ReadableStream()
|
||||
});
|
||||
|
||||
// Call the reconnect method directly with a lastEventId
|
||||
await transport.start();
|
||||
await transport["_startOrAuthStandaloneSSE"]();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
// Type assertion to access private method
|
||||
const transportWithPrivateMethods = transport as unknown as {
|
||||
_startOrAuthSse: (options: { resumptionToken?: string }) => Promise<void>
|
||||
};
|
||||
await transportWithPrivateMethods._startOrAuthSse({ resumptionToken: "test-event-id" });
|
||||
|
||||
// Now simulate attempting to reconnect
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "content-type": "text/event-stream" }),
|
||||
body: null
|
||||
});
|
||||
|
||||
await transport["_startOrAuthStandaloneSSE"]();
|
||||
|
||||
// Check that Last-Event-ID was included
|
||||
const calls = (global.fetch as jest.Mock).mock.calls;
|
||||
const lastCall = calls[calls.length - 1];
|
||||
expect(lastCall[1].headers.get("last-event-id")).toBe("event-123");
|
||||
// Verify fetch was called with the lastEventId header
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
const fetchCall = fetchSpy.mock.calls[0];
|
||||
const headers = fetchCall[1].headers;
|
||||
expect(headers.get("last-event-id")).toBe("test-event-id");
|
||||
});
|
||||
|
||||
it("should throw error when invalid content-type is received", async () => {
|
||||
// Clear any previous state from other tests
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create a fresh transport instance
|
||||
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"));
|
||||
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
method: "test",
|
||||
|
|
@ -323,7 +411,7 @@ describe("StreamableHTTPClientTransport", () => {
|
|||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue("invalid text response");
|
||||
controller.enqueue(new TextEncoder().encode("invalid text response"));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -365,7 +453,7 @@ describe("StreamableHTTPClientTransport", () => {
|
|||
|
||||
await transport.start();
|
||||
|
||||
await transport["_startOrAuthStandaloneSSE"]();
|
||||
await transport["_startOrAuthSse"]({});
|
||||
expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue");
|
||||
|
||||
requestInit.headers["X-Custom-Header"] = "SecondCustomValue";
|
||||
|
|
@ -375,4 +463,38 @@ describe("StreamableHTTPClientTransport", () => {
|
|||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
|
||||
it("should have exponential backoff with configurable maxRetries", () => {
|
||||
// This test verifies the maxRetries and backoff calculation directly
|
||||
|
||||
// Create transport with specific options for testing
|
||||
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), {
|
||||
reconnectionOptions: {
|
||||
initialReconnectionDelay: 100,
|
||||
maxReconnectionDelay: 5000,
|
||||
reconnectionDelayGrowFactor: 2,
|
||||
maxRetries: 3,
|
||||
}
|
||||
});
|
||||
|
||||
// Get access to the internal implementation
|
||||
const getDelay = transport["_getNextReconnectionDelay"].bind(transport);
|
||||
|
||||
// First retry - should use initial delay
|
||||
expect(getDelay(0)).toBe(100);
|
||||
|
||||
// Second retry - should double (2^1 * 100 = 200)
|
||||
expect(getDelay(1)).toBe(200);
|
||||
|
||||
// Third retry - should double again (2^2 * 100 = 400)
|
||||
expect(getDelay(2)).toBe(400);
|
||||
|
||||
// Fourth retry - should double again (2^3 * 100 = 800)
|
||||
expect(getDelay(3)).toBe(800);
|
||||
|
||||
// Tenth retry - should be capped at maxReconnectionDelay
|
||||
expect(getDelay(10)).toBe(5000);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import { Transport } from "../shared/transport.js";
|
||||
import { isJSONRPCNotification, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
|
||||
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
|
||||
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js";
|
||||
import { EventSourceParserStream } from "eventsource-parser/stream";
|
||||
|
||||
// Default reconnection options for StreamableHTTP connections
|
||||
const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = {
|
||||
initialReconnectionDelay: 1000,
|
||||
maxReconnectionDelay: 30000,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
maxRetries: 2,
|
||||
};
|
||||
|
||||
export class StreamableHTTPError extends Error {
|
||||
constructor(
|
||||
public readonly code: number | undefined,
|
||||
|
|
@ -12,6 +20,60 @@ export class StreamableHTTPError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for starting or authenticating an SSE connection
|
||||
*/
|
||||
interface StartSSEOptions {
|
||||
/**
|
||||
* The resumption token used to continue long-running requests that were interrupted.
|
||||
*
|
||||
* This allows clients to reconnect and continue from where they left off.
|
||||
*/
|
||||
resumptionToken?: string;
|
||||
|
||||
/**
|
||||
* A callback that is invoked when the resumption token changes.
|
||||
*
|
||||
* This allows clients to persist the latest token for potential reconnection.
|
||||
*/
|
||||
onresumptiontoken?: (token: string) => void;
|
||||
|
||||
/**
|
||||
* Override Message ID to associate with the replay message
|
||||
* so that response can be associate with the new resumed request.
|
||||
*/
|
||||
replayMessageId?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for reconnection behavior of the StreamableHTTPClientTransport.
|
||||
*/
|
||||
export interface StreamableHTTPReconnectionOptions {
|
||||
/**
|
||||
* Maximum backoff time between reconnection attempts in milliseconds.
|
||||
* Default is 30000 (30 seconds).
|
||||
*/
|
||||
maxReconnectionDelay: number;
|
||||
|
||||
/**
|
||||
* Initial backoff time between reconnection attempts in milliseconds.
|
||||
* Default is 1000 (1 second).
|
||||
*/
|
||||
initialReconnectionDelay: number;
|
||||
|
||||
/**
|
||||
* The factor by which the reconnection delay increases after each attempt.
|
||||
* Default is 1.5.
|
||||
*/
|
||||
reconnectionDelayGrowFactor: number;
|
||||
|
||||
/**
|
||||
* Maximum number of reconnection attempts before giving up.
|
||||
* Default is 2.
|
||||
*/
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the `StreamableHTTPClientTransport`.
|
||||
*/
|
||||
|
|
@ -36,6 +98,17 @@ export type StreamableHTTPClientTransportOptions = {
|
|||
* Customizes HTTP requests to the server.
|
||||
*/
|
||||
requestInit?: RequestInit;
|
||||
|
||||
/**
|
||||
* Options to configure the reconnection behavior.
|
||||
*/
|
||||
reconnectionOptions?: StreamableHTTPReconnectionOptions;
|
||||
|
||||
/**
|
||||
* Session ID for the connection. This is used to identify the session on the server.
|
||||
* When not provided and connecting to a server that supports session IDs, the server will generate a new session ID.
|
||||
*/
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -49,7 +122,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
private _requestInit?: RequestInit;
|
||||
private _authProvider?: OAuthClientProvider;
|
||||
private _sessionId?: string;
|
||||
private _lastEventId?: string;
|
||||
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
|
|
@ -62,6 +135,8 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
this._url = url;
|
||||
this._requestInit = opts?.requestInit;
|
||||
this._authProvider = opts?.authProvider;
|
||||
this._sessionId = opts?.sessionId;
|
||||
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
|
||||
}
|
||||
|
||||
private async _authThenStart(): Promise<void> {
|
||||
|
|
@ -81,7 +156,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
return await this._startOrAuthStandaloneSSE();
|
||||
return await this._startOrAuthSse({ resumptionToken: undefined });
|
||||
}
|
||||
|
||||
private async _commonHeaders(): Promise<Headers> {
|
||||
|
|
@ -102,16 +177,18 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
);
|
||||
}
|
||||
|
||||
private async _startOrAuthStandaloneSSE(): Promise<void> {
|
||||
|
||||
private async _startOrAuthSse(options: StartSSEOptions): Promise<void> {
|
||||
const { resumptionToken } = options;
|
||||
try {
|
||||
// Try to open an initial SSE stream with GET to listen for server messages
|
||||
// This is optional according to the spec - server may not support it
|
||||
const headers = await this._commonHeaders();
|
||||
headers.set("Accept", "text/event-stream");
|
||||
|
||||
// Include Last-Event-ID header for resumable streams
|
||||
if (this._lastEventId) {
|
||||
headers.set("last-event-id", this._lastEventId);
|
||||
// Include Last-Event-ID header for resumable streams if provided
|
||||
if (resumptionToken) {
|
||||
headers.set("last-event-id", resumptionToken);
|
||||
}
|
||||
|
||||
const response = await fetch(this._url, {
|
||||
|
|
@ -137,44 +214,128 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
`Failed to open SSE stream: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
// Successful connection, handle the SSE stream as a standalone listener
|
||||
this._handleSseStream(response.body);
|
||||
|
||||
this._handleSseStream(response.body, options);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSseStream(stream: ReadableStream<Uint8Array> | null): void {
|
||||
if (!stream) {
|
||||
|
||||
/**
|
||||
* Calculates the next reconnection delay using backoff algorithm
|
||||
*
|
||||
* @param attempt Current reconnection attempt count for the specific stream
|
||||
* @returns Time to wait in milliseconds before next reconnection attempt
|
||||
*/
|
||||
private _getNextReconnectionDelay(attempt: number): number {
|
||||
// Access default values directly, ensuring they're never undefined
|
||||
const initialDelay = this._reconnectionOptions.initialReconnectionDelay;
|
||||
const growFactor = this._reconnectionOptions.reconnectionDelayGrowFactor;
|
||||
const maxDelay = this._reconnectionOptions.maxReconnectionDelay;
|
||||
|
||||
// Cap at maximum delay
|
||||
return Math.min(initialDelay * Math.pow(growFactor, attempt), maxDelay);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconnection attempt with exponential backoff
|
||||
*
|
||||
* @param lastEventId The ID of the last received event for resumability
|
||||
* @param attemptCount Current reconnection attempt count for this specific stream
|
||||
*/
|
||||
private _scheduleReconnection(options: StartSSEOptions, attemptCount = 0): void {
|
||||
// Use provided options or default options
|
||||
const maxRetries = this._reconnectionOptions.maxRetries;
|
||||
|
||||
// Check if we've exceeded maximum retry attempts
|
||||
if (maxRetries > 0 && attemptCount >= maxRetries) {
|
||||
this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const processStream = async () => {
|
||||
// Create a pipeline: binary stream -> text decoder -> SSE parser
|
||||
const eventStream = stream
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(new EventSourceParserStream());
|
||||
// Calculate next delay based on current attempt count
|
||||
const delay = this._getNextReconnectionDelay(attemptCount);
|
||||
|
||||
for await (const event of eventStream) {
|
||||
// Update last event ID if provided
|
||||
if (event.id) {
|
||||
this._lastEventId = event.id;
|
||||
// Schedule the reconnection
|
||||
setTimeout(() => {
|
||||
// Use the last event ID to resume where we left off
|
||||
this._startOrAuthSse(options).catch(error => {
|
||||
this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
|
||||
// Schedule another attempt if this one failed, incrementing the attempt counter
|
||||
this._scheduleReconnection(options, attemptCount + 1);
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private _handleSseStream(stream: ReadableStream<Uint8Array> | null, options: StartSSEOptions): void {
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
const { onresumptiontoken, replayMessageId } = options;
|
||||
|
||||
let lastEventId: string | undefined;
|
||||
const processStream = async () => {
|
||||
// this is the closest we can get to trying to catch network errors
|
||||
// if something happens reader will throw
|
||||
try {
|
||||
// Create a pipeline: binary stream -> text decoder -> SSE parser
|
||||
const reader = stream
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(new EventSourceParserStream())
|
||||
.getReader();
|
||||
|
||||
|
||||
while (true) {
|
||||
const { value: event, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Update last event ID if provided
|
||||
if (event.id) {
|
||||
lastEventId = event.id;
|
||||
onresumptiontoken?.(event.id);
|
||||
}
|
||||
|
||||
if (!event.event || event.event === "message") {
|
||||
try {
|
||||
const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));
|
||||
if (replayMessageId !== undefined && isJSONRPCResponse(message)) {
|
||||
message.id = replayMessageId;
|
||||
}
|
||||
this.onmessage?.(message);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle message events (default event type is undefined per docs)
|
||||
// or explicit 'message' event type
|
||||
if (!event.event || event.event === "message") {
|
||||
try {
|
||||
const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));
|
||||
this.onmessage?.(message);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
} catch (error) {
|
||||
// Handle stream errors - likely a network disconnect
|
||||
this.onerror?.(new Error(`SSE stream disconnected: ${error}`));
|
||||
|
||||
// Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing
|
||||
if (this._abortController && !this._abortController.signal.aborted) {
|
||||
// Use the exponential backoff reconnection strategy
|
||||
if (lastEventId !== undefined) {
|
||||
try {
|
||||
this._scheduleReconnection({
|
||||
resumptionToken: lastEventId,
|
||||
onresumptiontoken,
|
||||
replayMessageId
|
||||
}, 0);
|
||||
}
|
||||
catch (error) {
|
||||
this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`));
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processStream().catch(err => this.onerror?.(err));
|
||||
processStream();
|
||||
}
|
||||
|
||||
async start() {
|
||||
|
|
@ -208,8 +369,16 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
this.onclose?.();
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise<void> {
|
||||
async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string, onresumptiontoken?: (token: string) => void }): Promise<void> {
|
||||
try {
|
||||
const { resumptionToken, onresumptiontoken } = options || {};
|
||||
|
||||
if (resumptionToken) {
|
||||
// If we have at last event ID, we need to reconnect the SSE stream
|
||||
this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err => this.onerror?.(err));
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = await this._commonHeaders();
|
||||
headers.set("content-type", "application/json");
|
||||
headers.set("accept", "application/json, text/event-stream");
|
||||
|
|
@ -251,9 +420,9 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
if (response.status === 202) {
|
||||
// if the accepted notification is initialized, we start the SSE stream
|
||||
// if it's supported by the server
|
||||
if (isJSONRPCNotification(message) && message.method === "notifications/initialized") {
|
||||
// We don't need to handle 405 here anymore as it's handled in _startOrAuthStandaloneSSE
|
||||
this._startOrAuthStandaloneSSE().catch(err => this.onerror?.(err));
|
||||
if (isInitializedNotification(message)) {
|
||||
// Start without a lastEventId since this is a fresh connection
|
||||
this._startOrAuthSse({ resumptionToken: undefined }).catch(err => this.onerror?.(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -268,7 +437,10 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
|
||||
if (hasRequests) {
|
||||
if (contentType?.includes("text/event-stream")) {
|
||||
this._handleSseStream(response.body);
|
||||
// Handle SSE stream responses for requests
|
||||
// We use the same handler as standalone streams, which now supports
|
||||
// reconnection with the last event ID
|
||||
this._handleSseStream(response.body, { onresumptiontoken });
|
||||
} else if (contentType?.includes("application/json")) {
|
||||
// For non-streaming servers, we might get direct JSON responses
|
||||
const data = await response.json();
|
||||
|
|
@ -291,4 +463,52 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
get sessionId(): string | undefined {
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminates the current session by sending a DELETE request to the server.
|
||||
*
|
||||
* Clients that no longer need a particular session
|
||||
* (e.g., because the user is leaving the client application) SHOULD send an
|
||||
* HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
|
||||
* terminate the session.
|
||||
*
|
||||
* The server MAY respond with HTTP 405 Method Not Allowed, indicating that
|
||||
* the server does not allow clients to terminate sessions.
|
||||
*/
|
||||
async terminateSession(): Promise<void> {
|
||||
if (!this._sessionId) {
|
||||
return; // No session to terminate
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = await this._commonHeaders();
|
||||
|
||||
const init = {
|
||||
...this._requestInit,
|
||||
method: "DELETE",
|
||||
headers,
|
||||
signal: this._abortController?.signal,
|
||||
};
|
||||
|
||||
const response = await fetch(this._url, init);
|
||||
|
||||
// We specifically handle 405 as a valid response according to the spec,
|
||||
// meaning the server does not support explicit session termination
|
||||
if (!response.ok && response.status !== 405) {
|
||||
throw new StreamableHTTPError(
|
||||
response.status,
|
||||
`Failed to terminate session: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
this._sessionId = undefined;
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Client } from '../../client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
|
||||
import { createInterface } from 'node:readline';
|
||||
import {
|
||||
ListToolsRequest,
|
||||
ListToolsResultSchema,
|
||||
|
|
@ -15,139 +16,487 @@ import {
|
|||
ResourceListChangedNotificationSchema,
|
||||
} from '../../types.js';
|
||||
|
||||
// Create readline interface for user input
|
||||
const readline = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
// Track received notifications for debugging resumability
|
||||
let notificationCount = 0;
|
||||
|
||||
// Global client and transport for interactive commands
|
||||
let client: Client | null = null;
|
||||
let transport: StreamableHTTPClientTransport | null = null;
|
||||
let serverUrl = 'http://localhost:3000/mcp';
|
||||
let notificationsToolLastEventId: string | undefined = undefined;
|
||||
let sessionId: string | undefined = undefined;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Create a new client with streamable HTTP transport
|
||||
const client = new Client({
|
||||
name: 'example-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
console.log('MCP Interactive Client');
|
||||
console.log('=====================');
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL('http://localhost:3000/mcp')
|
||||
);
|
||||
// Connect to server immediately with default settings
|
||||
await connect();
|
||||
|
||||
// Connect the client using the transport and initialize the server
|
||||
await client.connect(transport);
|
||||
console.log('Connected to MCP server');
|
||||
|
||||
// Set up notification handlers for server-initiated messages
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
|
||||
console.log(`Notification received: ${notification.params.level} - ${notification.params.data}`);
|
||||
});
|
||||
client.setNotificationHandler(ResourceListChangedNotificationSchema, async (_) => {
|
||||
console.log(`Resource list changed notification received!`);
|
||||
const resourcesRequest: ListResourcesRequest = {
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
};
|
||||
const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema);
|
||||
console.log('Available resources count:', resourcesResult.resources.length);
|
||||
});
|
||||
|
||||
// List and call tools
|
||||
await listTools(client);
|
||||
|
||||
await callGreetTool(client);
|
||||
await callMultiGreetTool(client);
|
||||
|
||||
|
||||
// List available prompts
|
||||
try {
|
||||
const promptsRequest: ListPromptsRequest = {
|
||||
method: 'prompts/list',
|
||||
params: {}
|
||||
};
|
||||
const promptsResult = await client.request(promptsRequest, ListPromptsResultSchema);
|
||||
console.log('Available prompts:', promptsResult.prompts);
|
||||
} catch (error) {
|
||||
console.log(`Prompts not supported by this server (${error})`);
|
||||
}
|
||||
|
||||
// Get a prompt
|
||||
try {
|
||||
const promptRequest: GetPromptRequest = {
|
||||
method: 'prompts/get',
|
||||
params: {
|
||||
name: 'greeting-template',
|
||||
arguments: { name: 'MCP User' }
|
||||
}
|
||||
};
|
||||
const promptResult = await client.request(promptRequest, GetPromptResultSchema);
|
||||
console.log('Prompt template:', promptResult.messages[0].content.text);
|
||||
} catch (error) {
|
||||
console.log(`Prompt retrieval not supported by this server (${error})`);
|
||||
}
|
||||
|
||||
// List available resources
|
||||
try {
|
||||
const resourcesRequest: ListResourcesRequest = {
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
};
|
||||
const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema);
|
||||
console.log('Available resources:', resourcesResult.resources);
|
||||
} catch (error) {
|
||||
console.log(`Resources not supported by this server (${error})`);
|
||||
}
|
||||
// Keep the connection open to receive notifications
|
||||
console.log('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.');
|
||||
// Print help and start the command loop
|
||||
printHelp();
|
||||
commandLoop();
|
||||
}
|
||||
|
||||
async function listTools(client: Client): Promise<void> {
|
||||
function printHelp(): void {
|
||||
console.log('\nAvailable commands:');
|
||||
console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)');
|
||||
console.log(' disconnect - Disconnect from server');
|
||||
console.log(' terminate-session - Terminate the current session');
|
||||
console.log(' reconnect - Reconnect to the server');
|
||||
console.log(' list-tools - List available tools');
|
||||
console.log(' call-tool <name> [args] - Call a tool with optional JSON arguments');
|
||||
console.log(' greet [name] - Call the greet tool');
|
||||
console.log(' multi-greet [name] - Call the multi-greet tool with notifications');
|
||||
console.log(' start-notifications [interval] [count] - Start periodic notifications');
|
||||
console.log(' list-prompts - List available prompts');
|
||||
console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments');
|
||||
console.log(' list-resources - List available resources');
|
||||
console.log(' help - Show this help');
|
||||
console.log(' quit - Exit the program');
|
||||
}
|
||||
|
||||
function commandLoop(): void {
|
||||
readline.question('\n> ', async (input) => {
|
||||
const args = input.trim().split(/\s+/);
|
||||
const command = args[0]?.toLowerCase();
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'connect':
|
||||
await connect(args[1]);
|
||||
break;
|
||||
|
||||
case 'disconnect':
|
||||
await disconnect();
|
||||
break;
|
||||
|
||||
case 'terminate-session':
|
||||
await terminateSession();
|
||||
break;
|
||||
|
||||
case 'reconnect':
|
||||
await reconnect();
|
||||
break;
|
||||
|
||||
case 'list-tools':
|
||||
await listTools();
|
||||
break;
|
||||
|
||||
case 'call-tool':
|
||||
if (args.length < 2) {
|
||||
console.log('Usage: call-tool <name> [args]');
|
||||
} else {
|
||||
const toolName = args[1];
|
||||
let toolArgs = {};
|
||||
if (args.length > 2) {
|
||||
try {
|
||||
toolArgs = JSON.parse(args.slice(2).join(' '));
|
||||
} catch {
|
||||
console.log('Invalid JSON arguments. Using empty args.');
|
||||
}
|
||||
}
|
||||
await callTool(toolName, toolArgs);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'greet':
|
||||
await callGreetTool(args[1] || 'MCP User');
|
||||
break;
|
||||
|
||||
case 'multi-greet':
|
||||
await callMultiGreetTool(args[1] || 'MCP User');
|
||||
break;
|
||||
|
||||
case 'start-notifications': {
|
||||
const interval = args[1] ? parseInt(args[1], 10) : 2000;
|
||||
const count = args[2] ? parseInt(args[2], 10) : 10;
|
||||
await startNotifications(interval, count);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'list-prompts':
|
||||
await listPrompts();
|
||||
break;
|
||||
|
||||
case 'get-prompt':
|
||||
if (args.length < 2) {
|
||||
console.log('Usage: get-prompt <name> [args]');
|
||||
} else {
|
||||
const promptName = args[1];
|
||||
let promptArgs = {};
|
||||
if (args.length > 2) {
|
||||
try {
|
||||
promptArgs = JSON.parse(args.slice(2).join(' '));
|
||||
} catch {
|
||||
console.log('Invalid JSON arguments. Using empty args.');
|
||||
}
|
||||
}
|
||||
await getPrompt(promptName, promptArgs);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'list-resources':
|
||||
await listResources();
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
printHelp();
|
||||
break;
|
||||
|
||||
case 'quit':
|
||||
case 'exit':
|
||||
await cleanup();
|
||||
return;
|
||||
|
||||
default:
|
||||
if (command) {
|
||||
console.log(`Unknown command: ${command}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing command: ${error}`);
|
||||
}
|
||||
|
||||
// Continue the command loop
|
||||
commandLoop();
|
||||
});
|
||||
}
|
||||
|
||||
async function connect(url?: string): Promise<void> {
|
||||
if (client) {
|
||||
console.log('Already connected. Disconnect first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
serverUrl = url;
|
||||
}
|
||||
|
||||
console.log(`Connecting to ${serverUrl}...`);
|
||||
|
||||
try {
|
||||
// Create a new client
|
||||
client = new Client({
|
||||
name: 'example-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
client.onerror = (error) => {
|
||||
console.error('\x1b[31mClient error:', error, '\x1b[0m');
|
||||
}
|
||||
|
||||
transport = new StreamableHTTPClientTransport(
|
||||
new URL(serverUrl),
|
||||
{
|
||||
sessionId: sessionId
|
||||
}
|
||||
);
|
||||
|
||||
// Set up notification handlers
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
|
||||
notificationCount++;
|
||||
console.log(`\nNotification #${notificationCount}: ${notification.params.level} - ${notification.params.data}`);
|
||||
// Re-display the prompt
|
||||
process.stdout.write('> ');
|
||||
});
|
||||
|
||||
client.setNotificationHandler(ResourceListChangedNotificationSchema, async (_) => {
|
||||
console.log(`\nResource list changed notification received!`);
|
||||
try {
|
||||
if (!client) {
|
||||
console.log('Client disconnected, cannot fetch resources');
|
||||
return;
|
||||
}
|
||||
const resourcesResult = await client.request({
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
}, ListResourcesResultSchema);
|
||||
console.log('Available resources count:', resourcesResult.resources.length);
|
||||
} catch {
|
||||
console.log('Failed to list resources after change notification');
|
||||
}
|
||||
// Re-display the prompt
|
||||
process.stdout.write('> ');
|
||||
});
|
||||
|
||||
// Connect the client
|
||||
await client.connect(transport);
|
||||
sessionId = transport.sessionId
|
||||
console.log('Transport created with session ID:', sessionId);
|
||||
console.log('Connected to MCP server');
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error);
|
||||
client = null;
|
||||
transport = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(): Promise<void> {
|
||||
if (!client || !transport) {
|
||||
console.log('Not connected.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.close();
|
||||
console.log('Disconnected from MCP server');
|
||||
client = null;
|
||||
transport = null;
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateSession(): Promise<void> {
|
||||
if (!client || !transport) {
|
||||
console.log('Not connected.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Terminating session with ID:', transport.sessionId);
|
||||
await transport.terminateSession();
|
||||
console.log('Session terminated successfully');
|
||||
|
||||
// Check if sessionId was cleared after termination
|
||||
if (!transport.sessionId) {
|
||||
console.log('Session ID has been cleared');
|
||||
sessionId = undefined;
|
||||
|
||||
// Also close the transport and clear client objects
|
||||
await transport.close();
|
||||
console.log('Transport closed after session termination');
|
||||
client = null;
|
||||
transport = null;
|
||||
} else {
|
||||
console.log('Server responded with 405 Method Not Allowed (session termination not supported)');
|
||||
console.log('Session ID is still active:', transport.sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error terminating session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnect(): Promise<void> {
|
||||
if (client) {
|
||||
await disconnect();
|
||||
}
|
||||
await connect();
|
||||
}
|
||||
|
||||
async function listTools(): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const toolsRequest: ListToolsRequest = {
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
const toolsResult = await client.request(toolsRequest, ListToolsResultSchema);
|
||||
console.log('Available tools:', toolsResult.tools);
|
||||
|
||||
console.log('Available tools:');
|
||||
if (toolsResult.tools.length === 0) {
|
||||
console.log('No tools available from the server');
|
||||
console.log(' No tools available');
|
||||
} else {
|
||||
for (const tool of toolsResult.tools) {
|
||||
console.log(` - ${tool.name}: ${tool.description}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Tools not supported by this server (${error})`);
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function callGreetTool(client: Client): Promise<void> {
|
||||
try {
|
||||
const greetRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'greet',
|
||||
arguments: { name: 'MCP User' }
|
||||
}
|
||||
};
|
||||
const greetResult = await client.request(greetRequest, CallToolResultSchema);
|
||||
console.log('Greeting result:', greetResult.content[0].text);
|
||||
} catch (error) {
|
||||
console.log(`Error calling greet tool: ${error}`);
|
||||
async function callTool(name: string, args: Record<string, unknown>): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function callMultiGreetTool(client: Client): Promise<void> {
|
||||
try {
|
||||
console.log('\nCalling multi-greet tool (with notifications)...');
|
||||
const multiGreetRequest: CallToolRequest = {
|
||||
const request: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'multi-greet',
|
||||
arguments: { name: 'MCP User' }
|
||||
name,
|
||||
arguments: args
|
||||
}
|
||||
};
|
||||
const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema);
|
||||
console.log('Multi-greet results:');
|
||||
multiGreetResult.content.forEach(item => {
|
||||
|
||||
console.log(`Calling tool '${name}' with args:`, args);
|
||||
const onLastEventIdUpdate = (event: string) => {
|
||||
notificationsToolLastEventId = event;
|
||||
};
|
||||
const result = await client.request(request, CallToolResultSchema, {
|
||||
resumptionToken: notificationsToolLastEventId, onresumptiontoken: onLastEventIdUpdate
|
||||
});
|
||||
|
||||
console.log('Tool result:');
|
||||
result.content.forEach(item => {
|
||||
if (item.type === 'text') {
|
||||
console.log(`- ${item.text}`);
|
||||
console.log(` ${item.text}`);
|
||||
} else {
|
||||
console.log(` ${item.type} content:`, item);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`Error calling multi-greet tool: ${error}`);
|
||||
console.log(`Error calling tool ${name}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function callGreetTool(name: string): Promise<void> {
|
||||
await callTool('greet', { name });
|
||||
}
|
||||
|
||||
async function callMultiGreetTool(name: string): Promise<void> {
|
||||
console.log('Calling multi-greet tool with notifications...');
|
||||
await callTool('multi-greet', { name });
|
||||
}
|
||||
|
||||
async function startNotifications(interval: number, count: number): Promise<void> {
|
||||
console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`);
|
||||
await callTool('start-notification-stream', { interval, count });
|
||||
}
|
||||
|
||||
async function listPrompts(): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const promptsRequest: ListPromptsRequest = {
|
||||
method: 'prompts/list',
|
||||
params: {}
|
||||
};
|
||||
const promptsResult = await client.request(promptsRequest, ListPromptsResultSchema);
|
||||
console.log('Available prompts:');
|
||||
if (promptsResult.prompts.length === 0) {
|
||||
console.log(' No prompts available');
|
||||
} else {
|
||||
for (const prompt of promptsResult.prompts) {
|
||||
console.log(` - ${prompt.name}: ${prompt.description}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Prompts not supported by this server (${error})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getPrompt(name: string, args: Record<string, unknown>): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const promptRequest: GetPromptRequest = {
|
||||
method: 'prompts/get',
|
||||
params: {
|
||||
name,
|
||||
arguments: args as Record<string, string>
|
||||
}
|
||||
};
|
||||
|
||||
const promptResult = await client.request(promptRequest, GetPromptResultSchema);
|
||||
console.log('Prompt template:');
|
||||
promptResult.messages.forEach((msg, index) => {
|
||||
console.log(` [${index + 1}] ${msg.role}: ${msg.content.text}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`Error getting prompt ${name}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function listResources(): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resourcesRequest: ListResourcesRequest = {
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
};
|
||||
const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema);
|
||||
|
||||
console.log('Available resources:');
|
||||
if (resourcesResult.resources.length === 0) {
|
||||
console.log(' No resources available');
|
||||
} else {
|
||||
for (const resource of resourcesResult.resources) {
|
||||
console.log(` - ${resource.name}: ${resource.uri}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Resources not supported by this server (${error})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (client && transport) {
|
||||
try {
|
||||
// First try to terminate the session gracefully
|
||||
if (transport.sessionId) {
|
||||
try {
|
||||
console.log('Terminating session before exit...');
|
||||
await transport.terminateSession();
|
||||
console.log('Session terminated successfully');
|
||||
} catch (error) {
|
||||
console.error('Error terminating session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Then close the transport
|
||||
await transport.close();
|
||||
} catch (error) {
|
||||
console.error('Error closing transport:', error);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.setRawMode(false);
|
||||
readline.close();
|
||||
console.log('\nGoodbye!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Set up raw mode for keyboard input to capture Escape key
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.on('data', async (data) => {
|
||||
// Check for Escape key (27)
|
||||
if (data.length === 1 && data[0] === 27) {
|
||||
console.log('\nESC key pressed. Disconnecting from server...');
|
||||
|
||||
// Abort current operation and disconnect from server
|
||||
if (client && transport) {
|
||||
await disconnect();
|
||||
console.log('Disconnected. Press Enter to continue.');
|
||||
} else {
|
||||
console.log('Not connected to server.');
|
||||
}
|
||||
|
||||
// Re-display the prompt
|
||||
process.stdout.write('> ');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Ctrl+C
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\nReceived SIGINT. Cleaning up...');
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
// Start the interactive client
|
||||
main().catch((error: unknown) => {
|
||||
console.error('Error running MCP client:', error);
|
||||
process.exit(1);
|
||||
|
|
|
|||
192
src/examples/client/streamableHttpWithSseFallbackClient.ts
Normal file
192
src/examples/client/streamableHttpWithSseFallbackClient.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { Client } from '../../client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
|
||||
import { SSEClientTransport } from '../../client/sse.js';
|
||||
import {
|
||||
ListToolsRequest,
|
||||
ListToolsResultSchema,
|
||||
CallToolRequest,
|
||||
CallToolResultSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
} from '../../types.js';
|
||||
|
||||
/**
|
||||
* Simplified Backwards Compatible MCP Client
|
||||
*
|
||||
* This client demonstrates backward compatibility with both:
|
||||
* 1. Modern servers using Streamable HTTP transport (protocol version 2025-03-26)
|
||||
* 2. Older servers using HTTP+SSE transport (protocol version 2024-11-05)
|
||||
*
|
||||
* Following the MCP specification for backwards compatibility:
|
||||
* - Attempts to POST an initialize request to the server URL first (modern transport)
|
||||
* - If that fails with 4xx status, falls back to GET request for SSE stream (older transport)
|
||||
*/
|
||||
|
||||
// Command line args processing
|
||||
const args = process.argv.slice(2);
|
||||
const serverUrl = args[0] || 'http://localhost:3000/mcp';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('MCP Backwards Compatible Client');
|
||||
console.log('===============================');
|
||||
console.log(`Connecting to server at: ${serverUrl}`);
|
||||
|
||||
let client: Client;
|
||||
let transport: StreamableHTTPClientTransport | SSEClientTransport;
|
||||
|
||||
try {
|
||||
// Try connecting with automatic transport detection
|
||||
const connection = await connectWithBackwardsCompatibility(serverUrl);
|
||||
client = connection.client;
|
||||
transport = connection.transport;
|
||||
|
||||
// Set up notification handler
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
|
||||
console.log(`Notification: ${notification.params.level} - ${notification.params.data}`);
|
||||
});
|
||||
|
||||
// DEMO WORKFLOW:
|
||||
// 1. List available tools
|
||||
console.log('\n=== Listing Available Tools ===');
|
||||
await listTools(client);
|
||||
|
||||
// 2. Call the notification tool
|
||||
console.log('\n=== Starting Notification Stream ===');
|
||||
await startNotificationTool(client);
|
||||
|
||||
// 3. Wait for all notifications (5 seconds)
|
||||
console.log('\n=== Waiting for all notifications ===');
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// 4. Disconnect
|
||||
console.log('\n=== Disconnecting ===');
|
||||
await transport.close();
|
||||
console.log('Disconnected from MCP server');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error running client:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to an MCP server with backwards compatibility
|
||||
* Following the spec for client backward compatibility
|
||||
*/
|
||||
async function connectWithBackwardsCompatibility(url: string): Promise<{
|
||||
client: Client,
|
||||
transport: StreamableHTTPClientTransport | SSEClientTransport,
|
||||
transportType: 'streamable-http' | 'sse'
|
||||
}> {
|
||||
console.log('1. Trying Streamable HTTP transport first...');
|
||||
|
||||
// Step 1: Try Streamable HTTP transport first
|
||||
const client = new Client({
|
||||
name: 'backwards-compatible-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
client.onerror = (error) => {
|
||||
console.error('Client error:', error);
|
||||
};
|
||||
const baseUrl = new URL(url);
|
||||
|
||||
try {
|
||||
// Create modern transport
|
||||
const streamableTransport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(streamableTransport);
|
||||
|
||||
console.log('Successfully connected using modern Streamable HTTP transport.');
|
||||
return {
|
||||
client,
|
||||
transport: streamableTransport,
|
||||
transportType: 'streamable-http'
|
||||
};
|
||||
} catch (error) {
|
||||
// Step 2: If transport fails, try the older SSE transport
|
||||
console.log(`StreamableHttp transport connection failed: ${error}`);
|
||||
console.log('2. Falling back to deprecated HTTP+SSE transport...');
|
||||
|
||||
try {
|
||||
// Create SSE transport pointing to /sse endpoint
|
||||
const sseTransport = new SSEClientTransport(baseUrl);
|
||||
const sseClient = new Client({
|
||||
name: 'backwards-compatible-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
await sseClient.connect(sseTransport);
|
||||
|
||||
console.log('Successfully connected using deprecated HTTP+SSE transport.');
|
||||
return {
|
||||
client: sseClient,
|
||||
transport: sseTransport,
|
||||
transportType: 'sse'
|
||||
};
|
||||
} catch (sseError) {
|
||||
console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`);
|
||||
throw new Error('Could not connect to server with any available transport');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available tools on the server
|
||||
*/
|
||||
async function listTools(client: Client): Promise<void> {
|
||||
try {
|
||||
const toolsRequest: ListToolsRequest = {
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
const toolsResult = await client.request(toolsRequest, ListToolsResultSchema);
|
||||
|
||||
console.log('Available tools:');
|
||||
if (toolsResult.tools.length === 0) {
|
||||
console.log(' No tools available');
|
||||
} else {
|
||||
for (const tool of toolsResult.tools) {
|
||||
console.log(` - ${tool.name}: ${tool.description}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Tools not supported by this server: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a notification stream by calling the notification tool
|
||||
*/
|
||||
async function startNotificationTool(client: Client): Promise<void> {
|
||||
try {
|
||||
// Call the notification tool using reasonable defaults
|
||||
const request: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'start-notification-stream',
|
||||
arguments: {
|
||||
interval: 1000, // 1 second between notifications
|
||||
count: 5 // Send 5 notifications
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Calling notification tool...');
|
||||
const result = await client.request(request, CallToolResultSchema);
|
||||
|
||||
console.log('Tool result:');
|
||||
result.content.forEach(item => {
|
||||
if (item.type === 'text') {
|
||||
console.log(` ${item.text}`);
|
||||
} else {
|
||||
console.log(` ${item.type} content:`, item);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`Error calling notification tool: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the client
|
||||
main().catch((error: unknown) => {
|
||||
console.error('Error running MCP client:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { z } from 'zod';
|
||||
import { CallToolResult } from '../../types.js';
|
||||
import { CallToolResult, isInitializeRequest } from '../../types.js';
|
||||
|
||||
// Create an MCP server with implementation details
|
||||
const server = new McpServer({
|
||||
|
|
@ -95,18 +95,17 @@ app.post('/mcp', async (req: Request, res: Response) => {
|
|||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
enableJsonResponse: true, // Enable JSON response mode
|
||||
onsessioninitialized: (sessionId) => {
|
||||
// Store the transport by session ID when session is initialized
|
||||
// This avoids race conditions where requests might come in before the session is stored
|
||||
console.log(`Session initialized with ID: ${sessionId}`);
|
||||
transports[sessionId] = transport;
|
||||
}
|
||||
});
|
||||
|
||||
// Connect the transport to the MCP server BEFORE handling the request
|
||||
await server.connect(transport);
|
||||
|
||||
// After handling the request, if we get a session ID back, store the transport
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
|
||||
// Store the transport by session ID for future requests
|
||||
if (transport.sessionId) {
|
||||
transports[transport.sessionId] = transport;
|
||||
}
|
||||
return; // Already handled
|
||||
} else {
|
||||
// Invalid request - no session ID or not initialization request
|
||||
|
|
@ -145,14 +144,6 @@ app.get('/mcp', async (req: Request, res: Response) => {
|
|||
res.status(405).set('Allow', 'POST').send('Method Not Allowed');
|
||||
});
|
||||
|
||||
// Helper function to detect initialize requests
|
||||
function isInitializeRequest(body: unknown): boolean {
|
||||
if (Array.isArray(body)) {
|
||||
return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize');
|
||||
}
|
||||
return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize';
|
||||
}
|
||||
|
||||
// Start the server
|
||||
const PORT = 3000;
|
||||
app.listen(PORT, () => {
|
||||
|
|
|
|||
169
src/examples/server/simpleSseServer.ts
Normal file
169
src/examples/server/simpleSseServer.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import express, { Request, Response } from 'express';
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { SSEServerTransport } from '../../server/sse.js';
|
||||
import { z } from 'zod';
|
||||
import { CallToolResult } from '../../types.js';
|
||||
|
||||
/**
|
||||
* This example server demonstrates the deprecated HTTP+SSE transport
|
||||
* (protocol version 2024-11-05). It mainly used for testing backward compatible clients.
|
||||
*
|
||||
* The server exposes two endpoints:
|
||||
* - /sse: For establishing the SSE stream (GET)
|
||||
* - /messages: For receiving client messages (POST)
|
||||
*
|
||||
*/
|
||||
|
||||
// Create an MCP server instance
|
||||
const server = new McpServer({
|
||||
name: 'simple-sse-server',
|
||||
version: '1.0.0',
|
||||
}, { capabilities: { logging: {} } });
|
||||
|
||||
server.tool(
|
||||
'start-notification-stream',
|
||||
'Starts sending periodic notifications',
|
||||
{
|
||||
interval: z.number().describe('Interval in milliseconds between notifications').default(1000),
|
||||
count: z.number().describe('Number of notifications to send').default(10),
|
||||
},
|
||||
async ({ interval, count }, { sendNotification }): Promise<CallToolResult> => {
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let counter = 0;
|
||||
|
||||
// Send the initial notification
|
||||
await sendNotification({
|
||||
method: "notifications/message",
|
||||
params: {
|
||||
level: "info",
|
||||
data: `Starting notification stream with ${count} messages every ${interval}ms`
|
||||
}
|
||||
});
|
||||
|
||||
// Send periodic notifications
|
||||
while (counter < count) {
|
||||
counter++;
|
||||
await sleep(interval);
|
||||
|
||||
try {
|
||||
await sendNotification({
|
||||
method: "notifications/message",
|
||||
params: {
|
||||
level: "info",
|
||||
data: `Notification #${counter} at ${new Date().toISOString()}`
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error sending notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Completed sending ${count} notifications every ${interval}ms`,
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Store transports by session ID
|
||||
const transports: Record<string, SSEServerTransport> = {};
|
||||
|
||||
// SSE endpoint for establishing the stream
|
||||
app.get('/mcp', async (req: Request, res: Response) => {
|
||||
console.log('Received GET request to /sse (establishing SSE stream)');
|
||||
|
||||
try {
|
||||
// Create a new SSE transport for the client
|
||||
// The endpoint for POST messages is '/messages'
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
|
||||
// Store the transport by session ID
|
||||
const sessionId = transport.sessionId;
|
||||
transports[sessionId] = transport;
|
||||
|
||||
// Set up onclose handler to clean up transport when closed
|
||||
transport.onclose = () => {
|
||||
console.log(`SSE transport closed for session ${sessionId}`);
|
||||
delete transports[sessionId];
|
||||
};
|
||||
|
||||
// Connect the transport to the MCP server
|
||||
await server.connect(transport);
|
||||
|
||||
// Start the SSE transport to begin streaming
|
||||
// This sends an initial 'endpoint' event with the session ID in the URL
|
||||
await transport.start();
|
||||
|
||||
console.log(`Established SSE stream with session ID: ${sessionId}`);
|
||||
} catch (error) {
|
||||
console.error('Error establishing SSE stream:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error establishing SSE stream');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Messages endpoint for receiving client JSON-RPC requests
|
||||
app.post('/messages', async (req: Request, res: Response) => {
|
||||
console.log('Received POST request to /messages');
|
||||
|
||||
// Extract session ID from URL query parameter
|
||||
// In the SSE protocol, this is added by the client based on the endpoint event
|
||||
const sessionId = req.query.sessionId as string | undefined;
|
||||
|
||||
if (!sessionId) {
|
||||
console.error('No session ID provided in request URL');
|
||||
res.status(400).send('Missing sessionId parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = transports[sessionId];
|
||||
if (!transport) {
|
||||
console.error(`No active transport found for session ID: ${sessionId}`);
|
||||
res.status(404).send('Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle the POST message with the transport
|
||||
await transport.handlePostMessage(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error('Error handling request:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error handling request');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const PORT = 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Handle server shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down server...');
|
||||
|
||||
// Close all active transports to properly clean up resources
|
||||
for (const sessionId in transports) {
|
||||
try {
|
||||
console.log(`Closing transport for session ${sessionId}`);
|
||||
await transports[sessionId].close();
|
||||
delete transports[sessionId];
|
||||
} catch (error) {
|
||||
console.error(`Error closing transport for session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
await server.close();
|
||||
console.log('Server shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
@ -1,9 +1,84 @@
|
|||
import express, { Request, Response } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { EventStore, StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { z } from 'zod';
|
||||
import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js';
|
||||
import { CallToolResult, GetPromptResult, isInitializeRequest, JSONRPCMessage, ReadResourceResult } from '../../types.js';
|
||||
|
||||
// Create a simple in-memory EventStore for resumability
|
||||
class InMemoryEventStore implements EventStore {
|
||||
private events: Map<string, { streamId: string, message: JSONRPCMessage }> = new Map();
|
||||
|
||||
/**
|
||||
* Generates a unique event ID for a given stream ID
|
||||
*/
|
||||
private generateEventId(streamId: string): string {
|
||||
return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
||||
}
|
||||
|
||||
private getStreamIdFromEventId(eventId: string): string {
|
||||
const parts = eventId.split('_');
|
||||
return parts.length > 0 ? parts[0] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an event with a generated event ID
|
||||
* Implements EventStore.storeEvent
|
||||
*/
|
||||
async storeEvent(streamId: string, message: JSONRPCMessage): Promise<string> {
|
||||
const eventId = this.generateEventId(streamId);
|
||||
console.log(`Storing event ${eventId} for stream ${streamId}`);
|
||||
this.events.set(eventId, { streamId, message });
|
||||
return eventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays events that occurred after a specific event ID
|
||||
* Implements EventStore.replayEventsAfter
|
||||
*/
|
||||
async replayEventsAfter(lastEventId: string,
|
||||
{ send }: { send: (eventId: string, message: JSONRPCMessage) => Promise<void> }
|
||||
): Promise<string> {
|
||||
if (!lastEventId || !this.events.has(lastEventId)) {
|
||||
console.log(`No events found for lastEventId: ${lastEventId}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
// Extract the stream ID from the event ID
|
||||
const streamId = this.getStreamIdFromEventId(lastEventId);
|
||||
if (!streamId) {
|
||||
console.log(`Could not extract streamId from lastEventId: ${lastEventId}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
let foundLastEvent = false;
|
||||
let eventCount = 0;
|
||||
|
||||
// Sort events by eventId for chronological ordering
|
||||
const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
|
||||
for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) {
|
||||
// Only include events from the same stream
|
||||
if (eventStreamId !== streamId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start sending events after we find the lastEventId
|
||||
if (eventId === lastEventId) {
|
||||
foundLastEvent = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (foundLastEvent) {
|
||||
await send(eventId, message);
|
||||
eventCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Replayed ${eventCount} events after ${lastEventId} for stream ${streamId}`);
|
||||
return streamId;
|
||||
}
|
||||
}
|
||||
|
||||
// Create an MCP server with implementation details
|
||||
const server = new McpServer({
|
||||
|
|
@ -92,6 +167,47 @@ server.prompt(
|
|||
}
|
||||
);
|
||||
|
||||
// Register a tool specifically for testing resumability
|
||||
server.tool(
|
||||
'start-notification-stream',
|
||||
'Starts sending periodic notifications for testing resumability',
|
||||
{
|
||||
interval: z.number().describe('Interval in milliseconds between notifications').default(100),
|
||||
count: z.number().describe('Number of notifications to send (0 for 100)').default(50),
|
||||
},
|
||||
async ({ interval, count }, { sendNotification }): Promise<CallToolResult> => {
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let counter = 0;
|
||||
|
||||
while (count === 0 || counter < count) {
|
||||
counter++;
|
||||
try {
|
||||
await sendNotification({
|
||||
method: "notifications/message",
|
||||
params: {
|
||||
level: "info",
|
||||
data: `Periodic notification #${counter} at ${new Date().toISOString()}`
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error sending notification:", error);
|
||||
}
|
||||
// Wait for the specified interval
|
||||
await sleep(interval);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Started sending periodic notifications every ${interval}ms`,
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create a simple resource at a fixed URI
|
||||
server.resource(
|
||||
'greeting-resource',
|
||||
|
|
@ -127,21 +243,32 @@ app.post('/mcp', async (req: Request, res: Response) => {
|
|||
transport = transports[sessionId];
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
// New initialization request
|
||||
const eventStore = new InMemoryEventStore();
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore, // Enable resumability
|
||||
onsessioninitialized: (sessionId) => {
|
||||
// Store the transport by session ID when session is initialized
|
||||
// This avoids race conditions where requests might come in before the session is stored
|
||||
console.log(`Session initialized with ID: ${sessionId}`);
|
||||
transports[sessionId] = transport;
|
||||
}
|
||||
});
|
||||
|
||||
// Set up onclose handler to clean up transport when closed
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid && transports[sid]) {
|
||||
console.log(`Transport closed for session ${sid}, removing from transports map`);
|
||||
delete transports[sid];
|
||||
}
|
||||
};
|
||||
|
||||
// Connect the transport to the MCP server BEFORE handling the request
|
||||
// so responses can flow back through the same transport
|
||||
await server.connect(transport);
|
||||
|
||||
// After handling the request, if we get a session ID back, store the transport
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
|
||||
// Store the transport by session ID for future requests
|
||||
if (transport.sessionId) {
|
||||
transports[transport.sessionId] = transport;
|
||||
}
|
||||
return; // Already handled
|
||||
} else {
|
||||
// Invalid request - no session ID or not initialization request
|
||||
|
|
@ -182,18 +309,38 @@ app.get('/mcp', async (req: Request, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
console.log(`Establishing SSE stream for session ${sessionId}`);
|
||||
// Check for Last-Event-ID header for resumability
|
||||
const lastEventId = req.headers['last-event-id'] as string | undefined;
|
||||
if (lastEventId) {
|
||||
console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
|
||||
} else {
|
||||
console.log(`Establishing new SSE stream for session ${sessionId}`);
|
||||
}
|
||||
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Helper function to detect initialize requests
|
||||
function isInitializeRequest(body: unknown): boolean {
|
||||
if (Array.isArray(body)) {
|
||||
return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize');
|
||||
// Handle DELETE requests for session termination (according to MCP spec)
|
||||
app.delete('/mcp', async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize';
|
||||
}
|
||||
|
||||
console.log(`Received session termination request for session ${sessionId}`);
|
||||
|
||||
try {
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
} catch (error) {
|
||||
console.error('Error handling session termination:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error processing session termination');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const PORT = 3000;
|
||||
|
|
@ -226,6 +373,18 @@ app.listen(PORT, () => {
|
|||
// Handle server shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down server...');
|
||||
|
||||
// Close all active transports to properly clean up resources
|
||||
for (const sessionId in transports) {
|
||||
try {
|
||||
console.log(`Closing transport for session ${sessionId}`);
|
||||
await transports[sessionId].close();
|
||||
delete transports[sessionId];
|
||||
} catch (error) {
|
||||
console.error(`Error closing transport for session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
await server.close();
|
||||
console.log('Server shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
||||
316
src/examples/server/sseAndStreamableHttpCompatibleServer.ts
Normal file
316
src/examples/server/sseAndStreamableHttpCompatibleServer.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import express, { Request, Response } from 'express';
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { EventStore, StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { SSEServerTransport } from '../../server/sse.js';
|
||||
import { z } from 'zod';
|
||||
import { CallToolResult, isInitializeRequest, JSONRPCMessage } from '../../types.js';
|
||||
|
||||
/**
|
||||
* This example server demonstrates backwards compatibility with both:
|
||||
* 1. The deprecated HTTP+SSE transport (protocol version 2024-11-05)
|
||||
* 2. The Streamable HTTP transport (protocol version 2025-03-26)
|
||||
*
|
||||
* It maintains a single MCP server instance but exposes two transport options:
|
||||
* - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE)
|
||||
* - /sse: The deprecated SSE endpoint for older clients (GET to establish stream)
|
||||
* - /messages: The deprecated POST endpoint for older clients (POST to send messages)
|
||||
*/
|
||||
|
||||
// Simple in-memory event store for resumability
|
||||
class InMemoryEventStore implements EventStore {
|
||||
private events: Map<string, { streamId: string, message: JSONRPCMessage }> = new Map();
|
||||
|
||||
/**
|
||||
* Generates a unique event ID for a given stream ID
|
||||
*/
|
||||
private generateEventId(streamId: string): string {
|
||||
return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
||||
}
|
||||
|
||||
private getStreamIdFromEventId(eventId: string): string {
|
||||
const parts = eventId.split('_');
|
||||
return parts.length > 0 ? parts[0] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an event with a generated event ID
|
||||
* Implements EventStore.storeEvent
|
||||
*/
|
||||
async storeEvent(streamId: string, message: JSONRPCMessage): Promise<string> {
|
||||
const eventId = this.generateEventId(streamId);
|
||||
console.log(`Storing event ${eventId} for stream ${streamId}`);
|
||||
this.events.set(eventId, { streamId, message });
|
||||
return eventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays events that occurred after a specific event ID
|
||||
* Implements EventStore.replayEventsAfter
|
||||
*/
|
||||
async replayEventsAfter(lastEventId: string,
|
||||
{ send }: { send: (eventId: string, message: JSONRPCMessage) => Promise<void> }
|
||||
): Promise<string> {
|
||||
if (!lastEventId || !this.events.has(lastEventId)) {
|
||||
console.log(`No events found for lastEventId: ${lastEventId}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const streamId = this.getStreamIdFromEventId(lastEventId);
|
||||
if (!streamId) {
|
||||
console.log(`Could not extract streamId from lastEventId: ${lastEventId}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
let foundLastEvent = false;
|
||||
let eventCount = 0;
|
||||
|
||||
// Sort events by eventId for chronological ordering
|
||||
const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
|
||||
for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) {
|
||||
// Only include events from the same stream
|
||||
if (eventStreamId !== streamId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start sending events after we find the lastEventId
|
||||
if (eventId === lastEventId) {
|
||||
foundLastEvent = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (foundLastEvent) {
|
||||
await send(eventId, message);
|
||||
eventCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Replayed ${eventCount} events after ${lastEventId} for stream ${streamId}`);
|
||||
return streamId;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a shared MCP server instance
|
||||
const server = new McpServer({
|
||||
name: 'backwards-compatible-server',
|
||||
version: '1.0.0',
|
||||
}, { capabilities: { logging: {} } });
|
||||
|
||||
// Register a simple tool that sends notifications over time
|
||||
server.tool(
|
||||
'start-notification-stream',
|
||||
'Starts sending periodic notifications for testing resumability',
|
||||
{
|
||||
interval: z.number().describe('Interval in milliseconds between notifications').default(100),
|
||||
count: z.number().describe('Number of notifications to send (0 for 100)').default(50),
|
||||
},
|
||||
async ({ interval, count }, { sendNotification }): Promise<CallToolResult> => {
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let counter = 0;
|
||||
|
||||
while (count === 0 || counter < count) {
|
||||
counter++;
|
||||
try {
|
||||
await sendNotification({
|
||||
method: "notifications/message",
|
||||
params: {
|
||||
level: "info",
|
||||
data: `Periodic notification #${counter} at ${new Date().toISOString()}`
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error sending notification:", error);
|
||||
}
|
||||
// Wait for the specified interval
|
||||
await sleep(interval);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Started sending periodic notifications every ${interval}ms`,
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create Express application
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Store transports by session ID
|
||||
const transports: Record<string, StreamableHTTPServerTransport | SSEServerTransport> = {};
|
||||
|
||||
//=============================================================================
|
||||
// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26)
|
||||
//=============================================================================
|
||||
|
||||
// Handle all MCP Streamable HTTP requests (GET, POST, DELETE) on a single endpoint
|
||||
app.all('/mcp', async (req: Request, res: Response) => {
|
||||
console.log(`Received ${req.method} request to /mcp`);
|
||||
|
||||
try {
|
||||
// Check for existing session ID
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
|
||||
if (sessionId && transports[sessionId]) {
|
||||
// Check if the transport is of the correct type
|
||||
const existingTransport = transports[sessionId];
|
||||
if (existingTransport instanceof StreamableHTTPServerTransport) {
|
||||
// Reuse existing transport
|
||||
transport = existingTransport;
|
||||
} else {
|
||||
// Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport)
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: Session exists but uses a different transport protocol',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) {
|
||||
const eventStore = new InMemoryEventStore();
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore, // Enable resumability
|
||||
onsessioninitialized: (sessionId) => {
|
||||
// Store the transport by session ID when session is initialized
|
||||
console.log(`StreamableHTTP session initialized with ID: ${sessionId}`);
|
||||
transports[sessionId] = transport;
|
||||
}
|
||||
});
|
||||
|
||||
// Set up onclose handler to clean up transport when closed
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid && transports[sid]) {
|
||||
console.log(`Transport closed for session ${sid}, removing from transports map`);
|
||||
delete transports[sid];
|
||||
}
|
||||
};
|
||||
|
||||
// Connect the transport to the MCP server
|
||||
await server.connect(transport);
|
||||
} else {
|
||||
// Invalid request - no session ID or not initialization request
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the request with the transport
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error('Error handling MCP request:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal server error',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//=============================================================================
|
||||
// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05)
|
||||
//=============================================================================
|
||||
|
||||
app.get('/sse', async (req: Request, res: Response) => {
|
||||
console.log('Received GET request to /sse (deprecated SSE transport)');
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
transports[transport.sessionId] = transport;
|
||||
res.on("close", () => {
|
||||
delete transports[transport.sessionId];
|
||||
});
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
app.post("/messages", async (req: Request, res: Response) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
let transport: SSEServerTransport;
|
||||
const existingTransport = transports[sessionId];
|
||||
if (existingTransport instanceof SSEServerTransport) {
|
||||
// Reuse existing transport
|
||||
transport = existingTransport;
|
||||
} else {
|
||||
// Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport)
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: Session exists but uses a different transport protocol',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
} else {
|
||||
res.status(400).send('No transport found for sessionId');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Start the server
|
||||
const PORT = 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Backwards compatible MCP server listening on port ${PORT}`);
|
||||
console.log(`
|
||||
==============================================
|
||||
SUPPORTED TRANSPORT OPTIONS:
|
||||
|
||||
1. Streamable Http(Protocol version: 2025-03-26)
|
||||
Endpoint: /mcp
|
||||
Methods: GET, POST, DELETE
|
||||
Usage:
|
||||
- Initialize with POST to /mcp
|
||||
- Establish SSE stream with GET to /mcp
|
||||
- Send requests with POST to /mcp
|
||||
- Terminate session with DELETE to /mcp
|
||||
|
||||
2. Http + SSE (Protocol version: 2024-11-05)
|
||||
Endpoints: /sse (GET) and /messages (POST)
|
||||
Usage:
|
||||
- Establish SSE stream with GET to /sse
|
||||
- Send requests with POST to /messages?sessionId=<id>
|
||||
==============================================
|
||||
`);
|
||||
});
|
||||
|
||||
// Handle server shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down server...');
|
||||
|
||||
// Close all active transports to properly clean up resources
|
||||
for (const sessionId in transports) {
|
||||
try {
|
||||
console.log(`Closing transport for session ${sessionId}`);
|
||||
await transports[sessionId].close();
|
||||
delete transports[sessionId];
|
||||
} catch (error) {
|
||||
console.error(`Error closing transport for session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
await server.close();
|
||||
console.log('Server shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@ import express, { Request, Response } from 'express';
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { ReadResourceResult } from '../../types.js';
|
||||
import { isInitializeRequest, ReadResourceResult } from '../../types.js';
|
||||
|
||||
// Create an MCP server with implementation details
|
||||
const server = new McpServer({
|
||||
|
|
@ -52,17 +52,19 @@ app.post('/mcp', async (req: Request, res: Response) => {
|
|||
// New initialization request
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sessionId) => {
|
||||
// Store the transport by session ID when session is initialized
|
||||
// This avoids race conditions where requests might come in before the session is stored
|
||||
console.log(`Session initialized with ID: ${sessionId}`);
|
||||
transports[sessionId] = transport;
|
||||
}
|
||||
});
|
||||
|
||||
// Connect the transport to the MCP server
|
||||
await server.connect(transport);
|
||||
|
||||
// Handle the request - the onsessioninitialized callback will store the transport
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
|
||||
// Store the transport by session ID for future requests
|
||||
if (transport.sessionId) {
|
||||
transports[transport.sessionId] = transport;
|
||||
}
|
||||
return; // Already handled
|
||||
} else {
|
||||
// Invalid request - no session ID or not initialization request
|
||||
|
|
@ -107,13 +109,6 @@ app.get('/mcp', async (req: Request, res: Response) => {
|
|||
await transport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Helper function to detect initialize requests
|
||||
function isInitializeRequest(body: unknown): boolean {
|
||||
if (Array.isArray(body)) {
|
||||
return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize');
|
||||
}
|
||||
return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize';
|
||||
}
|
||||
|
||||
// Start the server
|
||||
const PORT = 3000;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { InMemoryTransport } from "./inMemory.js";
|
||||
import { JSONRPCMessage } from "./types.js";
|
||||
import { AuthInfo } from "./server/auth/types.js";
|
||||
|
||||
describe("InMemoryTransport", () => {
|
||||
let clientTransport: InMemoryTransport;
|
||||
|
|
@ -35,6 +36,32 @@ describe("InMemoryTransport", () => {
|
|||
expect(receivedMessage).toEqual(message);
|
||||
});
|
||||
|
||||
test("should send message with auth info from client to server", async () => {
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
method: "test",
|
||||
id: 1,
|
||||
};
|
||||
|
||||
const authInfo: AuthInfo = {
|
||||
token: "test-token",
|
||||
clientId: "test-client",
|
||||
scopes: ["read", "write"],
|
||||
expiresAt: Date.now() / 1000 + 3600,
|
||||
};
|
||||
|
||||
let receivedMessage: JSONRPCMessage | undefined;
|
||||
let receivedAuthInfo: AuthInfo | undefined;
|
||||
serverTransport.onmessage = (msg, extra) => {
|
||||
receivedMessage = msg;
|
||||
receivedAuthInfo = extra?.authInfo;
|
||||
};
|
||||
|
||||
await clientTransport.send(message, { authInfo });
|
||||
expect(receivedMessage).toEqual(message);
|
||||
expect(receivedAuthInfo).toEqual(authInfo);
|
||||
});
|
||||
|
||||
test("should send message from server to client", async () => {
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import { Transport } from "./shared/transport.js";
|
||||
import { JSONRPCMessage } from "./types.js";
|
||||
import { JSONRPCMessage, RequestId } from "./types.js";
|
||||
import { AuthInfo } from "./server/auth/types.js";
|
||||
|
||||
interface QueuedMessage {
|
||||
message: JSONRPCMessage;
|
||||
extra?: { authInfo?: AuthInfo };
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory transport for creating clients and servers that talk to each other within the same process.
|
||||
*/
|
||||
export class InMemoryTransport implements Transport {
|
||||
private _otherTransport?: InMemoryTransport;
|
||||
private _messageQueue: JSONRPCMessage[] = [];
|
||||
private _messageQueue: QueuedMessage[] = [];
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
|
||||
sessionId?: string;
|
||||
|
||||
/**
|
||||
|
|
@ -27,10 +33,8 @@ export class InMemoryTransport implements Transport {
|
|||
async start(): Promise<void> {
|
||||
// Process any messages that were queued before start was called
|
||||
while (this._messageQueue.length > 0) {
|
||||
const message = this._messageQueue.shift();
|
||||
if (message) {
|
||||
this.onmessage?.(message);
|
||||
}
|
||||
const queuedMessage = this._messageQueue.shift()!;
|
||||
this.onmessage?.(queuedMessage.message, queuedMessage.extra);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,15 +45,19 @@ export class InMemoryTransport implements Transport {
|
|||
this.onclose?.();
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
/**
|
||||
* Sends a message with optional auth info.
|
||||
* This is useful for testing authentication scenarios.
|
||||
*/
|
||||
async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo }): Promise<void> {
|
||||
if (!this._otherTransport) {
|
||||
throw new Error("Not connected");
|
||||
}
|
||||
|
||||
if (this._otherTransport.onmessage) {
|
||||
this._otherTransport.onmessage(message);
|
||||
this._otherTransport.onmessage(message, { authInfo: options?.authInfo });
|
||||
} else {
|
||||
this._otherTransport._messageQueue.push(message);
|
||||
this._otherTransport._messageQueue.push({ message, extra: { authInfo: options?.authInfo } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
265
src/integration-tests/stateManagementStreamableHttp.test.ts
Normal file
265
src/integration-tests/stateManagementStreamableHttp.test.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { createServer, type Server } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Client } from '../client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../client/streamableHttp.js';
|
||||
import { McpServer } from '../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../server/streamableHttp.js';
|
||||
import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema } from '../types.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
describe('Streamable HTTP Transport Session Management', () => {
|
||||
// Function to set up the server with optional session management
|
||||
async function setupServer(withSessionManagement: boolean) {
|
||||
const server: Server = createServer();
|
||||
const mcpServer = new McpServer(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
logging: {},
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add a simple resource
|
||||
mcpServer.resource(
|
||||
'test-resource',
|
||||
'/test',
|
||||
{ description: 'A test resource' },
|
||||
async () => ({
|
||||
contents: [{
|
||||
uri: '/test',
|
||||
text: 'This is a test resource content'
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
mcpServer.prompt(
|
||||
'test-prompt',
|
||||
'A test prompt',
|
||||
async () => ({
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'This is a test prompt'
|
||||
}
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
mcpServer.tool(
|
||||
'greet',
|
||||
'A simple greeting tool',
|
||||
{
|
||||
name: z.string().describe('Name to greet').default('World'),
|
||||
},
|
||||
async ({ name }) => {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create transport with or without session management
|
||||
const serverTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: withSessionManagement
|
||||
? () => randomUUID() // With session management, generate UUID
|
||||
: () => undefined // Without session management, return undefined
|
||||
});
|
||||
|
||||
await mcpServer.connect(serverTransport);
|
||||
|
||||
server.on('request', async (req, res) => {
|
||||
await serverTransport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Start the server on a random port
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
|
||||
return { server, mcpServer, serverTransport, baseUrl };
|
||||
}
|
||||
|
||||
describe('Stateless Mode', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupServer(false);
|
||||
server = setup.server;
|
||||
mcpServer = setup.mcpServer;
|
||||
serverTransport = setup.serverTransport;
|
||||
baseUrl = setup.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => { });
|
||||
await serverTransport.close().catch(() => { });
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should operate without session management', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify that no session ID was set
|
||||
expect(transport.sessionId).toBeUndefined();
|
||||
|
||||
// List available tools
|
||||
const toolsResult = await client.request({
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
}, ListToolsResultSchema);
|
||||
|
||||
// Verify tools are accessible
|
||||
expect(toolsResult.tools).toContainEqual(expect.objectContaining({
|
||||
name: 'greet'
|
||||
}));
|
||||
|
||||
// List available resources
|
||||
const resourcesResult = await client.request({
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
}, ListResourcesResultSchema);
|
||||
|
||||
// Verify resources result structure
|
||||
expect(resourcesResult).toHaveProperty('resources');
|
||||
|
||||
// List available prompts
|
||||
const promptsResult = await client.request({
|
||||
method: 'prompts/list',
|
||||
params: {}
|
||||
}, ListPromptsResultSchema);
|
||||
|
||||
// Verify prompts result structure
|
||||
expect(promptsResult).toHaveProperty('prompts');
|
||||
expect(promptsResult.prompts).toContainEqual(expect.objectContaining({
|
||||
name: 'test-prompt'
|
||||
}));
|
||||
|
||||
// Call the greeting tool
|
||||
const greetingResult = await client.request({
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'greet',
|
||||
arguments: {
|
||||
name: 'Stateless Transport'
|
||||
}
|
||||
}
|
||||
}, CallToolResultSchema);
|
||||
|
||||
// Verify tool result
|
||||
expect(greetingResult.content).toEqual([
|
||||
{ type: 'text', text: 'Hello, Stateless Transport!' }
|
||||
]);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stateful Mode', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupServer(true);
|
||||
server = setup.server;
|
||||
mcpServer = setup.mcpServer;
|
||||
serverTransport = setup.serverTransport;
|
||||
baseUrl = setup.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => { });
|
||||
await serverTransport.close().catch(() => { });
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should operate with session management', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify that a session ID was set
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
expect(typeof transport.sessionId).toBe('string');
|
||||
|
||||
// List available tools
|
||||
const toolsResult = await client.request({
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
}, ListToolsResultSchema);
|
||||
|
||||
// Verify tools are accessible
|
||||
expect(toolsResult.tools).toContainEqual(expect.objectContaining({
|
||||
name: 'greet'
|
||||
}));
|
||||
|
||||
// List available resources
|
||||
const resourcesResult = await client.request({
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
}, ListResourcesResultSchema);
|
||||
|
||||
// Verify resources result structure
|
||||
expect(resourcesResult).toHaveProperty('resources');
|
||||
|
||||
// List available prompts
|
||||
const promptsResult = await client.request({
|
||||
method: 'prompts/list',
|
||||
params: {}
|
||||
}, ListPromptsResultSchema);
|
||||
|
||||
// Verify prompts result structure
|
||||
expect(promptsResult).toHaveProperty('prompts');
|
||||
expect(promptsResult.prompts).toContainEqual(expect.objectContaining({
|
||||
name: 'test-prompt'
|
||||
}));
|
||||
|
||||
// Call the greeting tool
|
||||
const greetingResult = await client.request({
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'greet',
|
||||
arguments: {
|
||||
name: 'Stateful Transport'
|
||||
}
|
||||
}
|
||||
}, CallToolResultSchema);
|
||||
|
||||
// Verify tool result
|
||||
expect(greetingResult.content).toEqual([
|
||||
{ type: 'text', text: 'Hello, Stateful Transport!' }
|
||||
]);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
329
src/integration-tests/taskResumability.test.ts
Normal file
329
src/integration-tests/taskResumability.test.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import { createServer, type Server } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Client } from '../client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../client/streamableHttp.js';
|
||||
import { McpServer } from '../server/mcp.js';
|
||||
import { EventStore, StreamableHTTPServerTransport } from '../server/streamableHttp.js';
|
||||
import { CallToolResultSchema, JSONRPCMessage, LoggingMessageNotificationSchema } from '../types.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Simple in-memory event store implementation for resumability
|
||||
*/
|
||||
class InMemoryEventStore implements EventStore {
|
||||
private events: Map<string, { streamId: string, message: JSONRPCMessage }> = new Map();
|
||||
|
||||
private generateEventId(streamId: string): string {
|
||||
return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
||||
}
|
||||
|
||||
private getStreamIdFromEventId(eventId: string): string {
|
||||
const parts = eventId.split('_');
|
||||
return parts.length > 0 ? parts[0] : '';
|
||||
}
|
||||
|
||||
async storeEvent(streamId: string, message: JSONRPCMessage): Promise<string> {
|
||||
const eventId = this.generateEventId(streamId);
|
||||
this.events.set(eventId, { streamId, message });
|
||||
return eventId;
|
||||
}
|
||||
|
||||
async replayEventsAfter(lastEventId: string,
|
||||
{ send }: { send: (eventId: string, message: JSONRPCMessage) => Promise<void> }
|
||||
): Promise<string> {
|
||||
if (!lastEventId || !this.events.has(lastEventId)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Extract the stream ID from the event ID
|
||||
const streamId = this.getStreamIdFromEventId(lastEventId);
|
||||
if (!streamId) {
|
||||
return '';
|
||||
}
|
||||
let foundLastEvent = false;
|
||||
|
||||
// Sort events by eventId for chronological ordering
|
||||
const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
|
||||
for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) {
|
||||
// Only include events from the same stream
|
||||
if (eventStreamId !== streamId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start collecting events after we find the lastEventId
|
||||
if (eventId === lastEventId) {
|
||||
foundLastEvent = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (foundLastEvent) {
|
||||
await send(eventId, message);
|
||||
}
|
||||
}
|
||||
|
||||
return streamId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
describe('Transport resumability', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
let eventStore: InMemoryEventStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create event store for resumability
|
||||
eventStore = new InMemoryEventStore();
|
||||
|
||||
// Create a simple MCP server
|
||||
mcpServer = new McpServer(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{ capabilities: { logging: {} } }
|
||||
);
|
||||
|
||||
// Add a simple notification tool that completes quickly
|
||||
mcpServer.tool(
|
||||
'send-notification',
|
||||
'Sends a single notification',
|
||||
{
|
||||
message: z.string().describe('Message to send').default('Test notification')
|
||||
},
|
||||
async ({ message }, { sendNotification }) => {
|
||||
// Send notification immediately
|
||||
await sendNotification({
|
||||
method: "notifications/message",
|
||||
params: {
|
||||
level: "info",
|
||||
data: message
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Notification sent' }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Add a long-running tool that sends multiple notifications
|
||||
mcpServer.tool(
|
||||
'run-notifications',
|
||||
'Sends multiple notifications over time',
|
||||
{
|
||||
count: z.number().describe('Number of notifications to send').default(10),
|
||||
interval: z.number().describe('Interval between notifications in ms').default(50)
|
||||
},
|
||||
async ({ count, interval }, { sendNotification }) => {
|
||||
// Send notifications at specified intervals
|
||||
for (let i = 0; i < count; i++) {
|
||||
await sendNotification({
|
||||
method: "notifications/message",
|
||||
params: {
|
||||
level: "info",
|
||||
data: `Notification ${i + 1} of ${count}`
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the specified interval before sending next notification
|
||||
if (i < count - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Sent ${count} notifications` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create a transport with the event store
|
||||
serverTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore
|
||||
});
|
||||
|
||||
// Connect the transport to the MCP server
|
||||
await mcpServer.connect(serverTransport);
|
||||
|
||||
// Create and start an HTTP server
|
||||
server = createServer(async (req, res) => {
|
||||
await serverTransport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Start the server on a random port
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => { });
|
||||
await serverTransport.close().catch(() => { });
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should store session ID when client connects', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify session ID was generated
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should have session ID functionality', async () => {
|
||||
// The ability to store a session ID when connecting
|
||||
const client = new Client({
|
||||
name: 'test-client-reconnection',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
|
||||
// Make sure the client can connect and get a session ID
|
||||
await client.connect(transport);
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
// This test demonstrates the capability to resume long-running tools
|
||||
// across client disconnection/reconnection
|
||||
it('should resume long-running notifications with lastEventId', async () => {
|
||||
// Create unique client ID for this test
|
||||
const clientId = 'test-client-long-running';
|
||||
const notifications = [];
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
// Create first client
|
||||
const client1 = new Client({
|
||||
id: clientId,
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Set up notification handler for first client
|
||||
client1.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
|
||||
if (notification.method === 'notifications/message') {
|
||||
notifications.push(notification.params);
|
||||
}
|
||||
});
|
||||
|
||||
// Connect first client
|
||||
const transport1 = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client1.connect(transport1);
|
||||
const sessionId = transport1.sessionId;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// Start a long-running notification stream with tracking of lastEventId
|
||||
const onLastEventIdUpdate = jest.fn((eventId: string) => {
|
||||
lastEventId = eventId;
|
||||
});
|
||||
expect(lastEventId).toBeUndefined();
|
||||
// Start the notification tool with event tracking using request
|
||||
const toolPromise = client1.request({
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'run-notifications',
|
||||
arguments: {
|
||||
count: 3,
|
||||
interval: 10
|
||||
}
|
||||
}
|
||||
}, CallToolResultSchema, {
|
||||
resumptionToken: lastEventId,
|
||||
onresumptiontoken: onLastEventIdUpdate
|
||||
});
|
||||
|
||||
// Wait for some notifications to arrive (not all) - shorter wait time
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Verify we received some notifications and lastEventId was updated
|
||||
expect(notifications.length).toBeGreaterThan(0);
|
||||
expect(notifications.length).toBeLessThan(4);
|
||||
expect(onLastEventIdUpdate).toHaveBeenCalled();
|
||||
expect(lastEventId).toBeDefined();
|
||||
|
||||
|
||||
// Disconnect first client without waiting for completion
|
||||
// When we close the connection, it will cause a ConnectionClosed error for
|
||||
// any in-progress requests, which is expected behavior
|
||||
await transport1.close();
|
||||
// Save the promise so we can catch it after closing
|
||||
const catchPromise = toolPromise.catch(err => {
|
||||
// This error is expected - the connection was intentionally closed
|
||||
if (err?.code !== -32000) { // ConnectionClosed error code
|
||||
console.error("Unexpected error type during transport close:", err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Add a short delay to ensure clean disconnect before reconnecting
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Wait for the rejection to be handled
|
||||
await catchPromise;
|
||||
|
||||
|
||||
// Create second client with same client ID
|
||||
const client2 = new Client({
|
||||
id: clientId,
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Set up notification handler for second client
|
||||
client2.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
|
||||
if (notification.method === 'notifications/message') {
|
||||
notifications.push(notification.params);
|
||||
}
|
||||
});
|
||||
|
||||
// Connect second client with same session ID
|
||||
const transport2 = new StreamableHTTPClientTransport(baseUrl, {
|
||||
sessionId
|
||||
});
|
||||
await client2.connect(transport2);
|
||||
|
||||
// Resume the notification stream using lastEventId
|
||||
// This is the key part - we're resuming the same long-running tool using lastEventId
|
||||
await client2.request({
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'run-notifications',
|
||||
arguments: {
|
||||
count: 1,
|
||||
interval: 5
|
||||
}
|
||||
}
|
||||
}, CallToolResultSchema, {
|
||||
resumptionToken: lastEventId, // Pass the lastEventId from the previous session
|
||||
onresumptiontoken: onLastEventIdUpdate
|
||||
});
|
||||
|
||||
// Verify we eventually received at leaset a few motifications
|
||||
expect(notifications.length).toBeGreaterThan(1);
|
||||
|
||||
|
||||
// Clean up
|
||||
await transport2.close();
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -21,4 +21,10 @@ export interface AuthInfo {
|
|||
* When the token expires (in seconds since epoch).
|
||||
*/
|
||||
expiresAt?: number;
|
||||
|
||||
/**
|
||||
* Additional data associated with the token.
|
||||
* This field should be used for any additional data that needs to be attached to the auth info.
|
||||
*/
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { Transport } from "../shared/transport.js";
|
|||
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
|
||||
import getRawBody from "raw-body";
|
||||
import contentType from "content-type";
|
||||
import { AuthInfo } from "./auth/types.js";
|
||||
import { URL } from 'url';
|
||||
|
||||
const MAXIMUM_MESSAGE_SIZE = "4mb";
|
||||
|
|
@ -19,7 +20,7 @@ export class SSEServerTransport implements Transport {
|
|||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
|
||||
|
||||
/**
|
||||
* Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`.
|
||||
|
|
@ -76,7 +77,7 @@ export class SSEServerTransport implements Transport {
|
|||
* This should be called when a POST request is made to send a message to the server.
|
||||
*/
|
||||
async handlePostMessage(
|
||||
req: IncomingMessage,
|
||||
req: IncomingMessage & { auth?: AuthInfo },
|
||||
res: ServerResponse,
|
||||
parsedBody?: unknown,
|
||||
): Promise<void> {
|
||||
|
|
@ -85,6 +86,7 @@ export class SSEServerTransport implements Transport {
|
|||
res.writeHead(500).end(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
const authInfo: AuthInfo | undefined = req.auth;
|
||||
|
||||
let body: string | unknown;
|
||||
try {
|
||||
|
|
@ -104,7 +106,7 @@ export class SSEServerTransport implements Transport {
|
|||
}
|
||||
|
||||
try {
|
||||
await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body);
|
||||
await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { authInfo });
|
||||
} catch {
|
||||
res.writeHead(400).end(`Invalid message: ${body}`);
|
||||
return;
|
||||
|
|
@ -116,7 +118,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): Promise<void> {
|
||||
async handleMessage(message: unknown, extra?: { authInfo?: AuthInfo }): Promise<void> {
|
||||
let parsedMessage: JSONRPCMessage;
|
||||
try {
|
||||
parsedMessage = JSONRPCMessageSchema.parse(message);
|
||||
|
|
@ -125,7 +127,7 @@ export class SSEServerTransport implements Transport {
|
|||
throw error;
|
||||
}
|
||||
|
||||
this.onmessage?.(parsedMessage);
|
||||
this.onmessage?.(parsedMessage, extra);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +1,32 @@
|
|||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
import { JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js";
|
||||
import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js";
|
||||
import getRawBody from "raw-body";
|
||||
import contentType from "content-type";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const MAXIMUM_MESSAGE_SIZE = "4mb";
|
||||
|
||||
export type StreamId = string;
|
||||
export type EventId = string;
|
||||
|
||||
/**
|
||||
* Interface for resumability support via event storage
|
||||
*/
|
||||
export interface EventStore {
|
||||
/**
|
||||
* Stores an event for later retrieval
|
||||
* @param streamId ID of the stream the event belongs to
|
||||
* @param message The JSON-RPC message to store
|
||||
* @returns The generated event ID for the stored event
|
||||
*/
|
||||
storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId>;
|
||||
|
||||
replayEventsAfter(lastEventId: EventId, { send }: {
|
||||
send: (eventId: EventId, message: JSONRPCMessage) => Promise<void>
|
||||
}): Promise<StreamId>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for StreamableHTTPServerTransport
|
||||
*/
|
||||
|
|
@ -18,12 +39,27 @@ export interface StreamableHTTPServerTransportOptions {
|
|||
*/
|
||||
sessionIdGenerator: () => string | undefined;
|
||||
|
||||
/**
|
||||
* A callback for session initialization events
|
||||
* This is called when the server initializes a new session.
|
||||
* Useful in cases when you need to register multiple mcp sessions
|
||||
* and need to keep track of them.
|
||||
* @param sessionId The generated session ID
|
||||
*/
|
||||
onsessioninitialized?: (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.
|
||||
* Default is false (SSE streams are preferred).
|
||||
*/
|
||||
enableJsonResponse?: boolean;
|
||||
|
||||
/**
|
||||
* Event store for resumability support
|
||||
* If provided, resumability will be enabled, allowing clients to reconnect and resume messages
|
||||
*/
|
||||
eventStore?: EventStore;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,12 +100,14 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
// when sessionId is not set (undefined), it means the transport is in stateless mode
|
||||
private sessionIdGenerator: () => string | undefined;
|
||||
private _started: boolean = false;
|
||||
private _responseMapping: Map<RequestId, ServerResponse> = new Map();
|
||||
private _streamMapping: Map<string, ServerResponse> = new Map();
|
||||
private _requestToStreamMapping: Map<RequestId, string> = new Map();
|
||||
private _requestResponseMap: Map<RequestId, JSONRPCMessage> = new Map();
|
||||
private _initialized: boolean = false;
|
||||
private _enableJsonResponse: boolean = false;
|
||||
private _standaloneSSE: ServerResponse | undefined;
|
||||
|
||||
private _standaloneSseStreamId: string = '_GET_stream';
|
||||
private _eventStore?: EventStore;
|
||||
private _onsessioninitialized?: (sessionId: string) => void;
|
||||
|
||||
sessionId?: string | undefined;
|
||||
onclose?: () => void;
|
||||
|
|
@ -79,6 +117,8 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
constructor(options: StreamableHTTPServerTransportOptions) {
|
||||
this.sessionIdGenerator = options.sessionIdGenerator;
|
||||
this._enableJsonResponse = options.enableJsonResponse ?? false;
|
||||
this._eventStore = options.eventStore;
|
||||
this._onsessioninitialized = options.onsessioninitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -131,6 +171,14 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
if (!this.validateSession(req, res)) {
|
||||
return;
|
||||
}
|
||||
// Handle resumability: check for Last-Event-ID header
|
||||
if (this._eventStore) {
|
||||
const lastEventId = req.headers['last-event-id'] as string | undefined;
|
||||
if (lastEventId) {
|
||||
await this.replayEvents(lastEventId, res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The server MUST either return Content-Type: text/event-stream in response to this HTTP GET,
|
||||
// or else return HTTP 405 Method Not Allowed
|
||||
|
|
@ -144,12 +192,9 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
if (this.sessionId !== undefined) {
|
||||
headers["mcp-session-id"] = this.sessionId;
|
||||
}
|
||||
// The server MAY include a Last-Event-ID header in the response to this HTTP GET.
|
||||
// Resumability will be supported in the future
|
||||
|
||||
// Check if there's already an active standalone SSE stream for this session
|
||||
|
||||
if (this._standaloneSSE !== undefined) {
|
||||
if (this._streamMapping.get(this._standaloneSseStreamId) !== undefined) {
|
||||
// Only one GET SSE stream is allowed per session
|
||||
res.writeHead(409).end(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
|
|
@ -161,19 +206,67 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
}));
|
||||
return;
|
||||
}
|
||||
// We need to send headers immediately as message will arrive much later,
|
||||
|
||||
// We need to send headers immediately as messages will arrive much later,
|
||||
// otherwise the client will just wait for the first message
|
||||
res.writeHead(200, headers).flushHeaders();
|
||||
|
||||
// Assing the response to the standalone SSE stream
|
||||
this._standaloneSSE = res;
|
||||
|
||||
// Assign the response to the standalone SSE stream
|
||||
this._streamMapping.set(this._standaloneSseStreamId, res);
|
||||
// Set up close handler for client disconnects
|
||||
res.on("close", () => {
|
||||
this._standaloneSSE = undefined;
|
||||
this._streamMapping.delete(this._standaloneSseStreamId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays events that would have been sent after the specified event ID
|
||||
* Only used when resumability is enabled
|
||||
*/
|
||||
private async replayEvents(lastEventId: string, res: ServerResponse): Promise<void> {
|
||||
if (!this._eventStore) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
};
|
||||
|
||||
if (this.sessionId !== undefined) {
|
||||
headers["mcp-session-id"] = this.sessionId;
|
||||
}
|
||||
res.writeHead(200, headers).flushHeaders();
|
||||
|
||||
const streamId = await this._eventStore?.replayEventsAfter(lastEventId, {
|
||||
send: async (eventId: string, message: JSONRPCMessage) => {
|
||||
if (!this.writeSSEEvent(res, message, eventId)) {
|
||||
this.onerror?.(new Error("Failed replay events"));
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
this._streamMapping.set(streamId, res);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an event to the SSE stream with proper formatting
|
||||
*/
|
||||
private writeSSEEvent(res: ServerResponse, message: JSONRPCMessage, eventId?: string): boolean {
|
||||
let eventData = `event: message\n`;
|
||||
// Include event ID if provided - this is important for resumability
|
||||
if (eventId) {
|
||||
eventData += `id: ${eventId}\n`;
|
||||
}
|
||||
eventData += `data: ${JSON.stringify(message)}\n\n`;
|
||||
|
||||
return res.write(eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles unsupported requests (PUT, PATCH, etc.)
|
||||
*/
|
||||
|
|
@ -246,10 +339,10 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
|
||||
// Check if this is an initialization request
|
||||
// https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/
|
||||
const isInitializationRequest = messages.some(
|
||||
msg => 'method' in msg && msg.method === 'initialize'
|
||||
);
|
||||
const isInitializationRequest = messages.some(isInitializeRequest);
|
||||
if (isInitializationRequest) {
|
||||
// If it's a server with session management and the session ID is already set we should reject the request
|
||||
// to avoid re-initialization.
|
||||
if (this._initialized) {
|
||||
res.writeHead(400).end(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
|
|
@ -275,6 +368,12 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
this.sessionId = this.sessionIdGenerator();
|
||||
this._initialized = true;
|
||||
|
||||
// If we have a session ID and an onsessioninitialized handler, call it immediately
|
||||
// This is needed in cases where the server needs to keep track of multiple sessions
|
||||
if (this.sessionId && this._onsessioninitialized) {
|
||||
this._onsessioninitialized(this.sessionId);
|
||||
}
|
||||
|
||||
}
|
||||
// If an Mcp-Session-Id is returned by the server during initialization,
|
||||
// clients using the Streamable HTTP transport MUST include it
|
||||
|
|
@ -285,11 +384,9 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
|
||||
|
||||
// check if it contains requests
|
||||
const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg);
|
||||
const hasOnlyNotificationsOrResponses = messages.every(msg =>
|
||||
('method' in msg && !('id' in msg)) || ('result' in msg || 'error' in msg));
|
||||
const hasRequests = messages.some(isJSONRPCRequest);
|
||||
|
||||
if (hasOnlyNotificationsOrResponses) {
|
||||
if (!hasRequests) {
|
||||
// if it only contains notifications or responses, return 202
|
||||
res.writeHead(202).end();
|
||||
|
||||
|
|
@ -300,6 +397,7 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
} else if (hasRequests) {
|
||||
// The default behavior is to use SSE streaming
|
||||
// but in some cases server will return JSON responses
|
||||
const streamId = randomUUID();
|
||||
if (!this._enableJsonResponse) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "text/event-stream",
|
||||
|
|
@ -317,20 +415,14 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
// Store the response for this request to send messages back through this connection
|
||||
// We need to track by request ID to maintain the connection
|
||||
for (const message of messages) {
|
||||
if ('method' in message && 'id' in message) {
|
||||
this._responseMapping.set(message.id, res);
|
||||
if (isJSONRPCRequest(message)) {
|
||||
this._streamMapping.set(streamId, res);
|
||||
this._requestToStreamMapping.set(message.id, streamId);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up close handler for client disconnects
|
||||
res.on("close", () => {
|
||||
// Remove all entries that reference this response
|
||||
for (const [id, storedRes] of this._responseMapping.entries()) {
|
||||
if (storedRes === res) {
|
||||
this._responseMapping.delete(id);
|
||||
this._requestResponseMap.delete(id);
|
||||
}
|
||||
}
|
||||
this._streamMapping.delete(streamId);
|
||||
});
|
||||
|
||||
// handle each message
|
||||
|
|
@ -431,20 +523,19 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
|
||||
async close(): Promise<void> {
|
||||
// Close all SSE connections
|
||||
this._responseMapping.forEach((response) => {
|
||||
this._streamMapping.forEach((response) => {
|
||||
response.end();
|
||||
});
|
||||
this._responseMapping.clear();
|
||||
this._streamMapping.clear();
|
||||
|
||||
// Clear any pending responses
|
||||
this._requestResponseMap.clear();
|
||||
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise<void> {
|
||||
let requestId = options?.relatedRequestId;
|
||||
if ('result' in message || 'error' in message) {
|
||||
if (isJSONRPCResponse(message) || isJSONRPCError(message)) {
|
||||
// If the message is a response, use the request ID from the message
|
||||
requestId = message.id;
|
||||
}
|
||||
|
|
@ -454,41 +545,60 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
// Those will be sent via dedicated response SSE streams
|
||||
if (requestId === undefined) {
|
||||
// For standalone SSE streams, we can only send requests and notifications
|
||||
if ('result' in message || 'error' in message) {
|
||||
if (isJSONRPCResponse(message) || isJSONRPCError(message)) {
|
||||
throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request");
|
||||
}
|
||||
|
||||
if (this._standaloneSSE === undefined) {
|
||||
const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId)
|
||||
if (standaloneSse === undefined) {
|
||||
// The spec says the server MAY send messages on the stream, so it's ok to discard if no stream
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate and store event ID if event store is provided
|
||||
let eventId: string | undefined;
|
||||
if (this._eventStore) {
|
||||
// Stores the event and gets the generated event ID
|
||||
eventId = await this._eventStore.storeEvent(this._standaloneSseStreamId, message);
|
||||
}
|
||||
|
||||
// Send the message to the standalone SSE stream
|
||||
this._standaloneSSE.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
|
||||
this.writeSSEEvent(standaloneSse, message, eventId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the response for this request
|
||||
const response = this._responseMapping.get(requestId);
|
||||
if (!response) {
|
||||
const streamId = this._requestToStreamMapping.get(requestId);
|
||||
const response = this._streamMapping.get(streamId!);
|
||||
if (!streamId) {
|
||||
throw new Error(`No connection established for request ID: ${String(requestId)}`);
|
||||
}
|
||||
|
||||
if (!this._enableJsonResponse) {
|
||||
response.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
|
||||
}
|
||||
if ('result' in message || 'error' in message) {
|
||||
this._requestResponseMap.set(requestId, message);
|
||||
// For SSE responses, generate event ID if event store is provided
|
||||
let eventId: string | undefined;
|
||||
|
||||
// Get all request IDs that share the same request response object
|
||||
const relatedIds = Array.from(this._responseMapping.entries())
|
||||
.filter(([_, res]) => res === response)
|
||||
if (this._eventStore) {
|
||||
eventId = await this._eventStore.storeEvent(streamId, message);
|
||||
}
|
||||
if (response) {
|
||||
// Write the event to the response stream
|
||||
this.writeSSEEvent(response, message, eventId);
|
||||
}
|
||||
}
|
||||
|
||||
if (isJSONRPCResponse(message)) {
|
||||
this._requestResponseMap.set(requestId, message);
|
||||
const relatedIds = Array.from(this._requestToStreamMapping.entries())
|
||||
.filter(([_, streamId]) => this._streamMapping.get(streamId) === response)
|
||||
.map(([id]) => id);
|
||||
|
||||
// Check if we have responses for all requests using this connection
|
||||
const allResponsesReady = relatedIds.every(id => this._requestResponseMap.has(id));
|
||||
|
||||
if (allResponsesReady) {
|
||||
if (!response) {
|
||||
throw new Error(`No connection established for request ID: ${String(requestId)}`);
|
||||
}
|
||||
if (this._enableJsonResponse) {
|
||||
// All responses ready, send as JSON
|
||||
const headers: Record<string, string> = {
|
||||
|
|
@ -514,7 +624,7 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
// Clean up
|
||||
for (const id of relatedIds) {
|
||||
this._requestResponseMap.delete(id);
|
||||
this._responseMapping.delete(id);
|
||||
this._requestToStreamMapping.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ import {
|
|||
Result,
|
||||
ServerCapabilities,
|
||||
} from "../types.js";
|
||||
import { Transport } from "./transport.js";
|
||||
import { Transport, TransportSendOptions } from "./transport.js";
|
||||
import { AuthInfo } from "../server/auth/types.js";
|
||||
|
||||
/**
|
||||
* Callback for progress notifications.
|
||||
|
|
@ -82,12 +83,7 @@ export type RequestOptions = {
|
|||
* If not specified, there is no maximum total timeout.
|
||||
*/
|
||||
maxTotalTimeout?: number;
|
||||
|
||||
/**
|
||||
* May be used to indicate to the transport which incoming request to associate this outgoing request with.
|
||||
*/
|
||||
relatedRequestId?: RequestId;
|
||||
};
|
||||
} & TransportSendOptions;
|
||||
|
||||
/**
|
||||
* Options that can be given per notification.
|
||||
|
|
@ -109,6 +105,11 @@ export type RequestHandlerExtra<SendRequestT extends Request,
|
|||
*/
|
||||
signal: AbortSignal;
|
||||
|
||||
/**
|
||||
* Information about a validated access token, provided to request handlers.
|
||||
*/
|
||||
authInfo?: AuthInfo;
|
||||
|
||||
/**
|
||||
* The session ID from the transport, if available.
|
||||
*/
|
||||
|
|
@ -274,11 +275,11 @@ export abstract class Protocol<
|
|||
this._onerror(error);
|
||||
};
|
||||
|
||||
this._transport.onmessage = (message) => {
|
||||
this._transport.onmessage = (message, extra) => {
|
||||
if (isJSONRPCResponse(message) || isJSONRPCError(message)) {
|
||||
this._onresponse(message);
|
||||
} else if (isJSONRPCRequest(message)) {
|
||||
this._onrequest(message);
|
||||
this._onrequest(message, extra);
|
||||
} else if (isJSONRPCNotification(message)) {
|
||||
this._onnotification(message);
|
||||
} else {
|
||||
|
|
@ -326,7 +327,7 @@ export abstract class Protocol<
|
|||
);
|
||||
}
|
||||
|
||||
private _onrequest(request: JSONRPCRequest): void {
|
||||
private _onrequest(request: JSONRPCRequest, extra?: { authInfo?: AuthInfo }): void {
|
||||
const handler =
|
||||
this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
|
||||
|
||||
|
|
@ -351,20 +352,20 @@ export abstract class Protocol<
|
|||
const abortController = new AbortController();
|
||||
this._requestHandlerAbortControllers.set(request.id, abortController);
|
||||
|
||||
// Create extra object with both abort signal and sessionId from transport
|
||||
const extra: RequestHandlerExtra<SendRequestT, SendNotificationT> = {
|
||||
const fullExtra: RequestHandlerExtra<SendRequestT, SendNotificationT> = {
|
||||
signal: abortController.signal,
|
||||
sessionId: this._transport?.sessionId,
|
||||
sendNotification:
|
||||
(notification) =>
|
||||
this.notification(notification, { relatedRequestId: request.id }),
|
||||
sendRequest: (r, resultSchema, options?) =>
|
||||
this.request(r, resultSchema, { ...options, relatedRequestId: request.id })
|
||||
this.request(r, resultSchema, { ...options, relatedRequestId: request.id }),
|
||||
authInfo: extra?.authInfo,
|
||||
};
|
||||
|
||||
// Starting with Promise.resolve() puts any synchronous errors into the monad as well.
|
||||
Promise.resolve()
|
||||
.then(() => handler(request, extra))
|
||||
.then(() => handler(request, fullExtra))
|
||||
.then(
|
||||
(result) => {
|
||||
if (abortController.signal.aborted) {
|
||||
|
|
@ -501,7 +502,7 @@ export abstract class Protocol<
|
|||
resultSchema: T,
|
||||
options?: RequestOptions,
|
||||
): Promise<z.infer<T>> {
|
||||
const { relatedRequestId } = options ?? {};
|
||||
const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this._transport) {
|
||||
|
|
@ -543,7 +544,7 @@ export abstract class Protocol<
|
|||
requestId: messageId,
|
||||
reason: String(reason),
|
||||
},
|
||||
}, { relatedRequestId })
|
||||
}, { relatedRequestId, resumptionToken, onresumptiontoken })
|
||||
.catch((error) =>
|
||||
this._onerror(new Error(`Failed to send cancellation: ${error}`)),
|
||||
);
|
||||
|
|
@ -581,7 +582,7 @@ export abstract class Protocol<
|
|||
|
||||
this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false);
|
||||
|
||||
this._transport.send(jsonrpcRequest, { relatedRequestId }).catch((error) => {
|
||||
this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch((error) => {
|
||||
this._cleanupTimeout(messageId);
|
||||
reject(error);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,29 @@
|
|||
import { AuthInfo } from "../server/auth/types.js";
|
||||
import { JSONRPCMessage, RequestId } from "../types.js";
|
||||
|
||||
/**
|
||||
* Options for sending a JSON-RPC message.
|
||||
*/
|
||||
export type TransportSendOptions = {
|
||||
/**
|
||||
* If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with.
|
||||
*/
|
||||
relatedRequestId?: RequestId;
|
||||
|
||||
/**
|
||||
* The resumption token used to continue long-running requests that were interrupted.
|
||||
*
|
||||
* This allows clients to reconnect and continue from where they left off, if supported by the transport.
|
||||
*/
|
||||
resumptionToken?: string;
|
||||
|
||||
/**
|
||||
* A callback that is invoked when the resumption token changes, if supported by the transport.
|
||||
*
|
||||
* This allows clients to persist the latest token for potential reconnection.
|
||||
*/
|
||||
onresumptiontoken?: (token: string) => void;
|
||||
}
|
||||
/**
|
||||
* Describes the minimal contract for a MCP transport that a client or server can communicate over.
|
||||
*/
|
||||
|
|
@ -18,7 +42,7 @@ export interface Transport {
|
|||
*
|
||||
* If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with.
|
||||
*/
|
||||
send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise<void>;
|
||||
send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* Closes the connection.
|
||||
|
|
@ -41,8 +65,11 @@ export interface Transport {
|
|||
|
||||
/**
|
||||
* Callback for when a message (request or response) is received over the connection.
|
||||
*
|
||||
* Includes the authInfo if the transport is authenticated.
|
||||
*
|
||||
*/
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
|
||||
|
||||
/**
|
||||
* The session ID generated for this connection.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue