import { Context, Data, Effect, Layer, Ref, Scope } from "effect"; import * as http from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { OAuthTestServer } from "@executor-js/sdk/testing"; import z from "zod"; export type McpTestServer = { readonly url: string; readonly endpoint: string; /** Number of MCP sessions created (each connect = 1 session) */ readonly sessionCount: () => number; readonly requests: Effect.Effect; readonly clearRequests: Effect.Effect; }; export type McpTestRequest = { readonly method: string; readonly url: string; readonly authorization: string | undefined; readonly sessionId: string | undefined; }; export type McpTestServerOptions = { readonly path?: string; readonly auth?: { readonly validateAuthorization: (authorization: string | undefined) => Effect.Effect; readonly authorizationServerUrls?: readonly string[]; readonly scopes?: readonly string[]; readonly wwwAuthenticate?: string; }; }; export class McpTestServerError extends Data.TaggedError("McpTestServerError")<{ readonly cause: unknown; }> {} const writeJson = ( response: http.ServerResponse, status: number, body: Readonly>, headers: Readonly> = {}, ) => { response.writeHead(status, { "content-type": "application/json", ...headers, }); response.end(JSON.stringify(body)); }; const writeText = (response: http.ServerResponse, status: number, body: string) => { response.writeHead(status, { "content-type": "text/plain; charset=utf-8" }); response.end(body); }; const isMcpPath = (url: string, path: string): boolean => { const parsed = new URL(url, "http://executor.test"); return parsed.pathname === path; }; const protectedResourcePath = "/.well-known/oauth-protected-resource"; export const serveMcpServer = (factory: () => McpServer, options: McpTestServerOptions = {}) => Effect.acquireRelease( Effect.gen(function* () { const transports = new Map(); const requests = yield* Ref.make([]); const path = options.path ?? "/"; let sessions = 0; const handleMcpRequest = ( request: http.IncomingMessage, response: http.ServerResponse, ): Effect.Effect => Effect.gen(function* () { const requestUrl = request.url ?? "/"; const sessionId = Array.isArray(request.headers["mcp-session-id"]) ? request.headers["mcp-session-id"][0] : request.headers["mcp-session-id"]; const authorization = Array.isArray(request.headers.authorization) ? request.headers.authorization[0] : request.headers.authorization; const origin = request.headers.host ? `http://${request.headers.host}` : "http://127.0.0.1"; yield* Ref.update(requests, (all) => [ ...all, { method: request.method ?? "GET", url: requestUrl, authorization, sessionId, }, ]); if ( options.auth?.authorizationServerUrls && requestUrl.startsWith(protectedResourcePath) ) { const resourcePath = requestUrl.slice(protectedResourcePath.length); writeJson(response, 200, { resource: `${origin}${resourcePath}`, authorization_servers: options.auth.authorizationServerUrls, bearer_methods_supported: ["header"], scopes_supported: options.auth.scopes ?? ["read"], }); return; } if (!isMcpPath(requestUrl, path)) { writeJson(response, 404, { error: "not_found" }); return; } if (options.auth) { const accepted = yield* options.auth.validateAuthorization(authorization); if (!accepted) { writeJson( response, 401, { error: "invalid_token" }, { "www-authenticate": options.auth.wwwAuthenticate ?? `Bearer resource_metadata="${origin}${protectedResourcePath}${path}", error="invalid_token"`, }, ); return; } } const existingTransport = sessionId ? transports.get(sessionId) : undefined; if (sessionId && !existingTransport) { writeText(response, 404, "Session not found"); return; } if (existingTransport) { yield* Effect.tryPromise({ try: () => existingTransport.handleRequest(request, response), catch: (cause) => new McpTestServerError({ cause }), }); return; } const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (sid) => { transports.set(sid, transport); }, }); sessions += 1; const mcpServer = factory(); yield* Effect.tryPromise({ try: () => mcpServer.connect(transport), catch: (cause) => new McpTestServerError({ cause }), }); yield* Effect.tryPromise({ try: () => transport.handleRequest(request, response), catch: (cause) => new McpTestServerError({ cause }), }); }).pipe( Effect.catch(() => Effect.sync(() => { if (!response.headersSent) { writeJson(response, 500, { error: "mcp_test_server_failed" }); } else if (!response.writableEnded) { response.end(); } }), ), ); const nodeServer = http.createServer((request, response) => { void Effect.runPromise(handleMcpRequest(request, response)); }); const port = yield* Effect.callback((resume) => { const onError = (cause: Error) => { nodeServer.off("error", onError); resume(Effect.fail(new McpTestServerError({ cause }))); }; nodeServer.once("error", onError); nodeServer.listen(0, () => { nodeServer.off("error", onError); const address = nodeServer.address(); if (typeof address === "object" && address) { resume(Effect.succeed(address.port)); return; } resume(Effect.fail(new McpTestServerError({ cause: address }))); }); }); const baseUrl = `http://127.0.0.1:${port}`; const endpoint = path === "/" ? baseUrl : new URL(path, baseUrl).toString(); return { url: endpoint, endpoint, sessionCount: () => sessions, requests: Ref.get(requests), clearRequests: Ref.set(requests, []), close: Effect.gen(function* () { for (const transport of transports.values()) { yield* Effect.tryPromise({ try: () => transport.close(), catch: (cause) => new McpTestServerError({ cause }), }).pipe(Effect.ignore); } yield* Effect.sync(() => { nodeServer.close(); nodeServer.closeAllConnections?.(); }); }), }; }), (server) => server.close, ).pipe(Effect.map(({ close: _close, ...server }) => server)); export const serveMcpServerWithOAuth = ( factory: () => McpServer, options: Omit & { readonly scopes?: readonly string[]; readonly wwwAuthenticate?: string; } = {}, ) => Effect.gen(function* () { const oauth = yield* OAuthTestServer; return yield* serveMcpServer(factory, { path: options.path, auth: { validateAuthorization: oauth.acceptsAuthorizationHeader, authorizationServerUrls: [oauth.issuerUrl], scopes: options.scopes ?? ["read"], wwwAuthenticate: options.wwwAuthenticate, }, }); }); export class McpTestServerLayer extends Context.Service()( "@executor-js/plugin-mcp/testing/McpTestServer", ) { static readonly layer = ( factory: () => McpServer, options?: McpTestServerOptions, ): Layer.Layer => Layer.effect(McpTestServerLayer, serveMcpServer(factory, options)); static readonly layerWithOAuth = ( factory: () => McpServer, options?: Omit & { readonly scopes?: readonly string[]; readonly wwwAuthenticate?: string; }, ): Layer.Layer => Layer.effect(McpTestServerLayer, serveMcpServerWithOAuth(factory, options)); } export const makeGreetingMcpServer = ( options: { readonly name?: string; readonly version?: string; readonly toolName?: string; readonly toolDescription?: string; readonly text?: string; } = {}, ) => { const server = new McpServer( { name: options.name ?? "executor-test-mcp", version: options.version ?? "1.0.0", }, { capabilities: {} }, ); server.registerTool( options.toolName ?? "simple_echo", { description: options.toolDescription ?? "Echoes from the executor MCP test server", inputSchema: {}, }, async () => ({ content: [{ type: "text" as const, text: options.text ?? "mcp-ok" }], }), ); return server; }; export const TEST_IMAGE_MIME_TYPE = "image/png"; export const TEST_IMAGE_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8N1wwAAAABJRU5ErkJggg=="; const testImageMetadata = () => ({ name: "mcp-image-fixture.png", mimeType: TEST_IMAGE_MIME_TYPE, byteLength: Buffer.from(TEST_IMAGE_PNG_BASE64, "base64").byteLength, }); export const makeImageMcpServer = () => { const server = new McpServer( { name: "image-test-server", version: "1.0.0" }, { capabilities: {} }, ); server.registerTool( "image_fixture", { description: "Returns a deterministic PNG as MCP image content", inputSchema: {}, }, async () => ({ content: [ { type: "image" as const, data: TEST_IMAGE_PNG_BASE64, mimeType: TEST_IMAGE_MIME_TYPE, }, ], structuredContent: testImageMetadata(), }), ); server.registerTool( "image_fixture_with_metadata", { description: "Returns text metadata followed by MCP image content", inputSchema: {}, }, async () => ({ content: [ { type: "text" as const, text: "Deterministic image fixture: mcp-image-fixture.png (image/png, 70 bytes)", }, { type: "image" as const, data: TEST_IMAGE_PNG_BASE64, mimeType: TEST_IMAGE_MIME_TYPE, }, ], structuredContent: testImageMetadata(), }), ); return server; }; export const makeEchoMcpServer = ( options: { readonly name?: string; readonly version?: string; readonly toolName?: string; readonly toolDescription?: string; readonly inputName?: "name" | "value" | "marker"; readonly text?: (value: string) => string; } = {}, ) => { const inputName = options.inputName ?? "value"; const server = new McpServer( { name: options.name ?? "executor-echo-mcp", version: options.version ?? "1.0.0", }, { capabilities: {} }, ); server.registerTool( options.toolName ?? "echo", { description: options.toolDescription ?? "Echoes a string value", inputSchema: { [inputName]: z.string() }, }, async (input) => ({ content: [ { type: "text" as const, text: options.text ? options.text(input[inputName]) : input[inputName], }, ], }), ); return server; }; export const makeElicitationMcpServer = () => { const server = new McpServer( { name: "elicitation-test-server", version: "1.0.0" }, { capabilities: {} }, ); server.registerTool( "gated_echo", { description: "Asks for approval before echoing a value", inputSchema: { value: z.string() }, }, async ({ value }: { value: string }) => { const response = await server.server.elicitInput({ mode: "form", message: `Approve echo for "${value}"?`, requestedSchema: { type: "object", properties: { approved: { type: "boolean", title: "Approve" }, }, required: ["approved"], }, }); if (response.action !== "accept" || !response.content || response.content.approved !== true) { return { content: [{ type: "text" as const, text: `denied:${value}` }], }; } return { content: [{ type: "text" as const, text: `approved:${value}` }], }; }, ); server.registerTool( "simple_echo", { description: "Echoes a value without elicitation", inputSchema: { value: z.string() }, }, async ({ value }: { value: string }) => ({ content: [{ type: "text" as const, text: value }], }), ); server.registerTool( "structured_echo", { description: "Returns text plus structured data", inputSchema: { value: z.string() }, outputSchema: { value: z.string(), upper: z.string(), }, }, async ({ value }: { value: string }) => ({ content: [{ type: "text" as const, text: value }], structuredContent: { value, upper: value.toUpperCase() }, _meta: { trace: "kept" }, }), ); return server; }; /** * A server whose tool catalog mutates at runtime. `renameTool` renames the * advertised tool from `initialToolName` to `renamedToolName` via the SDK's * `RegisteredTool.update`, which sends `notifications/tools/list_changed` to * connected sessions. Calling the retired name afterwards yields the spec's * unknown-tool protocol error. The rename applies to every live session and * to sessions created after it (one shared name across the factory), so a * mutation made during one client's call window is visible to the next * connection's `tools/list`. The `rename_greet` tool performs the rename * mid-call, so a client with an open connection receives the notification * inside its own call window. */ export const makeMutableCatalogMcpServer = ( options: { readonly name?: string; readonly initialToolName?: string; readonly renamedToolName?: string; } = {}, ) => { const serverName = options.name ?? "mutable-catalog-test-server"; const initialToolName = options.initialToolName ?? "greet"; const renamedToolName = options.renamedToolName ?? "greet_v2"; const registrations = new Set<{ update: (updates: { name: string }) => void }>(); let currentToolName = initialToolName; const renameTool = () => { currentToolName = renamedToolName; for (const registered of registrations) { registered.update({ name: renamedToolName }); } }; const factory = () => { const server = new McpServer({ name: serverName, version: "1.0.0" }, { capabilities: {} }); const registered = server.registerTool( currentToolName, { description: "Greets the caller", inputSchema: { name: z.string() }, }, async ({ name }: { name: string }) => ({ content: [{ type: "text" as const, text: `greeting:${name}` }], }), ); registrations.add(registered); server.registerTool( "rename_greet", { description: "Renames the greet tool", inputSchema: {} }, async (_args, extra) => { renameTool(); // `RegisteredTool.update` already emitted list_changed, but with no // relatedRequestId the transport routes it to the standalone GET SSE // stream, which a request-scoped client may never have open. Send it // through the handler's `extra` too: that stamps the request id, so // the notification rides THIS call's response stream and is // guaranteed to reach the caller before the tool result. await extra.sendNotification({ method: "notifications/tools/list_changed" }); return { content: [{ type: "text" as const, text: "renamed" }] }; }, ); return server; }; return { factory, renameTool, initialToolName, renamedToolName }; }; export const makeAnnotationsMcpServer = () => { const server = new McpServer( { name: "annotations-test-server", version: "1.0.0" }, { capabilities: {} }, ); server.registerTool( "delete", { description: "A destructive tool", inputSchema: { id: z.string() }, annotations: { destructiveHint: true }, }, async () => ({ content: [] }), ); server.registerTool( "delete_titled", { description: "A destructive tool with a title annotation", inputSchema: { id: z.string() }, annotations: { destructiveHint: true, title: "Delete dataset" }, }, async () => ({ content: [] }), ); server.registerTool( "list", { description: "A read-only tool", inputSchema: {}, annotations: { readOnlyHint: true }, }, async () => ({ content: [] }), ); server.registerTool( "ping", { description: "An unannotated tool", inputSchema: {} }, async () => ({ content: [] }), ); return server; };