diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 36ecee0d..06ac28fa 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -24,6 +24,9 @@ export interface ServerInfo { tools: Map; resources: Map; appHtmlCache: Map; + /** Resolve this with the active AppBridge once one is created, so the + * early-registered elicitation handler can forward requests to it. */ + setBridge: (bridge: AppBridge) => void; } @@ -42,13 +45,20 @@ export async function connectToServer(serverUrl: URL): Promise { const resources = new Map(resourcesList.resources.map((r) => [r.uri, r])); log.info("Server resources:", Array.from(resources.keys())); - return { name, client, tools, resources, appHtmlCache: new Map() }; + const setBridge = AppBridge.setupElicitationForwarding(client); + + return { name, client, tools, resources, appHtmlCache: new Map(), setBridge }; } async function connectWithFallback(serverUrl: URL): Promise { + const clientOptions = { capabilities: { elicitation: {} } }; + // Try Streamable HTTP first (modern transport) try { - const client = new Client(IMPLEMENTATION); + const client = new Client(IMPLEMENTATION, clientOptions); + client.fallbackRequestHandler = async (request) => { + throw new Error(`Unhandled request: ${request.method}`); + }; await client.connect(new StreamableHTTPClientTransport(serverUrl)); log.info("Connected via Streamable HTTP transport"); return client; @@ -58,7 +68,10 @@ async function connectWithFallback(serverUrl: URL): Promise { // Fall back to SSE (deprecated but needed for older servers) try { - const client = new Client(IMPLEMENTATION); + const client = new Client(IMPLEMENTATION, clientOptions); + client.fallbackRequestHandler = async (request) => { + throw new Error(`Unhandled request: ${request.method}`); + }; await client.connect(new SSEClientTransport(serverUrl)); log.info("Connected via SSE transport"); return client; @@ -202,7 +215,7 @@ export function loadSandboxProxy( export async function initializeApp( iframe: HTMLIFrameElement, appBridge: AppBridge, - { input, resultPromise, appResourcePromise }: Required, + { input, resultPromise, appResourcePromise, serverInfo }: Required, ): Promise { const appInitializedPromise = hookInitializedCallback(appBridge); @@ -224,6 +237,8 @@ export async function initializeApp( await appInitializedPromise; log.info("MCP App initialized"); + serverInfo.setBridge(appBridge); + // Send tool call input to iframe log.info("Sending tool call input to MCP App:", input); appBridge.sendToolInput({ arguments: input }); diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index 2ff4979f..52b10d24 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import path from "node:path"; +import { randomUUID } from "node:crypto"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -33,22 +34,66 @@ export async function startStreamableHTTPServer( const port = parseInt(process.env.PORT ?? "3001", 10); const app = createMcpExpressApp({ host: "0.0.0.0" }); - app.use(cors()); + app.use( + cors({ + exposedHeaders: ["mcp-session-id"], + }), + ); + + // Stateful mode: one server + transport per session. + // Required for server-initiated requests (e.g. elicitation/create) which + // need the client's response to arrive on the same transport instance. + const sessions = new Map< + string, + { server: McpServer; transport: StreamableHTTPServerTransport } + >(); app.all("/mcp", async (req: Request, res: Response) => { - const server = createServer(); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - }); + // Check for existing session + const sessionId = req.headers["mcp-session-id"] as string | undefined; + let entry = sessionId ? sessions.get(sessionId) : undefined; + + if (!entry) { + // Only create a new session for initialize requests. + // Non-initialize requests without a session are invalid. + const body = req.body; + const isInitialize = + body && typeof body === "object" && body.method === "initialize"; + + if (!isInitialize) { + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session. Send initialize first.", + }, + id: null, + }); + return; + } + // New session — create server + transport. + // Pre-generate the session ID and store immediately so that the + // follow-up `notifications/initialized` POST (which arrives while + // the initialize handleRequest is still streaming) finds it. + const sid = randomUUID(); + const server = createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => sid, + }); - res.on("close", () => { - transport.close().catch(() => {}); - server.close().catch(() => {}); - }); + await server.connect(transport); + + transport.onclose = () => { + sessions.delete(sid); + server.close().catch(() => {}); + }; + + entry = { server, transport }; + sessions.set(sid, entry); + } try { - await server.connect(transport); - await transport.handleRequest(req, res, req.body); + await entry.transport.handleRequest(req, res, req.body); } catch (error) { console.error("MCP error:", error); if (!res.headersSent) { diff --git a/examples/pdf-server/mcp-app.html b/examples/pdf-server/mcp-app.html index c18345d2..2e49911c 100644 --- a/examples/pdf-server/mcp-app.html +++ b/examples/pdf-server/mcp-app.html @@ -19,6 +19,22 @@

An error occurred

+ + +