From 93f3fb64f2d3f10ead33374d7894fa80cfd4233e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 23:02:45 -0400 Subject: [PATCH 1/2] cli: reconcile --print-handoff deep-link with #1576 canonical format (#1666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1666 #1575 shipped `--print-handoff` with an interim deep-link shape (hardcoded `transport: "http"`, marked `TODO(#1576)`). Now that #1576 owns the canonical `?serverUrl&transport&autoConnect` format, `--print-handoff` emits exactly it: - New `deepLinkTransport(serverUrl, transport)` helper derives `http`|`sse` from the resolved server — explicit `--transport` wins, else auto-detect from the URL path (`/sse` → sse), else default `http`. Unlike the core resolver it defaults instead of throwing (the handoff is best-effort; the web parser defaults an unknown transport to http too). - `buildHandoff` takes the transport, emits the normalized serverUrl + derived transport in the query, and keeps `autoConnect=` (which #1576 adopted as the canonical CSRF gate, so the interim guess was already correct). - Removed the `TODO(#1576)` marker; updated the doc comment + CLI README. Tests: handoff emits `transport=http` for `/mcp`, `transport=sse` for `/sse` and for explicit `--transport sse`; `deepLinkTransport` unit tests cover the explicit/auto/default/stdio/unparseable branches. CLI coverage gate green (cli.ts 100/92.8/100/100). Part of the #1579 decomposition (Wave 4). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- clients/cli/README.md | 2 +- clients/cli/__tests__/stored-auth.test.ts | 52 ++++++++++++++++++++- clients/cli/src/cli.ts | 55 ++++++++++++++++++----- 3 files changed, 96 insertions(+), 13 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index 3563e870e..4494fb42d 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -204,7 +204,7 @@ For the common case where OAuth was already completed in the **web inspector on **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://:/?serverUrl=&transport=&autoConnect=` — 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. diff --git a/clients/cli/__tests__/stored-auth.test.ts b/clients/cli/__tests__/stored-auth.test.ts index 69be24f59..fe7c694c9 100644 --- a/clients/cli/__tests__/stored-auth.test.ts +++ b/clients/cli/__tests__/stored-auth.test.ts @@ -4,7 +4,7 @@ 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 } from "../src/cli.js"; import { createTestServerHttp, createEchoTool, @@ -36,6 +36,27 @@ 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("--use-stored-auth", () => { let server: ReturnType; let serverUrl: string; @@ -258,6 +279,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 +289,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"], diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 059338db5..c4ac4e7ac 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -539,28 +539,59 @@ async function waitForStoredToken( } } +/** + * Derive the web deep-link `transport` value (`http` | `sse`) for a handoff. + * Mirrors {@link resolveServerConfig}'s 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=` 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" }); + // 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=` — 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: normalizeServerUrl(serverUrl), + transport: deepLinkTransport(serverUrl, transport), + }); if (apiToken) params.set("autoConnect", apiToken); return { serverUrl: normalizeServerUrl(serverUrl), @@ -897,7 +928,9 @@ async function parseArgs(argv?: string[]): Promise { 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 }; } From ac49000738e63a46185b803e56d087b16a67f60d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 23:23:19 -0400 Subject: [PATCH 2/2] =?UTF-8?q?cli:=20address=20#1672=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20correct=20doc=20ref=20+=20hoist=20normalizeServerUr?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The JSDoc referenced a non-existent `resolveServerConfig`; name the real `resolveServerConfigs` (core/mcp/node/config.ts) in plain text (it isn't imported here, so an @link wouldn't resolve). - Hoist the doubly-computed `normalizeServerUrl(serverUrl)` in buildHandoff to a single `normalizedUrl` reused for the query param and the serverUrl field. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- clients/cli/src/cli.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index c4ac4e7ac..cfdc2c3cd 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -541,7 +541,8 @@ async function waitForStoredToken( /** * Derive the web deep-link `transport` value (`http` | `sse`) for a handoff. - * Mirrors {@link resolveServerConfig}'s URL-path auto-detection (`/sse` → sse, + * 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. @@ -583,18 +584,19 @@ function buildHandoff( // 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; + 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=` — 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: normalizeServerUrl(serverUrl), + 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 --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`, oauthStatePath: statePath,