web+cli+docs: Wave 4 — deep-link auto-connect + programmatic MCP App review (rollup of #1576–#1578, #1665, #1666) (#1676)

web+cli+docs: Wave 4 — deep-link auto-connect + programmatic MCP App review (rollup of #1576–#1578, #1665, #1666)
This commit is contained in:
Cliff Hall 2026-07-12 00:31:35 -04:00 • committed by GitHub
commit e2a97c1b8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1634 additions and 62 deletions

View file

@ -44,6 +44,8 @@ inspector/
Each client has its own README with client-specific detail:
[web](./clients/web/README.md) · [cli](./clients/cli/README.md) · [tui](./clients/tui/README.md) · [launcher](./clients/launcher/README.md).
Task-oriented guides live under [`docs/`](./docs) — see [Reviewing an MCP App](./docs/mcp-app-review.md), the CLI-first → one-shot-web recipe for automated App-tool review: `--app-info` probe → deep-link navigate → rendered widget, plus OAuth handoff and proxy support.
## Setup
Requires Node `>=22.19.0`.
@ -120,13 +122,13 @@ Individual clients: `build:web`, `build:cli`, `build:tui`, `build:launcher`. The
Each client self-validates from its own folder; the root scripts chain them. There is **no** aggregate root `test` script — use `validate` (fast) or `coverage` (the gate).
| Script | What it does |
| --- | --- |
| `npm run validate` | `format:check` + `lint` + `build` + fast unit tests, per client. The quick inner-loop check. |
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project. |
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web). |
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `smoke` → Storybook. A true superset of GitHub CI. |
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |
| Script | What it does |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run validate` | `format:check` + `lint` + `build` + fast unit tests, per client. The quick inner-loop check. |
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project. |
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web). |
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `smoke` → Storybook. A true superset of GitHub CI. |
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |
Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …). Run `npm run format` (per client) before committing — `validate` runs the non-fixing `format:check` and fails CI on any unformatted file.

View file

@ -193,18 +193,20 @@ For the common case where OAuth was already completed in the **web inspector on
| Option | Description |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--use-stored-auth` | Read the stored access token for `--server-url` and inject it as `Authorization: Bearer`. Exits `3` (`no_stored_token`) — listing the stored keys — when no token matches. Requires `--server-url`. |
| `--wait-for-auth <sec>` | Poll the OAuth state file (500 ms interval) until a token for `--server-url` appears, then proceed as if `--use-stored-auth` were set. Times out at `<sec>` with exit `3` (`auth_wait_timeout`). Use after handing off to a human to complete OAuth in a browser. |
| `--use-stored-auth` | Read the stored auth for `--server-url` and inject `Authorization: Bearer`. When a `refresh_token` is stored, the CLI runs the OAuth refresh grant first and injects the **fresh** access token (persisting the rotation); otherwise it injects the stored access token. Exits `3` (`no_stored_token`) — listing the stored keys — when nothing matches. Requires `--server-url`. |
| `--wait-for-auth <sec>` | Poll the OAuth state file (500 ms interval) until an access token for `--server-url` appears, then inject it. Times out at `<sec>` with exit `3` (`auth_wait_timeout`). Use after handing off to a human to complete OAuth in a browser. Unlike `--use-stored-auth`, this injects the freshly-landed access token directly (a token that just completed the browser flow is not expired), so it does **not** run the refresh grant. |
| `--list-stored-auth` | Print `{ oauthStatePath, storedServerUrls }` (the server keys that currently have a token) and exit. No server connection is made. |
| `--print-handoff` | Print a JSON handoff block (`deepLink`, `portForwardCmd`, `oauthStatePath`, `apiToken`) for `--server-url` and exit — everything a script/remote VM needs to drive the browser-side OAuth dance. Requires `--server-url`. |
**State-file resolution** follows `MCP_INSPECTOR_OAUTH_STATE_PATH` → `<MCP_STORAGE_DIR>/oauth.json` → `~/.mcp-inspector/storage/oauth.json` — the same precedence the rest of the Inspector uses, so the CLI and web backend agree on the file. Server keys are canonicalised with `new URL().href` (the scheme the web store writes), so a trailing-slash or case mismatch between the URL a human opened and the one the agent passed still resolves.
**Blind injection.** The token is injected as-is; the CLI does not validate or refresh it (a stored `refresh_token` is not yet used — a natural follow-up). A stale/expired access token surfaces as an HTTP `401` on the first request → exit `3` (`auth_required`). Re-complete the flow in the web inspector (or use `--wait-for-auth`) and retry.
**Token refresh (#1665).** When the stored server state carries a `refresh_token` (plus the `clientInformation` the web inspector persists after a completed flow), `--use-stored-auth` runs the SDK's OAuth `refresh_token` grant to mint a fresh access token before connecting, then writes the rotated tokens back to the state file (owner-only `0o600`, via the shared store writer) so web and CLI stay consistent. The auth-server metadata is reused from the stored state, or discovered from `--server-url` when absent. This covers both an absent and an expired stored access token — the persisted blob carries no expiry, so the refresh token is treated as the durable credential. If the refresh fails (revoked token, transient auth-server error) **and** a stored access token is also present, the CLI falls back to injecting that token rather than hard-failing; only when there is nothing to fall back on does it exit `3` (`auth_required`, envelope `refresh_failed`). Without a stored `refresh_token` the access token is injected as-is, and a stale one surfaces as an HTTP `401` → exit `3`.
Because there is no stored expiry, a `refresh_token` is refreshed on every `--use-stored-auth` run. Two consequences with rotating refresh tokens: two concurrent invocations against the same state file race the single-use token (one wins), and a crash between a successful grant and the write-back leaves the rotated token unsaved. Both are narrow; re-authorize in the web inspector to recover.
**Short-circuit modes.** `--list-stored-auth` and `--print-handoff` each print their output and exit without connecting to a server; they ignore the method/target flags. They are mutually exclusive — if both are passed, `--list-stored-auth` takes precedence.
> The `deepLink` shape emitted by `--print-handoff` is interim, pending the web deep-link auto-connect work ([#1576](https://github.com/modelcontextprotocol/inspector/issues/1576)); it will be reconciled with that issue's canonical handoff format once it lands.
The `deepLink` is the canonical web deep-link ([#1576](https://github.com/modelcontextprotocol/inspector/issues/1576)) — `http://<host>:<port>/?serverUrl=<url>&transport=<http|sse>&autoConnect=<token>` — so navigating it in a browser reaches a connected inspector in one shot. `transport` is derived from the resolved server (`--transport`, else auto-detected from the URL path: `/sse` → `sse`, else `http`), not hardcoded. `autoConnect` is set to `MCP_INSPECTOR_API_TOKEN`; when that env var is unset the link is still emitted but a `note` field flags that the web app's `autoConnect` gate will reject it until the inspector is launched with a known token.
```bash
# On a remote VM: print what a human needs to complete OAuth in their browser.

View file

@ -1,10 +1,15 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { createServer, type Server } from "node:http";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { runCli } from "./helpers/cli-runner.js";
import { expectCliFailure, expectCliSuccess } from "./helpers/assertions.js";
import { normalizeServerUrl } from "../src/cli.js";
import {
normalizeServerUrl,
deepLinkTransport,
refreshStoredAuthToken,
} from "../src/cli.js";
import {
createTestServerHttp,
createEchoTool,
@ -36,6 +41,198 @@ describe("normalizeServerUrl", () => {
});
});
describe("deepLinkTransport", () => {
it("honors an explicit sse/http transport over the URL path", () => {
expect(deepLinkTransport("https://x.example/mcp", "sse")).toBe("sse");
expect(deepLinkTransport("https://x.example/sse", "http")).toBe("http");
});
it("auto-detects sse from a /sse path when no transport is given", () => {
expect(deepLinkTransport("https://x.example/sse", undefined)).toBe("sse");
});
it("defaults to http for a /mcp path, an ambiguous path, or stdio", () => {
expect(deepLinkTransport("https://x.example/mcp", undefined)).toBe("http");
expect(deepLinkTransport("https://x.example/", undefined)).toBe("http");
expect(deepLinkTransport("https://x.example/mcp", "stdio")).toBe("http");
});
it("defaults to http for an unparseable URL without throwing", () => {
expect(deepLinkTransport("not a url", undefined)).toBe("http");
});
});
describe("refreshStoredAuthToken", () => {
const SERVER = "https://api.example/mcp";
const freshTokens = {
access_token: "refreshed-access-token",
token_type: "Bearer",
refresh_token: "rotated-refresh-token",
expires_in: 3600,
};
it("runs the refresh grant, injects the fresh token, and persists the rotation", async () => {
const path = writeOAuthFixture({
[SERVER]: {
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
clientInformation: { client_id: "cid", client_secret: "sec" },
serverMetadata: {
issuer: "https://auth.example",
token_endpoint: "https://auth.example/token",
},
},
});
try {
const refresh = vi.fn().mockResolvedValue(freshTokens);
const discover = vi.fn();
const token = await refreshStoredAuthToken(SERVER, path, {
refresh,
discover,
});
expect(token).toBe("refreshed-access-token");
// Stored metadata present → no discovery needed.
expect(discover).not.toHaveBeenCalled();
// Refresh called with the stored refresh token + client information.
expect(refresh).toHaveBeenCalledTimes(1);
const [, opts] = refresh.mock.calls[0]!;
expect(opts.refreshToken).toBe("old-refresh");
expect(opts.clientInformation).toEqual({
client_id: "cid",
client_secret: "sec",
});
// Rotation persisted back under the same key.
const persisted = JSON.parse(readFileSync(path, "utf8")) as {
servers: Record<string, { tokens?: { refresh_token?: string } }>;
};
expect(persisted.servers[SERVER]?.tokens?.refresh_token).toBe(
"rotated-refresh-token",
);
} finally {
rmSync(path, { force: true });
}
});
it("discovers the auth-server metadata when it was not persisted", async () => {
const path = writeOAuthFixture({
[SERVER]: {
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
clientInformation: { client_id: "cid" },
},
});
try {
const refresh = vi.fn().mockResolvedValue(freshTokens);
const discover = vi.fn().mockResolvedValue({
issuer: "https://api.example",
token_endpoint: "https://api.example/token",
});
const token = await refreshStoredAuthToken(SERVER, path, {
refresh,
discover,
});
expect(token).toBe("refreshed-access-token");
expect(discover).toHaveBeenCalledTimes(1);
} finally {
rmSync(path, { force: true });
}
});
it("passes metadata=undefined to the refresh grant when discovery returns nothing", async () => {
const path = writeOAuthFixture({
[SERVER]: {
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
clientInformation: { client_id: "cid" },
},
});
try {
const refresh = vi.fn().mockResolvedValue(freshTokens);
const discover = vi.fn().mockResolvedValue(undefined);
const token = await refreshStoredAuthToken(SERVER, path, {
refresh,
discover,
});
expect(token).toBe("refreshed-access-token");
const [, opts] = refresh.mock.calls[0]!;
expect(opts.metadata).toBeUndefined();
} finally {
rmSync(path, { force: true });
}
});
it("uses the distinct no_client_information code when client info is missing", async () => {
const path = writeOAuthFixture({
[SERVER]: {
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
},
});
try {
await expect(
refreshStoredAuthToken(SERVER, path, { refresh: vi.fn() }),
).rejects.toMatchObject({
exitCode: 3,
envelope: { code: "no_client_information" },
});
} finally {
rmSync(path, { force: true });
}
});
it("throws AUTH_REQUIRED when no refresh token is stored", async () => {
const path = writeOAuthFixture({
[SERVER]: {
tokens: { access_token: "only-access", token_type: "Bearer" },
},
});
try {
await expect(
refreshStoredAuthToken(SERVER, path, { refresh: vi.fn() }),
).rejects.toMatchObject({ exitCode: 3 });
} finally {
rmSync(path, { force: true });
}
});
it("throws AUTH_REQUIRED when a refresh token is present but client information is missing", async () => {
const path = writeOAuthFixture({
[SERVER]: {
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
},
});
try {
await expect(
refreshStoredAuthToken(SERVER, path, { refresh: vi.fn() }),
).rejects.toMatchObject({ exitCode: 3 });
} finally {
rmSync(path, { force: true });
}
});
it("surfaces a refresh-grant failure as AUTH_REQUIRED (refresh_failed)", async () => {
const path = writeOAuthFixture({
[SERVER]: {
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
clientInformation: { client_id: "cid" },
serverMetadata: {
issuer: "https://auth.example",
token_endpoint: "https://auth.example/token",
},
},
});
try {
const refresh = vi
.fn()
.mockRejectedValue(new Error("invalid_grant: token revoked"));
await expect(
refreshStoredAuthToken(SERVER, path, { refresh }),
).rejects.toMatchObject({
exitCode: 3,
envelope: { code: "refresh_failed" },
});
} finally {
rmSync(path, { force: true });
}
});
});
describe("--use-stored-auth", () => {
let server: ReturnType<typeof createTestServerHttp>;
let serverUrl: string;
@ -197,6 +394,121 @@ describe("--use-stored-auth", () => {
const last = server.getRecordedRequests().at(-1)!;
expect(last.headers?.authorization).toBe(`Bearer ${TOKEN}`);
});
it("refreshes a stored refresh_token end-to-end and injects the fresh access token (#1665)", async () => {
// Minimal OAuth token endpoint: honors the refresh_token grant with a
// rotated token pair, so the CLI's real SDK refresh path has something to
// talk to (the MCP test server accepts any bearer, so a successful connect
// proves the refreshed token was injected).
let tokenRequests = 0;
const tokenServer: Server = createServer((req, res) => {
tokenRequests += 1;
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
access_token: "refreshed-access-token",
token_type: "Bearer",
refresh_token: "rotated-refresh-token",
expires_in: 3600,
}),
);
});
await new Promise<void>((resolve) => tokenServer.listen(0, resolve));
const addr = tokenServer.address();
const tokenBase =
typeof addr === "object" && addr ? `http://127.0.0.1:${addr.port}` : "";
const fixture = writeOAuthFixture({
[serverUrl]: {
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
clientInformation: { client_id: "cid", client_secret: "sec" },
serverMetadata: {
issuer: tokenBase,
token_endpoint: `${tokenBase}/token`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: ["client_secret_post"],
},
},
});
try {
const result = await runCli(
[
"--transport",
"http",
"--server-url",
serverUrl,
"--use-stored-auth",
"--method",
"tools/list",
],
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixture } },
);
expectCliSuccess(result);
expect(tokenRequests).toBeGreaterThan(0);
const last = server.getRecordedRequests().at(-1)!;
expect(last.headers?.authorization).toBe("Bearer refreshed-access-token");
// Rotation persisted so a subsequent run reuses the new refresh token.
const persisted = JSON.parse(readFileSync(fixture, "utf8")) as {
servers: Record<string, { tokens?: { refresh_token?: string } }>;
};
expect(persisted.servers[serverUrl]?.tokens?.refresh_token).toBe(
"rotated-refresh-token",
);
} finally {
rmSync(fixture, { force: true });
await new Promise<void>((resolve) => tokenServer.close(() => resolve()));
}
});
it("falls back to the stored access token when the refresh grant fails (#1665 review #1)", async () => {
// Token endpoint that rejects the refresh grant, so the CLI must fall back
// to the still-present stored access token instead of hard-failing.
const tokenServer: Server = createServer((_req, res) => {
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "invalid_grant" }));
});
await new Promise<void>((resolve) => tokenServer.listen(0, resolve));
const addr = tokenServer.address();
const tokenBase =
typeof addr === "object" && addr ? `http://127.0.0.1:${addr.port}` : "";
const fixture = writeOAuthFixture({
[serverUrl]: {
tokens: {
access_token: "still-usable-access",
refresh_token: "old-refresh",
token_type: "Bearer",
},
clientInformation: { client_id: "cid", client_secret: "sec" },
serverMetadata: {
issuer: tokenBase,
token_endpoint: `${tokenBase}/token`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: ["client_secret_post"],
},
},
});
try {
const result = await runCli(
[
"--transport",
"http",
"--server-url",
serverUrl,
"--use-stored-auth",
"--method",
"tools/list",
],
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixture } },
);
expectCliSuccess(result);
const last = server.getRecordedRequests().at(-1)!;
expect(last.headers?.authorization).toBe("Bearer still-usable-access");
} finally {
rmSync(fixture, { force: true });
await new Promise<void>((resolve) => tokenServer.close(() => resolve()));
}
});
});
describe("--list-stored-auth", () => {
@ -258,6 +570,8 @@ describe("--print-handoff", () => {
expect(out.serverUrl).toBe("https://x.example/mcp");
expect(out.deepLink).toContain("autoConnect=tok123");
expect(out.deepLink).toContain("serverUrl=https%3A%2F%2Fx.example%2Fmcp");
// Canonical #1576 deep-link: a `/mcp` server hands off `transport=http`.
expect(out.deepLink).toContain("transport=http");
expect(out.portForwardCmd).toContain("--tcp 16274:16274");
expect(out.portForwardCmd).toContain("--tcp 16275:16275");
expect(out.oauthStatePath).toBe(
@ -266,6 +580,33 @@ describe("--print-handoff", () => {
expect(out.apiToken).toBe("tok123");
});
it("derives transport=sse for an SSE server (auto-detected from the /sse path)", async () => {
const result = await runCli(
["--print-handoff", "--server-url", "https://x.example/sse"],
{ env: { MCP_INSPECTOR_API_TOKEN: "tok123" } },
);
expectCliSuccess(result);
const out = JSON.parse(result.stdout) as { deepLink: string };
expect(out.deepLink).toContain("transport=sse");
expect(out.deepLink).not.toContain("transport=http");
});
it("honors an explicit --transport sse over the URL path", async () => {
const result = await runCli(
[
"--print-handoff",
"--server-url",
"https://x.example/mcp",
"--transport",
"sse",
],
{ env: { MCP_INSPECTOR_API_TOKEN: "tok123" } },
);
expectCliSuccess(result);
const out = JSON.parse(result.stdout) as { deepLink: string };
expect(out.deepLink).toContain("transport=sse");
});
it("includes a note when MCP_INSPECTOR_API_TOKEN is unset", async () => {
const result = await runCli(
["--print-handoff", "--server-url", "https://x.example/mcp"],

View file

@ -28,7 +28,22 @@ import type { JsonValue } from "@inspector/core/mcp/index.js";
import { extractAppInfo } from "@inspector/core/mcp/apps.js";
import type { AppInfo } from "@inspector/core/mcp/apps.js";
import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js";
import { parseOAuthPersistBlob } from "@inspector/core/auth/oauth-persist.js";
import {
parseOAuthPersistBlob,
serializeOAuthPersistBlob,
type OAuthPersistSnapshot,
} from "@inspector/core/auth/oauth-persist.js";
import { getAuthorizationServerUrl } from "@inspector/core/auth/discovery.js";
import { writeStoreFile } from "@inspector/core/storage/store-io.js";
import {
refreshAuthorization,
discoverAuthorizationServerMetadata,
} from "@modelcontextprotocol/sdk/client/auth.js";
import type {
OAuthClientInformation,
OAuthMetadata,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { CliExitCodeError, EXIT_CODES } from "./error-handler.js";
import {
ConsoleNavigation,
@ -469,26 +484,59 @@ export function normalizeServerUrl(serverUrl: string): string {
}
}
/** The subset of a stored server's OAuth state the CLI reads/refreshes. */
type StoredServerState = {
tokens?: OAuthTokens;
clientInformation?: OAuthClientInformation;
serverMetadata?: OAuthMetadata;
};
/** The stored-server map shape the CLI reads out of the OAuth state file. */
type StoredServers = Record<string, { tokens?: { access_token?: string } }>;
type StoredServers = Record<string, StoredServerState>;
/**
* Read the OAuth state file directly (bypassing the Zustand store cache) so
* each call sees the current on-disk state — required for `--wait-for-auth`
* polling. Returns the `servers` map, or an empty object when the file is
* absent or unreadable. Uses the shared {@link parseOAuthPersistBlob} so both
* the plain `{servers,idpSessions}` and legacy `{state,version}` layouts are
* polling. Returns the full snapshot, or an empty one when the file is absent
* or unreadable. Uses the shared {@link parseOAuthPersistBlob} so both the
* plain `{servers,idpSessions}` and legacy `{state,version}` layouts are
* accepted, matching whatever the web backend wrote.
*/
async function readOAuthServers(statePath: string): Promise<StoredServers> {
async function readOAuthSnapshot(
statePath: string,
): Promise<OAuthPersistSnapshot> {
const { readFile } = await import("node:fs/promises");
try {
const text = await readFile(statePath, "utf8");
const snapshot = parseOAuthPersistBlob(text);
return (snapshot?.servers as StoredServers | undefined) ?? {};
if (snapshot) return snapshot;
} catch {
return {};
// Absent/unreadable/malformed → fall through to the empty snapshot below.
}
return { servers: {}, idpSessions: {} };
}
/**
* Read just the `servers` map. Thin wrapper over {@link readOAuthSnapshot} for
* the read-only lookups (`findStoredToken`, `--wait-for-auth`, key listing).
*/
async function readOAuthServers(statePath: string): Promise<StoredServers> {
return (await readOAuthSnapshot(statePath)).servers as StoredServers;
}
/**
* Look up a stored server's OAuth state, trying the URL-normalised key first
* (how the web store writes it) and the raw string second. Returns the matched
* key so a write-back updates the same entry.
*/
function findStoredServerState(
servers: StoredServers,
serverUrl: string,
): { key: string; state: StoredServerState } | undefined {
const normalized = normalizeServerUrl(serverUrl);
if (servers[normalized])
return { key: normalized, state: servers[normalized] };
if (servers[serverUrl]) return { key: serverUrl, state: servers[serverUrl] };
return undefined;
}
/**
@ -499,11 +547,91 @@ function findStoredToken(
servers: StoredServers,
serverUrl: string,
): string | undefined {
const key = normalizeServerUrl(serverUrl);
return (
servers[key]?.tokens?.access_token ??
servers[serverUrl]?.tokens?.access_token
);
return findStoredServerState(servers, serverUrl)?.state.tokens?.access_token;
}
/**
* Injectable dependencies for {@link refreshStoredAuthToken}, so the refresh
* grant + auth-server discovery can be faked in unit tests without standing up
* a real OAuth token endpoint. Both default to the SDK implementations.
*/
export interface RefreshStoredAuthDeps {
refresh?: typeof refreshAuthorization;
discover?: typeof discoverAuthorizationServerMetadata;
}
/**
* Run the OAuth `refresh_token` grant for a stored server and persist the
* rotated tokens back to `statePath` (same `{servers,idpSessions}` shape the
* web backend writes), returning the fresh access token.
*
* Reuses the SDK's {@link refreshAuthorization} (not a hand-rolled token
* request) with the stored `clientInformation` + `serverMetadata`; when the
* metadata wasn't persisted it is discovered from the resolved authorization
* server. A missing refresh token or client information, or a failed grant,
* throws {@link CliExitCodeError} with {@link EXIT_CODES.AUTH_REQUIRED} so the
* caller exits with the documented code and a clear message.
*/
export async function refreshStoredAuthToken(
serverUrl: string,
statePath: string,
deps: RefreshStoredAuthDeps = {},
): Promise<string> {
const refresh = deps.refresh ?? refreshAuthorization;
const discover = deps.discover ?? discoverAuthorizationServerMetadata;
const snapshot = await readOAuthSnapshot(statePath);
const servers = snapshot.servers as StoredServers;
const found = findStoredServerState(servers, serverUrl);
const refreshToken = found?.state.tokens?.refresh_token;
const clientInformation = found?.state.clientInformation;
if (!found || !refreshToken) {
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`No stored refresh token for ${normalizeServerUrl(serverUrl)} in ${statePath}. Complete the OAuth flow in the web inspector first.`,
{ code: "no_stored_token", url: serverUrl },
);
}
if (!clientInformation) {
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`Stored auth for ${normalizeServerUrl(serverUrl)} has a refresh token but no client information; cannot refresh. Re-authorize in the web inspector.`,
{ code: "no_client_information", url: serverUrl },
);
}
const authServerUrl = found.state.serverMetadata?.issuer
? new URL(found.state.serverMetadata.issuer)
: getAuthorizationServerUrl(serverUrl);
const metadata =
found.state.serverMetadata ?? (await discover(authServerUrl)) ?? undefined;
let tokens: OAuthTokens;
try {
tokens = await refresh(authServerUrl, {
metadata,
clientInformation,
refreshToken,
resource: new URL(serverUrl),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`Failed to refresh the stored OAuth token for ${normalizeServerUrl(serverUrl)}: ${message}. Re-authorize in the web inspector.`,
{ code: "refresh_failed", url: serverUrl },
);
}
// Persist the rotated tokens back under the same key, preserving every other
// server entry and the idpSessions block, so web and CLI stay consistent.
// Route through the shared `writeStoreFile` (not a raw `writeFile`) so the
// secrets file keeps its owner-only `0o600` mode + `mkdir -p`, identical to
// how the web backend's OAuth persist backend writes it.
servers[found.key] = { ...found.state, tokens };
await writeStoreFile(statePath, serializeOAuthPersistBlob(snapshot));
return tokens.access_token;
}
/**
@ -539,31 +667,64 @@ async function waitForStoredToken(
}
}
/**
* Derive the web deep-link `transport` value (`http` | `sse`) for a handoff.
* Mirrors `resolveServerConfigs` (core/mcp/node/config.ts) URL-path
* auto-detection (`/sse` → sse,
* everything else → http) but, unlike that resolver, defaults to `http` instead
* of throwing on an ambiguous path — the handoff is best-effort, and the web
* {@link parseDeepLink} likewise defaults an unknown/missing transport to http.
*/
export function deepLinkTransport(
serverUrl: string,
transport: "sse" | "http" | "stdio" | undefined,
): "http" | "sse" {
if (transport === "sse") return "sse";
if (transport === "http") return "http";
try {
if (new URL(serverUrl).pathname.endsWith("/sse")) return "sse";
} catch {
// Unparseable URL: fall through to the http default. The web parser rejects
// a non-http(s) serverUrl anyway, so guessing a transport for it is moot.
}
return "http";
}
/**
* Build the JSON `--print-handoff` emits: everything an automated caller needs
* to relay to a human so they can complete OAuth in a browser and have the
* token land where the CLI will find it.
*
* NOTE: the `deepLink` query shape (serverUrl/transport/autoConnect) is the
* interim format pending the web deep-link auto-connect work (#1576); reconcile
* with that issue's canonical handoff format once it lands.
* The `deepLink` is the canonical web format owned by
* `clients/web/src/utils/deepLink.ts` (#1576): `?serverUrl&transport&autoConnect`,
* where `autoConnect` is the CSRF gate (must equal `MCP_INSPECTOR_API_TOKEN`).
* `transport` is derived from the resolved server via {@link deepLinkTransport}
* rather than hardcoded, so an SSE server hands off a `transport=sse` link.
*/
function buildHandoff(serverUrl: string, statePath: string): McpResponse {
function buildHandoff(
serverUrl: string,
statePath: string,
transport: "sse" | "http" | "stdio" | undefined,
): McpResponse {
const host = process.env.HOST || "127.0.0.1";
const clientPort = process.env.CLIENT_PORT || "6274";
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
// can't satisfy the deep-link autoConnect gate.
const apiToken = process.env.MCP_INSPECTOR_API_TOKEN || undefined;
// TODO(#1576): interim deep-link shape. `transport: "http"` is hardcoded even
// for SSE servers, and `autoConnect=<token>` is NOT one of the three token
// sources the web app reads (window.__INSPECTOR_API_TOKEN__ /
// ?MCP_INSPECTOR_API_TOKEN / sessionStorage — see CLAUDE.md). Reconcile both
// with #1576's canonical handoff/deep-link format once it lands.
const params = new URLSearchParams({ serverUrl, transport: "http" });
const normalizedUrl = normalizeServerUrl(serverUrl);
// Canonical #1576 deep-link shape: the normalized serverUrl (matching the
// OAuth-store key form the web app reuses) plus the resolved transport, gated
// by `autoConnect=<token>` — the same per-launch token the web parser
// requires. Omitted when no token is set; the `note` below flags that the
// link will be rejected until the web inspector is launched with a token.
const params = new URLSearchParams({
serverUrl: normalizedUrl,
transport: deepLinkTransport(serverUrl, transport),
});
if (apiToken) params.set("autoConnect", apiToken);
return {
serverUrl: normalizeServerUrl(serverUrl),
serverUrl: normalizedUrl,
deepLink: `http://${host}:${clientPort}/?${params.toString()}`,
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
oauthStatePath: statePath,
@ -897,7 +1058,9 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
throw new Error("--print-handoff requires --server-url");
}
await awaitableLog(
JSON.stringify(buildHandoff(options.serverUrl, oauthStatePath)) + "\n",
JSON.stringify(
buildHandoff(options.serverUrl, oauthStatePath, options.transport),
) + "\n",
);
return { shortCircuit: true };
}
@ -934,11 +1097,13 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
}
// Read the OAuth state file directly so the lookup is normalised the same
// way the web inspector wrote it (`new URL().href`), and so `--wait-for-
// auth` sees fresh on-disk state on each poll. Header injection is the
// prototype path — only the access token is injected, blindly; a stale one
// surfaces as HTTP 401 → exit 3 (auth_required). Follow-up: when the stored
// `tokens.refresh_token` is present and the access token is expired, mint a
// fresh one instead of injecting the stale token.
// auth` sees fresh on-disk state on each poll. When a `refresh_token` is
// stored, the CLI runs the SDK refresh grant and injects the fresh access
// token (persisting the rotation) rather than blindly injecting a possibly-
// stale stored access token (#1665) — the stored blob carries no expiry, so
// the refresh token is the durable credential. Without a refresh token it
// falls back to injecting the stored access token; a stale one surfaces as
// HTTP 401 → exit 3 (auth_required).
let token: string;
if (options.waitForAuth !== undefined) {
token = await waitForStoredToken(
@ -948,18 +1113,40 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
);
} else {
const servers = await readOAuthServers(oauthStatePath);
const found = findStoredToken(servers, options.serverUrl);
if (!found) {
const key = normalizeServerUrl(options.serverUrl);
const stored = Object.keys(servers);
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`No stored OAuth token for ${key} in ${oauthStatePath}. Complete the OAuth flow in the web inspector first.` +
(stored.length > 0 ? ` Stored keys: ${stored.join(", ")}.` : ""),
{ code: "no_stored_token", url: options.serverUrl },
);
const stored = findStoredServerState(servers, options.serverUrl);
if (stored?.state.tokens?.refresh_token) {
const storedAccess = stored.state.tokens.access_token;
try {
token = await refreshStoredAuthToken(
options.serverUrl,
oauthStatePath,
);
} catch (err) {
// A failed refresh (transient auth-server hiccup, missing client
// info) shouldn't turn a previously-working invocation into a hard
// failure when a still-usable access token is also on disk — fall
// back to injecting it (a genuinely stale one surfaces as HTTP 401 →
// exit 3, the same as without a refresh token). With no stored access
// token to fall back on, the refresh error stands.
if (!storedAccess) throw err;
token = storedAccess;
}
} else {
const found = findStoredToken(servers, options.serverUrl);
if (!found) {
const key = normalizeServerUrl(options.serverUrl);
const storedKeys = Object.keys(servers);
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`No stored OAuth token for ${key} in ${oauthStatePath}. Complete the OAuth flow in the web inspector first.` +
(storedKeys.length > 0
? ` Stored keys: ${storedKeys.join(", ")}.`
: ""),
{ code: "no_stored_token", url: options.serverUrl },
);
}
token = found;
}
token = found;
}
serverOptions.headers = {
...(serverOptions.headers ?? {}),

View file

@ -71,6 +71,45 @@ 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.
## 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.
```
http://127.0.0.1:6274/?serverUrl=<url>&transport=http|sse&autoConnect=<token>
```
| Param | Meaning |
| --- | --- |
| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. |
| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. |
| `autoConnect` | **CSRF gate.** Must equal the per-launch `MCP_INSPECTOR_API_TOKEN`. The token is random per launch and only known to whatever started the server, so a third-party-minted link cannot satisfy it — this is the same exposure surface as the existing `?MCP_INSPECTOR_API_TOKEN=` param. Without a match the link is ignored. |
The deep link upserts a stable `deep-link` catalog row (so a reload reconnects to the same row instead of accumulating duplicates) and connects. Connection-level outcomes are surfaced on the `AppShell.Header` as a machine-readable contract, so a driver can `waitForSelector` and read the failure reason without scraping a transient toast:
| Attribute | Where | Meaning |
| --- | --- | --- |
| `data-testid="connection-status"` | header | The element carrying the attributes below. |
| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. |
| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. |
| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. |
### Landing on a rendered app
Three further params extend the deep link to pre-select — and optionally auto-open — an MCP App, so a driver reaches a rendered widget with zero clicks:
```
…&openApp=<toolName>&appArgs=<base64url(JSON)>&autoOpen=<token>
```
| Param | Meaning |
| --- | --- |
| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. |
| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. |
| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. |
The app-side render lifecycle is observable through the [MCP Apps screen automation contract](#mcp-apps-screen-automation-contract) above (`data-app-status="ready"`), so a driver can `waitForSelector` the whole `connect → open → ready` chain deterministically.
## Theme (`src/theme/`)
Each customized Mantine component has a `Theme<Name>.ts` file (`Button.ts`, `Text.ts`, …, ~21 total) exporting a `Theme<Name>` constant; the barrel `index.ts` re-exports them and `theme.ts` assembles the `MantineProvider` theme. Theme files hold app-wide defaults and **variants** (flat CSS-in-JS); only pseudo-selectors, nested child selectors, keyframes, and native-HTML styling belong in `App.css`. Element components import from `@mantine/core` (never from `theme/`) — the theme layer is applied transparently by the provider.

View file

@ -154,6 +154,12 @@ import {
} from "./components/groups/PendingClientRequestModal/PendingClientRequestModal";
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs";
import {
parseDeepLink,
deepLinkConfigEquals,
deepLinkParseStatus,
} from "./utils/deepLink";
import type { DeepLink, DeepLinkParseStatus } from "./utils/deepLink";
import {
applyOAuthResumeUi,
buildTabUiSnapshot,
@ -1347,6 +1353,37 @@ function App() {
serversRef.current = servers;
}, [activeServer, activeServerId, servers]);
// Last connection-level error message, surfaced as `data-error-message` on
// the InspectorView header so an automated driver can read *why* a connect
// failed without scraping a transient toast. Cleared on the next connect
// attempt and on successful connection.
const [connectErrorMessage, setConnectErrorMessage] = useState<
string | undefined
>(undefined);
// Named writer so a single call site can be extended later (e.g. telemetry)
// and the intent (record a connection-level failure) stays explicit.
const recordConnectError = useCallback((message: string) => {
setConnectErrorMessage(message);
}, []);
// Deep-link parameters parsed once from the initial URL. Security gating
// (auth-token match, http(s)-only serverUrl) happens inside `parseDeepLink`,
// so a `DeepLink` value here is already validated. The parse status is
// surfaced as `data-deeplink` so an automated driver can tell "no deep link"
// from "deep link present but rejected" — both leave `data-status` idle.
const [deepLink, deepLinkStatus] = useMemo<
[DeepLink | undefined, DeepLinkParseStatus]
>(() => {
/* v8 ignore next -- SSR guard: happy-dom always defines window in tests */
if (typeof window === "undefined") return [undefined, "none"];
const search = window.location.search;
const parsed = parseDeepLink(search, getAuthToken());
return [parsed, deepLinkParseStatus(search, parsed)];
}, []);
const deepLinkEnsureRef = useRef(false);
const deepLinkUpdateRef = useRef(false);
const deepLinkConnectRef = useRef(false);
const showReAuthBanner = useCallback(
(
serverId: string,
@ -2338,7 +2375,11 @@ function App() {
return;
}
const target = servers.find((s) => s.id === id);
// Read from the ref so a caller that already awaited an
// addServer/updateServer in the same async tick (e.g. the deep-link
// auto-connect IIFE) sees the freshly-mutated list, not the stale array
// captured by this callback's closure.
const target = serversRef.current.find((s) => s.id === id);
if (!target) return;
// Always rebuild the InspectorClient on a (re)connect so the latest
@ -2354,6 +2395,9 @@ function App() {
// the red border on the last-failed card is removed (#1621). If this
// attempt also fails, the catch below re-sets it for this server.
setFailedServerId(undefined);
// Clear the machine-readable connect error for the same reason; a fresh
// attempt starts from a clean `data-error-message`.
setConnectErrorMessage(undefined);
connectStartRef.current = Date.now();
try {
@ -2432,6 +2476,7 @@ function App() {
}
const message =
authErr instanceof Error ? authErr.message : String(authErr);
setConnectErrorMessage(message);
notifications.show({
title: `OAuth authorization failed for "${target.name}"`,
message,
@ -2446,6 +2491,7 @@ function App() {
// "disconnected", and flag the card with a red border (#1621).
setFailedServerId(id);
const message = err instanceof Error ? err.message : String(err);
setConnectErrorMessage(message);
notifications.show({
title: `Failed to connect to "${target.name}"`,
message,
@ -2457,7 +2503,6 @@ function App() {
activeServerId,
connectionStatus,
inspectorClient,
servers,
setupClientForServer,
prepareOAuthRedirect,
finalizeExplicitDisconnect,
@ -2473,6 +2518,85 @@ function App() {
}
}, [inspectorClient, finalizeExplicitDisconnect]);
// Deep-link auto-connect (the URL-driven case of #1183). `useServers`
// hydrates asynchronously (initial `servers` is `[]`), so this effect runs in
// discrete phases keyed on what `servers` currently reflects, one per render:
// 1. ensure — no row yet: one-shot `addServer`.
// 2. update — row present but its persisted config differs from the deep
// link (a stale transport/url from an earlier load under the stable
// `deep-link` id): `updateServer`, then return so the effect re-runs.
// 3. connect — row present AND its config already matches: connect.
// Splitting update and connect across renders (rather than awaiting both in
// one closure) is what makes the connect correct: `onToggleConnection` reads
// the target from `serversRef`, which an earlier passive effect syncs from
// `servers` — so connecting only once `servers` reflects the updated config
// guarantees the client is built from the fresh transport, not the stale one.
// The OAuth callback path takes precedence; a deep link on `/oauth/callback`
// would be a misconfiguration, and the callback handler clears the URL.
useEffect(() => {
if (!deepLink) return;
if (window.location.pathname === OAUTH_CALLBACK_PATH) return;
const existing = servers.find((s) => s.id === deepLink.serverId);
if (!existing) {
if (deepLinkEnsureRef.current) return;
deepLinkEnsureRef.current = true;
void addServer(deepLink.serverId, deepLink.serverConfig).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
// A 409 ("already exists") means the row is on disk and hydration will
// surface it on a later render, so the connect phase still proceeds —
// swallow it. Any other failure (read-only catalog, backend 5xx) would
// otherwise leave the deep link permanently stuck at this guard with no
// signal, so record it on the machine-readable error surface.
if (!message.includes("already exists")) recordConnectError(message);
});
return;
}
if (!deepLinkConfigEquals(existing.config, deepLink.serverConfig)) {
if (deepLinkUpdateRef.current) return;
deepLinkUpdateRef.current = true;
void updateServer(
deepLink.serverId,
deepLink.serverId,
deepLink.serverConfig,
).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
recordConnectError(message);
});
return;
}
if (deepLinkConnectRef.current) return;
deepLinkConnectRef.current = true;
// Connect unless we're already *connected* to the deep-link server. Gating
// on `activeServerId` identity alone would skip the connect when a prior
// session restored `activeServerId` to the `deep-link` id while the socket
// is disconnected — a reload of the same deep-link URL would then silently
// never connect. `onToggleConnection` only disconnects when the id is the
// active one AND the status is connected, so this condition also avoids
// toggling a live connection off.
const alreadyConnected =
activeServerId === deepLink.serverId && connectionStatus === "connected";
if (!alreadyConnected) {
void onToggleConnection(deepLink.serverId).catch((err) => {
// The toast fires from inside `onToggleConnection` for the common
// cases; this catch covers the rest (surfaced on `data-error-message`).
const message = err instanceof Error ? err.message : String(err);
recordConnectError(message);
});
}
}, [
deepLink,
servers,
activeServerId,
connectionStatus,
addServer,
updateServer,
onToggleConnection,
recordConnectError,
]);
const onReauthenticateFromBanner = useCallback(() => {
if (!reAuthBanner) return;
const serverId = reAuthBanner.serverId;
@ -3691,11 +3815,14 @@ function App() {
</Box>
) : null}
<InspectorView
deepLink={deepLink}
deepLinkStatus={deepLinkStatus}
servers={servers}
serverListWritable={serverListWritable}
activeServer={activeServerId}
erroredServerId={failedServerId}
connectionStatus={connectionStatus}
connectErrorMessage={connectErrorMessage}
initializeResult={initializeResult}
latencyMs={latencyMs}
tools={tools}

View file

@ -274,6 +274,40 @@ describe("AppsScreen", () => {
expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument();
});
it("auto-opens the pre-selected app with its seeded form values when autoOpen is set (deep-link)", async () => {
const onOpenApp = vi.fn();
renderWithMantine(
<ControlledAppsScreen
autoOpen
ui={{
selectedAppName: "weather",
formValues: { city: "Reykjavik" },
search: "",
}}
onOpenApp={onOpenApp}
/>,
);
// No click: the fire-once effect opens the seeded app with its form values.
await vi.waitFor(() =>
expect(onOpenApp).toHaveBeenCalledWith("weather", { city: "Reykjavik" }),
);
expect(onOpenApp).toHaveBeenCalledTimes(1);
// Renderer mounted; the Open App button is gone.
expect(
screen.queryByRole("button", { name: /Open App/ }),
).not.toBeInTheDocument();
expect(screen.getByTitle("Weather Widget")).toBeInTheDocument();
});
it("does not auto-open when autoOpen is set but no app is pre-selected", async () => {
const onOpenApp = vi.fn();
renderWithMantine(<ControlledAppsScreen autoOpen onOpenApp={onOpenApp} />);
// Give the effect a chance to run; with no selection it must stay idle.
await Promise.resolve();
expect(onOpenApp).not.toHaveBeenCalled();
expect(screen.getByText("Select an app to view details")).toBeVisible();
});
it("invokes onOpenApp with form values when Open App is clicked", async () => {
const user = userEvent.setup();
const onOpenApp = vi.fn();

View file

@ -1,4 +1,4 @@
import { useRef, useState, type Ref } from "react";
import { useCallback, useEffect, useRef, useState, type Ref } from "react";
import {
ActionIcon,
Button,
@ -64,6 +64,14 @@ export interface AppsScreenProps {
onCloseApp: () => void;
/** Surfaces bridge/runtime failures from the renderer (e.g. no client). */
onError?: (err: Error) => void;
/**
* Deep-link auto-open (#1577): when true and an app is already pre-selected
* (the parent seeds `ui.selectedAppName` + `ui.formValues` from the URL),
* the screen fires "Open App" automatically — no explicit click. Token-gated
* upstream in `parseDeepLink` (the URL value must equal the session token),
* so a third-party link cannot auto-invoke a tool. Fires exactly once.
*/
autoOpen?: boolean;
}
// Selected app, its form values, and the sidebar search — controlled by the
@ -333,6 +341,7 @@ export function AppsScreen({
onOpenApp,
onCloseApp,
onError,
autoOpen = false,
}: AppsScreenProps) {
const { selectedAppName, formValues, search } = ui;
const [running, setRunning] = useState(false);
@ -415,8 +424,9 @@ export function AppsScreen({
// Clear the message + log panels (and the reported height). Called when a run
// ends or the selected app changes so a new run starts clean. `keepPartials`
// is set for `handleOpen`, where the staged fragments are about to be consumed
// by the renderer and must not be cleared first.
function resetAppChannels(opts?: { keepPartials?: boolean }) {
// by the renderer and must not be cleared first. Memoized (stable setState
// calls only) so the deep-link auto-open effect's deps don't churn.
const resetAppChannels = useCallback((opts?: { keepPartials?: boolean }) => {
setAppHeight(undefined);
setMessages([]);
setAppLogs([]);
@ -424,7 +434,7 @@ export function AppsScreen({
setAppStatus("idle");
setAppError(undefined);
if (!opts?.keepPartials) setPartialStages([]);
}
}, []);
// Capture the error locally (so it can be shown in the card and surfaced as
// `data-app-error`) and forward it to the parent's onError. The renderer
@ -500,6 +510,36 @@ export function AppsScreen({
onCloseApp();
}
// Deep-link auto-open (#1577): the parent seeds `ui.selectedAppName` +
// `ui.formValues` from the URL and sets `autoOpen`; fire "Open App" here so
// the driver lands on a rendered widget with zero clicks. Ref-guarded to fire
// exactly once — a later manual close leaves `running` false without
// re-triggering. The open (running + channel resets + `onOpenApp`) is
// deferred one microtask past the synchronous effect body: it calls setState,
// which the set-state-in-effect lint rightly flags in general, but this is a
// ref-guarded run-once effect so the cascading-render concern doesn't apply
// (same pattern as App.tsx's deep-link connect effect). `resetAppChannels`
// changes identity each render, so the effect re-runs, but the ref guard
// makes every run after the first a no-op.
const autoOpenFiredRef = useRef(false);
useEffect(() => {
if (!autoOpen || autoOpenFiredRef.current) return;
if (!selectedTool || running) return;
autoOpenFiredRef.current = true;
void Promise.resolve().then(() => {
resetAppChannels({ keepPartials: true });
setRunning(true);
onOpenApp(selectedTool.name, formValues);
});
}, [
autoOpen,
selectedTool,
running,
formValues,
onOpenApp,
resetAppChannels,
]);
function handleBackToInput() {
setRunning(false);
setMaximized(false);

View file

@ -259,6 +259,42 @@ describe("InspectorView", () => {
expect(onToggleConnection).toHaveBeenCalledWith("alpha");
});
it("exposes the machine-readable connection-status header attributes for drivers", () => {
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
connectionStatus: "error",
connectErrorMessage: "handshake failed: 500",
deepLinkStatus: "rejected",
})}
/>,
);
const header = screen.getByTestId("connection-status");
expect(header).toHaveAttribute("data-status", "error");
expect(header).toHaveAttribute(
"data-error-message",
"handshake failed: 500",
);
expect(header).toHaveAttribute("data-deeplink", "rejected");
});
it("omits the error-message attribute when no connect error is recorded", () => {
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
connectionStatus: "disconnected",
deepLinkStatus: "none",
})}
/>,
);
const header = screen.getByTestId("connection-status");
expect(header).toHaveAttribute("data-status", "disconnected");
expect(header).not.toHaveAttribute("data-error-message");
expect(header).toHaveAttribute("data-deeplink", "none");
});
it("renders the connected header when connectionStatus + initializeResult are set", () => {
renderWithMantine(
<StatefulInspectorViewHost
@ -653,6 +689,104 @@ describe("InspectorView", () => {
});
});
it("deep-link openApp auto-switches to the Apps tab and pre-selects the app once connected", async () => {
const onSelectApp = vi.fn();
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
tools: [sampleAppTool],
onSelectApp,
deepLink: {
serverId: "deep-link",
serverConfig: {
type: "streamable-http",
url: "https://example.com/mcp",
},
openApp: "ops",
appArgs: {},
autoOpen: false,
},
})}
/>,
);
// No click: the effect flips the lifted tab to Apps and seeds the selection.
expect(await screen.findByText("MCP Apps (1)")).toBeInTheDocument();
await waitFor(() => expect(onSelectApp).toHaveBeenCalledWith("ops"));
});
it("deep-link appArgs are merged over the schema defaults into the pre-filled form", async () => {
const fieldedAppTool: Tool = {
name: "cohorts",
title: "Cohort Data",
inputSchema: {
type: "object",
properties: {
metric: { type: "string", default: "retention" },
zip: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://apps/cohorts" } },
};
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
tools: [fieldedAppTool],
deepLink: {
serverId: "deep-link",
serverConfig: {
type: "streamable-http",
url: "https://example.com/mcp",
},
openApp: "cohorts",
// Overrides no default (zip), leaving `metric`'s schema default intact.
appArgs: { zip: "10001" },
autoOpen: false,
},
})}
/>,
);
// The form is pre-filled: `zip` from appArgs, `metric` from the schema default.
expect(await screen.findByDisplayValue("10001")).toBeInTheDocument();
expect(screen.getByDisplayValue("retention")).toBeInTheDocument();
});
it("ignores a deep-link openApp whose tool is not an app (no tab switch)", async () => {
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
tools: [sampleAppTool],
deepLink: {
serverId: "deep-link",
serverConfig: {
type: "streamable-http",
url: "https://example.com/mcp",
},
openApp: "does-not-exist",
appArgs: {},
autoOpen: false,
},
})}
/>,
);
// The target app never appears, so the effect never switches tabs: the
// Apps screen is never activated even though the Apps tab is available.
const radios = await screen.findAllByRole("radio");
expect(radios.map((r) => r.getAttribute("value"))).toContain("Apps");
expect(screen.queryByText("MCP Apps (1)")).not.toBeInTheDocument();
});
it("snaps activeTab back to Servers when the Apps tab disappears after a refresh", async () => {
const user = userEvent.setup({ delay: null });
const { rerender } = renderWithMantine(

View file

@ -6,6 +6,7 @@ import {
type ReactNode,
type Ref,
} from "react";
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
import {
AppShell,
Flex,
@ -88,6 +89,7 @@ import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen
import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle";
import { getServerType } from "@inspector/core/mcp/config.js";
import { INSPECTOR_SERVERS_TAB } from "../../../utils/inspectorTabs";
import { collectSchemaDefaults } from "../../../utils/jsonUtils";
import { MONITOR_COLUMN_ANIM_MS } from "./monitorColumnAnimation";
const SORT_DEFAULT: SortDirection = "newest-first";
@ -304,6 +306,21 @@ const MonitorColumnGroup = Group.withProps({
});
export interface InspectorViewProps {
/**
* Validated deep-link parameters from the page URL. When present and
* `openApp` is set, the parent switches to the Apps tab and pre-selects that
* app (with `appArgs` as the form values) once the connection is up and the
* app list contains it. The connect itself is driven by the parent.
*/
deepLink?: DeepLink;
/**
* Outcome of parsing the initial-URL deep link, surfaced as `data-deeplink`
* on the `connection-status` testid. Distinguishes "no deep link" from
* "rejected" (token mismatch / bad serverUrl) — both leave `data-status`
* idle, so an automated driver otherwise cannot tell them apart.
*/
deepLinkStatus?: DeepLinkParseStatus;
// Server list (static config; runtime connection state comes from the
// separate fields below and is merged into each card by this component).
servers: ServerEntry[];
@ -324,6 +341,13 @@ export interface InspectorViewProps {
*/
erroredServerId?: string;
connectionStatus: ConnectionStatus;
/**
* Last connection-level error message (handshake failure, OAuth start
* failure, deep-link automation failure). Surfaced as `data-error-message`
* on the header's `connection-status` testid so an automated driver can read
* *why* a connect failed without scraping a transient toast.
*/
connectErrorMessage?: string;
initializeResult?: InitializeResult;
latencyMs?: number;
@ -497,11 +521,14 @@ export interface InspectorViewProps {
}
export function InspectorView({
deepLink,
deepLinkStatus,
servers: serversInput,
serverListWritable = true,
activeServer,
erroredServerId,
connectionStatus,
connectErrorMessage,
initializeResult,
latencyMs,
tools,
@ -841,6 +868,55 @@ export function InspectorView({
? firstNonServersTab
: SERVERS_TAB;
// Deep-link auto-open (#1577): once connected and the requested app appears in
// the app-tools list, switch to the Apps tab and pre-select it with the
// supplied form values. The `autoOpen` flag is forwarded to AppsScreen (which
// owns the running/iframe state) to fire the actual "Open App" — see its
// `autoOpen` prop. Guarded by a ref so it fires exactly once even though the
// effect re-runs as `appTools`/`availableTabs` settle.
const deepLinkOpenAppRef = useRef(false);
useEffect(() => {
if (!deepLink?.openApp) return;
if (deepLinkOpenAppRef.current) return;
if (connectionStatus !== "connected") return;
if (!availableTabs.includes("Apps")) return;
const target = appTools.find((t) => t.name === deepLink.openApp);
if (!target) return;
deepLinkOpenAppRef.current = true;
// Seed the schema's defaults THEN overlay the deep-link's appArgs. Without
// the defaults, a required field the form would display with its default
// value is absent from `formValues`, the schema-form's validity check
// fails, and Open App is silently disabled — an automated driver's click
// then no-ops and the iframe-wait spins forever.
const formValues = {
...collectSchemaDefaults(target.inputSchema),
...deepLink.appArgs,
};
// Seed the selection directly rather than routing through
// AppsScreen.handleSelect. This deliberately bypasses handleSelect's
// no-input-app auto-launch: a deep link must never invoke a tool against
// the target server unless the token-gated `autoOpen` is set — even a
// no-input app waits for that explicit signal (or a manual "Open App"
// click), matching the security stance that a crafted URL alone can't fire
// a tool call.
onActiveTabChange("Apps");
onAppsUiChange({
...appsUi,
selectedAppName: target.name,
formValues,
});
onSelectApp(target.name);
}, [
deepLink,
connectionStatus,
availableTabs,
appTools,
appsUi,
onActiveTabChange,
onAppsUiChange,
onSelectApp,
]);
// The monitor tab shown in the column, clamped to what's available (e.g. a
// switch to a stdio server drops Network). `?? LOGS_TAB` is a types-only
// fallback: the column only renders while `monitorAvailable.length > 0`.
@ -1037,7 +1113,12 @@ export function InspectorView({
// Main-slot height clamp + overflow:hidden keep that scroll on the inner
// ScrollArea regions only.
<AppShell header={{ height: 60 }} padding={0}>
<AppShell.Header>
<AppShell.Header
data-testid="connection-status"
data-status={connectionStatus}
data-error-message={connectErrorMessage}
data-deeplink={deepLinkStatus}
>
{connectionStatus === "connected" && initializeResult ? (
<ViewHeader
connected
@ -1116,6 +1197,7 @@ export function InspectorView({
onOpenApp={onOpenApp}
onCloseApp={onCloseApp}
onError={onAppError}
autoOpen={Boolean(deepLink?.openApp && deepLink.autoOpen)}
/>
</ScreenStage>
<ScreenStage active={activeTab === "Prompts"}>

View file

@ -0,0 +1,199 @@
import { describe, it, expect } from "vitest";
import {
parseDeepLink,
deepLinkConfigEquals,
deepLinkParseStatus,
DEEP_LINK_SERVER_ID,
} from "./deepLink";
const TOKEN = "tok-abc";
describe("parseDeepLink", () => {
it("returns undefined when no serverUrl param is present", () => {
expect(parseDeepLink("?autoConnect=" + TOKEN, TOKEN)).toBeUndefined();
});
it("returns undefined when autoConnect is missing", () => {
expect(
parseDeepLink("?serverUrl=https%3A%2F%2Fexample.com%2Fmcp", TOKEN),
).toBeUndefined();
});
it("rejects when autoConnect does not match the session token (CSRF guard)", () => {
expect(
parseDeepLink(
"?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&autoConnect=1",
TOKEN,
),
).toBeUndefined();
});
it("rejects when there is no session token to compare against", () => {
expect(
parseDeepLink(
"?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&autoConnect=" + TOKEN,
undefined,
),
).toBeUndefined();
});
it("rejects non-http(s) serverUrl schemes", () => {
for (const url of [
"javascript:alert(1)",
"file:///etc/passwd",
"data:text/html,<script>",
"not a url",
]) {
expect(
parseDeepLink(
`?serverUrl=${encodeURIComponent(url)}&autoConnect=${TOKEN}`,
TOKEN,
),
).toBeUndefined();
}
});
it("parses a valid streamable-http deep link with the default transport", () => {
const link = parseDeepLink(
"?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&autoConnect=" + TOKEN,
TOKEN,
);
expect(link).toEqual({
serverId: DEEP_LINK_SERVER_ID,
serverConfig: { type: "streamable-http", url: "https://example.com/mcp" },
openApp: undefined,
appArgs: {},
autoOpen: false,
});
});
it("sets autoOpen only when the param equals the session token (same CSRF gate as autoConnect)", () => {
const ok = parseDeepLink(
`?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&autoConnect=${TOKEN}&autoOpen=${TOKEN}`,
TOKEN,
);
expect(ok?.autoOpen).toBe(true);
const wrong = parseDeepLink(
`?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&autoConnect=${TOKEN}&autoOpen=1`,
TOKEN,
);
expect(wrong?.autoOpen).toBe(false);
});
it("honors transport=sse and ignores unknown transport values", () => {
const sse = parseDeepLink(
"?serverUrl=https%3A%2F%2Fexample.com%2Fsse&transport=sse&autoConnect=" +
TOKEN,
TOKEN,
);
expect(sse?.serverConfig).toEqual({
type: "sse",
url: "https://example.com/sse",
});
const bogus = parseDeepLink(
"?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&transport=stdio&autoConnect=" +
TOKEN,
TOKEN,
);
expect(bogus?.serverConfig.type).toBe("streamable-http");
});
it("decodes base64url appArgs into an object", () => {
const args = { zip: "10001", category: "electrician" };
const encoded = btoa(JSON.stringify(args))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const link = parseDeepLink(
`?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&autoConnect=${TOKEN}&openApp=get_pros&appArgs=${encoded}`,
TOKEN,
);
expect(link?.openApp).toBe("get_pros");
expect(link?.appArgs).toEqual(args);
});
it("falls back to {} for malformed or non-object appArgs", () => {
for (const bad of ["!!!", btoa("[1,2,3]"), btoa('"string"')]) {
const link = parseDeepLink(
`?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&autoConnect=${TOKEN}&appArgs=${encodeURIComponent(bad)}`,
TOKEN,
);
expect(link?.appArgs).toEqual({});
}
});
it("normalizes serverUrl (host case, trailing slash) to match the OAuth-store key form", () => {
const link = parseDeepLink(
"?serverUrl=" +
encodeURIComponent("https://Example.COM") +
"&autoConnect=" +
TOKEN,
TOKEN,
);
expect(link?.serverConfig).toEqual({
type: "streamable-http",
url: "https://example.com/",
});
});
});
describe("deepLinkParseStatus", () => {
it("returns 'none' when no deep-link params are present", () => {
expect(deepLinkParseStatus("", undefined)).toBe("none");
expect(deepLinkParseStatus("?foo=bar", undefined)).toBe("none");
});
it("returns 'rejected' when deep-link params are present but parsing failed", () => {
expect(
deepLinkParseStatus(
"?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&autoConnect=wrong",
undefined,
),
).toBe("rejected");
expect(deepLinkParseStatus("?serverUrl=javascript:x", undefined)).toBe(
"rejected",
);
});
it("returns 'parsed' when a DeepLink was produced", () => {
const link = parseDeepLink(
"?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&autoConnect=" + TOKEN,
TOKEN,
);
expect(deepLinkParseStatus("?serverUrl=x&autoConnect=" + TOKEN, link)).toBe(
"parsed",
);
});
});
describe("deepLinkConfigEquals", () => {
const URL_A = "https://example.com/mcp";
const URL_B = "https://example.com/sse";
it("matches when both type and url are identical", () => {
expect(
deepLinkConfigEquals(
{ type: "streamable-http", url: URL_A },
{ type: "streamable-http", url: URL_A },
),
).toBe(true);
});
it("differs when only the type changed (sse↔streamable-http)", () => {
expect(
deepLinkConfigEquals(
{ type: "sse", url: URL_A },
{ type: "streamable-http", url: URL_A },
),
).toBe(false);
});
it("differs when only the url changed", () => {
expect(
deepLinkConfigEquals(
{ type: "streamable-http", url: URL_A },
{ type: "streamable-http", url: URL_B },
),
).toBe(false);
});
});

View file

@ -0,0 +1,180 @@
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
/**
* Parsed deep-link parameters that drive an automated session: the inspector
* connects to `serverConfig` on load, then (if `openApp` is set) switches to
* the Apps tab with that tool selected and `appArgs` pre-filled.
*/
export interface DeepLink {
serverId: string;
serverConfig: MCPServerConfig;
openApp?: string;
appArgs: Record<string, unknown>;
/**
* When true, the Apps screen opens the selected app automatically once it is
* pre-selected and `appArgs` are seeded — no explicit "Open App" click. Same
* CSRF gate as `autoConnect`: the URL value must equal the session's API
* token, so a third-party-minted link cannot auto-invoke a tool.
*/
autoOpen: boolean;
}
/**
* Stable, URL-safe id for the ad-hoc server entry a deep link creates. Reusing
* one id (rather than a fresh uuid per load) means a reload reconnects to the
* same catalog row instead of accumulating duplicates.
*/
export const DEEP_LINK_SERVER_ID = "deep-link";
const ALLOWED_TRANSPORTS = new Set(["http", "sse"]);
function decodeAppArgs(encoded: string | null): Record<string, unknown> {
if (!encoded) return {};
// base64url → base64
const b64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
let json: string;
try {
json = atob(padded);
} catch {
return {};
}
try {
const parsed: unknown = JSON.parse(json);
return parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
/**
* Reject any `serverUrl` that is not a well-formed `http:`/`https:` URL. The
* connect form already accepts arbitrary URLs from the user, so this is not a
* new capability — but a deep link can be crafted by a third party, and we do
* not want a click to drive a `javascript:` / `file:` / `data:` value into the
* connect path.
*
* Loopback and private-range hosts are intentionally **not** blocked here:
* connecting to a locally running MCP server is the inspector's primary
* development use case, and the manual connect form imposes no such
* restriction either. The CSRF gate on `autoConnect` (see
* {@link parseDeepLink}) is what prevents a third-party page from driving a
* connect the user did not initiate.
*
* The returned value is `URL.href`, which canonicalizes host case and adds the
* root path for a bare-host URL — the same shape the OAuth store keys server
* URLs under, so a token saved by the web inspector is found by the CLI's
* `--use-stored-auth` lookup (and vice versa) regardless of how the URL was
* typed.
*/
function validateServerUrl(raw: string): string | undefined {
let url: URL;
try {
url = new URL(raw);
} catch {
return undefined;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
return url.href;
}
/**
* Shallow equality for the subset of {@link MCPServerConfig} a deep link can
* produce. Used by the auto-connect effect to decide whether the persisted
* `deep-link` catalog row needs updating before connecting — comparing only
* `url` (the previous behavior) misses a stale `type` (sse↔streamable-http)
* and would connect with the wrong transport.
*/
export function deepLinkConfigEquals(
a: MCPServerConfig,
b: MCPServerConfig,
): boolean {
if (a.type !== b.type) return false;
const aUrl = "url" in a ? a.url : undefined;
const bUrl = "url" in b ? b.url : undefined;
return aUrl === bUrl;
}
/**
* Outcome of {@link parseDeepLink} for the page's initial URL, surfaced as
* `data-deeplink` on the `connection-status` testid so an automated driver can
* distinguish "no deep link" from "deep link present but rejected" (token
* mismatch / bad serverUrl) — both look the same to a poll on `data-status`.
*/
export type DeepLinkParseStatus = "none" | "parsed" | "rejected";
/**
* Classify the initial-URL deep-link outcome without re-parsing. `undefined`
* from {@link parseDeepLink} is ambiguous (no params vs. rejected); this
* inspects the raw search string to tell them apart.
*/
export function deepLinkParseStatus(
search: string,
parsed: DeepLink | undefined,
): DeepLinkParseStatus {
if (parsed) return "parsed";
const params = new URLSearchParams(search);
return params.has("serverUrl") || params.has("autoConnect")
? "rejected"
: "none";
}
/**
* Parse the page's query string into a {@link DeepLink}. Returns `undefined`
* when no deep link is present **or** when the security gate fails.
*
* Security gate: `autoConnect` must equal the session's API auth token. The
* token is per-launch random and only known to whatever started the web server,
* so a deep link minted by an external page cannot satisfy this check — it
* defeats the "send a developer a crafted localhost URL" SSRF / auto-invocation
* vector while keeping the one-URL automated flow (the launcher knows the
* token, so it can always build a valid link).
*
* Carrying the token in the URL is the same exposure surface as the existing
* `?MCP_INSPECTOR_API_TOKEN=…` query param the launcher banner already prints
* (see `getAuthToken()` in `App.tsx`): the value is ephemeral (regenerated per
* launch), the page is loopback-only, and it never crosses to a third-party
* referer because the inspector's own backend serves every navigation.
*/
export function parseDeepLink(
search: string,
authToken: string | undefined,
): DeepLink | undefined {
const params = new URLSearchParams(search);
const rawServerUrl = params.get("serverUrl");
const autoConnect = params.get("autoConnect");
if (!rawServerUrl || !autoConnect) return undefined;
if (!authToken || autoConnect !== authToken) return undefined;
const serverUrl = validateServerUrl(rawServerUrl);
if (!serverUrl) return undefined;
const transportParam = params.get("transport") ?? "http";
const transport = ALLOWED_TRANSPORTS.has(transportParam)
? transportParam
: "http";
const serverConfig: MCPServerConfig =
transport === "sse"
? { type: "sse", url: serverUrl }
: { type: "streamable-http", url: serverUrl };
const openApp = params.get("openApp") ?? undefined;
const appArgs = decodeAppArgs(params.get("appArgs"));
// autoOpen is gated on the same per-launch token as autoConnect (already
// validated above), so the mere presence of the param is sufficient here —
// a link that reached this line has already proven knowledge of the token.
const autoOpen = params.get("autoOpen") === authToken;
return {
serverId: DEEP_LINK_SERVER_ID,
serverConfig,
openApp,
appArgs,
autoOpen,
};
}

205
docs/mcp-app-review.md Normal file
View file

@ -0,0 +1,205 @@
# 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 **both**
ports:
```bash
ssh -L 6274:127.0.0.1:6274 -L 6275:127.0.0.1:6275 <remote-host>
```
Then open `http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=$TOKEN` locally.
- Use `127.0.0.1`, **not** `localhost` — the backend's origin guard checks the
literal `Origin` header against the configured `HOST`.
- 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 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.