diff --git a/.gitignore b/.gitignore index 6c4bf1a6..4b58d00c 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ out .DS_Store dist/ + +# Ignoring IntelliJ files +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 5345c546..86e80be1 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - [Running Your Server](#running-your-server) - [stdio](#stdio) - [Streamable HTTP](#streamable-http) + - [Loopback Transport](#loopback-transport-in-memory) - [Testing and Debugging](#testing-and-debugging) - [Examples](#examples) - [Echo Server](#echo-server) @@ -380,6 +381,46 @@ This stateless approach is useful for: - RESTful scenarios where each request is independent - Horizontally scaled deployments without shared session state +### Loopback Transport (In-Memory) + +The Loopback Transport provides an efficient way to connect MCP clients and servers within the same execution context, +making it ideal for testing, browser-based applications, and environments where external network calls are unnecessary. +Unlike stdio or Streamable HTTP, Loopback Transport is intended exclusively for scenarios where clients and servers operate +in the same execution context. Note that the stdio transport cannot operate in a browser. While Streamable HTTP can be used +in a brower with a lot of complexity, Loopback Transport is a much simpler option. + +#### Usage + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { LoopbackTransport } from "@modelcontextprotocol/sdk/shared/loopback.js"; + +// Create server and client +const server = new McpServer({ name: "example-server", version: "1.0.0" }); +const client = new Client({ name: "example-client", version: "1.0.0" }); + +// Create loopback transports +const clientTransport = new LoopbackTransport({ name: "client" }); +const serverTransport = new LoopbackTransport({ name: "server" }); + +// Connect transports in-memory +clientTransport.connect(serverTransport); + +// Connect to MCP +await server.connect(serverTransport); +await client.connect(clientTransport); + +// Use client and server as normal +``` + +#### When to Use Loopback Transport + +- **Testing**: Provides fast, reliable tests without external dependencies. +- **Browser Applications**: Enables MCP functionality entirely within the browser. +- **Rapid Prototyping**: Ideal for quick iteration without networking overhead. + + ### Testing and Debugging To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information. diff --git a/src/integration-tests/loopback.integration.test.ts b/src/integration-tests/loopback.integration.test.ts new file mode 100644 index 00000000..d3110891 --- /dev/null +++ b/src/integration-tests/loopback.integration.test.ts @@ -0,0 +1,123 @@ +import {JSONRPCRequest, ResultSchema} from "../types.js"; +import {z} from "zod"; +import {Server} from "../server/index.js"; +import {Client} from "../client/index.js"; +import {LoopbackTransport} from "../shared/loopback.js"; + +// Assume you have a base RequestSchema defined somewhere in your code. +// For this example, we create a minimal stub. +const RequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.union([z.number(), z.string()]), +}); + +// Define the weather request schema. +const GetWeatherRequestSchema = RequestSchema.extend({ + method: z.literal("weather/get"), + params: z.object({ + city: z.string(), + }), +}); +const WeatherResultSchema = ResultSchema.extend({ + temperature: z.number(), + conditions: z.string(), +}); + +describe("Client-Server Integration Test for Weather Request", () => { + let clientTransport: LoopbackTransport; + let serverTransport: LoopbackTransport + let client: Client; + let server: Server; + + beforeEach(async () => { + clientTransport = new LoopbackTransport(); + serverTransport = new LoopbackTransport(); + clientTransport.connect(serverTransport) + + server = new Server( + {name: "test server", version: "1.0"}, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + instructions: "Test instructions", + } + ); + await server.connect(serverTransport); + + client = new Client( + {name: "test client", version: "1.0"}, + {capabilities: {sampling: {}}} + ); + + await client.connect(clientTransport); + + // Register the weather request handler on the server. + server.setRequestHandler(GetWeatherRequestSchema, (_) => { + // In a real handler, you might use request.params.city. + return { + temperature: 72, + conditions: "sunny", + }; + }); + }); + + test("client sends weather/get request and receives expected response", async () => { + // Construct a JSONRPCRequest matching the weather schema. + const request: JSONRPCRequest = { + jsonrpc: "2.0", + id: 1, + method: "weather/get", + params: {city: "New York"}, + }; + + // The client issues the request; we assume client.request returns a promise resolving to a JSONRPCResponse. + const response = await client.request(request, WeatherResultSchema); + + // Verify that the response from the server matches the expected result. + expect(response).toEqual({temperature: 72, conditions: "sunny"}); + }); + + test("server returns error response for invalid request", async () => { + server.setRequestHandler(GetWeatherRequestSchema, () => { + throw new Error("Unexpected server error"); + }); + + const request: JSONRPCRequest = { + jsonrpc: "2.0", + id: 2, + method: "weather/get", + params: {city: "Invalid City"}, + }; + + await expect(client.request(request, WeatherResultSchema)).rejects.toThrow(); + }); + + test("multiple concurrent requests are handled correctly", async () => { + const cities = ["New York", "London", "Tokyo"]; + server.setRequestHandler(GetWeatherRequestSchema, (req) => ({ + temperature: 20, + conditions: `Weather for ${req.params.city}`, + })); + + const responses = await Promise.all( + cities.map((city, idx) => { + const request: JSONRPCRequest = { + jsonrpc: "2.0", + id: idx + 1, + method: "weather/get", + params: {city}, + }; + return client.request(request, WeatherResultSchema); + }) + ); + + responses.forEach((response, idx) => { + expect(response).toEqual({temperature: 20, conditions: `Weather for ${cities[idx]}`}); + }); + }); + +}); diff --git a/src/shared/loopback.test.ts b/src/shared/loopback.test.ts new file mode 100644 index 00000000..b6129d39 --- /dev/null +++ b/src/shared/loopback.test.ts @@ -0,0 +1,213 @@ +import {generateSessionId, LoopbackTransport} from "./loopback.js"; +import {JSONRPCNotification, JSONRPCRequest, JSONRPCResponse} from "../types.js"; + +describe("LoopbackTransport - Happy Path", () => { + let clientTransport: LoopbackTransport; + let serverTransport: LoopbackTransport; + + beforeEach(async () => { + clientTransport = new LoopbackTransport(); + serverTransport = new LoopbackTransport(); + + // Set onmessage handlers so that start() does not throw + clientTransport.onmessage = () => { + }; + serverTransport.onmessage = () => { + }; + + // Connect the transports + clientTransport.connect(serverTransport); + + // Start them so that they check for proper registration and connection + await clientTransport.start(); + await serverTransport.start(); + }); + + afterEach(async () => { + await clientTransport.close(); + await serverTransport.close(); + }); + + test("client sends JSONRPCRequest to server", async () => { + const request: JSONRPCRequest = { + jsonrpc: "2.0", + id: 1, + method: "echo", + params: {message: "hello"}, + }; + + const serverReceived = new Promise((resolve) => { + // Overwrite server's onmessage to capture the request + serverTransport.onmessage = (msg) => resolve(msg as JSONRPCRequest); + }); + + await clientTransport.send(request); + const received = await serverReceived; + expect(received).toEqual(request); + }); + + test("server sends JSONRPCResponse to client", async () => { + const response: JSONRPCResponse = { + jsonrpc: "2.0", + id: 1, + result: {message: "hi there!"}, + }; + + const clientReceived = new Promise((resolve) => { + clientTransport.onmessage = (msg) => resolve(msg as JSONRPCResponse); + }); + + await serverTransport.send(response); + const received = await clientReceived; + expect(received).toEqual(response); + }); + + test("client sends JSONRPCNotification to server", async () => { + const notification: JSONRPCNotification = { + jsonrpc: "2.0", + method: "notify", + params: {alert: "test"}, + }; + + const serverReceived = new Promise((resolve) => { + serverTransport.onmessage = (msg) => resolve(msg as JSONRPCNotification); + }); + + await clientTransport.send(notification); + const received = await serverReceived; + expect(received).toEqual(notification); + }); + + test("closing client transport calls server's onclose", async () => { + const serverClosed = new Promise((resolve) => { + serverTransport.onclose = () => resolve(); + }); + await clientTransport.close(); + await expect(serverClosed).resolves.toBeUndefined(); + }); + + test("closing server transport calls client's onclose", async () => { + const clientClosed = new Promise((resolve) => { + clientTransport.onclose = () => resolve(); + }); + await serverTransport.close(); + await expect(clientClosed).resolves.toBeUndefined(); + }); + + test("close() can be called multiple times safely", async () => { + await clientTransport.close(); + await expect(clientTransport.close()).resolves.not.toThrow(); + }); + + +}); + +describe("LoopbackTransport - Error Cases", () => { + let transport: LoopbackTransport; + let peer: LoopbackTransport; + const sampleRequest: JSONRPCRequest = { + jsonrpc: "2.0", + id: 1, + method: "test", + params: {sample: "data"}, + }; + + beforeEach(async () => { + transport = new LoopbackTransport(); + peer = new LoopbackTransport(); + // Pre-register onmessage handlers for start() when needed. + transport.onmessage = () => { + }; + peer.onmessage = () => { + }; + }); + + + test("start() should throw if onmessage is not set", async () => { + const t = new LoopbackTransport(); + // Do not set t.onmessage. + await expect(t.start()).rejects.toThrow( + "Cannot start without being registered to a protocol" + ); + }); + + test("start() should throw if not connected", async () => { + const t = new LoopbackTransport(); + t.onmessage = () => { + }; + await expect(t.start()).rejects.toThrow("Cannot start without connect"); + }); + + test("connect() should throw if one transport is already connected", () => { + transport.connect(peer); + const another = new LoopbackTransport(); + expect(() => transport.connect(another)).toThrow( + "One of the transports is already connected" + ); + expect(() => peer.connect(another)).toThrow( + "One of the transports is already connected" + ); + }); + + test("send() should throw if peer's onmessage handler is not set", async () => { + transport.connect(peer); + // Remove peer's onmessage handler. + peer.onmessage = undefined; + await expect(transport.send(sampleRequest)).rejects.toThrow( + "Peer transport has no onmessage handler set" + ); + }); + + test("send() should throw if sending after transport is closed", async () => { + transport.connect(peer); + transport.onmessage = () => { + }; + peer.onmessage = () => { + }; + await transport.close(); + await expect(transport.send(sampleRequest)).rejects.toThrow( + "Peer transport has no onmessage handler set" + ); + }); + test("send() handles JSON serialization errors gracefully", async () => { + const transport = new LoopbackTransport({ stringifyMessage: true }); + const peer = new LoopbackTransport(); + transport.connect(peer); + transport.onmessage = () => {}; + peer.onmessage = () => {}; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- We are making a test for 'broken data'. The type is not important + const circular: any = {}; + circular.self = circular; // intentionally cause serialization error + + await expect(transport.send(circular)).rejects.toThrow(/Error stringifying message/); + }); + +}); + + +describe("generateSessionId", () => { + test("should return a non-empty string", () => { + const id = generateSessionId(); + expect(typeof id).toBe("string"); + expect(id.length).toBeGreaterThan(0); + }); + + test("should return different values on successive calls", () => { + const id1 = generateSessionId(); + const id2 = generateSessionId(); + expect(id1).not.toEqual(id2); + }); + + test("should generate expected session id using fallback when uuid returns falsy", () => { + // Custom UUID generator that returns undefined to force fallback. + const customUuid = () => undefined; + // Custom functions for predictable output. + const customNow = () => 1234567890; + const customRandom = () => 0.123456; // Math.floor(0.123456 * 1e8) = 12345600 + + const expected = "123456789012345600"; // "1234567890" + "12345600" + const id = generateSessionId(customUuid, customNow, customRandom); + expect(id).toBe(expected); + }); +}); diff --git a/src/shared/loopback.ts b/src/shared/loopback.ts new file mode 100644 index 00000000..d204e7d3 --- /dev/null +++ b/src/shared/loopback.ts @@ -0,0 +1,197 @@ +/** + * LoopbackTransport: Custom MCP Transport Implementation + * + * This custom transport complies with the MCP custom transport requirements: + * + * - The JSON-RPC message format (for requests, responses, and notifications) is preserved, + * ensuring that all communication over this transport adheres to MCP's expected message schema. + * + * - The transport lifecycle follows MCP's guidelines: + * - Connection Establishment: The `connect()` method creates a bidirectional binding between two transport instances. + * - Message Exchange: The `send()` method delivers messages asynchronously using a callback mechanism. + * - Teardown: The `close()` method gracefully terminates the connection, ensuring all associated resources are released. + * + * Example Usage: + * + * // Create your normal server and client instances. + * const server = new Server( ...server options... ); + * const client = new Client( ...client options... ); + * + * // Create transport instances. + * const clientTransport = new LoopbackTransport(); + * const serverTransport = new LoopbackTransport(); + * + * // Connect the transports. + * clientTransport.connect(serverTransport); + * + * // Attach the transport to the server and client. + * await server.connect(serverTransport); + * await client.connect(clientTransport); + * + * // At this point the client is connected to the server via the loopback transport. + */ +import {JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse} from "../types.js"; +import {Transport} from "./transport.js"; + +type Message = JSONRPCRequest | JSONRPCResponse | JSONRPCNotification; +export type ClientOrServer = 'client' | 'server' | 'unknown'; +export type LoopbackTransportConfig = { + /** If you give this debugging is easier, and it is included in the debug log. Should be client or server */ + name?: ClientOrServer + /** If true then debug messages will appear in the console */ + debug?: boolean + /** If true then messages are 'serialised/deserialised'. This means the server gets a totally different copy. While this slows things down it more closely duplicates the behavior of the other transports*/ + stringifyMessage?: boolean +} + +export class LoopbackTransport implements Transport { + private _config: LoopbackTransportConfig | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- We really want any here. It's 'just debugging' We know almost nothing about what is passed, and don't need to + debug: (...args: any[]) => void = (...args) => { + if (this._config?.debug) + if (this._config.name) + console.log(`LoopbackTransport`, this._config?.name, args) + else + console.log(`LoopbackTransport`, args) + } + + constructor(config: LoopbackTransportConfig = {name: 'unknown'}) { + this._config = config; + } + + + /** + * Handlers set by the protocol implementation. + * MCP requires that the transport invoke these callbacks appropriately to manage the lifecycle: + * - onmessage: Called when a JSON-RPC message (request, response, or notification) is received. + * - onclose: Called when the transport is closed. + * - onerror: Called when an error occurs during transport operations. + */ + onmessage?: (msg: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + + /** + * For debugging and logging purposes, each connection is assigned a unique sessionId. + * This helps identify MCP client/session pairs in multi-connection scenarios. + */ + sessionId: string | undefined = undefined; + + // Direct reference to the connected peer transport. + private peer?: LoopbackTransport; + + /** + * Establishes a bidirectional connection with another LoopbackTransport instance. + * + * @param peer - The peer transport to connect to. + * @throws Error if either transport is already connected. + * + * The connection establishment pattern is documented as follows: + * - Both transports receive the same sessionId. + * - The instance's peer references are mutually set. + */ + connect(peer: LoopbackTransport) { + this.debug(this, peer); + // Safety check: ensure that neither transport is already connected. + if (!peer) throw new Error("Peer argument is not valid" + `${JSON.stringify(peer)}`); + if (this.peer || peer.peer) throw new Error("One of the transports is already connected"); + this.sessionId = generateSessionId(); + peer.sessionId = this.sessionId; + this.peer = peer; + peer.peer = this; + } + + /** + * Prepares the transport for operation by validating that it has an onmessage handler + * and that it is connected to a peer. This method complies with MCP's lifecycle requirements. + * + * @throws Error if onmessage is not registered or if there is no peer connection. + */ + async start(): Promise { + this.debug('start', this); + if (this.onmessage === undefined) throw new Error('Cannot start without being registered to a protocol'); + if (this.peer === undefined) throw new Error('Cannot start without connect'); + // Transport is now ready for message exchange. + } + + /** + * Sends a JSON-RPC message (request, response, or notification) to the connected peer. + * + * MCP requires that the JSON-RPC message format is preserved in transmission. + * This implementation delivers the message asynchronously to simulate network delay. + * + * @param message - The JSON-RPC message to send. + * @throws Error if the peer transport's onmessage handler is not set. + */ + async send(message: Message): Promise { + this.debug('send', message); + if (!this.peer?.onmessage) { + // If this error is thrown, it indicates that the server transport has not been connected to the server + // You need to execute some code like 'server.connect(serverTransport)' before this is called.throw new Error("Peer transport has no onmessage handler set. Have connected the server to the server transport?"); + throw new Error("Peer transport has no onmessage handler set. Have you connected the server to the server transport?"); + } + // Asynchronously deliver the message to simulate a real-world transport. + try { + if (this._config?.stringifyMessage) { + message = JSON.parse(JSON.stringify(message)); + } + } catch (e) { + const newError = new Error(`Error stringifying message: ${e}`); + this.onerror?.(newError); + throw newError; + } + setTimeout(() => this.peer!.onmessage!(message), 0); + } + + /** + * Closes the transport, calling the onclose handler and cleaning up the session state. + * + * In line with MCP lifecycle requirements, closing a transport should release all resources + * and propagate the close event to the connected peer. + */ + async close(): Promise { + this.debug('close') + if (!this.peer) { + this.debug('close called, but already closed. Not an error'); + return + } + this.onclose?.(); + this.sessionId = undefined; + const peer = this.peer; + this.peer = undefined; + // Propagate the close event to the peer, if any. + peer?.close(); + } +} + +/** + * Generates a unique session ID. + * + * Example usage: const id = generateSessionId() + * + * This helper function first attempts to use the secure native `crypto.randomUUID` API + * (available in Node.js and modern browsers). If unavailable, it falls back to a simple + * concatenation of the current timestamp (provided by the `now` function) and a random + * number (provided by the `random` function). This approach is sufficient for creating a unique, + * non-user-facing session identifier. + * + * The parameters only exist to help with testing. They should only be used when testing (and + * they make the tests trivial instead of awkward). + * + * @param uuid - A function that returns a random string (usually a uuid). This might not exist in older browsers. + * Defaults to `crypto.randomUUID`. + * @param now - A function that returns the current timestamp in milliseconds. Defaults to `Date.now`. + * @param random - A function that returns a random number between 0 and 1. Defaults to `Math.random`. + * @returns A unique session identifier string. + * + */ +export function generateSessionId( + uuid: () => string | undefined = () => crypto?.randomUUID(), + now: () => number = Date.now, + random: () => number = Math.random, +): string { + const result = uuid(); + if (result) return result; + // Fallback: generate a session ID using the provided now and random functions. + return `${now()}${Math.floor(random() * 1e8)}`; +}