inspector/clients/web/src/utils/deepLink.ts
cliffhall 0e93b13426 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
2026-07-11 22:48:28 -04:00

180 lines
6.6 KiB
TypeScript

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