web: deep-link auto-connect + machine-readable connection status (#1576)

Closes #1576

Adds `?serverUrl=<url>&transport=http|sse&autoConnect=<token>` URL-driven
auto-connect so a driver can reach a connected inspector with one navigate.

- `clients/web/src/utils/deepLink.ts`: `parseDeepLink()` (http(s)-only
  serverUrl; `autoConnect` CSRF-gated on the per-launch API token),
  `deepLinkParseStatus()`, `deepLinkConfigEquals()`, `DEEP_LINK_SERVER_ID`
  (stable `deep-link` row so reloads reconnect to the same catalog row).
- App.tsx: ensure/connect effects (two-phase upsert-then-connect against the
  hydrated list), `serversRef` read in `onToggleConnection`,
  `connectErrorMessage` / `recordConnectError` recording connection-level
  failures.
- InspectorView header: `data-testid="connection-status"` with `data-status`,
  `data-error-message`, `data-deeplink` so drivers can `waitForSelector` and
  read failure reasons without scraping toasts.
- Docs: deep-link scheme + `data-*` contract in clients/web/README.md.

Re-implemented informed by PR #1510 (33fac3f); adapted to the post-#1417
lifted-tab architecture and dropping the non-existent `normalizeServerUrl`
import in favor of `URL.href` canonicalization. Coverage gate green
(deepLink.ts 97.8/95.1/100/97.4).

Part of the #1579 decomposition (Wave 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
cliffhall 2026-07-11 22:48:28 -04:00
commit 0e93b13426
6 changed files with 575 additions and 3 deletions

View file

@ -71,6 +71,31 @@ 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. |
The `openApp` / `appArgs` / `autoOpen` params extend this to land on a rendered MCP App; see the [MCP Apps screen automation contract](#mcp-apps-screen-automation-contract) above for the app-side `data-*` signals.
## 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,36 @@ 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 deepLinkConnectRef = useRef(false);
const showReAuthBanner = useCallback(
(
serverId: string,
@ -2338,7 +2374,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 +2394,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 +2475,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 +2490,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 +2502,6 @@ function App() {
activeServerId,
connectionStatus,
inspectorClient,
servers,
setupClientForServer,
prepareOAuthRedirect,
finalizeExplicitDisconnect,
@ -2473,6 +2517,61 @@ 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 two phases: first render attempts a one-shot `addServer` (a 409 means
// the row already exists on disk and hydration will surface it); a later
// render where `servers` actually contains the deep-link row then updates the
// URL if it has changed and connects. Connecting in the same closure as
// `addServer` would resolve against a stale (empty) `servers` and silently
// no-op. 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(() => {
// 409 = already on disk; hydration will surface it on a later render.
});
return;
}
if (deepLinkConnectRef.current) return;
deepLinkConnectRef.current = true;
void (async () => {
if (!deepLinkConfigEquals(existing.config, deepLink.serverConfig)) {
await updateServer(
deepLink.serverId,
deepLink.serverId,
deepLink.serverConfig,
);
}
if (activeServerId !== deepLink.serverId) {
await onToggleConnection(deepLink.serverId);
}
})().catch((err) => {
// Surface deep-link automation failures (updateServer 5xx, connect throw)
// on the machine-readable error attribute instead of dropping them. The
// toast still fires from inside `onToggleConnection` for the common
// cases; this catch covers the rest.
const message = err instanceof Error ? err.message : String(err);
recordConnectError(message);
});
}, [
deepLink,
servers,
activeServerId,
addServer,
updateServer,
onToggleConnection,
recordConnectError,
]);
const onReauthenticateFromBanner = useCallback(() => {
if (!reAuthBanner) return;
const serverId = reAuthBanner.serverId;
@ -3691,11 +3790,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

@ -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

View file

@ -6,6 +6,7 @@ import {
type ReactNode,
type Ref,
} from "react";
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
import {
AppShell,
Flex,
@ -304,6 +305,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 +340,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 +520,13 @@ export interface InspectorViewProps {
}
export function InspectorView({
deepLinkStatus,
servers: serversInput,
serverListWritable = true,
activeServer,
erroredServerId,
connectionStatus,
connectErrorMessage,
initializeResult,
latencyMs,
tools,
@ -1037,7 +1062,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

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,
};
}