From 7f1aa1814d9ceb98190958d9193a814d5646252d Mon Sep 17 00:00:00 2001 From: Hryhorii Avcharov Date: Mon, 2 Mar 2026 16:58:25 +0100 Subject: [PATCH 1/4] feat: add elicitation/create prototype --- examples/basic-host/src/implementation.ts | 43 +++++++++++++-- examples/pdf-server/main.ts | 59 ++++++++++++++++---- examples/pdf-server/mcp-app.html | 16 ++++++ examples/pdf-server/server.ts | 64 ++++++++++++++++++++-- examples/pdf-server/src/mcp-app.css | 65 ++++++++++++++++++++++- examples/pdf-server/src/mcp-app.ts | 58 +++++++++++++++++++- src/app.ts | 38 +++++++++++++ src/types.ts | 4 ++ 8 files changed, 325 insertions(+), 22 deletions(-) diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 36ecee0d8..29d527e26 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -2,7 +2,7 @@ import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNo import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { CallToolResult, Resource, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { ElicitRequestSchema, ElicitResultSchema, type CallToolResult, type Resource, type Tool } from "@modelcontextprotocol/sdk/types.js"; import { getTheme, onThemeChange } from "./theme"; import { HOST_STYLE_VARIABLES } from "./host-styles"; @@ -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,38 @@ 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() }; + // Buffer elicitation/create requests until the AppBridge is ready. + // The promise is reset after each setBridge() call so it works across + // multiple sequential tool invocations. + let resolveBridge!: (bridge: AppBridge) => void; + let bridgePromise = new Promise((resolve) => { resolveBridge = resolve; }); + + const setBridge = (bridge: AppBridge) => { + resolveBridge(bridge); + bridgePromise = new Promise((resolve) => { resolveBridge = resolve; }); + }; + + client.setRequestHandler(ElicitRequestSchema, async (request, extra) => { + const bridge = await bridgePromise; + return bridge.request( + { method: "elicitation/create", params: request.params }, + ElicitResultSchema, + { signal: extra.signal }, + ); + }); + + 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 +86,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 +233,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 +255,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 2ff4979ff..31d6de774 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,58 @@ 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(); 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 c18345d20..2e49911cb 100644 --- a/examples/pdf-server/mcp-app.html +++ b/examples/pdf-server/mcp-app.html @@ -19,6 +19,22 @@

An error occurred

+ + +