Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 2 additions & 0 deletions clients/launcher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"author": "The MCP Maintainers and Community",
"type": "module",
"bin": {
"inspector": "./clients/launcher/build/index.js",
Comment thread
cliffhall marked this conversation as resolved.
"mcp-inspector": "./clients/launcher/build/index.js",
"mcpdo": "./clients/mcpdo/build/mcp-bin.js"
},
Expand Down
37 changes: 37 additions & 0 deletions scripts/lib/npx-default-bin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Which bin `npx <package>` / `npm exec <package>` 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<string, string> }} 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 `{ <unscoped name>: <path> }` 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;
}
72 changes: 72 additions & 0 deletions scripts/lib/npx-default-bin.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
35 changes: 34 additions & 1 deletion scripts/pack-and-verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading