inspector/clients/web/src/utils/deepLink.test.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

199 lines
5.7 KiB
TypeScript

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