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:
parent
a029e42a19
commit
0e93b13426
2 changed files with 575 additions and 3 deletions
199
clients/web/src/utils/deepLink.test.ts
Normal file
199
clients/web/src/utils/deepLink.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
180
clients/web/src/utils/deepLink.ts
Normal file
180
clients/web/src/utils/deepLink.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue