Skip to content
Draft
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
42 changes: 42 additions & 0 deletions examples/apps-elicitation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# apps-elicitation

A self-verifying `2026-07-28` example of using an MCP App as the UI for a
standard form elicitation.

The client and server negotiate `elicitation: {}` as a setting of the existing
`io.modelcontextprotocol/ui` extension. The server returns an
`InputRequiredResult` whose `inputRequests["delivery-window"]` entry is an
ordinary `elicitation/create` request with:

- a complete `requestedSchema` for native fallback; and
- `_meta.ui.resourceUri` pointing to a `text/html;profile=mcp-app` resource.

The headless client resolves that resource through the same MCP connection and
returns a standard `ElicitResult`. The SDK then retries the original
`tools/call` with the result under the matching `inputResponses` key. A
graphical host replaces the deterministic selection with an MCP Apps bridge;
the MRTR wire flow is unchanged.

The HTTP/modern leg is stateless: `createMcpHandler(buildServer)` constructs a
fresh server for each request, so the elicitation response reaches the retried
`tools/call` entirely through the MRTR `inputResponses` envelope rather than
in-memory session state. The examples runner exercises both this HTTP leg and
the stdio leg.

No app-elicitation extension, custom method, or custom result type is used.
MCP Apps continues to negotiate its View↔Host protocol independently of core
MCP's `2026-07-28` revision.

```bash
# stdio
pnpm tsx examples/apps-elicitation/client.ts

# HTTP (two terminals)
pnpm tsx examples/apps-elicitation/server.ts --http --port 3000
pnpm tsx examples/apps-elicitation/client.ts --http http://127.0.0.1:3000/mcp
```

Related proposals:

- [MCP Apps draft PR #733](https://github.com/modelcontextprotocol/ext-apps/pull/733)
- [SEP-3118](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3118)
66 changes: 66 additions & 0 deletions examples/apps-elicitation/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Headless host for the app-rendered elicitation server.
*
* A graphical host would initialize the resource as an MCP App and forward
* the unchanged `elicitation/create` request over its App bridge. This
* deterministic example exercises the surrounding SDK contract: two-sided
* extension negotiation, same-connection resource loading, and the automatic
* MRTR retry with `inputResponses`. On the HTTP leg, that retry reaches a fresh
* per-request server instance, demonstrating that the flow is stateless.
*/
import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared';
import type { ElicitRequest } from '@modelcontextprotocol/client';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

const APPS_EXTENSION_ID = 'io.modelcontextprotocol/ui';
const APP_MIME_TYPE = 'text/html;profile=mcp-app';

const { transport, url } = parseExampleArgs();

const client = new Client(
{ name: 'apps-elicitation-example-client', version: '1.0.0' },
{
versionNegotiation: { mode: { pin: '2026-07-28' } },
capabilities: {
elicitation: { form: {} },
extensions: {
[APPS_EXTENSION_ID]: {
mimeTypes: [APP_MIME_TYPE],
elicitation: {}
}
}
}
}
);

client.setRequestHandler('elicitation/create', async request => {
Comment thread
krubenok marked this conversation as resolved.
const typed = request as ElicitRequest;
check.equal(typed.params.mode, 'form');

const serverApps = client.getServerCapabilities()?.extensions?.[APPS_EXTENSION_ID] as Record<string, unknown> | undefined;
check.ok(serverApps?.['elicitation'] !== undefined, 'server must advertise MCP Apps elicitation');

const resourceUri = (typed.params._meta?.['ui'] as { resourceUri?: unknown } | undefined)?.resourceUri;
check.ok(typeof resourceUri === 'string' && resourceUri.startsWith('ui://'), 'request must bind an MCP App resource');

const resource = await client.readResource({ uri: resourceUri as string });
const html = resource.contents.find(content => 'text' in content && content.mimeType === APP_MIME_TYPE);
check.ok(html !== undefined && 'text' in html && html.text.includes('Choose a delivery window'));

// A real host forwards the unchanged request to the initialized App and
// validates the returned ElicitResult. The example chooses deterministically.
return { action: 'accept', content: { window: 'morning' } };
});

await client.connect(
transport === 'stdio'
? new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })
: new StreamableHTTPClientTransport(new URL(url))
);

const result = await client.callTool({ name: 'schedule_delivery', arguments: {} });
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '';
check.equal(text, 'scheduled:morning');

await client.close();
23 changes: 23 additions & 0 deletions examples/apps-elicitation/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@mcp-examples/apps-elicitation",
"private": true,
"type": "module",
"scripts": {
"server": "tsx server.ts",
"client": "tsx client.ts"
},
"dependencies": {
"@hono/node-server": "catalog:runtimeServerOnly",
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/hono": "workspace:*",
"@modelcontextprotocol/server": "workspace:*"
},
"devDependencies": {
"tsx": "catalog:devTools"
},
"example": {
"era": "modern",
"//": "App-rendered elicitation uses the 2026-07-28 MRTR flow."
}
}
127 changes: 127 additions & 0 deletions examples/apps-elicitation/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* App-rendered form elicitation over the 2026-07-28 MRTR flow.
*
* The server and client negotiate one feature of the existing
* `io.modelcontextprotocol/ui` extension. The tool returns an embedded
* `elicitation/create` request with a complete native schema and an optional
* `_meta.ui.resourceUri`; no second extension or result type is introduced.
* The HTTP entry uses a per-request server factory, so its MRTR retry is
* stateless and carries the answer only through `inputResponses`.
*/
import { serve } from '@hono/node-server';
import { parseExampleArgs } from '@mcp-examples/shared';
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
import type { CallToolResult, ClientCapabilities, InputRequiredResult } from '@modelcontextprotocol/server';
import { acceptedContent, CLIENT_CAPABILITIES_META_KEY, createMcpHandler, inputRequired, McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';

const APPS_EXTENSION_ID = 'io.modelcontextprotocol/ui';
const APP_MIME_TYPE = 'text/html;profile=mcp-app';
const APP_URI = 'ui://delivery/choose-window.html';

const APP_HTML = `<!doctype html>
<html>
<body>
<main>
<h1>Choose a delivery window</h1>
<button data-window="morning">Morning</button>
<button data-window="afternoon">Afternoon</button>
</main>
</body>
</html>`;

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}

function supportsAppElicitation(capabilities: ClientCapabilities | undefined): boolean {
const apps = capabilities?.extensions?.[APPS_EXTENSION_ID];
if (!isRecord(apps)) return false;
return (
capabilities?.elicitation?.form !== undefined &&
Array.isArray(apps['mimeTypes']) &&
apps['mimeTypes'].includes(APP_MIME_TYPE) &&
isRecord(apps['elicitation'])
);
}

function buildServer(): McpServer {
const mcp = new McpServer({ name: 'apps-elicitation-example-server', version: '1.0.0' });

mcp.server.registerCapabilities({
extensions: {
[APPS_EXTENSION_ID]: { elicitation: {} }
}
});

mcp.registerResource(
'delivery-window-app',
APP_URI,
{
description: 'Self-contained MCP App for choosing a delivery window',
mimeType: APP_MIME_TYPE
},
async uri => ({
contents: [{ uri: uri.href, mimeType: APP_MIME_TYPE, text: APP_HTML }]
})
);

mcp.registerTool(
'schedule_delivery',
{ description: 'Schedules a delivery after the user chooses a window' },
async (ctx): Promise<CallToolResult | InputRequiredResult> => {
const response = acceptedContent<{ window: string }>(ctx.mcpReq.inputResponses, 'delivery-window');
if (response?.window) {
return {
content: [{ type: 'text', text: `scheduled:${response.window}` }]
};
}

const requestCapabilities = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined;
const useApp = supportsAppElicitation(requestCapabilities);
return inputRequired({
inputRequests: {
'delivery-window': inputRequired.elicit({
message: 'Choose a delivery window',
requestedSchema: {
type: 'object',
properties: {
window: {
type: 'string',
oneOf: [
{ const: 'morning', title: 'Morning' },
{ const: 'afternoon', title: 'Afternoon' }
]
}
},
required: ['window']
},
...(useApp && {
_meta: {
ui: { resourceUri: APP_URI }
}
})
})
}
});
}
);

return mcp;
}

const { transport, port } = parseExampleArgs();

if (transport === 'stdio') {
void serveStdio(buildServer);
console.error('[server] serving over stdio');
} else {
// The modern HTTP path creates a fresh server for every request; the
// elicitation result survives the MRTR boundary only in inputResponses.
const handler = createMcpHandler(buildServer);
const app = createMcpHonoApp();
app.all('/mcp', c => handler.fetch(c.req.raw));
serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
}
22 changes: 22 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading