diff --git a/README.md b/README.md index f422f2c0..faaf675d 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ 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: @@ -140,7 +142,7 @@ The root `package.json` `"files"` allowlist is the source of truth for the tarba - **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`. -- **`clients/cli/package.json` and `clients/tui/package.json` ship** because those bundles read their own `package.json` at runtime (CLI client identity; TUI header `name`/`description`/`version`) relative to the bundle. Without them the installed CLI crashes on connect and the installed TUI crashes on launch — even though both work in-repo, where the files are present. +- **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 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/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/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/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 359605f2..278bc437 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,7 @@ "clients/web/build", "clients/web/dist", "clients/cli/build", - "clients/cli/package.json", "clients/tui/build", - "clients/tui/package.json", "scripts/install-clients.mjs" ], "scripts": { diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index aa44f51f..758d2525 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -125,15 +125,14 @@ if (maps.length > 0) { // 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) — plus the TUI's package.json, which the TUI bundle reads at -// runtime for its header (name/description/version). `clients/web/build` was -// previously dropped by the nested .gitignore; `clients/tui/package.json` -// was never listed, so the installed TUI crashed on launch. +// (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", - "clients/cli/package.json", - "clients/tui/package.json", ]) { if (!tarredPaths.includes(required)) { fail( @@ -179,8 +178,6 @@ try { join(installedPkg, "clients", "web", "build", "index.js"), join(installedPkg, "clients", "web", "dist", "index.html"), join(installedPkg, "clients", "launcher", "build", "index.js"), - join(installedPkg, "clients", "cli", "package.json"), - join(installedPkg, "clients", "tui", "package.json"), ]) { if (!existsSync(required)) { fail(`expected installed file missing: ${required}`); 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