Merge branch 'v2/1666-print-handoff' into v2/1579-wave4-rollup
This commit is contained in:
commit
43980fd68c
3 changed files with 99 additions and 14 deletions
|
|
@ -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.
|
**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://<host>:<port>/?serverUrl=<url>&transport=<http|sse>&autoConnect=<token>` — 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
|
```bash
|
||||||
# On a remote VM: print what a human needs to complete OAuth in their browser.
|
# On a remote VM: print what a human needs to complete OAuth in their browser.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { join, dirname } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { runCli } from "./helpers/cli-runner.js";
|
import { runCli } from "./helpers/cli-runner.js";
|
||||||
import { expectCliFailure, expectCliSuccess } from "./helpers/assertions.js";
|
import { expectCliFailure, expectCliSuccess } from "./helpers/assertions.js";
|
||||||
import { normalizeServerUrl } from "../src/cli.js";
|
import { normalizeServerUrl, deepLinkTransport } from "../src/cli.js";
|
||||||
import {
|
import {
|
||||||
createTestServerHttp,
|
createTestServerHttp,
|
||||||
createEchoTool,
|
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", () => {
|
describe("--use-stored-auth", () => {
|
||||||
let server: ReturnType<typeof createTestServerHttp>;
|
let server: ReturnType<typeof createTestServerHttp>;
|
||||||
let serverUrl: string;
|
let serverUrl: string;
|
||||||
|
|
@ -258,6 +279,8 @@ describe("--print-handoff", () => {
|
||||||
expect(out.serverUrl).toBe("https://x.example/mcp");
|
expect(out.serverUrl).toBe("https://x.example/mcp");
|
||||||
expect(out.deepLink).toContain("autoConnect=tok123");
|
expect(out.deepLink).toContain("autoConnect=tok123");
|
||||||
expect(out.deepLink).toContain("serverUrl=https%3A%2F%2Fx.example%2Fmcp");
|
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 16274:16274");
|
||||||
expect(out.portForwardCmd).toContain("--tcp 16275:16275");
|
expect(out.portForwardCmd).toContain("--tcp 16275:16275");
|
||||||
expect(out.oauthStatePath).toBe(
|
expect(out.oauthStatePath).toBe(
|
||||||
|
|
@ -266,6 +289,33 @@ describe("--print-handoff", () => {
|
||||||
expect(out.apiToken).toBe("tok123");
|
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 () => {
|
it("includes a note when MCP_INSPECTOR_API_TOKEN is unset", async () => {
|
||||||
const result = await runCli(
|
const result = await runCli(
|
||||||
["--print-handoff", "--server-url", "https://x.example/mcp"],
|
["--print-handoff", "--server-url", "https://x.example/mcp"],
|
||||||
|
|
|
||||||
|
|
@ -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
|
* 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
|
* to relay to a human so they can complete OAuth in a browser and have the
|
||||||
* token land where the CLI will find it.
|
* token land where the CLI will find it.
|
||||||
*
|
*
|
||||||
* NOTE: the `deepLink` query shape (serverUrl/transport/autoConnect) is the
|
* The `deepLink` is the canonical web format owned by
|
||||||
* interim format pending the web deep-link auto-connect work (#1576); reconcile
|
* `clients/web/src/utils/deepLink.ts` (#1576): `?serverUrl&transport&autoConnect`,
|
||||||
* with that issue's canonical handoff format once it lands.
|
* 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 host = process.env.HOST || "127.0.0.1";
|
||||||
const clientPort = process.env.CLIENT_PORT || "6274";
|
const clientPort = process.env.CLIENT_PORT || "6274";
|
||||||
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
|
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
|
||||||
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
|
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
|
||||||
// can't satisfy the deep-link autoConnect gate.
|
// can't satisfy the deep-link autoConnect gate.
|
||||||
const apiToken = process.env.MCP_INSPECTOR_API_TOKEN || undefined;
|
const apiToken = process.env.MCP_INSPECTOR_API_TOKEN || undefined;
|
||||||
// TODO(#1576): interim deep-link shape. `transport: "http"` is hardcoded even
|
const normalizedUrl = normalizeServerUrl(serverUrl);
|
||||||
// for SSE servers, and `autoConnect=<token>` is NOT one of the three token
|
// Canonical #1576 deep-link shape: the normalized serverUrl (matching the
|
||||||
// sources the web app reads (window.__INSPECTOR_API_TOKEN__ /
|
// OAuth-store key form the web app reuses) plus the resolved transport, gated
|
||||||
// ?MCP_INSPECTOR_API_TOKEN / sessionStorage — see CLAUDE.md). Reconcile both
|
// by `autoConnect=<token>` — the same per-launch token the web parser
|
||||||
// with #1576's canonical handoff/deep-link format once it lands.
|
// requires. Omitted when no token is set; the `note` below flags that the
|
||||||
const params = new URLSearchParams({ serverUrl, transport: "http" });
|
// 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);
|
if (apiToken) params.set("autoConnect", apiToken);
|
||||||
return {
|
return {
|
||||||
serverUrl: normalizeServerUrl(serverUrl),
|
serverUrl: normalizedUrl,
|
||||||
deepLink: `http://${host}:${clientPort}/?${params.toString()}`,
|
deepLink: `http://${host}:${clientPort}/?${params.toString()}`,
|
||||||
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
|
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
|
||||||
oauthStatePath: statePath,
|
oauthStatePath: statePath,
|
||||||
|
|
@ -897,7 +930,9 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
||||||
throw new Error("--print-handoff requires --server-url");
|
throw new Error("--print-handoff requires --server-url");
|
||||||
}
|
}
|
||||||
await awaitableLog(
|
await awaitableLog(
|
||||||
JSON.stringify(buildHandoff(options.serverUrl, oauthStatePath)) + "\n",
|
JSON.stringify(
|
||||||
|
buildHandoff(options.serverUrl, oauthStatePath, options.transport),
|
||||||
|
) + "\n",
|
||||||
);
|
);
|
||||||
return { shortCircuit: true };
|
return { shortCircuit: true };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue