Merge pull request #1637 from modelcontextprotocol/1636-v2-publish-bundle
Fix v2 single-package publish bundle + add pack:verify
This commit is contained in:
commit
4fe101bb9e
19 changed files with 714 additions and 59 deletions
161
README.md
Normal file
161
README.md
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
# 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:
|
||||||
|
|
||||||
|
- **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).
|
||||||
|
|
||||||
|
All three run through one global `mcp-inspector` binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @modelcontextprotocol/inspector # web UI (default)
|
||||||
|
npx @modelcontextprotocol/inspector --cli # CLI
|
||||||
|
npx @modelcontextprotocol/inspector --tui # TUI
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Repo status.** This is the **v2** line of the Inspector (branch `v2/main`). The `main` branch is the legacy v1 implementation (bug fixes only). v2 will eventually replace `main`. See [`AGENTS.md`](./AGENTS.md) for branch/board conventions.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
v2 is **not** an npm workspace. Each client under `clients/*` keeps its own `package.json` and `node_modules`; shared code lives in `core/` and is consumed via a `@inspector/core` build-time alias (no `package.json` of its own). A single `npm install` at the root cascades installs into every client (see [Setup](#setup)).
|
||||||
|
|
||||||
|
```
|
||||||
|
inspector/
|
||||||
|
├── clients/
|
||||||
|
│ ├── web/ # Web client (Vite + React + Mantine). src/ = browser app; server/ = Node dev/prod backend
|
||||||
|
│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias)
|
||||||
|
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
|
||||||
|
│ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui
|
||||||
|
├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json)
|
||||||
|
│ ├── auth/ # OAuth: providers, discovery, storage, mid-session recovery (browser/node/remote backends)
|
||||||
|
│ ├── json/ # JSON + parameter/argument conversion utilities
|
||||||
|
│ ├── logging/ # Silent pino logger singleton
|
||||||
|
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import
|
||||||
|
│ ├── react/ # React hooks over the state stores
|
||||||
|
│ └── storage/ # File I/O helpers for the OAuth persist backends
|
||||||
|
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests
|
||||||
|
├── scripts/ # Root build/verify tooling (install cascade, smokes, pack:verify)
|
||||||
|
├── specification/ # Design/build specifications
|
||||||
|
├── AGENTS.md # Contribution rules for agents AND humans (see below)
|
||||||
|
└── README.md # You are here
|
||||||
|
```
|
||||||
|
|
||||||
|
Each client has its own README with client-specific detail:
|
||||||
|
[web](./clients/web/README.md) · [cli](./clients/cli/README.md) · [tui](./clients/tui/README.md) · [launcher](./clients/launcher/README.md).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Requires Node `>=22.19.0`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # root install; postinstall cascades into every client
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Fresh clone:** run `npm install` at the repo root.
|
||||||
|
- **After a pull that changes a client's dependencies:** re-run `npm install` at the root to re-sync every client.
|
||||||
|
|
||||||
|
The cascade (`scripts/install-clients.mjs`) is dev-only — it exits early when the package is installed as a dependency, and the published tarball ships only each client's `build/`, so end users are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip it.
|
||||||
|
|
||||||
|
## Running during development
|
||||||
|
|
||||||
|
For day-to-day web iteration, run Vite directly from the web client (fast HMR, no launcher build needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd clients/web && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The launcher-driven scripts below run the **built** launcher, so build first (`npm run build`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run web # prod web launcher against clients/web/dist
|
||||||
|
npm run web:dev # web launcher in --dev mode (Vite)
|
||||||
|
```
|
||||||
|
|
||||||
|
## The `@inspector/core` shared package
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`core/` holds the logic shared by all three clients so that web, CLI, and TUI behave identically. Its entry point is the **`InspectorClient`** class (`core/mcp/`), which owns the connection to an MCP server, the request/response lifecycle, and a set of state stores; `core/react/` exposes React hooks over those stores that both the web and TUI (Ink) React trees consume. OAuth (`core/auth/`) is factored into isomorphic logic plus browser/node/remote backends so the same flows work in the browser, in Node, and against a remote backend.
|
||||||
|
|
||||||
|
`core/` intentionally has **no `package.json`** — it is not published on its own. Each client bundles it in via a `@inspector/core` alias:
|
||||||
|
|
||||||
|
- **CLI / TUI:** `esbuildOptions.alias` in their `tsup.config.ts` maps `@inspector/core` → the repo `core/` directory, and `noExternal: [/^@inspector\/core/]` inlines it into the bundle.
|
||||||
|
- **Web:** the same alias in `clients/web/vite.config.ts` for the browser app and the Node backend runner.
|
||||||
|
|
||||||
|
Publishing `core/` as its own package (e.g. for third parties to build on) is deliberately deferred — see issue [#1636](https://github.com/modelcontextprotocol/inspector/issues/1636).
|
||||||
|
|
||||||
|
## Web client: "dumb components" + Storybook
|
||||||
|
|
||||||
|
The v2 web client is built from **presentational ("dumb") components** — they accept data and callbacks as props and contain only display logic, with no direct data fetching or client state. State comes from the `@inspector/core` hooks, wired in near the top of the tree. This keeps components isolated, testable, and documentable.
|
||||||
|
|
||||||
|
That approach is what makes **Storybook** first-class here: every screen and element component has a `*.stories.tsx` file (96+ stories) that renders it against fixture props. Storybook **play functions** double as interaction tests, run headless in CI (`npm run ci:storybook`, Chromium via Playwright).
|
||||||
|
|
||||||
|
Styling follows a strict Mantine-first convention (theme variants and component props over CSS classes, `--inspector-*` CSS custom properties over raw color literals). The full rules live in [`AGENTS.md`](./AGENTS.md) under **React instructions** — read them before touching web UI. Element components live in `clients/web/src/components/elements/`; theme variants in `clients/web/src/theme/`.
|
||||||
|
|
||||||
|
## Test servers
|
||||||
|
|
||||||
|
`test-servers/` provides **composable MCP servers** used by the integration and smoke suites, so tests exercise a real server over a real transport instead of mocks. A server is assembled from **presets** (fixture factories in `test-servers/src/preset-registry.ts` — tools, resources, prompts, tasks, elicitation, sampling, OAuth, …) and can be driven two ways:
|
||||||
|
|
||||||
|
- **In-process** — import the factories (`createTestServerHttp`, `createEchoTool`, …) and run the server inside the test's event loop (used by the HTTP integration paths).
|
||||||
|
- **As a subprocess** — `test-servers/build/test-server-stdio.js` is spawned as a real stdio child (used by the CLI smoke and stdio integration tests).
|
||||||
|
|
||||||
|
Configure a server declaratively with a JSON config (see `test-servers/configs/*.json`) selecting presets, then load it via `--config`. Because the servers are spawned as real subprocesses, the build output must exist first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test-servers:build # (from clients/web) → tsc -p test-servers, emits test-servers/build/
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vite.config.ts`) points at `test-servers/build/index.js` so `getTestMcpServerPath()` resolves to a real `.js` path.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # builds all clients: web → cli → tui → launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
Individual clients: `build:web`, `build:cli`, `build:tui`, `build:launcher`. The web build produces both the browser SPA (`clients/web/dist`, Vite) and the Node prod-server runner (`clients/web/build`, tsup).
|
||||||
|
|
||||||
|
## Testing & the quality gate
|
||||||
|
|
||||||
|
Each client self-validates from its own folder; the root scripts chain them. There is **no** aggregate root `test` script — use `validate` (fast) or `coverage` (the gate).
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run validate` | `format:check` + `lint` + `build` + fast unit tests, per client. The quick inner-loop check. |
|
||||||
|
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project. |
|
||||||
|
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web). |
|
||||||
|
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `smoke` → Storybook. A true superset of GitHub CI. |
|
||||||
|
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |
|
||||||
|
|
||||||
|
Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …). Run `npm run format` (per client) before committing — `validate` runs the non-fixing `format:check` and fails CI on any unformatted file.
|
||||||
|
|
||||||
|
For the full testing rules — the ≥90% per-file gate, where test files live, the unit vs. integration vs. storybook projects, and the `v8 ignore` policy — see [`AGENTS.md`](./AGENTS.md).
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
The root `@modelcontextprotocol/inspector` package ships as **one tarball with a single version number** — no separate `-web` / `-cli` / `-tui` / `-core` packages. `npm run build` builds every client, then `prepack` runs before `npm publish`. Runtime dependencies are declared on the root `package.json`; client builds bundle `@inspector/core` and externalize npm packages resolved from the root install.
|
||||||
|
|
||||||
|
### What ships, and the packaging invariants
|
||||||
|
|
||||||
|
The root `package.json` `"files"` allowlist is the source of truth for the tarball. A few non-obvious entries exist because they are read **at runtime** or were silently dropped by npm's packlist — do not remove them without re-running `npm run pack:verify`:
|
||||||
|
|
||||||
|
- **No source maps.** The client bundlers set `sourcemap: false` (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`); Vite and the launcher's `tsc` already emit none. Maps are ~half the unpacked size and aren't needed at runtime — debug via `npm run dev` on the source.
|
||||||
|
- **`clients/web/build` ships via `clients/web/.npmignore`.** `clients/web/.gitignore` lists `build/`, and npm's packlist honors that nested `.gitignore` over the root `"files"` allowlist — so the prod web-server runner was silently missing from the tarball while `clients/web/dist` slipped through (its `.gitignore` only lists `dist-ssr`). `clients/web/.npmignore` overrides the `.gitignore` for publishing so both `build/` (runner) and `dist/` (SPA) ship. The other clients don't need this — none ship a nested `.gitignore`.
|
||||||
|
- **A single version number, read from the root `package.json`.** The Inspector ships as one package with one version, so only the **root** `package.json` carries a `version` — the four `clients/*/package.json`s deliberately have none. Every Node client (CLI, TUI, and the web backend) resolves the version through the shared `readInspectorVersion()` reader in `core/node/version.ts`, which walks up to the root manifest (always present in the tarball). No client `package.json` is read at runtime, so none needs to ship. The web **browser** can't read the filesystem; it gets its version from the backend via `GET /api/config` (see [#1639](https://github.com/modelcontextprotocol/inspector/issues/1639)).
|
||||||
|
|
||||||
|
### `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, a real `--cli tools/list` over stdio, and a prod `--web` boot that must serve `/` from the shipped `dist`. 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`/`ci` loop.
|
||||||
|
|
||||||
|
## Contributing — `AGENTS.md` and `CLAUDE.md`
|
||||||
|
|
||||||
|
**[`AGENTS.md`](./AGENTS.md) is the contract for changing this codebase, and it applies to humans and AI agents alike.** It is not agent-only boilerplate — it holds the project's real conventions: the issue-and-board workflow, branch/label rules, the TypeScript and Mantine/React standards, the testing and coverage requirements, and the mandatory pre-push gate. Read it before making changes, and keep it up to date when you change structure, tooling, or rules.
|
||||||
|
|
||||||
|
`CLAUDE.md` is the entry point the [Claude Code](https://claude.com/claude-code) agent loads automatically; it simply includes `AGENTS.md` and this README, so both agents and humans work from the same source of truth. If you use a different agent that reads `AGENTS.md`, you get the same rules.
|
||||||
|
|
||||||
|
A key rule worth surfacing here: **all work is issue-driven.** Before starting, find or create a tracking issue on the v2 project board; open PRs against `v2/main` with `Closes #<issue>`. The exact recipes (labels, board IDs, statuses) are in `AGENTS.md`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT.
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-cli",
|
"name": "@modelcontextprotocol/inspector-cli",
|
||||||
"version": "2.0.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CLI for the Model Context Protocol inspector",
|
"description": "CLI for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { dirname, join } from "path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "url";
|
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
type McpResponse = Record<string, unknown>;
|
type McpResponse = Record<string, unknown>;
|
||||||
import { awaitableLog } from "./utils/awaitable-log.js";
|
import { awaitableLog } from "./utils/awaitable-log.js";
|
||||||
|
|
@ -49,11 +47,15 @@ import {
|
||||||
LoggingLevelSchema,
|
LoggingLevelSchema,
|
||||||
type LoggingLevel,
|
type LoggingLevel,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { readInspectorVersion } from "@inspector/core/node/version.js";
|
||||||
|
|
||||||
export const validLogLevels: LoggingLevel[] = Object.values(
|
export const validLogLevels: LoggingLevel[] = Object.values(
|
||||||
LoggingLevelSchema.enum,
|
LoggingLevelSchema.enum,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Client identity name the CLI reports to servers. */
|
||||||
|
const CLI_CLIENT_NAME = "inspector-cli";
|
||||||
|
|
||||||
type MethodArgs = {
|
type MethodArgs = {
|
||||||
method?: string;
|
method?: string;
|
||||||
promptName?: string;
|
promptName?: string;
|
||||||
|
|
@ -74,20 +76,13 @@ async function callMethod(
|
||||||
cliAuthOverrides: RunnerClientConfigOverrides,
|
cliAuthOverrides: RunnerClientConfigOverrides,
|
||||||
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
// Version comes from the single source of truth — the root package.json —
|
||||||
const packageJsonPath = join(__dirname, "../package.json");
|
// via the shared core reader, not the CLI's own manifest.
|
||||||
const packageJsonData = await import(pathToFileURL(packageJsonPath).href, {
|
const clientIdentity = {
|
||||||
with: { type: "json" },
|
name: CLI_CLIENT_NAME,
|
||||||
});
|
version: readInspectorVersion(import.meta.url),
|
||||||
const packageJson = packageJsonData.default as {
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [, name = packageJson.name] = packageJson.name.split("/");
|
|
||||||
const version = packageJson.version;
|
|
||||||
const clientIdentity = { name, version };
|
|
||||||
|
|
||||||
const environment: InspectorClientEnvironment = {
|
const environment: InspectorClientEnvironment = {
|
||||||
transport: createTransportNode,
|
transport: createTransportNode,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ export default defineConfig({
|
||||||
format: ['esm'],
|
format: ['esm'],
|
||||||
outDir: 'build',
|
outDir: 'build',
|
||||||
clean: true,
|
clean: true,
|
||||||
sourcemap: true,
|
// No source maps in the published bundle — they roughly double the on-disk
|
||||||
|
// size and aren't needed at runtime (debug via `npm run dev` on the source).
|
||||||
|
sourcemap: false,
|
||||||
target: 'node22',
|
target: 'node22',
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
// Bundle core source; leave npm deps external.
|
// Bundle core source; leave npm deps external.
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ repo-root `validate:launcher` simply delegates here (`cd clients/launcher && npm
|
||||||
|
|
||||||
## Publishing
|
## Publishing
|
||||||
|
|
||||||
The root `@modelcontextprotocol/inspector` package ships as one fat tarball: `npm run build` at the repo root builds all clients, then `prepack` runs before `npm publish`. Runtime dependencies are declared on the root `package.json`; client builds bundle `@inspector/core` and externalize npm packages resolved from the root install.
|
The launcher provides the `mcp-inspector` bin for the single `@modelcontextprotocol/inspector` tarball. Packaging is a whole-repo concern — how the one-package/single-version tarball is assembled, the `"files"` allowlist invariants (no source maps, why `clients/web/build` needs `.npmignore`, why the cli/tui `package.json`s ship), and the `npm run pack:verify` publish smoke — is documented in the [root README](../../README.md#publishing).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-launcher",
|
"name": "@modelcontextprotocol/inspector-launcher",
|
||||||
"version": "2.0.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Launcher for MCP Inspector (web, CLI, TUI)",
|
"description": "Launcher for MCP Inspector (web, CLI, TUI)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-tui",
|
"name": "@modelcontextprotocol/inspector-tui",
|
||||||
"version": "2.0.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Terminal User Interface (TUI) for the Model Context Protocol inspector",
|
"description": "Terminal User Interface (TUI) for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ import React, {
|
||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Box, Text, useInput, useApp, type Key } from "ink";
|
import { Box, Text, useInput, useApp, type Key } from "ink";
|
||||||
import { readFileSync } from "fs";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import { dirname, join } from "path";
|
|
||||||
import type {
|
import type {
|
||||||
MessageEntry,
|
MessageEntry,
|
||||||
FetchRequestEntry,
|
FetchRequestEntry,
|
||||||
|
|
@ -56,6 +53,7 @@ import {
|
||||||
isOAuthCapableServerConfig,
|
isOAuthCapableServerConfig,
|
||||||
} from "@inspector/core/client/runner.js";
|
} from "@inspector/core/client/runner.js";
|
||||||
import { formatRunnerOAuthRedirectUrl } from "@inspector/core/auth/node/runner-oauth-callback.js";
|
import { formatRunnerOAuthRedirectUrl } from "@inspector/core/auth/node/runner-oauth-callback.js";
|
||||||
|
import { readInspectorVersion } from "@inspector/core/node/version.js";
|
||||||
import {
|
import {
|
||||||
createOAuthCallbackServer,
|
createOAuthCallbackServer,
|
||||||
type OAuthCallbackServer,
|
type OAuthCallbackServer,
|
||||||
|
|
@ -85,33 +83,17 @@ import { PromptTestModal } from "./components/PromptTestModal.js";
|
||||||
import { DetailsModal } from "./components/DetailsModal.js";
|
import { DetailsModal } from "./components/DetailsModal.js";
|
||||||
import type { TuiServer } from "./tui-servers.js";
|
import type { TuiServer } from "./tui-servers.js";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
// Header branding. The version is the single source of truth — the root
|
||||||
const __dirname = dirname(__filename);
|
// package.json — read via the shared core reader; the name/description are the
|
||||||
|
// TUI's own display strings (they live in code, not the npm manifest, which no
|
||||||
|
// longer carries them per-client).
|
||||||
|
const APP_NAME = "MCP Inspector TUI";
|
||||||
|
const APP_DESCRIPTION =
|
||||||
|
"Terminal User Interface for the Model Context Protocol Inspector";
|
||||||
|
const APP_VERSION = readInspectorVersion(import.meta.url);
|
||||||
|
|
||||||
// Read package.json to get project info
|
/** Client identity name the TUI reports to servers. */
|
||||||
// Strategy: Try multiple paths to handle both local dev and global install
|
const TUI_CLIENT_NAME = "inspector-tui";
|
||||||
// - Local dev (tsx): __dirname = src/, package.json is one level up
|
|
||||||
// - Global install: __dirname = dist/src/, package.json is two levels up
|
|
||||||
let packagePath: string;
|
|
||||||
let packageJson: { name: string; description: string; version: string };
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try two levels up first (global install case)
|
|
||||||
packagePath = join(__dirname, "..", "..", "package.json");
|
|
||||||
packageJson = JSON.parse(readFileSync(packagePath, "utf-8")) as {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
version: string;
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
// Fall back to one level up (local dev case)
|
|
||||||
packagePath = join(__dirname, "..", "package.json");
|
|
||||||
packageJson = JSON.parse(readFileSync(packagePath, "utf-8")) as {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
version: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus management types
|
// Focus management types
|
||||||
type FocusArea =
|
type FocusArea =
|
||||||
|
|
@ -310,6 +292,7 @@ function App({
|
||||||
);
|
);
|
||||||
const opts: InspectorClientOptions = {
|
const opts: InspectorClientOptions = {
|
||||||
environment,
|
environment,
|
||||||
|
clientIdentity: { name: TUI_CLIENT_NAME, version: APP_VERSION },
|
||||||
pipeStderr: true,
|
pipeStderr: true,
|
||||||
...(savedSettings &&
|
...(savedSettings &&
|
||||||
savedSettings.requestTimeout > 0 && {
|
savedSettings.requestTimeout > 0 && {
|
||||||
|
|
@ -1498,11 +1481,11 @@ function App({
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Text bold color="cyan">
|
<Text bold color="cyan">
|
||||||
{packageJson.name}
|
{APP_NAME}
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor> - {packageJson.description}</Text>
|
<Text dimColor> - {APP_DESCRIPTION}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text dimColor>v{packageJson.version}</Text>
|
<Text dimColor>v{APP_VERSION}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ export default defineConfig({
|
||||||
format: ['esm'],
|
format: ['esm'],
|
||||||
outDir: 'build',
|
outDir: 'build',
|
||||||
clean: true,
|
clean: true,
|
||||||
sourcemap: true,
|
// No source maps in the published bundle — they roughly double the on-disk
|
||||||
|
// size and aren't needed at runtime (debug via `npm run dev` on the source).
|
||||||
|
sourcemap: false,
|
||||||
target: 'node22',
|
target: 'node22',
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
noExternal: [/^@inspector\/core/],
|
noExternal: [/^@inspector\/core/],
|
||||||
|
|
|
||||||
19
clients/web/.npmignore
Normal file
19
clients/web/.npmignore
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# npm's packlist uses THIS file instead of .gitignore when deciding what to
|
||||||
|
# publish from clients/web. It exists to fix a packaging bug: .gitignore here
|
||||||
|
# lists `build/` (a git artifact we never commit), and npm honored that
|
||||||
|
# exclusion even though the root package.json "files" field explicitly lists
|
||||||
|
# `clients/web/build`. The result was the prod web-server runner silently
|
||||||
|
# missing from the published @modelcontextprotocol/inspector tarball while
|
||||||
|
# `clients/web/dist` slipped through — so `mcp-inspector --web` had no server to
|
||||||
|
# load. (The other clients don't hit this because none of them ship a nested
|
||||||
|
# .gitignore.)
|
||||||
|
#
|
||||||
|
# Crucially this file does NOT list `build` or `dist`, so both the prod runner
|
||||||
|
# (build/) and the SPA (dist/) are packed. The root "files" allowlist already
|
||||||
|
# restricts publishing to those two directories, so everything else in
|
||||||
|
# clients/web (src, configs, node_modules, coverage, storybook-static) stays out
|
||||||
|
# regardless — the entries below are just belt-and-suspenders.
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
storybook-static
|
||||||
|
dist-ssr
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-web",
|
"name": "@modelcontextprotocol/inspector-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
69
clients/web/src/test/integration/node/version.test.ts
Normal file
69
clients/web/src/test/integration/node/version.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
readFileSync,
|
||||||
|
} from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join, dirname } from "path";
|
||||||
|
import { pathToFileURL, fileURLToPath } from "url";
|
||||||
|
import {
|
||||||
|
readInspectorVersion,
|
||||||
|
ROOT_PACKAGE_NAME,
|
||||||
|
} from "@inspector/core/node/version.js";
|
||||||
|
|
||||||
|
/** Absolute path to the repo root package.json (three levels above core/node). */
|
||||||
|
const rootPackageJsonPath = join(
|
||||||
|
dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"package.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
function readRootVersion(): string {
|
||||||
|
return (
|
||||||
|
JSON.parse(readFileSync(rootPackageJsonPath, "utf-8")) as {
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
).version;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("readInspectorVersion", () => {
|
||||||
|
it("resolves the version from the root package.json for a caller inside the repo", () => {
|
||||||
|
// This test file lives below the root manifest; walking up from it passes
|
||||||
|
// clients/web/package.json (wrong name → skipped) and several levels with no
|
||||||
|
// package.json (the catch branch) before reaching the root.
|
||||||
|
expect(readInspectorVersion(import.meta.url)).toBe(readRootVersion());
|
||||||
|
// Sanity: the root manifest really is the one we match on.
|
||||||
|
expect(ROOT_PACKAGE_NAME).toBe("@modelcontextprotocol/inspector");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a matching-name manifest that has no version, then throws when no versioned root exists above the caller", () => {
|
||||||
|
// A fake root manifest with the right name but NO version must not satisfy
|
||||||
|
// the search — the walk keeps going up. Rooted in the OS temp dir (outside
|
||||||
|
// the repo), so the walk reaches the filesystem root without finding a
|
||||||
|
// versioned inspector manifest and throws.
|
||||||
|
const work = mkdtempSync(join(tmpdir(), "version-test-"));
|
||||||
|
try {
|
||||||
|
writeFileSync(
|
||||||
|
join(work, "package.json"),
|
||||||
|
JSON.stringify({ name: ROOT_PACKAGE_NAME }),
|
||||||
|
);
|
||||||
|
const sub = join(work, "sub");
|
||||||
|
mkdirSync(sub);
|
||||||
|
const callerUrl = pathToFileURL(join(sub, "caller.js")).href;
|
||||||
|
|
||||||
|
expect(() => readInspectorVersion(callerUrl)).toThrow(
|
||||||
|
/Could not locate the @modelcontextprotocol\/inspector root package\.json/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,7 +10,9 @@ export default defineConfig({
|
||||||
format: ['esm'],
|
format: ['esm'],
|
||||||
outDir: 'build',
|
outDir: 'build',
|
||||||
clean: true,
|
clean: true,
|
||||||
sourcemap: true,
|
// No source maps in the published bundle — they roughly double the on-disk
|
||||||
|
// size and aren't needed at runtime (debug via `npm run dev` on the source).
|
||||||
|
sourcemap: false,
|
||||||
target: 'node22',
|
target: 'node22',
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
noExternal: [/^@inspector\/core/],
|
noExternal: [/^@inspector\/core/],
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ export default defineConfig(({ command }) => {
|
||||||
path.join(repoRoot, 'core/auth/**/*.{ts,tsx}'),
|
path.join(repoRoot, 'core/auth/**/*.{ts,tsx}'),
|
||||||
path.join(repoRoot, 'core/storage/**/*.{ts,tsx}'),
|
path.join(repoRoot, 'core/storage/**/*.{ts,tsx}'),
|
||||||
path.join(repoRoot, 'core/logging/**/*.{ts,tsx}'),
|
path.join(repoRoot, 'core/logging/**/*.{ts,tsx}'),
|
||||||
|
path.join(repoRoot, 'core/node/**/*.{ts,tsx}'),
|
||||||
],
|
],
|
||||||
exclude: [
|
exclude: [
|
||||||
'**/*.stories.{ts,tsx}',
|
'**/*.stories.{ts,tsx}',
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,16 @@ export type {
|
||||||
AppRendererClient,
|
AppRendererClient,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { getServerType as getServerTypeFromConfig } from "./config.js";
|
import { getServerType as getServerTypeFromConfig } from "./config.js";
|
||||||
// v2 doesn't have a core/package.json (the package isn't published independently),
|
// Fallback client identity, used ONLY when a caller doesn't pass
|
||||||
// so we hardcode the client identity that v1.5 read from corePackageJson.
|
// `clientIdentity`. Real clients supply their own: the Node clients (CLI, TUI)
|
||||||
|
// read the single-source version from the root package.json via
|
||||||
|
// `readInspectorVersion()`, and the web browser — which can't read the
|
||||||
|
// filesystem — will pass a version sourced from `GET /api/config` (see #1639).
|
||||||
|
// This stays a neutral placeholder rather than a hardcoded release number that
|
||||||
|
// would silently drift out of sync with the root package.json version.
|
||||||
const corePackageJson = {
|
const corePackageJson = {
|
||||||
name: "@modelcontextprotocol/inspector-core",
|
name: "mcp-inspector",
|
||||||
version: "0.20.0",
|
version: "0.0.0",
|
||||||
} as const;
|
} as const;
|
||||||
import type {
|
import type {
|
||||||
CreateTransport,
|
CreateTransport,
|
||||||
|
|
|
||||||
66
core/node/version.ts
Normal file
66
core/node/version.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
/**
|
||||||
|
* Single source of truth for the Inspector's version, read from the **root**
|
||||||
|
* `package.json` of the published `@modelcontextprotocol/inspector` package.
|
||||||
|
*
|
||||||
|
* The Inspector ships as one package with one version number; the per-client
|
||||||
|
* `package.json`s deliberately carry no `version` field, so every Node client
|
||||||
|
* (CLI, TUI, and the web backend) resolves the version here instead of reading
|
||||||
|
* its own manifest.
|
||||||
|
*
|
||||||
|
* **Node-only** — this uses `node:fs`; never import it from browser code. The
|
||||||
|
* web *browser* cannot read the filesystem and gets its version from the
|
||||||
|
* backend via `GET /api/config` instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, join, parse } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/** The published root package's name — how we recognize the root manifest. */
|
||||||
|
export const ROOT_PACKAGE_NAME = "@modelcontextprotocol/inspector";
|
||||||
|
|
||||||
|
interface RootManifest {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Inspector version by walking up from the calling module to the
|
||||||
|
* root `package.json` (the one named {@link ROOT_PACKAGE_NAME}, which alone
|
||||||
|
* carries a `version`). Pass the caller's `import.meta.url`.
|
||||||
|
*
|
||||||
|
* This works identically in the dev tree and the published tarball: in both,
|
||||||
|
* a client entry (`clients/<name>/{src,build}/…`) sits below the root manifest,
|
||||||
|
* and npm always ships the root `package.json`. Client `package.json`s are
|
||||||
|
* skipped because they have no matching `name`/`version`, so a stray manifest
|
||||||
|
* on the way up never shadows the real root.
|
||||||
|
*
|
||||||
|
* @param callerUrl the calling module's `import.meta.url`
|
||||||
|
* @returns the version string from the root `package.json`
|
||||||
|
* @throws if no root manifest is found above the caller
|
||||||
|
*/
|
||||||
|
export function readInspectorVersion(callerUrl: string): string {
|
||||||
|
const startDir = dirname(fileURLToPath(callerUrl));
|
||||||
|
const { root } = parse(startDir);
|
||||||
|
|
||||||
|
let dir = startDir;
|
||||||
|
for (;;) {
|
||||||
|
let manifest: RootManifest | undefined;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(
|
||||||
|
readFileSync(join(dir, "package.json"), "utf-8"),
|
||||||
|
) as RootManifest;
|
||||||
|
} catch {
|
||||||
|
// No (readable) package.json at this level — keep walking up.
|
||||||
|
}
|
||||||
|
if (manifest?.name === ROOT_PACKAGE_NAME && manifest.version) {
|
||||||
|
return manifest.version;
|
||||||
|
}
|
||||||
|
if (dir === root) break;
|
||||||
|
dir = dirname(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Could not locate the ${ROOT_PACKAGE_NAME} root package.json above ${startDir}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"smoke:tui": "node scripts/smoke-tui.mjs",
|
"smoke:tui": "node scripts/smoke-tui.mjs",
|
||||||
"smoke:web": "node scripts/smoke-web.mjs",
|
"smoke:web": "node scripts/smoke-web.mjs",
|
||||||
"smoke:launcher": "node scripts/smoke-launcher.mjs",
|
"smoke:launcher": "node scripts/smoke-launcher.mjs",
|
||||||
|
"pack:verify": "node scripts/pack-and-verify.mjs",
|
||||||
"prepack": "npm run build",
|
"prepack": "npm run build",
|
||||||
"postinstall": "node scripts/install-clients.mjs"
|
"postinstall": "node scripts/install-clients.mjs"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
353
scripts/pack-and-verify.mjs
Normal file
353
scripts/pack-and-verify.mjs
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Pack-and-verify: build the exact tarball npm would publish, install it into a
|
||||||
|
* clean throwaway consumer, and drive the real `mcp-inspector` bin against that
|
||||||
|
* installed package — web, cli, and tui (#1636).
|
||||||
|
*
|
||||||
|
* Why this exists, and why the `smoke:*` scripts aren't enough: the smokes run
|
||||||
|
* against the in-repo build tree (`clients/<name>/build`, `clients/web/dist`) via the
|
||||||
|
* launcher's relative paths. That tree is NOT the published package. We have
|
||||||
|
* repeatedly been bitten by things that work in `--dev` / in-repo yet break for
|
||||||
|
* a real consumer running `npx @modelcontextprotocol/inspector …`, because:
|
||||||
|
*
|
||||||
|
* - the `files` allowlist (root + each nested client `package.json`) silently
|
||||||
|
* omits something the runtime needs (this is exactly how `clients/web/build`
|
||||||
|
* was missing from the tarball while `clients/web/dist` slipped through);
|
||||||
|
* - path resolution that is correct relative to the repo differs once the code
|
||||||
|
* lives under `node_modules/@modelcontextprotocol/inspector/…`;
|
||||||
|
* - the `postinstall` client-install cascade misbehaves for an end user.
|
||||||
|
*
|
||||||
|
* This script closes that gap by exercising the package the way npm publishes
|
||||||
|
* and a user installs it, end to end. It:
|
||||||
|
*
|
||||||
|
* 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}` are
|
||||||
|
* both present (the two packaging fixes this work landed);
|
||||||
|
* 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
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Exits non-zero on the first failure. Requires network access (step 3 pulls the
|
||||||
|
* package's runtime dependencies from the registry) — it is a local / release
|
||||||
|
* verification tool, not part of the fast `validate` loop.
|
||||||
|
*
|
||||||
|
* Usage: `npm run pack:verify` (from the repo root).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
|
||||||
|
const repoRoot = resolve(import.meta.dirname, "..");
|
||||||
|
const testServer = join(repoRoot, "test-servers", "build", "test-server-stdio.js");
|
||||||
|
|
||||||
|
// Mirrors INSPECTOR_API_TOKEN_GLOBAL in core/mcp/remote/constants.ts; kept as a
|
||||||
|
// literal because this plain .mjs script can't import the TS source.
|
||||||
|
const TOKEN_GLOBAL = "__INSPECTOR_API_TOKEN__";
|
||||||
|
|
||||||
|
// Set once the throwaway consumer dir exists. `fail()` is the single
|
||||||
|
// failure-exit point (called from everywhere, including deep inside the run),
|
||||||
|
// and it calls process.exit(), which skips any `finally` — so cleanup has to
|
||||||
|
// happen here, not in a trailing `finally`. On failure we remove the heavy work
|
||||||
|
// dir (a full node_modules from the real install) but leave the packed tarball
|
||||||
|
// in place for post-mortem inspection.
|
||||||
|
let workDir = null;
|
||||||
|
// The live `--web` child during verifyWeb(), if any. fail() kills it so a
|
||||||
|
// failure in the web phase doesn't orphan the server — which would linger on
|
||||||
|
// the port and could even serve a stale false-200 to a later run (verifyWeb's
|
||||||
|
// own `finally { stop() }` is skipped when fail() calls process.exit()).
|
||||||
|
let webChild = null;
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(`\npack:verify FAILED — ${message}`);
|
||||||
|
if (webChild && webChild.exitCode === null) {
|
||||||
|
webChild.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
if (workDir) {
|
||||||
|
rmSync(workDir, { recursive: true, force: true });
|
||||||
|
// `tarball` is initialized before `workDir` is ever set, so this is safe.
|
||||||
|
console.error(`pack:verify — tarball retained for inspection at ${tarball}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(message) {
|
||||||
|
console.log(`\npack:verify — ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a command to completion, inheriting stdio. Returns the exit status. */
|
||||||
|
function runInherit(command, args, cwd = repoRoot) {
|
||||||
|
const r = spawnSync(command, args, {
|
||||||
|
cwd,
|
||||||
|
stdio: "inherit",
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
return r.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the bundled stdio test server if it isn't present yet. */
|
||||||
|
function ensureTestServer() {
|
||||||
|
if (existsSync(testServer)) return;
|
||||||
|
step("building test-servers (missing build output)...");
|
||||||
|
const status = runInherit("npx", ["tsc", "-p", "test-servers", "--noCheck"]);
|
||||||
|
if (status !== 0 || !existsSync(testServer)) {
|
||||||
|
fail("could not build the stdio test server (test-servers/build/test-server-stdio.js)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Build every client exactly as the publish would.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
step("building all clients (npm run build)...");
|
||||||
|
if (runInherit("npm", ["run", "build"]) !== 0) {
|
||||||
|
fail("`npm run build` failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Pack the publishable tarball and inspect its file list.
|
||||||
|
// --ignore-scripts: we just built, so skip the prepack rebuild AND keep
|
||||||
|
// stdout clean JSON (prepack build output would otherwise pollute it).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
step("packing the publishable tarball (npm pack)...");
|
||||||
|
const pack = spawnSync(
|
||||||
|
"npm",
|
||||||
|
["pack", "--json", "--ignore-scripts", "--pack-destination", tmpdir()],
|
||||||
|
{ cwd: repoRoot, encoding: "utf8" },
|
||||||
|
);
|
||||||
|
if (pack.status !== 0) {
|
||||||
|
fail(`\`npm pack\` failed:\n${pack.stderr || pack.stdout}`);
|
||||||
|
}
|
||||||
|
let packInfo;
|
||||||
|
try {
|
||||||
|
packInfo = JSON.parse(pack.stdout)[0];
|
||||||
|
} catch {
|
||||||
|
fail(`could not parse \`npm pack --json\` output:\n${pack.stdout}`);
|
||||||
|
}
|
||||||
|
const tarball = join(tmpdir(), packInfo.filename.replace(/^.*[/\\]/, ""));
|
||||||
|
if (!existsSync(tarball)) {
|
||||||
|
fail(`packed tarball not found at ${tarball}`);
|
||||||
|
}
|
||||||
|
const tarredPaths = packInfo.files.map((f) => f.path);
|
||||||
|
|
||||||
|
// 2a. No source maps in the published bundle.
|
||||||
|
const maps = tarredPaths.filter((p) => p.endsWith(".map"));
|
||||||
|
if (maps.length > 0) {
|
||||||
|
fail(
|
||||||
|
`${maps.length} source map(s) leaked into the tarball — they should be disabled ` +
|
||||||
|
`in the client bundlers:\n ${maps.slice(0, 10).join("\n ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Runtime files that are easy to omit from the packlist and only fail once
|
||||||
|
// installed: both web artifacts — the prod server runner (build) AND the SPA
|
||||||
|
// (dist). `clients/web/build` was previously dropped by the nested
|
||||||
|
// .gitignore. (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.)
|
||||||
|
for (const required of [
|
||||||
|
"clients/web/build/index.js",
|
||||||
|
"clients/web/dist/index.html",
|
||||||
|
]) {
|
||||||
|
if (!tarredPaths.includes(required)) {
|
||||||
|
fail(
|
||||||
|
`expected \`${required}\` in the published tarball but it is missing — ` +
|
||||||
|
`check the "files" field in clients/web/package.json`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`pack:verify — tarball OK: ${tarredPaths.length} files, no source maps, ` +
|
||||||
|
`clients/web/{build,dist} present (${(packInfo.unpackedSize / 1048576).toFixed(2)} MB unpacked)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Install the tarball into a clean throwaway consumer (real npm install,
|
||||||
|
// runs the package's postinstall).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const work = mkdtempSync(join(tmpdir(), "pack-verify-"));
|
||||||
|
workDir = work; // from now on, fail() cleans this up
|
||||||
|
try {
|
||||||
|
ensureTestServer();
|
||||||
|
step(`installing the tarball into a clean consumer at ${work} (pulls runtime deps)...`);
|
||||||
|
writeFileSync(
|
||||||
|
join(work, "package.json"),
|
||||||
|
JSON.stringify({ name: "pack-verify-consumer", private: true, version: "0.0.0" }),
|
||||||
|
);
|
||||||
|
const install = runInherit(
|
||||||
|
"npm",
|
||||||
|
["install", tarball, "--no-audit", "--no-fund", "--loglevel", "error"],
|
||||||
|
work,
|
||||||
|
);
|
||||||
|
if (install !== 0) {
|
||||||
|
fail("`npm install <tarball>` into the throwaway consumer failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedPkg = join(work, "node_modules", "@modelcontextprotocol", "inspector");
|
||||||
|
const bin = join(work, "node_modules", ".bin", process.platform === "win32" ? "mcp-inspector.cmd" : "mcp-inspector");
|
||||||
|
if (!existsSync(bin)) {
|
||||||
|
fail(`installed \`mcp-inspector\` bin not found at ${bin}`);
|
||||||
|
}
|
||||||
|
// Confirm the two packaging fixes survived install onto disk.
|
||||||
|
for (const required of [
|
||||||
|
join(installedPkg, "clients", "web", "build", "index.js"),
|
||||||
|
join(installedPkg, "clients", "web", "dist", "index.html"),
|
||||||
|
join(installedPkg, "clients", "launcher", "build", "index.js"),
|
||||||
|
]) {
|
||||||
|
if (!existsSync(required)) {
|
||||||
|
fail(`expected installed file missing: ${required}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the installed bin. Returns { status, output }. */
|
||||||
|
const runBin = (args, extraEnv = {}) => {
|
||||||
|
const r = spawnSync(bin, args, {
|
||||||
|
cwd: work,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, ...extraEnv },
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
return { status: r.status, output: `${r.stdout ?? ""}${r.stderr ?? ""}`, stdout: r.stdout ?? "" };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. Drive the installed bin.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4a. Help dispatch for each mode — proves the launcher resolved every client
|
||||||
|
// build from its installed location.
|
||||||
|
step("verifying help dispatch (--help, --cli --help, --tui --help)...");
|
||||||
|
const helpChecks = [
|
||||||
|
{ args: ["--help"], marker: "Mode flags (--web, --cli, --tui)", label: "launcher --help" },
|
||||||
|
{ args: ["--cli", "--help"], marker: "Usage: inspector-cli", label: "--cli dispatch" },
|
||||||
|
{ args: ["--tui", "--help"], marker: "Usage: mcp-inspector-tui", label: "--tui dispatch" },
|
||||||
|
];
|
||||||
|
for (const { args, marker, label } of helpChecks) {
|
||||||
|
const r = runBin(args);
|
||||||
|
if (r.status !== 0) {
|
||||||
|
fail(`\`${args.join(" ")}\` exited ${r.status}\n${r.output.slice(0, 800)}`);
|
||||||
|
}
|
||||||
|
if (!r.output.includes(marker)) {
|
||||||
|
fail(`\`${args.join(" ")}\` (${label}) did not print "${marker}"\n${r.output.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.
|
||||||
|
step("verifying `--cli` tools/list over stdio from the installed package...");
|
||||||
|
const catalogPath = join(work, "catalog.json");
|
||||||
|
writeFileSync(
|
||||||
|
catalogPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
test: { type: "stdio", command: process.execPath, args: [testServer] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const list = runBin(["--cli", "--catalog", catalogPath, "--server", "test", "--method", "tools/list"]);
|
||||||
|
if (list.status !== 0) {
|
||||||
|
fail(`\`--cli … tools/list\` exited ${list.status}\n${list.output}`);
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(list.stdout);
|
||||||
|
} catch {
|
||||||
|
fail(`\`--cli … tools/list\` did not return JSON on stdout:\n${list.stdout}`);
|
||||||
|
}
|
||||||
|
if (!(parsed.tools ?? []).some((t) => t.name === "echo")) {
|
||||||
|
fail(`\`--cli … tools/list\` missing expected "echo" tool`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4c. Prod `--web` boot from the installed package — THE critical packaging
|
||||||
|
// path: the runner must locate and serve the shipped `dist` (not rebuild
|
||||||
|
// it) and inject the auth token. Run non-blocking and poll `/`.
|
||||||
|
await verifyWeb(bin, work);
|
||||||
|
|
||||||
|
// Success: clean up both the work dir and the tarball. (This is reached only
|
||||||
|
// on success — every failure path goes through fail() → process.exit(), which
|
||||||
|
// does its own cleanup above and never returns here.)
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
rmSync(tarball, { force: true });
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"\npack:verify OK — published tarball installs clean and the real bin drives " +
|
||||||
|
"web (prod / served dist), cli (stdio tools/list), and tui (help) end to end.",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Unexpected throw (not via fail()) — route through fail() for consistent
|
||||||
|
// cleanup + exit.
|
||||||
|
fail(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot the installed `mcp-inspector --web` (prod), wait for it to listen, and
|
||||||
|
* assert `GET /` returns 200 with the injected token global served from the
|
||||||
|
* shipped `dist`. Kills the server before returning.
|
||||||
|
*/
|
||||||
|
async function verifyWeb(bin, cwd) {
|
||||||
|
step("verifying prod `--web` serves the shipped dist from the installed package...");
|
||||||
|
const host = "127.0.0.1";
|
||||||
|
const port = process.env.PACK_VERIFY_WEB_PORT ?? "6399";
|
||||||
|
const token = "pack-verify-token";
|
||||||
|
const child = spawn(bin, ["--web"], {
|
||||||
|
cwd,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CLIENT_PORT: port,
|
||||||
|
HOST: host,
|
||||||
|
MCP_INSPECTOR_API_TOKEN: token,
|
||||||
|
MCP_AUTO_OPEN_ENABLED: "false",
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "inherit", "inherit"],
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
// Expose the child so fail() can kill it if a check below exits the process
|
||||||
|
// (process.exit skips the `finally { stop() }`).
|
||||||
|
webChild = child;
|
||||||
|
|
||||||
|
let exited = false;
|
||||||
|
let exitCode = null;
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
exited = true;
|
||||||
|
exitCode = code;
|
||||||
|
});
|
||||||
|
const stop = () => {
|
||||||
|
if (!exited) child.kill("SIGTERM");
|
||||||
|
webChild = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res = null;
|
||||||
|
for (let attempt = 0; attempt < 120 && !res; attempt++) {
|
||||||
|
if (exited) {
|
||||||
|
fail(`\`--web\` exited (code ${exitCode}) before serving — see output above`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res = await fetch(`http://${host}:${port}/`);
|
||||||
|
} catch {
|
||||||
|
await delay(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res) {
|
||||||
|
fail("`--web` server did not start within 60s");
|
||||||
|
}
|
||||||
|
if (res.status !== 200) {
|
||||||
|
fail(`\`--web\` GET / returned HTTP ${res.status}, expected 200`);
|
||||||
|
}
|
||||||
|
const body = await res.text();
|
||||||
|
if (!body.includes(TOKEN_GLOBAL)) {
|
||||||
|
fail(`\`--web\` served HTML is missing the ${TOKEN_GLOBAL} global (token not injected)`);
|
||||||
|
}
|
||||||
|
if (!body.includes(token)) {
|
||||||
|
fail("`--web` served HTML is missing the injected auth-token value");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
specification/diagrams/shared-code-architecture.png
Normal file
BIN
specification/diagrams/shared-code-architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Loading…
Reference in a new issue