diff --git a/clients/cli/README.md b/clients/cli/README.md index 2d2b11157..408977fc0 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -507,7 +507,7 @@ For the common case where OAuth was already completed in the **web inspector on | `--use-stored-auth` | Read the stored auth for `--server-url` and inject `Authorization: Bearer`. When a `refresh_token` is stored, the CLI runs the OAuth refresh grant first and injects the **fresh** access token (persisting the rotation); otherwise it injects the stored access token. Exits `3` (`no_stored_token`) — listing the stored keys — when nothing matches. Requires `--server-url`. | | `--wait-for-auth ` | Poll the OAuth state file (500 ms interval) until an access token for `--server-url` appears, then inject it. Times out at `` with exit `3` (`auth_wait_timeout`). Use after handing off to a human to complete OAuth in a browser. Unlike `--use-stored-auth`, this injects the freshly-landed access token directly (a token that just completed the browser flow is not expired), so it does **not** run the refresh grant. | | `--list-stored-auth` | Print `{ oauthStatePath, storedServerUrls }` (the server keys that currently have a token) and exit. No server connection is made. | -| `--print-handoff` | Print a JSON handoff block (`deepLink`, `portForwardCmd`, `oauthStatePath`, `apiToken`) for `--server-url` and exit — everything a script/remote VM needs to drive the browser-side OAuth dance. Requires `--server-url`. | +| `--print-handoff` | Print a JSON handoff block (`deepLink`, `portForwardCmd`, `oauthStatePath`, `apiToken`) for `--server-url` and exit — everything a script/remote VM needs to drive the browser-side OAuth dance. The `deepLink` is `https` when both `INSPECTOR_TLS_KEY_PATH` and `INSPECTOR_TLS_CERT_PATH` are set, matching a web UI served over TLS. Requires `--server-url`. | **State-file resolution** follows `MCP_INSPECTOR_OAUTH_STATE_PATH` → `/oauth.json` → `~/.mcp-inspector/storage/oauth.json` — the same precedence the rest of the Inspector uses, so the CLI and web backend agree on the file. Server keys are canonicalised with `new URL().href` (the scheme the web store writes), so a trailing-slash or case mismatch between the URL a human opened and the one the agent passed still resolves. diff --git a/clients/cli/__tests__/stored-auth.test.ts b/clients/cli/__tests__/stored-auth.test.ts index 27c05655c..fa272dca8 100644 --- a/clients/cli/__tests__/stored-auth.test.ts +++ b/clients/cli/__tests__/stored-auth.test.ts @@ -782,6 +782,57 @@ describe("--print-handoff", () => { expect(out.deepLink.startsWith("http://localhost:16274/?")).toBe(true); }); + // The web listener serves https only when both TLS paths are set (#2446), so + // a handoff for it must link https or the page cannot load (#2770). + it.each([ + [ + "both TLS paths set", + { INSPECTOR_TLS_KEY_PATH: "/k.pem", INSPECTOR_TLS_CERT_PATH: "/c.pem" }, + "https", + ], + [ + "only the key path set", + { INSPECTOR_TLS_KEY_PATH: "/k.pem", INSPECTOR_TLS_CERT_PATH: "" }, + "http", + ], + [ + "only the cert path set", + { INSPECTOR_TLS_KEY_PATH: "", INSPECTOR_TLS_CERT_PATH: "/c.pem" }, + "http", + ], + [ + "a blank key path", + { INSPECTOR_TLS_KEY_PATH: " ", INSPECTOR_TLS_CERT_PATH: "/c.pem" }, + "http", + ], + ])( + "links the web UI's scheme in the deep link with %s", + async (_case, tlsEnv, scheme) => { + const result = await runCli( + ["--print-handoff", "--server-url", "https://x.example/mcp"], + { + env: { + MCP_INSPECTOR_API_TOKEN: "tok123", + HOST: "127.0.0.1", + CLIENT_PORT: "16274", + ...tlsEnv, + }, + }, + ); + expectCliSuccess(result); + const out = JSON.parse(result.stdout) as { + deepLink: string; + portForwardCmd: string; + }; + expect(out.deepLink.startsWith(`${scheme}://127.0.0.1:16274/?`)).toBe( + true, + ); + // The sandbox and app-origin ports stay plain http under TLS, and the + // port-forward command forwards ports, not schemes, so it is unchanged. + expect(out.portForwardCmd).toContain("--tcp 16274:16274"); + }, + ); + it("classifies a bad --callback-url as a usage error, not auth_required", async () => { // The guard's message contains "OAuth"; without the explicit exit-code pin // the heuristic would map it to auth_required (exit 3) and tell an automated diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 034e38c83..2a75a45f3 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -727,6 +727,17 @@ function buildHandoff( ? "localhost" : canonicalUrlHost(host); const clientPort = process.env.CLIENT_PORT || "6274"; + // The web UI listener serves https only when BOTH TLS paths are set (#2446; + // `resolveTlsConfig` in clients/web/server/tls.ts, where a blank value counts + // as unset). An http link to that listener cannot load (#2770). A half pair + // stays http: the web server refuses to start on one, so there is no https + // listener to point at. The sandbox and app-origin ports stay plain http + // under TLS by design, so only the deep link's scheme follows this. + const scheme = + process.env.INSPECTOR_TLS_KEY_PATH?.trim() && + process.env.INSPECTOR_TLS_CERT_PATH?.trim() + ? "https" + : "http"; const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275"; // The dedicated app origin (#2056). Forwarded alongside the other two: an App // whose UI resource declares `_meta.ui.domain` is served from this port and @@ -749,7 +760,7 @@ function buildHandoff( if (apiToken) params.set("autoConnect", apiToken); return { serverUrl: normalizedUrl, - deepLink: `http://${linkHost}:${clientPort}/?${params.toString()}`, + deepLink: `${scheme}://${linkHost}:${clientPort}/?${params.toString()}`, portForwardCmd: `coder port-forward --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort} --tcp ${appOriginPort}:${appOriginPort}`, oauthStatePath: statePath, apiToken: apiToken ?? null,