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 docs/publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The root `package.json` `"files"` allowlist is the source of truth for the tarba

## `npm run pack:verify` — publish smoke against the real tarball

The `smoke:*` scripts run against the in-repo build tree, which is **not** the published package. `npm run pack:verify` (`scripts/pack-and-verify.mjs`) closes that gap: it builds, `npm pack`s the publishable tarball (asserting no source maps ship and that the runtime-required files are present), installs the tarball into a **clean throwaway consumer** — a fresh temp directory where it runs a real `npm install <tgz>` (pulls runtime deps, runs `postinstall`), exactly as `npx @modelcontextprotocol/inspector` would — and drives the installed `mcp-inspector` bin end to end: `--help` dispatch; the same help reached through `npm exec @modelcontextprotocol/inspector`, which makes npm choose the bin itself as a bare `npx` does — the package ships two distinct bins (`mcp-inspector`, `mcpdo`), so that choice lands only on a bin named after the unscoped package, the root manifest's `inspector` alias, and every other check here names its bin and cannot see a missing alias ([#2651](https://github.com/modelcontextprotocol/inspector/issues/2651)); a real `--cli tools/list` over stdio, a prod `--web` boot that must serve `/` from the shipped `dist`, and — riding that same boot — an **MCP App rendered in headless Chromium** through the shipped sandbox proxy, connect → open app → `data-app-status="ready"` ([#2003](https://github.com/modelcontextprotocol/inspector/issues/2003)). That last step shares its flow with `smoke:web:app` via `scripts/lib/mcp-app-flow.mjs` rather than copying it, since the deep-link shape is the part that rots silently; the *client* comes from the install while the App test server stays a repo fixture. It catches "works in `--dev`, breaks under `npx …`" path/packaging failures. It requires network access (the install pulls deps), so it is a local / release check, **not** part of the fast `validate` loop or of `local:gate`.
The `smoke:*` scripts run against the in-repo build tree, which is **not** the published package. `npm run pack:verify` (`scripts/pack-and-verify.mjs`) closes that gap: it builds, `npm pack`s the publishable tarball (asserting no source maps ship and that the runtime-required files are present), installs the tarball into a **clean throwaway consumer** — a fresh temp directory where it runs a real `npm install <tgz>` (pulls runtime deps, runs `postinstall`), exactly as `npx @modelcontextprotocol/inspector` would — and drives the installed `mcp-inspector` bin end to end: `--help` dispatch; the same help reached through `npm exec @modelcontextprotocol/inspector`, which makes npm choose the bin itself as a bare `npx` does — the package ships two distinct bins (`mcp-inspector`, `mcpdo`), so that choice lands only on a bin named after the unscoped package, the root manifest's `inspector` alias, and every other check here names its bin and cannot see a missing alias ([#2651](https://github.com/modelcontextprotocol/inspector/issues/2651)); a real `--cli tools/list` over stdio, a prod `--web` boot that must serve `/` from the shipped `dist`, and — riding that same boot — an **MCP App rendered in headless Chromium** through the shipped sandbox proxy, connect → open app → `data-app-status="ready"` ([#2003](https://github.com/modelcontextprotocol/inspector/issues/2003)). That last step shares its flow with `smoke:web:app` via `scripts/lib/mcp-app-flow.mjs` rather than copying it, since the deep-link shape is the part that rots silently; the *client* comes from the install while the App test server stays a repo fixture. The installed **`mcpdo`** bin gets the same treatment ([#2650](https://github.com/modelcontextprotocol/inspector/issues/2650)): the tarball must list `skills/mcpdo/SKILL.md`, which nothing reads at run time and so nothing else would miss; every bare import in `clients/mcpdo/build` must resolve from the consumer install under **ESM** rules — `import.meta.resolve` from inside the package, because `@modelcontextprotocol/ext-tasks` exports only an `import` condition and a CommonJS `require.resolve` reports it missing when it is not; and after `connect`, `@test tools/call echo` must round-trip through the packaged daemon. The daemon directory is deliberately a one-character name under the work dir, since the socket path is capped at 104 bytes on macOS and `$TMPDIR` there is already long. It catches "works in `--dev`, breaks under `npx …`" path/packaging failures. It requires network access (the install pulls deps), so it is a local / release check, **not** part of the fast `validate` loop or of `local:gate`.

## Cutting a release

Expand Down
87 changes: 87 additions & 0 deletions scripts/lib/bundle-bare-imports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Which bare specifiers a built bundle imports, and whether each one resolves
* from a given directory under **ESM** rules (#2650).
*
* `pack:verify` uses this to prove that every package the installed `mcpdo`
* bundle reaches for is actually resolvable from the consumer install — the
* failure a missing root `dependencies` entry or a dropped `external` produces,
* and one that only shows at the moment the import runs. Driving a couple of
* commands cannot see a lazy `import()` on a path those commands never take.
*
* Resolution is ESM on purpose. `@modelcontextprotocol/ext-tasks` exports only
* an `import` condition, so a CommonJS `require.resolve` reports it missing even
* though the bundle — which is ESM — loads it fine. The 2.10.0 hand check hit
* exactly that false failure. So the probe runs `import.meta.resolve` in a
* module placed **inside** the directory being checked, which is the resolver
* and the starting point the bundle itself gets.
*
* Extraction is a statement-anchored scan rather than a parser: esbuild emits
* every static import at column 0, so anchoring at the line start keeps prose
* in the bundle's comments (`… apart from "a digest was wrong" …`) from reading
* as a `from "<specifier>"` clause.
*/

import { spawnSync } from "node:child_process";
import { rmSync, writeFileSync } from "node:fs";
import { isBuiltin } from "node:module";
import { join, resolve } from "node:path";

const STATIC_FROM = /^(?:import|export)\b[^;]*?\bfrom\s*["']([^"']+)["']/gm;
const SIDE_EFFECT = /^import\s*["']([^"']+)["']/gm;
const DYNAMIC = /\bimport\(\s*["']([^"']+)["']\s*\)/g;
Comment thread
cliffhall marked this conversation as resolved.
Resolved

/** True for a specifier npm has to supply: not relative, absolute or a builtin. */
export function isBareSpecifier(specifier) {
return (
!specifier.startsWith(".") &&
!specifier.startsWith("/") &&
!specifier.includes(":") &&
!isBuiltin(specifier)
);
}

/**
* @param {string} source one bundle file's text
* @returns {string[]} the bare specifiers it imports, sorted and de-duplicated
*/
export function collectBareImports(source) {
const found = new Set();
for (const pattern of [STATIC_FROM, SIDE_EFFECT, DYNAMIC]) {
for (const match of source.matchAll(pattern)) {
if (isBareSpecifier(match[1])) found.add(match[1]);
}
}
return [...found].sort();
}

const PROBE_NAME = "__pack_verify_resolve_probe.mjs";
const PROBE = `const failed = [];
for (const s of JSON.parse(process.argv[2])) {
try { import.meta.resolve(s); } catch (e) { failed.push({ specifier: s, error: e.message }); }
}
process.stdout.write(JSON.stringify(failed));
`;

/**
* ESM-resolve each specifier from inside `dir`. The probe module is written
* into `dir` and removed again, so it resolves exactly as a module there does.
*
* @param {{ dir: string, specifiers: string[] }} options
* @returns {{ specifier: string, error: string }[]} the ones that did not resolve
*/
export function unresolvedFrom({ dir, specifiers }) {
const probe = resolve(dir, PROBE_NAME);
writeFileSync(probe, PROBE);
try {
const r = spawnSync(process.execPath, [probe, JSON.stringify(specifiers)], {
cwd: dir,
encoding: "utf8",
});
if (r.status !== 0) {
throw new Error(`resolve probe exited ${r.status}: ${r.stderr}`);
}
return JSON.parse(r.stdout);
} finally {
rmSync(probe, { force: true });
}
}
103 changes: 103 additions & 0 deletions scripts/lib/bundle-bare-imports.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Tests for `bundle-bare-imports.mjs` (#2650): the bare-specifier scan over a
// bundle, and ESM resolution from inside a directory — including the
// import-only `exports` shape a CommonJS `require.resolve` misreports.

import { test } from "node:test";
import assert from "node:assert/strict";
import {
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
collectBareImports,
isBareSpecifier,
unresolvedFrom,
} from "./bundle-bare-imports.mjs";

test("isBareSpecifier excludes relative, absolute, URL and builtin specifiers", () => {
assert.equal(isBareSpecifier("zod"), true);
assert.equal(isBareSpecifier("@modelcontextprotocol/client/stdio"), true);
for (const s of [
"./chunk.js",
"../x.js",
"/abs.js",
"node:fs",
"fs",
"fs/promises",
"readline/promises",
]) {
assert.equal(isBareSpecifier(s), false, s);
}
});

test("collectBareImports finds static, multi-line, re-export, side-effect and dynamic imports", () => {
const source = [
'import { a } from "zod";',
"import {",
" b,",
" c",
'} from "@modelcontextprotocol/client/stdio";',
'export * from "yaml";',
'import "side-effect-pkg";',
'import { readFileSync } from "fs";',
'import { x } from "./chunk-ABC.js";',
'const m = await import("undici");',
'const n = await import("@napi-rs/keyring");',
].join("\n");
assert.deepEqual(collectBareImports(source), [
"@modelcontextprotocol/client/stdio",
"@napi-rs/keyring",
"side-effect-pkg",
"undici",
"yaml",
"zod",
]);
});

test('collectBareImports ignores `from "..."` prose inside a comment', () => {
const source = [
"/**",
' * digests to check" apart from "a digest was wrong" and "the walk',
' * event since I last looked" from "something has", without',
" */",
'import { z } from "zod";',
].join("\n");
assert.deepEqual(collectBareImports(source), ["zod"]);
});

test("unresolvedFrom ESM-resolves an import-only package and reports a missing one", () => {
const root = mkdtempSync(join(tmpdir(), "bare-imports-"));
try {
const pkg = join(root, "node_modules", "import-only");
mkdirSync(pkg, { recursive: true });
writeFileSync(
join(pkg, "package.json"),
JSON.stringify({
name: "import-only",
type: "module",
exports: { "./sub": { import: "./sub.js" } },
}),
);
writeFileSync(join(pkg, "sub.js"), "export {};\n");
const dir = join(root, "consumer");
mkdirSync(dir);

const failed = unresolvedFrom({
dir,
specifiers: ["import-only/sub", "not-installed"],
});
assert.deepEqual(
failed.map((f) => f.specifier),
["not-installed"],
);
// The probe leaves nothing behind in the checked directory.
assert.deepEqual(readdirSync(dir), []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
105 changes: 92 additions & 13 deletions scripts/pack-and-verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
*
* 1. builds every client (`npm run build`);
* 2. packs the publishable tarball (`npm pack`) and inspects its file list —
* asserting NO source maps ship and that `clients/web/{build,dist,static}`
* are all present (the packaging fixes this work landed);
* asserting NO source maps ship, that `clients/web/{build,dist,static}`
* are all present (the packaging fixes this work landed), and that the
* `skills/mcpdo` agent skill ships (#2650);
* 3. installs that tarball into a fresh temp dir (real `npm install <tgz>`,
* which runs the package's `postinstall`);
* 4. runs the installed `mcp-inspector` bin: `--help`, `--cli`/`--tui` help
Expand All @@ -32,6 +33,9 @@
* 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);
* and the installed `mcpdo`: every bare import its bundle makes must
* ESM-resolve from inside the package, and a daemon connect must carry a
* real `tools/call` round trip (#2650);
* 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 All @@ -53,7 +57,13 @@
*/

import { spawn, spawnSync } from "node:child_process";
import { mkdtempSync, existsSync, writeFileSync } from "node:fs";
import {
mkdtempSync,
existsSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
Expand All @@ -70,6 +80,10 @@ import {
startMcpAppServer,
} from "./lib/mcp-app-flow.mjs";
import { winShellArgs } from "./lib/win-shell-args.mjs";
import {
collectBareImports,
unresolvedFrom,
} from "./lib/bundle-bare-imports.mjs";

const repoRoot = resolve(import.meta.dirname, "..");
const testServer = testServerEntryPath(repoRoot, "stdio");
Expand Down Expand Up @@ -226,22 +240,27 @@ if (maps.length > 0) {
// (The version the CLI/TUI report is read from the root package.json —
// always shipped — via readInspectorVersion(), so no client package.json
// needs to ship; that read is exercised by driving the bin in step 4.)
// `skills/mcpdo/SKILL.md` is not a runtime file, but it is a shipped one:
// the agent skill that teaches `mcpdo` is listed in "files" and nothing
// reads it at run time, so a "files" edit that dropped it would pass every
// other check here (#2650).
for (const required of [
"clients/web/build/index.js",
"clients/web/dist/index.html",
"clients/web/static/sandbox_proxy.html",
"skills/mcpdo/SKILL.md",
]) {
if (!tarredPaths.includes(required)) {
fail(
`expected \`${required}\` in the published tarball but it is missing — ` +
`check the "files" field in the root package.json (and that ` +
`clients/web/.npmignore does not exclude it)`,
`check the "files" field in the root package.json (and that no ` +
`.npmignore excludes it)`,
);
}
}
console.log(
`pack:verify — tarball OK: ${tarredPaths.length} files, no source maps, ` +
`clients/web/{build,dist,static} present (${(packInfo.unpackedSize / 1048576).toFixed(2)} MB unpacked)`,
`clients/web/{build,dist,static} and skills/mcpdo present (${(packInfo.unpackedSize / 1048576).toFixed(2)} MB unpacked)`,
);

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -448,6 +467,43 @@ try {
});
return { status: r.status, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
};

// 4b¹. Every bare import the installed mcpdo bundle makes must resolve from
// the consumer install (#2650). Driving commands only reaches the
// imports those commands load — a lazy `import()` on another path
// (`undici`, `@napi-rs/keyring`) stays untested until a user hits it.
// Resolution is ESM, from inside the package (`lib/bundle-bare-imports`
// says why a CommonJS `require.resolve` misreports `ext-tasks`).
const mcpdoBuild = join(installedPkg, "clients", "mcpdo", "build");
const mcpdoImports = [
...new Set(
readdirSync(mcpdoBuild)
.filter((f) => f.endsWith(".js"))
.flatMap((f) =>
collectBareImports(readFileSync(join(mcpdoBuild, f), "utf8")),
),
),
].sort();
if (mcpdoImports.length === 0) {
fail(`found no bare imports in ${mcpdoBuild} — the scan itself is broken`);
}
const unresolved = unresolvedFrom({
dir: mcpdoBuild,
specifiers: mcpdoImports,
});
if (unresolved.length > 0) {
fail(
`${unresolved.length} bare import(s) in the installed mcpdo bundle do not ` +
`resolve — check the root package.json "dependencies" and mcpdo's ` +
`tsup "external" list:\n ` +
unresolved.map((u) => `${u.specifier}: ${u.error}`).join("\n "),
);
}
console.log(
`pack:verify — all ${mcpdoImports.length} bare imports in the installed ` +
`mcpdo bundle ESM-resolve from the consumer install`,
);

const mcpdoHelp = runMcpdo(["--help"]);
if (mcpdoHelp.status !== 0 || !mcpdoHelp.output.includes("Usage: mcpdo")) {
fail(
Expand All @@ -456,10 +512,15 @@ try {
);
}
// Point the daemon dir into the throwaway consumer so the check never sees
// (or touches) a real daemon on the host.
// (or touches) a real daemon on the host. The name is one character on
// purpose: the socket lives at `<dir>/mcpdod.sock`, a Unix socket path is
// capped at 104 bytes on macOS, and `work` already sits under the long
// per-user `$TMPDIR` there — a longer directory is what failed the 2.10.0
// hand check (#2650).
const mcpdoDaemonDir = join(work, "d");
const mcpdoServers = runMcpdo(
["servers/list", "--catalog", catalogPath, "--plain"],
{ MCP_INSPECTOR_DAEMON_DIR: join(work, "mcpdo-daemon") },
{ MCP_INSPECTOR_DAEMON_DIR: mcpdoDaemonDir },
);
if (mcpdoServers.status !== 0 || !mcpdoServers.output.includes("test")) {
fail(
Expand All @@ -475,10 +536,10 @@ try {
// the same stdio fixture, verify the connection is listed, then tear
// everything down (stop the daemon before any fail() so nothing
// outlives the check).
step("verifying installed `mcpdo` daemon flow (connect/list/disconnect)...");
const mcpdoDaemonEnv = {
MCP_INSPECTOR_DAEMON_DIR: join(work, "mcpdo-daemon"),
};
step(
"verifying installed `mcpdo` daemon flow (connect/list/tools/call/disconnect)...",
);
const mcpdoDaemonEnv = { MCP_INSPECTOR_DAEMON_DIR: mcpdoDaemonDir };
const stopMcpdoDaemon = () => runMcpdo(["daemon", "stop"], mcpdoDaemonEnv);
const failMcpdoDaemonFlow = (message) => {
stopMcpdoDaemon();
Expand Down Expand Up @@ -509,6 +570,23 @@ try {
mcpdoConnections.output.slice(0, 800),
);
}
// A method dispatched through the daemon, not just its startup: `connect`
// proves the packaged daemon runs, this proves it can carry a request from
// the installed bundle to the server and the result back (#2650).
const mcpdoCall = runMcpdo(
["@test", "tools/call", "echo", "message:=pack-verify", "--plain"],
mcpdoDaemonEnv,
);
if (
mcpdoCall.status !== 0 ||
!mcpdoCall.output.includes("Echo: pack-verify")
) {
failMcpdoDaemonFlow(
`\`mcpdo @test tools/call echo\` exited ${mcpdoCall.status} or did not ` +
`echo "pack-verify"\n` +
mcpdoCall.output.slice(0, 800),
);
}
const mcpdoDisconnect = runMcpdo(
["disconnect", "test", "--plain"],
mcpdoDaemonEnv,
Expand Down Expand Up @@ -546,7 +624,8 @@ try {
console.log(
"\npack:verify OK — published tarball installs clean and the real bin drives " +
"web (prod / served dist + an MCP App rendered through the shipped sandbox " +
"proxy), cli (stdio tools/list), and tui (help) end to end.",
"proxy), cli (stdio tools/list), tui (help), and mcpdo (daemon tools/call) " +
"end to end.",
);
} catch (err) {
// Unexpected throw (not via fail()) — route through fail() for consistent
Expand Down
Loading