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
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ Requires Node `>=22.19.0`.
npm install # root install; postinstall cascades into every client
```

This reference branch pins `@modelcontextprotocol/ext-apps` to commit
`89ab2bc` from
[ext-apps#733](https://github.com/modelcontextprotocol/ext-apps/pull/733).
No private package or registry credentials are required. Replace the GitHub
dependency with the first published ext-apps release that includes that PR.

- **Fresh clone:** run `npm install` at the repo root.
- **After a pull that changes a client's dependencies:** re-run `npm install` at the root to re-sync every client.

Expand Down Expand Up @@ -131,6 +137,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
| Config | Demonstrates | Issue |
| ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- |
| `modern-mrtr-http.json` | A single MRTR round-trip | — |
| `modern-app-elicitation-http.json` | Generic app-rendered MRTR confirmation | [#1854](https://github.com/modelcontextprotocol/inspector/issues/1854) |
| `mrtr-showcase-http.json` | Every MRTR preset in one server | — |
| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) |
| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) |
Expand Down Expand Up @@ -159,6 +166,68 @@ The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so

> The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there. MRTR is the modern replacement.

#### Generic app-rendered elicitation experiment

`modern-app-elicitation-http.json` is the smallest reusable, server-agnostic
SEP-3118 test path. It combines:

- `mrtr_app_confirm` — a modern tool whose first call returns `input_required`
with a complete native boolean schema, opaque `requestState`, and
`_meta.ui.resourceUri`.
- `mcp_app_elicitation_demo` — a self-contained
`text/html;profile=mcp-app` resource that advertises top-level
`appCapabilities.elicitation` and renders **Accept**, **Decline**, and
**Cancel** actions.

Start the fixture and Inspector in separate terminals:

```bash
cd clients/web
npm run test-servers:build
node ../../test-servers/build/server-composable.js \
--config ../../test-servers/configs/modern-app-elicitation-http.json

# Repository root, after npm run build
MCP_INSPECTOR_API_TOKEN=local-token MCP_AUTO_OPEN_ENABLED=false npm run web
```

In Inspector, add `http://localhost:3102/mcp`, set **Protocol Era** to
**Modern (2026-07-28, sessionless)**, then disconnect and reconnect. Run
`mrtr_confirm` from **Tools** with:

```json
{ "action": "publish demo" }
```

The App Elicitation modal should render the generic confirmation App. Choosing
an action sends a standard elicitation result; the Inspector retries the same
`tools/call` with unchanged arguments, a new JSON-RPC id, the echoed
`requestState`, and `inputResponses.confirm`. **Accept** includes
`content: { "confirm": true }`; **Decline** and **Cancel** send only their
action. The final tool result prints the response received by the server.

For protocol verification, the client capability envelope must include core
`elicitation.form` and
`extensions["io.modelcontextprotocol/ui"].elicitation`. The server must
advertise the matching nested MCP Apps capability. No second extension is used.
A legacy connection starts with `initialize`; modern requests carry the same
capabilities in their request-scoped envelope.

Interop is defined by the wire shapes, not by which package produced them:

| Request / bridge shape | Expected behavior |
| --- | --- |
| Both peers advertise the nested MCP Apps `elicitation` capability, the client also advertises core `elicitation.form`, the request has a valid `_meta.ui.resourceUri`, and the app/host advertise `elicitation` during `ui/initialize` | Render the App and bridge the standard elicitation result. |
| Either peer omits the nested MCP Apps elicitation capability | Use the complete native form. |
| The request omits a valid absolute `ui://` resource URI | Use the complete native form. |
| App-elicitation bridge missing either first-class App or host `elicitation` capability | Initialization or negotiation fails and the unchanged native form remains the fallback. |

Resource selection, request forwarding, bridge negotiation, result validation,
and fallback use only the standard elicitation request/result, the
`2026-07-28` MRTR retry fields, and the existing MCP Apps extension. The Apps
bridge still negotiates its own `2026-01-26` protocol version; that value is
independent of the core MCP protocol revision.

#### Network tab — standardized headers and error taxonomy

`modern-network-http.json` covers SEP-2243 / SEP-2575. It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation, so a modern client mirrors it to `Mcp-Param-City`.
Expand Down
23 changes: 23 additions & 0 deletions clients/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,29 @@ The Apps screen exposes a small, stable set of `data-testid` / `data-*` attribut

The renderer lifecycle itself is `AppRendererStatus` (`loading` | `ready` | `error`) reported via `AppRenderer`'s `onAppStatusChange`; the screen maps it to `data-app-status`. Resource-read failures (malformed/404 UI resource) are surfaced as a toast via the bridge factory's `onResourceError`; because the app never reaches `ready` in that case, a driver times out on `data-app-status` and reads the toast.

## MCP Apps-rendered elicitations

Form elicitations carrying `_meta.ui.resourceUri: "ui://..."` are routed through
the same Apps sandbox and bridge stack as the Apps screen. The
`WebAppElicitationHost` queue binds each iframe session to the originating SDK
client, complete `elicitation/create` request, and resource URI; the app receives
that unchanged request over the bridge. The resulting standard elicitation
response is schema-validated before the Inspector retries MRTR or answers a
legacy request. Resource loading, initialization, capability negotiation,
bridge, or result-validation failures close the app session and surface the
unchanged complete request in the native elicitation modal.

This reference branch uses the first-class APIs from
[ext-apps#733](https://github.com/modelcontextprotocol/ext-apps/pull/733).
Inspector advertises the nested `io.modelcontextprotocol/ui.elicitation`
capability only when the web sandbox host is available, verifies the matching
server capability, and calls `AppBridge.requestElicitation` on the bridge bound
to the originating request. CLI and TUI clients do not advertise app-rendered
elicitation support.

The active modal exposes `data-app-elicitation-status="loading|ready|error"` on
its content stack for browser automation.

## Deep-link auto-connect

A driver (launcher, CLI `--print-handoff`, CI review harness) can reach a **connected** inspector with a single navigate by encoding the target in the URL query string. Parsing + security gating live in `src/utils/deepLink.ts` (`parseDeepLink`), and a returned `DeepLink` is proof the link passed validation.
Expand Down
7 changes: 3 additions & 4 deletions clients/web/package-lock.json

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

2 changes: 1 addition & 1 deletion clients/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"@mantine/notifications": "^8.3.17",
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@modelcontextprotocol/core": "2.0.0-beta.5",
"@modelcontextprotocol/ext-apps": "^1.7.4",
"@modelcontextprotocol/ext-apps": "github:krubenok/ext-apps#89ab2bc",
"@modelcontextprotocol/server": "2.0.0-beta.5",
"@modelcontextprotocol/server-legacy": "2.0.0-beta.5",
"@napi-rs/keyring": "^1.3.0",
Expand Down
124 changes: 97 additions & 27 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ import {
PendingClientRequestModal,
type PendingClientRequestContent,
} from "./components/groups/PendingClientRequestModal/PendingClientRequestModal";
import { AppElicitationModal } from "./components/groups/AppElicitationModal/AppElicitationModal";
import {
WebAppElicitationHost,
type PendingAppElicitation,
} from "./lib/appElicitationHost";
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs";
import { enrichProtocolEntries } from "./utils/correlateTransportErrors";
Expand Down Expand Up @@ -720,13 +725,35 @@ function App() {
// next switch happens (or when the component unmounts).
const [inspectorClient, setInspectorClient] =
useState<InspectorClient | null>(null);
const appRendererRef = useRef<AppRendererHandle>(null);
const appsScreenBridgeActiveRef = useRef(false);
const [appElicitationHost] = useState(
() =>
new WebAppElicitationHost(
undefined,
() => !appsScreenBridgeActiveRef.current,
),
);
const [pendingAppElicitations, setPendingAppElicitations] = useState<
readonly PendingAppElicitation[]
>([]);

useEffect(() => {
const update = () =>
setPendingAppElicitations([...appElicitationHost.getPending()]);
update();
appElicitationHost.addEventListener("change", update);
return () => {
appElicitationHost.removeEventListener("change", update);
appElicitationHost.clear("Inspector closed");
};
}, [appElicitationHost]);

// MCP Apps runtime wiring. `sandboxUrl` is the inspector's sandbox-proxy page
// (the trusted outer iframe); `appRendererRef` lets the app handlers push tool
// input/result into the running app and tear it down. The bridge factory wraps
// the active client's underlying SDK client so the running view can call the
// server, and reads the tool's UI resource into the sandbox on handshake.
const appRendererRef = useRef<AppRendererHandle>(null);
const configBaseUrl =
typeof window !== "undefined" ? window.location.origin : "http://localhost";
// One `GET /api/config` fetch recovers every static payload field the app
Expand Down Expand Up @@ -763,31 +790,54 @@ function App() {
});
}, [configBaseUrl]);

const sandboxBridgeFactory = useMemo(
() =>
createAppBridgeFactory({
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
readResource: async (uri) => {
if (!inspectorClient) throw new Error("No MCP client connected.");
const invocation = await inspectorClient.readResource(uri);
return invocation.result;
},
// The bridge's sandboxready handler reads + posts the UI resource
// inside a detached async block; without this hook a 404 / malformed
// resource is console.error-only and the user stares at a blank
// frame. Surface it as a toast. The renderer separately drives
// `data-app-status` so an automated driver can time out on
// never-reaching-"ready" and read the toast.
onResourceError: (err) => {
notifications.show({
title: "App resource failed to load",
message: err.message,
color: "red",
});
},
}),
[inspectorClient],
);
const sandboxBridgeFactory = useMemo(() => {
const createBridge = createAppBridgeFactory({
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
readResource: async (uri) => {
if (!inspectorClient) throw new Error("No MCP client connected.");
const invocation = await inspectorClient.readResource(uri);
return invocation.result;
},
// The bridge's sandboxready handler reads + posts the UI resource
// inside a detached async block; without this hook a 404 / malformed
// resource is console.error-only and the user stares at a blank
// frame. Surface it as a toast. The renderer separately drives
// `data-app-status` so an automated driver can time out on
// never-reaching-"ready" and read the toast.
onResourceError: (err) => {
notifications.show({
title: "App resource failed to load",
message: err.message,
color: "red",
});
},
});
return async (...args: Parameters<typeof createBridge>) => {
if (appElicitationHost.getPending().length > 0) {
throw new Error(
"An MCP App elicitation is already running on this connection",
);
}
appsScreenBridgeActiveRef.current = true;
let bridge: Awaited<ReturnType<typeof createBridge>>;
try {
bridge = await createBridge(...args);
} catch (error) {
appsScreenBridgeActiveRef.current = false;
throw error;
}
const closable = bridge as typeof bridge & { close(): Promise<void> };
const close = closable.close.bind(bridge);
closable.close = async () => {
try {
await close();
} finally {
appsScreenBridgeActiveRef.current = false;
}
};
return bridge;
};
}, [appElicitationHost, inspectorClient]);

const [managedToolsState, setManagedToolsState] =
useState<ManagedToolsState | null>(null);
Expand Down Expand Up @@ -1275,6 +1325,7 @@ function App() {
useEffect(() => {
if (!inspectorClient) return;
const onDisconnect = () => {
appElicitationHost.clear("MCP connection disconnected");
setActiveServerId(undefined);
// Drop the open flag too — without this the modal would pop back the
// next time `initializeResult` re-becomes truthy (e.g. reconnect).
Expand All @@ -1285,7 +1336,7 @@ function App() {
return () => {
inspectorClient.removeEventListener("disconnect", onDisconnect);
};
}, [inspectorClient, resetSessionScopedUiState]);
}, [appElicitationHost, inspectorClient, resetSessionScopedUiState]);

// Surface incoming `notifications/progress` as toasts so the user can watch a
// long-running tool's progress while staying on the tool view — the v2
Expand Down Expand Up @@ -2320,6 +2371,7 @@ function App() {
...(activeCimdUrl && { clientMetadataUrl: activeCimdUrl }),
}
: undefined;
appElicitationHost.clear("Inspector switched MCP connections");
const client = new InspectorClient(server.config, {
environment,
// The Tasks tab needs the receiver-task pipeline; the
Expand All @@ -2328,6 +2380,19 @@ function App() {
// Sampling / elicitation are on by default; keep the parameterized
// options off until the UI grows the surface to render them.
elicit: { form: true, url: true },
appElicitation: {
host: appElicitationHost,
onError: (error) => {
const message =
error instanceof Error ? error.message : String(error);
notifications.show({
title: "MCP App elicitation fell back to the native form",
message,
color: "yellow",
autoClose: false,
});
},
},
// Always advertise the roots capability (even with no configured
// roots) so the server can issue roots/list and receive
// roots/list_changed; the configured roots are the answer to
Expand Down Expand Up @@ -2429,6 +2494,7 @@ function App() {
sessionStorageAdapter,
onBeforeOAuthRedirect,
clientConfig,
appElicitationHost,
],
);

Expand Down Expand Up @@ -4615,6 +4681,10 @@ function App() {
onSamplingReject={onSamplingReject}
onElicitationRespond={onElicitationRespond}
/>
<AppElicitationModal
request={pendingAppElicitations[0] ?? null}
sandboxPath={sandboxUrl}
/>
<StepUpAuthModal
opened={
pendingStepUp !== null && pendingStepUp.serverId === activeServerId
Expand Down
Loading