Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion clients/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sec>` | Poll the OAuth state file (500 ms interval) until an access token for `--server-url` appears, then inject it. Times out at `<sec>` 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` → `<MCP_STORAGE_DIR>/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.

Expand Down
51 changes: 51 additions & 0 deletions clients/cli/__tests__/stored-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion clients/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort} --tcp ${appOriginPort}:${appOriginPort}`,
oauthStatePath: statePath,
apiToken: apiToken ?? null,
Expand Down
Loading