Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions examples/basic-host/src/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export interface ServerInfo {
tools: Map<string, Tool>;
resources: Map<string, Resource>;
appHtmlCache: Map<string, string>;
/** 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;
}


Expand All @@ -42,13 +45,20 @@ export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
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<Client> {
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;
Expand All @@ -58,7 +68,10 @@ async function connectWithFallback(serverUrl: URL): Promise<Client> {

// 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;
Expand Down Expand Up @@ -202,7 +215,7 @@ export function loadSandboxProxy(
export async function initializeApp(
iframe: HTMLIFrameElement,
appBridge: AppBridge,
{ input, resultPromise, appResourcePromise }: Required<ToolCallInfo>,
{ input, resultPromise, appResourcePromise, serverInfo }: Required<ToolCallInfo>,
): Promise<void> {
const appInitializedPromise = hookInitializedCallback(appBridge);

Expand All @@ -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 });
Expand Down
67 changes: 56 additions & 11 deletions examples/pdf-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions examples/pdf-server/mcp-app.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@
<p id="error-message">An error occurred</p>
</div>

<!-- Elicitation State: app asks user for input on behalf of the server -->
<div id="elicitation" class="elicitation" style="display: none">
<p id="elicitation-message" class="elicitation-message"></p>
<input
id="elicitation-url"
type="url"
class="elicitation-input"
placeholder="https://arxiv.org/pdf/1706.03762"
autocomplete="url"
/>
<div class="elicitation-actions">
<button id="elicitation-submit" class="elicitation-btn elicitation-btn-primary">Open PDF</button>
<button id="elicitation-cancel" class="elicitation-btn elicitation-btn-secondary">Cancel</button>
</div>
</div>

<!-- PDF Viewer -->
<div id="viewer" class="viewer" style="display: none">
<!-- Toolbar -->
Expand Down
68 changes: 61 additions & 7 deletions examples/pdf-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import {
ElicitResultSchema,
RootsListChangedNotificationSchema,
type CallToolResult,
type ReadResourceResult,
Expand Down Expand Up @@ -613,17 +614,14 @@ export function createServer(options: CreateServerOptions = {}): McpServer {
"display_pdf",
{
title: "Display PDF",
description: `Display an interactive PDF viewer.
description: `Display an interactive PDF viewer. If no URL is provided, the viewer will ask the user to enter one.

Accepts:
- Local files explicitly added to the server (use list_pdfs to see available files)
- Local files under directories provided by the client as MCP roots
- Any remote PDF accessible via HTTPS`,
inputSchema: {
url: z
.string()
.default(DEFAULT_PDF)
.describe("PDF URL or local file path"),
url: z.string().optional().describe("PDF URL or local file path"),
page: z.number().min(1).default(1).describe("Initial page"),
},
outputSchema: z.object({
Expand All @@ -633,8 +631,64 @@ Accepts:
}),
_meta: { ui: { resourceUri: RESOURCE_URI } },
},
async ({ url, page }): Promise<CallToolResult> => {
const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
async ({ url, page }, extra): Promise<CallToolResult> => {
let resolvedUrl = url;

if (!resolvedUrl) {
// Use extra.sendRequest() (session-scoped) so elicitation is routed to
// the correct transport in stateful multi-session mode.
let elicitResult;
try {
elicitResult = await extra.sendRequest(
{
method: "elicitation/create",
params: {
message: "Enter the URL of the PDF you want to view",
requestedSchema: {
type: "object" as const,
properties: {
url: {
type: "string" as const,
title: "PDF URL",
description:
"Any remote PDF accessible via HTTPS, or a local file path",
},
},
required: ["url"],
},
},
},
ElicitResultSchema,
);
} catch (err) {
return {
content: [
{
type: "text",
text: `Could not request URL from app: ${err instanceof Error ? err.message : String(err)}\n\nPlease provide a URL directly.`,
},
],
isError: true,
};
}

if (elicitResult.action !== "accept" || !elicitResult.content?.url) {
return {
content: [
{
type: "text",
text: "No PDF URL provided. Please try again with a URL.",
},
],
};
}

resolvedUrl = String(elicitResult.content.url);
}

const normalized = isArxivUrl(resolvedUrl)
? normalizeArxivUrl(resolvedUrl)
: resolvedUrl;
const validation = validateUrl(normalized);

if (!validation.valid) {
Expand Down
65 changes: 64 additions & 1 deletion examples/pdf-server/src/mcp-app.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ body {
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
flex: 1;
gap: 1rem;
padding: 2rem;
text-align: center;
Expand All @@ -87,6 +87,7 @@ body {
#error-message {
color: var(--text200);
max-width: 400px;
text-align: center;
}

/* Viewer Container */
Expand Down Expand Up @@ -468,3 +469,65 @@ body {
.loading-indicator.error .loading-indicator-arc {
stroke: #e74c3c;
}

/* Elicitation State: shown when the server asks the app for user input */
.elicitation {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 1rem;
padding: 2rem;
}

.elicitation-message {
color: var(--text100);
text-align: center;
max-width: 360px;
}

.elicitation-input {
width: 100%;
max-width: 360px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--bg200);
border-radius: 6px;
background: var(--bg100);
color: var(--text100);
font-size: 0.85rem;
outline: none;
}

.elicitation-input:focus {
border-color: var(--text200);
}

.elicitation-actions {
display: flex;
gap: 0.5rem;
}

.elicitation-btn {
padding: 0.4rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
border: 1px solid transparent;
transition: opacity 0.15s;
}

.elicitation-btn:hover {
opacity: 0.85;
}

.elicitation-btn-primary {
background: var(--text100);
color: var(--bg100);
}

.elicitation-btn-secondary {
background: transparent;
color: var(--text200);
border-color: var(--bg200);
}
Loading
Loading