diff --git a/README.md b/README.md index 53e031ab6..7a7e7f388 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # MCP Inspector -A developer tool for inspecting [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers. It ships as a single package, `@modelcontextprotocol/inspector`, that provides three ways to inspect a server: +A developer tool for inspecting [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers. It ships as a single package, `@modelcontextprotocol/inspector`, that provides four ways to inspect a server: - **Web** — a Vite + React + [Mantine](https://mantine.dev) single-page app with a Node backend. - **CLI** — a scriptable command-line client for automation, CI, and fast agent feedback loops. - **TUI** — an interactive terminal UI built with [Ink](https://github.com/vadimdemedes/ink). +- **mcpdo** _(experimental)_ — a connection CLI: connect once, then run many commands against the named connection through an implicit local daemon. See [`clients/mcpdo`](./clients/mcpdo/README.md). -All three run through one global `mcp-inspector` binary: +The first three run through one global `mcp-inspector` binary; `mcpdo` is a second binary in the same package: ```bash -npx @modelcontextprotocol/inspector # web UI (default) -npx @modelcontextprotocol/inspector --cli # CLI -npx @modelcontextprotocol/inspector --tui # TUI +npx @modelcontextprotocol/inspector # web UI (default) +npx @modelcontextprotocol/inspector --cli # CLI +npx @modelcontextprotocol/inspector --tui # TUI +npx -p @modelcontextprotocol/inspector mcpdo --help # mcpdo ``` > [!WARNING] diff --git a/clients/launcher/README.md b/clients/launcher/README.md index dade4b0d1..af91f9a6e 100644 --- a/clients/launcher/README.md +++ b/clients/launcher/README.md @@ -2,6 +2,8 @@ The launcher is the package that provides the global `mcp-inspector` binary (e.g. when users run `npx @modelcontextprotocol/inspector`). It is not a separate user-facing app—it is the single entrypoint that selects and runs one of the clients (web, CLI, or TUI). +The root `package.json` also publishes it as an `inspector` bin. That alias is what makes a bare `npx @modelcontextprotocol/inspector` work: the package ships a second bin (`mcpdo`), and with more than one distinct bin npm only runs the one named after the unscoped package name. Without that alias npx fails with "could not determine executable to run", which is how 2.10.0 shipped (#2651). `scripts/lib/npx-default-bin.test.mjs` and `pack:verify` both check this. + ## Responsibility - Parse mode from a leading prefix of `--web` (default), `--cli`, or `--tui` immediately after the script name. diff --git a/package-lock.json b/package-lock.json index 7b5c7f073..b8ffcc4c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "zod": "^4.4.3" }, "bin": { + "inspector": "clients/launcher/build/index.js", "mcp-inspector": "clients/launcher/build/index.js", "mcpdo": "clients/mcpdo/build/mcp-bin.js" }, diff --git a/package.json b/package.json index 51931a956..258b08e2b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "author": "The MCP Maintainers and Community", "type": "module", "bin": { + "inspector": "./clients/launcher/build/index.js", "mcp-inspector": "./clients/launcher/build/index.js", "mcpdo": "./clients/mcpdo/build/mcp-bin.js" }, diff --git a/scripts/lib/npx-default-bin.mjs b/scripts/lib/npx-default-bin.mjs new file mode 100644 index 000000000..848e83f8a --- /dev/null +++ b/scripts/lib/npx-default-bin.mjs @@ -0,0 +1,37 @@ +/** + * Which bin `npx ` / `npm exec ` runs, by npm's own rule + * (#2651). + * + * A mirror of `getBinFromManifest` in npm's bundled `libnpmexec` + * (`lib/get-bin-from-manifest.js`): run the sole bin when every entry points at + * the same file; otherwise run the bin named after the package's **unscoped** + * name; otherwise npm fails with "could not determine executable to run". + * + * 2.10.0 shipped exactly that failure. Adding `mcpdo` gave the package a second + * distinct bin, and nothing named `inspector` existed, so the documented + * `npx @modelcontextprotocol/inspector` stopped working for everyone — while + * `pack:verify` and every smoke stayed green, because they all invoke the + * installed bin by name and never ask npm to choose one. This is the offline + * half of the guard (its test asserts the root manifest); `pack:verify` runs + * the installed tarball through `npm exec` for the online half. + * + * Mirrored rather than imported: `libnpmexec` is npm's internal dependency, not + * one of ours, and its location depends on how Node was installed. + */ + +/** + * @param {{ name: string, bin?: string | Record }} manifest + * @returns {string | null} the bin name npm would run, or null where npm fails + */ +export function npxDefaultBin(manifest) { + // npm normalizes a string `bin` to `{ : }` on publish. + const name = manifest.name.replace(/^@[^/]+\//, ""); + const bin = + typeof manifest.bin === "string" + ? { [name]: manifest.bin } + : (manifest.bin ?? {}); + const entries = Object.keys(bin); + if (new Set(Object.values(bin)).size === 1) return entries[0]; + if (bin[name]) return name; + return null; +} diff --git a/scripts/lib/npx-default-bin.test.mjs b/scripts/lib/npx-default-bin.test.mjs new file mode 100644 index 000000000..10984d649 --- /dev/null +++ b/scripts/lib/npx-default-bin.test.mjs @@ -0,0 +1,72 @@ +// Tests for `npx-default-bin.mjs` (#2651): npm's default-bin rule, and the +// assertion that the root manifest — the one that publishes — resolves +// `npx @modelcontextprotocol/inspector` to the launcher. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { npxDefaultBin } from "./npx-default-bin.mjs"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); +const LAUNCHER = "./clients/launcher/build/index.js"; + +test("the root manifest's npx default bin is the launcher", () => { + const manifest = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), + ); + const bin = npxDefaultBin(manifest); + assert.notEqual( + bin, + null, + "npx @modelcontextprotocol/inspector would fail: no single bin and none named after the package", + ); + assert.equal(manifest.bin[bin], LAUNCHER); +}); + +test("a sole bin is run whatever its name", () => { + assert.equal( + npxDefaultBin({ name: "@s/pkg", bin: { "mcp-inspector": LAUNCHER } }), + "mcp-inspector", + ); +}); + +test("aliases of one file count as a sole bin", () => { + assert.equal( + npxDefaultBin({ name: "@s/pkg", bin: { a: LAUNCHER, b: LAUNCHER } }), + "a", + ); +}); + +test("several distinct bins resolve to the one named after the unscoped package", () => { + assert.equal( + npxDefaultBin({ + name: "@s/inspector", + bin: { inspector: LAUNCHER, mcpdo: "./mcpdo.js" }, + }), + "inspector", + ); +}); + +test("several distinct bins with none named after the package fail (the 2.10.0 shape)", () => { + assert.equal( + npxDefaultBin({ + name: "@modelcontextprotocol/inspector", + bin: { "mcp-inspector": LAUNCHER, mcpdo: "./mcpdo.js" }, + }), + null, + ); +}); + +test("a string bin is named after the unscoped package", () => { + assert.equal(npxDefaultBin({ name: "@s/pkg", bin: LAUNCHER }), "pkg"); +}); + +test("no bin at all fails", () => { + assert.equal(npxDefaultBin({ name: "pkg" }), null); +}); diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index 7e88ef79b..81b826576 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -29,7 +29,9 @@ * 4. runs the installed `mcp-inspector` bin: `--help`, `--cli`/`--tui` help * dispatch, a real `--cli` `tools/list` over stdio, and a prod `--web` boot * that must serve `/` (HTTP 200) with the injected auth-token global from - * the shipped `dist` — all from the INSTALLED location, not the repo; + * the shipped `dist` — all from the INSTALLED location, not the repo — + * plus `npm exec @modelcontextprotocol/inspector --help`, so npm's own + * default-bin choice is exercised and not just the bin by name (#2651); * 5. drives the **MCP Apps** path in headless Chromium against that same * installed `--web` server — connect → open app → `data-app-status="ready"` * (#2003). Asserting the sandbox proxy page merely *exists* (step 2/3) is @@ -354,6 +356,37 @@ try { } } + // 4a'. The same help, reached the way a user reaches it: through npm's own + // default-bin choice rather than the bin's name (#2651). Every check + // above names `mcp-inspector` directly, which is how 2.10.0 shipped a + // second bin, broke `npx @modelcontextprotocol/inspector` for everyone, + // and stayed green here. `--no` keeps npm on the installed tarball. + step( + "verifying `npm exec @modelcontextprotocol/inspector` picks the launcher...", + ); + const npmExec = spawnSync( + "npm", + shellArgs([ + "exec", + "--no", + "--", + "@modelcontextprotocol/inspector", + "--help", + ]), + { cwd: work, encoding: "utf8", shell: WIN_SHELL }, + ); + const npmExecOutput = `${npmExec.stdout ?? ""}${npmExec.stderr ?? ""}`; + if ( + npmExec.status !== 0 || + !npmExecOutput.includes("Mode flags (--web, --cli, --tui)") + ) { + fail( + `\`npm exec @modelcontextprotocol/inspector --help\` exited ${npmExec.status} ` + + `without the launcher's help — check the root package.json "bin" ` + + `(scripts/lib/npx-default-bin.mjs)\n${npmExecOutput.slice(0, 800)}`, + ); + } + // 4b. Real CLI connect over stdio from the installed package: tools/list must // return the bundled test server's tools. Exercises launcher → cli → core // → stdio transport path from node_modules.