diff --git a/README.md b/README.md new file mode 100644 index 00000000..faaf675d --- /dev/null +++ b/README.md @@ -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 + +![Shared code architecture: the four clients over the @inspector/core shared package](specification/diagrams/shared-code-architecture.png) + +`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 ` (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 #`. The exact recipes (labels, board IDs, statuses) are in `AGENTS.md`. + +## License + +MIT. diff --git a/clients/cli/package.json b/clients/cli/package.json index 0a1232d9..89671c50 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -1,6 +1,5 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "2.0.0", "private": true, "description": "CLI for the Model Context Protocol inspector", "license": "MIT", diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index b5d8e42d..3d858815 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -1,5 +1,3 @@ -import { dirname, join } from "path"; -import { fileURLToPath, pathToFileURL } from "url"; import { Command } from "commander"; type McpResponse = Record; import { awaitableLog } from "./utils/awaitable-log.js"; @@ -49,11 +47,15 @@ import { LoggingLevelSchema, type LoggingLevel, } from "@modelcontextprotocol/sdk/types.js"; +import { readInspectorVersion } from "@inspector/core/node/version.js"; export const validLogLevels: LoggingLevel[] = Object.values( LoggingLevelSchema.enum, ); +/** Client identity name the CLI reports to servers. */ +const CLI_CLIENT_NAME = "inspector-cli"; + type MethodArgs = { method?: string; promptName?: string; @@ -74,20 +76,13 @@ async function callMethod( cliAuthOverrides: RunnerClientConfigOverrides, callbackUrlConfig: RunnerOAuthCallbackConfig, ): Promise { - const __dirname = dirname(fileURLToPath(import.meta.url)); - const packageJsonPath = join(__dirname, "../package.json"); - const packageJsonData = await import(pathToFileURL(packageJsonPath).href, { - with: { type: "json" }, - }); - const packageJson = packageJsonData.default as { - name: string; - version: string; + // Version comes from the single source of truth — the root package.json — + // via the shared core reader, not the CLI's own manifest. + const clientIdentity = { + name: CLI_CLIENT_NAME, + version: readInspectorVersion(import.meta.url), }; - const [, name = packageJson.name] = packageJson.name.split("/"); - const version = packageJson.version; - const clientIdentity = { name, version }; - const environment: InspectorClientEnvironment = { transport: createTransportNode, }; diff --git a/clients/cli/tsup.config.ts b/clients/cli/tsup.config.ts index 63278f07..752ab878 100644 --- a/clients/cli/tsup.config.ts +++ b/clients/cli/tsup.config.ts @@ -10,7 +10,9 @@ export default defineConfig({ format: ['esm'], outDir: 'build', 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', platform: 'node', // Bundle core source; leave npm deps external. diff --git a/clients/launcher/README.md b/clients/launcher/README.md index 085dc7ee..73e268c6 100644 --- a/clients/launcher/README.md +++ b/clients/launcher/README.md @@ -96,7 +96,7 @@ repo-root `validate:launcher` simply delegates here (`cd clients/launcher && npm ## 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 diff --git a/clients/launcher/package.json b/clients/launcher/package.json index dbfbaecc..6a5f5ff7 100644 --- a/clients/launcher/package.json +++ b/clients/launcher/package.json @@ -1,6 +1,5 @@ { "name": "@modelcontextprotocol/inspector-launcher", - "version": "2.0.0", "private": true, "description": "Launcher for MCP Inspector (web, CLI, TUI)", "license": "MIT", diff --git a/clients/tui/package.json b/clients/tui/package.json index 73070e2f..e4b644d5 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -1,6 +1,5 @@ { "name": "@modelcontextprotocol/inspector-tui", - "version": "2.0.0", "private": true, "description": "Terminal User Interface (TUI) for the Model Context Protocol inspector", "license": "MIT", diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 0d126414..66a2584b 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -6,9 +6,6 @@ import React, { useRef, } from "react"; import { Box, Text, useInput, useApp, type Key } from "ink"; -import { readFileSync } from "fs"; -import { fileURLToPath } from "url"; -import { dirname, join } from "path"; import type { MessageEntry, FetchRequestEntry, @@ -56,6 +53,7 @@ import { isOAuthCapableServerConfig, } from "@inspector/core/client/runner.js"; import { formatRunnerOAuthRedirectUrl } from "@inspector/core/auth/node/runner-oauth-callback.js"; +import { readInspectorVersion } from "@inspector/core/node/version.js"; import { createOAuthCallbackServer, type OAuthCallbackServer, @@ -85,33 +83,17 @@ import { PromptTestModal } from "./components/PromptTestModal.js"; import { DetailsModal } from "./components/DetailsModal.js"; import type { TuiServer } from "./tui-servers.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +// Header branding. The version is the single source of truth — the root +// 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 -// Strategy: Try multiple paths to handle both local dev and global install -// - 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; - }; -} +/** Client identity name the TUI reports to servers. */ +const TUI_CLIENT_NAME = "inspector-tui"; // Focus management types type FocusArea = @@ -310,6 +292,7 @@ function App({ ); const opts: InspectorClientOptions = { environment, + clientIdentity: { name: TUI_CLIENT_NAME, version: APP_VERSION }, pipeStderr: true, ...(savedSettings && savedSettings.requestTimeout > 0 && { @@ -1498,11 +1481,11 @@ function App({ > - {packageJson.name} + {APP_NAME} - - {packageJson.description} + - {APP_DESCRIPTION} - v{packageJson.version} + v{APP_VERSION} {/* Main content area */} diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 568d97d7..c998221a 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -10,7 +10,9 @@ export default defineConfig({ format: ['esm'], outDir: 'build', 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', platform: 'node', noExternal: [/^@inspector\/core/], diff --git a/clients/web/.npmignore b/clients/web/.npmignore new file mode 100644 index 00000000..133cadc2 --- /dev/null +++ b/clients/web/.npmignore @@ -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 diff --git a/clients/web/package.json b/clients/web/package.json index 3c666856..6b9900d9 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -1,7 +1,6 @@ { "name": "@modelcontextprotocol/inspector-web", "private": true, - "version": "2.0.0", "type": "module", "main": "build/index.js", "bin": { diff --git a/clients/web/src/test/integration/node/version.test.ts b/clients/web/src/test/integration/node/version.test.ts new file mode 100644 index 00000000..6bb8d0bb --- /dev/null +++ b/clients/web/src/test/integration/node/version.test.ts @@ -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 }); + } + }); +}); diff --git a/clients/web/tsup.runner.config.ts b/clients/web/tsup.runner.config.ts index 6841de44..a0fb8a2e 100644 --- a/clients/web/tsup.runner.config.ts +++ b/clients/web/tsup.runner.config.ts @@ -10,7 +10,9 @@ export default defineConfig({ format: ['esm'], outDir: 'build', 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', platform: 'node', noExternal: [/^@inspector\/core/], diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index cc2ed813..d1acd996 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -109,6 +109,7 @@ export default defineConfig(({ command }) => { path.join(repoRoot, 'core/auth/**/*.{ts,tsx}'), path.join(repoRoot, 'core/storage/**/*.{ts,tsx}'), path.join(repoRoot, 'core/logging/**/*.{ts,tsx}'), + path.join(repoRoot, 'core/node/**/*.{ts,tsx}'), ], exclude: [ '**/*.stories.{ts,tsx}', diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index e51d9887..8ae4fccb 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -26,11 +26,16 @@ export type { AppRendererClient, } from "./types.js"; import { getServerType as getServerTypeFromConfig } from "./config.js"; -// v2 doesn't have a core/package.json (the package isn't published independently), -// so we hardcode the client identity that v1.5 read from corePackageJson. +// Fallback client identity, used ONLY when a caller doesn't pass +// `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 = { - name: "@modelcontextprotocol/inspector-core", - version: "0.20.0", + name: "mcp-inspector", + version: "0.0.0", } as const; import type { CreateTransport, diff --git a/core/node/version.ts b/core/node/version.ts new file mode 100644 index 00000000..063612cc --- /dev/null +++ b/core/node/version.ts @@ -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//{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}`, + ); +} diff --git a/package.json b/package.json index 3d069649..278bc437 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "smoke:tui": "node scripts/smoke-tui.mjs", "smoke:web": "node scripts/smoke-web.mjs", "smoke:launcher": "node scripts/smoke-launcher.mjs", + "pack:verify": "node scripts/pack-and-verify.mjs", "prepack": "npm run build", "postinstall": "node scripts/install-clients.mjs" }, diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs new file mode 100644 index 00000000..79bfc33b --- /dev/null +++ b/scripts/pack-and-verify.mjs @@ -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//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 `, + * 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 ` 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(); + } +} diff --git a/specification/diagrams/shared-code-architecture.png b/specification/diagrams/shared-code-architecture.png new file mode 100644 index 00000000..b05d8c4e Binary files /dev/null and b/specification/diagrams/shared-code-architecture.png differ