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..cfdc2c3cd 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -539,31 +539,64 @@ async function waitForStoredToken( } } +/** + * Derive the web deep-link `transport` value (`http` | `sse`) for a handoff. + * 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. + */ +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" }); + 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: 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, @@ -897,7 +930,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 }; }