inspector/docs/mcp-app-review.md
cliffhall fafc8367b8
fix(web): move the app origin off 6276, bound the publish route
Copilot review round 2 on #2100, plus a collision the review did not find.

The default app-origin port was 6276 — which is already
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT, the CLI/TUI loopback OAuth callback.
That port is fixed precisely so an OAuth app can pre-register
http://127.0.0.1:6276/oauth/callback, which makes it the one listener here
that cannot move when something takes it first. A running --web would hold
it and break a later --cli/--tui OAuth flow at its registered redirect URI,
and docs/mcp-app-review.md has those two running side by side. This
listener has a documented dynamic fallback, so this listener yields: the
default is now 6278 (6277 skipped — v1's retired proxy port, which the
migration guide tells people to stop forwarding). A test asserts the two
defaults stay distinct.

Review comments:

- POST /api/app-document now applies hono's bodyLimit BEFORE parsing. The
  8MiB check ran after c.req.json() had buffered and parsed the whole
  request, and bounded only `html` — so bulk in `csp`, or in keys the route
  never reads, was fully materialized before the 413.
- `csp` is validated as an HTTP header value and length-bounded. The
  controller hands it to res.writeHead() verbatim, where Node throws
  ERR_INVALID_CHAR synchronously — inside the handler for a later,
  unrelated request, outside this route's error handling.
- The app-origin port now propagates everywhere the other two do: the CLI
  --print-handoff port-forward command (and its test), the Dockerfile's ENV
  and EXPOSE, and the remote-review SSH recipe in docs/mcp-app-review.md.
  Without it an app declaring _meta.ui.domain is unreachable through every
  documented remote workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 08:45:40 -04:00

215 lines
8.8 KiB
Markdown

# Reviewing an MCP App with the Inspector
This recipe is for automated reviewers (CI, agents) that need to inspect an MCP
server's **App tools** — the tools that bind a UI widget to a tool result via
`_meta.ui.resourceUri`. It uses the **CLI for everything that has a JSON
answer** and drops to a browser **only** to render the widget itself.
The flow: probe with `--app-info` → build a deep link → drive the rendered
widget, with no manual form-filling in between. It is the composition of the
Wave-2 (spec-conformant Apps host) and Wave-3/4 (programmatic review path)
work; each surface below links to the client README that owns its full contract.
## 1. Probe: does the tool have an App, and what is its security posture?
```bash
npx @modelcontextprotocol/inspector --cli \
--transport http --server-url https://example.com/mcp \
--method tools/call --tool-name <tool> --app-info
```
Stdout is one JSON line; the process exits **0** when the tool has an App and
**2** (`no_app`) when it does not, so a shell `&&` chain can short-circuit.
Example output for an App tool:
```json
{
"hasApp": true,
"toolName": "get_pros",
"resourceUri": "ui://pros/view.html",
"csp": { "connectDomains": ["https://api.example.com"] },
"permissions": { "clipboard": false },
"prefersBorder": true,
"resourceMimeType": "text/html"
}
```
`csp` / `permissions` / `domain` come from the UI **resource** (per the spec
they live on the resource, not the tool); `--app-info` reads the resource for
you. Nothing here invokes the tool. To probe every tool at once, use
`--method tools/list --app-info` (NDJSON, one line per tool, single
connection). See [clients/cli/README.md](../clients/cli/README.md) for the full
flag reference and exit-code map.
## 2. Full result payload (still no browser)
```bash
npx @modelcontextprotocol/inspector --cli \
--transport http --server-url https://example.com/mcp \
--method tools/call --tool-name <tool> --tool-args-json '{"zip":"10001"}'
```
Stdout is the pretty-printed `CallToolResult`; for App tools an
`--- MCP App Info ---` block is appended. Add `--format json` to emit a single
`{ "result": …, "appInfo": … }` object that pipes cleanly into `jq`.
`--tool-args-json` passes the arguments verbatim (no `key=value` coercion), so
`"012"` stays a string.
## 3. Launch the web inspector once, loopback-only
```bash
TOKEN="$(openssl rand -hex 24)"
HOST=127.0.0.1 CLIENT_PORT=6274 MCP_SANDBOX_PORT=6275 \
MCP_AUTO_OPEN_ENABLED=false MCP_INSPECTOR_API_TOKEN="$TOKEN" \
npx @modelcontextprotocol/inspector --web &
```
`HOST=127.0.0.1` binds the API and sandbox servers to loopback only. The token
is also the deep-link auth gate (next step).
## 4. One deep-link navigate → a rendered widget
The deep-link URL shape (owned by
[clients/web/README.md](../clients/web/README.md#deep-link-auto-connect)):
```
http://127.0.0.1:6274/?serverUrl=<url-encoded server URL>
&transport=http|sse
&autoConnect=<TOKEN>
&openApp=<tool name>
&appArgs=<base64url(JSON args)>
&autoOpen=<TOKEN>
```
`autoConnect` **must equal** `MCP_INSPECTOR_API_TOKEN`; the page rejects any
other value, so a link minted by a third party cannot drive the connect.
Loading the URL connects to the server, switches to the **Apps** tab, selects
`openApp`, and pre-fills `appArgs` (merged **over** the tool's schema defaults,
so a required-with-default field doesn't leave "Open App" disabled).
`appArgs` is `base64url(JSON)`:
```js
const args = Buffer.from(JSON.stringify({ zip: "10001" }))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
```
`autoOpen=<TOKEN>` (same token gate) fires "Open App" automatically — a tool
call from a URL, which is why it is gated. Omit it to stop at the pre-filled
form and click "Open App" yourself.
The inspector exposes a stable `data-testid` / `data-*` contract (documented in
[clients/web/README.md](../clients/web/README.md)) so a driver waits on
deterministic signals instead of sleeping:
```js
// Playwright (or any driver). With autoOpen set, no click is needed.
const url =
`http://127.0.0.1:6274/?serverUrl=${encodeURIComponent(serverUrl)}` +
`&transport=http&autoConnect=${TOKEN}` +
`&openApp=${tool}&appArgs=${args}&autoOpen=${TOKEN}`;
await page.goto(url);
// Connection surface: poll data-status; read data-error-message on failure.
await page.waitForSelector(
'[data-testid="connection-status"][data-status="connected"]',
);
// App render lifecycle: idle → loading → ready (or error).
await page.waitForSelector('[data-app-status="ready"]');
await page.locator('[data-testid="apps-form"]').screenshot({ path: out });
```
Without `autoOpen`, click the button first:
`await page.click('[data-testid="open-app"]')`, then wait on
`[data-app-status="ready"]`. If the connect is rejected (bad token / non-http
serverUrl), `[data-testid="connection-status"]` carries
`data-deeplink="rejected"` and `data-error-message` — read those instead of
scraping a toast.
## 5. OAuth-gated servers
When the target server requires OAuth, complete the flow once in the **web**
inspector — the credential is written through the backend to
`~/.mcp-inspector/storage/oauth.json`, so the **CLI** on the same machine can
reuse it:
```bash
npx @modelcontextprotocol/inspector --cli \
--transport http --server-url "$SERVER_URL" \
--use-stored-auth --method tools/list
```
`--use-stored-auth` looks up the stored state for `$SERVER_URL` (keyed by
`new URL().href` normalization, so pass the same URL you gave the web
inspector). When a `refresh_token` is stored it runs the OAuth refresh grant
and injects the **fresh** access token, persisting the rotation back to the
state file; otherwise it injects the stored access token. A failed refresh
(revoked token) exits `3` (`auth_required`). Use `--wait-for-auth <sec>` to
block until a human completes the flow in a browser, and `--print-handoff` to
emit the deep link + port-forward command a remote VM needs (see
[clients/cli/README.md](../clients/cli/README.md)).
Connection state persists in `~/.mcp-inspector/` between runs. For a clean
slate between reviews of different servers, `rm -rf ~/.mcp-inspector` before
launching.
## 6. Reaching the web inspector remotely (SSH port-forward)
When the backend runs on another host and you want to complete OAuth or
visually inspect a rendered App from your local browser, forward **all three**
ports:
```bash
ssh -L 6274:127.0.0.1:6274 -L 6275:127.0.0.1:6275 -L 6278:127.0.0.1:6278 <remote-host>
```
Then open `http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=$TOKEN` locally.
- Use `127.0.0.1` or `localhost` — the default origin allow-list emits both (and
`[::1]`) for a loopback bind (#1795), so the guard accepts either, whichever
the browser sends. **Do not** use a bare `http://[::1]:…` URL for App review:
it connects, but a bracketed IPv6 literal isn't a valid CSP `frame-ancestors`
source, so the App sandbox iframe is CSP-blocked and the widget never renders
(see the MCP Apps caveat in [`clients/web/README.md`](../clients/web/README.md#host-binding--the-origin-allow-list)).
A **non-loopback** forward target needs `ALLOWED_ORIGINS` set to that origin.
- Forward `:6275` (`MCP_SANDBOX_PORT`) as well: the Apps tab renders widgets in
an iframe served from that second origin; without it the App frame stays
blank.
- Forward `:6278` (`MCP_APP_ORIGIN_PORT`) too, for an App whose UI resource
declares `_meta.ui.domain` (#2056). That App is served from a *third* origin
the browser loads directly, so without the forward its frame fails to load —
and the backend cannot detect that, since publishing succeeded on its side.
An App that declares no `domain` is unaffected.
- Forward the **same** local port number you bind on the remote — the browser
sends `Origin: http://127.0.0.1:<local-port>` and the backend compares it to
its own port.
- The OAuth credential ends up in `~/.mcp-inspector/storage/oauth.json` on the
**remote** host, so a CLI invocation there can pick it up with
`--use-stored-auth` (§ 5).
## 7. Reaching the target server through an HTTP proxy
The CLI's outbound connections honor the standard `HTTPS_PROXY` / `HTTP_PROXY`
/ `NO_PROXY` environment variables:
```bash
HTTPS_PROXY=http://proxy.example:3128 \
npx @modelcontextprotocol/inspector --cli \
--transport http --server-url "$SERVER_URL" --method tools/list
```
Per-scheme routing and `NO_PROXY` exclusions are handled by undici's
`EnvHttpProxyAgent`, imported lazily only when a proxy variable is set — runs
without a proxy pay no cost. See
[clients/cli/README.md](../clients/cli/README.md#http-proxy-support).
## Local fixture
The bundled test server includes an `mcp_app_demo` tool (presets `mcp_app_demo`
and `mcp_app_demo_widget`) whose widget reports `size-changed`, sends one
`ui/message`, emits one log notification, and renders its received `hostContext`
— useful for verifying the host side of the UI protocol end-to-end without a
remote server.