From 9644ac7fe8eb4324a6836faac4c52c813a94ece4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 6 Aug 2026 17:05:36 -0400 Subject: [PATCH 01/73] fix: prime SSE streams so Firefox resolves the events fetch Firefox does not hand a streaming `fetch()` response to JS until the first *body* byte arrives; Chromium resolves the promise as soon as the headers do. Both SSE endpoints flushed headers immediately and then stayed silent until there was something to report, which deadlocked `/api/mcp/events`: `RemoteClientTransport.openEventStream()` awaits that fetch *before* the MCP client sends `initialize`, so no `initialize` -> no event to report -> no body byte -> the fetch never resolves. The web UI hung on "Connecting..." forever with nothing in the UI, the console, or the node logs. Write an inert `:` SSE comment the instant each stream opens. Conforming parsers ignore a comment line, so this unblocks the read without inventing a wire event and needs no client-side protocol change. `X-Content-Type-Options: nosniff` was also proposed on the issue but does not fix it -- verified against Firefox 153, the fetch still never resolves with the header set and no body byte. The delay is not MIME sniffing. The priming write is deliberately ordered *after* each handler registers its consumer (the session event consumer; the file-watch subscriber plus `ensureWatcher()`). Callers treat the arrival of the stream's first bytes as proof they are subscribed, so priming first would hand out that proof across an `await` and drop an edit made in the gap. `/api/servers/events` gets the same treatment: its fetch was equally stuck on Firefox until the first real change event, which for an unedited `mcp.json` never comes. Its client-side reader counted any `\n\n` as a change, so the inert frame would have fired a spurious background re-fetch on every connection -- it now skips frames carrying no `event:`/`data:` field. The transport's own `parseSSE` already drops comment frames correctly and needed no change. Verified end to end in real Firefox against a live streamable-HTTP test server: before, stuck on "Connecting..." past 25s; after, initialize -> notifications/initialized -> tools/list, connected. Closes #1858 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- .../src/test/core/react/useServers.test.tsx | 68 ++++++++ .../mcp/remote/sse-priming.test.ts | 159 ++++++++++++++++++ core/mcp/remote/node/server.ts | 37 ++++ core/react/useServers.ts | 19 ++- 4 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 clients/web/src/test/integration/mcp/remote/sse-priming.test.ts diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index 0ee49df7..bb95402d 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -969,6 +969,74 @@ describe("useServers", () => { ); }); + it("ignores the backend's inert priming comment frame (#1858)", async () => { + // The backend opens every SSE stream with a `:` comment so a streaming + // fetch() resolves on Firefox at all. That frame carries no event/data + // field and must NOT be read as a change — otherwise every connection + // would fire a spurious background re-fetch on open. + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { seed: { type: "stdio", command: "s" } }, + }), + ); + + // Count list GETs rather than watching the rendered list: a spurious + // refresh fires the instant the priming frame is read, so it would race + // ahead of any later disk mutation and land the same data — invisible in + // the output but a real extra round-trip on every connection. + let listGets = 0; + let reads = 0; + let releaseSecondRead: (() => void) | undefined; + const secondRead = new Promise((r) => { + releaseSecondRead = r; + }); + const encoder = new TextEncoder(); + // Must be referentially stable: the hook's SSE effect keys off `fetchFn`, + // so an inline arrow would re-subscribe (and re-refresh) every render and + // inflate the very count this test asserts on. + const fetchFn: typeof fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/servers/events")) { + const body = { + getReader: () => ({ + read: async () => { + reads += 1; + // Priming comment only — no `event:` / `data:` line. + if (reads === 1) { + return { done: false, value: encoder.encode(":\n\n") }; + } + // Hold the stream open so the loop can't end and let a + // teardown-time settle hide a queued refresh. + await secondRead; + return { done: true, value: undefined }; + }, + }), + }; + return { ok: true, body } as unknown as Response; + } + if (url.endsWith("/api/servers")) listGets += 1; + return h.fetchFn(url, init); + }; + const { result } = renderHook(() => + useServers({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.servers.map((s) => s.id)).toEqual(["seed"]); + + // The priming frame has been consumed (the loop is parked on read #2). + await waitFor(() => expect(reads).toBeGreaterThanOrEqual(2)); + await new Promise((r) => setTimeout(r, 100)); + + // Exactly one GET: the hook's mount refresh. The comment frame added none. + expect(listGets).toBe(1); + + await act(async () => { + releaseSecondRead?.(); + await Promise.resolve(); + }); + }); + it("falls back to globalThis.fetch when no fetchFn is provided", async () => { // No fetchFn → the `doFetch = fetchFn ?? globalThis.fetch` default branch. const globalFetch = vi diff --git a/clients/web/src/test/integration/mcp/remote/sse-priming.test.ts b/clients/web/src/test/integration/mcp/remote/sse-priming.test.ts new file mode 100644 index 00000000..1735d027 --- /dev/null +++ b/clients/web/src/test/integration/mcp/remote/sse-priming.test.ts @@ -0,0 +1,159 @@ +/** + * Regression tests for #1858 — the web UI hanging forever on "Connecting…" + * in Firefox. + * + * Firefox does not hand a streaming `fetch()` response to JS until the first + * *body* byte arrives (Chromium resolves on headers). Both SSE endpoints used + * to flush headers and then stay silent until there was something to report, + * which deadlocked `/api/mcp/events`: the browser transport awaits that fetch + * before the MCP client sends `initialize`, so nothing was ever reported and + * the fetch never resolved. + * + * The fix is a priming SSE comment written the instant each stream opens. + * These tests assert the bytes land on an otherwise-idle stream — the + * behavior the browser depends on — rather than any particular payload. + */ + +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { closeHarnessServer } from "./harnessTeardown.js"; + +interface Harness { + baseUrl: string; + server: ServerType; + storageDir: string; +} + +async function setup(): Promise { + const storageDir = mkdtempSync(join(tmpdir(), "sse-priming-")); + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + storageDir, + initialConfig: { defaultEnvironment: {} }, + }); + return new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info) => { + const port = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : 0; + resolve({ baseUrl: `http://127.0.0.1:${port}`, server, storageDir }); + }, + ); + server.on("error", reject); + }); +} + +/** + * Read the first body chunk off a streaming response, or reject if none + * arrives within `timeoutMs`. Pre-fix, an idle stream produced no chunk at + * all — which is exactly what stalled Firefox — so the timeout is the + * assertion that matters here. + */ +async function firstChunk(res: Response, timeoutMs = 3000): Promise { + if (!res.body) throw new Error("response has no body"); + const reader = res.body.getReader(); + try { + const read = reader.read().then(({ value }) => { + return value ? new TextDecoder().decode(value) : ""; + }); + const timeout = new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`no body bytes within ${timeoutMs}ms`)), + timeoutMs, + ); + }); + return await Promise.race([read, timeout]); + } finally { + await reader.cancel().catch(() => { + /* stream already torn down */ + }); + } +} + +/** The inert SSE comment frame the backend primes each stream with. */ +const PRIMING_FRAME = ":\n\n"; + +describe("SSE streams are primed on open (#1858)", () => { + let h: Harness; + + beforeEach(async () => { + h = await setup(); + }); + + afterEach(async () => { + await closeHarnessServer(h.server); + rmSync(h.storageDir, { recursive: true, force: true }); + }); + + it("GET /api/mcp/events flushes bytes before any MCP traffic", async () => { + // A subprocess that spawns and stays alive but never speaks — the state + // the session is in between `connect` and the client's `initialize`. + // This is precisely the window in which the deadlock occurred. + const config: MCPServerConfig = { + type: "stdio", + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1000);"], + }; + + const connectRes = await fetch(`${h.baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ config }), + }); + expect(connectRes.status).toBe(200); + const { sessionId } = (await connectRes.json()) as { sessionId: string }; + + const controller = new AbortController(); + try { + const eventsRes = await fetch( + `${h.baseUrl}/api/mcp/events?sessionId=${sessionId}`, + { signal: controller.signal }, + ); + expect(eventsRes.status).toBe(200); + + expect(await firstChunk(eventsRes)).toBe(PRIMING_FRAME); + } finally { + controller.abort(); + await fetch(`${h.baseUrl}/api/mcp/disconnect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }).catch(() => { + /* best-effort teardown */ + }); + } + }); + + it("GET /api/servers/events flushes bytes before any file change", async () => { + const controller = new AbortController(); + try { + const res = await fetch(`${h.baseUrl}/api/servers/events`, { + signal: controller.signal, + }); + expect(res.status).toBe(200); + + expect(await firstChunk(res)).toBe(PRIMING_FRAME); + } finally { + controller.abort(); + } + }); + + it("primes with an SSE comment, which carries no event or data field", () => { + // The priming frame must be inert: a conforming parser drops it, so no + // client needs to know about it. Guards against it ever being changed + // into something a consumer would mistake for a real event. + const lines = PRIMING_FRAME.split("\n"); + expect(lines.some((l) => l.startsWith("data:"))).toBe(false); + expect(lines.some((l) => l.startsWith("event:"))).toBe(false); + expect(lines[0]).toBe(":"); + }); +}); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 4bbc518a..6edb05d9 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -78,6 +78,27 @@ import { formatClientConfigLoadError } from "../../../client/config-parse.js"; import { envSecretField } from "../../../auth/secret-fields.js"; import { ZodError } from "zod"; +/** + * Written to every SSE stream the instant it opens, before anything else. + * + * Firefox does not hand a streaming `fetch()` response to JS until the first + * *body* byte arrives; Chromium resolves the promise as soon as the headers + * do. Both SSE endpoints here flush headers immediately and then stay silent + * until there is something to report, which deadlocks Firefox on + * `/api/mcp/events`: `RemoteClientTransport.openEventStream()` awaits that + * fetch *before* the MCP client sends `initialize`, so no `initialize` → no + * event to report → no body byte → the fetch never resolves → the web UI + * hangs on "Connecting…" forever with no error anywhere (#1858). + * + * A `:` comment line is inert per the SSE spec — conforming parsers ignore it + * — so priming with one unblocks the read without inventing a wire event. + * + * `X-Content-Type-Options: nosniff` does **not** fix this. Verified against + * Firefox 153: with the header and no body byte, the fetch still never + * resolves. The delay is not MIME sniffing. + */ +const SSE_PRIMING_COMMENT = ":\n\n"; + /** * Shape of the initial config returned by GET /api/config (defaults for client). */ @@ -785,6 +806,7 @@ export function createRemoteApp( } }); + // Prime every SSE stream the instant it opens — see SSE_PRIMING_COMMENT. app.get("/api/mcp/events", async (c) => { const sessionId = c.req.query("sessionId"); if (!sessionId) { @@ -818,6 +840,11 @@ export function createRemoteApp( return; } + // Prime only after the consumer is registered, so nothing observable + // to the client happens before this stream can actually report + // events. See SSE_PRIMING_COMMENT. + await stream.write(SSE_PRIMING_COMMENT); + stream.onAbort(() => { // Client disconnected - clear event consumer const shouldCleanup = session.clearEventConsumer(); @@ -2389,6 +2416,16 @@ export function createRemoteApp( ensureWatcher(); } + // Prime the stream so the client's fetch() actually resolves — on + // Firefox it otherwise stays pending until the first real change + // event, which for an unedited `mcp.json` is never. See + // SSE_PRIMING_COMMENT. Deliberately *after* the subscriber is + // registered and the watcher started: callers treat the arrival of + // this stream's first bytes as proof they are subscribed, so priming + // first would hand out that proof across an `await`, before the + // watcher exists, and drop an edit made in the gap. + await stream.write(SSE_PRIMING_COMMENT); + stream.onAbort(() => { serverEventSubscribers.delete(send); void maybeStopWatcher(); diff --git a/core/react/useServers.ts b/core/react/useServers.ts index 010a1e91..3a2a5002 100644 --- a/core/react/useServers.ts +++ b/core/react/useServers.ts @@ -82,6 +82,18 @@ async function readErrorMessage(res: Response): Promise { return `HTTP ${res.status}`; } +/** + * Whether an SSE frame carries an actual event rather than only comment + * lines. The backend primes each stream with an inert `:` comment so a + * streaming `fetch()` resolves on Firefox at all (#1858); that frame must + * not be mistaken for a change notification. + */ +function isSseDataFrame(frame: string): boolean { + return frame + .split("\n") + .some((line) => line.startsWith("event:") || line.startsWith("data:")); +} + export function useServers(opts: UseServersOptions): UseServersResult { const { baseUrl, authToken, fetchFn } = opts; const doFetch = fetchFn ?? globalThis.fetch; @@ -157,6 +169,10 @@ export function useServers(opts: UseServersOptions): UseServersResult { // single background refresh per decode chunk. Two `change` // broadcasts landing in the same chunk become one re-fetch instead // of two concurrent ones whose setState order is unspecified. + // Frames carrying no `event:`/`data:` field are skipped: the backend + // opens the stream with an inert `:` comment frame so Firefox + // resolves this fetch at all (see SSE_PRIMING_COMMENT in the remote + // server), and that must not read as a change. // Cross-chunk debounce is not added: `awaitWriteFinish`'s 100ms // stability threshold already serializes external edits at the // source, and chained fetches against the same GET endpoint are @@ -169,8 +185,9 @@ export function useServers(opts: UseServersOptions): UseServersResult { let sawFrame = false; let frameEnd = buffer.indexOf("\n\n"); while (frameEnd !== -1) { + const frame = buffer.slice(0, frameEnd); buffer = buffer.slice(frameEnd + 2); - sawFrame = true; + if (isSseDataFrame(frame)) sawFrame = true; frameEnd = buffer.indexOf("\n\n"); } if (sawFrame) void refreshInternal(true); From 1881e5c73f551080f3509fbc22e933efe4bf31ce Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 17:02:05 -0400 Subject: [PATCH 02/73] fix: settle the deliberately in-flight tool calls in the progress tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two progress tests fire `client.callTool()` without holding the promise so they can assert on the notifications the call streams while it is still in flight. `disconnect()` then closes the SDK client, which rejects every pending request with "Connection closed" — with nothing holding the promise that lands as an unhandled rejection, which vitest counts as a run error and fails `npm run ci` at `coverage` even though all tests pass. Add a `settleInFlight()` helper that attaches the handler at call time (not after the assertions, which would leave a window the rejection can escape through) and returns a promise the test awaits after `disconnect()` so teardown stays ordered. Neither outcome is asserted: the call may legitimately complete before the teardown or reject with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../integration/mcp/inspectorClient.test.ts | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 7937b219..3586e51f 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -95,6 +95,29 @@ async function getTool(client: InspectorClient, name: string): Promise { throw new Error(`Tool ${name} not found`); } +/** + * Hold a deliberately un-awaited in-flight call so its rejection is handled. + * + * A few tests start a tool call, assert on the notifications it streams, and + * then tear the connection down while the call is still in flight. That is a + * legitimate thing to exercise, but `disconnect()` closes the SDK client, which + * rejects every pending request with "Connection closed" — and a floating + * promise makes that an *unhandled* rejection, which vitest counts as a run + * error and fails `npm run ci` even though every test passes (#1947). + * + * Attach the handler at call time (not after the assertions) so there is no + * window in which the rejection can escape, and `await` the returned promise + * after `disconnect()` so teardown stays ordered. The call may legitimately + * either settle before the teardown or reject with it, so neither outcome is + * asserted here. + */ +function settleInFlight(call: Promise): Promise { + return call.then( + () => undefined, + () => undefined, + ); +} + /** Get all resources from the client via listResources() (paginates if needed). */ async function getAllResources( client: InspectorClient, @@ -2221,16 +2244,18 @@ describe("InspectorClient", () => { const progressToken = 12345; const sendProgressTool = await getTool(client, "send_progress"); - client.callTool( - sendProgressTool, - { - units: 3, - delayMs: 50, - total: 3, - message: "Test progress", - }, - undefined, // generalMetadata - { progressToken: progressToken.toString() }, // toolSpecificMetadata + const inFlight = settleInFlight( + client.callTool( + sendProgressTool, + { + units: 3, + delayMs: 50, + total: 3, + message: "Test progress", + }, + undefined, // generalMetadata + { progressToken: progressToken.toString() }, // toolSpecificMetadata + ), ); const progressEvents = await waitForProgressCount(client, 3, { @@ -2262,6 +2287,7 @@ describe("InspectorClient", () => { }); await client!.disconnect(); + await inFlight; await server.stop(); }); @@ -2349,15 +2375,17 @@ describe("InspectorClient", () => { const progressToken = 67890; const sendProgressTool2 = await getTool(client, "send_progress"); - client.callTool( - sendProgressTool2, - { - units: 2, - delayMs: 50, - message: "Indeterminate progress", - }, - undefined, // generalMetadata - { progressToken: progressToken.toString() }, // toolSpecificMetadata + const inFlight = settleInFlight( + client.callTool( + sendProgressTool2, + { + units: 2, + delayMs: 50, + message: "Indeterminate progress", + }, + undefined, // generalMetadata + { progressToken: progressToken.toString() }, // toolSpecificMetadata + ), ); const progressEvents = await waitForProgressCount(client, 2, { @@ -2380,6 +2408,7 @@ describe("InspectorClient", () => { expect((progressEvents[1] as { total?: number }).total).toBeUndefined(); await client!.disconnect(); + await inFlight; await server.stop(); }); From 0b9aee11a313e4d9afe056575766730913c9f5a9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 18:01:27 -0400 Subject: [PATCH 03/73] fix: only absorb the teardown's own rejection in settleInFlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: the blanket rejection handler also converted a genuine `callTool` failure into a pass. That matters here because these tests assert on the progress notifications, which arrive before the result — so a call that emitted all three progress events and then failed would have satisfied every assertion and been swallowed. Narrow the handler to absorb only `SdkError` / `CONNECTION_CLOSED` and re-throw anything else. The handler is still attached at call time, so an unexpected rejection stays *handled* and surfaces as an ordinary test failure at the `await inFlight` after `disconnect()` rather than as another unhandled rejection. Fulfillment is still accepted: whether the call beats the teardown is a race, so asserting either outcome would reintroduce the original bug as a flake. Verified the narrowed predicate is actually exercised rather than dead code — both call sites reject with SdkError/CONNECTION_CLOSED on every run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../integration/mcp/inspectorClient.test.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 3586e51f..9f5379a7 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -107,14 +107,27 @@ async function getTool(client: InspectorClient, name: string): Promise { * * Attach the handler at call time (not after the assertions) so there is no * window in which the rejection can escape, and `await` the returned promise - * after `disconnect()` so teardown stays ordered. The call may legitimately - * either settle before the teardown or reject with it, so neither outcome is - * asserted here. + * after `disconnect()` so teardown stays ordered. + * + * Only the teardown's own `CONNECTION_CLOSED` rejection is absorbed. Plain + * fulfillment is fine too — whether the call beats the teardown is a race, so + * asserting either outcome would turn this straight back into a flake. Any + * *other* rejection is re-thrown, so a call that fails for a real reason still + * fails the test instead of passing on the strength of the progress + * notifications it managed to emit first. */ function settleInFlight(call: Promise): Promise { return call.then( () => undefined, - () => undefined, + (error: unknown) => { + if ( + error instanceof SdkError && + error.code === SdkErrorCode.ConnectionClosed + ) { + return; + } + throw error; + }, ); } From c60563b7d647303ce15b34690836f3ffd8a7f127 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 18:09:52 -0400 Subject: [PATCH 04/73] fix: observe the derived promise so an unexpected rejection can't go unhandled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (round 2). Narrowing the handler in the previous commit fixed the silent-pass but reintroduced #1947's own failure class on the unexpected path: `then` returns a *derived* promise, and the re-throw rejects that one rather than `call`. The caller doesn't await it until after `disconnect()`, so a rejection arriving while the test is still waiting on progress notifications sits unobserved for seconds and Node reports it as an unhandled rejection. Attach an observer to the derived promise the moment it exists. Nothing is swallowed — it stays rejected, so the caller's `await` still fails the test. Verified both directions with a probe rejecting with a non-ConnectionClosed error and awaiting after a 1.5s gap: without the observer the run reports "Unhandled Rejection" + "Errors 1 error"; with it, the test fails and the run stays clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../src/test/integration/mcp/inspectorClient.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 9f5379a7..9a3a690c 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -117,7 +117,7 @@ async function getTool(client: InspectorClient, name: string): Promise { * notifications it managed to emit first. */ function settleInFlight(call: Promise): Promise { - return call.then( + const settled = call.then( () => undefined, (error: unknown) => { if ( @@ -129,6 +129,15 @@ function settleInFlight(call: Promise): Promise { throw error; }, ); + // `then` returns a *derived* promise, and the re-throw above rejects that one + // — not `call`. The caller does not await it until after `disconnect()`, so an + // unexpected rejection arriving while the test is still waiting on progress + // notifications would sit unobserved for seconds and be reported as an + // unhandled rejection: precisely the failure this helper exists to prevent. + // Observe it the moment it exists. This does not swallow anything — `settled` + // stays rejected, so the caller's `await` still fails the test. + settled.catch(() => undefined); + return settled; } /** Get all resources from the client via listResources() (paginates if needed). */ From 347173fab7363fa25f0a50b1b4b246c72028757f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 18:19:33 -0400 Subject: [PATCH 05/73] fix: absorb only the teardown's own close, not any ConnectionClosed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (round 3). The predicate absorbed *any* CONNECTION_CLOSED, while the doc comment claimed it absorbed only the teardown's — so a transport dropping on its own after the progress notifications but before `disconnect()` would have been swallowed. These tests assert only on the notifications, so that regression would have passed green. Gate the absorption on a teardown flag owned by the helper, which now returns an `InFlightCall` exposing `disconnectAndSettle()`. Keeping the flag inside means it can't be raised too early (which would silently reopen the hole) and the settle can't be forgotten; call sites also collapse from two statements to one. Verified: a pre-teardown ConnectionClosed now fails the test and stays observed (no run-level error), while a teardown-time one is still absorbed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../integration/mcp/inspectorClient.test.ts | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 9a3a690c..cc208835 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -106,21 +106,33 @@ async function getTool(client: InspectorClient, name: string): Promise { * error and fails `npm run ci` even though every test passes (#1947). * * Attach the handler at call time (not after the assertions) so there is no - * window in which the rejection can escape, and `await` the returned promise - * after `disconnect()` so teardown stays ordered. + * window in which the rejection can escape, then finish through + * `disconnectAndSettle()`, which tears down and awaits the call in one step. * - * Only the teardown's own `CONNECTION_CLOSED` rejection is absorbed. Plain + * Only a `CONNECTION_CLOSED` raised *by that teardown* is absorbed. Plain * fulfillment is fine too — whether the call beats the teardown is a race, so - * asserting either outcome would turn this straight back into a flake. Any - * *other* rejection is re-thrown, so a call that fails for a real reason still - * fails the test instead of passing on the strength of the progress - * notifications it managed to emit first. + * asserting either outcome would turn this straight back into a flake. Every + * other rejection is re-thrown, including a `CONNECTION_CLOSED` that arrives + * before teardown begins: a transport that drops on its own after emitting the + * progress notifications is a real regression, and absorbing it would let these + * tests pass on the strength of the notifications alone. + * + * The teardown flag is owned by the helper and set inside `disconnectAndSettle` + * rather than by the caller, so the flag cannot be raised too early (which would + * reopen the hole) and the await cannot be forgotten. */ -function settleInFlight(call: Promise): Promise { +interface InFlightCall { + /** Disconnect, then await the call — absorbing only this teardown's close. */ + disconnectAndSettle(client: InspectorClient): Promise; +} + +function settleInFlight(call: Promise): InFlightCall { + let tearingDown = false; const settled = call.then( () => undefined, (error: unknown) => { if ( + tearingDown && error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed ) { @@ -135,9 +147,15 @@ function settleInFlight(call: Promise): Promise { // notifications would sit unobserved for seconds and be reported as an // unhandled rejection: precisely the failure this helper exists to prevent. // Observe it the moment it exists. This does not swallow anything — `settled` - // stays rejected, so the caller's `await` still fails the test. + // stays rejected, so the caller's `await` below still fails the test. settled.catch(() => undefined); - return settled; + return { + async disconnectAndSettle(client: InspectorClient): Promise { + tearingDown = true; + await client.disconnect(); + await settled; + }, + }; } /** Get all resources from the client via listResources() (paginates if needed). */ @@ -2308,8 +2326,7 @@ describe("InspectorClient", () => { progressToken: progressToken.toString(), }); - await client!.disconnect(); - await inFlight; + await inFlight.disconnectAndSettle(client!); await server.stop(); }); @@ -2429,8 +2446,7 @@ describe("InspectorClient", () => { }); expect((progressEvents[1] as { total?: number }).total).toBeUndefined(); - await client!.disconnect(); - await inFlight; + await inFlight.disconnectAndSettle(client!); await server.stop(); }); From 2e8944373937a32e450eca2eab588e6e6500abfb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 19:23:43 -0400 Subject: [PATCH 06/73] =?UTF-8?q?docs:=20add=20the=20v1=20=E2=86=92=20v2?= =?UTF-8?q?=20migration=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1822 Adds docs/v1-to-v2-migration.md — the map for users arriving from v1: - CLI flag mapping (every v1 flag survives; lists what v2 adds) - `--config` vs `--catalog` semantics with before/after examples - Node engine bump (>=22.7.5 → >=22.19.0) - what no longer ships (the three sub-packages; v2 is one tarball) - the three behavior changes most likely to bite: exit code 5 on a failing tool call, target-must-come-first under --cli, and the reversed `--` separator - env-var mapping (MCP_PROXY_AUTH_TOKEN → MCP_INSPECTOR_API_TOKEN, MCP_PROXY_FULL_ADDRESS removed, SERVER_PORT repurposed), the v1 UI config settings, web query params, and Docker Linked from the root README (banner + docs list), the CLI README, and docs/mcp-server-configuration.md; AGENTS.md's docs/ tree entry updated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- AGENTS.md | 216 ++++++++++---------- README.md | 51 ++--- clients/cli/README.md | 2 + docs/mcp-server-configuration.md | 1 + docs/v1-to-v2-migration.md | 325 +++++++++++++++++++++++++++++++ 5 files changed, 468 insertions(+), 127 deletions(-) create mode 100644 docs/v1-to-v2-migration.md diff --git a/AGENTS.md b/AGENTS.md index a4185baa..c1039e87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,8 +89,9 @@ v2/main/ │ # in clients/web/vite.config.ts points at build/index.js │ # (not src/) so `getTestMcpServerPath()` returns a `.js` path. │ # tsconfig.test.json keeps paths pointing at src for typecheck. -├── docs/ # Task-oriented guides (mcp-server-configuration.md, -│ # mcp-app-review.md, launcher-config-consolidation-plan.md, +├── docs/ # Task-oriented guides (v1-to-v2-migration.md, +│ # mcp-server-configuration.md, mcp-app-review.md, +│ # launcher-config-consolidation-plan.md, │ # images/). Linked from the root README. ├── scripts/ # Root build/verify tooling: install-clients.mjs (the │ # postinstall cascade), the smoke-*.mjs runners, @@ -120,20 +121,22 @@ If you've already built a change locally, share the **prompt** you used and scre **This applies to org members with write access too, not just outside contributors.** Having permission to push a branch is not authorization to open a PR. Pull requests against this repo are opened by the **repo maintainers** only. Anyone else — including organization members whose write access makes it technically possible — opens a **detailed issue** instead, and a maintainer takes it from there. A detailed issue means: the problem, how to reproduce it, the behavior you expected, and — if you've already prototyped a fix — the prompt you used and any screenshots, rather than a diff. -**Issues are filed through the forms in [`.github/ISSUE_TEMPLATE/`](./.github/ISSUE_TEMPLATE) — blank issues are disabled.** GitHub serves the chooser from the **default branch** only, so a form edited here on `v2/main` has no effect on the live chooser until the next milestone merge into `main` — and it cannot be previewed before then, which is why the schema notes below matter. There are two forms, **Bug report** (`1-bug_report.yml`, auto-labels `bug`) and **Feature request** (`2-feature_request.yml`, auto-labels `enhancement` **and `v2`**); `config.yml` holds the chooser's contact links. A form's `labels:` is **static** — GitHub cannot map a reporter's answer to a label — which splits the two cases: the **bug** form could target either line, so it carries a required version-line *dropdown* and a maintainer applies the matching label at triage per [Label by version](#issue-driven-work-style); the **feature** form is v2 by construction (v1 takes security fixes only and cannot receive a feature), so it needs no dropdown and declares `v2` statically. If v1 ever reopens to features, that static label is what has to change. **There is deliberately no security template**: a vulnerability report must not open a public issue, so the chooser routes it to the private advisory form as a contact link instead (see [`SECURITY.md`](./SECURITY.md)). When adding or changing a form, validate it against GitHub's issue-forms schema (`markdown` blocks take no `id` and no `validations`; `checkboxes` mark `required` per option, not under `validations`). +**Issues are filed through the forms in [`.github/ISSUE_TEMPLATE/`](./.github/ISSUE_TEMPLATE) — blank issues are disabled.** GitHub serves the chooser from the **default branch** only, so a form edited here on `v2/main` has no effect on the live chooser until the next milestone merge into `main` — and it cannot be previewed before then, which is why the schema notes below matter. There are two forms, **Bug report** (`1-bug_report.yml`, auto-labels `bug`) and **Feature request** (`2-feature_request.yml`, auto-labels `enhancement` **and `v2`**); `config.yml` holds the chooser's contact links. A form's `labels:` is **static** — GitHub cannot map a reporter's answer to a label — which splits the two cases: the **bug** form could target either line, so it carries a required version-line _dropdown_ and a maintainer applies the matching label at triage per [Label by version](#issue-driven-work-style); the **feature** form is v2 by construction (v1 takes security fixes only and cannot receive a feature), so it needs no dropdown and declares `v2` statically. If v1 ever reopens to features, that static label is what has to change. **There is deliberately no security template**: a vulnerability report must not open a public issue, so the chooser routes it to the private advisory form as a contact link instead (see [`SECURITY.md`](./SECURITY.md)). When adding or changing a form, validate it against GitHub's issue-forms schema (`markdown` blocks take no `id` and no `validations`; `checkboxes` mark `required` per option, not under `validations`). **Every PR must reference an issue. No exceptions, regardless of who opens it.** The PR body's first line is `Closes #` (see the [Issue-driven Work Style](#issue-driven-work-style) rules below). A PR with no linked issue has no board card, so the work is invisible to the project board and untracked — if you're about to open one and there's no issue yet, create the issue first. This holds for a maintainer's own one-line fix as much as for a feature. ## Project Status and Direction -* The v1/main branch currently contains the legacy version of the Inspector, which we are creating security fixes for in deprecated maintenance mode. It is **published straight from the branch** to the `v1-latest` npm dist-tag — v1 releases never pass through `main`, and v1 PRs therefore target `v1/main` directly. -* The v2/main branch currently contains the the new version of the Inspector, which is actively being developed and maintained. All new features, bug fixes, and refactors should be implemented in this branch. It acts as the **develop branch**: work accumulates here continuously and is merged into `main` at milestone releases. +- The v1/main branch currently contains the legacy version of the Inspector, which we are creating security fixes for in deprecated maintenance mode. It is **published straight from the branch** to the `v1-latest` npm dist-tag — v1 releases never pass through `main`, and v1 PRs therefore target `v1/main` directly. -* The main branch is the default branch for the repo, and it currently points to the latest v2 release. It is not a development branch, and no new features or bug fixes should be implemented here. It is only used for releases of the v2 Inspector via merge from v2/main, which is what publishes the `latest` npm dist-tag. +- The v2/main branch currently contains the the new version of the Inspector, which is actively being developed and maintained. All new features, bug fixes, and refactors should be implemented in this branch. It acts as the **develop branch**: work accumulates here continuously and is merged into `main` at milestone releases. + +- The main branch is the default branch for the repo, and it currently points to the latest v2 release. It is not a development branch, and no new features or bug fixes should be implemented here. It is only used for releases of the v2 Inspector via merge from v2/main, which is what publishes the `latest` npm dist-tag. ## Maintenance Rules ### Keep documentation files up to date + - When adding, removing, renaming, or changing the purpose of any file or folder, update the corresponding entry in the main README.md and/or the related clients/*/README.md - When the structure of the project, the tech stack, or the developer setup changes, update appropriate README.md files with the details. - When adding new commands, dependencies, or architectural patterns, update the relevant sections of appropriate README.md files as well. @@ -148,14 +151,14 @@ If you've already built a change locally, share the **prompt** you used and scre All work should be driven by items on the project board. -> **A v2 issue is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status *and* a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. +> **A v2 issue is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status _and_ a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. - Before starting work, check the board for the relevant item. - **Every board item is a real GitHub issue.** Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — **never create a duplicate**. - **Label by version — every issue and every PR, no exceptions.** Each one carries **exactly one** of `v1` or `v2` at creation. There is no unlabeled state and no "decide later": an issue with neither label belongs to no version line, appears in no version-filtered query, and is effectively invisible. - `v1` — work targeting `v1/main` (the deprecated line: security fixes only) - `v2` — work targeting `v2/main` (active development; the default for anything new) - Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix *for released v1 behavior* and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status **and** a Priority (a `v1` one needs a Status; board #11 has no Priority field). + Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix _for released v1 behavior_ and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status **and** a Priority (a `v1` one needs a Status; board #11 has no Priority field). - **Prioritize every new issue.** Every new issue must have a Priority (Urgent, High, Medium, or Low) set at creation time. Priority is a **board field**, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in [Setting issue priority](#setting-issue-priority) rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace. - **Add the issue to the board and set Status and Priority.** After creating an issue, add it to the board for its version — **`v2` → board #28**, **`v1` → board #11** — and set the fields. (PRs are never added to either board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim, and take them from the section for the right board; the two projects' ids are not interchangeable. - **New and untriaged → `Incoming`.** This is the **default status for a new item on either board.** An issue nobody has evaluated yet belongs in **Incoming**, not Todo. Todo means a maintainer approved it and it is ready to be picked up; using Todo as the inbox erases that distinction and quietly promotes unreviewed work into the queue. Anything filed by an outside reporter starts in Incoming. Work you are starting immediately goes straight to In Progress. @@ -168,7 +171,8 @@ All work should be driven by items on the project board. 'map(select(.state=="open")) | sort_by(.due_on) | .[] | "\(.title)\tdue \(.due_on[0:10])\topen=\(.open_issues)"' ``` - Milestones are **release** buckets (`v2.1.0`, `v2.2.0`, …), so pick by *when the work ships*, not by size. If a new issue plainly can't make the current milestone, say so and put it in the next one rather than leaving it blank. Sub-issues normally inherit their parent's milestone — if a sub-task must ship with its parent, they belong in the same one. + Milestones are **release** buckets (`v2.1.0`, `v2.2.0`, …), so pick by _when the work ships_, not by size. If a new issue plainly can't make the current milestone, say so and put it in the next one rather than leaving it blank. Sub-issues normally inherit their parent's milestone — if a sub-task must ship with its parent, they belong in the same one. + - When work begins, create a feature branch and set the item's Status to **In Progress**. - **Branch names start with the target version segment.** The first path segment must be the version whose base branch the PR targets — `v2/` for work on `v2/main`, `v1/` for work on `v1/main` — followed by the usual type and slug: `v2/ci/restore-claude-workflow`, `v2/fix/oauth-scope-union`, `v1/fix/proxy-ssrf-pin`. Not `ci/restore-claude-workflow`. This keeps the two lines legible in `git branch -a` and in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (`v2/main`, `v1/main`). - When work is complete: @@ -185,36 +189,36 @@ Every issue gets a **Priority on its board card**, set when you add the issue to > ⚠️ **There are two different "Priority" fields on an issue page, and they are unrelated. Ours is the one under _Projects → Inspector V2_.** > -> | Where it appears | What it is | Ours? | -> | --- | --- | --- | -> | **Projects → Inspector V2 → Priority** | The **project board** field on board #28 (`PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`). Urgent/High/Medium/Low, each option carrying its rubric band in the description. | ✅ **Yes — this is the one this rubric sets.** | -> | **Fields → Priority** (above _Projects_) | A GitHub **issue field**, `IFSS_kgDOAdAWeg`. Defined at the **`modelcontextprotocol` org** and shared by every repo in it (typescript-sdk, servers, registry, …), alongside `Effort`, `Start date`, and `Target date`. Created 2026-05-06, `ORG_ONLY`. | ❌ No. Not ours, not repo-scoped. | +> | Where it appears | What it is | Ours? | +> | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +> | **Projects → Inspector V2 → Priority** | The **project board** field on board #28 (`PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`). Urgent/High/Medium/Low, each option carrying its rubric band in the description. | ✅ **Yes — this is the one this rubric sets.** | +> | **Fields → Priority** (above _Projects_) | A GitHub **issue field**, `IFSS_kgDOAdAWeg`. Defined at the **`modelcontextprotocol` org** and shared by every repo in it (typescript-sdk, servers, registry, …), alongside `Effort`, `Start date`, and `Target date`. Created 2026-05-06, `ORG_ONLY`. | ❌ No. Not ours, not repo-scoped. | > > They look identical — same name, same four option names — but **nothing syncs them.** Setting one does not set the other, and they will happily disagree (this was first noticed on #1891 showing `Urgent` in Fields and `High` on the board). There is no pass-through, in either direction. > > **Never delete the org-level field.** It belongs to the whole org, so removing it would strip Priority from every other `modelcontextprotocol` repo. > -> **Don't set it either — but do read it.** A value there is a *reporter's* opinion, not a maintainer's assessment, so it is **untrusted input**. It feeds the rubric as a capped +1 signal bonus and nothing more; see [Trust boundary](#trust-boundary-who-can-set-what) below. +> **Don't set it either — but do read it.** A value there is a _reporter's_ opinion, not a maintainer's assessment, so it is **untrusted input**. It feeds the rubric as a capped +1 signal bonus and nothing more; see [Trust boundary](#trust-boundary-who-can-set-what) below. **Axis 1 — Severity / impact (1–5).** How bad is it when it happens? -| Score | Means | -| --- | --- | -| 1 | Cosmetic — a typo, a misaligned control, a wording nit. | -| 2 | Minor friction with an easy workaround. | -| 3 | A real feature is broken or missing; the workaround is annoying or partial. | -| 4 | A core workflow is unusable, or the Inspector reports something false about the server under test. | -| 5 | Data loss, a security vulnerability, or a release that is broken on arrival for everyone. | +| Score | Means | +| ----- | -------------------------------------------------------------------------------------------------- | +| 1 | Cosmetic — a typo, a misaligned control, a wording nit. | +| 2 | Minor friction with an easy workaround. | +| 3 | A real feature is broken or missing; the workaround is annoying or partial. | +| 4 | A core workflow is unusable, or the Inspector reports something false about the server under test. | +| 5 | Data loss, a security vulnerability, or a release that is broken on arrival for everyone. | **Axis 2 — Urgency / staleness (1–5).** How time-sensitive or neglected is it? -| Score | Means | -| --- | --- | -| 1 | No time pressure; nothing waits on it. | -| 2 | Wanted eventually. | -| 3 | Wanted this milestone, or has sat >90 days with no activity. | -| 4 | Blocking other work, or tied to a dated external dependency (an SDK release, a spec deadline). | -| 5 | Blocking a release, or actively hurting users on a published version right now. | +| Score | Means | +| ----- | ---------------------------------------------------------------------------------------------- | +| 1 | No time pressure; nothing waits on it. | +| 2 | Wanted eventually. | +| 3 | Wanted this milestone, or has sat >90 days with no activity. | +| 4 | Blocking other work, or tied to a dated external dependency (an SDK release, a spec deadline). | +| 5 | Blocking a release, or actively hurting users on a published version right now. | **Signal indicators (bonuses, +1 each — not an axis of their own).** These are corroborating evidence that the two axes may have undercounted, so they adjust the total rather than standing alone: @@ -227,14 +231,14 @@ Every issue gets a **Priority on its board card**, set when you add the issue to **Bands.** Axes give 2–10 and there are six bonuses, so the total runs 2–16. -| Total | Priority | Meaning | -| --- | --- | --- | -| 12+ | **Urgent** | Drop what you're doing. | -| 9–11 | **High** | Next up after current work. | -| 6–8 | **Medium** | Scheduled normally. | -| ≤5 | **Low** | Nice to have; may sit. | +| Total | Priority | Meaning | +| ----- | ---------- | --------------------------- | +| 12+ | **Urgent** | Drop what you're doing. | +| 9–11 | **High** | Next up after current work. | +| 6–8 | **Medium** | Scheduled normally. | +| ≤5 | **Low** | Nice to have; may sit. | -Note that severity alone doesn't reach Urgent: a 5/5 with no corroborating signals totals 10 and lands **High**. That's deliberate — Urgent is reserved for a severe problem that something *else* also confirms is burning, and a band that everything qualifies for stops carrying information. Override the band when it's plainly wrong, but say why in the issue; a rubric nobody may overrule is a rubric people route around. +Note that severity alone doesn't reach Urgent: a 5/5 with no corroborating signals totals 10 and lands **High**. That's deliberate — Urgent is reserved for a severe problem that something _else_ also confirms is burning, and a band that everything qualifies for stops carrying information. Override the band when it's plainly wrong, but say why in the issue; a rubric nobody may overrule is a rubric people route around. Set the resulting level on the board card with the Priority recipe in the [V2 board (#28) `gh` recipes](#v2-board-28-gh-recipes) below. @@ -250,7 +254,7 @@ That asymmetry is the whole reason the reporter's value earns a flat +1 and noth - **It cannot decide an outcome.** The bonus is capped, identical for `Urgent` and `High`, and can lift an issue at most one band. Nothing a reporter can type reaches Urgent by itself: Urgent needs 12, so the issue must already sit at 11 on maintainer-assessed axes — at which point the reporter is not the reason. - **Never map the value across.** A reporter selecting `Urgent` does **not** make the board card Urgent. Doing that would hand queue position to anyone with a GitHub account, and the queue would sort by assertiveness instead of impact. -Don't lean on GitHub's permission gate to enforce this. Whether an outside reporter can set that field today is an implementation detail that can change without notice; the rule holds either way, because it rests on *who assessed the issue* rather than on who was technically able to click. +Don't lean on GitHub's permission gate to enforce this. Whether an outside reporter can set that field today is an implementation detail that can change without notice; the rule holds either way, because it rests on _who assessed the issue_ rather than on who was technically able to click. **Assess board Priority at boarding time**, from the issue as it stands. The reporter's value is one input among several, weighted as above. @@ -259,13 +263,14 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor - **Repo**: https://github.com/modelcontextprotocol/inspector.git - **Base Branches** — three branches, three distinct roles. Target the one matching the work; never open a PR against `main`. - | Branch | Role | PRs target it? | Publishes to | - | --- | --- | --- | --- | - | `v2/main` | **Develop.** All active v2 work lands here. | **Yes** — every v2 PR | nothing directly; reaches npm via `main` | - | `main` | **Release.** The repo's default branch; holds the latest released v2. Not a development branch. | **No** — it only receives milestone merges from `v2/main` | `latest` | - | `v1/main` | **Maintenance.** The deprecated v1 line, security fixes only, no active development. | **Yes** — every v1 PR, directly | `v1-latest`, published straight from this branch | + | Branch | Role | PRs target it? | Publishes to | + | --------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------ | + | `v2/main` | **Develop.** All active v2 work lands here. | **Yes** — every v2 PR | nothing directly; reaches npm via `main` | + | `main` | **Release.** The repo's default branch; holds the latest released v2. Not a development branch. | **No** — it only receives milestone merges from `v2/main` | `latest` | + | `v1/main` | **Maintenance.** The deprecated v1 line, security fixes only, no active development. | **Yes** — every v1 PR, directly | `v1-latest`, published straight from this branch | So v2 flows `feature branch → v2/main → (milestone) main → npm latest`, while v1 is flat: `feature branch → v1/main → npm v1-latest`, with no merge into `main` at any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does **not** need to be forward-ported to reach users on v1 (`npx @modelcontextprotocol/inspector@v1-latest`). + - **Project Boards**: - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here) - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes) @@ -274,7 +279,7 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor #### V2 board (#28) `gh` recipes -The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The project node id and the field ids are stable. **The *option* ids are NOT stable — they are regenerated whenever a single-select field's option list is edited** (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with: +The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The project node id and the field ids are stable. **The _option_ ids are NOT stable — they are regenerated whenever a single-select field's option list is edited** (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with: ```sh # Swap "Status" for "Priority" to fetch the other field's options. @@ -282,35 +287,36 @@ gh project field-list 28 --owner modelcontextprotocol --format json \ | jq '.fields[] | select(.name=="Status") | .options' ``` -| Thing | ID | -| --- | --- | -| Project node ID | `PVT_kwDOCt2Azc4BJVxt` | -| Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | +| Thing | ID | +| ----------------- | -------------------------------- | +| Project node ID | `PVT_kwDOCt2Azc4BJVxt` | +| Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | | Priority field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4` | Status option IDs (`--single-select-option-id`) — **last verified 2026-08-01**. -| Status | Option ID | -| --- | --- | -| Incoming | `721a3d4c` | -| Todo | `fbdaf21e` | +| Status | Option ID | +| ----------- | ---------- | +| Incoming | `721a3d4c` | +| Todo | `fbdaf21e` | | In Progress | `195df262` | -| In Review | `159c8a02` | -| Done | `259d6aab` | +| In Review | `159c8a02` | +| Done | `259d6aab` | Use **Incoming** for newly filed, untriaged work, **Todo** once a maintainer has approved it and it's ready to pick up, **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge. The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. Priority option IDs (`--single-select-option-id`) — **last verified 2026-08-01**. Derive the level with the rubric in [Setting issue priority](#setting-issue-priority); don't eyeball it. -| Priority | Option ID | Rubric total | -| --- | --- | --- | -| Urgent | `79628723` | 12+ | -| High | `0a877460` | 9–11 | -| Medium | `da944a9c` | 6–8 | -| Low | `d67ac7ce` | ≤5 | +| Priority | Option ID | Rubric total | +| -------- | ---------- | ------------ | +| Urgent | `79628723` | 12+ | +| High | `0a877460` | 9–11 | +| Medium | `da944a9c` | 6–8 | +| Low | `d67ac7ce` | ≤5 | -> ⚠️ **Never add, rename, or remove an option on a single-select board field (Status or Priority) with the `updateProjectV2Field` GraphQL mutation unless you pass every existing option's `id`.** That mutation does a **full replace** of the option list: if you resend options by name/color/description but omit their `id`s, GitHub **deletes all existing options and mints new ones**, which **orphans that field's value on every card on the board** (all items go blank for the field you edited — Status if you were editing Status, Priority if you were editing Priority) *and* invalidates every option id in that field's table above. This has happened once, on Status (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference: -> 1. **Add or rename an option in the GitHub web UI** (Project #28 → the field's settings). This preserves ids of untouched options and never orphans the cards on *other* options. ⚠️ **Deleting is different, in the UI as much as in the API: removing an option blanks that field's value on every card that held it, with no undo and no warning that says so.** Before deleting any option, snapshot the board (see recovery below). +> ⚠️ **Never add, rename, or remove an option on a single-select board field (Status or Priority) with the `updateProjectV2Field` GraphQL mutation unless you pass every existing option's `id`.** That mutation does a **full replace** of the option list: if you resend options by name/color/description but omit their `id`s, GitHub **deletes all existing options and mints new ones**, which **orphans that field's value on every card on the board** (all items go blank for the field you edited — Status if you were editing Status, Priority if you were editing Priority) _and_ invalidates every option id in that field's table above. This has happened once, on Status (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference: +> +> 1. **Add or rename an option in the GitHub web UI** (Project #28 → the field's settings). This preserves ids of untouched options and never orphans the cards on _other_ options. ⚠️ **Deleting is different, in the UI as much as in the API: removing an option blanks that field's value on every card that held it, with no undo and no warning that says so.** Before deleting any option, snapshot the board (see recovery below). > 2. If you must script it, first `gh api graphql` the current options **with their `id`s**, then call `updateProjectV2Field` echoing back every existing option **including its `id`**, appending only the new one. `ProjectV2SingleSelectFieldOptionInput.id` is an optional `String`, so a mixed list works: echo the `id` for every option that already exists, omit it only for the one being added. Verify afterward that no card lost its value — snapshot `gh project item-list … --format json` before and after and diff, don't just spot-check. > > Both the `Incoming` Status option and the Urgent/High/Medium/Low `Priority` options were added this way (#1891), with the before/after diff confirming all 264 cards kept their Status. @@ -329,7 +335,7 @@ Priority option IDs (`--single-select-option-id`) — **last verified 2026-08-01 > > This has now happened twice — once via the API (~197 items, reconstructed by inference) and once via the UI (the `Done` column, 247 items, restored from a snapshot in minutes). With a snapshot the recovery is mechanical. > -> **The recipe below is written for a deleted *Status* option** — it reads `.status` and writes the Status field id. For a deleted **Priority** option it is the same three steps with two substitutions: read `.priority` instead of `.status` (`gh project item-list --format json` exposes each single-select field under its lowercased name, so both keys are present), and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4` instead of the Status one. Everything else — the snapshot, the grouping safety check, the new-id caveat — applies unchanged. +> **The recipe below is written for a deleted _Status_ option** — it reads `.status` and writes the Status field id. For a deleted **Priority** option it is the same three steps with two substitutions: read `.priority` instead of `.status` (`gh project item-list --format json` exposes each single-select field under its lowercased name, so both keys are present), and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4` instead of the Status one. Everything else — the snapshot, the grouping safety check, the new-id caveat — applies unchanged. > > ```sh > # 1. Which cards lost their value, and what did they hold? @@ -390,20 +396,20 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i The v1 line takes **security fixes only**, so this board sees little traffic — but a v1 issue still gets a card, and it starts in **Incoming** like a v2 one. Board #11 is a separate org project with **its own ids**; none of the #28 ids above work here. -| Thing | ID | -| --- | --- | -| Project node ID | `PVT_kwDOCt2Azc4BA5sz` | +| Thing | ID | +| --------------- | -------------------------------- | +| Project node ID | `PVT_kwDOCt2Azc4BA5sz` | | Status field ID | `PVTSSF_lADOCt2Azc4BA5szzgzkS-g` | Status option IDs — **last verified 2026-08-01**. -| Status | Option ID | -| --- | --- | -| Incoming | `831820cf` | -| Todo | `f75ad846` | +| Status | Option ID | +| ----------- | ---------- | +| Incoming | `831820cf` | +| Todo | `f75ad846` | | In Progress | `47fc9ee4` | -| In Review | `0439b2bf` | -| Done | `98236657` | +| In Review | `0439b2bf` | +| Done | `98236657` | There is **no Priority field on this board** — the priority rubric applies to v2 only. Don't try to set one here; the field id doesn't exist. @@ -416,6 +422,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" --field-i The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe above apply to **this board too** — same mutation, same failure mode, different ids. Note that three cards on #11 already carry no Status; that predates the `Incoming` addition (verified by before/after diff on 2026-08-01) and is not evidence of an orphaning event. ### Always test new or modified code + - Ensure all code has corresponding tests - Ensure test coverage for each file is at least 90% - In unit tests that expect error output, suppress it from the console @@ -433,37 +440,40 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **TUI** (`clients/tui`): the gate now covers **all of `src/**`, React surface included** — the former interim exclusion of the Ink components, `App.tsx`, and `hooks/` was lifted in #1501. Components mount through `ink-testing-library` with the `ink-scroll-view` / `ink-form` passthrough doubles in `__tests__/helpers/`, `App.tsx` mounts against a controllable mock of the `@inspector/core` surface, and keypresses are driven through stdin. The **only** coverage exclusion left in `clients/tui/vitest.config.ts` is `src/tui-servers.ts` — a pure re-export + type alias of core's server resolver with no runtime statements of its own (the logic is measured in `core/` via the web suite; `tui-servers.test.ts` still exercises it behaviorally, and it's excluded only so it doesn't surface as a misleading 0/0 row). Any new logic under `clients/tui/src`, React or not, is held to the gate automatically. - Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI does not run `test:integration` as its own step — the integration project is covered by the CI `coverage` gate, whose web `test:coverage` runs `--project=unit --project=integration --coverage`. - Test files live alongside the source as `.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. -- **Test placement: side-by-side by default, `src/test/` only for what can't be co-located.** These look like competing conventions but aren't — the split is: *tests live beside their source, **except** tests for the repo-root `core/` package (which lives outside `clients/web/`) and shared test scaffolding — both of which live under `src/test/`, with `core/` tests mirroring the `core/` layout and integration tests under `src/test/integration/`.* +- **Test placement: side-by-side by default, `src/test/` only for what can't be co-located.** These look like competing conventions but aren't — the split is: _tests live beside their source, **except** tests for the repo-root `core/` package (which lives outside `clients/web/`) and shared test scaffolding — both of which live under `src/test/`, with `core/` tests mirroring the `core/` layout and integration tests under `src/test/integration/`._ - **Side-by-side (`.test.tsx` next to the source) — the default for web's own `src/` code.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead of beside its source is a bug (fixed one such straggler, `downloadFile.test.ts`, in #1776). - - **`src/test/` — the three things that *cannot* be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement *is* the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests *of* a source file, so nothing to sit beside. + - **`src/test/` — the three things that _cannot_ be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement _is_ the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests _of_ a source file, so nothing to sit beside. - **The above is web only.** The Node clients (**cli, tui, launcher**) keep **all** their tests in a top-level **`__tests__/`** dir, not beside their source — their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes `__tests__/**/*` (plus, for launcher, its root `vitest.config.ts`), so a co-located `src/**/*.test.*` lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage` (#1791). Put a new cli/tui/launcher test under `__tests__/`. -- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert *mid-flight* transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent *enter* (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the *real* clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. +- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. ### Responding to Code Reviews + - When asked to respond to a code review of a PR, - it is not necessary to implement all suggestions - you are free to implement suggestions in a different way or to ignore if there is a good reason - after making the changes, respond to each review comment with what was done (or why it was ignored) ### Mandatory pre-push gate + - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`test:scripts`** (the guard's own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). + - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib _resolution_ options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is _wired_ (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently _skips_ a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the _global_ union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. - `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with `smoke:web:browser`, so the two can't drift. -- `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a *class* (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first *call* into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A *synchronous* such throw fires `pageerror`; its *async* twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never *called* ships a harmless `{}` and is invisible here by design. Every *other* console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). -- `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=&openApp=…&appArgs=…&autoOpen=`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe *and* fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. **Scope note:** this runs against the repo build tree like every other smoke, so it would *not* have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. -- **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's *browser-externalization warning* (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the *called-at-init* case (which `smoke:web:browser` also catches, but later/at runtime) **and** the *imported-but-never-called* case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin *records* the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. +- `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a _class_ (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first _call_ into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A _synchronous_ such throw fires `pageerror`; its _async_ twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never _called_ ships a harmless `{}` and is invisible here by design. Every _other_ console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). +- `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=&openApp=…&appArgs=…&autoOpen=`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe _and_ fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. **Scope note:** this runs against the repo build tree like every other smoke, so it would _not_ have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. +- **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's _browser-externalization warning_ (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the _called-at-init_ case (which `smoke:web:browser` also catches, but later/at runtime) **and** the _imported-but-never-called_ case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin _records_ the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. - `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog ` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing. - Storybook play-function tests (`clients/web` `test:storybook`) run in headless Chromium via `@vitest/browser-playwright` (~10s). They are part of `npm run ci` (which installs Playwright chromium first); kept out of `validate` because they need the browser binary and are slower than the unit suite. ### Typescript instructions + - Use TypeScript for all new code - Follow TypeScript best practices and coding standards - NEVER use 'any' as a type @@ -482,7 +492,7 @@ The web client keeps two grab-bag directories under `clients/web/src`, split by - **`src/utils/`** — pure, side-effect-free functions. Input → output, no DOM/browser/storage I/O, no subsystem ownership. Trivially unit-testable with no mocks. (Anchors: `jsonUtils`, `schemaUtils`, `toolUtils`, `maskSecrets`, `inspectorTabs`, `deepLink`, `mcpNetworkHeaders`.) Carve-outs that are still `utils`: - _Domain types._ Pure **shared domain types plus their pure constructors/transforms** live here (`customHeaders` — `CustomHeader` + `headersToRecord`/`migrateFromLegacyAuth`, a shape staged for `ServerSettingsForm`, see `specification/v2_ux_interfaces_plan.md`, so it currently has no importer but its own test). There is no `types/` sub-bucket **inside** `lib`/`utils` — removing `lib/types/` is what the `customHeaders` move settles. - _Diagnostic logging._ `console.warn`/`console.error` does **not** count as a side effect for this rule — a validator that warns on bad input is still "pure" here (`sandbox-csp`, `jsonUtils`, `schemaUtils` all warn). - - _Importing from `@inspector/core`._ Two forms are fine: a **type-only** import is not a subsystem dependency (`pendingReauth` is pure type declarations), and **re-exporting pure functions or constants** from core is not subsystem ownership either (`oauthUx`/`oauthFlow` re-export core copy/predicates). What makes a module `lib` is wrapping core's *stateful runtime*, not merely importing from it. + - _Importing from `@inspector/core`._ Two forms are fine: a **type-only** import is not a subsystem dependency (`pendingReauth` is pure type declarations), and **re-exporting pure functions or constants** from core is not subsystem ownership either (`oauthUx`/`oauthFlow` re-export core copy/predicates). What makes a module `lib` is wrapping core's _stateful runtime_, not merely importing from it. - **`src/lib/`** — infrastructure / integration / stateful adapters. Modules that instantiate or compose subsystems, wrap the `@inspector/core` **runtime** (not just its types), touch the DOM / `window` / `sessionStorage`, or otherwise produce side effects. (Anchors: `environmentFactory` composes `InspectorClientEnvironment`; `remoteOAuthStorage` is an adapter class over `core/auth`; `oauthResume` reads/writes `sessionStorage`; `browserTabVisibility` registers `visibilitychange` listeners; `clearServerOAuthState` drives the live `InspectorClient` / `OAuthStorage`; `downloadFile` triggers browser downloads.) The top-level **`src/types/`** is a sibling of both and is not the place for new domain types — it's now purely the home for ambient `.d.ts` module stubs (e.g. the `react-syntax-highlighter` shims wired through `tsconfig.app.json` `paths`). The last plain-`.ts` domain type there, the dead `navigation.ts` `InspectorTab`, was removed in #1785, so a pure domain type belongs in `utils/`, not `src/types/`. @@ -492,6 +502,7 @@ Cross-directory imports point **one way, `lib → utils`** (infra depends on pur Nothing **enforces** the boundary: no path alias keys off it, and the coverage `include` in `clients/web/vite.config.ts` lists **both** `src/lib/**` and `src/utils/**`, so a move between them is coverage-neutral (this is why the refactor was gate-safe). It's a human-legible signal at import time, valuable in a codebase this test-heavy (the ≥90% per-file gate). Note that `include` is a **whitelist** — it names `components`/`hooks`/`theme`/`lib`/`utils`/`server` (plus the `core/*` runtime; `hooks` and `theme` were added in #1787), so a module placed **outside** those directories (`types/`, `App.tsx`, or a brand-new grab-bag) falls out of the ≥90 gate entirely, silently. The **deliberate, documented** top-level-file exceptions are `src/App.tsx` — a ~4.5k-line composition root at ~42% branch coverage (gating it is a dedicated testing/decomposition effort, not a whitelist tweak) — and the `src/main.tsx` / `src/index.ts` bootstraps (browser `createRoot` render and the bin `runWeb` re-export, the analog of `clients/cli`'s excluded `src/index.ts`). All three are called out in a comment on the `include` array itself rather than left silent. When adding a module, place it by the rule and keep it inside a gated directory; when it genuinely mixes both (e.g. `downloadFile` bundles DOM-side-effect helpers with a couple of pure ones), keep it whole on its dominant side (`lib`) rather than splitting hairs. ## React instructions + - UI Components - We are using the Mantine component library for UI. - Instructions are at https://mantine.dev/llms.txt @@ -507,7 +518,7 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage ` - NEVER use inline code; instead extract to functions in the same file, exported or located in a shared location if immediately reusable. - In a component's file, for sub-components: - ALWAYS use Mantine components for layout and content, configured with props for styling and behavior. - - ALWAYS declare a meaningfully named subcomponent as a constant using `.withProps()` if an inline Mantine element carries two or more **static** props. A *static* prop is one whose value is a literal that configures the element's **styling, layout, or behavior** (`size="sm"`, `c="dimmed"`, `fw={500}`, `gap="xs"`, `justify="space-between"`, `variant="light"`, `withBorder`, `readOnly`, `striped`, …); dynamic props (`value`, `onChange`/`on*`, `children`, `key`, `ref`, and anything whose value is a variable/expression) do **not** count toward the two and are passed at the call site, not baked into the constant. Purely per-instance **content/accessibility** literals — `label`, `description`, `placeholder`, `title`, `aria-label`, `role` — likewise do **not** count toward the two (a `` with no styling/layout/behavior props stays inline); they may be baked into a constant when it already qualifies and doing so aids reuse, but they never by themselves trigger extraction. This rule applies in **all** cases: "repeated pattern" is NOT the bar — a single-use element with two or more static styling/layout/behavior props must still be extracted. Bake the static props into the `.withProps()` constant and pass the dynamic ones where it's rendered. + - ALWAYS declare a meaningfully named subcomponent as a constant using `.withProps()` if an inline Mantine element carries two or more **static** props. A _static_ prop is one whose value is a literal that configures the element's **styling, layout, or behavior** (`size="sm"`, `c="dimmed"`, `fw={500}`, `gap="xs"`, `justify="space-between"`, `variant="light"`, `withBorder`, `readOnly`, `striped`, …); dynamic props (`value`, `onChange`/`on*`, `children`, `key`, `ref`, and anything whose value is a variable/expression) do **not** count toward the two and are passed at the call site, not baked into the constant. Purely per-instance **content/accessibility** literals — `label`, `description`, `placeholder`, `title`, `aria-label`, `role` — likewise do **not** count toward the two (a `` with no styling/layout/behavior props stays inline); they may be baked into a constant when it already qualifies and doing so aids reuse, but they never by themselves trigger extraction. This rule applies in **all** cases: "repeated pattern" is NOT the bar — a single-use element with two or more static styling/layout/behavior props must still be extracted. Bake the static props into the `.withProps()` constant and pass the dynamic ones where it's rendered. - The following **cannot** be expressed via `.withProps()` and so stay inline (like `Box` below), each with a one-line comment saying why: **`Accordion`** (a compound, `multiple`-discriminated generic — `.withProps({ multiple: true, … })` loses its JSX call signature and fails to type); **headless, non-`factory()` Mantine components** such as **`Transition`** (plain function components with no Styles API — they have no `.withProps` static at all, e.g. `Transition.withProps` is a TS2339); and **`data-*` attributes** (not part of a component's typed props object, so excess-property-checked out of a `withProps` literal — pass them at the call site). The rule targets factory-based (Styles-API) Mantine components; anything that isn't one is out of scope entirely — a third-party element (a `react-icons` glyph, another library's component) **and** a first-party component that isn't a Mantine factory (a dumb `export function` like `ContentViewer`, which has no `.withProps` static of its own). - NEVER use `Box` for subcomponent constants — `Box` does not support `.withProps()`. Use `Group`, `Stack`, `Flex`, `Text`, `Paper`, `UnstyledButton`, or `Image` instead. Pick the component that best matches the purpose: `Paper` for bordered/surfaced containers, `Text` for any text or content wrapper, `Stack`/`Group`/`Flex` for layout. A `Box` that genuinely needs a non-flex primitive it can't provide — `component="iframe"`, or `display="grid"` (no Mantine flex primitive is a CSS grid) — stays a `Box` inline, with a one-line comment saying why. - NEVER use a CSS class on a subcomponent constant when the styles can be expressed as a Mantine theme variant instead. Define variants in `src/theme/.ts` using `Component.extend({ styles: (_theme, props) => { ... } })` and reference them with `variant="variantName"` on the component or in `.withProps()`. @@ -515,32 +526,32 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage ` - When a theme variant needs a CSS class for nested/pseudo selectors, use `classNames` in the theme extension to auto-assign it — never add `className` manually in JSX for theme-styled components. - Example — subcomponent constant with `withProps`: ```tsx - const CardContent = Group.withProps({ - flex: 1, - align: 'flex-start', - justify: 'space-between', - wrap: 'nowrap', - }); - return ... + const CardContent = Group.withProps({ + flex: 1, + align: "flex-start", + justify: "space-between", + wrap: "nowrap", + }); + return ... ; ``` - Example — theme variant with auto-assigned className for nested selectors: ```tsx - // src/theme/Paper.ts - export const ThemePaper = Paper.extend({ - classNames: (_theme, props) => { - if (props.variant === 'message') return { root: 'message' }; - return {}; - }, - styles: (_theme, props) => { - if (props.variant === 'message') { - return { root: { padding: '1.5rem', borderRadius: 12 } }; - } - return { root: {} }; - }, - }), + // src/theme/Paper.ts + export const ThemePaper = Paper.extend({ + classNames: (_theme, props) => { + if (props.variant === "message") return { root: "message" }; + return {}; + }, + styles: (_theme, props) => { + if (props.variant === "message") { + return { root: { padding: "1.5rem", borderRadius: 12 } }; + } + return { root: {} }; + }, + }); - // Component.tsx - const MessageContainer = Paper.withProps({ variant: 'message' }); + // Component.tsx + const MessageContainer = Paper.withProps({ variant: "message" }); ``` - State and effects - **NEVER reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error under `react-hooks/set-state-in-effect`, which the web client's `eslint-plugin-react-hooks` recommended set enforces. @@ -563,4 +574,3 @@ The dev/prod web backend protects every `/api/*` route with `x-mcp-remote-auth: 3. `sessionStorage` — backstop for navigations that land without either of the above. Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the global name is the shared `INSPECTOR_API_TOKEN_GLOBAL` constant in `core/mcp/remote/constants.ts`. - diff --git a/README.md b/README.md index 13792764..b5d5d7a4 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ npx @modelcontextprotocol/inspector --cli # CLI npx @modelcontextprotocol/inspector --tui # TUI ``` +> **Upgrading from v1?** Read the [v1 → v2 migration guide](./docs/v1-to-v2-migration.md) — CLI flags, the new `--config` vs. `--catalog` split, the Node engine bump, and what no longer ships. + > **Repo status.** This is the **v2** line of the Inspector. Active development happens on **`v2/main`** (the develop branch — all v2 PRs target it), which is merged into **`main`** at milestone releases; `main` is the default branch and holds the latest released v2, published to the npm `latest` tag. The legacy **v1** line lives on **`v1/main`** — security fixes only, published straight from that branch to the npm `v1-latest` tag (`npx @modelcontextprotocol/inspector@v1-latest`). See [`AGENTS.md`](./AGENTS.md) for branch/board conventions. ## Project layout @@ -38,7 +40,7 @@ inspector/ │ └── 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, verify-build-gate, verify-format-coverage, pack:verify) -├── docs/ # Task-oriented guides (server configuration, MCP App review, launcher/config plan) +├── docs/ # Task-oriented guides (v1→v2 migration, server configuration, MCP App review, launcher/config plan) ├── specification/ # Design/build specifications ├── AGENTS.md # Contribution rules for agents AND humans (see below) └── README.md # You are here @@ -49,6 +51,7 @@ Each client has its own README with client-specific detail: Task-oriented guides live under [`docs/`](./docs): +- [Migrating from v1 to v2](./docs/v1-to-v2-migration.md) — the v1 → v2 map: CLI flag mapping, `--config` vs. `--catalog` semantics with before/after examples, the Node engine bump (`>=22.7.5` → `>=22.19.0`), env-var renames, and the sub-packages that no longer ship. - [MCP server configuration](./docs/mcp-server-configuration.md) — which server(s) the Inspector connects to: `--catalog` vs. `--config`, ad-hoc targets, the `--` separator, the file format and its Inspector-specific per-server fields. Shared by all three clients; the cli and tui READMEs delegate their server-options sections to it. - [Reviewing an MCP App](./docs/mcp-app-review.md) — the CLI-first → one-shot-web recipe for automated App-tool review: `--app-info` probe → deep-link navigate → rendered widget, plus OAuth handoff and proxy support. - [Launcher and config consolidation](./docs/launcher-config-consolidation-plan.md) — why the launcher runs a client in-process rather than spawning it, and how the shared config processor fits in. @@ -130,18 +133,18 @@ This is what lets an Inspector connection negotiating `protocolEra: "auto" | "mo Each config below is a ready-made server for exercising one feature by hand. Load one with `--config`, and unless noted, connect with **Protocol Era = Modern**. -| Config | Demonstrates | Issue | -| ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | +| Config | Demonstrates | Issue | +| ----------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- | | `mcp-app-http.json` **(legacy era)** | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) | -| `modern-mrtr-http.json` | A single MRTR round-trip | — | -| `mrtr-showcase-http.json` | Every MRTR preset in one server | — | -| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | -| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) | -| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | -| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | -| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | -| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | -| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | +| `modern-mrtr-http.json` | A single MRTR round-trip | — | +| `mrtr-showcase-http.json` | Every MRTR preset in one server | — | +| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | +| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) | +| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | +| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | +| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | +| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | +| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | #### MCP Apps @@ -261,17 +264,17 @@ Individual clients: `build:web`, `build:cli`, `build:tui`, `build:launcher`. The 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` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. 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 and covers the shared `core/` runtime (including `core/json` and `core/client`). | -| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | -| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | -| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | -| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | -| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | -| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | +| Script | What it does | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. 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 and covers the shared `core/` runtime (including `core/json` and `core/client`). | +| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | +| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | +| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards _this_ gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | +| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `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`, …), plus root `validate:core` / `format:core` for the shared `core/` package, `format:scripts` for the root `scripts/` tooling, and `format:shared` / `lint:shared` for the root "shared" surface (`test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`). Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, the shared surface, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file. @@ -287,7 +290,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/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved *relative to* `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. +- **`clients/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved _relative to_ `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. - **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/README.md b/clients/cli/README.md index b3da972b..d5c033df 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -12,6 +12,8 @@ npx @modelcontextprotocol/inspector --cli node build/index.js Supports tools, resources, and prompts (plus `--method servers/list` / `servers/show` for catalog entries without connecting). +> Coming from the v1 CLI? See the [v1 → v2 migration guide](../../docs/v1-to-v2-migration.md) — every v1 flag still exists, but exit codes, argument ordering, and the `--` separator changed. + ### Examples **Basic usage** diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 98336df1..88250cd6 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -201,5 +201,6 @@ The CLI and TUI do not perform catalog CRUD yet — they are read consumers — ## Related +- [Migrating from v1 to v2](./v1-to-v2-migration.md) — why `--config` means something narrower than it did in v1, and what `--catalog` replaced. - [Launcher and config consolidation](./launcher-config-consolidation-plan.md) — how the launcher and the shared config processor fit together. - [Reviewing an MCP App](./mcp-app-review.md) — the CLI-first App review recipe. diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md new file mode 100644 index 00000000..ef2865fc --- /dev/null +++ b/docs/v1-to-v2-migration.md @@ -0,0 +1,325 @@ +# Migrating from Inspector v1 to v2 + +v2 is a rewrite. The package name and the `npx @modelcontextprotocol/inspector` entry point are unchanged, but the architecture, the CLI surface, and the way you point the Inspector at a server all changed. + +This guide is the map. It covers what breaks, what it becomes, and the handful of places where a v1 command still runs in v2 but means something different — the ones worth reading before you conclude v2 is broken. + +> **v1 is deprecated** and receives security fixes only, published to the `v1-latest` npm dist-tag. To stay on it, pin it explicitly: +> +> ```bash +> npx @modelcontextprotocol/inspector@v1-latest +> ``` + +## At a glance + +| | v1 | v2 | +| ----------------- | ------------------------------------------------ | --------------------------------------------------------------- | +| Node | `>=22.7.5` | **`>=22.19.0`** | +| npm packages | 4 (`inspector` + `-client` / `-server` / `-cli`) | **1** (`@modelcontextprotocol/inspector`) | +| Processes / ports | web UI on `6274` **+ MCP proxy on `6277`** | one web server on `6274` (plus a dynamic MCP Apps sandbox port) | +| Modes | web, `--cli` | web, `--cli`, **`--tui`** | +| Server list | per-browser, in `localStorage` | a **catalog file** on disk (`~/.mcp-inspector/mcp.json`) | +| Auth token env | `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | +| CLI exit codes | `0` / `1` | `0`–`5`, plus a JSON error envelope on stderr | + +The three changes most likely to bite an existing setup: + +1. **`--config` no longer means what it did** — see [`--config` vs `--catalog`](#--config-vs---catalog). +2. **A failing tool call now exits non-zero** (`5`), where v1 exited `0`. CI scripts that ran `&&` chains past a failed call will start failing — correctly. +3. **`SERVER_PORT` was the proxy port and now isn't** — it survives as a fallback for the MCP Apps sandbox port. Setting it no longer moves anything you can browse. + +## Requirements + +v2 requires **Node `>=22.19.0`** (v1 required `>=22.7.5`). The floor comes from `undici@^8`, used for HTTP proxy support. `npx` will refuse to run on an older Node rather than fail obscurely later. + +## What no longer ships + +v1 published four packages: + +- `@modelcontextprotocol/inspector` +- `@modelcontextprotocol/inspector-client` +- `@modelcontextprotocol/inspector-server` +- `@modelcontextprotocol/inspector-cli` + +**v2 publishes only the first.** It is a single tarball with a single version number, containing the web client, the CLI, the TUI, and the launcher that dispatches between them. The three sub-packages are frozen at `1.0.1`, deprecated on npm, and will never see a 2.x. + +If you depend on one of them directly, drop it and use the root package's `mcp-inspector` bin. There is no v2 equivalent of importing `inspector-server` as a library; the shared runtime lives in this repo's `core/` and is not published separately yet ([#1636](https://github.com/modelcontextprotocol/inspector/issues/1636)). + +```diff + { + "devDependencies": { +- "@modelcontextprotocol/inspector-cli": "^1.0.1" ++ "@modelcontextprotocol/inspector": "^2.0.0" + } + } +``` + +## Architecture: the proxy is gone + +v1 ran **two** processes: a React client on `6274` and an "MCP Proxy" on `6277` that held the actual MCP connections. The browser talked to the proxy over HTTP, authenticating with `MCP_PROXY_AUTH_TOKEN`. + +v2 runs **one** web server on `6274`. It serves the SPA and exposes `/api/*`, which the browser calls with `x-mcp-remote-auth: Bearer `. There is no second port to expose, forward, or allow through a firewall. + +Consequences: + +- `SERVER_PORT` no longer selects a port you browse. (It is read as a fallback for the MCP Apps sandbox server's port — see `MCP_SANDBOX_PORT` below.) +- `MCP_PROXY_FULL_ADDRESS` has no v2 equivalent and is ignored. It existed to tell the browser where a non-default proxy lived; there is no proxy. +- Docker only needs `-p 6274:6274`. The v1 recipe published `6277` as well. + +## Launching + +```bash +npx @modelcontextprotocol/inspector # web UI (unchanged default) +npx @modelcontextprotocol/inspector --cli # CLI +npx @modelcontextprotocol/inspector --tui # TUI (new in v2) +``` + +The mode flag must come **first**, immediately after the binary name; everything after it is forwarded to that client unchanged. + +Passing a server ad-hoc works the same way it did: + +```bash +npx @modelcontextprotocol/inspector node build/index.js +npx @modelcontextprotocol/inspector -e KEY=value -- node build/index.js --server-flag +``` + +## `--config` vs `--catalog` + +**This is the change most likely to surprise you.** Both versions have a `--config` flag, both take an `mcpServers` file, and they do not mean the same thing. + +In v1, `--config path.json --server name` resolved one entry out of the file and launched against it. The file was read once and never written — but there was no other file, because v1's web UI kept its server list in browser `localStorage`. + +v2 has a first-class **server catalog** on disk, and splits the two roles: + +| | `--catalog ` | `--config ` | +| ------------------------- | -------------------------------------------------- | -------------------------------------------------------- | +| Writable by the Inspector | **Yes** — this is the Inspector's own list | **No.** Served as-is; never written, seeded, or migrated | +| When the file is missing | created and seeded | **errors** | +| Default path | `~/.mcp-inspector/mcp.json`, or `MCP_CATALOG_PATH` | none — must be passed | +| Editable in the web UI | yes | no (catalog CRUD is hidden) | + +So: + +- **`--config` in v2 is the read-only role.** Point it at a file you didn't write — a coworker's, a client application's, one checked into a repo — and the Inspector guarantees it won't touch the bytes, including any plaintext secrets in them. This is close to v1's behavior, minus the write-back that never existed anyway. +- **`--catalog` is new** and is what you want if you'd like the web UI to add, edit, and remove servers. +- **Neither is required.** With no source flag, v2 uses the default catalog `~/.mcp-inspector/mcp.json`, creating it if absent. v1 had no such default — it started empty every time. + +### Before / after + +**Your v1 file works unchanged.** The format is the same `mcpServers` shape, `type: "streamable-http"` is still accepted (as is the `"http"` alias), and unknown fields such as v1's exported `note` are carried through rather than rejected. + +```bash +# v1 — one entry out of a file +npx @modelcontextprotocol/inspector --config ./mcp.json --server everything + +# v2 — same thing, same read-only guarantee +npx @modelcontextprotocol/inspector --config ./mcp.json --server everything +``` + +⚠️ One caveat on that last line: **`--server` only selects under `--cli`.** On the web client it is a no-op that logs a warning, and the TUI rejects it as an unknown option. In the web UI you pick the server from the list after it loads. + +```bash +# v2 — let the Inspector manage the file (web UI can edit it) +npx @modelcontextprotocol/inspector --catalog ./mcp.json + +# v2 — the default catalog, no flags at all +npx @modelcontextprotocol/inspector +``` + +**Rules:** `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target (a positional command, `--server-url`, or `--transport`). v1 silently preferred the config file; v2 tells you. + +If you export `MCP_CATALOG_PATH` in your shell, note that **web and TUI read it unconditionally** — so an ad-hoc invocation such as `mcp-inspector --tui node build/index.js` is rejected as a catalog/ad-hoc conflict. Unset it for that invocation. The CLI ignores the variable whenever an ad-hoc target is present. + +The full model, including the Inspector-specific per-server fields v2 adds (`protocolEra`, `roots`, `requestTimeout`, `oauth`, …), is documented in [MCP server configuration](./mcp-server-configuration.md). + +## CLI flag mapping + +Every v1 CLI flag still exists in v2 and means the same thing. Nothing was renamed or removed: + +| v1 flag | v2 | Notes | +| -------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------- | +| `--method ` | same | v2 adds `initialize`, `servers/list`, `servers/show`; stream-only methods are rejected explicitly | +| `--tool-name ` | same | | +| `--tool-arg ` | same | | +| `--uri ` | same | | +| `--prompt-name ` | same | | +| `--prompt-args ` | same | | +| `--log-level ` | same | | +| `--transport ` | same | | +| `--server-url ` | same | | +| `--header "Name: Value"` | same | | +| `--metadata ` | same | | +| `--tool-metadata ` | same | | +| `-e KEY=VALUE` | same | | +| `--config ` | same flag, **narrower meaning** | see [above](#--config-vs---catalog) | +| `--server ` | same | now `--cli`-only | +| `[target...]` | same | but see the target-ordering rule below | + +New in v2, with no v1 equivalent: + +| Flag | What it does | +| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `--catalog ` | writable catalog file (see above) | +| `--cwd ` | working directory for a stdio server process | +| `--tool-args-json ''` | tool arguments as one JSON object, passed verbatim (no `key=value` coercion, so `"012"` stays a string) | +| `--format text\|json` | `json` emits a single `{"result":…}` object on stdout with no banners | +| `--app-info` | probe a tool's MCP App UI metadata without invoking the tool | +| `--connect-timeout ` | connect timeout; defaults to `15000` for ad-hoc targets so a black-holed host fails fast | +| `--client-id` / `--client-secret` / `--client-metadata-url` / `--client-config` | OAuth client configuration | +| `--callback-url ` | OAuth loopback redirect URI (default `http://127.0.0.1:6276/oauth/callback`) | +| `--relogin` / `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / `--list-stored-auth` / `--print-handoff` | OAuth token reuse and web→CLI handoff | + +v1 had no OAuth support in the CLI at all; a protected server simply failed. If you script against protected servers, `--stored-auth-only` is the flag to reach for in CI — it never opens a browser and fails fast with `auth_required` instead of hanging on a loopback callback. + +See the [CLI README](../clients/cli/README.md) for the full surface. + +## CLI behavior changes + +The flags survived; three behaviors did not. + +### 1. Exit codes and the error envelope + +v1 exited `0` on success and `1` on any failure — including a `tools/call` that came back `isError: true`, which exited `0` because the _call_ succeeded. + +v2 maps failures onto a stable set: + +| Code | Meaning | +| ---- | ------------------------------------------------------- | +| `0` | Success | +| `1` | Usage / unexpected error | +| `2` | No MCP App on the tool (`--app-info` probe) | +| `3` | Server requires authentication | +| `4` | Server unreachable (DNS, refused, timeout) | +| `5` | Tool error — `isError: true`, or the tool was not found | + +and writes one JSON line to **stderr** on any non-zero exit: + +```json +{ + "error": { + "code": "auth_required", + "message": "Unauthorized", + "status": 401, + "url": "https://api.example/mcp" + } +} +``` + +**Migration note:** a CI step that chained `inspector --cli … --method tools/call … && next-step` silently continued past failing tool calls in v1. In v2 it stops. That is the intended behavior, but it will surface as "v2 broke my pipeline" the first time a tool was failing all along. Parse the class with `2>&1 | tail -1 | jq .error` rather than matching on prose. + +### 2. The target must come first + +v2's CLI reads the leading run of non-dash tokens as the server target. Anything after the first flag is no longer part of it: + +```bash +mcp-inspector --cli node build/index.js --method tools/list # ✅ +mcp-inspector --cli --method tools/list node build/index.js # ❌ target silently dropped +``` + +The second form does **not** error — the target is discarded and the Inspector falls back to your catalog, so it appears to work against the wrong server. v1 tolerated either order. + +### 3. `--` splits the other way under `--cli` + +On **web and TUI**, everything _after_ `--` goes to the target command — same as v1: + +```bash +mcp-inspector node build/index.js -- --config /etc/myserver.conf --verbose +``` + +Under **`--cli`** it is reversed: everything _before_ `--` is the target, everything _after_ is the Inspector's own options. + +```bash +mcp-inspector --cli node build/index.js -- --method tools/list +``` + +So the web example above, run under `--cli`, would have `--config /etc/myserver.conf` consumed as the Inspector's read-only-session flag (and then rejected as a catalog/ad-hoc conflict). There is currently no way to pass a leading-dash argument through to a stdio server on the `--cli` command line — put it in the server entry's `args` in a catalog or config file instead. + +Stdout is otherwise compatible: the default `text` format still pretty-prints the result as `JSON.stringify(result, null, 2)`, exactly as v1 did. + +## Environment variables + +| v1 | v2 | Notes | +| ------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed. Guards `/api/*` via `x-mcp-remote-auth: Bearer `; the browser also receives it injected into `index.html`, so a bare reload keeps working | +| `MCP_PROXY_FULL_ADDRESS` | — | Removed; there is no proxy | +| `SERVER_PORT` | _repurposed_ | Was the proxy port. Now only a fallback for the MCP Apps sandbox port | +| `CLIENT_PORT` | same | Web UI port, default `6274`. Must be a fixed port — `0`/dynamic is rejected, since the origin allow-list and sandbox CSP derive from it | +| `HOST` | same, **guarded** | An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is now **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES=true`. Binding a specific IP or hostname needs no opt-in | +| `ALLOWED_ORIGINS` | same | Still comma-separated, still **replaces** the default list rather than merging. Entries must include the scheme | +| `DANGEROUSLY_OMIT_AUTH` | same | | +| `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | +| — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | +| — | `MCP_CATALOG_PATH` | Default catalog path | +| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (dynamic by default) | +| — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations | +| — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback | + +### The v1 UI configuration settings + +v1 exposed a **Configuration** panel writing `localStorage` keys, also settable by query param. Those were global; in v2 the equivalents are **per-server settings**, stored in the catalog entry and editable in Server Settings: + +| v1 setting | v2 | +| --------------------------------------- | ----------------------------------- | +| `MCP_SERVER_REQUEST_TIMEOUT` | per-server `requestTimeout` (ms) | +| _(no v1 equivalent)_ | per-server `connectionTimeout` (ms) | +| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | always on; no longer configurable | +| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | no equivalent | +| `MCP_PROXY_FULL_ADDRESS` | no equivalent (no proxy) | + +## Web UI + +**Server list.** v1 kept it in browser `localStorage`, so it was per-browser and invisible to the CLI. v2 keeps it in the catalog file, shared by all three clients — add a server in the web UI and the CLI and TUI see it. + +**Query params.** v1 accepted `?transport=…&serverUrl=…&serverCommand=…&serverArgs=…` plus `?MCP_PROXY_AUTH_TOKEN=…`. v2's deep link is narrower and gated: + +``` +http://127.0.0.1:6274/?serverUrl=&transport=http|sse&autoConnect= +``` + +- `serverCommand` / `serverArgs` are gone — a URL can no longer ask the Inspector to spawn a process. +- `serverUrl` is restricted to `http:` / `https:`. +- `autoConnect` must equal the per-launch `MCP_INSPECTOR_API_TOKEN`, so a third-party-minted link can't drive a connect. +- `?MCP_INSPECTOR_API_TOKEN=…` replaces `?MCP_PROXY_AUTH_TOKEN=…`. + +Three further params (`openApp`, `appArgs`, `autoOpen`) land you on a rendered MCP App; see the [web README](../clients/web/README.md#deep-link-auto-connect). + +**Auth.** v1's bearer-token field in the sidebar is replaced by per-server `headers` and real OAuth (including CIMD and enterprise-managed auth) configured in Server Settings and persisted in the catalog. + +## Docker + +```bash +# v1 +docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6277:6277 \ + -e HOST=0.0.0.0 -e MCP_AUTO_OPEN_ENABLED=false \ + ghcr.io/modelcontextprotocol/inspector:1.0.1 + +# v2 — one port; the image already sets the wildcard-bind opt-in +docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector:latest +``` + +Notes: + +- The v2 image sets `DANGEROUSLY_BIND_ALL_INTERFACES=true` internally (a container must bind `0.0.0.0` to be reachable through `-p`). Setting a bare `HOST=0.0.0.0` **outside** a container now exits with an error. +- **If you remap the published port** (`-p 8080:6274`), the browser's origin no longer matches the in-container port, so set `ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. +- The image runs as non-root and has a `HEALTHCHECK` that assumes `--web`; add `--no-healthcheck` when running `--cli` / `--tui`. +- `:latest` now points at v2. To stay on the v1 image, pin its exact version tag (`:1.0.1`). + +## Troubleshooting + +**"`--config` errors that my file doesn't exist, but v1 created it."** v1 never created config files either — but v2's _default_ path is a catalog it will create. If you want a file created on demand, pass it as `--catalog`, not `--config`. + +**"`--server` is ignored."** It selects only under `--cli`. Web warns and ignores it; the TUI errors on it as unknown. + +**"My CI step started failing after upgrading."** Most likely exit code `5`: a `tools/call` returning `isError: true` now exits non-zero. Check `2>&1 | tail -1 | jq .error`. + +**"The Inspector connects to a different server than I named."** Check flag order — under `--cli` the target must precede all flags, or it is silently dropped in favor of the catalog. + +**"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list). + +**"I need v1 back."** `npx @modelcontextprotocol/inspector@v1-latest`. + +## Related + +- [MCP server configuration](./mcp-server-configuration.md) — the full `--catalog` / `--config` / ad-hoc model. +- [CLI README](../clients/cli/README.md) · [TUI README](../clients/tui/README.md) · [web README](../clients/web/README.md) +- [Reviewing an MCP App](./mcp-app-review.md) — the CLI-first App review recipe. From 6c33da38c964ae38b4b4e28f8c33fc379951166d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 20:29:58 -0400 Subject: [PATCH 07/73] chore(deps): align zod across installs and guard against version skew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1896 `clients/web`'s `tsc -b` exhausted the 4GB default heap at zod 4.4.x. The cause is not a zod regression nor a pathological inference site of ours: it is two copies of zod at *mismatched versions* inside one tsc program. `clients/web/tsconfig.test.json` compiles `test-servers/src`, whose files resolve zod (and the SDK's zod) from the **root** install, alongside web's own sources, which resolve from `clients/web/node_modules`. At 4.3.6 vs 4.4.3 TypeScript must relate two structurally-distinct declarations of every `@modelcontextprotocol/*` schema type, which is exponential over a recursive generic surface — `TS2589 Type instantiation is excessively deep`, then OOM. Bumping root zod to 4.4.3 and changing nothing else returns the build to baseline: 11.4s and 2,567,882 instantiations, against 11.2s / 2,567,503 at 4.3.6. Two copies at the same version are harmless; skew is what explodes. So drop the `~4.3.6` hold on `clients/web` and move all four manifests and lockfiles to zod 4.4.3 together, and add `verify:dep-lockstep` to `validate` to keep them there. The guard derives its candidate set from what `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and fails deny-by-default on any skew not in the annotated `TOLERATED_SKEW` allowlist. It joins the existing mutual-vouch ring so dropping it from `validate` is caught by `verify:format-coverage`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 1 + AGENTS.md | 3 +- README.md | 7 +- clients/cli/package-lock.json | 2 +- clients/cli/package.json | 2 +- clients/tui/package-lock.json | 2 +- clients/tui/package.json | 2 +- clients/web/package-lock.json | 8 +- clients/web/package.json | 2 +- package-lock.json | 8 +- package.json | 5 +- scripts/verify-dep-lockstep.mjs | 280 +++++++++++++++++++++++++++ scripts/verify-dep-lockstep.test.mjs | 196 +++++++++++++++++++ scripts/verify-format-coverage.mjs | 11 +- 14 files changed, 505 insertions(+), 24 deletions(-) create mode 100644 scripts/verify-dep-lockstep.mjs create mode 100644 scripts/verify-dep-lockstep.test.mjs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aa1d6dd1..a49cec61 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,6 +92,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo ## Gates and PR hygiene - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. +- **A dependency bump must land in every install.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR touching one `package.json`/`package-lock.json` for a package `core/` or `test-servers/src` imports should touch all of them. - **Every PR references an issue**, first body line `Closes #`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. diff --git a/AGENTS.md b/AGENTS.md index a4185baa..1d38a6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,11 +448,12 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`test:scripts`** (the guard's own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no install-crossing dependency resolves to two different versions), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. + - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from the packages `core/` and `test-servers/src` import (so a new shared dependency is covered without editing the guard), reads the committed lockfiles' **top-level** `node_modules/` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and fails, **deny-by-default**, on any candidate held at two versions across installs. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; `react`, `hono`, `jose`, and `@modelcontextprotocol/ext-apps` are listed today. **When bumping a dependency that `core/` or `test-servers/src` imports, bump it in every install** (root + all four clients), not just the one you're working in. Its pure helpers are unit-tested via `test:scripts`, and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. diff --git a/README.md b/README.md index 13792764..635d93cf 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ inspector/ │ ├── 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, verify-build-gate, verify-format-coverage, pack:verify) +├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, verify-dep-lockstep, pack:verify) ├── docs/ # Task-oriented guides (server configuration, MCP App review, launcher/config plan) ├── specification/ # Design/build specifications ├── AGENTS.md # Contribution rules for agents AND humans (see below) @@ -263,13 +263,14 @@ Each client self-validates from its own folder; the root scripts chain them. The | Script | What it does | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `verify:typecheck-coverage` and `verify:dep-lockstep`, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. 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 and covers the shared `core/` runtime (including `core/json` and `core/client`). | | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | +| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles `core/` and `test-servers/src` — which resolve from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist. Runs in `validate`. | | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index ee8ea1e4..926899e7 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -19,7 +19,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "undici": "^8.5.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector-cli": "build/index.js" diff --git a/clients/cli/package.json b/clients/cli/package.json index 598fc7f3..7dfbad9c 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -43,7 +43,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "undici": "^8.5.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 0b3dbd3e..431b60d8 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -20,7 +20,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector-tui": "build/index.js" diff --git a/clients/tui/package.json b/clients/tui/package.json index 9a3278bc..b2ffcc92 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -39,7 +39,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "overrides": { "ink-select-input": "^6.2.0" diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 22c54dd7..11cfb098 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -35,7 +35,7 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", - "zod": "~4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector-web": "build/index.js" @@ -11677,9 +11677,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/clients/web/package.json b/clients/web/package.json index a347376e..d15870e6 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -61,7 +61,7 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", - "zod": "~4.3.6" + "zod": "^4.4.3" }, "devDependencies": { "@chromatic-com/storybook": "^5.2.1", diff --git a/package-lock.json b/package-lock.json index 58f21e7a..4766aff2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector": "clients/launcher/build/index.js" @@ -4534,9 +4534,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index d3467cf6..0d353453 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,9 @@ "verify:build-gate": "node scripts/verify-build-gate.mjs", "verify:typecheck-coverage": "node scripts/verify-typecheck-coverage.mjs", "test:scripts": "node --test \"scripts/**/*.test.mjs\"", - "validate": "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "validate": "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", "verify:format-coverage": "node scripts/verify-format-coverage.mjs", + "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", "validate:core": "npm run format:check:core && npm run format:check:scripts && npm run format:check:shared && npm run lint:core && npm run lint:shared", "lint:core": "eslint \"core/**/*.{ts,tsx}\"", "lint:shared": "eslint \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts eslint.config.js", @@ -98,7 +99,7 @@ "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "overrides": { "ink-select-input": "^6.2.0" diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs new file mode 100644 index 00000000..496fdc8d --- /dev/null +++ b/scripts/verify-dep-lockstep.mjs @@ -0,0 +1,280 @@ +#!/usr/bin/env node +// Durable guard for the "one version per install-crossing dependency" invariant +// (#1896). v2 is not an npm workspace: the root and each `clients/*` carry their +// own `node_modules`, so the *same* package can resolve to two different +// versions in one process — or, worse, in one `tsc` program. +// +// That second case is what this guard exists for. A client's +// `tsconfig.test.json` compiles first-party sources that live *outside* the +// client (`test-servers/src`, `core/`), and those files resolve their +// dependencies from the **root** install while the client's own sources resolve +// from the client install. When the two copies are the same version the +// duplication is harmless; when they skew, TypeScript must relate two +// structurally-distinct declarations of the same type. +// +// For most packages that is merely redundant work. For a deeply +// recursive-generic type surface it is exponential: zod `4.3.6` (root) against +// zod `4.4.3` (clients/web) made `tsc -b` in `clients/web` exhaust the 4GB +// default heap outright (`FATAL ERROR: Ineffective mark-compacts near heap +// limit`) via `TS2589 Type instantiation is excessively deep`, because every +// `@modelcontextprotocol/*` schema is built out of zod generics. Aligning the +// two copies — changing nothing else — returned the build to its baseline cost. +// +// The candidate set is DERIVED, not hand-listed: it is the packages imported by +// `core/` and `test-servers/src`, the two first-party surfaces compiled into +// more than one client's program. Skew is then denied by default, with a small +// allowlist of packages verified to tolerate it (below). A dependency that +// starts skewing therefore fails `validate` and forces a decision, rather than +// surfacing months later as an unexplained OOM. + +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { builtinModules } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { rootReachesScript } from "./lib/npm-scripts.mjs"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +// The first-party source trees that are compiled into more than one install's +// `tsc` program, and so define which dependencies can appear twice in one +// program. `core/` is consumed by every client via the `@inspector/core` alias; +// `test-servers/src` is pulled into the web and cli test projects. +const SHARED_SOURCE_DIRS = ["core", "test-servers/src"]; + +// Packages whose cross-install skew is verified benign, each with the reason. +// This is an allowlist of *names*, not of version pairs, so an ordinary patch +// float within one of these does not churn the file — while any package NOT +// listed here failing the check is a genuine, unreviewed new skew. +// +// The admission test is the one the zod incident established: does the +// package's public type surface consist of deeply recursive generics that +// first-party code relates across the boundary? If yes it must stay in +// lockstep; if no, a patch-level difference costs nothing. +const TOLERATED_SKEW = new Map([ + [ + "react", + "Types are shallow interfaces (`ReactNode`, `FC`), not recursive generics; the runtime copies never meet — each client bundles its own.", + ], + [ + "hono", + "Only used behind first-party wrappers in `core/mcp/remote/node`; its generic router types are not related across the boundary.", + ], + [ + "jose", + "Consumed as flat function calls in `core/auth`; no generic type flows between installs.", + ], + [ + "@modelcontextprotocol/ext-apps", + "Plain interface/constant surface for the MCP Apps UI protocol; no generic instantiation to blow up.", + ], +]); + +/** Package names that are Node built-ins (with or without the `node:` prefix). */ +const BUILTINS = new Set([ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`), +]); + +// A bare package specifier: optional `@scope/`, then a name, then any subpath. +// Anchored so prose that happens to sit after the word `from` in a comment +// ("from cwd omitted") cannot be mistaken for an import. +const PACKAGE_SPECIFIER = /^(?:(@[^/\s]+)\/)?([^@/\s][^/\s]*)(?:\/.*)?$/; + +/** + * The bare package name a module specifier resolves to — `@scope/name` or + * `name`, with any subpath dropped (`zod/v4` → `zod`). Returns null for + * relative paths, built-ins, URLs, and anything not shaped like a specifier. + */ +export function packageNameOf(specifier) { + if (typeof specifier !== "string" || specifier === "") return null; + if (specifier.startsWith(".") || specifier.startsWith("/")) return null; + if (BUILTINS.has(specifier)) return null; + const m = PACKAGE_SPECIFIER.exec(specifier); + if (!m) return null; + const name = m[1] ? `${m[1]}/${m[2]}` : m[2]; + // A protocol-ish specifier (`node:test`, `file:`, `data:`) is not a package. + if (name.includes(":")) return null; + return name; +} + +// `from "x"` (import and re-export), a side-effect `import "x"`, and a dynamic +// `import("x")`. Only these three forms introduce a dependency's *types*. +const SPECIFIER_FORMS = [ + /\bfrom\s*["']([^"']+)["']/g, + /\bimport\s+["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, +]; + +/** + * Every third-party package name imported by a blob of TypeScript source. + * Deliberately a regex scan rather than a parse: it only needs to over- rather + * than under-approximate, since a name absent from every lockfile contributes + * nothing downstream (`@inspector/core` is a build-time alias, not a package, + * and drops out that way). + */ +export function importedPackageNames(source) { + const names = new Set(); + for (const re of SPECIFIER_FORMS) { + for (const m of source.matchAll(re)) { + const name = packageNameOf(m[1]); + if (name) names.add(name); + } + } + return names; +} + +/** + * Top-level installed versions of every package in a parsed lockfile, keyed by + * package name. Only `node_modules/` entries count — a *nested* + * `node_modules/a/node_modules/b` is npm resolving a transitive conflict inside + * one install, which is routine and not what this guard is about. + */ +export function topLevelLockVersions(lock) { + const versions = new Map(); + for (const [entryPath, entry] of Object.entries(lock?.packages ?? {})) { + const m = /^node_modules\/(@[^/]+\/[^/]+|[^@/][^/]*)$/.exec(entryPath); + if (!m || typeof entry?.version !== "string") continue; + versions.set(m[1], entry.version); + } + return versions; +} + +/** + * Find candidate packages that resolve to more than one version across the + * installs. `installs` is an array of `{ dir, versions }`. Returns one entry per + * skewed package, sorted by name, each listing the version each install holds. + * Packages present in fewer than two installs cannot skew and are skipped. + */ +export function findSkew(candidates, installs) { + const skewed = []; + for (const name of [...candidates].sort()) { + const holders = installs + .filter(({ versions }) => versions.has(name)) + .map(({ dir, versions }) => ({ dir, version: versions.get(name) })); + if (holders.length < 2) continue; + const distinct = new Set(holders.map((h) => h.version)); + if (distinct.size > 1) skewed.push({ name, holders }); + } + return skewed; +} + +/** Split skewed packages into the tolerated ones and the failures. */ +export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { + return { + failures: skewed.filter((s) => !tolerated.has(s.name)), + ignored: skewed.filter((s) => tolerated.has(s.name)), + }; +} + +/** Tracked `.ts`/`.tsx` files under the shared first-party source trees. */ +function sharedSourceFiles() { + const out = execFileSync( + "git", + ["ls-files", "--", ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`)], + { cwd: repoRoot, encoding: "utf8" }, + ); + return out.split("\n").filter((f) => f.endsWith(".ts") || f.endsWith(".tsx")); +} + +/** + * The installs to compare: the repo root plus every `clients/*` that carries a + * lockfile. Discovered from disk rather than listed, so a new client is covered + * without editing this guard (the same enrollment style as + * `verify:typecheck-coverage`). + */ +function installDirs() { + const clientsDir = path.join(repoRoot, "clients"); + const clients = existsSync(clientsDir) + ? readdirSync(clientsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => `clients/${e.name}`) + .sort() + : []; + return ["."] + .concat(clients) + .filter((dir) => existsSync(path.join(repoRoot, dir, "package-lock.json"))); +} + +/** + * Run the guard. Prints its verdict and `process.exit(1)`s on any failure. + * Called only when this file is executed directly — importing it (for tests) + * gives access to the pure helpers above without running any of this. + */ +export function main() { + const rootScripts = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), + ).scripts; + + // Vouch for a sibling guard: a guard cannot detect being unrun itself, but the + // three can vouch for one another, so dropping any single one from `validate` + // is caught by another. `verify:format-coverage` vouches for this one in turn. + if (!rootReachesScript(rootScripts, "verify:format-coverage")) { + console.error( + "verify:dep-lockstep — the root `validate` no longer runs `verify:format-coverage` (a sibling guard). Restore it.", + ); + process.exit(1); + } + + const files = sharedSourceFiles(); + if (files.length === 0) { + console.error( + `verify:dep-lockstep — found no tracked sources under ${SHARED_SOURCE_DIRS.join(", ")}. The guard would check nothing; fix the enumeration.`, + ); + process.exit(1); + } + + const candidates = new Set(); + for (const file of files) { + const source = readFileSync(path.join(repoRoot, file), "utf8"); + for (const name of importedPackageNames(source)) candidates.add(name); + } + + const dirs = installDirs(); + const installs = dirs.map((dir) => ({ + dir, + versions: topLevelLockVersions( + JSON.parse( + readFileSync(path.join(repoRoot, dir, "package-lock.json"), "utf8"), + ), + ), + })); + + const { failures, ignored } = partitionSkew(findSkew(candidates, installs)); + + if (failures.length > 0) { + console.error( + `verify:dep-lockstep — ${failures.length} ${failures.length === 1 ? "dependency resolves" : "dependencies resolve"} to different versions across installs:\n`, + ); + for (const { name, holders } of failures) { + console.error(` ${name}`); + for (const { dir, version } of holders) + console.error(` ${version} (${dir})`); + } + console.error( + "\nThese packages' types are compiled into a single `tsc` program from two installs" + + `\n(${SHARED_SOURCE_DIRS.join(" and ")} resolve from the root, a client's own sources from the client),` + + "\nso a version skew makes TypeScript relate two structurally-distinct copies of the same" + + "\ntype. For a recursive-generic surface like zod that is what exhausted the tsc heap in #1896.", + ); + console.error( + "\nAlign them — `npm install @` in each install so all lockfiles agree — or, if this" + + "\npackage's types genuinely cannot blow up, add it to TOLERATED_SKEW in scripts/verify-dep-lockstep.mjs" + + "\nwith the reason. See AGENTS.md.", + ); + process.exit(1); + } + + const note = ignored.length > 0 ? `, ${ignored.length} tolerated` : ""; + console.log( + `verify:dep-lockstep — OK: ${candidates.size} install-crossing dependencies agree across ${installs.length} installs${note}.`, + ); +} + +// Run only when executed directly (`node scripts/verify-dep-lockstep.mjs`); +// importing this file (tests) exposes the pure helpers without running the guard. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + main(); diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs new file mode 100644 index 00000000..6e36d255 --- /dev/null +++ b/scripts/verify-dep-lockstep.test.mjs @@ -0,0 +1,196 @@ +// Table-driven tests for the pure helpers of the dep-lockstep guard (#1896). +// One case per rule the guard encodes — the comment names the rule, so a future +// change that relaxes one is visible as a deleted assertion rather than a quiet +// behavior shift. Run via `npm run test:scripts` (node:test; the root has no +// vitest harness). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + findSkew, + importedPackageNames, + packageNameOf, + partitionSkew, + topLevelLockVersions, +} from "./verify-dep-lockstep.mjs"; + +test("packageNameOf: bare names, scopes, and subpaths", () => { + const cases = [ + ["zod", "zod"], + ["zod/v4", "zod"], // subpath dropped — one package, one version + ["@modelcontextprotocol/client", "@modelcontextprotocol/client"], + ["@modelcontextprotocol/client/core", "@modelcontextprotocol/client"], + ["react-dom/client", "react-dom"], + ]; + for (const [input, expected] of cases) + assert.equal(packageNameOf(input), expected, input); +}); + +test("packageNameOf: non-packages are rejected", () => { + // Relative/absolute paths, built-ins with and without the `node:` prefix, + // protocol specifiers, and prose that follows the word `from` in a comment. + const rejected = [ + "./foo", + "../core/mcp", + "/abs/path", + "fs", + "path", + "node:crypto", + "node:test", + "file:", + "data:text/plain,x", + "cwd omitted", + "", + ]; + for (const input of rejected) + assert.equal(packageNameOf(input), null, JSON.stringify(input)); + assert.equal(packageNameOf(undefined), null); +}); + +test("importedPackageNames: the three specifier forms that introduce types", () => { + const source = ` + import { z } from "zod/v4"; + export type { Foo } from '@modelcontextprotocol/core'; + import "./side-effect.css"; + import "pino"; + const mod = await import("chokidar"); + import fs from "node:fs"; + import { helper } from "../local/helper"; + `; + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "@modelcontextprotocol/core", + "chokidar", + "pino", + "zod", + ]); +}); + +test("importedPackageNames: prose after `from` is not an import", () => { + // The scan over-approximates on purpose, but must not invent package names + // out of comment text — a bogus name absent from every lockfile is inert, + // yet a *plausible* one would silently widen the candidate set. + const source = ` + // Resolved relative to the runner dir, not from "cwd omitted" by the caller. + /** Reads the manifest from "file:" URLs. */ + import { z } from "zod"; + `; + assert.deepEqual([...importedPackageNames(source)], ["zod"]); +}); + +test("topLevelLockVersions: nested duplicates are ignored", () => { + // A nested `node_modules/a/node_modules/b` is npm resolving a transitive + // conflict *inside* one install — routine, and not the cross-install skew + // this guard is about (`cosmiconfig`'s yaml@1 alongside the top-level yaml@2 + // is the live example). + const lock = { + packages: { + "": { name: "root" }, + "node_modules/zod": { version: "4.4.3" }, + "node_modules/yaml": { version: "2.9.0" }, + "node_modules/cosmiconfig/node_modules/yaml": { version: "1.10.3" }, + "node_modules/@modelcontextprotocol/client": { version: "2.0.0-beta.5" }, + "node_modules/no-version": { resolved: "https://example.test/x.tgz" }, + }, + }; + assert.deepEqual([...topLevelLockVersions(lock)].sort(), [ + ["@modelcontextprotocol/client", "2.0.0-beta.5"], + ["yaml", "2.9.0"], + ["zod", "4.4.3"], + ]); +}); + +test("topLevelLockVersions: a malformed or empty lockfile yields nothing", () => { + for (const lock of [undefined, null, {}, { packages: {} }]) + assert.equal(topLevelLockVersions(lock).size, 0); +}); + +test("findSkew: reports a package held at two versions", () => { + const installs = [ + { dir: ".", versions: new Map([["zod", "4.3.6"]]) }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + { dir: "clients/cli", versions: new Map([["zod", "4.4.3"]]) }, + ]; + assert.deepEqual(findSkew(new Set(["zod"]), installs), [ + { + name: "zod", + holders: [ + { dir: ".", version: "4.3.6" }, + { dir: "clients/web", version: "4.4.3" }, + { dir: "clients/cli", version: "4.4.3" }, + ], + }, + ]); +}); + +test("findSkew: agreement and single-install packages are not skew", () => { + const installs = [ + { + dir: ".", + versions: new Map([ + ["zod", "4.4.3"], + ["express", "5.2.1"], + ]), + }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + ]; + // `express` lives in one install only, so it cannot skew — a package absent + // from a client is not a finding. + assert.deepEqual(findSkew(new Set(["zod", "express"]), installs), []); +}); + +test("findSkew: a candidate in no lockfile is inert", () => { + // `@inspector/core` is a build-time alias, not a package; the scan picks it + // up and it must drop out here rather than error. + const installs = [ + { dir: ".", versions: new Map([["zod", "4.4.3"]]) }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + ]; + assert.deepEqual(findSkew(new Set(["@inspector/core"]), installs), []); +}); + +test("findSkew: results are sorted by package name", () => { + const installs = [ + { + dir: ".", + versions: new Map([ + ["zod", "1.0.0"], + ["hono", "1.0.0"], + ]), + }, + { + dir: "clients/web", + versions: new Map([ + ["zod", "2.0.0"], + ["hono", "2.0.0"], + ]), + }, + ]; + assert.deepEqual( + findSkew(new Set(["zod", "hono"]), installs).map((s) => s.name), + ["hono", "zod"], + ); +}); + +test("partitionSkew: the allowlist is by name, not by version pair", () => { + // So an ordinary patch float within a tolerated package does not churn the + // allowlist, while any *unlisted* package that starts skewing still fails. + const skewed = [ + { name: "react", holders: [] }, + { name: "zod", holders: [] }, + ]; + const tolerated = new Map([["react", "shallow interfaces"]]); + const { failures, ignored } = partitionSkew(skewed, tolerated); + assert.deepEqual( + failures.map((s) => s.name), + ["zod"], + ); + assert.deepEqual( + ignored.map((s) => s.name), + ["react"], + ); +}); + +test("partitionSkew: deny by default — nothing tolerated fails everything", () => { + const skewed = [{ name: "zod", holders: [] }]; + assert.equal(partitionSkew(skewed, new Map()).failures.length, 1); +}); diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index 8963c8c2..014b850f 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -166,15 +166,16 @@ function trackedSourceFiles() { return out.split("\n").filter(Boolean); } -// Vouch for the sibling guard: a guard can't detect being unrun itself, but the -// two coverage guards can each assert the other is still wired into `validate`, -// so dropping either is caught here. Only deleting both slips through. +// Vouch for the sibling guards: a guard can't detect being unrun itself, but the +// three can each assert the others are still wired into `validate`, so dropping +// any one is caught here. Only deleting all of them slips through. const rootScripts = JSON.parse( readFileSync(path.join(repoRoot, "package.json"), "utf8"), ).scripts; -if (!rootReachesScript(rootScripts, "verify:typecheck-coverage")) { +for (const sibling of ["verify:typecheck-coverage", "verify:dep-lockstep"]) { + if (rootReachesScript(rootScripts, sibling)) continue; console.error( - "verify:format-coverage — the root `validate` no longer runs `verify:typecheck-coverage` (its sibling guard). Restore it.", + `verify:format-coverage — the root \`validate\` no longer runs \`${sibling}\` (its sibling guard). Restore it.`, ); process.exit(1); } From e480fdc49d5923e17a9b85407a660d6599990c8b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 20:38:41 -0400 Subject: [PATCH 08/73] chore(deps): cover .mts/.cts in the shared-source scan, fix the README validate row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from Copilot's review of #1962. The candidate-set enumeration matched only `.ts`/`.tsx`, while `verify:format-coverage` and `verify:typecheck-coverage` both gate all four TypeScript extensions. No `.mts`/`.cts` exists under `core/` or `test-servers/src` today, which is exactly why the omission would have gone unnoticed until a shared dependency arrived through one and skewed — the failure this guard exists to prevent. Extract the rule as the exported `isSharedSourceFile` predicate, cover all four extensions, and pin it with regression tests (including path-boundary anchoring, so a `core-internal/` sibling isn't swept in). The README's `validate` row also omitted `test:scripts` and claimed typecheck was cli/tui only; launcher runs one too, and web typechecks via `tsc -b` inside its `build`. Restate the row to match the actual script chain. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- README.md | 2 +- scripts/verify-dep-lockstep.mjs | 20 +++++++++++++++-- scripts/verify-dep-lockstep.test.mjs | 32 ++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 635d93cf..fef78256 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | Script | What it does | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `verify:typecheck-coverage` and `verify:dep-lockstep`, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no install-crossing dependency skews) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. 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 and covers the shared `core/` runtime (including `core/json` and `core/client`). | | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 496fdc8d..487a927c 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -170,14 +170,30 @@ export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { }; } -/** Tracked `.ts`/`.tsx` files under the shared first-party source trees. */ +// The TypeScript extensions the shared trees can hold. Deliberately the same +// four `verify:format-coverage` and `verify:typecheck-coverage` gate on: a +// `.mts`/`.cts` under `core/` or `test-servers/src` is typechecked like any +// other source, so its imports must reach the candidate set too. None exist +// under those trees today, which is exactly why omitting them would go +// unnoticed until a new shared dependency arrived through one and skewed. +const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; + +/** Whether a repo-relative path is a TypeScript source of a shared tree. */ +export function isSharedSourceFile(file) { + if (!SOURCE_EXTENSIONS.some((ext) => file.endsWith(ext))) return false; + // Anchored on a path boundary so a sibling whose name merely starts with a + // shared dir (`core-internal/`, `test-servers/src-legacy/`) isn't swept in. + return SHARED_SOURCE_DIRS.some((dir) => file.startsWith(`${dir}/`)); +} + +/** Tracked TypeScript files under the shared first-party source trees. */ function sharedSourceFiles() { const out = execFileSync( "git", ["ls-files", "--", ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`)], { cwd: repoRoot, encoding: "utf8" }, ); - return out.split("\n").filter((f) => f.endsWith(".ts") || f.endsWith(".tsx")); + return out.split("\n").filter(isSharedSourceFile); } /** diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 6e36d255..640291fb 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -9,11 +9,43 @@ import assert from "node:assert/strict"; import { findSkew, importedPackageNames, + isSharedSourceFile, packageNameOf, partitionSkew, topLevelLockVersions, } from "./verify-dep-lockstep.mjs"; +test("isSharedSourceFile: all four TS extensions, not just .ts/.tsx", () => { + // `.mts`/`.cts` are gated by `verify:format-coverage` and + // `verify:typecheck-coverage` too. None exist under the shared trees today, + // so dropping them here would go unnoticed until a new shared dependency + // arrived through one — the skew this guard exists to catch (Copilot, #1962). + for (const ext of [".ts", ".tsx", ".mts", ".cts"]) { + assert.equal(isSharedSourceFile(`core/mcp/thing${ext}`), true, ext); + assert.equal( + isSharedSourceFile(`test-servers/src/thing${ext}`), + true, + `test-servers ${ext}`, + ); + } +}); + +test("isSharedSourceFile: non-TS files and other trees are excluded", () => { + const rejected = [ + "core/README.md", // not TypeScript + "core/mcp/data.json", + "clients/web/src/App.tsx", // a client's own sources resolve from the client + "scripts/verify-dep-lockstep.mjs", + "test-servers/configs/modern-http.json", + // Path-boundary anchoring: a sibling dir whose name merely starts with a + // shared dir's name must not be swept in. + "core-internal/thing.ts", + "test-servers/src-legacy/thing.ts", + ]; + for (const file of rejected) + assert.equal(isSharedSourceFile(file), false, file); +}); + test("packageNameOf: bare names, scopes, and subpaths", () => { const cases = [ ["zod", "zod"], From 20527dc1776a1f75d09d7670ff84f02c1e63c974 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 20:40:39 -0400 Subject: [PATCH 09/73] =?UTF-8?q?docs:=20address=20Copilot=20review=20on?= =?UTF-8?q?=20the=20v1=20=E2=86=92=20v2=20migration=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sandbox port: the Apps tab needs a second, dynamic listener, so "no second port" was wrong for Docker/remote deployments. Qualify the architecture section, add a pinned-port Docker recipe, and add a troubleshooting entry. - Node floor: npm only warns EBADENGINE unless engine-strict is set, so npx does not refuse to run on an older Node. - v1 default list: the web UI persisted its list in localStorage; only the on-disk default catalog is new. - The v1→v2 --server example needs --cli (and a --method) to select an entry; note there is no one-entry web equivalent. - New behavior change #4: an ambiguous URL path (neither /mcp nor /sse) now errors instead of falling back to SSE. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/v1-to-v2-migration.md | 51 ++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index ef2865fc..745779c9 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -30,7 +30,7 @@ The three changes most likely to bite an existing setup: ## Requirements -v2 requires **Node `>=22.19.0`** (v1 required `>=22.7.5`). The floor comes from `undici@^8`, used for HTTP proxy support. `npx` will refuse to run on an older Node rather than fail obscurely later. +v2 requires **Node `>=22.19.0`** (v1 required `>=22.7.5`). The floor comes from `undici@^8`, used for HTTP proxy support. npm only _warns_ about an `engines` mismatch (`EBADENGINE`) unless you have `engine-strict=true` set, so an older Node won't stop you at install time — it fails later, obscurely. Check `node -v` first. ## What no longer ships @@ -58,13 +58,15 @@ If you depend on one of them directly, drop it and use the root package's `mcp-i v1 ran **two** processes: a React client on `6274` and an "MCP Proxy" on `6277` that held the actual MCP connections. The browser talked to the proxy over HTTP, authenticating with `MCP_PROXY_AUTH_TOKEN`. -v2 runs **one** web server on `6274`. It serves the SPA and exposes `/api/*`, which the browser calls with `x-mcp-remote-auth: Bearer `. There is no second port to expose, forward, or allow through a firewall. +v2 runs **one** web server on `6274`. It serves the SPA and exposes `/api/*`, which the browser calls with `x-mcp-remote-auth: Bearer `. The proxy port is gone: nothing needs `6277` exposed, forwarded, or allowed through a firewall. Consequences: - `SERVER_PORT` no longer selects a port you browse. (It is read as a fallback for the MCP Apps sandbox server's port — see `MCP_SANDBOX_PORT` below.) - `MCP_PROXY_FULL_ADDRESS` has no v2 equivalent and is ignored. It existed to tell the browser where a non-default proxy lived; there is no proxy. -- Docker only needs `-p 6274:6274`. The v1 recipe published `6277` as well. +- Docker needs `-p 6274:6274` for the UI, where the v1 recipe published `6277` as well. + +⚠️ **`6274` is not the only listener.** The web backend also starts a **separate MCP Apps sandbox server**, on a dynamic port by default. It is only used by the Apps tab, and on plain loopback it needs no attention — but the browser reaches it directly, so anywhere the Inspector is _not_ served from the browser's own machine (Docker, a remote host, an SSH tunnel) you must pin it with `MCP_SANDBOX_PORT` and expose/forward that port too, or the Apps tab won't load. This is a different port from v1's proxy — it carries no MCP traffic — but it is still a second port. See [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). ## Launching @@ -102,7 +104,7 @@ So: - **`--config` in v2 is the read-only role.** Point it at a file you didn't write — a coworker's, a client application's, one checked into a repo — and the Inspector guarantees it won't touch the bytes, including any plaintext secrets in them. This is close to v1's behavior, minus the write-back that never existed anyway. - **`--catalog` is new** and is what you want if you'd like the web UI to add, edit, and remove servers. -- **Neither is required.** With no source flag, v2 uses the default catalog `~/.mcp-inspector/mcp.json`, creating it if absent. v1 had no such default — it started empty every time. +- **Neither is required.** With no source flag, v2 uses the default catalog `~/.mcp-inspector/mcp.json`, creating it if absent. v1 had no default file: the CLI started with nothing unless you passed `--config`, and the web UI's list lived in the browser's `localStorage` — per-browser, and invisible to the CLI. ### Before / after @@ -112,11 +114,11 @@ So: # v1 — one entry out of a file npx @modelcontextprotocol/inspector --config ./mcp.json --server everything -# v2 — same thing, same read-only guarantee -npx @modelcontextprotocol/inspector --config ./mcp.json --server everything +# v2 — same file, same read-only guarantee; --server selects under --cli +npx @modelcontextprotocol/inspector --cli --config ./mcp.json --server everything --method tools/list ``` -⚠️ One caveat on that last line: **`--server` only selects under `--cli`.** On the web client it is a no-op that logs a warning, and the TUI rejects it as an unknown option. In the web UI you pick the server from the list after it loads. +⚠️ **`--server` only selects under `--cli`**, which is why the v2 line above gains the mode flag (and, being a CLI invocation, a `--method`). On the web client `--server` is a no-op that logs a warning and the UI loads **every** entry in the file; the TUI rejects it as an unknown option. There is no web equivalent of "open just this one entry" — you pick the server from the list after it loads. ```bash # v2 — let the Inspector manage the file (web UI can edit it) @@ -153,7 +155,7 @@ Every v1 CLI flag still exists in v2 and means the same thing. Nothing was renam | `-e KEY=VALUE` | same | | | `--config ` | same flag, **narrower meaning** | see [above](#--config-vs---catalog) | | `--server ` | same | now `--cli`-only | -| `[target...]` | same | but see the target-ordering rule below | +| `[target...]` | same | but see the target-ordering **and** URL-transport rules below | New in v2, with no v1 equivalent: @@ -175,7 +177,7 @@ See the [CLI README](../clients/cli/README.md) for the full surface. ## CLI behavior changes -The flags survived; three behaviors did not. +The flags survived; four behaviors did not. ### 1. Exit codes and the error envelope @@ -234,6 +236,24 @@ mcp-inspector --cli node build/index.js -- --method tools/list So the web example above, run under `--cli`, would have `--config /etc/myserver.conf` consumed as the Inspector's read-only-session flag (and then rejected as a catalog/ad-hoc conflict). There is currently no way to pass a leading-dash argument through to a stdio server on the `--cli` command line — put it in the server entry's `args` in a catalog or config file instead. +### 4. An ambiguous URL path no longer guesses a transport + +With no `--transport`, v2 infers it from the URL's path suffix — and **only** from that: + +| URL path | v2 | +| -------------- | ---------------------------------- | +| ends in `/mcp` | `streamable-http` | +| ends in `/sse` | `sse` | +| anything else | **error — `--transport` required** | + +``` +Transport type not specified and could not be determined from URL: . +``` + +v1 fell back to SSE for an unrecognized path, so a server at e.g. `https://example.com/api` connected without a flag. In v2 the same command stops with the error above; pass `--transport http` (or `sse`) explicitly. The suffix match is exact, so a trailing slash (`…/mcp/`) is ambiguous too. + +This applies to every client's command line — CLI, TUI, and `--web` (which prints the message and exits `1`). The **browser deep link** is the one exception: `?serverUrl=…` with no `transport` param defaults to `http`. + Stdout is otherwise compatible: the default `text` format still pretty-prints the result as `JSON.stringify(result, null, 2)`, exactly as v1 did. ## Environment variables @@ -293,12 +313,19 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6277:6277 \ -e HOST=0.0.0.0 -e MCP_AUTO_OPEN_ENABLED=false \ ghcr.io/modelcontextprotocol/inspector:1.0.1 -# v2 — one port; the image already sets the wildcard-bind opt-in +# v2 — no proxy port; the image already sets the wildcard-bind opt-in docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector:latest ``` Notes: +- **The Apps tab needs one more published port.** The sandbox server is dynamic by default and the `Dockerfile` `EXPOSE`s only `6274`, so the recipe above covers everything _except_ MCP Apps. To use them, pin and publish the sandbox port as well: + + ```bash + docker run --rm -p 6274:6274 -p 6280:6280 -e MCP_SANDBOX_PORT=6280 \ + ghcr.io/modelcontextprotocol/inspector:latest + ``` + - The v2 image sets `DANGEROUSLY_BIND_ALL_INTERFACES=true` internally (a container must bind `0.0.0.0` to be reachable through `-p`). Setting a bare `HOST=0.0.0.0` **outside** a container now exits with an error. - **If you remap the published port** (`-p 8080:6274`), the browser's origin no longer matches the in-container port, so set `ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. - The image runs as non-root and has a `HEALTHCHECK` that assumes `--web`; add `--no-healthcheck` when running `--cli` / `--tui`. @@ -314,6 +341,10 @@ Notes: **"The Inspector connects to a different server than I named."** Check flag order — under `--cli` the target must precede all flags, or it is silently dropped in favor of the catalog. +**"`Transport type not specified and could not be determined from URL`."** The path ends in neither `/mcp` nor `/sse`, so v2 refuses to guess where v1 fell back to SSE. Pass `--transport http` or `--transport sse`. + +**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second, dynamic port. Pin it with `MCP_SANDBOX_PORT` and publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). + **"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list). **"I need v1 back."** `npx @modelcontextprotocol/inspector@v1-latest`. From 7a74b5eba192f8fe2dbfdbc491bc61580041ceb8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 20:44:37 -0400 Subject: [PATCH 10/73] fix: forward the negotiated protocol version to the remote backend transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web client's SDK Client owns the `initialize` handshake, so the `setProtocolVersion()` call it makes after initialize lands on RemoteClientTransport — a pass-through to the Node backend, not the real HTTP transport. The backend's StreamableHTTPClientTransport therefore never learned the negotiated version and stamped no `Mcp-Protocol-Version` on any request after initialize, starting with `notifications/initialized`. A stateful server that requires the header rejects that notification with HTTP 400. RemoteClientTransport now records the version and rides it on every `/api/mcp/send`; the backend applies it to its transport before the send, so that request and everything the transport issues afterwards (the standalone SSE GET, the session DELETE) carry the header. The version is validated against a token pattern on the backend so a client can't push arbitrary bytes into an upstream header. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../mcp/remote/remoteClientTransport.test.ts | 57 +++++++++++++++++++ .../mcp/remote/remote-session.test.ts | 34 +++++++++++ .../integration/mcp/remote/transport.test.ts | 52 +++++++++++++++++ core/mcp/remote/node/remote-session.ts | 26 +++++++++ core/mcp/remote/node/server.ts | 8 ++- core/mcp/remote/remoteClientTransport.ts | 22 +++++++ core/mcp/remote/types.ts | 6 ++ 7 files changed, 204 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts index 64a1328d..3c51abcb 100644 --- a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts +++ b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts @@ -515,4 +515,61 @@ describe("RemoteClientTransport", () => { reason: "token_expired", }); }); + + // #1935: the SDK Client calls setProtocolVersion() on this transport, but the + // real HTTP transport lives on the backend — so the version has to ride the + // send envelope or the backend drops `Mcp-Protocol-Version` upstream. + it("forwards the negotiated protocol version on every send after setProtocolVersion", async () => { + const seenBodies: string[] = []; + let sse = createPushableSseStream(); + const fetchFn = vi + .fn() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect")) { + return new Response(JSON.stringify({ sessionId: "abc" }), { + status: 200, + }); + } + if (url.includes("/api/mcp/events")) { + sse = createPushableSseStream(); + return sse.response; + } + if (url.endsWith("/api/mcp/send")) { + seenBodies.push(String(init?.body)); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const transport = new RemoteClientTransport( + { baseUrl, fetchFn: fetchFn as unknown as typeof fetch }, + config, + ); + await transport.start(); + + // Pre-initialize sends carry no version (none negotiated yet). + await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" }); + expect(JSON.parse(seenBodies[0]!)).not.toHaveProperty("protocolVersion"); + + transport.setProtocolVersion("2025-11-25"); + + // The first post-initialize send is `notifications/initialized` — the one + // the reporting server rejected for a missing header. + await transport.send({ + jsonrpc: "2.0", + method: "notifications/initialized", + }); + await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" }); + + expect(JSON.parse(seenBodies[1]!)).toMatchObject({ + protocolVersion: "2025-11-25", + message: { method: "notifications/initialized" }, + }); + expect(JSON.parse(seenBodies[2]!)).toMatchObject({ + protocolVersion: "2025-11-25", + }); + + await transport.close(); + }); }); diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index 5fe6f4de..a74e980c 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -120,6 +120,40 @@ describe("RemoteSession", () => { expect(session.transport).toBe(transport); }); + // #1935: the browser's Client negotiates the version, so the backend has to + // be told before it can stamp `Mcp-Protocol-Version` on upstream requests. + it("applyProtocolVersion forwards a new version to the transport once", () => { + const session = new RemoteSession("s8a"); + const setProtocolVersion = vi.fn(); + session.setTransport({ setProtocolVersion } as unknown as Transport); + + session.applyProtocolVersion("2025-11-25"); + session.applyProtocolVersion("2025-11-25"); + expect(setProtocolVersion.mock.calls).toEqual([["2025-11-25"]]); + + // A renegotiated version (e.g. after a reconnect) is re-applied. + session.applyProtocolVersion("2026-07-28"); + expect(setProtocolVersion).toHaveBeenLastCalledWith("2026-07-28"); + }); + + it("applyProtocolVersion ignores an absent or non-token version", () => { + const session = new RemoteSession("s8b"); + const setProtocolVersion = vi.fn(); + session.setTransport({ setProtocolVersion } as unknown as Transport); + + session.applyProtocolVersion(undefined); + // Header injection attempt — must never reach the upstream transport. + session.applyProtocolVersion("2025-11-25\r\nX-Evil: 1"); + session.applyProtocolVersion(""); + expect(setProtocolVersion).not.toHaveBeenCalled(); + }); + + it("applyProtocolVersion is a no-op on a transport without setProtocolVersion (stdio)", () => { + const session = new RemoteSession("s8c"); + session.setTransport({} as unknown as Transport); + expect(() => session.applyProtocolVersion("2025-11-25")).not.toThrow(); + }); + it("hasEventConsumer reflects whether a consumer is attached", () => { const session = new RemoteSession("s9"); expect(session.hasEventConsumer()).toBe(false); diff --git a/clients/web/src/test/integration/mcp/remote/transport.test.ts b/clients/web/src/test/integration/mcp/remote/transport.test.ts index d66ae36a..2e2b55ec 100644 --- a/clients/web/src/test/integration/mcp/remote/transport.test.ts +++ b/clients/web/src/test/integration/mcp/remote/transport.test.ts @@ -452,6 +452,58 @@ describe("Remote transport e2e", () => { } }); + it("end-to-end: the negotiated Mcp-Protocol-Version reaches the upstream server after initialize (#1935)", async () => { + // The browser's SDK Client owns the initialize handshake, so its + // setProtocolVersion() lands on the *remote* transport. Without the + // forward to the backend, the backend's real streamable-HTTP transport + // never learns the version and every post-initialize request — starting + // with `notifications/initialized` — goes out without the header, which + // a stateful server may reject outright. + mcpHttpServer = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "streamable-http", + }); + await mcpHttpServer.start(); + + const config: MCPServerConfig = { + type: "streamable-http", + url: mcpHttpServer.url, + }; + + const { client, fetchRequestLogState } = + await setupRemoteAndConnect(config); + + try { + await client.listTools(); + + const protocolVersion = client.getProtocolVersion(); + expect(protocolVersion).toBeDefined(); + + const posts = fetchRequestLogState + .getFetchRequests() + .filter((r) => r.method === "POST"); + const headerFor = (body: string): string | undefined => { + const entry = posts.find((r) => (r.requestBody ?? "").includes(body)); + expect(entry).toBeDefined(); + for (const [k, v] of Object.entries(entry?.requestHeaders ?? {})) { + if (k.toLowerCase() === "mcp-protocol-version") return v; + } + return undefined; + }; + + // The initialize POST itself predates negotiation and carries none. + expect(headerFor('"method":"initialize"')).toBeUndefined(); + // Everything after it does. + expect(headerFor('"method":"notifications/initialized"')).toBe( + protocolVersion, + ); + expect(headerFor('"method":"tools/list"')).toBe(protocolVersion); + } finally { + await client.disconnect(); + } + }); + it("end-to-end: SEP-2243 Mcp-Param-* mirroring reaches the upstream modern server (#1846)", async () => { // A modern (2026-07-28) server whose `get_weather` tool annotates `city` // with `x-mcp-header: "City"`. The SDK's modern handler validates the diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index 34a2db09..60ee176f 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -37,6 +37,9 @@ export class RemoteSession { static readonly AUTH_HTTP_ECHO_SUPPRESS_MS = 30_000; private readonly requestWaits = new Map(); private authProviderHandle: RemoteAuthProviderHandle | null = null; + private appliedProtocolVersion: string | undefined; + /** A protocol version is a dated token (`2025-11-25`, `2026-07-28`, …). */ + private static readonly PROTOCOL_VERSION_PATTERN = /^[A-Za-z0-9._-]{1,64}$/; constructor(sessionId: string) { this.sessionId = sessionId; @@ -57,6 +60,29 @@ export class RemoteSession { this.transport = transport; } + /** + * Apply the client's negotiated protocol version to the upstream transport + * (#1935). The browser's SDK Client owns the `initialize` handshake, so the + * `setProtocolVersion` call it makes lands on the *remote* transport; without + * this the backend's real HTTP transport never learns the version and drops + * `Mcp-Protocol-Version` from every subsequent request. + * + * Ignores anything that isn't a plausible version token so a client can't + * push arbitrary bytes into an upstream header, and re-applies only on + * change (the value rides every `/api/mcp/send`). + */ + applyProtocolVersion(version: string | undefined): void { + if ( + version === undefined || + version === this.appliedProtocolVersion || + !RemoteSession.PROTOCOL_VERSION_PATTERN.test(version) + ) { + return; + } + this.appliedProtocolVersion = version; + this.transport.setProtocolVersion?.(version); + } + setEventConsumer(consumer: (event: SessionEvent) => void): void { this.eventConsumer = consumer; // Flush queued events diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 4bbc518a..8a3f2a05 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -728,7 +728,8 @@ export function createRemoteApp( return c.json({ error: "Invalid JSON body" }, 400); } - const { sessionId, message, relatedRequestId, headers } = body; + const { sessionId, message, relatedRequestId, headers, protocolVersion } = + body; if (!sessionId || !message) { return c.json({ error: "Missing sessionId or message" }, 400); } @@ -744,6 +745,11 @@ export function createRemoteApp( return c.json({ ok: false, kind: "transport_error", error: errorMsg }); } + // The browser's SDK Client negotiated the version; hand it to the real + // upstream transport before the send so `Mcp-Protocol-Version` is stamped + // on this request and everything after it (#1935). + session.applyProtocolVersion(protocolVersion); + session.beginSend(); const requestId = requestIdForSendWait(message); let responseWait: Promise | undefined; diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 03c92461..a9dfed78 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -221,6 +221,7 @@ async function* parseSSE( */ export class RemoteClientTransport implements Transport { private _sessionId: string | undefined = undefined; + private _protocolVersion: string | undefined = undefined; private eventStreamReader: ReadableStreamDefaultReader | null = null; private eventStreamAbort: AbortController | null = null; @@ -250,6 +251,22 @@ export class RemoteClientTransport implements Transport { return this._sessionId; } + /** + * The SDK Client calls this with the negotiated protocol version as soon as + * `initialize` resolves — before it sends `notifications/initialized` — so an + * HTTP transport can stamp `Mcp-Protocol-Version` on every later request. + * + * Here the real upstream transport lives on the backend, so we can only + * record the version and forward it on the next `/api/mcp/send`, which + * applies it to the upstream transport *before* sending. Because + * `notifications/initialized` is that next send, it and everything after it + * (including the standalone SSE GET and the session DELETE, both issued by + * the upstream transport itself) carry the header (#1935). + */ + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } + /** * Reattach to an existing remote backend session after a full-page OAuth * redirect. Opens the SSE event stream without POST /connect. @@ -707,6 +724,11 @@ export class RemoteClientTransport implements Transport { // Forward per-send `Mcp-Param-*` headers (SEP-2243) for the backend to // apply to the upstream send; the browser can't set them cross-origin. ...(options?.headers != null && { headers: options.headers }), + // Forward the negotiated protocol version so the backend's transport can + // stamp `Mcp-Protocol-Version` on this and every later request (#1935). + ...(this._protocolVersion !== undefined && { + protocolVersion: this._protocolVersion, + }), }; const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, { diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts index 23bb6aa9..9e948401 100644 --- a/core/mcp/remote/types.ts +++ b/core/mcp/remote/types.ts @@ -125,6 +125,12 @@ export interface RemoteSendRequest { * them to the upstream `transport.send`, filtered to the `Mcp-Param-` prefix. */ headers?: Record; + /** + * MCP protocol version negotiated by the client's `initialize`. The backend + * applies it to the upstream transport before the send so HTTP transports + * stamp `Mcp-Protocol-Version` on this and every later request (#1935). + */ + protocolVersion?: string; } export type RemoteEventType = From 47e0ee059a0dc1a1134362c6a8369b104298772b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 20:47:09 -0400 Subject: [PATCH 11/73] chore(deps): match CommonJS and template-literal imports in the shared-source scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the suppressed comment on Copilot's second pass of #1962. Under- approximating is the dangerous direction here: a package the scan misses never enters the candidate set, so its skew passes the guard silently. Three forms went unmatched. `import x = require("pkg")` and a bare `require("pkg")` are the ordinary import syntax in `.cts` — which the previous commit just admitted to the scan, so leaving them out would have been an inconsistency introduced by that widening. A dynamic import carrying import attributes (`import("pkg", { with: … })`) was missed because the pattern required the closing paren. A static template literal (`import(\`pkg\`)`) was missed for want of a backtick in the quote class. Backticks are accepted only in the *call* forms. A `from` clause requires a string literal, so allowing them there would buy nothing while reopening the prose hazard the anchored specifier pattern closes: inline code in comments is written with backticks throughout this codebase, and admitting it inflated the derived set from 18 names to 41 with prose fragments (`tools`, `connect()`, `messages`). Inert today, since a name absent from every lockfile contributes nothing — but a prose word colliding with a real package name would silently widen the set. Pinned with a regression test. The real set is unchanged at 18, so these forms add no package today; they close the syntax against the one that arrives later. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 23 ++++++++++++++----- scripts/verify-dep-lockstep.test.mjs | 33 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 487a927c..0eb8a9dd 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -101,12 +101,25 @@ export function packageNameOf(specifier) { return name; } -// `from "x"` (import and re-export), a side-effect `import "x"`, and a dynamic -// `import("x")`. Only these three forms introduce a dependency's *types*. +// Every form that can introduce a dependency's *types* (Copilot, #1962). +// `import x = require("pkg")` and a bare `require("pkg")` matter because the +// shared trees may hold `.cts` sources, where that is the ordinary import +// syntax; and neither call form requires the closing paren, so an import +// attributes argument (`import("pkg", { with: … })`) can't hide the specifier. +// +// Backticks are accepted ONLY in the call forms, where a static template +// literal is legal. A `from` clause requires a string literal, so allowing +// backticks there would buy nothing while reopening the prose hazard the +// anchored specifier pattern exists to close — inline code in a comment is +// written with backticks throughout this codebase, and "…derived from +// `tools`…" would otherwise enter the candidate set as a package named +// `tools`. Inert today, but a prose word that collides with a real package +// name would silently widen the set. const SPECIFIER_FORMS = [ - /\bfrom\s*["']([^"']+)["']/g, - /\bimport\s+["']([^"']+)["']/g, - /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /\bfrom\s*["']([^"']+)["']/g, // import … from "x" / export … from "x" + /\bimport\s+["']([^"']+)["']/g, // side-effect import "x" + /\bimport\s*\(\s*["'`]([^"'`]+)["'`]/g, // dynamic import("x"[, opts]) + /\brequire\s*\(\s*["'`]([^"'`]+)["'`]/g, // import x = require("x"), require("x") ]; /** diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 640291fb..890d90b5 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -79,6 +79,26 @@ test("packageNameOf: non-packages are rejected", () => { assert.equal(packageNameOf(undefined), null); }); +test("importedPackageNames: CommonJS and awkward dynamic-import forms (Copilot, #1962)", () => { + // Under-approximating is the dangerous direction: a package the scan misses + // never enters the candidate set, so its skew passes the guard silently. + // `.cts` sources in the shared trees use `import x = require(…)` as ordinary + // syntax, and a dynamic import may carry import attributes or a static + // template literal — none of which the original three patterns matched. + const source = ` + import express = require("express"); + const yaml = require("yaml"); + const a = await import("undici", { with: { type: "json" } }); + const b = await import(\`jose\`); + `; + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "express", + "jose", + "undici", + "yaml", + ]); +}); + test("importedPackageNames: the three specifier forms that introduce types", () => { const source = ` import { z } from "zod/v4"; @@ -109,6 +129,19 @@ test("importedPackageNames: prose after `from` is not an import", () => { assert.deepEqual([...importedPackageNames(source)], ["zod"]); }); +test("importedPackageNames: a backticked `from` in prose is not an import", () => { + // Backticks are legal in the *call* forms (a static template literal) but + // never after `from`, which requires a string literal. Accepting them there + // would sweep in inline code from comments — this codebase writes it with + // backticks throughout — and a prose word colliding with a real package name + // would silently widen the candidate set. + const source = ` + /** The excluded set derived from \\\`tools\\\`, keyed off \\\`express\\\`-style paths. */ + import { z } from "zod"; + `; + assert.deepEqual([...importedPackageNames(source)], ["zod"]); +}); + test("topLevelLockVersions: nested duplicates are ignored", () => { // A nested `node_modules/a/node_modules/b` is npm resolving a transitive // conflict *inside* one install — routine, and not the cross-install skew From 908284d04655c865a8a90be8f1035460cb6e3495 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 20:47:40 -0400 Subject: [PATCH 12/73] docs: address Copilot's suppressed comments on the migration guide - `--` under --cli: everything before the separator IS forwarded to the target, flags included (cli.ts:536-541), so "no way to pass a leading-dash argument" was wrong. Show the correct placement instead of sending users to a catalog file. - Stdout: v2's emitResult appends "\n" where v1's awaitableLog wrote the JSON with no terminator, so the output is not byte-identical. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/v1-to-v2-migration.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 745779c9..04f2a640 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -234,7 +234,17 @@ Under **`--cli`** it is reversed: everything _before_ `--` is the target, everyt mcp-inspector --cli node build/index.js -- --method tools/list ``` -So the web example above, run under `--cli`, would have `--config /etc/myserver.conf` consumed as the Inspector's read-only-session flag (and then rejected as a catalog/ad-hoc conflict). There is currently no way to pass a leading-dash argument through to a stdio server on the `--cli` command line — put it in the server entry's `args` in a catalog or config file instead. +So the web example above, run verbatim under `--cli`, would have `--config /etc/myserver.conf` consumed as the Inspector's read-only-session flag (and then rejected as a catalog/ad-hoc conflict). To pass a leading-dash argument through to a stdio server, put it **before** the `--` instead — everything on that side is forwarded to the target untouched, flags included: + +```bash +# v1 +mcp-inspector node build/index.js -- --config /etc/myserver.conf --verbose + +# v2, same thing under --cli — target and its flags first, Inspector options after +mcp-inspector --cli node build/index.js --config /etc/myserver.conf --verbose -- --method tools/list +``` + +(Without a `--` on the line, the target is only the leading run of non-dash tokens — so `--` is required whenever the server itself takes flags.) ### 4. An ambiguous URL path no longer guesses a transport @@ -254,7 +264,7 @@ v1 fell back to SSE for an unrecognized path, so a server at e.g. `https://examp This applies to every client's command line — CLI, TUI, and `--web` (which prints the message and exits `1`). The **browser deep link** is the one exception: `?serverUrl=…` with no `transport` param defaults to `http`. -Stdout is otherwise compatible: the default `text` format still pretty-prints the result as `JSON.stringify(result, null, 2)`, exactly as v1 did. +Stdout is otherwise compatible: the default `text` format still pretty-prints the result as `JSON.stringify(result, null, 2)`. One byte differs — v2 appends a trailing newline where v1 wrote none — so a script diffing raw stdout against a stored v1 fixture needs the fixture re-captured (or the comparison trimmed). ## Environment variables From 8c9cf40211700474a26a922862742cbfc1490b35 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 20:53:45 -0400 Subject: [PATCH 13/73] docs: qualify the ad-hoc rule and fix the contradicting `--` guidance - The "nothing combines with an ad-hoc target" rule has a web exception: run-web.ts:130 exempts `--transport stdio`, so web ignores it rather than rejecting. Only cli/tui reject every `--transport`. - mcp-server-configuration.md still said a leading-dash argument cannot reach a stdio server under --cli, the opposite of what the migration guide now says. The parser puts every pre-`--` token in targetArgs, so the guide is right; corrected the older section to match. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/mcp-server-configuration.md | 8 +++++++- docs/v1-to-v2-migration.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 88250cd6..00455a24 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -99,7 +99,13 @@ They differ on **when you need it**, though. Web sets `allowUnknownOption()` + ` mcp-inspector --cli node build/index.js -- --method tools/list ``` -So under `--cli` the separator does **not** protect an argument from the Inspector — it does the opposite, and the web example above would have `--config /etc/myserver.conf` consumed as a read-only-session flag (then rejected as a catalog/ad-hoc conflict). There is currently no way to pass a leading-dash argument through to a stdio server on the `--cli` command line; put it in the server entry's `args` in a catalog or config file instead. +So under `--cli` the separator does **not** protect an argument from the Inspector — it does the opposite, and the web example above, run verbatim, would have `--config /etc/myserver.conf` consumed as a read-only-session flag (then rejected as a catalog/ad-hoc conflict). Leading-dash arguments for the server still get through; they just go on the other side of the separator, where the whole pre-`--` run is taken as the target verbatim: + +```bash +mcp-inspector --cli node build/index.js --config /etc/myserver.conf --verbose -- --method tools/list +``` + +Without a `--` on the line the target is only the leading run of **non-dash** tokens, so the separator is required whenever the server itself takes flags. ## The shared flags diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 04f2a640..e086a674 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -128,7 +128,7 @@ npx @modelcontextprotocol/inspector --catalog ./mcp.json npx @modelcontextprotocol/inspector ``` -**Rules:** `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target (a positional command, `--server-url`, or `--transport`). v1 silently preferred the config file; v2 tells you. +**Rules:** `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target (a positional command, `--server-url`, or `--transport`). v1 silently preferred the config file; v2 tells you. One exception: the **web** client exempts `--transport stdio` from that check, so it survives alongside `--catalog`/`--config` (ignored rather than rejected) — the CLI and TUI reject every `--transport`. If you export `MCP_CATALOG_PATH` in your shell, note that **web and TUI read it unconditionally** — so an ad-hoc invocation such as `mcp-inspector --tui node build/index.js` is rejected as a catalog/ad-hoc conflict. Unset it for that invocation. The CLI ignores the variable whenever an ad-hoc target is present. From ddfce0f7ac30cab4316809ae3d6d90f97a238d3f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 20:55:51 -0400 Subject: [PATCH 14/73] fix: reject a non-string protocolVersion, and drop the test double casts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review follow-ups: - `applyProtocolVersion` took the value straight from an unvalidated JSON body and went to `RegExp.test`, which coerces — `123` and `true` both matched the token pattern and reached `Transport.setProtocolVersion` with the wrong type. It now takes `unknown` and checks `typeof === "string"` first, with cases for the coercible values. - Replace `as unknown as Transport` in the new session fixtures with a `makeTransport()` helper returning a structurally valid `Transport`, so an interface change stays visible to the tests. - Drop `as unknown as typeof fetch` in the new transport test — `vi.fn()` is already the right type. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../mcp/remote/remoteClientTransport.test.ts | 5 +--- .../mcp/remote/remote-session.test.ts | 23 +++++++++++++++---- core/mcp/remote/node/remote-session.ts | 9 +++++--- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts index 3c51abcb..c0478851 100644 --- a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts +++ b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts @@ -542,10 +542,7 @@ describe("RemoteClientTransport", () => { return new Response("not found", { status: 404 }); }); - const transport = new RemoteClientTransport( - { baseUrl, fetchFn: fetchFn as unknown as typeof fetch }, - config, - ); + const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config); await transport.start(); // Pre-initialize sends carry no version (none negotiated yet). diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index a74e980c..c152b960 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -25,6 +25,16 @@ function makeFetchEntry( }; } +/** A structurally valid `Transport`, so a fixture needs no cast. */ +function makeTransport(overrides: Partial = {}): Transport { + return { + start: async () => {}, + send: async () => {}, + close: async () => {}, + ...overrides, + }; +} + describe("RemoteSession", () => { it("queues events before a consumer attaches and drains them on attach", () => { const session = new RemoteSession("s1"); @@ -125,7 +135,7 @@ describe("RemoteSession", () => { it("applyProtocolVersion forwards a new version to the transport once", () => { const session = new RemoteSession("s8a"); const setProtocolVersion = vi.fn(); - session.setTransport({ setProtocolVersion } as unknown as Transport); + session.setTransport(makeTransport({ setProtocolVersion })); session.applyProtocolVersion("2025-11-25"); session.applyProtocolVersion("2025-11-25"); @@ -136,21 +146,26 @@ describe("RemoteSession", () => { expect(setProtocolVersion).toHaveBeenLastCalledWith("2026-07-28"); }); - it("applyProtocolVersion ignores an absent or non-token version", () => { + it("applyProtocolVersion ignores an absent, non-token, or non-string version", () => { const session = new RemoteSession("s8b"); const setProtocolVersion = vi.fn(); - session.setTransport({ setProtocolVersion } as unknown as Transport); + session.setTransport(makeTransport({ setProtocolVersion })); session.applyProtocolVersion(undefined); // Header injection attempt — must never reach the upstream transport. session.applyProtocolVersion("2025-11-25\r\nX-Evil: 1"); session.applyProtocolVersion(""); + // The value arrives from an unvalidated JSON body, so a non-string must be + // rejected on its type — `RegExp.test` would coerce these into a match. + session.applyProtocolVersion(123); + session.applyProtocolVersion(true); + session.applyProtocolVersion(null); expect(setProtocolVersion).not.toHaveBeenCalled(); }); it("applyProtocolVersion is a no-op on a transport without setProtocolVersion (stdio)", () => { const session = new RemoteSession("s8c"); - session.setTransport({} as unknown as Transport); + session.setTransport(makeTransport()); expect(() => session.applyProtocolVersion("2025-11-25")).not.toThrow(); }); diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index 60ee176f..fc7c7654 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -69,11 +69,14 @@ export class RemoteSession { * * Ignores anything that isn't a plausible version token so a client can't * push arbitrary bytes into an upstream header, and re-applies only on - * change (the value rides every `/api/mcp/send`). + * change (the value rides every `/api/mcp/send`). The value arrives from an + * unvalidated JSON body, so the type is checked rather than assumed — + * `RegExp.test` would otherwise coerce a `123` or `true` into a "matching" + * string and hand the wrong type to the transport. */ - applyProtocolVersion(version: string | undefined): void { + applyProtocolVersion(version: unknown): void { if ( - version === undefined || + typeof version !== "string" || version === this.appliedProtocolVersion || !RemoteSession.PROTOCOL_VERSION_PATTERN.test(version) ) { From 4fe02bca45d3e3e0fcb01169d3b4ddf96dde2084 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 20:59:38 -0400 Subject: [PATCH 15/73] docs: keep the Docker recipes loopback-only; note the legacy token fallback - The v1 recipe published on 127.0.0.1 and the v2 one silently dropped that. Docker's -p binds host interfaces independently of the container's HOST, and a no-Origin request skips the allow-list (core/mcp/remote/node/server.ts:241), so the token is the only guard on a process-spawning backend. Restore the loopback prefix on both recipes and say why. - MCP_PROXY_AUTH_TOKEN is not removed: web-server-config.ts:329 still falls back to it. Call it a deprecated fallback rather than a rename, and distinguish it from the query param, which has no fallback (App.tsx getAuthToken reads only the new name). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/v1-to-v2-migration.md | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index e086a674..a2db3f55 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -268,21 +268,21 @@ Stdout is otherwise compatible: the default `text` format still pretty-prints th ## Environment variables -| v1 | v2 | Notes | -| ------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed. Guards `/api/*` via `x-mcp-remote-auth: Bearer `; the browser also receives it injected into `index.html`, so a bare reload keeps working | -| `MCP_PROXY_FULL_ADDRESS` | — | Removed; there is no proxy | -| `SERVER_PORT` | _repurposed_ | Was the proxy port. Now only a fallback for the MCP Apps sandbox port | -| `CLIENT_PORT` | same | Web UI port, default `6274`. Must be a fixed port — `0`/dynamic is rejected, since the origin allow-list and sandbox CSP derive from it | -| `HOST` | same, **guarded** | An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is now **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES=true`. Binding a specific IP or hostname needs no opt-in | -| `ALLOWED_ORIGINS` | same | Still comma-separated, still **replaces** the default list rather than merging. Entries must include the scheme | -| `DANGEROUSLY_OMIT_AUTH` | same | | -| `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | -| — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | -| — | `MCP_CATALOG_PATH` | Default catalog path | -| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (dynamic by default) | -| — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations | -| — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback | +| v1 | v2 | Notes | +| ------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed, but the **old name still works** as a deprecated fallback when the new one is unset — an existing deployment keeps running while you migrate. Guards `/api/*` via `x-mcp-remote-auth: Bearer `; the browser also receives it injected into `index.html`, so a bare reload keeps working. (The `?MCP_PROXY_AUTH_TOKEN=` **query param** has no such fallback — see [Web UI](#web-ui).) | +| `MCP_PROXY_FULL_ADDRESS` | — | Removed; there is no proxy | +| `SERVER_PORT` | _repurposed_ | Was the proxy port. Now only a fallback for the MCP Apps sandbox port | +| `CLIENT_PORT` | same | Web UI port, default `6274`. Must be a fixed port — `0`/dynamic is rejected, since the origin allow-list and sandbox CSP derive from it | +| `HOST` | same, **guarded** | An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is now **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES=true`. Binding a specific IP or hostname needs no opt-in | +| `ALLOWED_ORIGINS` | same | Still comma-separated, still **replaces** the default list rather than merging. Entries must include the scheme | +| `DANGEROUSLY_OMIT_AUTH` | same | | +| `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | +| — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | +| — | `MCP_CATALOG_PATH` | Default catalog path | +| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (dynamic by default) | +| — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations | +| — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback | ### The v1 UI configuration settings @@ -324,15 +324,17 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6277:6277 \ ghcr.io/modelcontextprotocol/inspector:1.0.1 # v2 — no proxy port; the image already sets the wildcard-bind opt-in -docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector:latest +docker run --rm -p 127.0.0.1:6274:6274 ghcr.io/modelcontextprotocol/inspector:latest ``` Notes: +- **Keep the `127.0.0.1:` prefix on the published port**, as the v1 recipe did. `-p 6274:6274` publishes on every host interface, and the container's `HOST=0.0.0.0` is about the _container's_ interfaces, not the host's — the two are independent. That matters here because the backend spawns processes, `/` embeds the API token, and requests arriving with **no** `Origin` header (i.e. anything that isn't a browser) skip the origin allow-list, leaving the token as the only guard. Bind to loopback and opt into wider exposure deliberately. - **The Apps tab needs one more published port.** The sandbox server is dynamic by default and the `Dockerfile` `EXPOSE`s only `6274`, so the recipe above covers everything _except_ MCP Apps. To use them, pin and publish the sandbox port as well: ```bash - docker run --rm -p 6274:6274 -p 6280:6280 -e MCP_SANDBOX_PORT=6280 \ + docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6280:6280 \ + -e MCP_SANDBOX_PORT=6280 \ ghcr.io/modelcontextprotocol/inspector:latest ``` From 35317fd9ed3062ec93c42a08ff429bc979028b0b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 21:00:31 -0400 Subject: [PATCH 16/73] chore(deps): scope the allowlist to a major, cover vitest.shared.mts, fix the vouch comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four suppressed comments from Copilot's third pass of #1962. Each was correct. The candidate boundary omitted `vitest.shared.mts` — root-owned, imported by every client's vitest config, and already treated as shared non-client source by `verify:typecheck-coverage`. It imports only Node built-ins today, which is exactly why the omission would have gone unnoticed until a third-party import appeared there, resolved from the root, and skewed. Shared *files* are now a first-class list beside the shared dirs. The allowlist was a blanket exemption by name. Each entry's rationale establishes that a patch/minor difference is benign, which is no evidence that a React 18-vs-19 or Hono 4-vs-5 split would be — that is a different type surface. Being listed now tolerates skew only *within a major version*; a cross-major skew fails like anything else. Deriving the major from the lockfile version keeps this automatic, with no per-package range to maintain and bump. A version that can't be parsed is treated as "cannot prove same major" and fails rather than passes. `verify-format-coverage.mjs`'s comment overstated the vouching graph: the three guards do not each check the others. This one checks both siblings; each sibling checks only this one. Described as it actually works. The AGENTS.md rule said to bump a shared dependency in "root + all four clients", but launcher declares no zod and `findSkew` ignores installs where a package is absent — so the rule as written asked for spurious dependencies. Reworded to every install that *declares* it, and mirrored in `.github/copilot-instructions.md`. The failure output follows: it no longer advises adding a package that is already allowlisted, saying "MAJOR skew" instead, and it names all three shared sources rather than the two dirs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 2 +- AGENTS.md | 2 +- scripts/verify-dep-lockstep.mjs | 85 ++++++++++++++++++++++++---- scripts/verify-dep-lockstep.test.mjs | 85 +++++++++++++++++++++++++++- scripts/verify-format-coverage.mjs | 8 ++- 5 files changed, 162 insertions(+), 20 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a49cec61..38ae3ca9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,7 +92,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo ## Gates and PR hygiene - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. -- **A dependency bump must land in every install.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR touching one `package.json`/`package-lock.json` for a package `core/` or `test-servers/src` imports should touch all of them. +- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR bumping a package that the shared sources import should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. - **Every PR references an issue**, first body line `Closes #`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. diff --git a/AGENTS.md b/AGENTS.md index 1d38a6e2..2fcb3ab9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -453,7 +453,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from the packages `core/` and `test-servers/src` import (so a new shared dependency is covered without editing the guard), reads the committed lockfiles' **top-level** `node_modules/` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and fails, **deny-by-default**, on any candidate held at two versions across installs. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; `react`, `hono`, `jose`, and `@modelcontextprotocol/ext-apps` are listed today. **When bumping a dependency that `core/` or `test-servers/src` imports, bump it in every install** (root + all four clients), not just the one you're working in. Its pure helpers are unit-tested via `test:scripts`, and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. + - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from the packages the shared first-party TypeScript imports — `core/`, `test-servers/src`, and the individually-named `vitest.shared.mts` (root-owned and imported by every client's vitest config) — so a new shared dependency is covered without editing the guard. It reads the committed lockfiles' **top-level** `node_modules/` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and fails, **deny-by-default**, on any candidate held at two versions across installs. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; `react`, `hono`, `jose`, and `@modelcontextprotocol/ext-apps` are listed today. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources import, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts`, and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 0eb8a9dd..2e487fd8 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -21,8 +21,9 @@ // two copies — changing nothing else — returned the build to its baseline cost. // // The candidate set is DERIVED, not hand-listed: it is the packages imported by -// `core/` and `test-servers/src`, the two first-party surfaces compiled into -// more than one client's program. Skew is then denied by default, with a small +// the shared first-party TypeScript — `core/`, `test-servers/src`, and the +// root-owned `vitest.shared.mts` — the surfaces compiled into more than one +// client's program. Skew is then denied by default, with a small // allowlist of packages verified to tolerate it (below). A dependency that // starts skewing therefore fails `validate` and forces a decision, rather than // surfacing months later as an unexplained OOM. @@ -45,11 +46,25 @@ const repoRoot = path.resolve( // `test-servers/src` is pulled into the web and cli test projects. const SHARED_SOURCE_DIRS = ["core", "test-servers/src"]; +// Individual root-owned TypeScript files that are shared the same way but sit +// outside those trees. `vitest.shared.mts` is imported by every client's vitest +// config, and `verify:typecheck-coverage` already treats it as shared +// non-client source. It imports only Node built-ins today — which is precisely +// why omitting it would go unnoticed until a third-party import appeared there, +// resolved from the root, and skewed (Copilot, #1962). +const SHARED_SOURCE_FILES = ["vitest.shared.mts"]; + // Packages whose cross-install skew is verified benign, each with the reason. // This is an allowlist of *names*, not of version pairs, so an ordinary patch // float within one of these does not churn the file — while any package NOT // listed here failing the check is a genuine, unreviewed new skew. // +// Being listed is NOT a blanket exemption: it tolerates skew only *within a +// major version*. Each rationale below establishes that a patch/minor +// difference is harmless, which is not evidence that a React 18-vs-19 or Hono +// 4-vs-5 split across installs would be — that is a different type surface, and +// it fails like anything else (Copilot, #1962). +// // The admission test is the one the zod incident established: does the // package's public type surface consist of deeply recursive generics that // first-party code relates across the boundary? If yes it must stay in @@ -175,11 +190,34 @@ export function findSkew(candidates, installs) { return skewed; } -/** Split skewed packages into the tolerated ones and the failures. */ +/** + * The major-version component of a lockfile version string. Prerelease and + * build metadata are irrelevant here (`2.0.0-beta.5` → `2`). Returns null for + * anything not starting with an integer, which is treated as "cannot prove same + * major" and therefore fails rather than passes. + */ +export function majorOf(version) { + const m = /^(\d+)\./.exec(String(version ?? "")); + return m ? m[1] : null; +} + +/** + * Split skewed packages into the tolerated ones and the failures. + * + * Being on the allowlist tolerates skew only *within a major version*: each + * entry's rationale establishes that a patch/minor difference is benign, which + * says nothing about a major split, where the type surface itself changes. So a + * listed package whose holders disagree on major is still a failure. + */ export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { + const isTolerated = (s) => { + if (!tolerated.has(s.name)) return false; + const majors = new Set(s.holders.map((h) => majorOf(h.version))); + return majors.size === 1 && !majors.has(null); + }; return { - failures: skewed.filter((s) => !tolerated.has(s.name)), - ignored: skewed.filter((s) => tolerated.has(s.name)), + failures: skewed.filter((s) => !isTolerated(s)), + ignored: skewed.filter(isTolerated), }; } @@ -191,8 +229,12 @@ export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { // unnoticed until a new shared dependency arrived through one and skewed. const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; -/** Whether a repo-relative path is a TypeScript source of a shared tree. */ +/** + * Whether a repo-relative path is shared first-party TypeScript — under one of + * the shared trees, or one of the individually-named shared files. + */ export function isSharedSourceFile(file) { + if (SHARED_SOURCE_FILES.includes(file)) return true; if (!SOURCE_EXTENSIONS.some((ext) => file.endsWith(ext))) return false; // Anchored on a path boundary so a sibling whose name merely starts with a // shared dir (`core-internal/`, `test-servers/src-legacy/`) isn't swept in. @@ -203,7 +245,12 @@ export function isSharedSourceFile(file) { function sharedSourceFiles() { const out = execFileSync( "git", - ["ls-files", "--", ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`)], + [ + "ls-files", + "--", + ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`), + ...SHARED_SOURCE_FILES, + ], { cwd: repoRoot, encoding: "utf8" }, ); return out.split("\n").filter(isSharedSourceFile); @@ -278,22 +325,36 @@ export function main() { console.error( `verify:dep-lockstep — ${failures.length} ${failures.length === 1 ? "dependency resolves" : "dependencies resolve"} to different versions across installs:\n`, ); + let anyListed = false; for (const { name, holders } of failures) { - console.error(` ${name}`); + // A package already on the allowlist reached here only by skewing across + // a MAJOR boundary, so say that rather than advising an entry that exists. + const listed = TOLERATED_SKEW.has(name); + anyListed ||= listed; + console.error( + ` ${name}${listed ? " (allowlisted — but this is a MAJOR skew)" : ""}`, + ); for (const { dir, version } of holders) console.error(` ${version} (${dir})`); } + const shared = [...SHARED_SOURCE_DIRS, ...SHARED_SOURCE_FILES].join(", "); console.error( "\nThese packages' types are compiled into a single `tsc` program from two installs" + - `\n(${SHARED_SOURCE_DIRS.join(" and ")} resolve from the root, a client's own sources from the client),` + + `\n(${shared} resolve from the root, a client's own sources from the client),` + "\nso a version skew makes TypeScript relate two structurally-distinct copies of the same" + "\ntype. For a recursive-generic surface like zod that is what exhausted the tsc heap in #1896.", ); console.error( - "\nAlign them — `npm install @` in each install so all lockfiles agree — or, if this" + - "\npackage's types genuinely cannot blow up, add it to TOLERATED_SKEW in scripts/verify-dep-lockstep.mjs" + - "\nwith the reason. See AGENTS.md.", + "\nAlign them — `npm install @` in each install that declares the package, so all" + + "\nlockfiles agree. (Don't add it to an install that doesn't declare it: a package absent from an" + + "\ninstall can't skew.) If instead its types genuinely cannot blow up, add it to TOLERATED_SKEW in" + + "\nscripts/verify-dep-lockstep.mjs with the reason. See AGENTS.md.", ); + if (anyListed) + console.error( + "\nNote: an allowlisted package is tolerated only WITHIN a major version — the rationale for one" + + "\nestablishes that a patch/minor difference is benign, not that a major split is. Align the major.", + ); process.exit(1); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 890d90b5..55704578 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -10,6 +10,7 @@ import { findSkew, importedPackageNames, isSharedSourceFile, + majorOf, packageNameOf, partitionSkew, topLevelLockVersions, @@ -240,8 +241,20 @@ test("partitionSkew: the allowlist is by name, not by version pair", () => { // So an ordinary patch float within a tolerated package does not churn the // allowlist, while any *unlisted* package that starts skewing still fails. const skewed = [ - { name: "react", holders: [] }, - { name: "zod", holders: [] }, + { + name: "react", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, + { + name: "zod", + holders: [ + { dir: ".", version: "4.3.6" }, + { dir: "clients/web", version: "4.4.3" }, + ], + }, ]; const tolerated = new Map([["react", "shallow interfaces"]]); const { failures, ignored } = partitionSkew(skewed, tolerated); @@ -256,6 +269,72 @@ test("partitionSkew: the allowlist is by name, not by version pair", () => { }); test("partitionSkew: deny by default — nothing tolerated fails everything", () => { - const skewed = [{ name: "zod", holders: [] }]; + const skewed = [{ name: "zod", holders: [{ dir: ".", version: "1.0.0" }] }]; assert.equal(partitionSkew(skewed, new Map()).failures.length, 1); }); + +test("partitionSkew: the allowlist tolerates skew only within a major (Copilot, #1962)", () => { + // Each rationale establishes that a patch/minor difference is benign; that is + // not evidence a React 18-vs-19 split is, so a listed package still fails + // across a major boundary. + const tolerated = new Map([["react", "shallow interfaces"]]); + const withinMajor = [ + { + name: "react", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, + ]; + const acrossMajor = [ + { + name: "react", + holders: [ + { dir: ".", version: "18.3.1" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, + ]; + assert.equal(partitionSkew(withinMajor, tolerated).failures.length, 0); + assert.equal(partitionSkew(withinMajor, tolerated).ignored.length, 1); + assert.equal(partitionSkew(acrossMajor, tolerated).failures.length, 1); + assert.equal(partitionSkew(acrossMajor, tolerated).ignored.length, 0); +}); + +test("partitionSkew: an unparseable version can't be proven same-major, so it fails", () => { + const tolerated = new Map([["react", "shallow interfaces"]]); + const skewed = [ + { + name: "react", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "next" }, + ], + }, + ]; + assert.equal(partitionSkew(skewed, tolerated).failures.length, 1); +}); + +test("majorOf: prerelease and build metadata are irrelevant", () => { + const cases = [ + ["4.4.3", "4"], + ["2.0.0-beta.5", "2"], + ["19.2.8", "19"], + ["1.10.3+build.7", "1"], + ]; + for (const [input, expected] of cases) + assert.equal(majorOf(input), expected, input); + for (const bad of ["next", "", undefined, null, "v4.4.3"]) + assert.equal(majorOf(bad), null, JSON.stringify(bad)); +}); + +test("isSharedSourceFile: individually-named shared files are included (Copilot, #1962)", () => { + // `vitest.shared.mts` is root-owned, imported by every client's vitest + // config, and already treated as shared by `verify:typecheck-coverage`. It + // imports only Node built-ins today, which is why omitting it would go + // unnoticed until a third-party import appeared there and skewed. + assert.equal(isSharedSourceFile("vitest.shared.mts"), true); + // Still anchored: a same-named file nested elsewhere is not the shared one. + assert.equal(isSharedSourceFile("clients/web/vitest.shared.mts"), false); +}); diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index 014b850f..20ea65db 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -166,9 +166,11 @@ function trackedSourceFiles() { return out.split("\n").filter(Boolean); } -// Vouch for the sibling guards: a guard can't detect being unrun itself, but the -// three can each assert the others are still wired into `validate`, so dropping -// any one is caught here. Only deleting all of them slips through. +// Vouch for the sibling guards: a guard can't detect being unrun itself, so the +// three form a cycle instead. This one checks BOTH others; each of them checks +// only this one. So dropping `verify:typecheck-coverage` or +// `verify:dep-lockstep` is caught here, and dropping *this* guard is caught by +// either of them. Only removing all three at once slips through. const rootScripts = JSON.parse( readFileSync(path.join(repoRoot, "package.json"), "utf8"), ).scripts; From 2dcf1450b21e76be4d89d61c74ff300dffece25e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 21:07:37 -0400 Subject: [PATCH 17/73] chore(deps): see imports through comment trivia; name vitest.shared.mts in the README Both suppressed comments from Copilot's fourth pass of #1962. TypeScript allows a comment anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")` and `from /* why */ "pkg"` are valid imports the patterns stepped over. That is the dangerous direction: a missed specifier never enters the candidate set, so the package's skew passes the guard silently, against the over-approximation the module claims. A `TRIVIA` fragment now stands in for `\s*` at every inter-token position, with regression cases for the commented static, dynamic, side-effect, and `require` forms. I did not move to the TypeScript parser. This is a root `scripts/` `.mjs` tool with no bundler and no TS dependency; pulling in a compiler API to read import specifiers is a lot of machinery for a scan that only needs to over-approximate, and each missed form is a one-line pattern with a test beside it. The derived set is unchanged at 18 real names, so nothing here widens what is checked today. The README's command row also still described the candidate scan as `core/` and `test-servers/src` only, omitting the `vitest.shared.mts` the guard now includes; it also never mentioned that an allowlisted package is tolerated only within a major. Both stated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- README.md | 2 +- scripts/verify-dep-lockstep.mjs | 24 ++++++++++++++++++++---- scripts/verify-dep-lockstep.test.mjs | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fef78256..cf163f54 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | | `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | -| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles `core/` and `test-servers/src` — which resolve from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist. Runs in `validate`. | +| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what those shared sources import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. | | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 2e487fd8..0a328e44 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -130,11 +130,27 @@ export function packageNameOf(specifier) { // `tools`…" would otherwise enter the candidate set as a package named // `tools`. Inert today, but a prose word that collides with a real package // name would silently widen the set. +// +// Whitespace between tokens is really *trivia*: TypeScript allows a comment +// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")` +// and `from /* why */ "pkg"` are valid and must still be seen (Copilot, +// #1962). `TRIVIA` stands in for `\s*` at every such position. +const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/)*`; const SPECIFIER_FORMS = [ - /\bfrom\s*["']([^"']+)["']/g, // import … from "x" / export … from "x" - /\bimport\s+["']([^"']+)["']/g, // side-effect import "x" - /\bimport\s*\(\s*["'`]([^"'`]+)["'`]/g, // dynamic import("x"[, opts]) - /\brequire\s*\(\s*["'`]([^"'`]+)["'`]/g, // import x = require("x"), require("x") + // import … from "x" / export … from "x" + new RegExp(String.raw`\bfrom${TRIVIA}["']([^"']+)["']`, "g"), + // side-effect import "x" + new RegExp(String.raw`\bimport${TRIVIA}["']([^"']+)["']`, "g"), + // dynamic import("x"[, opts]) + new RegExp( + String.raw`\bimport${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, + "g", + ), + // import x = require("x"), require("x") + new RegExp( + String.raw`\brequire${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, + "g", + ), ]; /** diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 55704578..6d0840d7 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -100,6 +100,24 @@ test("importedPackageNames: CommonJS and awkward dynamic-import forms (Copilot, ]); }); +test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () => { + // TypeScript allows a comment anywhere whitespace is legal, so all of these + // are valid imports. Missing one is the dangerous direction: the package + // never enters the candidate set and its skew passes the guard silently. + const source = ` + import { a } from /* explanation */ "express"; + const b = await import(/* webpackIgnore: true */ "undici"); + const c = require(/* lazy */ "yaml"); + import /* side effect */ "pino"; + `; + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "express", + "pino", + "undici", + "yaml", + ]); +}); + test("importedPackageNames: the three specifier forms that introduce types", () => { const source = ` import { z } from "zod/v4"; From 2f1d97e7bbf4dc38de7cf0f47bab8fa74e202e07 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 21:13:56 -0400 Subject: [PATCH 18/73] chore(deps): treat line comments as trivia too in the import scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's fifth pass of #1962. `TRIVIA` covered block comments but not `//`, despite the contract right above it saying a comment is legal wherever whitespace is — so `import(// lazy\n"pkg")`, `require(// lazy\n"pkg")`, and `from // reason\n"pkg"` were stepped over, and such a package would never enter the candidate set. Same silent-miss direction as the block-comment gap. Both comment syntaxes are now covered. The line-comment branch runs to end-of-line only; the newline itself is matched by TRIVIA's `\s` branch. Regression test added alongside the block-comment one. The derived set is unchanged at 18 real names — widening trivia does not admit prose, since a specifier must still follow immediately and `packageNameOf` rejects anything not shaped like one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 10 ++++++---- scripts/verify-dep-lockstep.test.mjs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 0a328e44..5d29497b 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -132,10 +132,12 @@ export function packageNameOf(specifier) { // name would silently widen the set. // // Whitespace between tokens is really *trivia*: TypeScript allows a comment -// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")` -// and `from /* why */ "pkg"` are valid and must still be seen (Copilot, -// #1962). `TRIVIA` stands in for `\s*` at every such position. -const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/)*`; +// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")`, +// `from /* why */ "pkg"`, and the line-comment forms (`import(// lazy\n"pkg")`) +// are all valid and must still be seen (Copilot, #1962). `TRIVIA` stands in for +// `\s*` at every such position and covers both comment syntaxes. A line comment +// runs to end-of-line only — the newline itself is matched by the `\s` branch. +const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/|//[^\n]*)*`; const SPECIFIER_FORMS = [ // import … from "x" / export … from "x" new RegExp(String.raw`\bfrom${TRIVIA}["']([^"']+)["']`, "g"), diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 6d0840d7..eee81432 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -118,6 +118,25 @@ test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () ]); }); +test("importedPackageNames: line-comment trivia, not just block (Copilot, #1962)", () => { + // `//` runs to end-of-line and is legal in every position a block comment is, + // so these are valid imports too. The newline is matched by TRIVIA's `\\s` + // branch rather than by the line-comment branch. + const source = [ + "import { a } from // reason", + ' "express";', + "const b = await import(// lazy", + ' "undici");', + "const c = require(// lazy", + ' "yaml");', + ].join("\n"); + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "express", + "undici", + "yaml", + ]); +}); + test("importedPackageNames: the three specifier forms that introduce types", () => { const source = ` import { z } from "zod/v4"; From fcd45037c9c012486f548ed43265c2319d79340a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 21:24:55 -0400 Subject: [PATCH 19/73] chore(deps): extract specifiers with ts.preProcessFile; document the transitive boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's sixth pass of #1962. Take the TypeScript-parser suggestion, which was raised across three rounds and was right. The regex scan got both directions wrong and every widening traded one failure for the other: it *missed* valid syntax (`import x = require(…)`, an import-attributes argument, a comment between tokens — each a silent miss, so the package never entered the candidate set and its skew passed), and it *invented* names from prose, since `// adapted from "react"` is indistinguishable from an import to a pattern that cannot tell code from a comment. The earlier prose test only passed because `cwd omitted` isn't a valid package name. `ts.preProcessFile` is the cheap answer I'd missed while arguing against the full AST: TypeScript's lightweight pre-parse scanner, no parse tree and no type checking, purpose-built to return module specifiers. It handles every import form, trivia, strings, and regex literals, and never reads a comment as code. typescript resolves from `clients/web`, which already carries it, via the `createRequire` base `smoke-web-browser.mjs` established — a bare import would resolve relative to `scripts/`, not the cwd. The derived set drops 18 → 17 real names: every genuine package is retained and the one prose artifact is gone. Regression tests now use *real* package names in comments and strings, the case the old test couldn't cover. Also records the KNOWN BOUNDARY the review's other finding identified. Candidates cover what the shared sources name directly, so a package reaching the program only through another package's `.d.ts` is invisible — `@modelcontextprotocol/sdk` is the live example, skewed 1.29.0 vs 1.30.0 and present in web's program from both installs, never written in first-party code. Closing it properly changes what the guard measures, so it is tracked as #1965, with both candidate derivations measured there: a lockfile closure is unusable (155 packages, 25 skewed, nearly all irrelevant tooling, and it misses the SDK anyway), while `tsc --listFilesOnly` is correct and small (15 for clients/web). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 99 +++++++++++++++------------- scripts/verify-dep-lockstep.test.mjs | 26 ++++---- 2 files changed, 66 insertions(+), 59 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 5d29497b..4eb32d9a 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -27,10 +27,25 @@ // allowlist of packages verified to tolerate it (below). A dependency that // starts skewing therefore fails `validate` and forces a decision, rather than // surfacing months later as an unexplained OOM. +// +// KNOWN BOUNDARY (#1965): the candidate set covers packages the shared sources +// name *directly*. A package whose declarations reach the program only through +// another package's `.d.ts` is invisible here — `@modelcontextprotocol/sdk` is +// the live example, skewed root 1.29.0 vs `clients/web` 1.30.0 and present in +// web's program from both installs, yet never written in first-party code +// (the shared sources import the split `@modelcontextprotocol/client|core|…`). +// Two derivations were measured for closing this. A lockfile dependency +// closure is unusable — 155 packages, 25 of them skewed, nearly all irrelevant +// tooling (`chai`, `qs`, `iconv-lite`) — and it misses the SDK anyway. Reading +// what actually lands in each program (`tsc --listFilesOnly`, keeping packages +// present under two install roots) is both correct and small: 15 for +// `clients/web`, ~10 once nested duplicates are dropped. That is the right +// derivation and is tracked separately, since it changes what the guard +// measures and surfaces skews needing their own decisions. import { readFileSync, existsSync, readdirSync } from "node:fs"; import { execFileSync } from "node:child_process"; -import { builtinModules } from "node:module"; +import { builtinModules, createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { rootReachesScript } from "./lib/npm-scripts.mjs"; @@ -116,59 +131,51 @@ export function packageNameOf(specifier) { return name; } -// Every form that can introduce a dependency's *types* (Copilot, #1962). -// `import x = require("pkg")` and a bare `require("pkg")` matter because the -// shared trees may hold `.cts` sources, where that is the ordinary import -// syntax; and neither call form requires the closing paren, so an import -// attributes argument (`import("pkg", { with: … })`) can't hide the specifier. +// Specifiers are extracted with TypeScript's own `preProcessFile` rather than +// by regex (Copilot, #1962 — raised across three review rounds, and correctly). +// A regex scan gets both directions wrong: it *misses* valid syntax (an +// `import x = require(…)` in a `.cts`, an import-attributes argument, a comment +// between tokens — each a silent miss, so the package never enters the +// candidate set and its skew passes), and it *invents* names from prose, since +// `// adapted from "react"` is indistinguishable from an import to a pattern +// that can't tell code from a comment. Every widening of the regex traded one +// of those failures for the other. // -// Backticks are accepted ONLY in the call forms, where a static template -// literal is legal. A `from` clause requires a string literal, so allowing -// backticks there would buy nothing while reopening the prose hazard the -// anchored specifier pattern exists to close — inline code in a comment is -// written with backticks throughout this codebase, and "…derived from -// `tools`…" would otherwise enter the candidate set as a package named -// `tools`. Inert today, but a prose word that collides with a real package -// name would silently widen the set. +// `preProcessFile` is TypeScript's lightweight pre-parse scanner — not a full +// parse and no type checking — and it is exactly built for this: it returns +// every module specifier, handling all import forms, trivia, strings, and +// regex literals correctly, and it never sees a comment as code. // -// Whitespace between tokens is really *trivia*: TypeScript allows a comment -// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")`, -// `from /* why */ "pkg"`, and the line-comment forms (`import(// lazy\n"pkg")`) -// are all valid and must still be seen (Copilot, #1962). `TRIVIA` stands in for -// `\s*` at every such position and covers both comment syntaxes. A line comment -// runs to end-of-line only — the newline itself is matched by the `\s` branch. -const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/|//[^\n]*)*`; -const SPECIFIER_FORMS = [ - // import … from "x" / export … from "x" - new RegExp(String.raw`\bfrom${TRIVIA}["']([^"']+)["']`, "g"), - // side-effect import "x" - new RegExp(String.raw`\bimport${TRIVIA}["']([^"']+)["']`, "g"), - // dynamic import("x"[, opts]) - new RegExp( - String.raw`\bimport${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, - "g", - ), - // import x = require("x"), require("x") - new RegExp( - String.raw`\brequire${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, - "g", - ), -]; +// typescript is resolved from `clients/web`, which already carries it; the root +// has no TS dependency of its own. The `createRequire` base is load-bearing — +// a bare `import("typescript")` would resolve relative to `scripts/`, not the +// cwd (the same reason `smoke-web-browser.mjs` resolves playwright this way). +let tsCache; +function typescript() { + if (!tsCache) { + const require_ = createRequire( + path.join(repoRoot, "clients", "web", "package.json"), + ); + tsCache = require_("typescript"); + } + return tsCache; +} /** * Every third-party package name imported by a blob of TypeScript source. - * Deliberately a regex scan rather than a parse: it only needs to over- rather - * than under-approximate, since a name absent from every lockfile contributes - * nothing downstream (`@inspector/core` is a build-time alias, not a package, - * and drops out that way). + * Over-approximating is safe (a name absent from every lockfile contributes + * nothing downstream — `@inspector/core` is a build-time alias, not a package, + * and drops out that way); under-approximating is not, since a missed package + * never enters the candidate set and its skew would pass silently. */ export function importedPackageNames(source) { const names = new Set(); - for (const re of SPECIFIER_FORMS) { - for (const m of source.matchAll(re)) { - const name = packageNameOf(m[1]); - if (name) names.add(name); - } + // (source, readImportFiles, detectJavaScriptImports) — the latter two make it + // report `require(…)` and dynamic imports as well as static ones. + const { importedFiles } = typescript().preProcessFile(source, true, true); + for (const { fileName } of importedFiles) { + const name = packageNameOf(fileName); + if (name) names.add(name); } return names; } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index eee81432..6fd9ecac 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -155,26 +155,26 @@ test("importedPackageNames: the three specifier forms that introduce types", () ]); }); -test("importedPackageNames: prose after `from` is not an import", () => { - // The scan over-approximates on purpose, but must not invent package names - // out of comment text — a bogus name absent from every lockfile is inert, - // yet a *plausible* one would silently widen the candidate set. +test("importedPackageNames: prose in comments never becomes a package (Copilot, #1962)", () => { + // The regex scan this replaced could not tell code from a comment, so + // `// adapted from "react"` added `react` to the candidate set — and if that + // installed package were skewed, an unrelated comment would fail `validate`. + // These use REAL package names, which is the case the old prose test missed: + // it only passed because `cwd omitted` isn't a valid package name. const source = ` - // Resolved relative to the runner dir, not from "cwd omitted" by the caller. - /** Reads the manifest from "file:" URLs. */ + // adapted from "react" + /** Mirrors the behavior of "express", see require("yaml") below. */ + /** The excluded set derived from \\\`hono\\\`-style paths. */ + // const disabled = await import("undici"); import { z } from "zod"; `; assert.deepEqual([...importedPackageNames(source)], ["zod"]); }); -test("importedPackageNames: a backticked `from` in prose is not an import", () => { - // Backticks are legal in the *call* forms (a static template literal) but - // never after `from`, which requires a string literal. Accepting them there - // would sweep in inline code from comments — this codebase writes it with - // backticks throughout — and a prose word colliding with a real package name - // would silently widen the candidate set. +test("importedPackageNames: a specifier inside a string literal is not an import", () => { const source = ` - /** The excluded set derived from \\\`tools\\\`, keyed off \\\`express\\\`-style paths. */ + const msg = 'run require("chokidar") to load it'; + const re = /"jose"/; import { z } from "zod"; `; assert.deepEqual([...importedPackageNames(source)], ["zod"]); From 3638185e0ffa489419b6e4247c1431374ea500f0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 21:33:37 -0400 Subject: [PATCH 20/73] chore(deps): fail with a cause when typescript can't be resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard resolves typescript from `clients/web`. The realistic way for that to fail is a root install run with INSPECTOR_SKIP_CLIENT_INSTALL=1, which leaves `clients/web/node_modules` empty — and a bare MODULE_NOT_FOUND stack gives no hint that the fix is `npm install` at the root. Throw with that guidance and the original error as `cause`. Deliberately not a silent skip: an unrun guard guards nothing, which is the failure mode the sibling-vouch ring exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 4eb32d9a..8d20c206 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -156,7 +156,20 @@ function typescript() { const require_ = createRequire( path.join(repoRoot, "clients", "web", "package.json"), ); - tsCache = require_("typescript"); + try { + tsCache = require_("typescript"); + } catch (cause) { + // Fail with the cause, not a bare MODULE_NOT_FOUND: the realistic way to + // get here is a root install run with INSPECTOR_SKIP_CLIENT_INSTALL=1, + // which leaves `clients/web/node_modules` empty. Silently skipping the + // check instead would be worse — an unrun guard guards nothing. + throw new Error( + "verify:dep-lockstep — could not resolve `typescript` from clients/web. " + + "Run `npm install` at the repo root (the postinstall cascade installs each client); " + + "if you set INSPECTOR_SKIP_CLIENT_INSTALL=1, this guard cannot run.", + { cause }, + ); + } } return tsCache; } From 48d74ba423e83b8d6ad3f207913e42bfa055c28f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 21:41:31 -0400 Subject: [PATCH 21/73] chore(deps): check each shared source contributed, not the aggregate count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's eighth pass of #1962. The no-op guard tested only `files.length === 0`, which stopped meaning anything once `SHARED_SOURCE_FILES` was added: `vitest.shared.mts` alone keeps the total nonzero, so a moved or renamed `core/` would leave the guard deriving candidates from a near-empty set and passing on skew it exists to catch. `sourcesWithNoFiles` now requires every configured dir and every named file to have contributed, and names the ones that didn't. Directory matching is on a path separator, so a prefix sibling (`core-internal/`) can't vouch for a renamed `core/`. Also drops two test comments left stale by the move to `ts.preProcessFile` — they still described the deleted TRIVIA regex. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 31 +++++++++++++++++-- scripts/verify-dep-lockstep.test.mjs | 46 ++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 8d20c206..de4cccf0 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -279,6 +279,23 @@ export function isSharedSourceFile(file) { return SHARED_SOURCE_DIRS.some((dir) => file.startsWith(`${dir}/`)); } +/** + * Which configured shared sources contributed no file to `files`. Each dir and + * each individually-named file must be represented; an aggregate count can't + * see one of them going missing, because the others keep the total nonzero. + */ +export function sourcesWithNoFiles( + files, + dirs = SHARED_SOURCE_DIRS, + named = SHARED_SOURCE_FILES, +) { + const missingDirs = dirs.filter( + (dir) => !files.some((f) => f.startsWith(`${dir}/`)), + ); + const missingNamed = named.filter((name) => !files.includes(name)); + return [...missingDirs, ...missingNamed]; +} + /** Tracked TypeScript files under the shared first-party source trees. */ function sharedSourceFiles() { const out = execFileSync( @@ -334,9 +351,19 @@ export function main() { } const files = sharedSourceFiles(); - if (files.length === 0) { + // Per-source, not an aggregate count (Copilot, #1962): `vitest.shared.mts` + // alone keeps the total nonzero, so a moved or renamed `core/` would leave + // the guard checking a near-empty candidate set and passing. Every configured + // source must contribute, or the enumeration is broken. + const empty = sourcesWithNoFiles(files); + if (empty.length > 0) { console.error( - `verify:dep-lockstep — found no tracked sources under ${SHARED_SOURCE_DIRS.join(", ")}. The guard would check nothing; fix the enumeration.`, + `verify:dep-lockstep — ${empty.length} configured shared source(s) matched no tracked file:\n`, + ); + for (const source of empty) console.error(` ${source}`); + console.error( + "\nThe guard would derive its candidates from an incomplete set and pass on skew it should catch." + + "\nA path was moved or renamed — fix SHARED_SOURCE_DIRS / SHARED_SOURCE_FILES in this file.", ); process.exit(1); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 6fd9ecac..89268ee4 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -13,6 +13,7 @@ import { majorOf, packageNameOf, partitionSkew, + sourcesWithNoFiles, topLevelLockVersions, } from "./verify-dep-lockstep.mjs"; @@ -47,6 +48,46 @@ test("isSharedSourceFile: non-TS files and other trees are excluded", () => { assert.equal(isSharedSourceFile(file), false, file); }); +test("sourcesWithNoFiles: each configured source must contribute (Copilot, #1962)", () => { + const dirs = ["core", "test-servers/src"]; + const named = ["vitest.shared.mts"]; + const complete = [ + "core/mcp/a.ts", + "test-servers/src/b.ts", + "vitest.shared.mts", + ]; + assert.deepEqual(sourcesWithNoFiles(complete, dirs, named), []); + + // The failure an aggregate count can't see: `core/` moved, but the other two + // sources keep `files.length` nonzero, so the guard would derive candidates + // from an incomplete set and pass on skew it should catch. + assert.deepEqual( + sourcesWithNoFiles( + ["test-servers/src/b.ts", "vitest.shared.mts"], + dirs, + named, + ), + ["core"], + ); + assert.deepEqual( + sourcesWithNoFiles(["core/mcp/a.ts", "test-servers/src/b.ts"], dirs, named), + ["vitest.shared.mts"], + ); + assert.deepEqual(sourcesWithNoFiles([], dirs, named), [ + "core", + "test-servers/src", + "vitest.shared.mts", + ]); +}); + +test("sourcesWithNoFiles: a prefix sibling does not vouch for a dir", () => { + // `core-internal/` starts with `core` but is not it — the boundary check has + // to be on a path separator, or a renamed dir would look present. + assert.deepEqual(sourcesWithNoFiles(["core-internal/a.ts"], ["core"], []), [ + "core", + ]); +}); + test("packageNameOf: bare names, scopes, and subpaths", () => { const cases = [ ["zod", "zod"], @@ -120,8 +161,7 @@ test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () test("importedPackageNames: line-comment trivia, not just block (Copilot, #1962)", () => { // `//` runs to end-of-line and is legal in every position a block comment is, - // so these are valid imports too. The newline is matched by TRIVIA's `\\s` - // branch rather than by the line-comment branch. + // so a specifier can sit on the next line and these are still valid imports. const source = [ "import { a } from // reason", ' "express";', @@ -137,7 +177,7 @@ test("importedPackageNames: line-comment trivia, not just block (Copilot, #1962) ]); }); -test("importedPackageNames: the three specifier forms that introduce types", () => { +test("importedPackageNames: static, side-effect, and dynamic forms; builtins and relatives dropped", () => { const source = ` import { z } from "zod/v4"; export type { Foo } from '@modelcontextprotocol/core'; From 2f2d545751fee1e9710d26b460e58411896808fb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 21:48:13 -0400 Subject: [PATCH 22/73] docs: narrow the dep-lockstep summaries to the invariant it actually proves From Copilot's ninth pass of #1962. The one-line descriptions in AGENTS.md and the README claimed "no install-crossing dependency resolves to two different versions", which is stronger than the gate: candidates cover only packages the shared sources name directly, and #1965 records a live counterexample (`@modelcontextprotocol/sdk`, skewed across two install roots while the guard stays green). The boundary was already documented at length on the guard and in the AGENTS.md rule; the summaries just hadn't been narrowed to match, which is how a doc ends up asserting a guarantee nobody implemented. Both now say "directly imported" and point at #1965. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- AGENTS.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fcb3ab9..3da1011e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,7 +448,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no install-crossing dependency resolves to two different versions), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no dependency *directly imported* by the shared sources resolves to two different versions across installs; transitive-only declarations are a known boundary, #1965), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). diff --git a/README.md b/README.md index cf163f54..126c76a0 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | Script | What it does | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no install-crossing dependency skews) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency the shared sources directly import skews across installs) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. 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 and covers the shared `core/` runtime (including `core/json` and `core/client`). | | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | From 32c827f0895b3db0d3948d73158dc741b3576d4a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 21:55:49 -0400 Subject: [PATCH 23/73] test(scripts): exercise the dep-lockstep guard's executable path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's tenth pass of #1962. The suite covered only pure helpers, so a regression in source enumeration, install discovery, lockfile loading, the sibling-guard vouch, or the nonzero exit on real skew would have left it green — and the negative cases had only ever been run by hand, once per review round. Five end-to-end cases build a throwaway repo and run the guard as a subprocess: aligned installs exit 0; a skewed one exits 1 naming the package and every holder while leaving the agreeing package unreported; a package imported only by `test-servers/src` still fails (so dropping the second shared tree can't pass); a shared source matching no tracked file fails; and a `validate` chain that stopped running `verify:format-coverage` fails the vouch even when the versions are fine. The fixture must be realpath'd. On macOS `tmpdir()` is `/var/…`, a symlink to `/private/var/…`, and the guard runs `main()` only when `import.meta.url` (the resolved path) matches `process.argv[1]` — so launching it via the unresolved path loads the module and does nothing, exiting 0 silently. That is how all five first "passed" as no-ops, and the comment records it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.main.test.mjs | 203 ++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 scripts/verify-dep-lockstep.main.test.mjs diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs new file mode 100644 index 00000000..6321d24e --- /dev/null +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -0,0 +1,203 @@ +// End-to-end tests for the dep-lockstep guard's executable path (Copilot, +// #1962). The sibling tests cover the pure helpers; nothing exercised `main()`, +// so a regression in source enumeration, install discovery, lockfile loading, +// the sibling-guard vouch, or the nonzero exit on real skew would have left the +// whole suite green. +// +// Each case builds a throwaway repo — the guard derives its root from its own +// file location, so the script is copied into the fixture rather than pointed +// at one — `git add`s it (the enumeration is `git ls-files`, which reads the +// index), and runs the guard as a subprocess to assert the exit status and +// message. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(scriptsDir, ".."); + +/** The real typescript install, symlinked into each fixture so the guard resolves it. */ +const typescriptDir = path.dirname( + createRequire(path.join(repoRoot, "clients", "web", "package.json")).resolve( + "typescript/package.json", + ), +); + +const lock = (deps) => ({ + lockfileVersion: 3, + packages: Object.fromEntries( + Object.entries(deps).map(([name, version]) => [ + `node_modules/${name}`, + { version }, + ]), + ), +}); + +/** + * Build a fixture repo: shared sources importing `zod` and `express`, a root + * install and one client install, and the guard itself. `rootDeps`/`webDeps` + * decide whether the two installs agree. + */ +function makeFixture({ rootDeps, webDeps, scripts }) { + // realpath matters: on macOS `tmpdir()` is `/var/...`, a symlink to + // `/private/var/...`. The guard only runs `main()` when `import.meta.url` + // (always the resolved path) matches `process.argv[1]`, so launching it via + // the unresolved path would load the module and silently do nothing — + // exiting 0 with no output, which every assertion here would misread. + const dir = realpathSync(mkdtempSync(path.join(tmpdir(), "dep-lockstep-"))); + const write = (rel, contents) => { + mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true }); + writeFileSync( + path.join(dir, rel), + typeof contents === "string" + ? contents + : JSON.stringify(contents, null, 2), + ); + }; + + write("package.json", { + name: "fixture", + scripts: scripts ?? { + validate: "npm run verify:format-coverage && npm run verify:dep-lockstep", + "verify:format-coverage": "node scripts/verify-format-coverage.mjs", + "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", + }, + }); + write("core/client.ts", 'import { z } from "zod";\nexport const s = z;\n'); + write( + "test-servers/src/server.ts", + 'import express from "express";\nexport const app = express;\n', + ); + write( + "vitest.shared.mts", + 'import path from "node:path";\nexport default path;\n', + ); + write("package-lock.json", lock(rootDeps)); + write("clients/web/package.json", { name: "web" }); + write("clients/web/package-lock.json", lock(webDeps)); + + // The guard resolves its repo root from its own location, so it has to live + // inside the fixture; `lib/npm-scripts.mjs` comes along as its import. + mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); + for (const rel of [ + "verify-dep-lockstep.mjs", + path.join("lib", "npm-scripts.mjs"), + ]) + cpSync(path.join(scriptsDir, rel), path.join(dir, "scripts", rel)); + + mkdirSync(path.join(dir, "node_modules"), { recursive: true }); + symlinkSync( + typescriptDir, + path.join(dir, "node_modules", "typescript"), + "dir", + ); + + execFileSync("git", ["init", "-q"], { cwd: dir }); + execFileSync("git", ["add", "-A"], { cwd: dir }); + return dir; +} + +function runGuard(dir) { + const r = spawnSync( + process.execPath, + [path.join(dir, "scripts", "verify-dep-lockstep.mjs")], + { cwd: dir, encoding: "utf8" }, + ); + return { status: r.status, out: `${r.stdout}${r.stderr}` }; +} + +/** Run `fn` against a fresh fixture, always cleaning the temp dir up. */ +function withFixture(options, fn) { + const dir = makeFixture(options); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const ALIGNED = { zod: "4.4.3", express: "5.2.1" }; + +test("main: exits 0 when every install agrees", () => { + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 0, out); + assert.match(out, /verify:dep-lockstep — OK/); + // Both shared trees and the named shared file contributed their imports. + assert.match(out, /2 install-crossing dependencies/); + }); +}); + +test("main: exits 1 and names the skewed package and every holder", () => { + withFixture( + { rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /\bzod\b/); + assert.match(out, /4\.3\.6\s+\(\.\)/); + assert.match(out, /4\.4\.3\s+\(clients\/web\)/); + // `express` agrees, so it must not be reported. + assert.doesNotMatch(out, /^\s+express$/m); + }, + ); +}); + +test("main: a package imported only by test-servers/src is still checked", () => { + // Guards the enumeration of the *second* shared tree: if only `core/` were + // scanned, this skew would pass. + withFixture( + { rootDeps: { ...ALIGNED, express: "5.0.0" }, webDeps: ALIGNED }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /\bexpress\b/); + }, + ); +}); + +test("main: exits 1 when a configured shared source matches no file", () => { + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + // Drop `core/` from the index — the other sources keep the file count + // nonzero, which is exactly what the old aggregate check missed. + execFileSync("git", ["rm", "-r", "-q", "--cached", "core"], { cwd: dir }); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /matched no tracked file/); + assert.match(out, /^\s+core$/m); + }); +}); + +test("main: exits 1 when the root validate no longer runs the sibling guard", () => { + withFixture( + { + rootDeps: ALIGNED, + webDeps: ALIGNED, + // `verify:format-coverage` dropped from the chain: the vouch must fail + // even though the dependency versions themselves are fine. + scripts: { + validate: "npm run verify:dep-lockstep", + "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", + }, + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no longer runs `verify:format-coverage`/); + }, + ); +}); From af63a078179c3bb51be7f3667928db5e4341785a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 22:02:56 -0400 Subject: [PATCH 24/73] chore(deps): reject an unreadable lockfile instead of failing open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's eleventh pass of #1962, and the most consequential kind of bug for a gate: it failed *open*. `topLevelLockVersions` returns an empty map for anything without a v2+ `packages` table — a lockfileVersion 1 file, or a malformed one. `main()` fed that straight into the comparison, so such an install contributed no holders at all, and a genuine skew among the remaining installs was reported as aligned. The guard would have said OK to exactly what it exists to catch. `hasReadableLockShape` now gates every discovered lockfile before any comparison, requiring a `packages` object carrying the `""` root entry npm always writes — "has a packages key" is not enough. An unreadable one is named and exits 1, telling you to regenerate it. A JSON parse failure likewise reports the file rather than dying on a bare SyntaxError with no path in it. The empty-map behavior of the pure helper is kept and its test now says why it is safe: the shape check rejects those inputs before they can reach `findSkew`. Three regression cases: a v1 lockfile in a fixture that IS skewed (so failing open would show as a pass), a `packages` table with no root entry, and the unit table for the predicate. Fixtures now write the `""` root entry, matching real npm output. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.main.test.mjs | 62 ++++++++++++++++++++--- scripts/verify-dep-lockstep.mjs | 60 +++++++++++++++++++--- scripts/verify-dep-lockstep.test.mjs | 29 +++++++++++ 3 files changed, 137 insertions(+), 14 deletions(-) diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index 6321d24e..50db6b99 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -37,14 +37,18 @@ const typescriptDir = path.dirname( ), ); +/** A lockfileVersion 3 lockfile, including the `""` root entry npm always writes. */ const lock = (deps) => ({ lockfileVersion: 3, - packages: Object.fromEntries( - Object.entries(deps).map(([name, version]) => [ - `node_modules/${name}`, - { version }, - ]), - ), + packages: { + "": { name: "fixture" }, + ...Object.fromEntries( + Object.entries(deps).map(([name, version]) => [ + `node_modules/${name}`, + { version }, + ]), + ), + }, }); /** @@ -52,7 +56,7 @@ const lock = (deps) => ({ * install and one client install, and the guard itself. `rootDeps`/`webDeps` * decide whether the two installs agree. */ -function makeFixture({ rootDeps, webDeps, scripts }) { +function makeFixture({ rootDeps, webDeps, scripts, rawWebLock }) { // realpath matters: on macOS `tmpdir()` is `/var/...`, a symlink to // `/private/var/...`. The guard only runs `main()` when `import.meta.url` // (always the resolved path) matches `process.argv[1]`, so launching it via @@ -88,7 +92,7 @@ function makeFixture({ rootDeps, webDeps, scripts }) { ); write("package-lock.json", lock(rootDeps)); write("clients/web/package.json", { name: "web" }); - write("clients/web/package-lock.json", lock(webDeps)); + write("clients/web/package-lock.json", rawWebLock ?? lock(webDeps)); // The guard resolves its repo root from its own location, so it has to live // inside the fixture; `lib/npm-scripts.mjs` comes along as its import. @@ -182,6 +186,48 @@ test("main: exits 1 when a configured shared source matches no file", () => { }); }); +test("main: exits 1 on a lockfile it cannot read, rather than failing open (Copilot, #1962)", () => { + // A v1 lockfile has `dependencies` and no `packages` table. Treating it as an + // install that simply holds nothing would leave the *root's* zod unopposed + // and the skew below reported as aligned — the gate failing open. + withFixture( + { + rootDeps: { ...ALIGNED, zod: "4.3.6" }, + webDeps: ALIGNED, + rawWebLock: { + lockfileVersion: 1, + dependencies: { zod: { version: "4.4.3" } }, + }, + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /not in a readable format/); + assert.match(out, /clients\/web\/package-lock\.json/); + }, + ); +}); + +test("main: exits 1 on a lockfile with a `packages` table but no root entry", () => { + // The shape check is not just "has a packages key" — a table without the + // `""` root npm always writes is not a lockfile this guard can trust. + withFixture( + { + rootDeps: ALIGNED, + webDeps: ALIGNED, + rawWebLock: { + lockfileVersion: 3, + packages: { "node_modules/zod": { version: "4.4.3" } }, + }, + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /not in a readable format/); + }, + ); +}); + test("main: exits 1 when the root validate no longer runs the sibling guard", () => { withFixture( { diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index de4cccf0..ab928835 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -209,6 +209,25 @@ export function topLevelLockVersions(lock) { return versions; } +/** + * Whether a parsed lockfile has the shape this guard can read: a + * `lockfileVersion` 2+ `packages` table, keyed by install path with `""` for + * the root project. + * + * This is checked rather than tolerated because the gate is deny-by-default and + * `topLevelLockVersions` returns an empty map for anything else. An unreadable + * lockfile would otherwise contribute no holders, and a real skew among the + * remaining installs would be reported as aligned — the gate failing *open*, + * which is the one way it must never fail (Copilot, #1962). A v1 lockfile + * (`dependencies` only, no `packages`) lands here too, correctly: this guard + * cannot read it, so it must say so rather than skip the install. + */ +export function hasReadableLockShape(lock) { + const packages = lock?.packages; + if (typeof packages !== "object" || packages === null) return false; + return Object.prototype.hasOwnProperty.call(packages, ""); +} + /** * Find candidate packages that resolve to more than one version across the * installs. `installs` is an array of `{ dir, versions }`. Returns one entry per @@ -375,13 +394,42 @@ export function main() { } const dirs = installDirs(); - const installs = dirs.map((dir) => ({ + const locks = dirs.map((dir) => { + const file = path.join(repoRoot, dir, "package-lock.json"); + let lock; + try { + lock = JSON.parse(readFileSync(file, "utf8")); + } catch (cause) { + // Unparseable is the same failure as unreadable — say which file, rather + // than dying on a raw SyntaxError with no path in it. + throw new Error( + `verify:dep-lockstep — could not parse ${dir}/package-lock.json.`, + { cause }, + ); + } + return { dir, lock }; + }); + + // Refuse to compare against a lockfile whose shape we can't read, instead of + // treating it as an install that holds nothing — see `hasReadableLockShape`. + const unreadable = locks.filter(({ lock }) => !hasReadableLockShape(lock)); + if (unreadable.length > 0) { + console.error( + `verify:dep-lockstep — ${unreadable.length} lockfile(s) are not in a readable format:\n`, + ); + for (const { dir } of unreadable) + console.error(` ${dir}/package-lock.json`); + console.error( + "\nThis guard reads the `packages` table of a lockfileVersion 2+ lockfile. Without it the install" + + "\ncontributes no versions, so a real skew among the others would be reported as aligned — the gate" + + "\nfailing open. Regenerate the lockfile with a current npm (`npm install`).", + ); + process.exit(1); + } + + const installs = locks.map(({ dir, lock }) => ({ dir, - versions: topLevelLockVersions( - JSON.parse( - readFileSync(path.join(repoRoot, dir, "package-lock.json"), "utf8"), - ), - ), + versions: topLevelLockVersions(lock), })); const { failures, ignored } = partitionSkew(findSkew(candidates, installs)); diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 89268ee4..79974404 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -8,6 +8,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { findSkew, + hasReadableLockShape, importedPackageNames, isSharedSourceFile, majorOf, @@ -243,10 +244,38 @@ test("topLevelLockVersions: nested duplicates are ignored", () => { }); test("topLevelLockVersions: a malformed or empty lockfile yields nothing", () => { + // Safe as a pure helper *because* `hasReadableLockShape` rejects these before + // any comparison — an empty map reaching `findSkew` is the fail-open path. for (const lock of [undefined, null, {}, { packages: {} }]) assert.equal(topLevelLockVersions(lock).size, 0); }); +test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot, #1962)", () => { + assert.equal( + hasReadableLockShape({ lockfileVersion: 3, packages: { "": {} } }), + true, + ); + assert.equal( + hasReadableLockShape({ + lockfileVersion: 2, + packages: { "": {}, "node_modules/zod": { version: "4.4.3" } }, + }), + true, + ); + const rejected = [ + undefined, + null, + {}, + { packages: null }, + { packages: [] }, // an array has no `""` key + { packages: {} }, // no root entry + { packages: { "node_modules/zod": { version: "4.4.3" } } }, + { lockfileVersion: 1, dependencies: { zod: { version: "4.4.3" } } }, + ]; + for (const lock of rejected) + assert.equal(hasReadableLockShape(lock), false, JSON.stringify(lock)); +}); + test("findSkew: reports a package held at two versions", () => { const installs = [ { dir: ".", versions: new Map([["zod", "4.3.6"]]) }, From 87c1e0542046d9376d08f8d3300ba1a2b76b9d30 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 22:09:42 -0400 Subject: [PATCH 25/73] chore(deps): count triple-slash type references as candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's twelfth pass of #1962. A `/// ` pulls in a package's declarations exactly like an import does, but TypeScript reports it in `preProcessFile`'s `typeReferenceDirectives`, not `importedFiles` — and only the latter was read. A shared source using one could therefore load the root's copy of a package alongside a client's without it ever entering the candidate set, so its skew would pass. Same silent-miss direction as the earlier syntax gaps, through a channel the parser switch didn't cover because I only read one of its two outputs. `typeReferencePackageNames` maps a directive to both candidates, since the name is the *type* name and not the package: `node` resolves to `@types/node`, while a package shipping its own declarations resolves to itself. Returning both over-approximates, the safe direction — whichever isn't installed drops out downstream. Scoped names mangle `@scope/pkg` → `@types/scope__pkg`, TypeScript's convention. `path` references name a file, not a package, and are ignored. The real candidate set is unchanged at 17: no shared source uses a triple-slash reference today. As with the other syntax fixes, this closes the channel against the one that arrives later. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 30 +++++++++++++++++++++-- scripts/verify-dep-lockstep.test.mjs | 36 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index ab928835..22ebb9ad 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -175,7 +175,30 @@ function typescript() { } /** - * Every third-party package name imported by a blob of TypeScript source. + * The package(s) a `/// ` directive can resolve to + * (Copilot, #1962). Such a directive pulls in declarations exactly like an + * import does, but TypeScript reports it separately from `importedFiles`, so + * reading only the latter would let a referenced package skew unseen. + * + * Both candidates are returned because the directive name is the *type* name, + * not the package: `node` resolves to `@types/node`, while a package shipping + * its own declarations resolves to itself. Returning both over-approximates, + * which is the safe direction — whichever isn't installed drops out. A scoped + * name mangles as `@scope/pkg` → `@types/scope__pkg`, TypeScript's convention. + */ +export function typeReferencePackageNames(directive) { + const name = packageNameOf(directive); + if (!name) return []; + const scoped = /^@([^/]+)\/(.+)$/.exec(name); + const typesName = scoped + ? `@types/${scoped[1]}__${scoped[2]}` + : `@types/${name}`; + return [name, typesName]; +} + +/** + * Every third-party package name whose declarations a blob of TypeScript source + * pulls in — via an import of any form, or a triple-slash type reference. * Over-approximating is safe (a name absent from every lockfile contributes * nothing downstream — `@inspector/core` is a build-time alias, not a package, * and drops out that way); under-approximating is not, since a missed package @@ -185,11 +208,14 @@ export function importedPackageNames(source) { const names = new Set(); // (source, readImportFiles, detectJavaScriptImports) — the latter two make it // report `require(…)` and dynamic imports as well as static ones. - const { importedFiles } = typescript().preProcessFile(source, true, true); + const { importedFiles, typeReferenceDirectives } = + typescript().preProcessFile(source, true, true); for (const { fileName } of importedFiles) { const name = packageNameOf(fileName); if (name) names.add(name); } + for (const { fileName } of typeReferenceDirectives ?? []) + for (const name of typeReferencePackageNames(fileName)) names.add(name); return names; } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 79974404..a00ce642 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -16,6 +16,7 @@ import { partitionSkew, sourcesWithNoFiles, topLevelLockVersions, + typeReferencePackageNames, } from "./verify-dep-lockstep.mjs"; test("isSharedSourceFile: all four TS extensions, not just .ts/.tsx", () => { @@ -196,6 +197,41 @@ test("importedPackageNames: static, side-effect, and dynamic forms; builtins and ]); }); +test("importedPackageNames: triple-slash type references count (Copilot, #1962)", () => { + // A `/// ` pulls in declarations exactly like an + // import, but TypeScript reports it in `typeReferenceDirectives`, not + // `importedFiles` — so reading only the latter let a referenced package skew + // unseen. `path` references name a file, not a package, and are ignored. + const source = [ + '/// ', + '/// ', + '/// ', + 'import { z } from "zod";', + ].join("\n"); + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "@types/express", + "@types/node", + "express", + "node", + "zod", + ]); +}); + +test("typeReferencePackageNames: both the bare and the @types form (Copilot, #1962)", () => { + // The directive names a *type*, not a package: `node` resolves to + // `@types/node`, while a package shipping its own declarations resolves to + // itself. Returning both over-approximates, the safe direction — whichever + // isn't installed drops out downstream. + assert.deepEqual(typeReferencePackageNames("node"), ["node", "@types/node"]); + // Scoped names mangle with a double underscore, TypeScript's convention. + assert.deepEqual(typeReferencePackageNames("@scope/pkg"), [ + "@scope/pkg", + "@types/scope__pkg", + ]); + assert.deepEqual(typeReferencePackageNames("./relative"), []); + assert.deepEqual(typeReferencePackageNames(""), []); +}); + test("importedPackageNames: prose in comments never becomes a package (Copilot, #1962)", () => { // The regex scan this replaced could not tell code from a comment, so // `// adapted from "react"` added `react` to the candidate set — and if that From 8a2035c4d1145801914671b48f85d25bebe4a7bf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 22:16:29 -0400 Subject: [PATCH 26/73] test(scripts): cover verify-format-coverage's sibling-guard vouch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's thirteenth pass of #1962. This PR added `verify:dep-lockstep` to the vouch loop in `verify-format-coverage.mjs` without a test for it. The existing tests exercise `rootReachesScript` in isolation and the opposite direction (dep-lockstep vouching for format-coverage), so a typo in a sibling's name here would have left `test:scripts` green while that guard silently stopped being enforced — the same "a gate that stops gating" failure the vouch cycle exists to prevent, one level up. Three cases, reusing the fixture-repo approach: dropping either sibling from `validate` exits 1 naming that sibling, and with both wired no sibling is reported missing. The last asserts on the *reason* rather than the exit status, since the fixture has no client manifests to harvest globs from and so fails later for an unrelated reason. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-format-coverage.main.test.mjs | 97 ++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 scripts/verify-format-coverage.main.test.mjs diff --git a/scripts/verify-format-coverage.main.test.mjs b/scripts/verify-format-coverage.main.test.mjs new file mode 100644 index 00000000..e10130f6 --- /dev/null +++ b/scripts/verify-format-coverage.main.test.mjs @@ -0,0 +1,97 @@ +// Regression tests for `verify-format-coverage`'s sibling-guard vouch (Copilot, +// #1962). The three root guards form a cycle so that dropping any one from +// `validate` is caught by another — but the vouch branch itself had no test, so +// a typo in a sibling's name would leave `test:scripts` green while that guard +// silently stopped being enforced. That is the same "a gate that stops gating" +// failure the cycle exists to prevent, one level up. +// +// The vouch runs before any file enumeration, so the fixture needs only a +// `package.json` and the script. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); + +/** A `validate` chain running each named guard, plus the always-present self. */ +function scriptsRunning(guards) { + const scripts = { + validate: ["verify:format-coverage", ...guards] + .map((g) => `npm run ${g}`) + .join(" && "), + "verify:format-coverage": "node scripts/verify-format-coverage.mjs", + }; + for (const g of guards) scripts[g] = `node scripts/${g.slice(7)}.mjs`; + return scripts; +} + +/** + * Run `verify-format-coverage` in a throwaway repo whose root `validate` runs + * exactly `guards`. realpath'd because the script only executes when + * `import.meta.url` matches `process.argv[1]`, and macOS `tmpdir()` is a + * symlink — see the note in `verify-dep-lockstep.main.test.mjs`. + */ +function runWithGuards(guards) { + const dir = realpathSync(mkdtempSync(path.join(tmpdir(), "format-cov-"))); + try { + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify( + { name: "fixture", scripts: scriptsRunning(guards) }, + null, + 2, + ), + ); + mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); + for (const rel of [ + "verify-format-coverage.mjs", + path.join("lib", "npm-scripts.mjs"), + ]) + cpSync(path.join(scriptsDir, rel), path.join(dir, "scripts", rel)); + execFileSync("git", ["init", "-q"], { cwd: dir }); + execFileSync("git", ["add", "-A"], { cwd: dir }); + + const r = spawnSync( + process.execPath, + [path.join(dir, "scripts", "verify-format-coverage.mjs")], + { cwd: dir, encoding: "utf8" }, + ); + return { status: r.status, out: `${r.stdout}${r.stderr}` }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const BOTH_SIBLINGS = ["verify:typecheck-coverage", "verify:dep-lockstep"]; + +test("vouch: fails when `verify:dep-lockstep` is dropped from validate", () => { + const { status, out } = runWithGuards(["verify:typecheck-coverage"]); + assert.equal(status, 1, out); + assert.match(out, /no longer runs `verify:dep-lockstep`/); +}); + +test("vouch: fails when `verify:typecheck-coverage` is dropped from validate", () => { + const { status, out } = runWithGuards(["verify:dep-lockstep"]); + assert.equal(status, 1, out); + assert.match(out, /no longer runs `verify:typecheck-coverage`/); +}); + +test("vouch: passes when both siblings are still wired", () => { + // The run still fails afterwards — the fixture has no client manifests to + // harvest globs from — so assert on the *reason*, not the exit status: no + // sibling may be reported missing. + const { out } = runWithGuards(BOTH_SIBLINGS); + assert.doesNotMatch(out, /no longer runs/); +}); From e1768e44cebba8c3ca41a0e589e8a70af7c2f6f8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 22:23:32 -0400 Subject: [PATCH 27/73] chore(deps): check the declared lockfileVersion, not just the packages key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's fourteenth pass of #1962. `hasReadableLockShape` and the diagnostic it drives both promise "lockfileVersion 2+", but the check only looked for a `packages` table with a root entry. A file declaring v1 while carrying such a table was accepted, produced an empty version map, and could make a real skew read as aligned once only one other holder remained — the same fail-open the shape check was added to close, through the half of the contract that wasn't enforced. The declared version is now verified: a finite number, 2 or greater. Absent, non-numeric, and declared-v1-with-packages cases are all in the table. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 9 ++++++++- scripts/verify-dep-lockstep.test.mjs | 17 +++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 22ebb9ad..55553375 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -249,7 +249,14 @@ export function topLevelLockVersions(lock) { * cannot read it, so it must say so rather than skip the install. */ export function hasReadableLockShape(lock) { - const packages = lock?.packages; + // The declared version is checked, not just inferred from the presence of a + // `packages` key: this function and the diagnostic it drives both promise + // "lockfileVersion 2+", so a file claiming v1 while carrying a `packages` + // table must be rejected rather than half-trusted (Copilot, #1962). + const version = lock?.lockfileVersion; + if (typeof version !== "number" || !Number.isFinite(version) || version < 2) + return false; + const packages = lock.packages; if (typeof packages !== "object" || packages === null) return false; return Object.prototype.hasOwnProperty.call(packages, ""); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index a00ce642..55ca3b6f 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -302,11 +302,20 @@ test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot undefined, null, {}, - { packages: null }, - { packages: [] }, // an array has no `""` key - { packages: {} }, // no root entry - { packages: { "node_modules/zod": { version: "4.4.3" } } }, + { lockfileVersion: 3, packages: null }, + { lockfileVersion: 3, packages: [] }, // an array has no `""` key + { lockfileVersion: 3, packages: {} }, // no root entry + { + lockfileVersion: 3, + packages: { "node_modules/zod": { version: "4.4.3" } }, + }, { lockfileVersion: 1, dependencies: { zod: { version: "4.4.3" } } }, + // A declared v1 carrying a `packages` table: the version is checked, not + // inferred from the key's presence, so this is rejected rather than + // half-trusted into an empty (fail-open) version map. + { lockfileVersion: 1, packages: { "": {} } }, + { packages: { "": {} } }, // no declared version at all + { lockfileVersion: "3", packages: { "": {} } }, // not a number ]; for (const lock of rejected) assert.equal(hasReadableLockShape(lock), false, JSON.stringify(lock)); From 288adbcfd621a53edc0115653a8d81836a24c750 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 22:31:05 -0400 Subject: [PATCH 28/73] chore(deps): enrol installs by package.json, and fail on a missing lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's fifteenth pass of #1962. `installDirs` filtered on the presence of a lockfile, so a missing one silently removed that install from the comparison rather than failing. For the root — the install every shared source resolves from — that meant the guard could report success from client locks alone, which is the deny-by-default contract inverted. Enrolment is now by `package.json` (an install we are meant to compare), with the root always enrolled, and a missing lockfile is a named, loud failure. A `clients/` directory with no `package.json` is not an install and is skipped, so a stray scratch dir isn't asked for a lockfile it should never have. Three regression cases: a missing client lockfile in a fixture that IS skewed (so failing open would show as a pass), a missing ROOT lockfile, and a stray `clients/` dir that must not be enrolled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.main.test.mjs | 41 +++++++++++++++++++++++ scripts/verify-dep-lockstep.mjs | 34 +++++++++++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index 50db6b99..c00379d7 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -228,6 +228,47 @@ test("main: exits 1 on a lockfile with a `packages` table but no root entry", () ); }); +test("main: exits 1 on an install whose lockfile is missing (Copilot, #1962)", () => { + // Enrolment is by `package.json`, so a missing lockfile is loud rather than a + // silently absent row. The fixture IS skewed, so dropping the install instead + // would leave the remaining holder unopposed and report aligned. + withFixture( + { rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED }, + (dir) => { + rmSync(path.join(dir, "clients", "web", "package-lock.json")); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no lockfile/); + assert.match(out, /clients\/web\/package-lock\.json/); + }, + ); +}); + +test("main: exits 1 when the ROOT lockfile is missing", () => { + // The worst variant: the root is the install every shared source resolves + // from, so omitting it could let the guard pass on client locks alone. + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + rmSync(path.join(dir, "package-lock.json")); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no lockfile/); + assert.match(out, /^\s+\.\/package-lock\.json$/m); + }); +}); + +test("main: a clients/ dir with no package.json is not an install", () => { + // Enrolling a stray directory would demand a lockfile it should never have. + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + mkdirSync(path.join(dir, "clients", "scratch"), { recursive: true }); + writeFileSync( + path.join(dir, "clients", "scratch", "notes.md"), + "scratch\n", + ); + const { status, out } = runGuard(dir); + assert.equal(status, 0, out); + }); +}); + test("main: exits 1 when the root validate no longer runs the sibling guard", () => { withFixture( { diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 55553375..27b5812c 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -377,9 +377,18 @@ function installDirs() { .map((e) => `clients/${e.name}`) .sort() : []; - return ["."] - .concat(clients) - .filter((dir) => existsSync(path.join(repoRoot, dir, "package-lock.json"))); + // Enrolment is by `package.json` — an install we are meant to compare — + // NOT by the presence of a lockfile (Copilot, #1962). Filtering on the + // lockfile made a missing one silently drop that install from the + // comparison; for the root, the install every shared source resolves from, + // that meant the guard could report success from client locks alone. A + // missing lockfile is now a loud failure in `main`, not an absent row. The + // root is always enrolled: it is this repo, so its manifest is a given. + return ["."].concat( + clients.filter((dir) => + existsSync(path.join(repoRoot, dir, "package.json")), + ), + ); } /** @@ -427,6 +436,25 @@ export function main() { } const dirs = installDirs(); + + // A missing lockfile is a failure, not a skipped install: dropping one would + // remove its versions from the comparison and could report a real skew as + // aligned. + const missing = dirs.filter( + (dir) => !existsSync(path.join(repoRoot, dir, "package-lock.json")), + ); + if (missing.length > 0) { + console.error( + `verify:dep-lockstep — ${missing.length} install(s) have a package.json but no lockfile:\n`, + ); + for (const dir of missing) console.error(` ${dir}/package-lock.json`); + console.error( + "\nEvery install must be compared; skipping one could report a real skew as aligned." + + "\nRun `npm install` there, or remove the install if it is no longer part of the repo.", + ); + process.exit(1); + } + const locks = dirs.map((dir) => { const file = path.join(repoRoot, dir, "package-lock.json"); let lock; From 0325d81493eda089afc40af089b3d8028859dfd9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 23:25:33 -0400 Subject: [PATCH 29/73] fix: open the modern listen stream whenever the filter is non-empty (#1920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the modern (2026-07-28) era every server→client notification rides the `subscriptions/listen` stream, but the Inspector only ever opened that stream from a resource-subscribe click. A tools-only server advertising `tools.listChanged` therefore had no path to an open stream, so `notifications/tools/list_changed` could never arrive — even though `buildSubscriptionFilter()` already modelled the opt-in. The trigger now matches the filter it builds: the stream opens (and stays open, and reconnects) whenever the built filter carries anything — subscribed URIs or an enabled list-change opt-in the server advertises. That mirrors the SDK's own `ClientOptions.listChanged` auto-open, which opens on a non-empty config ∩ capability intersection. `connect()` opens it once the handlers are registered; a failure there is handed to the reconnect machinery rather than failing the connect. `ResourceSubscriptionStreamState.active` deliberately keeps its narrower meaning — "at least one URI is subscribed" — because it drives the Subscriptions section's badge, which has nothing to report for a listChanged-only stream. That also preserves the invariant the rest of the file is written against: an empty subscribed set is never announced alongside an `active` stream. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../ResourceControls/ResourceControls.tsx | 4 +- .../inspectorClient-subscriptions-era.test.ts | 218 +++++++++++++++++- core/mcp/inspectorClient.ts | 116 ++++++++-- core/mcp/types.ts | 11 +- 4 files changed, 321 insertions(+), 28 deletions(-) diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index 8d4e9590..adf5aa1a 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -52,7 +52,9 @@ export interface ResourceControlsProps { * Modern-era `subscriptions/listen` stream state (#1630). When `active` * (modern era with at least one subscription) the Subscriptions section shows * a stream-status badge in its panel and a status dot in its header. Legacy - * connections pass `active: false` (or omit it) and see neither. + * connections pass `active: false` (or omit it) and see neither — and so does + * a stream open purely for list-change notifications, which this section has + * nothing to say about (#1920). */ subscriptionStreamState?: ResourceSubscriptionStreamState; /** Negotiated protocol era; gates the modern subscription stream chrome. */ diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index d10610a2..6d182817 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -8,6 +8,7 @@ import { type TestServerHttp, createTestServerInfo, createNumberedResources, + createEchoTool, } from "@modelcontextprotocol/inspector-test-server"; import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; import type { MessageEntry } from "@inspector/core/mcp/types.js"; @@ -23,6 +24,13 @@ describe("resource subscriptions era fork (#1630)", () => { let server: TestServerHttp | null = null; const RESOURCE_URI = "test://resource_0"; + /** + * Every list-change opt-in off. The listen filter is config ∩ capability and + * the SDK server advertises `listChanged` for every list it registers, so this + * is what leaves the filter empty when nothing is subscribed — i.e. the + * pre-#1920 world these lifecycle tests were written against. + */ + const NO_LIST_CHANGED = { tools: false, resources: false, prompts: false }; const RESOURCE_URI_2 = "test://resource_1"; afterEach(async () => { @@ -59,15 +67,28 @@ describe("resource subscriptions era fork (#1630)", () => { return started; } + /** + * @param listChangedNotifications the Inspector-side opt-ins. The SDK server + * advertises `listChanged` for every list it registers, so this — not the + * server config — is how a test gets an *empty* listen filter: the filter is + * config ∩ capability, and with every opt-in off only a subscribed URI can + * open the stream (#1920). + */ async function connect( url: string, era: "legacy" | "modern", + listChangedNotifications?: { + tools?: boolean; + resources?: boolean; + prompts?: boolean; + }, ): Promise<{ connected: InspectorClient; messages: MessageEntry[] }> { const connected = new InspectorClient( { type: "streamable-http", url }, { environment: { transport: createTransportNode }, versionNegotiation: eraToVersionNegotiation(era), + ...(listChangedNotifications ? { listChangedNotifications } : {}), }, ); const messages: MessageEntry[] = []; @@ -125,15 +146,43 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("closes the stream when the last subscription is removed", async () => { + // Nothing else in the filter (no list-change opt-ins), so the last URI + // leaving empties it and the stream closes (#1920). const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected, messages } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); await connected.subscribeToResource(RESOURCE_URI); expect(connected.getResourceSubscriptionStreamState().active).toBe(true); + messages.length = 0; await connected.unsubscribeFromResource(RESOURCE_URI); const streamState = connected.getResourceSubscriptionStreamState(); expect(streamState.active).toBe(false); expect(connected.getSubscribedResources()).toEqual([]); + // No re-listen: there is nothing left to listen for. + expect(methodsSent(messages)).not.toContain("subscriptions/listen"); + }); + + it("keeps the stream open past the last subscription when a listChanged opt-in remains", async () => { + // The other half of the above: with an advertised listChanged the filter + // is still non-empty, so the last unsubscribe re-lists rather than + // closing — and the state goes inactive because the *Subscriptions* + // section has nothing to report, not because the stream is gone (#1920). + const started = await startServer({}); + const { connected, messages } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + + messages.length = 0; + await connected.unsubscribeFromResource(RESOURCE_URI); + + expect(connected.getSubscribedResources()).toEqual([]); + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + const filter = lastListenFilter(messages); + expect(filter?.resourcesListChanged).toBe(true); + expect(filter?.resourceSubscriptions).toBeUndefined(); }); it("re-lists (stream stays open) when one of several URIs is removed", async () => { @@ -197,6 +246,145 @@ describe("resource subscriptions era fork (#1630)", () => { }); }); + // The stream used to be reachable only by subscribing to a resource, so a + // server with no resources could never open it — and its `tools.listChanged` + // notifications, which ride the same stream, could never arrive (#1920). + describe("modern era: opening the stream for listChanged alone (#1920)", () => { + async function startToolsOnlyServer( + listChanged: ServerConfig["listChanged"], + ): Promise { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("tools-only-test", "1.0.0"), + tools: [createEchoTool()], + listChanged, + modern: {}, + }); + await started.start(); + server = started; + return started; + } + + it("opens the stream on connect against a tools-only server", async () => { + const started = await startToolsOnlyServer({ tools: true }); + const { connected, messages } = await connect(started.url, "modern"); + + expect(connected.getCapabilities()?.resources).toBeUndefined(); + expect(methodsSent(messages)).toContain("subscriptions/listen"); + const filter = lastListenFilter(messages); + expect(filter?.toolsListChanged).toBe(true); + // Nothing is subscribed, so the filter carries no URIs at all. + expect(filter?.resourceSubscriptions).toBeUndefined(); + // …and the Subscriptions section still has nothing to report. + expect(connected.getSubscribedResources()).toEqual([]); + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + }); + + it("leaves the stream closed when the opt-in is disabled in config", async () => { + // The filter is config ∩ capability, so turning the handler off in the + // Inspector's own options is enough to keep the stream closed. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + listChangedNotifications: { tools: false }, + }, + ); + const messages: MessageEntry[] = []; + connected.addEventListener("message", (event) => { + messages.push(event.detail); + }); + await connected.connect(); + client = connected; + + expect(methodsSent(messages)).not.toContain("subscriptions/listen"); + }); + + it("opens no stream on the legacy era", async () => { + // Legacy has no `subscriptions/listen` at all — list-change notifications + // arrive on the session's own channel. + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("tools-only-legacy-test", "1.0.0"), + tools: [createEchoTool()], + listChanged: { tools: true }, + }); + await started.start(); + server = started; + const { connected, messages } = await connect(started.url, "legacy"); + + expect(connected.getProtocolEra()).toBe("legacy"); + expect(methodsSent(messages)).not.toContain("subscriptions/listen"); + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + }); + + it("reconnects a dropped listChanged-only stream (no subscriptions to keep it alive)", async () => { + const started = await startToolsOnlyServer({ tools: true }); + const { connected } = await connect(started.url, "modern"); + + const int = connected as unknown as { + modernSubscription: McpSubscription | null; + modernListenGeneration: number; + onModernSubscriptionClosed( + subscription: McpSubscription, + reason: "local" | "graceful" | "remote", + generation: number, + ): void; + }; + const dropped = int.modernSubscription; + expect(dropped).not.toBeNull(); + if (!dropped) return; + + int.onModernSubscriptionClosed( + dropped, + "remote", + int.modernListenGeneration, + ); + // Reconnect-by-re-listen runs on the filter, not on the subscribed set — + // which is empty here, and used to be the reason to give up. + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "reconnecting", + ); + await vi.waitFor(() => { + expect(int.modernSubscription).not.toBeNull(); + }); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "acknowledged", + ); + }); + + it("connects anyway when the connect-time listen fails, and retries", async () => { + // The handshake succeeded; every request-scoped feature works without the + // stream, so the failure is handed to the reconnect machinery instead of + // failing `connect()`. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + // Shadow the private refresh on the instance so the connect-time open + // fails without a live client to reach into (there is none until + // `connect()` builds one). + ( + connected as unknown as { + refreshModernSubscription: () => Promise; + } + ).refreshModernSubscription = () => + Promise.reject(new Error("listen boom")); + + await expect(connected.connect()).resolves.toBeUndefined(); + client = connected; + + expect(connected.getStatus()).toBe("connected"); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "reconnecting", + ); + }); + }); + describe("legacy era", () => { it("subscribes via resources/subscribe with no listen stream", async () => { const started = await startServer(undefined); @@ -355,8 +543,17 @@ describe("resource subscriptions era fork (#1630)", () => { // Reachable without any close() failure: a rejecting `listen()` does it, // which is what a user subscribing while the reconnect timer re-lists // (i.e. exactly when the server is flaky) can produce. + // + // No list-change opt-ins, so connect leaves the stream closed (#1920) and + // the first subscribe's `listen()` is reached synchronously — with a + // stream to tear down first, the swap below would land after this call + // already read `client.listen`. const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); const int = internals(connected); const real = int.client.listen; @@ -504,8 +701,15 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("reflects the subscription optimistically as 'connecting' before the ack", async () => { + // No list-change opt-ins → no connect-time stream, so the subscribe's + // `listen()` is the first one and this test's held ack is the one it waits + // on (#1920). const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); const int = internals(connected); // Hold the listen ack so we can observe the pre-ack (optimistic) state. @@ -632,8 +836,14 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("does not reconnect when the subscription set empties before the timer fires", async () => { + // No list-change opt-ins, so emptying the set empties the *filter* — + // which is what the timer's bail now tests (#1920). const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); await connected.subscribeToResource(RESOURCE_URI); const int = internals(connected); const fake = await installFakeSubscription(int); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 8e268477..31a7e5e3 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -479,7 +479,10 @@ export class InspectorClient extends InspectorClientEventTarget { // `McpSubscription` whose filter's `resourceSubscriptions` mirrors // `subscribedResources`; mutating the set re-lists (close old, open new), and // an unexpected `"remote"` close re-lists (reconnect-by-re-listen — the stream - // is not resumable). `null` when no URI is subscribed or on the legacy era. + // is not resumable). `null` on the legacy era, and on the modern era whenever + // the filter is empty — which is *not* the same as "no URI subscribed": the + // stream also carries the list-change opt-ins, so a tools-only server opens it + // with no subscriptions at all (#1920). private modernSubscription: McpSubscription | null = null; // Monotonic guard so a stale re-list/reconnect (whose `listen()` or `closed` // resolves after a newer refresh already started) can detect it lost the race @@ -2056,6 +2059,14 @@ export class InspectorClient extends InspectorClientEventTarget { // a progress notification handler so the Protocol's _onprogress stays; timeout reset // and routing work, and we inject the caller's progressToken into dispatched events. } + + // Modern era: the handlers registered above only fire for notifications + // that reach us, and on this era every server→client notification rides + // the `subscriptions/listen` stream. Open it now when the filter says + // there is something to listen for — otherwise a list-change opt-in on a + // server with no resources would have no way in, since a subscribe click + // was the only thing that ever opened the stream (#1920). + await this.openModernListenStreamOnConnect(); } catch (error) { if (!isConnectAuthRecoveryError(error)) { this.status = "error"; @@ -4893,9 +4904,13 @@ export class InspectorClient extends InspectorClientEventTarget { * opted-in notification type (SEP §7.4). */ private buildSubscriptionFilter(): SubscriptionFilter { - const filter: SubscriptionFilter = { - resourceSubscriptions: Array.from(this.subscribedResources), - }; + const filter: SubscriptionFilter = {}; + // Omitted rather than sent empty: a listChanged-only stream (#1920) is not + // subscribing to any resource, and `[]` would say it asked for none of a set + // it is participating in. + if (this.subscribedResources.size > 0) { + filter.resourceSubscriptions = Array.from(this.subscribedResources); + } if ( this.listChangedNotifications.tools && this.capabilities?.tools?.listChanged @@ -4917,6 +4932,69 @@ export class InspectorClient extends InspectorClientEventTarget { return filter; } + /** + * Whether the modern listen stream should be open: the built filter carries + * something to listen for (#1920). Before #1920 this was "at least one URI is + * subscribed", which made the stream unreachable on a server with no resources + * — a tools-only server advertising `tools.listChanged` had no way to open it, + * so `notifications/tools/list_changed` could never arrive. The filter already + * modelled the list-change opt-ins; only the trigger was narrower than the + * filter it built. This matches the SDK's own `ClientOptions.listChanged` + * auto-open, which opens whenever the effective (config ∩ capability) + * intersection is non-empty. + * + * Note this is deliberately *not* the same predicate as the stream state's + * `active` — see `modernStreamActive()`. + */ + private wantsModernStream(): boolean { + const filter = this.buildSubscriptionFilter(); + return ( + (filter.resourceSubscriptions?.length ?? 0) > 0 || + filter.toolsListChanged === true || + filter.resourcesListChanged === true || + filter.promptsListChanged === true + ); + } + + /** + * Whether the stream state reports `active` — i.e. whether the *Subscriptions* + * UI has a stream to describe. That is a narrower question than + * `wantsModernStream()`: `ResourceSubscriptionStreamState` drives the + * Subscriptions section's badge, so a stream open purely for list-change + * notifications (no subscribed URI) has nothing to report there and stays + * `active: false` (#1920). Keeping the two apart also preserves the invariant + * the rest of this file is written against — an empty subscribed set is never + * announced alongside an `active` stream. + */ + private modernStreamActive(): boolean { + return this.subscribedResources.size > 0; + } + + /** + * Open the modern listen stream at the end of a successful connect, when the + * filter is non-empty (#1920). Only the list-change opt-ins can make it + * non-empty here — the subscribed set is emptied by `resetSessionState()` on + * the way in — so this is exactly the "server advertises a listChanged the + * Inspector wants" case that had no trigger before. + * + * A failure is not allowed to fail the connect: the handshake succeeded and + * every request-scoped feature works without this stream. It is reported the + * way a lost stream is — hand it to the reconnect machinery, which retries + * with backoff and settles on `"ended"` past the cap. + */ + private async openModernListenStreamOnConnect(): Promise { + if (!this.isModernEra() || !this.wantsModernStream()) return; + try { + await this.refreshModernSubscription(); + } catch (error) { + this.logger.error( + { error }, + "Failed to open the modern subscriptions/listen stream on connect", + ); + this.reconcileModernStreamStateAfterFailedRefresh(); + } + } + /** Cancel a pending reconnect re-listen, if any (#1630). */ private clearModernReconnectTimer(): void { if (this.modernReconnectTimer !== undefined) { @@ -4927,9 +5005,10 @@ export class InspectorClient extends InspectorClientEventTarget { /** * (Re-)establish the modern `subscriptions/listen` stream to match the current - * `subscribedResources` set (#1630). Because the stream is not resumable, - * every filter change re-lists: the existing stream is closed and a fresh - * `listen()` opened. With no subscribed URIs the stream is left closed. + * filter (#1630). Because the stream is not resumable, every filter change + * re-lists: the existing stream is closed and a fresh `listen()` opened. With + * an empty filter — no subscribed URIs *and* no enabled list-change opt-in the + * server advertises — the stream is left closed (#1920). * * `modernListenGeneration` guards against races — if a newer refresh starts * while this one awaits its acknowledgement, the just-opened stream is @@ -4955,8 +5034,8 @@ export class InspectorClient extends InspectorClientEventTarget { await closeSubscriptionBestEffort(previous); } - // Nothing subscribed → keep the stream closed. - if (this.subscribedResources.size === 0) { + // Nothing to listen for → keep the stream closed. + if (!this.wantsModernStream()) { this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); return; } @@ -4978,7 +5057,7 @@ export class InspectorClient extends InspectorClientEventTarget { // starts fresh next time (#1630). this.modernReconnectAttempts = 0; this.setModernStreamState({ - active: true, + active: this.modernStreamActive(), status: "acknowledged", honoredUris: subscription.honoredFilter.resourceSubscriptions ?? [], }); @@ -5024,7 +5103,7 @@ export class InspectorClient extends InspectorClientEventTarget { const shouldReconnect = reason === "remote" && !isTerminalStatus(this.status) && - this.subscribedResources.size > 0; + this.wantsModernStream(); if (!shouldReconnect) { // "stream gone but subscriptions remain" renders the same whether we gave // up after failed reconnects or the server closed it gracefully: keep the @@ -5033,7 +5112,7 @@ export class InspectorClient extends InspectorClientEventTarget { // until the next `connect()` calls `resetSubscriptionStream`, which moves // them together — so "active with an empty set" is never observable.) this.setModernStreamState({ - active: this.subscribedResources.size > 0, + active: this.modernStreamActive(), status: "ended", honoredUris: [], }); @@ -5073,7 +5152,7 @@ export class InspectorClient extends InspectorClientEventTarget { * its error either way — the retry is about the subscriptions, not the call. */ private reconcileModernStreamStateAfterFailedRefresh(): void { - if (this.subscribedResources.size === 0) { + if (!this.wantsModernStream()) { this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); return; } @@ -5088,7 +5167,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ private scheduleModernReconnect(): void { this.setModernStreamState({ - active: true, + active: this.modernStreamActive(), status: "reconnecting", honoredUris: [], }); @@ -5101,10 +5180,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.modernReconnectTimer = undefined; // Disconnect/unsubscribe may have raced the timer — bail if the reconnect // is no longer wanted. - if ( - isTerminalStatus(this.status) || - this.subscribedResources.size === 0 - ) { + if (isTerminalStatus(this.status) || !this.wantsModernStream()) { return; } this.refreshModernSubscription(true).catch(() => @@ -5123,10 +5199,10 @@ export class InspectorClient extends InspectorClientEventTarget { if ( this.modernReconnectAttempts > MODERN_RECONNECT_MAX_ATTEMPTS || isTerminalStatus(this.status) || - this.subscribedResources.size === 0 + !this.wantsModernStream() ) { this.setModernStreamState({ - active: this.subscribedResources.size > 0, + active: this.modernStreamActive(), status: "ended", honoredUris: [], }); diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 52840af8..b89f117b 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -436,9 +436,14 @@ export type ResourceSubscriptionStreamStatus = * persistent stream, so `active` is `false` and the UI surfaces no stream chrome. * On the modern era all subscriptions are a filter over one long-lived * `subscriptions/listen` stream; `active` is `true` whenever that stream is being - * managed (i.e. at least one URI is subscribed), and `honoredUris` is the subset - * of requested URIs the server acknowledged in its `honoredFilter` (may be a - * strict subset — a server is allowed to decline some). + * managed *for resource subscriptions* (i.e. at least one URI is subscribed), and + * `honoredUris` is the subset of requested URIs the server acknowledged in its + * `honoredFilter` (may be a strict subset — a server is allowed to decline some). + * + * `active: false` does not imply no stream: the same stream also carries the + * list-change opt-ins, so it can be open with no subscribed URI at all (#1920). + * This state describes the Subscriptions section, which has nothing to show for + * such a stream. */ export interface ResourceSubscriptionStreamState { active: boolean; From eda8ec0db8afdf0543567019c0771bdf0d3f66bd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 23:34:19 -0400 Subject: [PATCH 30/73] fix: guard the connect-time listen failure on the refresh generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: `openModernListenStreamOnConnect`'s catch reconciled unconditionally, unlike the subscribe/unsubscribe call sites. The `connect` event is dispatched before the stream opens, so a listener subscribing to a resource can start and acknowledge a newer refresh while this one is still awaiting its `listen()` — and reconciling anyway would arm a reconnect that tears down a healthy stream. Apply the same generation test. Also consolidates the tests' private-member access onto the file's existing `internals()` helper (one justified double cast, hoisted and documented) and makes the failure stub bump the generation the way the real method does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 102 ++++++++++++------ core/mcp/inspectorClient.ts | 12 ++- 2 files changed, 80 insertions(+), 34 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index 6d182817..91daa638 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -124,6 +124,34 @@ describe("resource subscriptions era fork (#1630)", () => { return params.notifications as Record | undefined; } + /** + * The private members these tests drive directly. `InspectorClient` declares + * every one of them, so the shape below is a faithful mirror rather than a + * reinterpretation — the double cast is only to reach past `private`, which no + * single `as` can do (the two types share no public overlap). It is confined to + * `internals()` so there is one such cast in the file, and it is unavoidable: + * these are lifecycle branches no public API can reach against a healthy server + * (there is no way to ask for a *remote* stream close, or for a `listen()` that + * fails). + */ + interface StreamInternals { + client: { listen: (...args: unknown[]) => Promise }; + modernSubscription: McpSubscription | null; + modernListenGeneration: number; + modernReconnectAttempts: number; + subscribedResources: Set; + refreshModernSubscription(fromReconnect?: boolean): Promise; + onModernSubscriptionClosed( + subscription: McpSubscription, + reason: "local" | "graceful" | "remote", + generation: number, + ): void; + } + + function internals(c: InspectorClient): StreamInternals { + return c as unknown as StreamInternals; + } + describe("modern era", () => { it("opens an acknowledged listen stream on subscribe (no resources/subscribe)", async () => { const started = await startServer({}); @@ -322,15 +350,7 @@ describe("resource subscriptions era fork (#1630)", () => { const started = await startToolsOnlyServer({ tools: true }); const { connected } = await connect(started.url, "modern"); - const int = connected as unknown as { - modernSubscription: McpSubscription | null; - modernListenGeneration: number; - onModernSubscriptionClosed( - subscription: McpSubscription, - reason: "local" | "graceful" | "remote", - generation: number, - ): void; - }; + const int = internals(connected); const dropped = int.modernSubscription; expect(dropped).not.toBeNull(); if (!dropped) return; @@ -367,13 +387,15 @@ describe("resource subscriptions era fork (#1630)", () => { ); // Shadow the private refresh on the instance so the connect-time open // fails without a live client to reach into (there is none until - // `connect()` builds one). - ( - connected as unknown as { - refreshModernSubscription: () => Promise; - } - ).refreshModernSubscription = () => - Promise.reject(new Error("listen boom")); + // `connect()` builds one). The bump mirrors the real method's contract — + // it claims the generation synchronously before it can fail — which is + // what the caller's guard reads to tell "our refresh failed" from "a newer + // one took over". + const int = internals(connected); + int.refreshModernSubscription = () => { + int.modernListenGeneration++; + return Promise.reject(new Error("listen boom")); + }; await expect(connected.connect()).resolves.toBeUndefined(); client = connected; @@ -383,6 +405,37 @@ describe("resource subscriptions era fork (#1630)", () => { "reconnecting", ); }); + + it("leaves a superseded connect-time failure to the refresh that owns the stream", async () => { + // `connect` is dispatched before the stream is opened, so a listener can + // start and acknowledge a newer refresh while this one is still awaiting + // its `listen()`. Reconciling anyway would arm a reconnect against a + // healthy stream and tear it down — the same ownership test the + // subscribe/unsubscribe paths make. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + const int = internals(connected); + // Two bumps: this call's own, plus the newer refresh that superseded it + // before its failure landed. + int.refreshModernSubscription = () => { + int.modernListenGeneration += 2; + return Promise.reject(new Error("listen boom")); + }; + + await expect(connected.connect()).resolves.toBeUndefined(); + client = connected; + + // No reconnect armed: the state is the newer refresh's to write. + expect(connected.getResourceSubscriptionStreamState().status).not.toBe( + "reconnecting", + ); + }); }); describe("legacy era", () => { @@ -433,23 +486,6 @@ describe("resource subscriptions era fork (#1630)", () => { // the client's private state — the pattern used across the InspectorClient // coverage-backfill suite — to drive them deterministically. describe("modern stream internals", () => { - interface StreamInternals { - client: { listen: (...args: unknown[]) => Promise }; - modernSubscription: McpSubscription | null; - modernListenGeneration: number; - modernReconnectAttempts: number; - subscribedResources: Set; - onModernSubscriptionClosed( - subscription: McpSubscription, - reason: "local" | "graceful" | "remote", - generation: number, - ): void; - } - - function internals(c: InspectorClient): StreamInternals { - return c as unknown as StreamInternals; - } - /** A controllable fake `McpSubscription` whose `closed` we resolve on demand. */ function makeFakeSub(): { sub: McpSubscription; diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 31a7e5e3..c9ec8207 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4981,9 +4981,17 @@ export class InspectorClient extends InspectorClientEventTarget { * every request-scoped feature works without this stream. It is reported the * way a lost stream is — hand it to the reconnect machinery, which retries * with backoff and settles on `"ended"` past the cap. + * + * Gated on the same generation test as `subscribeToResource` — see the long + * comment there. This call is *not* the only refresh that can be in flight: + * the `connect` event has already been dispatched, so a listener subscribing + * to a resource can start and acknowledge a newer refresh while this one is + * still awaiting its `listen()`. Reconciling unconditionally would then arm a + * reconnect that tears down a stream that is up and healthy. */ private async openModernListenStreamOnConnect(): Promise { if (!this.isModernEra() || !this.wantsModernStream()) return; + const generationBefore = this.modernListenGeneration; try { await this.refreshModernSubscription(); } catch (error) { @@ -4991,7 +4999,9 @@ export class InspectorClient extends InspectorClientEventTarget { { error }, "Failed to open the modern subscriptions/listen stream on connect", ); - this.reconcileModernStreamStateAfterFailedRefresh(); + if (this.modernListenGeneration === generationBefore + 1) { + this.reconcileModernStreamStateAfterFailedRefresh(); + } } } From ca70a7cfa7d7860e7e4f89b9d2f51d2b5aa78edb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 23:46:32 -0400 Subject: [PATCH 31/73] chore(storybook): drop the redundant setProjectAnnotations setup file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since Storybook 10.3, @storybook/addon-vitest provisions the preview annotations itself — and *skips* doing so when it finds a setup file calling setProjectAnnotations. So .storybook/vitest.setup.ts was not merely redundant, it was actively opting the project out of the automatic path, which the addon printed a notice about on every run. Remove the file and the `setupFiles` entry from the `storybook` vitest project. A green suite doesn't prove the automatic provisioning works: without the preview annotations, stories would render outside MantineProvider and without App.css, and would very likely still pass. Add src/test/PreviewAnnotations.stories.tsx, which asserts from a play function that the Mantine theme variables and the App.css tokens are actually present in the rendered document, so an unthemed render fails loudly. Verified by emptying `decorators` in preview.tsx and confirming the story goes red. The a11y addon's axe runner can't be introspected from a play function, so it was checked out-of-band: a story with a real violation turned the suite red with the setup file already removed, confirming @storybook/addon-a11y/preview is applied automatically. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- clients/web/.storybook/vitest.setup.ts | 7 -- .../src/test/PreviewAnnotations.stories.tsx | 78 +++++++++++++++++++ clients/web/vite.config.ts | 10 ++- 3 files changed, 87 insertions(+), 8 deletions(-) delete mode 100644 clients/web/.storybook/vitest.setup.ts create mode 100644 clients/web/src/test/PreviewAnnotations.stories.tsx diff --git a/clients/web/.storybook/vitest.setup.ts b/clients/web/.storybook/vitest.setup.ts deleted file mode 100644 index fd7ac45e..00000000 --- a/clients/web/.storybook/vitest.setup.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; -import { setProjectAnnotations } from "@storybook/react-vite"; -import * as projectAnnotations from "./preview"; - -// This is an important step to apply the right configuration when testing your stories. -// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations -setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/clients/web/src/test/PreviewAnnotations.stories.tsx b/clients/web/src/test/PreviewAnnotations.stories.tsx new file mode 100644 index 00000000..50cafe5d --- /dev/null +++ b/clients/web/src/test/PreviewAnnotations.stories.tsx @@ -0,0 +1,78 @@ +import { Button } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { theme } from "../theme/theme"; + +// Guard for #1898. +// +// `.storybook/vitest.setup.ts` used to call `setProjectAnnotations([...])` to +// hand the preview annotations to the Storybook vitest project. Since Storybook +// 10.3 `@storybook/addon-vitest` applies them automatically — and *skips* doing +// so when it finds such a setup file — so the file was removed and the +// `setupFiles` entry dropped from the `storybook` project in `vite.config.ts`. +// +// A green suite is not evidence that the automatic path works. Two things the +// setup file used to provision are silently losable: +// +// • `./preview`'s decorator, which wraps every story in `MantineProvider` +// with the project theme, and its `import "../src/App.css"`, which defines +// the `--inspector-*` tokens. Without them stories render unthemed and +// unstyled — and would very likely still pass their play functions. +// • `@storybook/addon-a11y/preview`, which runs axe after each play function. +// Without it the `a11y: { test: "error" }` parameter is inert and every +// story passes its accessibility check vacuously. +// +// The story below closes the first gap: it asserts the Mantine theme variables +// and the `App.css` tokens are actually present in the rendered document, so an +// unthemed render fails loudly rather than passing. It also asserts the preview +// `parameters` reached the story, which is the same channel the a11y parameter +// arrives on. The axe *runner* itself can't be introspected from a play +// function; it was verified out-of-band by temporarily giving a story a real +// violation and confirming the suite went red (see the PR for #1898). + +// The primary shade the theme pins for the light scheme — the value Mantine +// derives `--mantine-primary-color-filled` from. Read from the theme rather +// than hard-coded so a palette change can't silently invalidate the guard. +const LIGHT_PRIMARY_SHADE = 7; + +function expectedPrimaryColor(): string { + const palette = theme.colors?.[theme.primaryColor ?? ""]; + const color = palette?.[LIGHT_PRIMARY_SHADE]; + if (!color) throw new Error("theme is missing its primary color palette"); + return color; +} + +const meta: Meta = { + title: "Meta/Preview Annotations", + component: Button, + args: { children: "Themed" }, +}; + +export default meta; +type Story = StoryObj; + +export const ProjectAnnotationsApplied: Story = { + play: async ({ canvasElement, parameters }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("button", { name: "Themed" })).toBeInTheDocument(); + + const root = getComputedStyle(document.documentElement); + + // `MantineProvider` (the `./preview` decorator) injects the theme's CSS + // variables onto `:root`. No provider, no variables. + expect(root.getPropertyValue("--mantine-primary-color-filled").trim()).toBe( + expectedPrimaryColor(), + ); + + // `--inspector-brand-primary` is defined in `App.css`, which only reaches + // the story through `./preview`'s stylesheet import — and it resolves + // *through* the Mantine variable above, so this covers both layers. + expect(root.getPropertyValue("--inspector-brand-primary").trim()).toBe( + expectedPrimaryColor(), + ); + + // The preview `parameters` merged into the story context — the same channel + // that carries `a11y: { test: "error" }` to the a11y addon. + expect(parameters.a11y).toMatchObject({ test: "error" }); + }, +}; diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 3b99d672..a31c0169 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -379,7 +379,15 @@ export default defineConfig(({ command }) => { }, ], }, - setupFiles: [".storybook/vitest.setup.ts"], + // No `setupFiles`: since Storybook 10.3 `@storybook/addon-vitest` + // provisions the preview annotations (`.storybook/preview.tsx` plus + // `@storybook/addon-a11y/preview`) itself, and *skips* doing so when + // it finds a setup file calling `setProjectAnnotations` — so the old + // `.storybook/vitest.setup.ts` was both redundant and actively + // opting out of the automatic path (#1898). A green suite doesn't + // prove the automatic provisioning works (stories rendered without + // the Mantine decorator would very likely still pass), so + // `src/test/PreviewAnnotations.stories.tsx` asserts it directly. }, }, ], From d08a53027a3e6bdeea3d091af6a49ac2cdef5919 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 23:49:20 -0400 Subject: [PATCH 32/73] fix: dispatch the connect event after the listen stream is up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 2: opening the stream after `dispatchTypedEvent("connect")` left a real gap. The managed list states start their initial `refresh()` from that event, so `tools/list` could go out ahead of `subscriptions/listen` — and a list the server changed in that window would notify nobody, leaving the UI stale with no way to notice. The handler registration, the initial logging level, and the stream open now all run before the `connect` dispatch, so no consumer can act on the connection until the notification channel is established (or its retry armed). Costs one listen round-trip on a modern connect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 24 +++++++++++++++++++ core/mcp/inspectorClient.ts | 11 +++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index 91daa638..9c61266f 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -373,6 +373,30 @@ describe("resource subscriptions era fork (#1630)", () => { ); }); + it("has the stream up before the connect event fires", async () => { + // The managed list states start their initial `refresh()` from the + // `connect` event, so the stream has to be established (or its retry + // armed) first: dispatching earlier would let `tools/list` go out ahead of + // `subscriptions/listen`, and a list the server changed in that window + // would notify nobody. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + let streamAtConnectEvent: boolean | undefined; + connected.addEventListener("connect", () => { + streamAtConnectEvent = internals(connected).modernSubscription !== null; + }); + await connected.connect(); + client = connected; + + expect(streamAtConnectEvent).toBe(true); + }); + it("connects anyway when the connect-time listen fails, and retries", async () => { // The handshake succeeded; every request-scoped feature works without the // stream, so the failure is handed to the reconnect machinery instead of diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index c9ec8207..4f9993a4 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1936,8 +1936,6 @@ export class InspectorClient extends InspectorClientEventTarget { // capabilities and wipe tools/prompts/resources to empty on every connect. await this.fetchServerInfo(); - this.dispatchTypedEvent("connect"); - // Set initial logging level if configured and server supports it if (this.initialLoggingLevel && this.capabilities?.logging) { await this.client.setLoggingLevel( @@ -2067,6 +2065,15 @@ export class InspectorClient extends InspectorClientEventTarget { // server with no resources would have no way in, since a subscribe click // was the only thing that ever opened the stream (#1920). await this.openModernListenStreamOnConnect(); + + // Last, so the notification channel is established (or its retry armed) + // before any consumer acts on the connection. The managed list states + // start their initial `refresh()` from this event, so dispatching earlier + // would let `tools/list` go out ahead of `subscriptions/listen` — and a + // list the server changes in that window would notify nobody, leaving the + // UI stale with no way to notice (#1920). The cost is one listen + // round-trip added to connect on the modern era. + this.dispatchTypedEvent("connect"); } catch (error) { if (!isConnectAuthRecoveryError(error)) { this.status = "error"; From d1135dfe7c425a021a7e7cdf6811c97157d5e801 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 23:54:44 -0400 Subject: [PATCH 33/73] chore(storybook): derive the primary shade from the theme in the guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard restated `primaryShade.light` as a literal 7, so re-pinning the theme's primary shade would have made it fail even with the preview annotations correctly applied — a guard that has to be edited alongside a legitimate theme change just trains people to edit it. Derive the shade from `theme.primaryShade`, handling both of Mantine's forms (a bare number and `{ light, dark }`) and falling back to Mantine's own default of 6 when a theme pins none. Verified by temporarily re-pinning the theme to `{ light: 8, dark: 8 }`: the guard still passes, where the hard-coded 7 would have failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../src/test/PreviewAnnotations.stories.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/clients/web/src/test/PreviewAnnotations.stories.tsx b/clients/web/src/test/PreviewAnnotations.stories.tsx index 50cafe5d..bca4fb48 100644 --- a/clients/web/src/test/PreviewAnnotations.stories.tsx +++ b/clients/web/src/test/PreviewAnnotations.stories.tsx @@ -30,14 +30,29 @@ import { theme } from "../theme/theme"; // function; it was verified out-of-band by temporarily giving a story a real // violation and confirming the suite went red (see the PR for #1898). -// The primary shade the theme pins for the light scheme — the value Mantine -// derives `--mantine-primary-color-filled` from. Read from the theme rather -// than hard-coded so a palette change can't silently invalidate the guard. -const LIGHT_PRIMARY_SHADE = 7; +// Mantine's own default when a theme pins no `primaryShade` — the value it +// falls back to when deriving `--mantine-primary-color-filled`. +const MANTINE_DEFAULT_LIGHT_PRIMARY_SHADE = 6; + +// The shade Mantine derives `--mantine-primary-color-filled` from. Everything +// here is read from the theme rather than restated, so re-pinning +// `primaryShade` or repainting the palette moves the expectation with it — a +// guard that had to be edited alongside a legitimate theme change would just +// train people to edit it, which is the opposite of what it's for. +// +// The light branch is the one that matters: `./preview` renders every story +// with `defaultColorScheme="light"` and an `initialGlobals.colorScheme` of +// `"light"`. `primaryShade` is `number | { light, dark }` in Mantine's types, +// so both forms are handled. +function lightPrimaryShade(): number { + const shade = theme.primaryShade; + if (typeof shade === "number") return shade; + return shade?.light ?? MANTINE_DEFAULT_LIGHT_PRIMARY_SHADE; +} function expectedPrimaryColor(): string { const palette = theme.colors?.[theme.primaryColor ?? ""]; - const color = palette?.[LIGHT_PRIMARY_SHADE]; + const color = palette?.[lightPrimaryShade()]; if (!color) throw new Error("theme is missing its primary color palette"); return color; } From b2dfd4099ab6a1d9615eb9298993b56194456540 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 23:56:24 -0400 Subject: [PATCH 34/73] docs: correct three comments the connect-ordering change invalidated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 3 (no new code findings, three stale-comment ones): - `openModernListenStreamOnConnect`'s generation guard was justified by a `connect` listener racing it — which the previous commit made impossible by dispatching `connect` after this call. The guard is still right, for reasons that survive the reorder: `statusChange` has already fired, and a concurrent `subscribeToResource` or `disconnect()` (whose `resetSubscriptionStream` bumps the generation) can supersede it. - `reconcileModernStreamStateAfterFailedRefresh`'s JSDoc still described its branches as "nothing subscribed" vs "URIs live"; they are the empty vs non-empty *filter* now, which differ exactly on the listChanged-only path. - The superseded-failure test carried the same stale `connect` rationale. Comments only; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 14 ++++++---- core/mcp/inspectorClient.ts | 26 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index 9c61266f..16289fc3 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -431,11 +431,15 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("leaves a superseded connect-time failure to the refresh that owns the stream", async () => { - // `connect` is dispatched before the stream is opened, so a listener can - // start and acknowledge a newer refresh while this one is still awaiting - // its `listen()`. Reconciling anyway would arm a reconnect against a - // healthy stream and tear it down — the same ownership test the - // subscribe/unsubscribe paths make. + // Nothing about the ordering above makes this call the only refresh that + // can be in flight: `statusChange` has already fired, and a concurrent + // `subscribeToResource` — or a `disconnect()`, whose + // `resetSubscriptionStream` bumps the generation too — can supersede this + // one while its `listen()` is pending. Reconciling anyway would arm a + // reconnect against a stream that is healthy (or a session that is gone), + // so this makes the same ownership test the subscribe/unsubscribe paths + // make. Driven here by a stub that bumps twice, standing in for "someone + // else advanced it". const started = await startToolsOnlyServer({ tools: true }); const connected = new InspectorClient( { type: "streamable-http", url: started.url }, diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 4f9993a4..d36f7143 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4990,11 +4990,14 @@ export class InspectorClient extends InspectorClientEventTarget { * with backoff and settles on `"ended"` past the cap. * * Gated on the same generation test as `subscribeToResource` — see the long - * comment there. This call is *not* the only refresh that can be in flight: - * the `connect` event has already been dispatched, so a listener subscribing - * to a resource can start and acknowledge a newer refresh while this one is - * still awaiting its `listen()`. Reconciling unconditionally would then arm a - * reconnect that tears down a stream that is up and healthy. + * comment there. The `connect` event has deliberately *not* been dispatched + * yet (that is the point of running here), so a list-state consumer is not the + * risk; what is, is anything else that bumps the generation while this + * `listen()` is in flight. `statusChange` has already fired, and any + * concurrent call on this instance qualifies: a `subscribeToResource` from a + * caller restoring subscriptions, or a `disconnect()` — whose + * `resetSubscriptionStream` bumps the generation too, making a reconcile here + * arm a reconnect for a session that is already gone. */ private async openModernListenStreamOnConnect(): Promise { if (!this.isModernEra() || !this.wantsModernStream()) return; @@ -5148,10 +5151,11 @@ export class InspectorClient extends InspectorClientEventTarget { * refresh owns the state as well as the filter — so both call sites gate on * the generation first. * - * The empty case is the ordinary one: nothing subscribed, no stream, inactive. - * The non-empty one exists because a failed re-listen leaves - * `modernSubscription` null with URIs still subscribed, and nothing else will - * notice: the reconnect machinery is reachable only from a stream that closed + * The two branches are the empty and non-empty *filter* (#1920) — which is + * "nothing subscribed" only when no list-change opt-in is live. The empty case + * is the ordinary one: nothing to listen for, no stream, inactive. The + * non-empty one exists because a failed re-listen leaves `modernSubscription` + * null with the filter still wanting a stream, and nothing else will notice: the reconnect machinery is reachable only from a stream that closed * or a reconnect that failed, and neither happened here. Left alone, the state * keeps whatever the last success (or the optimistic `"connecting"`) wrote — a * badge that will never change over subscriptions the server may never have @@ -5159,11 +5163,11 @@ export class InspectorClient extends InspectorClientEventTarget { * Subscribe early-returns on the URI already being in the set). * * So it reconnects rather than settling for an honest-but-dead `"ended"`: - * every other route to "stream gone, URIs live" either expects the close or + * every other route to "stream gone, filter live" either expects the close or * has exhausted the retry cap, and this is the one that has made no attempt * at all. `scheduleModernReconnect` fits as-is — a user-initiated refresh * already reset `modernReconnectAttempts`, so it starts at the base delay; the - * timer bails on a terminal status or an emptied set; and past the cap + * timer bails on a terminal status or an emptied filter; and past the cap * `onModernReconnectFailed` lands on the same `"ended"` badge. The state * therefore becomes true or ends after a real attempt. The caller still sees * its error either way — the retry is about the subscriptions, not the call. From 2a4e93c621ac58928a85811acff07acfe68f1f40 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 00:05:56 -0400 Subject: [PATCH 35/73] fix: only announce the connection if it is still live after the stream open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 4: every await before the `connect` dispatch is a window for a `disconnect()` or a transport close to overtake the connect, and the listen round-trip widened it. Announcing anyway restarts every managed list refresh against a session being torn down or already dead. The dispatch is now gated on `status === "connected" && !disconnecting` — the second because an explicit teardown claims ownership before it awaits `client.close()`, leaving the status untouched until that block finishes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 46 +++++++++++++++++++ core/mcp/inspectorClient.ts | 12 ++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index 16289fc3..8c2a005a 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -397,6 +397,52 @@ describe("resource subscriptions era fork (#1630)", () => { expect(streamAtConnectEvent).toBe(true); }); + it("does not announce the connection when a disconnect overtakes the stream open", async () => { + // The listen round-trip widened the window between the handshake and the + // `connect` announcement, so a `disconnect()` can land inside it. + // Announcing anyway would restart every managed list refresh against a + // session being torn down. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + client = connected; + let connectAnnounced = false; + connected.addEventListener("connect", () => { + connectAnnounced = true; + }); + + // Hold the stream open so the disconnect lands while it is in flight. + // `openStarted` is what makes the race deterministic: the stub is only + // reached after the handshake, so disconnecting before it runs would leave + // `releaseOpen` unset and hang the connect. + const int = internals(connected); + let releaseOpen: () => void = () => {}; + let markOpenStarted: () => void = () => {}; + const openStarted = new Promise((resolve) => { + markOpenStarted = resolve; + }); + int.refreshModernSubscription = () => { + markOpenStarted(); + return new Promise((resolve) => { + releaseOpen = resolve; + }); + }; + + const connecting = connected.connect(); + await openStarted; + const disconnecting = connected.disconnect(); + releaseOpen(); + await connecting; + await disconnecting; + + expect(connectAnnounced).toBe(false); + }); + it("connects anyway when the connect-time listen fails, and retries", async () => { // The handshake succeeded; every request-scoped feature works without the // stream, so the failure is handed to the reconnect machinery instead of diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index d36f7143..b0b809f3 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -2073,7 +2073,17 @@ export class InspectorClient extends InspectorClientEventTarget { // list the server changes in that window would notify nobody, leaving the // UI stale with no way to notice (#1920). The cost is one listen // round-trip added to connect on the modern era. - this.dispatchTypedEvent("connect"); + // + // …which is also why the announcement is conditional. Every await above + // is a window for a `disconnect()` or a transport `onclose`/`onerror` to + // overtake this connect, and the listen round-trip widened it. Announcing + // then would restart every managed list refresh against a session being + // torn down or already dead. `disconnecting` covers the teardown that has + // claimed ownership but is still awaiting `client.close()` — the status is + // whatever it was until that block finishes. + if (this.status === "connected" && !this.disconnecting) { + this.dispatchTypedEvent("connect"); + } } catch (error) { if (!isConnectAuthRecoveryError(error)) { this.status = "error"; From 31c41d3ad61f1a8c0ff9eade0177e93fe8c3f018 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 09:07:04 -0400 Subject: [PATCH 36/73] chore: declare the MCP SDK packages only at the repo root (#1970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four v2 SDK packages (`client`, `core`, `server`, `server-legacy`) plus `ext-apps` were declared in the root manifest *and* again in clients/web, cli, and tui, so each client installed its own copy. Node resolution walks up and the root install is on every client's chain — and the root manifest is already the documented source of truth for what ships — so the per-client entries were duplicates of a decision made at the root, and they had already drifted: before this change the tree carried ext-apps 1.7.4 at the root and 1.7.5 under clients/web, dragging in two copies of the v1 `@modelcontextprotocol/sdk` (1.29.0 and 1.30.0) through ext-apps' peer dependency. Nothing imports the v1 SDK; it is not in any manifest of ours, only a peer of ext-apps, so consolidating ext-apps is the only lever over it. After a clean install there is now exactly one copy of each, at the root. `npm update` then moves ext-apps to 1.7.5 and its SDK peer to 1.30.0, whose widened `@hono/node-server` range (`^1.19.9 || ^2.0.5`) also drops a nested duplicate of that package. `express` needed a real home to make this work. `test-servers/src` imports it, but no manifest declared it: it was reaching `clients/cli/node_modules` only as a peer of `express-rate-limit` under `@modelcontextprotocol/server-legacy`, so removing that entry took express with it and every cli test that spawns a test server failed to resolve it. It is now a root devDependency — test-servers is root-owned code with no manifest of its own — and `vitest.shared.mts` resolves it from the root, joining `yaml`, which was already the precedent for exactly this case. The clients/web duplicate is dropped; its only reference is a type-only import covered by `@types/express`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- clients/cli/package-lock.json | 1022 +------------------------------ clients/cli/package.json | 4 - clients/tui/package-lock.json | 77 +-- clients/tui/package.json | 2 - clients/web/package-lock.json | 1069 +-------------------------------- clients/web/package.json | 6 - package-lock.json | 86 +-- package.json | 1 + vitest.shared.mts | 13 +- 9 files changed, 47 insertions(+), 2233 deletions(-) diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index ee8ea1e4..5db50d28 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -8,10 +8,6 @@ "name": "@modelcontextprotocol/inspector-cli", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", @@ -807,76 +803,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", - "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "jose": "^6.1.3", - "pkce-challenge": "^5.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", - "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", - "license": "MIT", - "dependencies": { - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", - "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", - "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", - "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "express-rate-limit": "^8.2.1", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "express": "^4.18.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - } - } - }, "node_modules/@napi-rs/keyring": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", @@ -2292,20 +2218,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2403,31 +2315,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -2472,15 +2359,6 @@ "esbuild": ">=0.18" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2491,37 +2369,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2574,29 +2421,6 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2604,47 +2428,11 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2659,6 +2447,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2719,15 +2508,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2738,58 +2518,6 @@ "node": ">=8" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -2797,19 +2525,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -2852,13 +2567,6 @@ "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3061,37 +2769,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3102,68 +2779,6 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3231,28 +2846,6 @@ "node": ">=16.0.0" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3303,26 +2896,6 @@ "dev": true, "license": "ISC" }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3338,55 +2911,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "peer": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3413,19 +2937,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3436,32 +2947,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "peer": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3469,42 +2954,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3525,31 +2974,6 @@ "node": ">=0.8.19" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -3606,13 +3030,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "peer": true - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -3632,6 +3049,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -3673,15 +3091,6 @@ "node": ">=8" } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -4100,66 +3509,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -4193,6 +3542,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -4233,38 +3583,16 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -4288,29 +3616,6 @@ "node": ">=14.0.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "peer": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -4379,16 +3684,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4403,22 +3698,12 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4493,15 +3778,6 @@ "node": ">= 6" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -4628,20 +3904,6 @@ ], "license": "MIT" }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "peer": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4652,53 +3914,12 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4820,23 +4041,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -4858,12 +4062,6 @@ "node": ">=10" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -4877,63 +4075,11 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "peer": true, - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4946,87 +4092,12 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5079,15 +4150,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -5229,15 +4291,6 @@ "node": ">=14.0.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5342,39 +4395,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "peer": true, - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5436,15 +4456,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5455,15 +4466,6 @@ "punycode": "^2.1.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", @@ -5652,6 +4654,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -5690,13 +4693,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true - }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/clients/cli/package.json b/clients/cli/package.json index 598fc7f3..fedd355c 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -32,10 +32,6 @@ "format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 0b3dbd3e..9b7de4d5 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -8,8 +8,6 @@ "name": "@modelcontextprotocol/inspector-tui", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", @@ -1054,36 +1052,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", - "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "jose": "^6.1.3", - "pkce-challenge": "^5.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", - "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", - "license": "MIT", - "dependencies": { - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@napi-rs/keyring": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", @@ -2830,6 +2798,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3247,27 +3216,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3793,6 +3741,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -3834,15 +3783,6 @@ "node": ">=8" } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -4529,6 +4469,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4608,15 +4549,6 @@ "node": ">= 6" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -4964,6 +4896,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4976,6 +4909,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6211,6 +6145,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/clients/tui/package.json b/clients/tui/package.json index 9a3278bc..18f71e8a 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -27,8 +27,6 @@ "format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 22c54dd7..ace9657b 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -15,11 +15,6 @@ "@mantine/form": "^8.3.17", "@mantine/hooks": "^8.3.17", "@mantine/notifications": "^8.3.17", - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", @@ -64,7 +59,6 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.5.5", - "express": "^5.2.1", "globals": "^17.4.0", "happy-dom": "^20.9.0", "playwright": "^1.58.2", @@ -1440,146 +1434,6 @@ "react": ">=16" } }, - "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", - "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "jose": "^6.1.3", - "pkce-challenge": "^5.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", - "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", - "license": "MIT", - "dependencies": { - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", - "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", - "license": "MIT", - "workspaces": [ - "examples/*" - ], - "dependencies": { - "@standard-schema/spec": "^1.1.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", - "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", - "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", - "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "express-rate-limit": "^8.2.1", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "express": "^4.18.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - } - } - }, "node_modules/@napi-rs/keyring": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", @@ -3310,6 +3164,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/addon-a11y": { @@ -4528,19 +4383,6 @@ "dev": true, "license": "MIT" }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -4580,24 +4422,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4754,43 +4578,6 @@ "node": ">=6.0.0" } }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -4882,15 +4669,6 @@ "esbuild": ">=0.18" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4901,35 +4679,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5128,69 +4877,12 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -5220,6 +4912,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5330,15 +5023,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5407,26 +5091,6 @@ "csstype": "^3.0.2" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.399", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", @@ -5444,15 +5108,6 @@ "node": ">=14" } }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -5475,15 +5130,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -5500,18 +5146,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -5564,12 +5198,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5837,36 +5465,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -5877,68 +5475,6 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.6.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", - "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -6025,27 +5561,6 @@ "node": ">=16.0.0" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -6110,24 +5625,6 @@ "node": ">=0.4.x" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -6162,30 +5659,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -6195,19 +5668,6 @@ "node": ">=6" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -6252,18 +5712,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6301,18 +5749,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -6462,42 +5898,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6544,36 +5944,12 @@ "node": ">=8" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, - "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -6707,12 +6083,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -6732,6 +6102,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -6773,15 +6144,6 @@ "node": ">=8" } }, - "node_modules/jose": { - "version": "6.2.7", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz", - "integrity": "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -6829,13 +6191,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -7338,15 +6693,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -7629,31 +6975,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -8217,31 +7538,6 @@ ], "license": "MIT" }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -8355,15 +7651,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-releases": { "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", @@ -8383,18 +7670,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -8418,27 +7693,6 @@ "node": ">=14.0.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -8637,15 +7891,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8660,6 +7905,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8698,16 +7944,6 @@ "node": "20 || >=22" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -8800,15 +8036,6 @@ "node": ">= 6" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -9042,19 +8269,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9065,56 +8279,12 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -9629,22 +8799,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -9666,12 +8820,6 @@ "node": ">=10" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9691,61 +8839,11 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -9758,83 +8856,12 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -9911,15 +8938,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -10248,15 +9266,6 @@ "node": ">=14.0.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -10930,37 +9939,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -11110,15 +10088,6 @@ "node": ">= 10.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/unplugin": { "version": "2.3.11", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", @@ -11274,15 +10243,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -11559,6 +10519,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -11597,12 +10558,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", @@ -11685,16 +10640,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peer": true, - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/clients/web/package.json b/clients/web/package.json index a347376e..66ad0d4c 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -41,11 +41,6 @@ "@mantine/form": "^8.3.17", "@mantine/hooks": "^8.3.17", "@mantine/notifications": "^8.3.17", - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", @@ -87,7 +82,6 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.5.5", - "express": "^5.2.1", "globals": "^17.4.0", "happy-dom": "^20.9.0", "playwright": "^1.58.2", diff --git a/package-lock.json b/package-lock.json index 58f21e7a..81d9bd03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.8.0", + "express": "^5.2.1", "globals": "^17.7.0", "prettier": "3.8.4", "typescript": "~5.9.3", @@ -330,9 +331,9 @@ } }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.4.tgz", - "integrity": "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", + "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", "license": "MIT", "workspaces": [ "examples/*" @@ -359,13 +360,13 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "peer": true, "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -399,19 +400,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { - "version": "1.19.17", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", - "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@modelcontextprotocol/server": { "version": "2.0.0-beta.5", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", @@ -1267,7 +1255,6 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", - "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -1418,7 +1405,6 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", @@ -1480,7 +1466,6 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -1494,7 +1479,6 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -1602,7 +1586,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1634,7 +1617,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -1644,7 +1626,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.6.0" } @@ -1767,7 +1748,6 @@ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -1781,8 +1761,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/emoji-regex": { "version": "10.6.0", @@ -1795,7 +1774,6 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -1817,7 +1795,6 @@ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -1827,7 +1804,6 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -1837,7 +1813,6 @@ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -1859,8 +1834,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "2.0.0", @@ -2068,7 +2042,6 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -2099,7 +2072,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2242,7 +2214,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -2302,7 +2273,6 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -2312,7 +2282,6 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -2336,7 +2305,6 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2358,7 +2326,6 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -2383,7 +2350,6 @@ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", - "peer": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -2423,7 +2389,6 @@ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -2436,7 +2401,6 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -2449,7 +2413,6 @@ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", - "peer": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -2673,7 +2636,6 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.10" } @@ -2768,8 +2730,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/is-unicode-supported": { "version": "2.1.0", @@ -3146,7 +3107,6 @@ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -3156,7 +3116,6 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -3166,7 +3125,6 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3179,7 +3137,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -3189,7 +3146,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -3262,7 +3218,6 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -3281,7 +3236,6 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -3303,7 +3257,6 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", - "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -3316,7 +3269,6 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", - "peer": true, "dependencies": { "wrappy": "1" } @@ -3409,7 +3361,6 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -3447,7 +3398,6 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -3592,7 +3542,6 @@ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", - "peer": true, "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -3616,7 +3565,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -3638,7 +3586,6 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -3767,7 +3714,6 @@ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -3830,7 +3776,6 @@ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", @@ -3857,7 +3802,6 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", - "peer": true, "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -3904,7 +3848,6 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -3924,7 +3867,6 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" @@ -3941,7 +3883,6 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -3960,7 +3901,6 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -4214,7 +4154,6 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", - "peer": true, "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", @@ -4460,8 +4399,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/ws": { "version": "8.21.0", diff --git a/package.json b/package.json index d3467cf6..f165824a 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.8.0", + "express": "^5.2.1", "globals": "^17.7.0", "prettier": "3.8.4", "typescript": "~5.9.3", diff --git a/vitest.shared.mts b/vitest.shared.mts index 060e099f..f52919cc 100644 --- a/vitest.shared.mts +++ b/vitest.shared.mts @@ -82,9 +82,20 @@ export function vitestSharedPaths(clientDir: string) { find: /^@napi-rs\/keyring$/, replacement: path.resolve(dirname, "node_modules/@napi-rs/keyring"), }, + // `express` and `yaml` resolve from the **repo root**, unlike every pin + // above. Both are reached only through root-owned code that has no manifest + // of its own — `test-servers/src` imports express, and `core`/the root + // tooling uses yaml — so the root is where they are declared and the only + // place a client's resolution chain is guaranteed to find them. Pointing + // these at `/node_modules` is what broke when the MCP packages moved + // to the root (#1970): express was never declared by a client at all, it + // arrived in `clients/cli` as a peer of `express-rate-limit` under + // `@modelcontextprotocol/server-legacy`, so removing that manifest entry + // took express with it and every cli test that spawns a test server failed + // to resolve it. { find: /^express$/, - replacement: path.resolve(dirname, "node_modules/express"), + replacement: path.resolve(repoRoot, "node_modules/express"), }, { find: /^yaml$/, From e132d551e6bcacd69de8982e89385ee529d1d8a3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 09:26:17 -0400 Subject: [PATCH 37/73] docs: record where a dependency is declared, and why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the maintenance rules, a change to the dependency layout has to land in the docs with it. Adds the placement rule in three places, at three altitudes: - README (Setup): the rule plus the concrete drift it prevents. - AGENTS.md: a "Dependency placement" section — the MCP SDK packages are root only, the v1 SDK is not ours and must not become a dependency, and anything reached solely through root-owned code with no manifest (`test-servers/src`, `core/`) is a root devDependency aliased to the repo root in `vitest.shared.mts`. - .github/copilot-instructions.md: the distilled, reviewer-citable form, so a diff re-adding one of these to a client manifest is flagged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 6 ++++++ AGENTS.md | 8 ++++++++ README.md | 2 ++ 3 files changed, 16 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aa1d6dd1..ccf73747 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -72,6 +72,12 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - `src/types/` is only for ambient `.d.ts` module stubs — not a home for new domain types. - ⚠️ The coverage `include` is a **whitelist** naming `components` / `hooks` / `theme` / `lib` / `utils` / `server` (plus the `core/*` runtime). A module placed outside those directories silently falls out of the ≥90% gate. Flag new top-level files or new grab-bag directories. +## Dependency placement + +- **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. +- **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. +- Dependencies used only by **root-owned code with no manifest** (`test-servers/src`, `core/`) go in the root `devDependencies` and are aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `/node_modules` like the other pins there. + ## Tests and the coverage gate - **All new or modified code needs tests.** The per-file gate is **≥ 90% on all four dimensions** — lines, statements, functions, **and branches** — enforced in CI for `clients/{web,cli,tui,launcher}` and the gated `core/` runtime. diff --git a/AGENTS.md b/AGENTS.md index a4185baa..164baff8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,14 @@ v2 is **not** an npm workspace — each client under `clients/*` keeps its own ` After installing, `npm run build` builds all clients. The launcher scripts (`npm run web` / `web:dev`) run the built launcher, so build first; for day-to-day web iteration use `cd clients/web && npm run dev`. +## Dependency placement + +**The MCP SDK packages — `@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps` — are declared in the repo-root `package.json` and nowhere else.** Do not add them to a `clients/*/package.json`: Node resolution walks up, so the root install already serves every client, and a per-client declaration installs a *second copy* that drifts from the root's. That drift is not theoretical — it put two versions of `ext-apps` (1.7.4 / 1.7.5), and of the transitive v1 `@modelcontextprotocol/sdk` (1.29.0 / 1.30.0), in the tree at once (#1970), and a second copy of `client`/`core` is exactly the failure the `dedupe` + `server.deps.inline` workaround in `vitest.shared.mts` exists for. + +The same rule covers anything reached only through **root-owned code that has no manifest of its own**: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, and `vitest.shared.mts` aliases both to the **repo root** rather than to `/node_modules` like its other pins. If you add a dependency to `test-servers/src` or `core/`, declare it at the root and alias it there too. + +The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one — v2 uses the packages above. It appears in the lock files only as a `"peer": true` entry pulled in by `ext-apps`. + ## Contributing External contributions are accepted as **issues, not pull requests** — maintainers handle design and implementation through a prompt-driven workflow. diff --git a/README.md b/README.md index 13792764..8ae07537 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ npm install # root install; postinstall cascades into 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. +**Where a dependency is declared.** The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) live in the **root** `package.json` only — never in a client's. Node resolution walks up, so the root install is on every client's chain, and the root manifest is already what the published tarball resolves against. Declaring them per client installs a second copy that can drift from the root's, which is how two versions of `ext-apps` (and of the transitive v1 `@modelcontextprotocol/sdk`) ended up in the tree before [#1970](https://github.com/modelcontextprotocol/inspector/issues/1970) — and a second copy of `client`/`core` is the failure `vitest.shared.mts` carries a `dedupe` workaround for. The same holds for anything used only by root-owned code with no manifest of its own: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, resolved from the root by `vitest.shared.mts`. + ## Running during development For day-to-day web iteration, run Vite directly from the web client (fast HMR, no launcher build needed): From 5a3a3436ea05e9b26d7728266c61515d5500d220 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 09:35:04 -0400 Subject: [PATCH 38/73] docs: correct the dependency-classification claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 2, four findings, all factual errors in the docs added by the previous commit: - `yaml` is in the root `dependencies`, not `devDependencies`, and its only importer is `test-servers/src/load-config.ts` — not `core/` or the root tooling, as the `vitest.shared.mts` comment claimed. - The rule as written told a reader to put anything reached through root-owned code — `core/` included — in `devDependencies`. That is wrong and would break the published package silently: the client builds externalize npm packages, so a published install resolves them from the root manifest, where devDependencies are absent. Placement (which manifest) and classification (which section) are now stated as separate questions, the second following from who consumes the package at runtime. `yaml`'s runtime classification is left alone and called out instead: moving it changes what ships, which is not a docs change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 3 ++- AGENTS.md | 8 +++++++- README.md | 2 +- vitest.shared.mts | 20 ++++++++++---------- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ccf73747..8f049824 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -76,7 +76,8 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. - **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. -- Dependencies used only by **root-owned code with no manifest** (`test-servers/src`, `core/`) go in the root `devDependencies` and are aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `/node_modules` like the other pins there. +- Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `/node_modules` like the other pins there. +- **Which section is a separate question from which manifest.** A package `core/` imports at runtime must be in root **`dependencies`**: client builds externalize npm packages, so a published install resolves them from the root manifest and devDependencies are absent there. Only test/build-only packages (`express`) belong in `devDependencies`. Flag a runtime `core/` import added to `devDependencies` — it passes every local check and breaks the published package. ## Tests and the coverage gate diff --git a/AGENTS.md b/AGENTS.md index 164baff8..31d0fbed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,13 @@ After installing, `npm run build` builds all clients. The launcher scripts (`npm **The MCP SDK packages — `@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps` — are declared in the repo-root `package.json` and nowhere else.** Do not add them to a `clients/*/package.json`: Node resolution walks up, so the root install already serves every client, and a per-client declaration installs a *second copy* that drifts from the root's. That drift is not theoretical — it put two versions of `ext-apps` (1.7.4 / 1.7.5), and of the transitive v1 `@modelcontextprotocol/sdk` (1.29.0 / 1.30.0), in the tree at once (#1970), and a second copy of `client`/`core` is exactly the failure the `dedupe` + `server.deps.inline` workaround in `vitest.shared.mts` exists for. -The same rule covers anything reached only through **root-owned code that has no manifest of its own**: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, and `vitest.shared.mts` aliases both to the **repo root** rather than to `/node_modules` like its other pins. If you add a dependency to `test-servers/src` or `core/`, declare it at the root and alias it there too. +The same **placement** rule covers anything reached only through **root-owned code that has no manifest of its own** (`test-servers/src`, `core/`): declare it at the root, and alias it to the **repo root** in `vitest.shared.mts` rather than to `/node_modules` like that file's other pins. `express` and `yaml` are the two today — both reached through `test-servers/src` (express by the http/oauth servers, yaml by `load-config.ts`). + +**Placement and classification are separate questions.** Which *section* of the root manifest it goes in follows from who consumes it at runtime: + +- A package `core/` imports at runtime belongs in root **`dependencies`**. The client builds bundle `core/` but externalize npm packages, so a published install resolves them from the root manifest — and devDependencies are not installed for consumers, so a runtime import parked there breaks the published package while passing every local check. +- A package only the tests, the test servers, or the build tooling need belongs in **`devDependencies`** — `express`, added there by #1970. +- `yaml` is in `dependencies` today even though its only importer is `test-servers/src/load-config.ts`. Left as-is deliberately (moving it changes what ships, which is not a docs change); if you touch it, confirm no published path reads YAML first. The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one — v2 uses the packages above. It appears in the lock files only as a `"peer": true` entry pulled in by `ext-apps`. diff --git a/README.md b/README.md index 8ae07537..1237e541 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ npm install # root install; postinstall cascades into 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. -**Where a dependency is declared.** The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) live in the **root** `package.json` only — never in a client's. Node resolution walks up, so the root install is on every client's chain, and the root manifest is already what the published tarball resolves against. Declaring them per client installs a second copy that can drift from the root's, which is how two versions of `ext-apps` (and of the transitive v1 `@modelcontextprotocol/sdk`) ended up in the tree before [#1970](https://github.com/modelcontextprotocol/inspector/issues/1970) — and a second copy of `client`/`core` is the failure `vitest.shared.mts` carries a `dedupe` workaround for. The same holds for anything used only by root-owned code with no manifest of its own: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, resolved from the root by `vitest.shared.mts`. +**Where a dependency is declared.** The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) live in the **root** `package.json` only — never in a client's. Node resolution walks up, so the root install is on every client's chain, and the root manifest is already what the published tarball resolves against. Declaring them per client installs a second copy that can drift from the root's, which is how two versions of `ext-apps` (and of the transitive v1 `@modelcontextprotocol/sdk`) ended up in the tree before [#1970](https://github.com/modelcontextprotocol/inspector/issues/1970) — and a second copy of `client`/`core` is the failure `vitest.shared.mts` carries a `dedupe` workaround for. The same root-only placement holds for anything reached solely through root-owned code with no manifest of its own (`test-servers/src`, `core/`), and `vitest.shared.mts` aliases those to the repo root — `express` and `yaml`, both reached through `test-servers/src`, are the two today. **Whether such a package is a `dependency` or a `devDependency` follows from who consumes it at runtime, not from where it is declared:** anything `core/` imports at runtime must be a root **`dependency`**, because the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent. `express` is test-only and is a devDependency; `yaml` currently sits in `dependencies`. ## Running during development diff --git a/vitest.shared.mts b/vitest.shared.mts index f52919cc..1ebbc96a 100644 --- a/vitest.shared.mts +++ b/vitest.shared.mts @@ -83,16 +83,16 @@ export function vitestSharedPaths(clientDir: string) { replacement: path.resolve(dirname, "node_modules/@napi-rs/keyring"), }, // `express` and `yaml` resolve from the **repo root**, unlike every pin - // above. Both are reached only through root-owned code that has no manifest - // of its own — `test-servers/src` imports express, and `core`/the root - // tooling uses yaml — so the root is where they are declared and the only - // place a client's resolution chain is guaranteed to find them. Pointing - // these at `/node_modules` is what broke when the MCP packages moved - // to the root (#1970): express was never declared by a client at all, it - // arrived in `clients/cli` as a peer of `express-rate-limit` under - // `@modelcontextprotocol/server-legacy`, so removing that manifest entry - // took express with it and every cli test that spawns a test server failed - // to resolve it. + // above. Both are reached only through `test-servers/src` — express by the + // http/oauth servers, yaml by `load-config.ts` — which is root-owned code + // with no manifest of its own, so the root is where they are declared and + // the only place a client's resolution chain is guaranteed to find them. + // Pointing these at `/node_modules` is what broke when the MCP + // packages moved to the root (#1970): express was never declared by a client + // at all, it arrived in `clients/cli` as a peer of `express-rate-limit` + // under `@modelcontextprotocol/server-legacy`, so removing that manifest + // entry took express with it and every cli test that spawns a test server + // failed to resolve it. { find: /^express$/, replacement: path.resolve(repoRoot, "node_modules/express"), From 29e3ce95b826ac13ef34ef348ad4e3924661f94c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 12:21:14 -0400 Subject: [PATCH 39/73] chore(smoke): run the web smokes against a throwaway catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startProdWebServer` spread `process.env` without setting `MCP_CATALOG_PATH`, so `smoke:web`, `smoke:web:browser`, and `smoke:web:app` all ran against the developer's real `~/.mcp-inspector/mcp.json` — unlike `smoke:cli` / `smoke:tui`, which have always driven a temp `--catalog`. That made the web smokes both destructive and non-deterministic. `smoke:web:app`'s deep link persists a `deep-link` server row, so a second run finds it already on disk, racing hydration: the deep-link effect misses it in `servers`, POSTs `addServer` anyway, and the backend answers 409. The app swallows that 409 by design (App.tsx:2842) so the smoke still passed, but it reported a spurious non-fatal console error that was really just residue from the previous run. Give the helper its own temp catalog dir and clean it up in `stop()` via `removeSafe`, so cleanup can never turn a passing smoke red. Isolation lives in the shared helper rather than per script, since every web smoke needs it. Only the catalog is redirected — other per-user state under `~/.mcp-inspector` stays shared, since isolating it means redirecting HOME wholesale, which would also move the npx and Playwright caches these smokes depend on. Verified: `smoke:web:app` twice in a row is now clean both times (previously the second run reported the 409), the real catalog's mtime is unchanged, and no temp dirs leak. CI never saw this — a fresh HOME per run made every CI run look like a first run. Closes #1977 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- scripts/lib/prod-web-server.mjs | 46 ++++++++++++++++++++++++++++----- scripts/smoke-web-app.mjs | 7 ++++- scripts/smoke-web-browser.mjs | 7 ++++- scripts/smoke-web.mjs | 7 ++++- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 254410ef..2991d588 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -1,10 +1,12 @@ /** * Shared boot/readiness helper for the prod web smokes. * - * Both `scripts/smoke-web.mjs` (serves-the-HTML check) and - * `scripts/smoke-web-browser.mjs` (runs-the-bundle check, #1615) boot the *same* - * prod `mcp-inspector --web` server, so the spawn + readiness-poll boilerplate - * lives here once instead of being copy-pasted (and drifting) in each script. + * `scripts/smoke-web.mjs` (serves-the-HTML check), `scripts/smoke-web-browser.mjs` + * (runs-the-bundle check, #1615), and `scripts/smoke-web-app.mjs` (MCP Apps + * end-to-end, #1859) all boot the *same* prod `mcp-inspector --web` server, so the + * spawn + readiness-poll boilerplate lives here once instead of being copy-pasted + * (and drifting) in each script. Catalog isolation (#1977) lives here for the same + * reason — it is a property every web smoke needs, not one script's concern. * * Repo-root paths are derived from import.meta.url, so a caller's cwd (e.g. * `smoke:web:browser` does `cd clients/web` first so its `npx playwright @@ -12,9 +14,13 @@ */ import { spawn } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; + +import { removeSafe } from "./child-cleanup.mjs"; const libDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(libDir, "..", ".."); @@ -24,20 +30,43 @@ const launcherEntry = resolve(repoRoot, "clients/launcher/build/index.js"); * Spawn `mcp-inspector --web` (prod, no `--dev`) against the built * `clients/web/dist` and return handles for readiness + teardown. * + * The server always runs against a **throwaway catalog**, never the developer's + * real `~/.mcp-inspector/mcp.json` (#1977). Without `MCP_CATALOG_PATH` the web + * backend falls back to that default writable catalog, which made these smokes + * both destructive and non-deterministic: `smoke:web:app`'s deep link persists a + * `deep-link` server row, so a second run finds it already on disk. The row is + * then racing hydration — the deep-link effect misses it in `servers`, POSTs + * `addServer` anyway, and the backend answers 409. The app swallows that 409 by + * design so the smoke still passes, but it reports a spurious non-fatal console + * error that is really just residue from the previous run. Isolating the catalog + * makes every run look like the first one, and mirrors `smoke:cli` / `smoke:tui`, + * which have always driven a temp `--catalog`. + * + * Only the catalog is redirected. Other per-user state under `~/.mcp-inspector` + * (OAuth tokens, the `storage/` dir) is still shared — isolating that means + * redirecting HOME wholesale, which these smokes deliberately do not do, since + * HOME also resolves the npx and Playwright caches they depend on. + * * @param {object} opts * @param {string} opts.host * @param {string} opts.port * @param {string} opts.token value injected as MCP_INSPECTOR_API_TOKEN + * @param {string} [opts.label] prefix for the temp dir + cleanup warnings */ -export function startProdWebServer({ host, port, token }) { +export function startProdWebServer({ host, port, token, label = "smoke:web" }) { const baseUrl = `http://${host}:${port}`; + // The file need not exist — the backend seeds an empty catalog on first run. + const catalogDir = mkdtempSync(join(tmpdir(), "smoke-web-catalog-")); + const catalogPath = join(catalogDir, "catalog.json"); + const child = spawn(process.execPath, [launcherEntry, "--web"], { env: { ...process.env, CLIENT_PORT: port, HOST: host, MCP_INSPECTOR_API_TOKEN: token, + MCP_CATALOG_PATH: catalogPath, // Don't pop a browser in CI. MCP_AUTO_OPEN_ENABLED: "false", }, @@ -119,10 +148,15 @@ export function startProdWebServer({ host, port, token }) { return { baseUrl, + catalogPath, waitForReady, whenChildExits, stop: () => { if (!exited) child.kill("SIGTERM"); + // SIGTERM is not awaited, so the server may still hold the catalog for a + // moment. `removeSafe` warns rather than throws, so the worst case is a + // leaked temp dir the OS reclaims — never a red smoke over cleanup. + removeSafe(catalogDir, { label }); }, }; } diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index 73ca5f03..dce98805 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -104,7 +104,12 @@ let mcpUrl = null; let mcpServer = null; let browser = null; -const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN }); +const server = startProdWebServer({ + host: HOST, + port: PORT, + token: TOKEN, + label: "smoke:web:app", +}); async function shutdown() { if (browser) { diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index 1188865c..dbc90844 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -78,7 +78,12 @@ const TOKEN = "smoke-web-browser-token"; // the font/CDN flake. const FATAL_CONSOLE = /^Uncaught\b|Failed to fetch dynamically imported module/; -const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN }); +const server = startProdWebServer({ + host: HOST, + port: PORT, + token: TOKEN, + label: "smoke:web:browser", +}); let browser = null; async function shutdown() { diff --git a/scripts/smoke-web.mjs b/scripts/smoke-web.mjs index 143ba925..2fbcfb44 100644 --- a/scripts/smoke-web.mjs +++ b/scripts/smoke-web.mjs @@ -30,7 +30,12 @@ const TOKEN = "smoke-web-token"; // literal because this plain .mjs script can't import the TS source. const TOKEN_GLOBAL = "__INSPECTOR_API_TOKEN__"; -const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN }); +const server = startProdWebServer({ + host: HOST, + port: PORT, + token: TOKEN, + label: "smoke:web", +}); function fail(message) { console.error(`smoke:web FAILED — ${message}`); From 16c96d44c33d876df1bc6badbd426909a299fdae Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 14:25:09 -0400 Subject: [PATCH 40/73] fix(smoke): await the server's exit before removing its catalog, and test it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both Copilot review comments on #1978. 1. `stop()` removed the catalog dir immediately after a bare `child.kill()`, which only *delivers* SIGTERM. That is precisely the #1801 race `child-cleanup.mjs:11-17` documents, and it says both halves are needed: `stopChild()` closes the race on the normal path, `removeSafe()` makes the residual case harmless. I had used only the second. `smoke-web-app.mjs` already used `stopChild` for its MCP test-server child, so the helper was inconsistent with its own sibling. `stop()` is now async — it awaits `stopChild` and then removes — and all three callers await it (`smoke-web.mjs`'s `fail()` became async to match, so every call site had to `await fail(...)` or execution would run past it instead of exiting). 2. The isolation contract had no test. The smokes exit the process right after teardown, so a regression reintroducing the shared catalog — or dropping cleanup — would leave them all green. Split the two pure pieces out (`createTempCatalog`, `buildWebServerEnv`) and cover them in a sibling `prod-web-server.test.mjs`: a unique dir per run, no pre-created catalog file, the dir is removable, and an inherited `MCP_CATALOG_PATH` (or `MCP_AUTO_OPEN_ENABLED`) cannot beat the explicit one — the spread-then-assign ordering that guard depends on. `startProdWebServer` itself stays untested by design; it spawns a real launcher, which is the smokes' job. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- scripts/lib/prod-web-server.mjs | 84 ++++++++++++++++++----- scripts/lib/prod-web-server.test.mjs | 99 ++++++++++++++++++++++++++++ scripts/smoke-web-app.mjs | 2 +- scripts/smoke-web-browser.mjs | 2 +- scripts/smoke-web.mjs | 17 +++-- 5 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 scripts/lib/prod-web-server.test.mjs diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 2991d588..6365a155 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -20,12 +20,64 @@ import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; -import { removeSafe } from "./child-cleanup.mjs"; +import { removeSafe, stopChild } from "./child-cleanup.mjs"; const libDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(libDir, "..", ".."); const launcherEntry = resolve(repoRoot, "clients/launcher/build/index.js"); +/** + * Mint a fresh throwaway catalog for one server run. + * + * `mkdtemp` (not a fixed name) is what makes concurrent runs safe: the three web + * smokes run back-to-back today, but a fixed path would have them share — and + * silently reintroduce the cross-run bleed this whole change removes. + * + * The catalog *file* is deliberately not created. The backend seeds an empty + * catalog on first use, which is exactly the first-run state we want every run + * to start from. + * + * @returns {{ dir: string, path: string }} + */ +export function createTempCatalog() { + const dir = mkdtempSync(join(tmpdir(), "smoke-web-catalog-")); + return { dir, path: join(dir, "catalog.json") }; +} + +/** + * Build the child env for the prod web server. + * + * Split out from the spawn so the isolation contract is unit-testable without + * starting a server (#1977). The ordering is load-bearing: `MCP_CATALOG_PATH` + * is assigned *after* the `process.env` spread, so an inherited value from the + * developer's shell is overridden rather than silently winning — which would + * put the smoke straight back on whatever catalog that variable names. + * + * @param {object} opts + * @param {string} opts.host + * @param {string} opts.port + * @param {string} opts.token + * @param {string} opts.catalogPath + * @param {NodeJS.ProcessEnv} [opts.baseEnv] + */ +export function buildWebServerEnv({ + host, + port, + token, + catalogPath, + baseEnv = process.env, +}) { + return { + ...baseEnv, + CLIENT_PORT: port, + HOST: host, + MCP_INSPECTOR_API_TOKEN: token, + MCP_CATALOG_PATH: catalogPath, + // Don't pop a browser in CI. + MCP_AUTO_OPEN_ENABLED: "false", + }; +} + /** * Spawn `mcp-inspector --web` (prod, no `--dev`) against the built * `clients/web/dist` and return handles for readiness + teardown. @@ -56,20 +108,10 @@ const launcherEntry = resolve(repoRoot, "clients/launcher/build/index.js"); export function startProdWebServer({ host, port, token, label = "smoke:web" }) { const baseUrl = `http://${host}:${port}`; - // The file need not exist — the backend seeds an empty catalog on first run. - const catalogDir = mkdtempSync(join(tmpdir(), "smoke-web-catalog-")); - const catalogPath = join(catalogDir, "catalog.json"); + const { dir: catalogDir, path: catalogPath } = createTempCatalog(); const child = spawn(process.execPath, [launcherEntry, "--web"], { - env: { - ...process.env, - CLIENT_PORT: port, - HOST: host, - MCP_INSPECTOR_API_TOKEN: token, - MCP_CATALOG_PATH: catalogPath, - // Don't pop a browser in CI. - MCP_AUTO_OPEN_ENABLED: "false", - }, + env: buildWebServerEnv({ host, port, token, catalogPath }), stdio: ["ignore", "inherit", "inherit"], }); @@ -151,11 +193,17 @@ export function startProdWebServer({ host, port, token, label = "smoke:web" }) { catalogPath, waitForReady, whenChildExits, - stop: () => { - if (!exited) child.kill("SIGTERM"); - // SIGTERM is not awaited, so the server may still hold the catalog for a - // moment. `removeSafe` warns rather than throws, so the worst case is a - // leaked temp dir the OS reclaims — never a red smoke over cleanup. + /** + * Terminate the server, then remove its catalog dir. **Await this** — the + * two halves are the documented pair in `child-cleanup.mjs`, and both are + * needed. `stopChild` closes the #1801 race on the normal path (a bare + * `kill()` only *delivers* the signal, so a synchronous remove can hit + * ENOTEMPTY when the server writes the catalog on its way out); `removeSafe` + * then makes the residual case harmless, warning instead of throwing so a + * leftover temp dir can never turn a passing smoke red. + */ + stop: async () => { + await stopChild(child, { label, what: "prod web server" }); removeSafe(catalogDir, { label }); }, }; diff --git a/scripts/lib/prod-web-server.test.mjs b/scripts/lib/prod-web-server.test.mjs new file mode 100644 index 00000000..bcf65f25 --- /dev/null +++ b/scripts/lib/prod-web-server.test.mjs @@ -0,0 +1,99 @@ +/** + * Unit tests for the prod-web-server helper's catalog-isolation contract (#1977). + * + * The smokes themselves cannot guard this: they exercise startup and then exit + * the process immediately after teardown, so a regression that reintroduced the + * shared catalog — or that stopped cleaning up — would still leave every smoke + * green. These lock down the three properties that make isolation real: each run + * gets its own catalog, an inherited `MCP_CATALOG_PATH` cannot win, and the temp + * dir is removable on stop. + * + * `startProdWebServer` itself is not unit-tested — it spawns a real launcher, so + * its contract is the smokes' job. What is tested here is exactly the part that + * decides *which catalog the server sees*, which is pure. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { buildWebServerEnv, createTempCatalog } from "./prod-web-server.mjs"; +import { removeSafe } from "./child-cleanup.mjs"; + +const BASE = { + host: "127.0.0.1", + port: "6299", + token: "test-token", + catalogPath: "/tmp/example/catalog.json", +}; + +test("createTempCatalog: mints a unique dir per call", () => { + const a = createTempCatalog(); + const b = createTempCatalog(); + try { + assert.notEqual(a.dir, b.dir, "two runs must not share a catalog dir"); + assert.equal(dirname(a.path), a.dir); + assert.ok(existsSync(a.dir)); + assert.ok(existsSync(b.dir)); + } finally { + removeSafe(a.dir); + removeSafe(b.dir); + } +}); + +test("createTempCatalog: does not create the catalog file itself", () => { + // The backend seeds an empty catalog on first use; pre-creating the file would + // hand it a zero-byte file to parse instead of the clean first-run state. + const { dir, path } = createTempCatalog(); + try { + assert.equal(existsSync(path), false); + } finally { + removeSafe(dir); + } +}); + +test("createTempCatalog: its dir is removable by removeSafe", () => { + const { dir, path } = createTempCatalog(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "{}"); + assert.equal(removeSafe(dir), true); + assert.equal(existsSync(dir), false); +}); + +test("buildWebServerEnv: points the server at the given catalog", () => { + const env = buildWebServerEnv({ ...BASE, baseEnv: {} }); + assert.equal(env.MCP_CATALOG_PATH, BASE.catalogPath); +}); + +test("buildWebServerEnv: overrides an inherited MCP_CATALOG_PATH", () => { + // The regression this guards: assigning before the spread would let a + // developer's exported MCP_CATALOG_PATH win, putting the smoke back on a + // real catalog while every test still passed. + const env = buildWebServerEnv({ + ...BASE, + baseEnv: { MCP_CATALOG_PATH: "/home/dev/.mcp-inspector/mcp.json" }, + }); + assert.equal(env.MCP_CATALOG_PATH, BASE.catalogPath); +}); + +test("buildWebServerEnv: passes through unrelated inherited vars", () => { + const env = buildWebServerEnv({ ...BASE, baseEnv: { PATH: "/usr/bin" } }); + assert.equal(env.PATH, "/usr/bin"); +}); + +test("buildWebServerEnv: sets host, port, token, and disables auto-open", () => { + const env = buildWebServerEnv({ ...BASE, baseEnv: {} }); + assert.equal(env.HOST, BASE.host); + assert.equal(env.CLIENT_PORT, BASE.port); + assert.equal(env.MCP_INSPECTOR_API_TOKEN, BASE.token); + assert.equal(env.MCP_AUTO_OPEN_ENABLED, "false"); +}); + +test("buildWebServerEnv: an inherited auto-open setting cannot re-enable it", () => { + const env = buildWebServerEnv({ + ...BASE, + baseEnv: { MCP_AUTO_OPEN_ENABLED: "true" }, + }); + assert.equal(env.MCP_AUTO_OPEN_ENABLED, "false"); +}); diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index dce98805..c5f2f8a6 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -120,7 +120,7 @@ async function shutdown() { } browser = null; } - server.stop(); + await server.stop(); if (mcpServer) { const child = mcpServer; mcpServer = null; diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index dbc90844..ade88c54 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -95,7 +95,7 @@ async function shutdown() { } browser = null; } - server.stop(); + await server.stop(); } async function fail(message) { diff --git a/scripts/smoke-web.mjs b/scripts/smoke-web.mjs index 2fbcfb44..867ae4ff 100644 --- a/scripts/smoke-web.mjs +++ b/scripts/smoke-web.mjs @@ -37,31 +37,34 @@ const server = startProdWebServer({ label: "smoke:web", }); -function fail(message) { +// Async because `server.stop()` awaits the child's exit before removing its temp +// catalog — so every call site must `await fail(...)`, or execution would run on +// past it instead of exiting. +async function fail(message) { console.error(`smoke:web FAILED — ${message}`); - server.stop(); + await server.stop(); process.exit(1); } try { const res = await server.waitForReady(); if (res.status !== 200) { - fail(`GET / returned HTTP ${res.status}, expected 200`); + await fail(`GET / returned HTTP ${res.status}, expected 200`); } const body = await res.text(); if (!body.includes(TOKEN_GLOBAL)) { - fail( + await fail( `served HTML is missing the ${TOKEN_GLOBAL} global (token not injected)`, ); } if (!body.includes(TOKEN)) { - fail("served HTML is missing the injected auth-token value"); + await fail("served HTML is missing the injected auth-token value"); } console.log( `smoke:web OK — GET / => 200 with injected ${TOKEN_GLOBAL} at ${server.baseUrl}`, ); - server.stop(); + await server.stop(); process.exit(0); } catch (err) { - fail(err instanceof Error ? err.message : String(err)); + await fail(err instanceof Error ? err.message : String(err)); } From 4a993fc45a93f74ce1b423d9f1ad355fd83c0a36 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 14:32:20 -0400 Subject: [PATCH 41/73] docs(smoke): document the throwaway-catalog invariant and fix the label JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two review comments that still applied to HEAD (the third, the stop() race, was already fixed in a8022138). - AGENTS.md still described the prod-web-server helper as shared by only smoke:web and smoke:web:browser, and said nothing about catalog isolation. It is the source of truth for these smoke mechanics, so record the invariant there: the temp catalog per run and why, that only the catalog (not HOME) is redirected, that teardown needs both halves of child-cleanup.mjs and is therefore async for every caller, and where the contract is unit-tested. - The `label` JSDoc claimed the option prefixes the temp dir. It does not — that prefix is fixed at `smoke-web-catalog-` (deliberately, so every run's dir is greppable); `label` only prefixes teardown warnings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- AGENTS.md | 4 +++- scripts/lib/prod-web-server.mjs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 829442c8..7a864308 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -657,7 +657,9 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. -- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with `smoke:web:browser`, so the two can't drift. +- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with **`smoke:web:browser` and `smoke:web:app`**, so the three can't drift. + + **Every web smoke runs against a throwaway catalog (#1977).** The helper mints a temp dir per run and passes it as `MCP_CATALOG_PATH`; without it the web backend falls back to the developer's real `~/.mcp-inspector/mcp.json`, which made these smokes both destructive and non-deterministic — `smoke:web:app`'s deep link persists a `deep-link` server row, so a *second* run found it already on disk, raced hydration, and drew a spurious (swallowed, non-fatal) 409 that was really just residue from the previous run. CI never saw it: a fresh `HOME` per run made every CI run look like a first run. This matches `smoke:cli` / `smoke:tui`, which have always driven a temp `--catalog`. Only the **catalog** is redirected — other per-user state under `~/.mcp-inspector` (OAuth tokens, `storage/`) stays shared, because isolating it means redirecting `HOME` wholesale, which would also move the npx and Playwright caches these smokes depend on. Teardown uses **both** halves of `scripts/lib/child-cleanup.mjs` (`stopChild` to await the child's exit, then `removeSafe` to delete the dir) — a bare `kill()` only *delivers* the signal, so removing synchronously re-enters the #1801 ENOTEMPTY race. That makes `stop()` **async**, so every caller must `await` it (and a caller's own `fail()`/`shutdown()` becomes async in turn, or execution runs past the intended exit). The isolation contract's pure halves — `createTempCatalog` and `buildWebServerEnv` — are unit-tested in `scripts/lib/prod-web-server.test.mjs` via `test:scripts`, since the smokes exit immediately after teardown and so cannot detect a regression that silently reshared the catalog. - `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a _class_ (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first _call_ into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A _synchronous_ such throw fires `pageerror`; its _async_ twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never _called_ ships a harmless `{}` and is invisible here by design. Every _other_ console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). - `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=&openApp=…&appArgs=…&autoOpen=`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe _and_ fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. **Scope note:** this runs against the repo build tree like every other smoke, so it would _not_ have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. - **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's _browser-externalization warning_ (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the _called-at-init_ case (which `smoke:web:browser` also catches, but later/at runtime) **and** the _imported-but-never-called_ case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin _records_ the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 6365a155..708933ee 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -103,7 +103,8 @@ export function buildWebServerEnv({ * @param {string} opts.host * @param {string} opts.port * @param {string} opts.token value injected as MCP_INSPECTOR_API_TOKEN - * @param {string} [opts.label] prefix for the temp dir + cleanup warnings + * @param {string} [opts.label] prefix for teardown warnings (the temp dir's own + * prefix is fixed, so every run's dir is greppable as `smoke-web-catalog-*`) */ export function startProdWebServer({ host, port, token, label = "smoke:web" }) { const baseUrl = `http://${host}:${port}`; From 3d541388db763bd655097ef4b91fc34d40743db0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 14:51:12 -0400 Subject: [PATCH 42/73] test(smoke): exercise the real teardown path, not just removeSafe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot was right that the previous test proved only that `removeSafe` can delete a directory — it never drove `stop()`, so deleting the cleanup call would have left the unit suite AND all three smokes green. That is exactly the regression these tests exist to catch, and they did not catch it. Extract the teardown into an exported `teardownWebServer({ child, catalogDir, label })`, which `stop()` now delegates to, and drive it against a stand-in child process (a real `node -e setInterval` that stays alive until signalled) rather than a mock. Three cases: - it removes the catalog dir it was given (asserted on the dir itself, not a spy, so the assertion cannot drift from the behavior); - it awaits the child's exit before removing — the #1801 race in one assertion, since losing the await is otherwise invisible; - an already-dead child is not an error, which is the failure path a smoke takes when it calls fail() after the launcher has crashed. Mutation-checked: with the `removeSafe` call deleted, 2 of the 3 now fail (previously: 0). AGENTS.md updated to match. Signed-off-by is now on every commit (#1978 DCO); the requirement itself is undocumented and tracked in #1979. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- AGENTS.md | 2 +- scripts/lib/prod-web-server.mjs | 45 ++++++++++++++------ scripts/lib/prod-web-server.test.mjs | 62 +++++++++++++++++++++++++--- 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7a864308..5f9e44bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -659,7 +659,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. - `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with **`smoke:web:browser` and `smoke:web:app`**, so the three can't drift. - **Every web smoke runs against a throwaway catalog (#1977).** The helper mints a temp dir per run and passes it as `MCP_CATALOG_PATH`; without it the web backend falls back to the developer's real `~/.mcp-inspector/mcp.json`, which made these smokes both destructive and non-deterministic — `smoke:web:app`'s deep link persists a `deep-link` server row, so a *second* run found it already on disk, raced hydration, and drew a spurious (swallowed, non-fatal) 409 that was really just residue from the previous run. CI never saw it: a fresh `HOME` per run made every CI run look like a first run. This matches `smoke:cli` / `smoke:tui`, which have always driven a temp `--catalog`. Only the **catalog** is redirected — other per-user state under `~/.mcp-inspector` (OAuth tokens, `storage/`) stays shared, because isolating it means redirecting `HOME` wholesale, which would also move the npx and Playwright caches these smokes depend on. Teardown uses **both** halves of `scripts/lib/child-cleanup.mjs` (`stopChild` to await the child's exit, then `removeSafe` to delete the dir) — a bare `kill()` only *delivers* the signal, so removing synchronously re-enters the #1801 ENOTEMPTY race. That makes `stop()` **async**, so every caller must `await` it (and a caller's own `fail()`/`shutdown()` becomes async in turn, or execution runs past the intended exit). The isolation contract's pure halves — `createTempCatalog` and `buildWebServerEnv` — are unit-tested in `scripts/lib/prod-web-server.test.mjs` via `test:scripts`, since the smokes exit immediately after teardown and so cannot detect a regression that silently reshared the catalog. + **Every web smoke runs against a throwaway catalog (#1977).** The helper mints a temp dir per run and passes it as `MCP_CATALOG_PATH`; without it the web backend falls back to the developer's real `~/.mcp-inspector/mcp.json`, which made these smokes both destructive and non-deterministic — `smoke:web:app`'s deep link persists a `deep-link` server row, so a *second* run found it already on disk, raced hydration, and drew a spurious (swallowed, non-fatal) 409 that was really just residue from the previous run. CI never saw it: a fresh `HOME` per run made every CI run look like a first run. This matches `smoke:cli` / `smoke:tui`, which have always driven a temp `--catalog`. Only the **catalog** is redirected — other per-user state under `~/.mcp-inspector` (OAuth tokens, `storage/`) stays shared, because isolating it means redirecting `HOME` wholesale, which would also move the npx and Playwright caches these smokes depend on. Teardown uses **both** halves of `scripts/lib/child-cleanup.mjs` (`stopChild` to await the child's exit, then `removeSafe` to delete the dir) — a bare `kill()` only *delivers* the signal, so removing synchronously re-enters the #1801 ENOTEMPTY race. That makes `stop()` **async**, so every caller must `await` it (and a caller's own `fail()`/`shutdown()` becomes async in turn, or execution runs past the intended exit). The isolation contract is unit-tested in `scripts/lib/prod-web-server.test.mjs` via `test:scripts`, since the smokes exit immediately after teardown and so cannot detect a regression that silently reshared the catalog or stopped cleaning up: `createTempCatalog` and `buildWebServerEnv` cover *which* catalog the server gets, and `teardownWebServer` — extracted from `stop()` for exactly this reason — is driven against a stand-in child process so the teardown asserts on the real directory rather than a spy. - `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a _class_ (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first _call_ into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A _synchronous_ such throw fires `pageerror`; its _async_ twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never _called_ ships a harmless `{}` and is invisible here by design. Every _other_ console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). - `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=&openApp=…&appArgs=…&autoOpen=`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe _and_ fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. **Scope note:** this runs against the repo build tree like every other smoke, so it would _not_ have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. - **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's _browser-externalization warning_ (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the _called-at-init_ case (which `smoke:web:browser` also catches, but later/at runtime) **and** the _imported-but-never-called_ case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin _records_ the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 708933ee..44986c2c 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -78,6 +78,36 @@ export function buildWebServerEnv({ }; } +/** + * Terminate a spawned web server, then remove its catalog dir. + * + * The two halves are the documented pair in `child-cleanup.mjs`, and both are + * needed. `stopChild` closes the #1801 race on the normal path (a bare `kill()` + * only *delivers* the signal, so a synchronous remove can hit ENOTEMPTY when the + * server writes the catalog on its way out); `removeSafe` then makes the + * residual case harmless, warning instead of throwing so a leftover temp dir can + * never turn a passing smoke red. + * + * Exported separately from `startProdWebServer` so the teardown *contract* is + * testable against a stand-in child, without booting a real launcher. Testing it + * matters more than it looks: deleting the `removeSafe` call would leave both the + * unit suite and all three smokes green (the smokes exit immediately after + * teardown), which is precisely the regression the tests exist to catch. + * + * @param {object} opts + * @param {import("node:child_process").ChildProcess} opts.child + * @param {string} opts.catalogDir + * @param {string} [opts.label] + */ +export async function teardownWebServer({ + child, + catalogDir, + label = "smoke:web", +}) { + await stopChild(child, { label, what: "prod web server" }); + removeSafe(catalogDir, { label }); +} + /** * Spawn `mcp-inspector --web` (prod, no `--dev`) against the built * `clients/web/dist` and return handles for readiness + teardown. @@ -194,18 +224,7 @@ export function startProdWebServer({ host, port, token, label = "smoke:web" }) { catalogPath, waitForReady, whenChildExits, - /** - * Terminate the server, then remove its catalog dir. **Await this** — the - * two halves are the documented pair in `child-cleanup.mjs`, and both are - * needed. `stopChild` closes the #1801 race on the normal path (a bare - * `kill()` only *delivers* the signal, so a synchronous remove can hit - * ENOTEMPTY when the server writes the catalog on its way out); `removeSafe` - * then makes the residual case harmless, warning instead of throwing so a - * leftover temp dir can never turn a passing smoke red. - */ - stop: async () => { - await stopChild(child, { label, what: "prod web server" }); - removeSafe(catalogDir, { label }); - }, + /** Terminate the server, then remove its catalog dir. **Await this.** */ + stop: () => teardownWebServer({ child, catalogDir, label }), }; } diff --git a/scripts/lib/prod-web-server.test.mjs b/scripts/lib/prod-web-server.test.mjs index bcf65f25..d3f46671 100644 --- a/scripts/lib/prod-web-server.test.mjs +++ b/scripts/lib/prod-web-server.test.mjs @@ -15,11 +15,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; -import { buildWebServerEnv, createTempCatalog } from "./prod-web-server.mjs"; -import { removeSafe } from "./child-cleanup.mjs"; +import { + buildWebServerEnv, + createTempCatalog, + teardownWebServer, +} from "./prod-web-server.mjs"; +import { hasExited, removeSafe } from "./child-cleanup.mjs"; const BASE = { host: "127.0.0.1", @@ -53,11 +59,55 @@ test("createTempCatalog: does not create the catalog file itself", () => { } }); -test("createTempCatalog: its dir is removable by removeSafe", () => { +/** + * A stand-in for the web server: a real child process that ignores nothing and + * simply stays alive until signalled, so `teardownWebServer` exercises its true + * SIGTERM→exit path rather than a mock's idea of one. + */ +function spawnIdleChild() { + return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + }); +} + +test("teardownWebServer: removes the catalog dir it was given", async () => { + // The regression this guards: dropping the `removeSafe` call from the teardown + // would leave every smoke green, since they exit right after teardown and so + // never observe the leak. Assert on the dir itself, not on a spy. + const child = spawnIdleChild(); const { dir, path } = createTempCatalog(); - mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, "{}"); - assert.equal(removeSafe(dir), true); + assert.ok(existsSync(dir)); + + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + + assert.equal(existsSync(dir), false, "teardown must remove the catalog dir"); +}); + +test("teardownWebServer: waits for the child to exit before removing", async () => { + // The #1801 race in one assertion: a bare kill() only *delivers* SIGTERM, so a + // teardown that removed synchronously could unlink the dir while the server was + // still writing to it. If teardown resolves with the child still alive, the + // await on stopChild has been lost. + const child = spawnIdleChild(); + const { dir } = createTempCatalog(); + assert.equal(hasExited(child), false, "child should start alive"); + + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + + assert.ok(hasExited(child), "teardown must await the child's exit"); +}); + +test("teardownWebServer: an already-dead child is not an error", async () => { + // The failure path: a smoke calls fail() after the launcher already crashed. + // Teardown still has to clean up rather than hang on an exit that never comes. + const child = spawnIdleChild(); + child.kill("SIGKILL"); + await once(child, "exit"); + const { dir } = createTempCatalog(); + + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + assert.equal(existsSync(dir), false); }); From 678184ed3e94aa0df9509ac9c87015c52273ded7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 15:17:56 -0400 Subject: [PATCH 43/73] fix(smoke): pin the teardown ordering, and correct a docblock the test refuted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both round-3 review points, both mine. 1. The docblock claimed deleting `removeSafe` would leave "both the unit suite and all three smokes green" — written before the test existed and never updated, so it contradicted the test added in the same PR. Only the smokes would stay green; the unit tests are precisely what fails on it. 2. `assert.ok(hasExited(child))` after the await proved only that the child was dead by the time teardown returned. It passes just as happily if the catalog were removed *first*, or while an un-awaited `stopChild` was still pending — so it did not lock down the #1801 ordering it claimed to. Sample the directory from the child's own `exit` handler instead, registered before teardown so it runs ahead of stopChild's listener and observes the world at the instant the child dies. `removeSafe` cannot have run by then: it is sequenced after the promise stopChild resolves from that very event. Mutation-checked against both defects the old assertion missed — removing before stopping, and dropping the await. Each now fails the test; neither did before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- scripts/lib/prod-web-server.mjs | 9 +++++---- scripts/lib/prod-web-server.test.mjs | 24 +++++++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 44986c2c..895d9509 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -89,10 +89,11 @@ export function buildWebServerEnv({ * never turn a passing smoke red. * * Exported separately from `startProdWebServer` so the teardown *contract* is - * testable against a stand-in child, without booting a real launcher. Testing it - * matters more than it looks: deleting the `removeSafe` call would leave both the - * unit suite and all three smokes green (the smokes exit immediately after - * teardown), which is precisely the regression the tests exist to catch. + * testable against a stand-in child, without booting a real launcher. That split + * is what makes the leak detectable at all: the smokes exit immediately after + * teardown, so deleting the `removeSafe` call below would leave all three of them + * green. The focused unit tests in `prod-web-server.test.mjs` are the only thing + * that fails on it. * * @param {object} opts * @param {import("node:child_process").ChildProcess} opts.child diff --git a/scripts/lib/prod-web-server.test.mjs b/scripts/lib/prod-web-server.test.mjs index d3f46671..0888d887 100644 --- a/scripts/lib/prod-web-server.test.mjs +++ b/scripts/lib/prod-web-server.test.mjs @@ -84,18 +84,32 @@ test("teardownWebServer: removes the catalog dir it was given", async () => { assert.equal(existsSync(dir), false, "teardown must remove the catalog dir"); }); -test("teardownWebServer: waits for the child to exit before removing", async () => { - // The #1801 race in one assertion: a bare kill() only *delivers* SIGTERM, so a - // teardown that removed synchronously could unlink the dir while the server was - // still writing to it. If teardown resolves with the child still alive, the - // await on stopChild has been lost. +test("teardownWebServer: the catalog outlives the child", async () => { + // The #1801 ordering, pinned at the one instant that distinguishes it. Simply + // asserting `hasExited` after the await is too weak — that also passes if the + // dir were removed *first*, or while an un-awaited stopChild was still pending. + // So sample the dir from the child's own `exit` handler: registering it before + // teardown puts it ahead of stopChild's listener, so it observes the world at + // the moment the child dies. removeSafe cannot have run yet — it is sequenced + // after the promise stopChild resolves from this very event. const child = spawnIdleChild(); const { dir } = createTempCatalog(); assert.equal(hasExited(child), false, "child should start alive"); + let dirAtChildExit = null; + child.once("exit", () => { + dirAtChildExit = existsSync(dir); + }); + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + assert.equal( + dirAtChildExit, + true, + "catalog must still exist when the child exits — removing earlier is the #1801 race", + ); assert.ok(hasExited(child), "teardown must await the child's exit"); + assert.equal(existsSync(dir), false, "and must remove it afterwards"); }); test("teardownWebServer: an already-dead child is not an error", async () => { From bb599e5057d89e96a46b0d22faf454e821bda9bf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 15:28:30 -0400 Subject: [PATCH 44/73] =?UTF-8?q?docs:=20add=20the=20six-month=20Inspector?= =?UTF-8?q?=20roadmap=20(Aug=202026=20=E2=86=92=20Feb=202027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Through v1 the Inspector was a follow-along project — the spec moved, we chased it, and whatever planning capacity was left went to keeping up rather than to the tool's own design. v2 meeting the 2026-07-28 spec across all three clients lifts that constraint, but nothing written down said so, and there was no shared list of what the MCP roadmap is likely to demand of us next. Add docs/inspector-roadmap-2026-h2.md covering 2026-08-11 → 2027-02-11 (~26 weekly milestones, v2.2.0 → ~v2.27.0), in two explicitly-budgeted tracks. Track A predicts Inspector features per MCP roadmap theme and WG charter, each tagged build-now / design-now-build-on-signal / watch, so a speculative item gets a liaison rather than code: transports and session lifecycle, Server Cards (SEP-2127), Tasks retry/expiry and the extension→core migration, enterprise audit + ID-JAG, triggers/events, result types, interceptors (SEP-1763), file inputs (SEP-2356), skills (SEP-2640), primitive grouping, and conformance. Track B is the work we choose, headlined by the zoomable protocol/network timeline — lanes, spans rather than points, brush-to-filter, MRTR and task grouping — then session record/replay, a shared diff primitive, a command palette, saved-call collections feeding CI assertions, the argument-editor workstream, and Connection Doctor. The sequencing argument is that several Track B items are force multipliers for Track A: each protocol feature arrives with a rendering problem, and one general timeline plus one general diff is cheaper than a bespoke panel per SEP. So the general surfaces come first and the SEP work renders into them. Two consolidations the survey surfaced, both worth acting on regardless of whether this plan is adopted as written: - #1853, #1856, #1885, #1928, #1919 and #1910 are one defect class, not six bugs — the argument editor is not schema-aware. Treating them separately has already produced one regression from v1.x (#1928). - #962, #1936, #1951, #1944 and #1914 are one missing feature: an ordered diagnostic that reports which connection step failed and what to do. Sourcing is the published roadmap plus the nine WG and six IG charters, cited in full at the end; the doc says so, and flags that Track A should be revised if internal planning carries themes the public page omits. Status is "Draft for WG review" — merging this starts the discussion rather than settling it. Adds one file and touches nothing else. The README guide list and the AGENTS.md docs/ tree entry are deliberately left for a follow-up rather than bundled here, to keep this diff to the document itself. Markdown-only, and outside every format and typecheck glob (SOURCE_EXTENSIONS has no "md"), so no gate is affected. Closes #1980 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013CaLPZQzfWDHoPxyL1vbYL Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 570 ++++++++++++++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 docs/inspector-roadmap-2026-h2.md diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md new file mode 100644 index 00000000..bfcee53d --- /dev/null +++ b/docs/inspector-roadmap-2026-h2.md @@ -0,0 +1,570 @@ +# Inspector Roadmap — August 2026 → February 2027 + +> A six-month plan for the Inspector client family (Web, CLI, TUI), covering both +> **spec-following work** driven by the MCP roadmap and **experience work** we choose +> for ourselves. + +**Horizon:** 2026-08-11 → 2027-02-11 (~26 weekly milestones, `v2.2.0` → ~`v2.27.0`) +**Owner:** [Inspector V2 WG](https://modelcontextprotocol.io/community/working-groups/inspector-v2) +**Status:** Draft for WG review + +--- + +## Table of Contents + +- [1. Why this document exists](#1-why-this-document-exists) +- [2. The two tracks](#2-the-two-tracks) +- [3. Track A — following the spec](#3-track-a--following-the-spec) + - [3.1 Transport evolution and scalability](#31-transport-evolution-and-scalability) + - [3.2 Server Cards](#32-server-cards) + - [3.3 Agent communication and Tasks](#33-agent-communication-and-tasks) + - [3.4 Enterprise readiness](#34-enterprise-readiness) + - [3.5 Triggers and events](#35-triggers-and-events) + - [3.6 Result type improvements](#36-result-type-improvements) + - [3.7 Interceptors](#37-interceptors) + - [3.8 File uploads](#38-file-uploads) + - [3.9 Skills over MCP](#39-skills-over-mcp) + - [3.10 Primitive grouping and tool annotations](#310-primitive-grouping-and-tool-annotations) + - [3.11 Conformance and validation](#311-conformance-and-validation) +- [4. Track B — experience work we choose](#4-track-b--experience-work-we-choose) + - [4.1 The zoomable timeline (headline)](#41-the-zoomable-timeline-headline) + - [4.2 Session record, replay, and share](#42-session-record-replay-and-share) + - [4.3 Diff and compare](#43-diff-and-compare) + - [4.4 Command palette and global search](#44-command-palette-and-global-search) + - [4.5 Saved calls and collections](#45-saved-calls-and-collections) + - [4.6 Assertions and CI flows](#46-assertions-and-ci-flows) + - [4.7 The argument editor workstream](#47-the-argument-editor-workstream) + - [4.8 Connection Doctor](#48-connection-doctor) + - [4.9 Server management and portability](#49-server-management-and-portability) + - [4.10 Workspace and layout](#410-workspace-and-layout) + - [4.11 Performance at scale](#411-performance-at-scale) + - [4.12 Accessibility and keyboard-first operation](#412-accessibility-and-keyboard-first-operation) + - [4.13 Onboarding](#413-onboarding) + - [4.14 Plugin architecture](#414-plugin-architecture) +- [5. Sequencing](#5-sequencing) +- [6. What we are deliberately not doing](#6-what-we-are-deliberately-not-doing) +- [7. Open questions](#7-open-questions) +- [8. Sources](#8-sources) + +--- + +## 1. Why this document exists + +Through v1, the Inspector was a **follow-along project**. The spec moved, we chased it, and +whatever planning capacity remained went to keeping up rather than to the tool's own design. +Every release was reactive by necessity. + +That constraint has lifted. v2 meets the 2026-07-28 spec across all three clients, on SDK v2, +with a shared `core/`, a ≥90% per-file coverage gate, and a smoke/e2e apparatus that catches +packaging failures. For the first time we can spend planned effort on **what the Inspector +should be**, not only on what the spec just became. + +This document splits the next six months into those two kinds of work, so that neither +starves the other. The explicit intent is a **roughly even split of capacity** — spec-following +work is non-negotiable but bounded, and the remaining capacity is ours to direct. + +> **Sourcing note.** The MCP roadmap circulated as a Google Doc ("MCP Roadmap Process and +> Timeline") requires authentication and could not be read directly. This plan is built from +> the **published** roadmap at `modelcontextprotocol.io/development/roadmap` (last updated +> 2026-03-05) plus the current WG and IG charters, which together cover the same themes at +> more implementation-relevant detail. If the private doc contains timelines or themes absent +> from the public page, §3 should be revised against it before the plan is adopted. + +--- + +## 2. The two tracks + +| | **Track A — Spec-following** | **Track B — Experience** | +| --------------------------- | ----------------------------------------------------- | ------------------------------------------- | +| **Driver** | MCP roadmap, WG deliverables, SEP acceptance | Our own judgment about the tool | +| **Trigger to start** | A SEP reaches Draft with a Tier-1 SDK reference impl | Whenever we have capacity | +| **Risk** | Slips when upstream slips; we cannot control the date | We control the date entirely | +| **Failure mode if starved** | Inspector stops being the reference test client | Inspector stays a protocol dump, not a tool | +| **Target capacity** | ~50% | ~50% | + +The two tracks are not independent. Several Track B items — the timeline, session +record/replay, diff — are **force multipliers for Track A**: each new protocol feature +arrives with a rendering problem, and a general timeline plus a general diff is cheaper than +one bespoke panel per SEP. That is the core scheduling argument of this plan: **build the +general surfaces early so the spec work that lands later is cheap to display.** + +### How the Inspector's role is changing + +Worth stating plainly, because it shapes the priorities below. The roadmap's Validation +section names **conformance test suites**, **SDK tiers**, and **reference implementations** as +standing investments, and SEP-2484 now requires conformance tests for final SEPs. The +Inspector is the most visible MCP client in the ecosystem and is already the thing people +reach for when a server misbehaves. + +That points at an expanded role: not just _"show me the traffic"_ but _"tell me whether this +server is correct."_ Several items below (Server Card diffing, the conformance runner, +assertions, the readiness summary) are steps toward that, and they should be evaluated as a +group rather than individually. + +--- + +## 3. Track A — following the spec + +Each subsection states the upstream theme, our read on what it means for the Inspector, and a +concrete feature list. **Confidence** flags how much of the list we can commit to now: + +- 🟢 **Build now** — the shape is known; blocked only on our own capacity. +- 🟡 **Design now, build on signal** — enough detail to design against; wait for a Draft SEP or a Tier-1 SDK impl before building. +- 🔴 **Watch** — too early to predict a UI; keep a tracking issue and a WG liaison. + +### 3.1 Transport evolution and scalability + +**Upstream:** Transports WG. Next-generation Streamable HTTP that runs statelessly across +multiple instances and behaves correctly behind load balancers and proxies; a session model +covering creation, resumption, and migration; conformance guidance for SDK authors. The +roadmap is explicit that **no additional official transports** ship this cycle. + +**Read:** This is the theme most likely to produce breaking wire changes, and the one where +the Inspector is most useful — session resumption and proxy behavior are exactly the failures +nobody can reproduce by reading code. Our era model (`legacy` / `modern` / `auto`) already +gives us the negotiation seam to add a third era behind. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Session lifecycle lane** — session id, creation, resumption, migration, and expiry as first-class events, not log lines | 🟢 | Renders into the timeline (§4.1). Buildable against today's session model; extends to the new one. | +| **`Last-Event-ID` resumption support and display** | 🟢 | Existing gap — [#920](https://github.com/modelcontextprotocol/inspector/issues/920). Do it now; it is table stakes for the new session work. | +| **Proxy / intermediary harness** — route through a configurable proxy, then deliberately misbehave: rewrite headers, drop the GET stream, close mid-response | 🟡 | Builds on [#1684](https://github.com/modelcontextprotocol/inspector/issues/1684). Needs a `misbehaving-proxy` preset in `test-servers/`. | +| **Stateless-mode verification** — issue the same request across N synthetic instances and diff the responses | 🟡 | Directly tests the property the WG is specifying. Pairs with §4.3. | +| **Third protocol era behind the existing negotiation seam** | 🟡 | Cost is low _if_ we keep era-conditional exposure rather than replacing the legacy path. | +| **Custom transport support** | 🟢 | [#1741](https://github.com/modelcontextprotocol/inspector/issues/1741). The roadmap pushes experimentation to custom transports, so the Inspector should be able to load one. | + +### 3.2 Server Cards + +**Upstream:** Server Card WG, [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) (Draft). A standard `.well-known` document exposing structured server metadata so browsers, crawlers, and registries can discover capabilities **without connecting**. Deliberately kept close to a subset of `server.json`. + +**Read:** This is the single highest-leverage Track A item for us, because it creates a new +Inspector capability rather than a new panel: **inspect before connect**. It also creates an +obvious correctness question that only a tool like ours can answer. + +| Feature | Confidence | Notes | +| ----------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Card preview** — paste a URL, fetch the card, render the capability surface, one-click add to catalog | 🟡 | The pre-connection entry point. Wait for the format to settle. | +| **Card-vs-reality diff** — compare the advertised card against what `initialize` + `*/list` actually return | 🟡 | _The_ Inspector-shaped feature here. Nobody else in the ecosystem is positioned to check this. Shares machinery with [#1034](https://github.com/modelcontextprotocol/inspector/issues/1034) and §4.3. | +| **`mcp-inspector --card-lint `** — validate a card, non-zero exit on drift | 🟡 | CI-usable; a natural companion to the conformance runner (§3.11). | +| **`server.json` support** | 🟢 | [#922](https://github.com/modelcontextprotocol/inspector/issues/922). Prerequisite — the card is a subset, so this lands first regardless. | + +### 3.3 Agent communication and Tasks + +**Upstream:** Agents WG. Tasks (`io.modelcontextprotocol/tasks`, SEP-2663) is being +**stabilized and promoted from an extension into core**. Named open gaps: **retry semantics** +(what happens on transient failure, who decides to retry) and **expiry policies** (result +retention, how clients learn a result expired). An Agents Extension is under evaluation. + +**Read:** We already drive the modern Tasks extension ourselves over a raw-wire channel, +because SDK v2 era-gates `tasks/*` out. Promotion to core will move that back under the SDK — +plan for the migration, but **keep the era-conditional exposure**; the legacy `capabilities.tasks` +path must keep working. + +| Feature | Confidence | Notes | +| -------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------- | +| **Retry visualization** — attempts, backoff, who initiated each retry | 🟡 | Design against the WG's gap list now. | +| **Expiry / TTL surfacing** — retention countdown on a completed task, distinct rendering for an expired-result error | 🟡 | Cheap once the semantics land; easy to get wrong if we guess early. | +| **Tasks as timeline spans** — a long-running task is a span, not a row | 🟢 | Falls out of §4.1 for free. The strongest argument for building the timeline first. | +| **Extension → core migration** | 🟡 | Retire the raw-wire channel when the SDK covers it; keep both paths during overlap. | +| **`Mcp-Name` header on Tasks over Streamable HTTP** | 🟢 | [#1917](https://github.com/modelcontextprotocol/inspector/issues/1917) — open bug, fix now. | +| **Discover checkmarks for task extensions** | 🟢 | [#1887](https://github.com/modelcontextprotocol/inspector/issues/1887). | + +### 3.4 Enterprise readiness + +**Upstream:** An Enterprise WG is expected to form. Four named areas: **audit trails and +observability**, **enterprise-managed auth** (Cross-App Access / ID-JAG), **gateway and proxy +patterns**, and **configuration portability**. Most output is expected as extensions rather +than core spec changes. Related: the Enterprise-Managed Authorization IG, and sponsored work +on [SEP-1932 (DPoP)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932) and [SEP-1933 (Workload Identity Federation)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933). + +**Read:** "Audit trails and observability, in a form enterprises can feed into their existing +pipelines" is a description of something the Inspector nearly already has. We hold the entire +session; we simply cannot **export** it in any pipeline-shaped format. That gap is cheap to +close and disproportionately valuable. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **OTLP export** — emit the session as OpenTelemetry spans; show trace/span ids inline; "copy as trace" | 🟢 | SEP-414 already puts trace context in `_meta`. Buildable today, no upstream dependency. | +| **Structured audit transcript** — the full session as a stable, documented JSON artifact | 🟢 | Shares its format with §4.2 record/replay. Build once, use for both. | +| **Machine-readable readiness summary** | 🟢 | [#1916](https://github.com/modelcontextprotocol/inspector/issues/1916). | +| **ID-JAG / Cross-App Access test flow** | 🟡 | The EMA IG exists specifically because this only works when IdP + client + AS interoperate. A test client is exactly what they lack. Related: [#1937](https://github.com/modelcontextprotocol/inspector/issues/1937), [#571](https://github.com/modelcontextprotocol/inspector/issues/571). | +| **DPoP and Workload Identity Federation** | 🔴 | Both sponsored but pre-acceptance. Watch; do not build. | +| **Gateway mode** — declare an intermediary, then show what we sent vs. what the gateway forwarded | 🟡 | Depends on the Gateways IG settling propagation semantics. | +| **Configuration portability** | 🟢 | [#1912](https://github.com/modelcontextprotocol/inspector/issues/1912), [#904](https://github.com/modelcontextprotocol/inspector/issues/904), plus `server.json` (§3.2). | + +### 3.5 Triggers and events + +**Upstream:** Triggers and Events WG. A standardized server→client callback mechanism +(webhooks or similar), with subscription lifecycle and **ordering guarantees that hold across +all transports**. Status: "SEP: Events in MCP v1 RFC" — **Ideating**. + +**Read:** ⚠️ **This is the largest architectural change on the horizon for us, and the one we +are least prepared for.** Every Inspector surface today assumes we are the party that +_initiated_ the connection. A webhook mechanism makes us a **server** — we must host a +publicly reachable callback endpoint, which for a tool that usually runs on `localhost` is a +real problem (tunnels, port forwarding, or a relay). + +We should start the design conversation **now**, well ahead of the SEP, and bring it to the +WG as implementation feedback. The ordering-guarantee requirement in particular is +untestable without a client that records arrival order — which is us. + +| Feature | Confidence | Notes | +| ----------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Callback receiver** — backend-hosted endpoint, its URL registered as the trigger target | 🔴 | Needs design now, build later. Security review mandatory: an inbound public endpoint on a process that spawns subprocesses is a serious surface. | +| **Local reachability story** — tunnel integration or documented guidance | 🔴 | Likely the hardest UX problem of the whole six months. | +| **Delivery log with ordering and duplicate assertions** | 🔴 | The conformance value: did events arrive in the promised order? were any redelivered? | + +### 3.6 Result type improvements + +**Upstream:** "On the Horizon." **Streamed results** (incremental output for generated text, +audio, video frames) and **reference-based results** (client decides when to pull a large +payload into context). Explicitly cross-cutting — streaming touches transport, references +touch the schema. + +**Read:** Streaming changes how every result panel renders: today we display a _result_, and +we would need to display a _stream that becomes a result_. Worth a rendering abstraction +before the SEP, not after. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------- | +| **Incremental result rendering** — progressive display, with time-to-first-chunk and inter-chunk timing | 🔴 | The timing view is Inspector-shaped; the timeline is the natural home. | +| **Reference-result handling** — show a handle plus an explicit "pull payload", with size accounting | 🔴 | Also a good default for large payloads _today_, independent of the SEP (see §4.11). | + +### 3.7 Interceptors + +**Upstream:** Interceptors WG, [SEP-1763](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2076) (Draft). Interceptors as a new primitive with two types — **validators** (pass/fail) and **mutators** (transform payloads) — across in-process, sidecar, and remote deployment models, with priority-based chain ordering and audit-mode semantics. A **CLI client for interceptor invocation and testing** is a listed WG deliverable (Ideating, unowned). + +**Read:** Two things stand out. First, "CLI client for interceptor invocation and testing" is +**an unclaimed deliverable that describes our CLI**. Worth raising with the WG — Ola co-leads +both groups, so the liaison already exists. Second, an interceptor chain is a +_before → after payload transformation_, which is a diff, which we should already be able to +render (§4.3). + +| Feature | Confidence | Notes | +| ---------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- | +| **Interceptor test bench** — register a chain, show before/after diff per hop, visualize priority ordering | 🟡 | The clearest "Inspector as the reference tool" opportunity of the six months. | +| **Audit-mode rendering** — what _would_ have been blocked or mutated | 🟡 | Follows the SEP's audit semantics. | +| **CLI interceptor invocation** | 🟡 | **Action: raise with the Interceptors WG.** If we take it, it needs its own milestone allocation. | +| **Our plugin architecture as an interceptor host** | 🟡 | [#1025](https://github.com/modelcontextprotocol/inspector/issues/1025). Prevents us building two extension mechanisms. | + +### 3.8 File uploads + +**Upstream:** File Uploads WG, [SEP-2356](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2356) (Draft, TS SDK reference impl targeted End May). Declarative `FileInputDescriptor` on tool input schemas and elicitation schemas, so hosts render native file pickers. Success criteria explicitly include **"at least one production host rendering a native file picker from the descriptor."** + +**Read:** The most tractable Track A item on the list — narrow, well-specified, with a TS SDK +reference implementation coming, and we are a credible candidate for that "production host." +It touches three surfaces: `SchemaForm` (Tools), elicitation forms, and MCP Apps. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------ | +| **File picker in `SchemaForm`** when a descriptor is present, with data-URI encoding | 🟡 | Wait for the TS SDK types, then build. Low risk. | +| **Same in elicitation forms** | 🟡 | Shared component. | +| **Size guardrails and host-side validation** | 🟡 | The SEP references OWASP ASVS V5. | + +### 3.9 Skills over MCP + +**Upstream:** Skills Over MCP WG, [SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) (In Review, Extensions Track). Resources-based; a reference implementation is also In Review. + +**Read:** Because it is Resources-based, the incremental cost is low — a Skills view over the +existing resource machinery rather than a new subsystem. + +| Feature | Confidence | Notes | +| -------------------------------------------------------- | ---------- | -------------------------------------------------------------------- | +| **Skills view** — list, preview content, show activation | 🟡 | Gate on the negotiated extension, the way the Tasks tab gates today. | + +### 3.10 Primitive grouping and tool annotations + +**Upstream:** Two IGs. **Primitive Grouping** explores organizing Tools/Resources/Prompts +beyond flat lists — deliberately not picking one canonical pattern early. **Tool Annotations** +is consolidating six independent annotation SEPs and considering runtime annotations and tool +_response_ annotations. + +**Read:** Grouping is the rare case where the spec-following work and the UX work are the same +work. Flat lists are already our weakest surface on large servers — [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) (duplicate tool names) was a symptom. **Build the grouped sidebar as a UX +improvement now**, and adopt whatever grouping the IG lands as a data source later. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------- | +| **Grouped / tree sidebars with group-aware search** | 🟢 | Build now on client-side heuristics (name prefixes, annotations). Ship value immediately; swap the data source later. | +| **Richer annotation rendering** | 🟢 | Extends the existing `AnnotationBadge`. | +| **Annotation-driven confirmation** before a `destructiveHint` call | 🟢 | Small, obviously correct, no upstream dependency. | +| **Runtime / response annotations** | 🔴 | Watch. | + +### 3.11 Conformance and validation + +**Upstream:** Standing investment — conformance test suites, SDK tiers ([SEP-1730](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1730)), reference implementations. [SEP-2484](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2484) now **requires conformance tests for final SEPs**, and the EMA IG is explicitly contributing scenarios to the `modelcontextprotocol/conformance` repository. + +**Read:** A conformance suite needs a driver and a report. We are the natural driver, and we +already have a CLI that exits non-zero. This is the clearest path to the expanded role +described in §2 — and unlike most of Track A, **it is not gated on any SEP**. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Conformance runner** — run the suite against a connected server, render pass/fail per assertion | 🟡 | Needs coordination on the suite's programmatic interface. **Action: open a conversation with the conformance maintainers.** | +| **`mcp-inspector --conformance` for CI** | 🟡 | Same engine, CLI report, exit code. | +| **Strict schema validation with actionable errors** | 🟢 | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015). No dependency; start here. | + +--- + +## 4. Track B — experience work we choose + +Nothing in this section waits on a SEP. Ordered by leverage, not by effort. + +### 4.1 The zoomable timeline (headline) + +**Committed.** The single feature that most changes what the Inspector _is_. + +The Protocol and Network screens are chronological lists. A list answers "what happened next" +but not "what happened _at the same time_", "how long did this take", or "which of these +caused that" — and those are the questions people actually bring to the Inspector. A +session with an MRTR round-trip, a long-running task, a subscription stream, and a +mid-session OAuth step-up is, in list form, an interleaved mess. On a time axis it is legible +at a glance. + +**Design sketch:** + +- **A third view over the existing stores**, not a new data path. Protocol, Network, and + Timeline become three renderings of one session. This keeps the coverage gate and the + existing `protocolUtils` derivations intact. +- **Lanes**, each independently collapsible: + `client → server` · `server → client` · notifications · tasks · subscription streams · OAuth/auth · errors +- **Spans, not points.** A request occupies from send to response; a task occupies its whole + lifetime; a stream is a bar with events on it. Duration becomes visible, which is most of + the value. +- **Zoom and pan** across the full range, from whole-session down to sub-millisecond. + Brush-to-select a range and filter every other view to it. +- **Grouping** — an MRTR conversation is one collapsible span containing its rounds; a task + contains its polls. +- **Click through** to the existing Protocol/Network entry. The timeline is navigation, not a + replacement. +- **A pinned mini-timeline strip** above every tab, so a spike is visible while you are in + Tools, and clicking it jumps to the full view. +- **Latency distribution** as a secondary view — per method, so a slow tool is obvious. +- **Virtualized**, keyboard-navigable, and rendered from the same store the other views use. + +**Deliberately out of scope for v1 of this feature:** cross-server correlation (needs §4.10), +and OTLP-shaped nesting (needs §3.4). + +### 4.2 Session record, replay, and share + +Save a complete session — protocol log, network log, server config, negotiated capabilities — +to a single file. Reopen it later, on another machine, with no server running. Attach it to a +bug report. + +This changes issue triage from "works on my machine" into an artifact, and it is the same +serialization format as the enterprise audit transcript (§3.4) — **build the format once**. +Replay also gives us fixtures: a recorded session is a regression test. + +### 4.3 Diff and compare + +Two sessions, or two servers, side by side. Concretely: + +- **Capability diff** — reconnect after changing your server, see exactly what moved in + `tools/list` / `resources/list` / `prompts/list`. ([#1034](https://github.com/modelcontextprotocol/inspector/issues/1034)) +- **Session diff** — same calls, two servers, what differed. +- **Payload diff** — before/after for any pair of JSON documents. + +The payload differ is a **shared primitive**: interceptor before/after (§3.7), Server +Card-vs-reality (§3.2), and stateless-instance comparison (§3.1) are all the same widget with +different inputs. Build it as a component first, then wire the three consumers. + +### 4.4 Command palette and global search + +`⌘K` to jump to any server, tool, resource, or prompt; re-run the last call; switch tabs. Plus +full-text search across the protocol log with a real filter syntax (`method:tools/call +status:error duration:>500ms`). The Inspector is currently a mouse-driven app; for a developer +tool that is a daily tax. + +### 4.5 Saved calls and collections + +Name a tool call with its arguments, save it, re-run it, parameterize it, share it. A +Postman-collection model for MCP. The single most requested shape of workflow improvement for +any protocol client, and it composes directly with §4.6. + +### 4.6 Assertions and CI flows + +Attach expectations to a saved call — result matches schema, field equals value, latency under +a bound — and run the collection from the CLI with a non-zero exit on failure. This turns the +Inspector from an interactive tool into part of a server author's test suite, and it shares an +engine with the conformance runner (§3.11). +Related: [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1886](https://github.com/modelcontextprotocol/inspector/issues/1886), [#1916](https://github.com/modelcontextprotocol/inspector/issues/1916). + +### 4.7 The argument editor workstream + +Six open issues are all the same defect class — the argument editor is not schema-aware: + +| Issue | Symptom | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------- | +| [#1853](https://github.com/modelcontextprotocol/inspector/issues/1853) | JSON parameter editor escaping while typing | +| [#1856](https://github.com/modelcontextprotocol/inspector/issues/1856) | Backspace recursively escapes JSON tool inputs | +| [#1885](https://github.com/modelcontextprotocol/inspector/issues/1885) | Null values corrupted with cascading escapes | +| [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) | Nullable enums fall back to a broken raw Textarea (v1.x regression) | +| [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) | Resource templates lack RFC 6570 expansion | +| [#1910](https://github.com/modelcontextprotocol/inspector/issues/1910) | Complex `_meta` not expressible | + +**Fix them as one workstream, not six bugs.** A proper schema-aware editor (CodeMirror or +Monaco with JSON Schema integration) resolves the class and unblocks file inputs (§3.8) and +strict validation (§3.11). Treating them individually has already produced one regression from +v1. + +### 4.8 Connection Doctor + +Connection failures are currently opaque, and five open issues say so +([#962](https://github.com/modelcontextprotocol/inspector/issues/962), [#1936](https://github.com/modelcontextprotocol/inspector/issues/1936), [#1951](https://github.com/modelcontextprotocol/inspector/issues/1951), [#1944](https://github.com/modelcontextprotocol/inspector/issues/1944), [#1914](https://github.com/modelcontextprotocol/inspector/issues/1914)). + +Run an ordered checklist on failure — DNS · TCP · TLS (including local-cert cases) · +`/.well-known` discovery · protocol version negotiation · auth — and report **which step +failed and what to do about it**. First-connection success is the entire first impression of +the tool, and today a `https://localhost` server or a dev container silently fails. + +Bundle the related fixes: `*.localhost` domains ([#1944](https://github.com/modelcontextprotocol/inspector/issues/1944)), the trusted-local-host OAuth HTTP +exception ([#1911](https://github.com/modelcontextprotocol/inspector/issues/1911)), and the ghost-server entry left by a failed manual connect ([#1914](https://github.com/modelcontextprotocol/inspector/issues/1914)). + +### 4.9 Server management and portability + +Already well represented on the board; grouping it here so it is scheduled as a theme rather +than piecemeal: rich server configuration ([#1857](https://github.com/modelcontextprotocol/inspector/issues/1857)), custom headers and cookies ([#1915](https://github.com/modelcontextprotocol/inspector/issues/1915)), +auth/token URL overrides ([#1906](https://github.com/modelcontextprotocol/inspector/issues/1906)), file-backed secrets where no OS keychain exists ([#1950](https://github.com/modelcontextprotocol/inspector/issues/1950)), +paste-MCP-JSON ([#904](https://github.com/modelcontextprotocol/inspector/issues/904)), and registry discovery ([#1101](https://github.com/modelcontextprotocol/inspector/issues/1101)). + +### 4.10 Workspace and layout + +Multiple servers side by side — the actual shape of debugging a gateway, or comparing a +server against a reference implementation. Detachable/resizable panels, remembered layout per +server, density modes, and full-collapse ([#928](https://github.com/modelcontextprotocol/inspector/issues/928)). Prerequisite for cross-server timeline +correlation. + +### 4.11 Performance at scale + +A 1000-tool server or a long-running session should not degrade. Virtualize the long lists and +logs; cap in-memory protocol history with spill-to-disk; truncate large payloads by default +with explicit expansion (which is also the right default for reference results, §3.6). + +### 4.12 Accessibility and keyboard-first operation + +Full keyboard operation across every tab, correct roles and labels, high-contrast support, +and `prefers-reduced-motion` (which the timeline's animations will make newly relevant). We +have a Storybook a11y harness already; the gap is coverage, not tooling. + +### 4.13 Onboarding + +A first run currently presents an empty server list and no path forward. Add a guided first +connection, one-click example servers drawn from `test-servers/`, and inline links from each +panel to the relevant spec section. + +### 4.14 Plugin architecture + +[#1025](https://github.com/modelcontextprotocol/inspector/issues/1025). The multiplier on everything above — custom panels, custom transports (§3.1), +interceptor hosting (§3.7), and community-contributed views without core changes. Sequenced +late deliberately: designing a plugin API before the timeline, diff, and session format exist +would mean designing it against the wrong surfaces. + +--- + +## 5. Sequencing + +Four phases of roughly six weekly milestones each. Track A items appear where their upstream +signal is expected; Track B items are placed to unblock Track A wherever possible. + +### Phase 1 — Foundations (~`v2.2` – `v2.7`, Aug–Sep 2026) + +_Build the general surfaces the rest of the plan renders into, and clear the debt that makes +first impressions bad._ + +- 🅑 **Zoomable timeline v1** — lanes, spans, zoom/pan, click-through +- 🅑 **Argument editor workstream** (§4.7) — closes six issues as one +- 🅑 **Connection Doctor** (§4.8) + the local-host connection fixes +- 🅐 `Last-Event-ID` resumption ([#920](https://github.com/modelcontextprotocol/inspector/issues/920)); `Mcp-Name` on Tasks ([#1917](https://github.com/modelcontextprotocol/inspector/issues/1917)); discover checkmarks ([#1887](https://github.com/modelcontextprotocol/inspector/issues/1887)) +- 🅐 `server.json` support ([#922](https://github.com/modelcontextprotocol/inspector/issues/922)) — prerequisite for Server Cards +- ⚙️ Windows CI/gate fixes already in `v2.2.0` + +### Phase 2 — Artifacts and comparison (~`v2.8` – `v2.13`, Sep–Nov 2026) + +_Make sessions into things you can keep, share, and compare._ + +- 🅑 **Session record / replay / share** (§4.2) — format shared with audit transcript +- 🅑 **Diff primitive** (§4.3) — then wire capability diff ([#1034](https://github.com/modelcontextprotocol/inspector/issues/1034)) +- 🅑 **Command palette and global search** (§4.4) +- 🅐 **OTLP export and audit transcript** (§3.4) — no upstream dependency +- 🅐 **Grouped sidebars** (§3.10) on client-side heuristics +- 🅐 Strict schema validation ([#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015)) +- 🅐 Timeline lanes for tasks and sessions (falls out of Phase 1) + +### Phase 3 — Automation and spec catch-up (~`v2.14` – `v2.20`, Nov 2026 – Jan 2027) + +_Turn the Inspector into something you can run in CI, and absorb the SEPs that have landed._ + +- 🅑 **Saved calls / collections** (§4.5) → **assertions and CI flows** (§4.6) +- 🅐 **Conformance runner** (§3.11) — shares the assertion engine +- 🅐 **File uploads** (§3.8) — assumes the TS SDK reference impl has shipped +- 🅐 **Server Card preview + card-vs-reality diff** (§3.2) — assumes SEP-2127 has settled +- 🅐 **Skills view** (§3.9) — assumes SEP-2640 accepted +- 🅑 Performance at scale (§4.11); accessibility pass (§4.12) + +### Phase 4 — Frontier (~`v2.21` – `v2.27`, Jan–Feb 2027) + +_The items whose shape we cannot yet commit to, plus the multiplier._ + +- 🅐 **Interceptor test bench** (§3.7) — and a decision on owning the WG's CLI deliverable +- 🅐 **Triggers/events receiver** (§3.5) — design throughout, build only if the SEP lands +- 🅐 **Transport/session work** (§3.1) — proxy harness, stateless verification, third era +- 🅐 **ID-JAG / Cross-App Access flow** (§3.4) +- 🅑 **Plugin architecture** (§4.14) — designed against surfaces that now exist +- 🅑 Workspace and layout (§4.10); onboarding (§4.13) + +### Standing commitments across all phases + +- **Weekly milestone cadence** and the `npm run ci` gate are unchanged. +- **Bug and triage capacity is reserved, not scheduled.** The board's Incoming queue keeps + flowing regardless of phase. +- **WG liaison**: attend Transports, Agents, Triggers, Interceptors, and Server Card sessions + and feed implementation experience back. Several items above are as much _inputs to_ the + spec as outputs of it. + +--- + +## 6. What we are deliberately not doing + +Stating these so they are decisions rather than oversights. + +- **Not building bespoke panels per SEP.** Where a new feature can render into the timeline, + the diff, or the session format, it does. A new top-level tab needs justification. +- **Not chasing pre-Draft SEPs.** 🔴 items get a tracking issue and a WG liaison, not code. + We were burned by this in v1. +- **Not publishing `core/` as a package this cycle.** [#1636](https://github.com/modelcontextprotocol/inspector/issues/1636) stays deferred; it adds an API + compatibility obligation we cannot yet afford. +- **Not adding transports beyond what the spec blesses**, per the roadmap — but §3.1 makes + _custom_ transports loadable so the community can experiment. +- **Not building a second extension mechanism.** If we host interceptors, they run on the + plugin architecture (§4.14). + +--- + +## 7. Open questions + +For WG discussion before this plan is adopted. + +1. **Does the private roadmap doc change §3?** This plan is built from the public roadmap; the + private doc may carry timelines or themes it omits. +2. **Do we claim the Interceptors WG's "CLI client for interceptor invocation and testing"?** + It is Ideating and unowned, it describes our CLI, and we have a co-lead in common. If yes, + it needs milestone allocation in Phase 3, not Phase 4. +3. **How far do we take the conformance role?** §3.11 and §4.6 point at "the Inspector tells + you whether your server is correct." That is a real expansion of mission — worth an + explicit yes or no, and possibly a charter amendment. +4. **Who owns the triggers/events reachability problem?** A publicly reachable callback + endpoint on a localhost dev tool is a security question as much as a UX one, and it needs + an owner before Phase 4. +5. **Is the ~50/50 capacity split right?** It is an assertion in this draft, not a measurement. +6. **Timeline v1 scope.** The §4.1 sketch is deliberately broad. Which parts are v1 and which + are follow-ups should be settled before Phase 1 starts. + +--- + +## 8. Sources + +- [MCP Roadmap](https://modelcontextprotocol.io/development/roadmap) (last updated 2026-03-05) +- WG charters: [Inspector V2](https://modelcontextprotocol.io/community/working-groups/inspector-v2) · [Server Card](https://modelcontextprotocol.io/community/working-groups/server-card) · [Triggers & Events](https://modelcontextprotocol.io/community/working-groups/triggers-events) · [Agents](https://modelcontextprotocol.io/community/working-groups/agents) · [Interceptors](https://modelcontextprotocol.io/community/working-groups/interceptors) · [File Uploads](https://modelcontextprotocol.io/community/working-groups/file-uploads) · [Skills Over MCP](https://modelcontextprotocol.io/community/working-groups/skills-over-mcp) +- IG charters: [Primitive Grouping](https://modelcontextprotocol.io/community/interest-groups/primitive-grouping) · [Tool Annotations](https://modelcontextprotocol.io/community/interest-groups/tool-annotations) · [Enterprise-Managed Authorization](https://modelcontextprotocol.io/community/interest-groups/enterprise-managed-authorization) +- Internal: [`specification/v2_new_spec_impact.md`](../specification/v2_new_spec_impact.md) · [`specification/v2_scope.md`](../specification/v2_scope.md) · [`specification/v2_ux_features.md`](../specification/v2_ux_features.md) +- [Inspector V2 project board (#28)](https://github.com/orgs/modelcontextprotocol/projects/28) From f060c97e8fd9b5a00673ddd2580ccebb539bbd11 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 15:47:01 -0400 Subject: [PATCH 45/73] docs: mirror the smoke-isolation invariant into copilot-instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md requires review-relevant changes to land in the Copilot mirror in the same PR, and the catalog-isolation rules are review-enforceable: a new web smoke that spawns its own server, or a teardown that removes a work dir without first awaiting stopChild, is something a reviewer should flag. Mirrored as one line under "Gates and PR hygiene" rather than the AGENTS.md paragraph. The mirror is read on every review and its own rule is "a distillation, not a copy" — the mechanism (why the 409 appeared, why only the catalog and not HOME is redirected, how the contract is tested) belongs in AGENTS.md, while what a reviewer needs is the invariant and the two shapes that violate it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 250a8300..0628741c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -100,6 +100,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. - **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR bumping a package that the shared sources import should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. +- **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. From f4529df219b8c4327109c97fa04d446ba0b93e81 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 22:20:14 -0400 Subject: [PATCH 46/73] docs: document the DCO signoff requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DCO check fails any PR with an unsigned commit, and the rule appeared nowhere in the repo — not AGENTS.md, CONTRIBUTING.md, README.md, or the Copilot mirror. A hard merge gate that is invisible until it fails, whose remedy is a history rewrite, is exactly what AGENTS.md exists to capture. It blocked #1978. Added to the "When work is complete" list beside the other PR-hygiene gates, weighted toward prevention over recitation: `git config format.signOff true` once per clone beats remembering `-s`, because the failure is only discovered after pushing. The repair path (`git rebase --signoff` + `--force-with-lease`) carries the rebase caveat and the empty-remediation-commit alternative for when you are not the sole author, plus a note that the signoff is a DCO assertion in the author's own name and is never applied on someone else's behalf. Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md requires for PR-hygiene rules — two lines next to `Closes #N` and the version label, since the mirror is read on every review and is a distillation, not a copy. Not added to CONTRIBUTING.md: external contributors file issues rather than PRs, so DCO never reaches them, and documenting it there would muddy that. Closes #1979 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- .github/copilot-instructions.md | 1 + AGENTS.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0628741c..f9d995d3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,6 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. +- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; `git commit -s`, or `git config format.signOff true` once per clone. Repairing pushed commits means `git rebase HEAD~ --signoff` + `git push --force-with-lease`. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. ## What to prioritize in review diff --git a/AGENTS.md b/AGENTS.md index 5f9e44bc..6a19dfb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,10 @@ All work should be driven by items on the project board. - **Branch names start with the target version segment.** The first path segment must be the version whose base branch the PR targets — `v2/` for work on `v2/main`, `v1/` for work on `v1/main` — followed by the usual type and slug: `v2/ci/restore-claude-workflow`, `v2/fix/oauth-scope-union`, `v1/fix/proxy-ssrf-pin`. Not `ci/restore-claude-workflow`. This keeps the two lines legible in `git branch -a` and in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (`v2/main`, `v1/main`). - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. + - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name ` trailer matching its author. There is no per-PR override and no partial credit: one unsigned commit out of six fails the whole check. + - **Prevent it, don't remember it.** Commit with `git commit -s`, or — better — run `git config format.signOff true` once per clone so the trailer is added automatically and the rule stops depending on memory. This matters more than the rule itself, because the failure is invisible until after you have pushed. + - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~ --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). If others are on the branch, add an empty remediation commit instead. + - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. - Open a PR against the matching base branch (`v1/main` for v1, `v2/main` for v2) and set the item's Status to **In Review** - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. - **Link the PR to its issue — mandatory for every PR, from anyone.** No PR is opened without an issue to reference; if one doesn't exist yet, create it first (labeled and on the board) rather than opening the PR and backfilling. Note also that only the **repo maintainers** open PRs at all (see [Contributing](#contributing)) — everyone else files a detailed issue. The PR body's **first line must be `Closes #`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) From b3242bc04c0748177bb6ea243f65be451d42fecc Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 22:29:54 -0400 Subject: [PATCH 47/73] fix(docs): format.signOff does not sign commits, and remediation is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both review points were factually right and my original text was wrong. 1. `git config format.signOff true` — which I had recommended as the *better* option — only defaults the -s flag for `git format-patch`. `git commit` never reads it, and there is no `commit.signoff` equivalent. Verified all three in a throwaway repo: format.signOff → no trailer; commit.signoff → no effect; a prepare-commit-msg hook → works. Shipping that advice would have been worse than saying nothing, since it looks like a fix and silently changes nothing. Now recommends `git commit -s`, gives the verified hook for automation, and names format.signOff explicitly as a trap — the name invites exactly the mistake I made, so it is worth warning about rather than omitting. 2. The DCO app's empty remediation-commit flow needs `allowRemediationCommits.individual`. This repo ships no DCO config, so it runs with that disabled and the original unsigned commits keep failing. The escape hatch I offered does not exist here; rewriting is the only repair, so the text now says to coordinate before rewriting shared history instead. Mirror updated with both corrections. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- .github/copilot-instructions.md | 2 +- AGENTS.md | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f9d995d3..ffc4c38f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,7 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. -- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; `git commit -s`, or `git config format.signOff true` once per clone. Repairing pushed commits means `git rebase HEAD~ --signoff` + `git push --force-with-lease`. +- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (note `format.signOff` does *not* sign `git commit` — only `format-patch`; automate with a `prepare-commit-msg` hook instead). Repairing pushed commits means `git rebase HEAD~ --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. ## What to prioritize in review diff --git a/AGENTS.md b/AGENTS.md index 6a19dfb8..afbb05c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,8 +208,20 @@ All work should be driven by items on the project board. - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name ` trailer matching its author. There is no per-PR override and no partial credit: one unsigned commit out of six fails the whole check. - - **Prevent it, don't remember it.** Commit with `git commit -s`, or — better — run `git config format.signOff true` once per clone so the trailer is added automatically and the rule stops depending on memory. This matters more than the rule itself, because the failure is invisible until after you have pushed. - - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~ --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). If others are on the branch, add an empty remediation commit instead. + - **Prevent it, don't remember it** — the failure is invisible until after you have pushed. Commit with **`git commit -s`**. To stop relying on memory, add a `prepare-commit-msg` hook, which is the only mechanism that actually signs an ordinary `git commit`: + + ```sh + cat > .git/hooks/prepare-commit-msg <<'HOOK' + #!/bin/sh + grep -q '^Signed-off-by:' "$1" || git interpret-trailers --in-place \ + --trailer "Signed-off-by: $(git config user.name) <$(git config user.email)>" "$1" + HOOK + chmod +x .git/hooks/prepare-commit-msg + ``` + + ⚠️ **`git config format.signOff true` does NOT do this** — despite the name, it only defaults the `-s` flag for `git format-patch`; `git commit` never reads it, and there is no `commit.signoff` equivalent. Setting it looks like a fix and silently changes nothing, so it is a trap worth naming rather than omitting. + + - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~ --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). **There is no non-rewrite escape hatch:** the DCO app's empty "remediation commit" flow requires `allowRemediationCommits.individual`, and this repo ships no DCO config, so it runs with that disabled and the original unsigned commits keep failing. If others are working on the branch, coordinate with them before rewriting rather than reaching for a remediation commit. - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. - Open a PR against the matching base branch (`v1/main` for v1, `v2/main` for v2) and set the item's Status to **In Review** - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. From 6899e0ae1e821e0a6d7270dfbb02b29fbfb463f9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 22:46:32 -0400 Subject: [PATCH 48/73] fix(docs): make the signoff hook identity-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook skipped insertion on any `Signed-off-by:` line, which is wrong for a message reused from another commit — cherry-pick, `git commit -c` — since that message already carries someone else's trailer. The DCO check requires one matching the *author*, so the guard would be satisfied while the commit still failed the gate: the exact silent-no-op failure mode this bullet warns about. Match this identity's exact trailer instead. Verified: a plain commit signs once; a message already carrying another author's signoff now gets the real author's added alongside it; `git commit -s` is not double-signed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- AGENTS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index afbb05c4..ae7cb54d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,8 +213,12 @@ All work should be driven by items on the project board. ```sh cat > .git/hooks/prepare-commit-msg <<'HOOK' #!/bin/sh - grep -q '^Signed-off-by:' "$1" || git interpret-trailers --in-place \ - --trailer "Signed-off-by: $(git config user.name) <$(git config user.email)>" "$1" + # Match THIS identity's exact trailer, not any Signed-off-by: a message + # reused from another commit (cherry-pick, `git commit -c`) already carries + # someone else's, and the DCO check requires one matching the author — so a + # bare `grep '^Signed-off-by:'` would skip and leave the commit failing. + SOB="Signed-off-by: $(git config user.name) <$(git config user.email)>" + grep -qF "$SOB" "$1" || git interpret-trailers --in-place --trailer "$SOB" "$1" HOOK chmod +x .git/hooks/prepare-commit-msg ``` From 3c93c0502104159a8b50dfd16bf6ad696f776d75 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 22:59:16 -0400 Subject: [PATCH 49/73] fix(docs): the DCO app does have a write-access override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claims were wrong, both verified against the app's own documentation (dcoapp/app): "The DCO also creates an override button for users with write access to the repository to create a successful check." So "there is no per-PR override" and "there is no non-rewrite escape hatch" were both false, and a maintainer following them would conclude that rewriting history was the only way to unblock a PR. Documented, but deliberately not as a repair. The override only silences the check; it does not make the author certify anything, and the certification is the whole point of a DCO signoff. Using it on your own unsigned commits asserts nothing while looking like compliance — so the text says to treat it as unavailable and sign the commits instead. The same source confirms two things the doc already claimed: remediation commits are disabled by default, and the trailer must match the commit author's email (which is what the identity-aware hook in 6899e0ae exists for). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae7cb54d..fc495082 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,7 +207,7 @@ All work should be driven by items on the project board. - **Branch names start with the target version segment.** The first path segment must be the version whose base branch the PR targets — `v2/` for work on `v2/main`, `v1/` for work on `v1/main` — followed by the usual type and slug: `v2/ci/restore-claude-workflow`, `v2/fix/oauth-scope-union`, `v1/fix/proxy-ssrf-pin`. Not `ci/restore-claude-workflow`. This keeps the two lines legible in `git branch -a` and in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (`v2/main`, `v1/main`). - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. - - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name ` trailer matching its author. There is no per-PR override and no partial credit: one unsigned commit out of six fails the whole check. + - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name ` trailer matching its author. There is no partial credit: one unsigned commit out of six fails the whole check. The app *does* expose an override button to anyone with write access, but treat it as unavailable — see the repair bullet below for why. - **Prevent it, don't remember it** — the failure is invisible until after you have pushed. Commit with **`git commit -s`**. To stop relying on memory, add a `prepare-commit-msg` hook, which is the only mechanism that actually signs an ordinary `git commit`: ```sh @@ -225,7 +225,7 @@ All work should be driven by items on the project board. ⚠️ **`git config format.signOff true` does NOT do this** — despite the name, it only defaults the `-s` flag for `git format-patch`; `git commit` never reads it, and there is no `commit.signoff` equivalent. Setting it looks like a fix and silently changes nothing, so it is a trap worth naming rather than omitting. - - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~ --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). **There is no non-rewrite escape hatch:** the DCO app's empty "remediation commit" flow requires `allowRemediationCommits.individual`, and this repo ships no DCO config, so it runs with that disabled and the original unsigned commits keep failing. If others are working on the branch, coordinate with them before rewriting rather than reaching for a remediation commit. + - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~ --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). **Rewriting is the only legitimate repair, and the two apparent alternatives are not.** The app's empty "remediation commit" flow requires `allowRemediationCommits.individual`; this repo ships no `.github/dco.yml`, so it runs with that disabled and the original unsigned commits keep failing. The app *does* also give anyone with write access an override button that forces the check green — but it only silences the check, it does not make the author certify anything, and the signoff is the certification (see below). Using it on your own unsigned commits asserts nothing while looking like compliance, so don't: sign the commits instead. If others are working on the branch, coordinate with them before rewriting. - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. - Open a PR against the matching base branch (`v1/main` for v1, `v2/main` for v2) and set the item's Status to **In Review** - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. From eb5bba62ede2c27d9082a833bcf23e34d4cda2ee Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 23:00:01 -0400 Subject: [PATCH 50/73] fix(tui): bundle React-rendering deps so a consumer install can't split React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ink-form` and `ink-scroll-view` were externalized by the TUI bundler and both declare a loose `react` peer (">=18"). Installed under a project that has its own React 18, npm hoists them to that project's root — where the peer is satisfied — while the Inspector's React 19 nests underneath. The bundle then renders through React 19 while those two call hooks on React 18, whose dispatcher is null, so opening a tool test form (or any scroll view) dies with "Cannot read properties of null (reading 'useState')". Inline them instead: their `import "react"` is emitted into the bundle and so resolves from the build directory exactly like the bundle's own, with npm's placement no longer part of the decision. This also pins their transitive deps to what this repo resolved — notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency, so consumers were getting the React-18-era v5. `ink` stays external: it can't be bundled (its CJS `signal-exit@3` fails ESM interop with `Dynamic require of "assert"`) and doesn't need to be — its ">=19" peer keeps npm from hoisting it next to an unusable React. Both packages are dropped from the root `dependencies`, since the tarball now ships their code rather than having consumers install them. `clients/tui/__tests__/tsupConfig.test.ts` is the durable guard: the failure is invisible in this repo and in every smoke (a dev install has one React) and only appears once the package is installed under another project, so the invariant is asserted against the config instead — every dependency declaring a `react` peer must be bundled unless exempted. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- .github/copilot-instructions.md | 1 + AGENTS.md | 2 + README.md | 1 + clients/tui/README.md | 32 ++++++ clients/tui/__tests__/tsupConfig.test.ts | 130 +++++++++++++++++++++++ clients/tui/tsup.config.ts | 22 +++- package-lock.json | 111 ------------------- package.json | 2 - 8 files changed, 185 insertions(+), 116 deletions(-) create mode 100644 clients/tui/__tests__/tsupConfig.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0628741c..24367041 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -77,6 +77,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. - **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. - Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `/node_modules` like the other pins there. +- **A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed it in the *consumer's* tree; `ink-form` and `ink-scroll-view` declare `react: ">=18"`, so a consumer's React 18 satisfied them and the TUI ended up with two React instances, crashing on the first hook (#1952). Inlining them (`noExternal` in `clients/tui/tsup.config.ts`) makes their `import "react"` resolve from the build directory like the bundle's own. `clients/tui/__tests__/tsupConfig.test.ts` enforces it; `ink` is the documented exception (unbundlable CJS `signal-exit@3`, and a `">=19"` peer). - **Which section is a separate question from which manifest.** A package `core/` imports at runtime must be in root **`dependencies`**: client builds externalize npm packages, so a published install resolves them from the root manifest and devDependencies are absent there. Only test/build-only packages (`express`) belong in `devDependencies`. Flag a runtime `core/` import added to `devDependencies` — it passes every local check and breaks the published package. ## Tests and the coverage gate diff --git a/AGENTS.md b/AGENTS.md index 5f9e44bc..8cbfdcf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,8 @@ The same **placement** rule covers anything reached only through **root-owned co - A package only the tests, the test servers, or the build tooling need belongs in **`devDependencies`** — `express`, added there by #1970. - `yaml` is in `dependencies` today even though its only importer is `test-servers/src/load-config.ts`. Left as-is deliberately (moving it changes what ships, which is not a docs change); if you touch it, confirm no published path reads YAML first. +**A package a client build _inlines_ is not a runtime dependency, and is not declared at the root.** The published tarball carries its code inside that client's bundle, so declaring it at the root only makes consumers install a second, unused copy. `ink-form` and `ink-scroll-view` are the two today: both render React and both declare a loose `react` peer (`">=18"`), which let npm hoist them next to a consumer's React 18 while the Inspector's React 19 nested underneath — two React instances, and the TUI crashed on the first hook they called (#1952). They are inlined by `clients/tui/tsup.config.ts` (`noExternal`) and declared only in `clients/tui/package.json`, where the build resolves them. The general rule: **a dependency that renders React components must be bundled into the client that uses it**, so its `import "react"` resolves from the build directory alongside the bundle's own; `clients/tui/__tests__/tsupConfig.test.ts` enforces it, and `ink` is the documented exception (unbundlable — its CJS `signal-exit@3` fails ESM interop — and safe, since its `">=19"` peer keeps npm from misplacing it). + The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one — v2 uses the packages above. It appears in the lock files only as a `"peer": true` entry pulled in by `ext-apps`. ## Contributing diff --git a/README.md b/README.md index 21b91d04..7f952494 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,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/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved _relative to_ `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. +- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours. `ink-form` and `ink-scroll-view` declare a loose `react` peer (`">=18"`), so a project holding React 18 satisfies them and hoists them to its own root while the Inspector's React 19 nests underneath — two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). `ink` is the one React renderer left external — it can't be bundled (CJS `signal-exit@3` fails ESM interop) and its `">=19"` peer keeps npm from misplacing it. `clients/tui/__tests__/tsupConfig.test.ts` guards the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). - **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/tui/README.md b/clients/tui/README.md index 7a784a97..bcf102c3 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -106,3 +106,35 @@ Tests live in `__tests__/`. The coverage gate currently covers the TUI's non-React logic (server resolution, logger, tab metadata, and the `utils/` form/URL helpers); the Ink components and `App.tsx` are an interim exclusion in `vitest.config.ts` pending a renderer-based follow-up. + +### Bundling: React-rendering dependencies must be inlined (#1952) + +`tsup.config.ts` splits the TUI's dependencies into bundled (`noExternal`) and +externalized (`external`). For anything that **renders React components**, that +choice decides which React instance it gets at runtime, and getting it wrong +crashes the TUI in a way nothing in this repo can see. + +An external package's `import "react"` resolves from wherever npm placed **that +package** — and npm places it next to a React satisfying its own peer range. +`ink-form` and `ink-scroll-view` both accept `react: ">=18"`, so a project that +installs the Inspector alongside its own React 18 satisfies them: they hoist to +that project's root while the Inspector's React 19 nests underneath. The bundle +then renders through React 19 while those two call hooks on React 18, whose +dispatcher is null — so opening a tool test form (or any scroll view) dies with +`TypeError: Cannot read properties of null (reading 'useState')`. + +Bundling them removes npm from the decision: their `import "react"` is emitted +into `build/index.js`, so it resolves from the build directory exactly like the +bundle's own. It also pins their transitive deps to what *this* install +resolved — notably `ink-select-input@6` via the `overrides` entry, since npm +ignores a dependency's `overrides` and a consumer install would otherwise pull +the React-18-era v5 that `ink-form` asks for. + +`ink` itself stays external: it can't be bundled (its CJS `signal-exit@3` +dependency fails ESM interop with `Dynamic require of "assert" is not +supported`), and it doesn't need to be — its `react` peer is `">=19"`, which +keeps npm from hoisting it next to a React the Inspector couldn't also use. + +`__tests__/tsupConfig.test.ts` enforces this: every dependency declaring a +`react` peer must be in `noExternal` unless it is listed as external by design. +Add a React-rendering dependency, and that test tells you to bundle it. diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts new file mode 100644 index 00000000..b66cf13b --- /dev/null +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -0,0 +1,130 @@ +/** + * Bundling invariant for the TUI's React-rendering dependencies (#1952). + * + * The published TUI is a single ESM bundle whose bare `import "react"` resolves + * from `clients/tui/build/`. Any package left *external* resolves its own + * `react` from wherever npm placed **it** instead — and npm places a package + * next to a version satisfying its declared peer range. `ink-form` and + * `ink-scroll-view` accept `react: ">=18"`, so a consumer project holding React + * 18 satisfies them, they hoist to that project's root, and the Inspector's + * React 19 nests beneath it. Two React copies later, the first hook either of + * them calls reads a null dispatcher and the TUI dies with + * "Cannot read properties of null (reading 'useState')" — the moment a tool + * test form or a scroll view mounts. + * + * Inlining them removes npm from the decision entirely: their `import "react"` + * is emitted into the bundle, so it resolves exactly where the bundle's does. + * + * This test is the durable guard, because the failure is invisible in the repo + * (a dev install has one React) and in every smoke (same) — it only appears + * once the package is installed *under* another project that renders React. + */ + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Options } from "tsup"; +import tsupConfig from "../tsup.config.js"; + +const clientDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +/** + * `defineConfig()` is typed as a union — a single options object, an array of + * them, or a function returning either. The TUI config is the first form, so + * narrow to it at runtime rather than assuming: a future config that grows a + * second build should fail loudly here instead of silently checking nothing. + */ +function singleConfig(config: typeof tsupConfig): Options { + if (typeof config !== "object" || Array.isArray(config)) { + throw new Error( + "clients/tui/tsup.config.ts is expected to export one options object", + ); + } + return config; +} + +/** The string entries of an `external` / `noExternal` list (ours are all strings but the type allows RegExp). */ +function stringEntries(list: (string | RegExp)[] | undefined): string[] { + return (list ?? []).filter((entry) => typeof entry === "string"); +} + +/** + * The one React-rendering dependency deliberately left external. + * + * `ink` cannot be bundled — its CJS `signal-exit@3` dependency fails ESM + * interop ("Dynamic require of \"assert\" is not supported") — and does not + * need to be: its `react` peer is ">=19", so npm cannot hoist it next to a + * React the Inspector itself could not use. + */ +const EXTERNAL_BY_DESIGN = new Set(["ink"]); + +function readPackageJson(name: string): { + peerDependencies?: Record; +} { + // Resolve through the client's own node_modules rather than `require.resolve` + // on the package name: several of these packages export only their entry + // point, so `/package.json` is not a resolvable subpath. + const manifest = path.join(clientDir, "node_modules", name, "package.json"); + return JSON.parse(readFileSync(manifest, "utf8")); +} + +/** Dependencies that declare a `react` peer — i.e. that render React themselves. */ +function reactRenderingDependencies(): string[] { + const { dependencies } = JSON.parse( + readFileSync(path.join(clientDir, "package.json"), "utf8"), + ) as { dependencies: Record }; + + return Object.keys(dependencies).filter( + (name) => readPackageJson(name).peerDependencies?.react !== undefined, + ); +} + +const config = singleConfig(tsupConfig); +const noExternal = stringEntries(config.noExternal); +const external = stringEntries(config.external); + +describe("tui tsup config", () => { + it("finds the React-rendering dependencies to check", () => { + // A guard on the guard: if this resolves to nothing (a rename, a moved + // node_modules), every assertion below would pass vacuously. + expect(reactRenderingDependencies().length).toBeGreaterThan(0); + }); + + it("bundles every React-rendering dependency that can be bundled", () => { + const shouldInline = reactRenderingDependencies().filter( + (name) => !EXTERNAL_BY_DESIGN.has(name), + ); + + expect(shouldInline.length).toBeGreaterThan(0); + for (const name of shouldInline) { + expect( + noExternal, + `${name} renders React, so it must be bundled`, + ).toContain(name); + expect(external, `${name} must not be external`).not.toContain(name); + } + }); + + it("keeps react itself external, as the single shared instance", () => { + expect(external).toContain("react"); + expect(noExternal).not.toContain("react"); + }); + + it("documents each React-rendering package left external", () => { + for (const name of EXTERNAL_BY_DESIGN) { + expect(external, `${name} is external by design`).toContain(name); + } + }); + + it("verifies the deps it exempts are still declared", () => { + // If `ink` ever leaves the dependency list, the exemption above is stale + // and would silently excuse a future package that took its name. + for (const name of EXTERNAL_BY_DESIGN) { + expect(reactRenderingDependencies()).toContain(name); + } + }); +}); diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 92a637b3..1a90e87f 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -15,12 +15,28 @@ export default defineConfig({ sourcemap: false, target: "node22", platform: "node", - noExternal: [/^@inspector\/core/], + // Every package here renders React components, so it MUST share the one + // React instance the bundle imports. Bundling them is what guarantees that: + // an inlined package's `import "react"` is emitted into build/index.js and so + // resolves from *this* directory, exactly like the bundle's own — no consumer + // install layout can point it somewhere else (#1952). Left external, npm is + // free to hoist them next to a *different* React: `ink-form` and + // `ink-scroll-view` declare a loose `react` peer (">=18"), so a consumer + // project holding React 18 satisfies it and gets them hoisted to its own root + // while the Inspector's React 19 nests under it — two React copies, and the + // first hook they call reads a null dispatcher ("Cannot read properties of + // null (reading 'useState')") the moment a tool test form or a scroll view + // mounts. `__tests__/tsupConfig.test.ts` guards this list. + noExternal: [/^@inspector\/core/, "ink-form", "ink-scroll-view"], external: [ + // `react` is deliberately external — it is the single instance everything + // above resolves to, from this build directory. "react", + // `ink` stays external too: it cannot be bundled (its CJS `signal-exit@3` + // dependency fails ESM interop with "Dynamic require of \"assert\""), and + // it does not need to be — its `react` peer is ">=19", which keeps npm + // from hoisting it next to a React the Inspector could not also use. "ink", - "ink-form", - "ink-scroll-view", "open", "commander", "pino", diff --git a/package-lock.json b/package-lock.json index 61e95289..972b65f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,6 @@ "commander": "^13.1.0", "hono": "^4.12.18", "ink": "^6.0.0", - "ink-form": "^2.0.1", - "ink-scroll-view": "^0.3.6", "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", @@ -2181,21 +2179,6 @@ } } }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2552,76 +2535,6 @@ } } }, - "node_modules/ink-form": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ink-form/-/ink-form-2.0.1.tgz", - "integrity": "sha512-vo0VMwHf+HOOJo7026K4vJEN8xm4sP9iWlQLx4bngNEEY5K8t30CUvVjQCCNAV6Mt2ODt2Aq+2crCuBONReJUg==", - "license": "MIT", - "dependencies": { - "ink-select-input": "^5.0.0", - "ink-text-input": "^6.0.0" - }, - "peerDependencies": { - "ink": ">=4", - "react": ">=18" - } - }, - "node_modules/ink-scroll-view": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/ink-scroll-view/-/ink-scroll-view-0.3.7.tgz", - "integrity": "sha512-lUBLxSbVry/+UtJhuvRu6wumP43+ScVp69J7e+hmYwz1kTkahWfkVwWOu7Mn1DMPb8AU8bnsmEsr6kvSJvnaRw==", - "license": "MIT", - "peerDependencies": { - "ink": "^5 || ^6 || ^7", - "react": "^18 || ^19" - } - }, - "node_modules/ink-select-input": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz", - "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==", - "license": "MIT", - "dependencies": { - "figures": "^6.1.0", - "to-rotated": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5.0.0", - "react": ">=18.0.0" - } - }, - "node_modules/ink-text-input": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", - "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5", - "react": ">=18" - } - }, - "node_modules/ink-text-input/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -2732,18 +2645,6 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4080,18 +3981,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-rotated": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz", - "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index 05155e99..c09afb9d 100644 --- a/package.json +++ b/package.json @@ -91,8 +91,6 @@ "commander": "^13.1.0", "hono": "^4.12.18", "ink": "^6.0.0", - "ink-form": "^2.0.1", - "ink-scroll-view": "^0.3.6", "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", From bd6c1072bf331f402d192b30824e19e9bf193119 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 23:09:01 -0400 Subject: [PATCH 51/73] =?UTF-8?q?fix(docs):=20drop=20the=20signoff=20hook?= =?UTF-8?q?=20=E2=80=94=20it=20cannot=20know=20the=20commit=20author?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 was right that the hook signs with the configured identity while the DCO check requires a trailer matching the commit *author*, and that the two diverge on cherry-pick and `git commit -c`. I tried the suggested author-aware guard and it does not work: inside prepare-commit-msg, `git var GIT_AUTHOR_IDENT` returns the config identity, not the preserved author. Verified end to end — cherry-picking Bob's commit as Alice yields author=Bob, committer=Alice, `Signed-off-by: Alice`. That commit both still fails the gate and certifies work Alice did not write, which is the one thing the last bullet of this section forbids. Three review rounds have now found three holes in this hook, each fix opening the next. That is a wrong mechanism rather than an unfinished one, so the hook is out. A subtly-broken hook in the file that governs how this repo is worked on is worse than no hook. The bullet now recommends `git commit -s` and names BOTH false automations — `format.signOff` and the hook — recording the cherry-pick finding so the next person doesn't rediscover it, and pointing anyone who wants automation at a layer that actually knows the author. Mirror updated to match. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- .github/copilot-instructions.md | 2 +- AGENTS.md | 18 ++++-------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ffc4c38f..5e7edd68 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,7 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. -- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (note `format.signOff` does *not* sign `git commit` — only `format-patch`; automate with a `prepare-commit-msg` hook instead). Repairing pushed commits means `git rebase HEAD~ --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. +- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (`format.signOff` does *not* sign `git commit` — only `format-patch` — and a `prepare-commit-msg` hook signs with the committer's identity, not the author's, so it breaks on cherry-picks). Repairing pushed commits means `git rebase HEAD~ --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. ## What to prioritize in review diff --git a/AGENTS.md b/AGENTS.md index fc495082..ac9fb499 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,22 +208,12 @@ All work should be driven by items on the project board. - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name ` trailer matching its author. There is no partial credit: one unsigned commit out of six fails the whole check. The app *does* expose an override button to anyone with write access, but treat it as unavailable — see the repair bullet below for why. - - **Prevent it, don't remember it** — the failure is invisible until after you have pushed. Commit with **`git commit -s`**. To stop relying on memory, add a `prepare-commit-msg` hook, which is the only mechanism that actually signs an ordinary `git commit`: + - **Prevent it with `git commit -s`** — the failure is invisible until after you have pushed, so make the flag habitual. Two things that look like automation and are not: - ```sh - cat > .git/hooks/prepare-commit-msg <<'HOOK' - #!/bin/sh - # Match THIS identity's exact trailer, not any Signed-off-by: a message - # reused from another commit (cherry-pick, `git commit -c`) already carries - # someone else's, and the DCO check requires one matching the author — so a - # bare `grep '^Signed-off-by:'` would skip and leave the commit failing. - SOB="Signed-off-by: $(git config user.name) <$(git config user.email)>" - grep -qF "$SOB" "$1" || git interpret-trailers --in-place --trailer "$SOB" "$1" - HOOK - chmod +x .git/hooks/prepare-commit-msg - ``` + - ⚠️ **`git config format.signOff true` does nothing here.** Despite the name it only defaults the `-s` flag for `git format-patch`; `git commit` never reads it, and there is no `commit.signoff` equivalent. Setting it looks like a fix and silently changes nothing. + - ⚠️ **Don't reach for a `prepare-commit-msg` hook either**, however tempting. A hook can only sign with your *configured* identity, while the DCO check requires a trailer matching the commit **author** — and the two diverge exactly when you least want a wrong answer. Verified: on `git cherry-pick`, the resulting commit keeps the original author but the hook signs as you, producing a commit that both still fails the gate and certifies someone else's work. `git var GIT_AUTHOR_IDENT` does **not** resolve the true author inside that hook, so an author-aware guard doesn't rescue it. - ⚠️ **`git config format.signOff true` does NOT do this** — despite the name, it only defaults the `-s` flag for `git format-patch`; `git commit` never reads it, and there is no `commit.signoff` equivalent. Setting it looks like a fix and silently changes nothing, so it is a trap worth naming rather than omitting. + If you want automation anyway, put it somewhere that knows the author — a CI pre-merge check, or a shell alias you invoke deliberately — not a blanket commit hook. - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~ --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). **Rewriting is the only legitimate repair, and the two apparent alternatives are not.** The app's empty "remediation commit" flow requires `allowRemediationCommits.individual`; this repo ships no `.github/dco.yml`, so it runs with that disabled and the original unsigned commits keep failing. The app *does* also give anyone with write access an override button that forces the check green — but it only silences the check, it does not make the author certify anything, and the signoff is the certification (see below). Using it on your own unsigned commits asserts nothing while looking like compliance, so don't: sign the commits instead. If others are working on the branch, coordinate with them before rewriting. - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. From 3c1bbbcb5fda7e4eb393f7323f1b347726ccc440 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 23:15:35 -0400 Subject: [PATCH 52/73] fix(tui): correct ink-form's misspelled incomplete-form hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint under a form with unfilled required fields reads "you have not competed yet". The string is upstream's, hardcoded in `ink-form/lib/SubmitButton.js` with no prop to override it, and `ink-form` was last published in 2024 — but it renders in the Inspector's tool, prompt, and resource test forms, so it is ours to fix in practice. Bundling `ink-form` (previous commit) is what makes it patchable: an esbuild `onLoad` hook rewrites the label as the file enters the bundle. The hook throws when the string is absent rather than passing the source through, so an `ink-form` upgrade cannot silently retire the patch or leave it aimed at a string that no longer exists — the tests drive it against the real dependency source for the same reason. Reported upstream as lukasbach/ink-form#14. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- clients/tui/README.md | 16 +++++++ clients/tui/__tests__/tsupConfig.test.ts | 39 ++++++++++++++++- clients/tui/tsup.config.ts | 54 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/clients/tui/README.md b/clients/tui/README.md index bcf102c3..58e8be40 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -138,3 +138,19 @@ keeps npm from hoisting it next to a React the Inspector couldn't also use. `__tests__/tsupConfig.test.ts` enforces this: every dependency declaring a `react` peer must be in `noExternal` unless it is listed as external by design. Add a React-rendering dependency, and that test tells you to bundle it. + +### The `ink-form` label patch + +Bundling `ink-form` also makes it patchable, which one label needs: the hint +under an incomplete form reads "you have not **competed** yet". It is upstream's +string, hardcoded in `ink-form/lib/SubmitButton.js` with no prop to override, +and `ink-form` was last published in 2024 — so `tsup.config.ts` corrects it with +an esbuild `onLoad` hook as the file enters the bundle. It is reported upstream +as [lukasbach/ink-form#14](https://github.com/lukasbach/ink-form/issues/14); if +a release ever carries the fix, drop the patch. + +The hook **throws** when the string isn't found rather than passing the source +through. A silent no-op would let an `ink-form` upgrade retire the patch without +anyone noticing it had stopped applying — or leave a patch aimed at a string +that no longer exists. If the build fails there, check whether upstream fixed +the typo and delete the patch instead of re-targeting it. diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts index b66cf13b..1ad3490c 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -25,7 +25,10 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Options } from "tsup"; -import tsupConfig from "../tsup.config.js"; +import tsupConfig, { + INK_FORM_INCOMPLETE_HINT, + fixInkFormIncompleteHint, +} from "../tsup.config.js"; const clientDir = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -120,6 +123,40 @@ describe("tui tsup config", () => { } }); + it("corrects ink-form's misspelled incomplete-form hint", () => { + // Read the real dependency, so an `ink-form` upgrade that fixes or rewords + // the label fails here — the patch must then be removed, not left silently + // matching nothing. + const submitButton = readFileSync( + path.join( + clientDir, + "node_modules", + "ink-form", + "lib", + "SubmitButton.js", + ), + "utf8", + ); + + const patched = fixInkFormIncompleteHint(submitButton, "SubmitButton.js"); + expect(patched).toContain(INK_FORM_INCOMPLETE_HINT.fixed); + expect(patched).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); + }); + + it("fails loudly rather than silently skipping a label it cannot find", () => { + expect(() => + fixInkFormIncompleteHint("no such label here", "SubmitButton.js"), + ).toThrow(/no longer contains the ink-form label/); + }); + + it("only rewrites the misspelling", () => { + // The two strings must differ by exactly the fix, or the patch is silently + // changing copy nobody reviewed. + expect(INK_FORM_INCOMPLETE_HINT.fixed).toBe( + INK_FORM_INCOMPLETE_HINT.typo.replace("competed", "completed"), + ); + }); + it("verifies the deps it exempts are still declared", () => { // If `ink` ever leaves the dependency list, the exemption above is stale // and would silently excuse a future package that took its name. diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 1a90e87f..3a4c66f7 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -1,10 +1,63 @@ import { defineConfig } from "tsup"; +import type { Plugin } from "esbuild"; +import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(dirname, "../.."); +/** + * `ink-form` hardcodes a misspelled hint under an incomplete form — "you have + * not competed yet" — with no prop to override it. It is upstream's string + * (`ink-form/lib/SubmitButton.js`), last published in 2024, but it renders in + * the Inspector's tool/prompt/resource test forms, so we correct it on the way + * into the bundle. Reported upstream as lukasbach/ink-form#14; drop this patch + * if a release ever carries the fix. + * + * This is only possible because `ink-form` is inlined (see `noExternal` below). + */ +export const INK_FORM_INCOMPLETE_HINT = { + typo: "There are still required inputs you have not competed yet.", + fixed: "There are still required inputs you have not completed yet.", +}; + +/** + * Applies the correction above, throwing if the string is no longer there. + * + * Failing loudly is the point: a silent no-op would let an `ink-form` upgrade + * (or a fixed upstream, or a reworded label) quietly retire this patch with + * nobody noticing it had stopped applying — or, worse, leave a patch here for a + * string that no longer exists. If this throws, check whether upstream fixed + * the typo; if so, delete this patch rather than re-targeting it. + */ +export function fixInkFormIncompleteHint(source: string, file: string): string { + if (!source.includes(INK_FORM_INCOMPLETE_HINT.typo)) { + throw new Error( + `${file} no longer contains the ink-form label this build patches ` + + `(${JSON.stringify(INK_FORM_INCOMPLETE_HINT.typo)}). If upstream fixed ` + + `the typo, remove fixInkFormIncompleteHint from tsup.config.ts.`, + ); + } + return source.replaceAll( + INK_FORM_INCOMPLETE_HINT.typo, + INK_FORM_INCOMPLETE_HINT.fixed, + ); +} + +const inkFormLabelPatch: Plugin = { + name: "ink-form-label-patch", + setup(build) { + build.onLoad( + { filter: /ink-form[\\/]lib[\\/]SubmitButton\.js$/ }, + async ({ path: file }) => ({ + contents: fixInkFormIncompleteHint(await readFile(file, "utf8"), file), + loader: "js", + }), + ); + }, +}; + export default defineConfig({ entry: ["index.ts"], format: ["esm"], @@ -44,6 +97,7 @@ export default defineConfig({ "@modelcontextprotocol/core", "@napi-rs/keyring", ], + esbuildPlugins: [inkFormLabelPatch], esbuildOptions(options) { options.alias = { "@inspector/core": path.join(repoRoot, "core"), From c158ad809fb9869daabffa39df0227f4c416874e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 23:22:22 -0400 Subject: [PATCH 53/73] fix(docs): correct the DCO rules against the validator source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5 was right on all three counts. Read dcoapp/app/lib/dco.js this time rather than the README, which is where the earlier errors came from: const authors = [commit.author.name, commit.committer.name] const emails = [commit.author.email, commit.committer.email] if (isMerge || ...) continue // parents.length > 1 else if (author && author.type === 'Bot') continue So, corrected: - Scope. Merge commits and bot-authored commits are skipped, not gated. "Every commit" overstated it and could prompt a needless history rewrite. - Matching. The trailer may match the author OR the committer, not the author alone. A cherry-picker's own signoff is accepted. - Certification. The DCO expressly covers submitting work created by others that you have the right to submit, so signing off a cherry-pick is legitimate. My "never sign off someone else's commit" would have told a maintainer not to do a correct thing. The real prohibition is narrower: never fabricate a trailer in another person's name. This also retracts the mechanical argument in bd6c1072 for dropping the hook — it does satisfy the check, since you are the committer. The recommendation against it stands on honest grounds now: the trailer is a certification, a hook makes it for every commit automatically including work merely applied on someone else's behalf, and it provably cannot tell the difference. A judgment about deliberateness, not a claim that it breaks. Mirror corrected to match. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- .github/copilot-instructions.md | 2 +- AGENTS.md | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5e7edd68..2d229764 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,7 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. -- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (`format.signOff` does *not* sign `git commit` — only `format-patch` — and a `prepare-commit-msg` hook signs with the committer's identity, not the author's, so it breaks on cherry-picks). Repairing pushed commits means `git rebase HEAD~ --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. +- **Commits carry a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; it matches the trailer against the author *or* committer, and skips only merge and bot commits. Use `git commit -s` — note `format.signOff` does *not* sign `git commit` (only `format-patch`). Repairing pushed commits means `git rebase HEAD~ --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. ## What to prioritize in review diff --git a/AGENTS.md b/AGENTS.md index ac9fb499..506ea43f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,16 +207,14 @@ All work should be driven by items on the project board. - **Branch names start with the target version segment.** The first path segment must be the version whose base branch the PR targets — `v2/` for work on `v2/main`, `v1/` for work on `v1/main` — followed by the usual type and slug: `v2/ci/restore-claude-workflow`, `v2/fix/oauth-scope-union`, `v1/fix/proxy-ssrf-pin`. Not `ci/restore-claude-workflow`. This keeps the two lines legible in `git branch -a` and in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (`v2/main`, `v1/main`). - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. - - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name ` trailer matching its author. There is no partial credit: one unsigned commit out of six fails the whole check. The app *does* expose an override button to anyone with write access, but treat it as unavailable — see the repair bullet below for why. + - **Sign off your commits — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless each commit carries a `Signed-off-by: Name ` trailer whose name **and** email match either the commit's **author or its committer** (the validator checks both, so a cherry-picker's own signoff is accepted). Its only exemptions are **merge commits** and **bot-authored** commits, which it skips; everything else counts, and there is no partial credit — one unsigned commit out of six fails the whole check. The app *does* expose an override button to anyone with write access, but treat it as unavailable — see the repair bullet below for why. - **Prevent it with `git commit -s`** — the failure is invisible until after you have pushed, so make the flag habitual. Two things that look like automation and are not: - ⚠️ **`git config format.signOff true` does nothing here.** Despite the name it only defaults the `-s` flag for `git format-patch`; `git commit` never reads it, and there is no `commit.signoff` equivalent. Setting it looks like a fix and silently changes nothing. - - ⚠️ **Don't reach for a `prepare-commit-msg` hook either**, however tempting. A hook can only sign with your *configured* identity, while the DCO check requires a trailer matching the commit **author** — and the two diverge exactly when you least want a wrong answer. Verified: on `git cherry-pick`, the resulting commit keeps the original author but the hook signs as you, producing a commit that both still fails the gate and certifies someone else's work. `git var GIT_AUTHOR_IDENT` does **not** resolve the true author inside that hook, so an author-aware guard doesn't rescue it. - - If you want automation anyway, put it somewhere that knows the author — a CI pre-merge check, or a shell alias you invoke deliberately — not a blanket commit hook. + - ⚠️ **A `prepare-commit-msg` hook works, but think before installing one.** It would satisfy the check — a hook signs with your configured identity, and you are the committer, which the validator accepts. The objection is not mechanical but what it means: the trailer is a certification, and a hook makes it on your behalf for *every* commit, including work you merely cherry-picked or applied on someone else's behalf. Verified that the hook cannot distinguish those cases: inside `prepare-commit-msg`, `git var GIT_AUTHOR_IDENT` returns your config identity rather than the preserved author, so it cannot even tell that it is signing for someone else's authorship. Prefer `-s`, where each certification is a deliberate act. - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~ --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). **Rewriting is the only legitimate repair, and the two apparent alternatives are not.** The app's empty "remediation commit" flow requires `allowRemediationCommits.individual`; this repo ships no `.github/dco.yml`, so it runs with that disabled and the original unsigned commits keep failing. The app *does* also give anyone with write access an override button that forces the check green — but it only silences the check, it does not make the author certify anything, and the signoff is the certification (see below). Using it on your own unsigned commits asserts nothing while looking like compliance, so don't: sign the commits instead. If others are working on the branch, coordinate with them before rewriting. - - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. + - The signoff is a **[Developer Certificate of Origin](https://developercertificate.org/)** assertion, made in **your own name**. It does not claim you wrote the code — the DCO expressly covers submitting work created by others that you have the right to submit — so signing off a commit you cherry-picked is legitimate. What is never acceptable is fabricating *someone else's* certification: don't add a trailer bearing another person's name or email. If a commit needs their signoff, they add it. - Open a PR against the matching base branch (`v1/main` for v1, `v2/main` for v2) and set the item's Status to **In Review** - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. - **Link the PR to its issue — mandatory for every PR, from anyone.** No PR is opened without an issue to reference; if one doesn't exist yet, create it first (labeled and on the board) rather than opening the PR and backfilling. Note also that only the **repo maintainers** open PRs at all (see [Contributing](#contributing)) — everyone else files a detailed issue. The PR body's **first line must be `Closes #`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) From 39575ff060bcc7f4a7478a19d1d58ec568c39e2c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 23:29:13 -0400 Subject: [PATCH 54/73] test(tui): drive the ink-form patch through the plugin, and drop the esbuild type import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #1983. The patch's tests called the string helper directly, so nothing exercised the esbuild `onLoad` filter that decides whether the helper runs at all. A filter that stopped matching would leave the build green, the tests green, and the typo shipped — the silent no-op the throwing helper exists to prevent, reintroduced one level up. The plugin is now driven the way esbuild drives it (register, then invoke) against the real `ink-form` module path, and a second test pins the filter's scope: it matches that path and a Windows-separator one, and rejects `Form.js`, `ink-select-input`'s own `SubmitButton.js`, and a first-party `src/SubmitButton.jsx`. The plugin type came from `import type { Plugin } from "esbuild"`, but `esbuild` is tsup's transitive dependency, not one this client declares — it typechecked only while npm happened to hoist it, and would break on a lock change that nested it. Derive it from tsup's own `Options["esbuildPlugins"]` instead of adding a dependency for a type. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- clients/tui/__tests__/tsupConfig.test.ts | 75 ++++++++++++++++++++---- clients/tui/tsup.config.ts | 21 +++++-- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts index 1ad3490c..0cff33ca 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -27,7 +27,9 @@ import { fileURLToPath } from "node:url"; import type { Options } from "tsup"; import tsupConfig, { INK_FORM_INCOMPLETE_HINT, + INK_FORM_SUBMIT_BUTTON, fixInkFormIncompleteHint, + inkFormLabelPatch, } from "../tsup.config.js"; const clientDir = path.resolve( @@ -86,6 +88,15 @@ function reactRenderingDependencies(): string[] { ); } +/** The real `ink-form` module the label patch targets. */ +const submitButtonPath = path.join( + clientDir, + "node_modules", + "ink-form", + "lib", + "SubmitButton.js", +); + const config = singleConfig(tsupConfig); const noExternal = stringEntries(config.noExternal); const external = stringEntries(config.external); @@ -127,22 +138,64 @@ describe("tui tsup config", () => { // Read the real dependency, so an `ink-form` upgrade that fixes or rewords // the label fails here — the patch must then be removed, not left silently // matching nothing. - const submitButton = readFileSync( - path.join( - clientDir, - "node_modules", - "ink-form", - "lib", - "SubmitButton.js", - ), - "utf8", + const patched = fixInkFormIncompleteHint( + readFileSync(submitButtonPath, "utf8"), + submitButtonPath, ); - - const patched = fixInkFormIncompleteHint(submitButton, "SubmitButton.js"); expect(patched).toContain(INK_FORM_INCOMPLETE_HINT.fixed); expect(patched).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); }); + it("routes the real module through the plugin, not just the helper", async () => { + // The helper is only reached if the plugin's onLoad filter matches. Drive + // the plugin as esbuild would — register, then invoke — so a filter that + // stops matching the real module's path fails here instead of no-opping + // through a green build (the exact silence this patch exists to prevent). + type OnLoadCallback = (args: { + path: string; + }) => Promise<{ contents: string }>; + const registered: { filter: RegExp; callback: OnLoadCallback }[] = []; + + const build = { + onLoad: (options: { filter: RegExp }, callback: OnLoadCallback) => + registered.push({ filter: options.filter, callback }), + }; + // esbuild's `PluginBuild` carries far more than this patch touches, and the + // stub above deliberately implements only the one hook it registers — so + // the double cast is bridging a real structural gap, not hiding a mismatch. + // A hook the patch called but the stub lacks fails as undefined here rather + // than passing silently. + inkFormLabelPatch.setup( + build as unknown as Parameters[0], + ); + + expect(registered).toHaveLength(1); + const [{ filter, callback }] = registered; + expect(filter.test(submitButtonPath)).toBe(true); + + const { contents } = await callback({ path: submitButtonPath }); + expect(contents).toContain(INK_FORM_INCOMPLETE_HINT.fixed); + expect(contents).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); + }); + + it("scopes the patch to ink-form's SubmitButton and nothing else", () => { + expect(INK_FORM_SUBMIT_BUTTON.test(submitButtonPath)).toBe(true); + // A Windows-style path must match too — the filter runs against whatever + // esbuild resolved, and its separator is the platform's. + expect( + INK_FORM_SUBMIT_BUTTON.test( + "C:\\repo\\node_modules\\ink-form\\lib\\SubmitButton.js", + ), + ).toBe(true); + for (const other of [ + "/repo/node_modules/ink-form/lib/Form.js", + "/repo/node_modules/ink-select-input/build/SubmitButton.js", + "/repo/src/SubmitButton.jsx", + ]) { + expect(INK_FORM_SUBMIT_BUTTON.test(other), other).toBe(false); + } + }); + it("fails loudly rather than silently skipping a label it cannot find", () => { expect(() => fixInkFormIncompleteHint("no such label here", "SubmitButton.js"), diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 3a4c66f7..b75dd86e 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -1,5 +1,4 @@ -import { defineConfig } from "tsup"; -import type { Plugin } from "esbuild"; +import { defineConfig, type Options } from "tsup"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -45,11 +44,25 @@ export function fixInkFormIncompleteHint(source: string, file: string): string { ); } -const inkFormLabelPatch: Plugin = { +/** + * Which module the patch above is applied to. + * + * Exported so the tests can check it against the *resolved* path of the real + * `ink-form` module — a filter that stops matching is the one way this patch + * can silently no-op, since `fixInkFormIncompleteHint` would then never run. + */ +export const INK_FORM_SUBMIT_BUTTON = /ink-form[\\/]lib[\\/]SubmitButton\.js$/; + +// The plugin type is derived from tsup rather than imported from `esbuild`: +// `esbuild` is tsup's transitive dependency, not a declared one of this client, +// so a direct import typechecks only while npm happens to hoist it. +type EsbuildPlugin = NonNullable[number]; + +export const inkFormLabelPatch: EsbuildPlugin = { name: "ink-form-label-patch", setup(build) { build.onLoad( - { filter: /ink-form[\\/]lib[\\/]SubmitButton\.js$/ }, + { filter: INK_FORM_SUBMIT_BUTTON }, async ({ path: file }) => ({ contents: fixInkFormIncompleteHint(await readFile(file, "utf8"), file), loader: "js", From 9fd5bc177cb9e40e651756c533fed14526043bc6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 23:45:09 -0400 Subject: [PATCH 55/73] chore: drop the orphaned root override, and correct the TUI coverage note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #1983 (Copilot's suppressed comments). Removing `ink-form` from the root dependencies left `overrides.ink-select-input` with nothing to override: no root dependency reaches that package any more, and the regenerated lock carries no entry for it. The override that matters lives in `clients/tui/package.json`, where the build resolves `ink-form` — and it is what puts `ink-select-input@6` (rather than the React-18-era v5) into the bundle. The README paragraph above the new bundling section still described the Ink components and `App.tsx` as an interim coverage exclusion. #1501 lifted that: `vitest.config.ts` now gates all of `src/**`, excluding only the pure `src/tui-servers.ts` re-export. Corrected to match the config it describes. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- clients/tui/README.md | 14 ++++++++++---- package.json | 3 --- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/clients/tui/README.md b/clients/tui/README.md index 58e8be40..0fb57407 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -102,10 +102,16 @@ The repo-root `validate:tui` just delegates here. `eslint.config.js` registers stricter react-hooks@7 rules are not enforced on the interim component surface (#1501). -Tests live in `__tests__/`. The coverage gate currently covers the TUI's -non-React logic (server resolution, logger, tab metadata, and the `utils/` -form/URL helpers); the Ink components and `App.tsx` are an interim exclusion in -`vitest.config.ts` pending a renderer-based follow-up. +Tests live in `__tests__/`. The coverage gate covers **all of `src/**`**, React +surface included — the Ink components mount through `ink-testing-library` (with +the `ink-scroll-view` / `ink-form` passthrough doubles in `__tests__/helpers/`), +`App.tsx` mounts against a mock of the `@inspector/core` surface, and keypresses +are driven through stdin. The former interim exclusion of the components and +`App.tsx` was lifted in #1501; the only exclusion left in `vitest.config.ts` is +`src/tui-servers.ts`, a pure re-export of core's server resolver with no runtime +statements of its own (its logic is measured in `core/` via the web suite, and +`tui-servers.test.ts` still exercises it behaviorally — it is excluded only so it +doesn't surface as a misleading 0/0 row). ### Bundling: React-rendering dependencies must be inlined (#1952) diff --git a/package.json b/package.json index c09afb9d..6484a82f 100644 --- a/package.json +++ b/package.json @@ -99,9 +99,6 @@ "yaml": "^2.9.0", "zod": "^4.4.3" }, - "overrides": { - "ink-select-input": "^6.2.0" - }, "engines": { "node": ">=22.19.0" }, From 1e0f94bb31a9034159f4b1b5895b01f43ca5869f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 12 Aug 2026 00:16:05 -0400 Subject: [PATCH 56/73] fix(test): clear leaked timers so one cannot fail an unrelated CI run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window.setTimeout that outlives its test file fires after happy-dom disposes that file's window; React's dispatchSetState then throws an uncaught ReferenceError: window is not defined. Vitest attributes it to whichever file was running, so a single leak fails the ENTIRE run with every test passing — landing on an innocent file under a step named "Enforce per-file coverage gate." Seen twice in CI, both on docs-only branches. Not a Mantine bug, and not a badly-written test. Both hooks do clear on unmount (useTransition's clearAllTimeouts, useLockScroll's effect cleanup). It is a rAF race: clearAllTimeouts cancels the *pending* rAF, but the transition schedules rAF -> rAF -> setTimeout, so if the inner callback is already in flight when the unmount lands, cancelAnimationFrame is a no-op and that callback then schedules a timer after cleanup has run. Nothing owns it. Needs a loaded machine, which is why it only ever appeared in CI. Track every timer and clear whatever is still outstanding after cleanup(). The ordering is load-bearing: legitimate unmount cleanups get their turn first, so only true leaks are dropped. Fake timers pass through untracked, which is correct — a fake timer cannot outlive the environment. Also corrects the claim, in AGENTS.md and in setup.ts's own comment, that env="test" prevents this. It does not: env is read only by Transition.mjs at its render branch, while useTransition runs before that check (hooks cannot be conditional) and still schedules real timers. Measured — opening a through renderWithMantine schedules three 200ms timers. Believing that guarantee is why this went unexamined. The regression test is mutation-verified: with the net disabled it fails, with it restored it passes. It deliberately does not assert that Mantine schedules no timers — it does, and that is normal; the contract is that none survive. Closes #1984 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- AGENTS.md | 2 +- clients/web/src/test/leakedTimers.test.tsx | 72 ++++++++++++++++++++ clients/web/src/test/setup.ts | 79 ++++++++++++++++++++-- 3 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 clients/web/src/test/leakedTimers.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 5f9e44bc..8584c231 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -635,7 +635,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **Side-by-side (`.test.tsx` next to the source) — the default for web's own `src/` code.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead of beside its source is a bug (fixed one such straggler, `downloadFile.test.ts`, in #1776). - **`src/test/` — the three things that _cannot_ be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement _is_ the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests _of_ a source file, so nothing to sit beside. - **The above is web only.** The Node clients (**cli, tui, launcher**) keep **all** their tests in a top-level **`__tests__/`** dir, not beside their source — their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes `__tests__/**/*` (plus, for launcher, its root `vitest.config.ts`), so a co-located `src/**/*.test.*` lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage` (#1791). Put a new cli/tui/launcher test under `__tests__/`. -- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. +- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"`, which makes Mantine skip the transition's animated **render** — but be clear on what that does *not* buy you: it does **not** stop the timers. `env` is read only by `Transition.mjs`, at its render branch (`transitionDuration === 0 || env === "test"`), while `useTransition()` runs before that check (hooks cannot be conditional) and still schedules real `window.setTimeout`s on every `mounted` change. Measured: opening a `` through `renderWithMantine` schedules three 200ms timers (#1984). A timer that outlives its file fires after happy-dom disposes that file's `window`, and React's `dispatchSetState` then throws an uncaught `ReferenceError: window is not defined` that fails the **whole run** — attributed to whichever file was running, so it lands on an innocent one (#1760). What actually prevents that is the **leaked-timer safety net in `src/test/setup.ts`**, which tracks every timer and clears whatever is still pending after `cleanup()`; see the comment there for the rAF race that makes Mantine's own unmount cleanup insufficient. **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. ### Responding to Code Reviews diff --git a/clients/web/src/test/leakedTimers.test.tsx b/clients/web/src/test/leakedTimers.test.tsx new file mode 100644 index 00000000..ea51b4be --- /dev/null +++ b/clients/web/src/test/leakedTimers.test.tsx @@ -0,0 +1,72 @@ +/** + * Regression tests for the leaked-timer safety net in `setup.ts` (#1984). + * + * A `window.setTimeout` that outlives its test file fires after happy-dom has + * disposed that file's `window`, and React then throws an uncaught + * `ReferenceError: window is not defined` that fails the whole run — from an + * arbitrary innocent file, with every test passing. These lock down the net that + * prevents it. + * + * Note what is deliberately NOT asserted: that Mantine schedules no timers. It + * does (measured: three 200ms timers when a `Modal` opens, even under + * `env="test"`), and that is the library behaving normally. The contract here is + * only that nothing survives the test that scheduled it. + */ + +import { describe, it, expect, vi } from "vitest"; +import { Modal } from "@mantine/core"; +import { renderWithMantine } from "./renderWithMantine"; + +/** Ids the net is tracking, read back through the wrapper it installed. */ +function scheduleTracked(ms: number): number { + return window.setTimeout(() => {}, ms) as unknown as number; +} + +describe("leaked-timer safety net", () => { + it("wraps window.setTimeout rather than leaving the native one in place", () => { + // If the wrapper were absent the net would silently track nothing, so assert + // the instrumentation exists before relying on the behavior it enables. + expect(window.setTimeout.toString()).not.toContain("[native code]"); + }); + + it("clears a timer the test leaves pending, so it cannot fire later", async () => { + const fired = vi.fn(); + window.setTimeout(fired, 20); + // Deliberately do not clear it: the afterEach net owns it from here. The + // next test asserts it never ran. + leaked.callback = fired; + expect(fired).not.toHaveBeenCalled(); + }); + + it("the previous test's leaked timer never fired", async () => { + // 20ms of real time, comfortably past the leaked timer's deadline. + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(leaked.callback).not.toBeNull(); + expect(leaked.callback).not.toHaveBeenCalled(); + }); + + it("clearTimeout stops tracking, so an explicitly-cleared timer is not double-cleared", () => { + const id = scheduleTracked(1000); + expect(() => window.clearTimeout(id)).not.toThrow(); + // Clearing twice must stay a no-op — the net also clears at teardown. + expect(() => window.clearTimeout(id)).not.toThrow(); + }); + + it("a Modal's own transition timers do not survive the test that opened it", () => { + // The real-world shape of #1984: ServerRemoveConfirmModal-style tests toggle + // `opened`, and Mantine schedules real timers for the transition and the + // scroll lock. Rendering here is enough — the net clears them at teardown, + // and a regression would surface as the run-killing uncaught ReferenceError + // rather than as a failure of this assertion. + const { rerender } = renderWithMantine( + {}} />, + ); + rerender( {}} />); + expect(document.body).toBeTruthy(); + }); +}); + +/** Carries the leaked callback across the two tests above. */ +const leaked: { callback: ReturnType | null } = { + callback: null, +}; diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index d818a2ce..c84a313f 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -65,10 +65,15 @@ Object.defineProperty(globalThis, "fetch", { // disable Mantine transitions: `useTransition` only honors reduced motion when // `theme.respectReducedMotion` is true, and Mantine 8 defaults it to `false` // (the project theme doesn't override it). The actual protection against the -// #1760 post-teardown `window is not defined` leak is `env="test"` in -// `renderWithMantine`, which forces transitions synchronous regardless. Tests -// that need specific media results still mock `@mantine/hooks` or stub -// `matchMedia` themselves. +// #1760 post-teardown `window is not defined` leak is the leaked-timer safety +// net below — NOT `env="test"`, which an earlier version of this comment +// claimed. `env` is read only by `Transition.mjs`, and only at its *render* +// branch (`transitionDuration === 0 || env === "test"`); `useTransition()` is +// called before that check, since hooks cannot be conditional, so it still +// schedules real `window.setTimeout`s on every `mounted` change. Measured: +// opening a `` through `renderWithMantine` schedules three 200ms timers +// (#1984). Tests that need specific media results still mock `@mantine/hooks` +// or stub `matchMedia` themselves. Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, @@ -91,7 +96,73 @@ Object.defineProperty(window, "matchMedia", { }) as unknown as MediaQueryList, }); +// --------------------------------------------------------------------------- +// Leaked-timer safety net (#1984) +// +// A `window.setTimeout` that outlives its test file fires after happy-dom has +// disposed that file's `window`, and React's `dispatchSetState` then throws an +// uncaught `ReferenceError: window is not defined`. Vitest attributes that to +// whichever file happened to be running, and one such error fails the ENTIRE +// run with every test passing — so it reads as a defect in an innocent file. +// +// Mantine's own hooks do clear their timers on unmount (`useTransition`'s +// `clearAllTimeouts`, `useLockScroll`'s effect cleanup), so this is not a +// library bug. It is a race: `clearAllTimeouts` cancels the *pending* rAF, but +// if that rAF callback is already in flight when the unmount lands, +// `cancelAnimationFrame` is a no-op and the callback goes on to schedule a +// `setTimeout` after cleanup has already run. Nothing owns that timer. It needs +// a loaded machine to hit, which is why it only ever appears in CI. +// +// Rather than chase each component, track every timer and clear whatever is +// still outstanding once the test's own teardown has had its turn. Ordering is +// load-bearing: this runs *after* `cleanup()` below, so legitimate unmount +// cleanups clear their own timers and only true leaks are left. +// +// Under `vi.useFakeTimers()` the wrapper is swapped out for vitest's fake +// implementation, so nothing is tracked while fake timers are installed — which +// is correct, since a fake timer cannot outlive the environment. +const pendingTimers = new Set(); +const realSetTimeout = window.setTimeout.bind(window); +const realClearTimeout = window.clearTimeout.bind(window); + +window.setTimeout = (( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] +): number => { + const id: number = realSetTimeout( + (...cbArgs: unknown[]) => { + pendingTimers.delete(id); + if (typeof handler === "function") { + handler(...cbArgs); + } + }, + timeout, + ...args, + ); + pendingTimers.add(id); + return id; + // happy-dom types `setTimeout` with Node's overloads (returning `Timeout`), + // while the DOM lib types it as `number`. The implementation above honors the + // DOM shape the app codes against; this cast bridges the two declarations, + // which TS cannot relate structurally. +}) as unknown as typeof window.setTimeout; + +window.clearTimeout = ((id?: number): void => { + if (typeof id === "number") { + pendingTimers.delete(id); + } + realClearTimeout(id); + // Same DOM-vs-Node overload mismatch as `setTimeout` above. +}) as unknown as typeof window.clearTimeout; + afterEach(() => { cleanup(); window.localStorage.clear(); + // After cleanup(), anything still pending is a leak — drop it so it cannot + // fire past this file's environment teardown. + for (const id of pendingTimers) { + realClearTimeout(id); + } + pendingTimers.clear(); }); From 94a227b9c12451ee7810f1177e4992ac4713742c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 12 Aug 2026 00:21:43 -0400 Subject: [PATCH 57/73] fix(tui): widen the root react range so an external ink can't split React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #1983. Copilot was right, and about a hazard this repo had already proven and then documented away: the claim that ink's ">=19" peer "keeps npm from hoisting it next to a React the Inspector couldn't also use" is false. A consumer pinning React 19.0 satisfies ">=19" while `^19.2.4` nests underneath, so ink renders through their React and the bundle through ours — the #1952 crash, one level up, breaking TUI startup rather than just its forms. Bundling ink closes it (verified: with a `createRequire` banner for the inlined CJS, since react-reconciler calls `require("react")` and signal-exit@3 `require("assert")`), but costs ~1.4MB — react-reconciler and yoga-layout come along. The root `react` range closes it for free instead: `^19.0.0` is open to the whole major, so npm can satisfy our React and a consumer's pinned React 19 with one copy, and an external ink resolves the same copy the bundle does. Verified against both consumer trees — the reporter's (React 18 + ink 5) and a pinned React 19.0.0 + ink 6.8.0, which splits under `^19.2.4`. That makes the range load-bearing while looking like an ordinary version bump, so the tests pin it: the root range must equal ink's own declared peer floor. They also assert each exempt package is both external and a root dependency — external means consumers install it, the mirror image of the inlined ones, which must not be root-declared. The docs in all four places now say ink is exempt on cost and never on a peer range, and name that claim as one we got wrong. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- .github/copilot-instructions.md | 2 +- AGENTS.md | 4 +- README.md | 2 +- clients/tui/README.md | 68 +++++++++++++++++------- clients/tui/__tests__/tsupConfig.test.ts | 66 ++++++++++++++++++----- clients/tui/tsup.config.ts | 43 +++++++++------ package-lock.json | 29 +++++----- package.json | 2 +- 8 files changed, 152 insertions(+), 64 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 24367041..48820a00 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -77,7 +77,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. - **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. - Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `/node_modules` like the other pins there. -- **A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed it in the *consumer's* tree; `ink-form` and `ink-scroll-view` declare `react: ">=18"`, so a consumer's React 18 satisfied them and the TUI ended up with two React instances, crashing on the first hook (#1952). Inlining them (`noExternal` in `clients/tui/tsup.config.ts`) makes their `import "react"` resolve from the build directory like the bundle's own. `clients/tui/__tests__/tsupConfig.test.ts` enforces it; `ink` is the documented exception (unbundlable CJS `signal-exit@3`, and a `">=19"` peer). +- **A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed it in the *consumer's* tree, beside a React satisfying *its* peer range — looser than ours, which is all it takes to split React. `ink-form`/`ink-scroll-view` declare `">=18"`, so a consumer's React 18 satisfies them, the TUI ends up with two React instances, and it crashes on the first hook (#1952). Both are inlined via `noExternal` in `clients/tui/tsup.config.ts`. **`ink` is the one exemption, justified by cost (~1.4MB) — never by a peer range**: flag any claim that `">=19"` keeps npm from misplacing it, which is false and was in this repo once. What keeps it safe is the **root `react` range staying open to the whole major (`^19.0.0`)** so npm can dedupe with a consumer's pinned React 19; treat narrowing that range as reopening the bug. `clients/tui/__tests__/tsupConfig.test.ts` enforces the split, the root-declaration of exempt packages, and that range. - **Which section is a separate question from which manifest.** A package `core/` imports at runtime must be in root **`dependencies`**: client builds externalize npm packages, so a published install resolves them from the root manifest and devDependencies are absent there. Only test/build-only packages (`express`) belong in `devDependencies`. Flag a runtime `core/` import added to `devDependencies` — it passes every local check and breaks the published package. ## Tests and the coverage gate diff --git a/AGENTS.md b/AGENTS.md index 8cbfdcf5..0211f8c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,9 @@ The same **placement** rule covers anything reached only through **root-owned co - A package only the tests, the test servers, or the build tooling need belongs in **`devDependencies`** — `express`, added there by #1970. - `yaml` is in `dependencies` today even though its only importer is `test-servers/src/load-config.ts`. Left as-is deliberately (moving it changes what ships, which is not a docs change); if you touch it, confirm no published path reads YAML first. -**A package a client build _inlines_ is not a runtime dependency, and is not declared at the root.** The published tarball carries its code inside that client's bundle, so declaring it at the root only makes consumers install a second, unused copy. `ink-form` and `ink-scroll-view` are the two today: both render React and both declare a loose `react` peer (`">=18"`), which let npm hoist them next to a consumer's React 18 while the Inspector's React 19 nested underneath — two React instances, and the TUI crashed on the first hook they called (#1952). They are inlined by `clients/tui/tsup.config.ts` (`noExternal`) and declared only in `clients/tui/package.json`, where the build resolves them. The general rule: **a dependency that renders React components must be bundled into the client that uses it**, so its `import "react"` resolves from the build directory alongside the bundle's own; `clients/tui/__tests__/tsupConfig.test.ts` enforces it, and `ink` is the documented exception (unbundlable — its CJS `signal-exit@3` fails ESM interop — and safe, since its `">=19"` peer keeps npm from misplacing it). +**A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, and npm places a package beside a React satisfying *that package's* peer range — looser than ours in every case here, which is all it takes to split React. `ink-form` and `ink-scroll-view` declare `">=18"`, satisfied by a consumer's React 18 while our React 19 nests underneath: the bundle renders through one React, those packages call hooks on another, and the TUI crashes on the first hook (#1952). Both are inlined by `clients/tui/tsup.config.ts` (`noExternal`) and declared only in `clients/tui/package.json`, where the build resolves them — declaring an inlined package at the root would just make consumers install a second, unused copy. + +**`ink` is the single exemption, and it is justified by cost, not by safety.** Bundling it works but adds ~1.4MB (`react-reconciler` + `yoga-layout`, plus a `createRequire` banner, since inlined CJS calls `require` at runtime and esbuild's ESM interop rejects that without a real `require` in scope). **Never justify an exemption by a peer range** — `ink` briefly carried "its `">=19"` peer keeps npm honest", which is false: a consumer pinning React 19.0 satisfies `">=19"` while a narrower range of ours nests underneath. What actually makes the exemption safe is a *different* lever: the **root `react` range stays open to the whole major (`^19.0.0`)**, so npm can dedupe our React with whatever React 19 a consumer pins and an external `ink` lands on the same copy the bundle uses. Narrowing it (e.g. back to `^19.2.4`) silently reopens the crash for the renderer itself, which breaks TUI *startup*, not just its forms. `clients/tui/__tests__/tsupConfig.test.ts` enforces all of it: React-rendering deps inlined, each exempt package both external and root-declared, and the root range pinned to `ink`'s peer floor. The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one — v2 uses the packages above. It appears in the lock files only as a `"peer": true` entry pulled in by `ext-apps`. diff --git a/README.md b/README.md index 7f952494..55459ad0 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,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/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved _relative to_ `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. -- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours. `ink-form` and `ink-scroll-view` declare a loose `react` peer (`">=18"`), so a project holding React 18 satisfies them and hoists them to its own root while the Inspector's React 19 nests underneath — two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). `ink` is the one React renderer left external — it can't be bundled (CJS `signal-exit@3` fails ESM interop) and its `">=19"` peer keeps npm from misplacing it. `clients/tui/__tests__/tsupConfig.test.ts` guards the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). +- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours — npm places a package beside a React satisfying *its* peer range, and those ranges are looser than ours. `ink-form` and `ink-scroll-view` declare `">=18"`, so a project holding React 18 satisfies them and gets them hoisted while the Inspector's React 19 nests underneath: two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). **`ink` is the one exception, on cost:** bundling it works but adds ~1.4 MB (`react-reconciler` and `yoga-layout` come along, plus a `createRequire` banner for the inlined CJS), so it stays external — *not* because its `">=19"` peer makes it safe, which it does not. What keeps that tolerable is the root `react` range: `"^19.0.0"` is deliberately open to the whole major so npm can dedupe our React with whatever React 19 a consumer pins, leaving an external `ink` on the same copy the bundle uses. **Narrowing that range reopens the bug for the renderer itself** — `clients/tui/__tests__/tsupConfig.test.ts` pins it to `ink`'s peer floor, and guards the rest of the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). - **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/tui/README.md b/clients/tui/README.md index 0fb57407..1ae2fb51 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -121,28 +121,60 @@ choice decides which React instance it gets at runtime, and getting it wrong crashes the TUI in a way nothing in this repo can see. An external package's `import "react"` resolves from wherever npm placed **that -package** — and npm places it next to a React satisfying its own peer range. -`ink-form` and `ink-scroll-view` both accept `react: ">=18"`, so a project that -installs the Inspector alongside its own React 18 satisfies them: they hoist to -that project's root while the Inspector's React 19 nests underneath. The bundle -then renders through React 19 while those two call hooks on React 18, whose -dispatcher is null — so opening a tool test form (or any scroll view) dies with +package** — and npm places it beside a React satisfying *its* peer range, which +is looser than ours in every case here: + +| Package | Its `react` peer | Placed beside a different React when… | +| --- | --- | --- | +| `ink-form`, `ink-scroll-view` | `">=18"` | the consumer has React 18 | +| `ink` | `">=19"` | the consumer pins React 19.0 (our `^19.2.4` then nests) | + +Either way the bundle renders through one React while the external package calls +hooks on another, whose dispatcher is null — so opening a tool test form (or any +scroll view, or in `ink`'s case simply starting the TUI) dies with `TypeError: Cannot read properties of null (reading 'useState')`. -Bundling them removes npm from the decision: their `import "react"` is emitted -into `build/index.js`, so it resolves from the build directory exactly like the -bundle's own. It also pins their transitive deps to what *this* install -resolved — notably `ink-select-input@6` via the `overrides` entry, since npm -ignores a dependency's `overrides` and a consumer install would otherwise pull -the React-18-era v5 that `ink-form` asks for. +`ink-form` and `ink-scroll-view` are therefore **inlined**, which removes npm +from the decision: an inlined package's `import "react"` is emitted into +`build/index.js`, so it resolves from the build directory exactly like the +bundle's own. Inlining also pins transitive deps to what *this* install +resolved, notably `ink-select-input@6` via the `overrides` entry — npm ignores a +dependency's `overrides`, so a consumer install would otherwise pull the +React-18-era v5 that `ink-form` asks for. -`ink` itself stays external: it can't be bundled (its CJS `signal-exit@3` -dependency fails ESM interop with `Dynamic require of "assert" is not -supported`), and it doesn't need to be — its `react` peer is `">=19"`, which -keeps npm from hoisting it next to a React the Inspector couldn't also use. +#### Why `ink` itself is the exception -`__tests__/tsupConfig.test.ts` enforces this: every dependency declaring a -`react` peer must be in `noExternal` unless it is listed as external by design. +`ink` is external for **cost**, not because its `">=19"` peer makes it safe — it +does not, and an earlier revision of this section wrongly claimed it did. +Bundling `ink` works (it was built and verified against both consumer repros) +but adds ~1.4 MB, since `react-reconciler` and `yoga-layout` come with it, plus +a `createRequire` banner: inlined CJS calls `require` at runtime +(`react-reconciler` for `"react"`, `signal-exit@3` for `"assert"`) and esbuild's +interop shim throws `Dynamic require of "x" is not supported` without a real +`require` in scope. A 602 KB bundle beat a 2 MB one. + +What makes the exemption tolerable is a **separate lever**: the root manifest +declares `react: "^19.0.0"` — open to the whole major, rather than pinned at the +version we happen to develop against. That lets npm satisfy our React and a +consumer's pinned React 19.x with a single copy, so an external `ink` resolves +*ours*. Verified against a consumer pinning `react@19.0.0` alongside `ink@6.8.0` +— the case that splits under a narrower range: + +``` +bundle react node_modules/react/index.js +ink -> react node_modules/react/index.js # same copy +``` + +Narrow that range and the exemption turns straight back into #1952, one level +up — breaking TUI startup rather than just its forms. `tsupConfig.test.ts` pins +the root range to `ink`'s own peer floor so it can't drift silently. The +residual after all this is a React **20**-era consumer that also depends on +`ink`; that gets revisited when the Inspector moves to React 20. + +`__tests__/tsupConfig.test.ts` enforces the whole split: every dependency +declaring a `react` peer must be in `noExternal` unless it is listed as external +by design, each exempt package must also be a root dependency (external means +consumers install it), and the root `react` range must stay open to the major. Add a React-rendering dependency, and that test tells you to bundle it. ### The `ink-form` label patch diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts index 0cff33ca..a9bfc35f 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -58,12 +58,18 @@ function stringEntries(list: (string | RegExp)[] | undefined): string[] { } /** - * The one React-rendering dependency deliberately left external. + * React-rendering dependencies left external by an explicit trade-off. * - * `ink` cannot be bundled — its CJS `signal-exit@3` dependency fails ESM - * interop ("Dynamic require of \"assert\" is not supported") — and does not - * need to be: its `react` peer is ">=19", so npm cannot hoist it next to a - * React the Inspector itself could not use. + * `ink` is here for its **cost**, not because its peer range makes it safe. + * Bundling it works (verified against both consumer repros) but adds ~1.4MB — + * react-reconciler and yoga-layout come with it — plus a `createRequire` banner + * for the inlined CJS. The smaller tarball won. + * + * The exemption is only tolerable because the root manifest keeps `react` open + * to the whole major (`^19.0.0`), which lets npm dedupe our React with whatever + * React 19 a consumer pins — so an external `ink` resolves the same copy the + * bundle does. The test below pins that relationship: narrowing the root range + * silently reopens #1952 for the renderer itself. */ const EXTERNAL_BY_DESIGN = new Set(["ink"]); @@ -108,7 +114,7 @@ describe("tui tsup config", () => { expect(reactRenderingDependencies().length).toBeGreaterThan(0); }); - it("bundles every React-rendering dependency that can be bundled", () => { + it("bundles every React-rendering dependency that is not exempt", () => { const shouldInline = reactRenderingDependencies().filter( (name) => !EXTERNAL_BY_DESIGN.has(name), ); @@ -123,17 +129,53 @@ describe("tui tsup config", () => { } }); + it("keeps the root react range open to the whole major, so npm can dedupe", () => { + // This is what keeps the one exemption tolerable. `ink` is external, so it + // resolves whatever React npm placed beside it — and npm can only place it + // beside *ours* if our range admits the consumer's React too. Pinning the + // root range above the major floor (say `^19.2.4`) means a consumer holding + // React 19.0 gets a second copy: `ink` renders through theirs, the bundle + // through ours, and the TUI dies at startup on a null dispatcher. + // + // So the root range must start at the same floor `ink`'s own peer does. + const rootReact = ( + JSON.parse( + readFileSync(path.join(clientDir, "..", "..", "package.json"), "utf8"), + ) as { dependencies: Record } + ).dependencies.react; + const inkPeerReact = readPackageJson("ink").peerDependencies?.react ?? ""; + + const floor = /^>=(\d+)\.0\.0$/.exec(inkPeerReact)?.[1]; + expect(floor, `unexpected ink peer range: ${inkPeerReact}`).toBeDefined(); + expect( + rootReact, + `root react must be ^${floor}.0.0 so npm can dedupe with any React ${floor} a consumer pins`, + ).toBe(`^${floor}.0.0`); + }); + + it("keeps each exempt package external, and declared for consumers", () => { + // An external package is not shipped in the bundle, so the root manifest + // has to install it — the mirror image of the inlined ones, which must NOT + // be root dependencies. Getting this backwards breaks the published TUI at + // startup with an unresolved import. + const rootDependencies = JSON.parse( + readFileSync(path.join(clientDir, "..", "..", "package.json"), "utf8"), + ) as { dependencies: Record }; + + for (const name of EXTERNAL_BY_DESIGN) { + expect(external, `${name} is external by design`).toContain(name); + expect( + Object.keys(rootDependencies.dependencies), + `${name} is external, so consumers must install it`, + ).toContain(name); + } + }); + it("keeps react itself external, as the single shared instance", () => { expect(external).toContain("react"); expect(noExternal).not.toContain("react"); }); - it("documents each React-rendering package left external", () => { - for (const name of EXTERNAL_BY_DESIGN) { - expect(external, `${name} is external by design`).toContain(name); - } - }); - it("corrects ink-form's misspelled incomplete-form hint", () => { // Read the real dependency, so an `ink-form` upgrade that fixes or rewords // the label fails here — the patch must then be removed, not left silently diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index b75dd86e..2c0197a9 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -81,27 +81,38 @@ export default defineConfig({ sourcemap: false, target: "node22", platform: "node", - // Every package here renders React components, so it MUST share the one - // React instance the bundle imports. Bundling them is what guarantees that: - // an inlined package's `import "react"` is emitted into build/index.js and so - // resolves from *this* directory, exactly like the bundle's own — no consumer - // install layout can point it somewhere else (#1952). Left external, npm is - // free to hoist them next to a *different* React: `ink-form` and - // `ink-scroll-view` declare a loose `react` peer (">=18"), so a consumer - // project holding React 18 satisfies it and gets them hoisted to its own root - // while the Inspector's React 19 nests under it — two React copies, and the + // Every package here renders React components, so it MUST share the one React + // instance the bundle imports. Bundling is what guarantees that: an inlined + // package's `import "react"` is emitted into build/index.js, so it resolves + // from *this* directory exactly like the bundle's own, and no consumer install + // layout can point it elsewhere (#1952). + // + // Left external, npm is free to place a package beside a *different* React, + // because it places one beside a version satisfying that package's own peer + // range — looser than ours in every case here. `ink-form` and + // `ink-scroll-view` accept ">=18", so a consumer's React 18 satisfies them + // while the Inspector's React 19 nests underneath: two React copies, and the // first hook they call reads a null dispatcher ("Cannot read properties of // null (reading 'useState')") the moment a tool test form or a scroll view - // mounts. `__tests__/tsupConfig.test.ts` guards this list. + // mounts. That is the reported crash, and inlining them is its fix. + // + // `__tests__/tsupConfig.test.ts` guards this list. noExternal: [/^@inspector\/core/, "ink-form", "ink-scroll-view"], external: [ - // `react` is deliberately external — it is the single instance everything - // above resolves to, from this build directory. + // `react` is deliberately external — the single instance every inlined + // package above resolves to, from this build directory. "react", - // `ink` stays external too: it cannot be bundled (its CJS `signal-exit@3` - // dependency fails ESM interop with "Dynamic require of \"assert\""), and - // it does not need to be — its `react` peer is ">=19", which keeps npm - // from hoisting it next to a React the Inspector could not also use. + // `ink` is external by a deliberate trade-off, NOT because a ">=19" peer + // makes it safe — it does not, and that claim was wrong here once already. + // Bundling it works (verified) but costs ~1.4MB, since react-reconciler and + // yoga-layout come with it, plus a `createRequire` banner for the inlined + // CJS. The smaller tarball won. + // + // What makes that tolerable is the root manifest's `react: ^19.0.0`: being + // open to the whole major lets npm satisfy our React and a consumer's + // pinned one with a single copy, so an external `ink` resolves *ours*. Narrow + // that range and this exemption turns back into the #1952 crash, one level + // up — `__tests__/tsupConfig.test.ts` guards it. "ink", "open", "commander", diff --git a/package-lock.json b/package-lock.json index 972b65f2..d574fdd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "ink": "^6.0.0", "open": "^10.2.0", "pino": "^9.14.0", - "react": "^19.2.4", + "react": "^19.0.0", "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", @@ -1819,13 +1819,14 @@ } }, "node_modules/es-toolkit": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", - "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", "license": "MIT", "workspaces": [ "docs", - "benchmarks" + "benchmarks", + "tests/types" ] }, "node_modules/escape-html": { @@ -3887,9 +3888,9 @@ } }, "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -4024,9 +4025,9 @@ } }, "node_modules/type-fest": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", - "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -4291,9 +4292,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 6484a82f..c52f04c1 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "ink": "^6.0.0", "open": "^10.2.0", "pino": "^9.14.0", - "react": "^19.2.4", + "react": "^19.0.0", "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", From 24d89d7d0b7be942c039a758483c13fea66d443e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 12 Aug 2026 00:28:34 -0400 Subject: [PATCH 58/73] fix(test): wait for the dead transport instead of sleeping 300ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connectDeadSession spawned a real node subprocess whose only job is to die, then slept a flat 300ms for the death to propagate. That budget has to cover a cold spawn(), node's startup, its exit, the parent observing the close, and onclose marking the session dead — comfortable on an idle box, not under CI contention or a machine running several agents. When the budget was missed the session was still live, so isTransportDead() was false and /api/mcp/send fell through to `await responseWait` for a tools/list reply the dead child can never send. Nothing bounds that wait, so the test hung to the project's full 30s ceiling. The timeout IS the symptom of losing the race, which is why it never presented as a clean assertion failure. Poll the observable condition instead. Two design points that are load-bearing: - The probe is a NOTIFICATION (method, no id). requestIdForSendWait returns undefined for it, so the route never awaits a response and cannot hang. Probing with a request would reproduce the exact bug being fixed. - Not /api/mcp/events, which looks like the natural "transport died" signal: opening that stream on a dead transport calls sessions.delete(sessionId), so the send under test would then answer 404 instead of transport_error. Both terminal branches of the route answer transport_error — the isTransportDead() short-circuit and the catch around a send() that threw on the dead pipe — and either proves the subsequent real send cannot reach the hanging path, so both are valid stop conditions. Verified by reproducing the bug deterministically: with the wait reduced to 0 (the budget always missed) the test times out at 30s; with the poll it passes 10/10. Fixing the helper rather than the test also covers its second call site, the auth-state test, which sits on the same race and had simply not lost it yet. connect-crash.test.ts:147 is deliberately left alone. #1985 proposed giving it the same treatment, but readSseEvents stops at transport_error, which arrives whenever the child dies — so its 200ms sleep cannot hang, and changing it would add risk for no gain. Closes #1985 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- .../mcp/remote/remote-auth-branches.test.ts | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts index 5f992d45..27b5bc6d 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts @@ -76,8 +76,63 @@ async function startUnauthorizedUpstream(): Promise<{ return { url: `http://127.0.0.1:${port}/mcp`, server }; } -/** Connect a stdio session whose process crashes almost immediately, then - * give the onclose handler time to mark the session's transport dead. */ +/** + * Poll until the server reports the session's transport as dead (#1985). + * + * The probe is a **notification** — a `method` with no `id` — which matters: + * `requestIdForSendWait` returns `undefined` for it, so `/api/mcp/send` never + * awaits a response and cannot hang. Probing with a *request* would reproduce + * the very failure this guards against, since a request sent before the + * transport is marked dead waits forever for a reply the dead child will never + * send. + * + * Either terminal branch of the route answers `kind: "transport_error"` — the + * `isTransportDead()` short-circuit, and the `catch` around a `send()` that + * threw on the dead pipe. Both are fine as a stop condition: each one proves the + * subsequent real send also cannot reach the hanging path. + * + * Deliberately not `/api/mcp/events`: opening that stream on a dead transport + * calls `sessions.delete(sessionId)`, so the send under test would then answer + * 404 instead of the `transport_error` it is asserting. + */ +async function waitForDeadTransport( + h: Harness, + sessionId: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last = "(no response)"; + while (Date.now() < deadline) { + const res = await fetch(`${h.baseUrl}/api/mcp/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId, + message: { jsonrpc: "2.0", method: "notifications/initialized" }, + }), + }); + const body = (await res.json()) as { kind?: string }; + if (body.kind === "transport_error") return; + last = `HTTP ${res.status} ${JSON.stringify(body)}`; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error( + `transport was never reported dead within ${timeoutMs}ms; last probe: ${last}`, + ); +} + +/** + * Connect a stdio session whose process crashes almost immediately, then wait + * until the transport is actually marked dead. + * + * This used to sleep a flat 300ms, which had to cover a cold `spawn()`, node's + * startup, its exit, the parent observing the close, and `onclose` marking the + * session dead. Comfortable on an idle machine; not under CI contention. When + * the budget was missed the session was still live, so the send under test was + * dispatched to a transport whose child was gone and hung for the project's full + * 30s timeout (#1985). Waiting on the observable condition costs latency on a + * slow machine instead of a false failure. + */ async function connectDeadSession(h: Harness): Promise { const config: MCPServerConfig = { type: "stdio", @@ -87,9 +142,7 @@ async function connectDeadSession(h: Harness): Promise { const res = await connect(h, config); expect(res.status).toBe(200); const { sessionId } = (await res.json()) as { sessionId: string }; - // Give the subprocess time to exit and the transport's onclose handler - // to mark the session dead (mirrors connect-crash.test.ts's technique). - await new Promise((resolve) => setTimeout(resolve, 300)); + await waitForDeadTransport(h, sessionId); return sessionId; } From c4e8ed992bccc82fd1ebc9d988a4a3d84cb4b047 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 12 Aug 2026 00:58:44 -0400 Subject: [PATCH 59/73] fix(test): cancel queued frames before sweeping timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the net still had the exact hole it was written to close. The afterEach is synchronous, so a queued rAF callback cannot run until it returns — at which point it registers a fresh setTimeout AFTER the sweep has already drained. On a file's last test that timer survives environment teardown. That is the rAF race described in the previous commit message, left open by the fix for it. Track animation frames too, and cancel them BEFORE sweeping timers. Order is the fix: JS is single-threaded and nothing between the two loops yields, so no frame callback can run in between. Mutation-checked — without the cancel pass the new ordering test fails (1 failed | 6 passed); with it, 7 pass. Also from review: - renderWithMantine.tsx still documented the disproven env="test" behavior. It is the file contributors are told to use, so leaving it would have preserved the wrong belief exactly where it does most damage. That was the third copy of this claim in the repo; all three now corrected. - Dropped an unjustified `as unknown as` in the test helper, which broke the repo's own double-cast rule. Inference keeps the handle type clearTimeout accepts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- clients/web/src/test/leakedTimers.test.tsx | 31 ++++++++++++-- clients/web/src/test/renderWithMantine.tsx | 21 ++++++--- clients/web/src/test/setup.ts | 50 +++++++++++++++++++--- 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/clients/web/src/test/leakedTimers.test.tsx b/clients/web/src/test/leakedTimers.test.tsx index ea51b4be..209efa25 100644 --- a/clients/web/src/test/leakedTimers.test.tsx +++ b/clients/web/src/test/leakedTimers.test.tsx @@ -17,9 +17,10 @@ import { describe, it, expect, vi } from "vitest"; import { Modal } from "@mantine/core"; import { renderWithMantine } from "./renderWithMantine"; -/** Ids the net is tracking, read back through the wrapper it installed. */ -function scheduleTracked(ms: number): number { - return window.setTimeout(() => {}, ms) as unknown as number; +/** Schedule through the wrapper the net installed, keeping its inferred handle + * type so it stays directly acceptable to `window.clearTimeout`. */ +function scheduleTracked(ms: number) { + return window.setTimeout(() => {}, ms); } describe("leaked-timer safety net", () => { @@ -52,6 +53,27 @@ describe("leaked-timer safety net", () => { expect(() => window.clearTimeout(id)).not.toThrow(); }); + it("queues a frame that would schedule a timer after the sweep", () => { + // The rAF race in miniature, and the ordering the net depends on. This + // synchronous test cannot let the frame callback run — it fires only after + // the test (and the whole afterEach) returns. If frames were not cancelled + // *before* the timer sweep, this callback would register `rafScheduled` + // after the drain and survive teardown. The next test proves it does not. + requestAnimationFrame(() => { + rafRace.ranFrame = true; + window.setTimeout(() => { + rafRace.ranTimer = true; + }, 5); + }); + expect(rafRace.ranFrame).toBe(false); + }); + + it("neither the queued frame nor its timer ever ran", async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(rafRace.ranFrame).toBe(false); + expect(rafRace.ranTimer).toBe(false); + }); + it("a Modal's own transition timers do not survive the test that opened it", () => { // The real-world shape of #1984: ServerRemoveConfirmModal-style tests toggle // `opened`, and Mantine schedules real timers for the transition and the @@ -70,3 +92,6 @@ describe("leaked-timer safety net", () => { const leaked: { callback: ReturnType | null } = { callback: null, }; + +/** Records whether a queued frame — or the timer it would register — ever ran. */ +const rafRace = { ranFrame: false, ranTimer: false }; diff --git a/clients/web/src/test/renderWithMantine.tsx b/clients/web/src/test/renderWithMantine.tsx index 61c91077..ea291c6b 100644 --- a/clients/web/src/test/renderWithMantine.tsx +++ b/clients/web/src/test/renderWithMantine.tsx @@ -37,13 +37,20 @@ function makeWrapper(env: "test" | "default", colorScheme: MantineColorScheme) { }; } -// Default render helper. `env="test"` makes Mantine render transitions -// synchronously (no internal `setTimeout`). Without it, a `Transition`/`Modal` -// open/close timer can fire after happy-dom tears down `window` at the end of -// the run, throwing an uncaught `ReferenceError: window is not defined` that -// fails the whole run even when every assertion passed (#1760). This is the -// right default for the vast majority of tests, which don't assert on -// mid-transition state. +// Default render helper. `env="test"` makes Mantine skip the *animated render* +// of a transition, which is the right default for the vast majority of tests — +// they don't assert on mid-transition state. +// +// It does NOT stop the timers, contrary to what this comment used to claim +// (#1984). `env` is read only by `Transition.mjs`, at its render branch +// (`transitionDuration === 0 || env === "test"`), while `useTransition()` runs +// before that check — hooks cannot be conditional — and still schedules real +// `window.setTimeout`s on every `mounted` change. Measured: opening a `` +// through this helper schedules three 200ms timers. A timer that outlives its +// file fires after happy-dom disposes that file's `window` and throws an +// uncaught `ReferenceError: window is not defined`, failing the whole run from +// an arbitrary innocent file (#1760). What actually prevents that is the +// leaked-timer safety net in `setup.ts`. export function renderWithMantine( ui: ReactElement, options?: MantineRenderOptions, diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index c84a313f..a564fa5d 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -113,17 +113,23 @@ Object.defineProperty(window, "matchMedia", { // `setTimeout` after cleanup has already run. Nothing owns that timer. It needs // a loaded machine to hit, which is why it only ever appears in CI. // -// Rather than chase each component, track every timer and clear whatever is -// still outstanding once the test's own teardown has had its turn. Ordering is -// load-bearing: this runs *after* `cleanup()` below, so legitimate unmount -// cleanups clear their own timers and only true leaks are left. +// Rather than chase each component, track every timer *and every animation +// frame*, and clear whatever is still outstanding once the test's own teardown +// has had its turn. Ordering is load-bearing twice over: this runs *after* +// `cleanup()` below, so legitimate unmount cleanups clear their own timers and +// only true leaks are left; and frames are cancelled *before* timers are swept, +// because a queued frame callback would otherwise run after the sweep and +// register a fresh timer. See the ordering note on the `afterEach` itself. // // Under `vi.useFakeTimers()` the wrapper is swapped out for vitest's fake // implementation, so nothing is tracked while fake timers are installed — which // is correct, since a fake timer cannot outlive the environment. const pendingTimers = new Set(); +const pendingFrames = new Set(); const realSetTimeout = window.setTimeout.bind(window); const realClearTimeout = window.clearTimeout.bind(window); +const realRequestAnimationFrame = window.requestAnimationFrame.bind(window); +const realCancelAnimationFrame = window.cancelAnimationFrame.bind(window); window.setTimeout = (( handler: TimerHandler, @@ -156,11 +162,43 @@ window.clearTimeout = ((id?: number): void => { // Same DOM-vs-Node overload mismatch as `setTimeout` above. }) as unknown as typeof window.clearTimeout; +window.requestAnimationFrame = ((callback: FrameRequestCallback): number => { + const handle: number = realRequestAnimationFrame((time: number) => { + pendingFrames.delete(handle); + callback(time); + }); + pendingFrames.add(handle); + return handle; + // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. +}) as unknown as typeof window.requestAnimationFrame; + +window.cancelAnimationFrame = ((handle: number): void => { + pendingFrames.delete(handle); + realCancelAnimationFrame(handle); + // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. +}) as unknown as typeof window.cancelAnimationFrame; + afterEach(() => { cleanup(); window.localStorage.clear(); - // After cleanup(), anything still pending is a leak — drop it so it cannot - // fire past this file's environment teardown. + + // Order is the whole point, and it is not interchangeable. + // + // Cancel queued animation frames FIRST. This `afterEach` is synchronous, so a + // frame callback already queued cannot run until it returns — at which point + // it would register a fresh `setTimeout` *after* the sweep below had already + // drained, and on the file's last test that timer would survive teardown. + // That is precisely the rAF race this net exists for, so sweeping timers + // without cancelling frames first would leave the original hole open. + // + // Cancelling first closes it deterministically: JS is single-threaded and + // nothing here yields, so no frame callback can run between these two loops. + for (const handle of pendingFrames) { + realCancelAnimationFrame(handle); + } + pendingFrames.clear(); + + // Then drop the timers. After cleanup(), anything still pending is a leak. for (const id of pendingTimers) { realClearTimeout(id); } From b70aed691418a7e2e98c897cba6ab44ad422f648 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 12 Aug 2026 01:10:03 -0400 Subject: [PATCH 60/73] test(fix): probe a single-source route when waiting for the dead transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #1987: `waitForDeadTransport` polled `/api/mcp/send`, which answers `transport_error` from two branches — the `isTransportDead()` short-circuit and the `catch` around a rejected `send()`. Only the first implies the transport is marked dead, so the stop condition could not tell them apart from the response alone. That was safe in practice: `StdioClientTransport.send` rejects only when `_process` is undefined, and the transport clears `_process` and calls `onclose` in one synchronous block, so the `catch` branch is not observable before `markTransportDead()` has run (confirmed empirically — 0/25 probes returned early). But the safety rested on an SDK-internal ordering this test neither states nor controls, and if it changed the send test would silently cover the `catch` branch instead of the short-circuit it names — a regression with no failing run attached to it. Poll `/api/mcp/auth-state` instead. For a session connected without an authState there is no auth provider, so `setAuthState` always throws and `transport_error` there has exactly one source: the dead-transport short-circuit. The route never awaits a transport response, so it cannot hang either. Same cost, exact condition, no dependence on SDK internals. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- .../mcp/remote/remote-auth-branches.test.ts | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts index 27b5bc6d..355f5bb8 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts @@ -79,17 +79,31 @@ async function startUnauthorizedUpstream(): Promise<{ /** * Poll until the server reports the session's transport as dead (#1985). * - * The probe is a **notification** — a `method` with no `id` — which matters: - * `requestIdForSendWait` returns `undefined` for it, so `/api/mcp/send` never - * awaits a response and cannot hang. Probing with a *request* would reproduce - * the very failure this guards against, since a request sent before the - * transport is marked dead waits forever for a reply the dead child will never - * send. + * The probe is `/api/mcp/auth-state`, chosen because for a session connected + * *without* an authState it answers `kind: "transport_error"` from exactly one + * place — the `isTransportDead()` short-circuit. Every other outcome is a 400 + * from `setAuthState` ("Session has no OAuth auth provider"), so a + * `transport_error` here is proof that `onclose` has already run + * `markTransportDead()`, not merely that some write failed. * - * Either terminal branch of the route answers `kind: "transport_error"` — the - * `isTransportDead()` short-circuit, and the `catch` around a `send()` that - * threw on the dead pipe. Both are fine as a stop condition: each one proves the - * subsequent real send also cannot reach the hanging path. + * That single source is the point. `/api/mcp/send` looks like the obvious probe + * but answers `transport_error` from *two* branches: the dead-transport + * short-circuit, and the `catch` around a `send()` that rejected. Only the first + * implies the transport is marked dead, so a stop condition keyed on the + * response cannot tell them apart. It happens to be safe today — + * `StdioClientTransport.send` rejects only when `_process` is undefined, and the + * transport clears `_process` and calls `onclose` in one synchronous block, so + * the `catch` branch cannot be observed before `markTransportDead()` has run — + * but that is an SDK-internal ordering detail, not something this test states or + * controls. If it ever changed, an early return here would hand each call site a + * live session: the send test would quietly exercise the `catch` branch instead + * of the short-circuit it names, and the auth-state test would fall through to + * `setAuthState` and fail on a 400. Probing a single-source route costs nothing + * and does not depend on the ordering holding. + * + * The route also cannot hang: it never awaits a response from the transport, so + * unlike a *request* through `/api/mcp/send` there is nothing here to wait + * forever on a reply the dead child will never send. * * Deliberately not `/api/mcp/events`: opening that stream on a dead transport * calls `sessions.delete(sessionId)`, so the send under test would then answer @@ -103,12 +117,14 @@ async function waitForDeadTransport( const deadline = Date.now() + timeoutMs; let last = "(no response)"; while (Date.now() < deadline) { - const res = await fetch(`${h.baseUrl}/api/mcp/send`, { + const res = await fetch(`${h.baseUrl}/api/mcp/auth-state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, - message: { jsonrpc: "2.0", method: "notifications/initialized" }, + authState: { + oauthTokens: { access_token: "probe", token_type: "Bearer" }, + }, }), }); const body = (await res.json()) as { kind?: string }; From b451949c9e446d8ceeece527d2e8cf58d091dcb3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 12 Aug 2026 01:11:05 -0400 Subject: [PATCH 61/73] docs: the renderWithMantine rule no longer rests on timer safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a second-order effect of the fix: making the leaked-timer net global invalidated the rationale of a neighbouring rule. "Do not hand-roll a bare MantineProvider — that reintroduces the leak class" stopped being true the moment the net moved into setup.ts, which covers every unit test regardless of how it renders. The rule itself is still right, so keep it and replace the justification: a hand-rolled provider skips the project theme and the helper's options and drifts from every other test. Both copies say explicitly that the old reason no longer holds, so nobody re-derives it. Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md requires for review-relevant guidance — it carried the identical stale claim. That makes four places this one belief had propagated to. A fix can create stale docs by making a previously-true statement false, which is easy to miss when only the changed lines get read. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- .github/copilot-instructions.md | 2 +- AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0628741c..a0a2ddae 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,7 +92,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo ### Rendering components in tests -- **Always render through `renderWithMantine`** from `src/test/renderWithMantine.tsx`. Never hand-roll a bare `MantineProvider` — that reintroduces a real failure class where a `Transition`/`Modal` timer fires after happy-dom tears down `window` and fails the entire run. +- **Always render through `renderWithMantine`** from `src/test/renderWithMantine.tsx`. A hand-rolled bare `MantineProvider` skips the project theme and the helper's options, and drifts from every other test. (It does *not* reintroduce the timer-leak class — an older version of this rule said so; the leaked-timer net in `setup.ts` is global and covers every unit test regardless of how it renders.) - For a forced color scheme, pass the option — `renderWithMantine(ui, { colorScheme: "dark" })` — rather than a hand-rolled `defaultColorScheme` provider. - Only when asserting _mid-flight_ transition state, use `renderWithMantineTransitions`, passing `settleMs` derived from the component's real animation duration. Do **not** combine it with `vi.useFakeTimers()`, and use the `unmount()` it returns if the test unmounts the tree itself. diff --git a/AGENTS.md b/AGENTS.md index 8584c231..3fd9988b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -635,7 +635,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **Side-by-side (`.test.tsx` next to the source) — the default for web's own `src/` code.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead of beside its source is a bug (fixed one such straggler, `downloadFile.test.ts`, in #1776). - **`src/test/` — the three things that _cannot_ be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement _is_ the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests _of_ a source file, so nothing to sit beside. - **The above is web only.** The Node clients (**cli, tui, launcher**) keep **all** their tests in a top-level **`__tests__/`** dir, not beside their source — their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes `__tests__/**/*` (plus, for launcher, its root `vitest.config.ts`), so a co-located `src/**/*.test.*` lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage` (#1791). Put a new cli/tui/launcher test under `__tests__/`. -- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"`, which makes Mantine skip the transition's animated **render** — but be clear on what that does *not* buy you: it does **not** stop the timers. `env` is read only by `Transition.mjs`, at its render branch (`transitionDuration === 0 || env === "test"`), while `useTransition()` runs before that check (hooks cannot be conditional) and still schedules real `window.setTimeout`s on every `mounted` change. Measured: opening a `` through `renderWithMantine` schedules three 200ms timers (#1984). A timer that outlives its file fires after happy-dom disposes that file's `window`, and React's `dispatchSetState` then throws an uncaught `ReferenceError: window is not defined` that fails the **whole run** — attributed to whichever file was running, so it lands on an innocent one (#1760). What actually prevents that is the **leaked-timer safety net in `src/test/setup.ts`**, which tracks every timer and clears whatever is still pending after `cleanup()`; see the comment there for the rAF race that makes Mantine's own unmount cleanup insufficient. **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. +- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"`, which makes Mantine skip the transition's animated **render** — but be clear on what that does *not* buy you: it does **not** stop the timers. `env` is read only by `Transition.mjs`, at its render branch (`transitionDuration === 0 || env === "test"`), while `useTransition()` runs before that check (hooks cannot be conditional) and still schedules real `window.setTimeout`s on every `mounted` change. Measured: opening a `` through `renderWithMantine` schedules three 200ms timers (#1984). A timer that outlives its file fires after happy-dom disposes that file's `window`, and React's `dispatchSetState` then throws an uncaught `ReferenceError: window is not defined` that fails the **whole run** — attributed to whichever file was running, so it lands on an innocent one (#1760). What actually prevents that is the **leaked-timer safety net in `src/test/setup.ts`**, which tracks every timer and clears whatever is still pending after `cleanup()`; see the comment there for the rAF race that makes Mantine's own unmount cleanup insufficient. **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** — a hand-rolled provider skips the project theme and the helper's options and drifts from every other test. Note this rule used to be justified by the leak class, which is now wrong: the `setup.ts` net is global and covers every unit test however it renders. The rule stands on consistency, not on timer safety. To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. ### Responding to Code Reviews From c0e4b89c8de0d2322ea09b434942c59e01ec6c75 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 12 Aug 2026 01:23:21 -0400 Subject: [PATCH 62/73] fix(test): treat timer handles as opaque; the numeric guard never matched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review was right, and I had assumed the handle type rather than checking it. Probed both under happy-dom: setTimeout -> typeof "object", constructor Timeout requestAnimationFrame -> typeof "object", constructor Immediate So `typeof id === "number"` in the clearTimeout wrapper never matched, and an explicitly-cleared timer was never untracked. Both `Set` annotations and `const id: number` were fictions TypeScript could not catch, since the DOM lib DECLARES number while happy-dom RETURNS an object. The effect was benign — the teardown sweep re-clears them and clearTimeout is idempotent — but the sharper problem is that the test asserting this passed for the wrong reason. "clearTimeout doesn't throw" holds whether or not tracking works, so it proved nothing and hid the broken guard. Hold handles as `unknown`, route cancellation through two small helpers that carry the one justified cast each, and drop the numeric guards. The test now asserts the net's own bookkeeping through an exported pendingTimerCount(), and is mutation-checked: restoring the numeric guard fails it (1 failed | 6 passed). That is the second test in this PR that passed without proving its claim. Both had the same tell — the assertion would have held with the feature removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- clients/web/src/test/leakedTimers.test.tsx | 15 +++-- clients/web/src/test/setup.ts | 74 ++++++++++++++++------ 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/clients/web/src/test/leakedTimers.test.tsx b/clients/web/src/test/leakedTimers.test.tsx index 209efa25..65445338 100644 --- a/clients/web/src/test/leakedTimers.test.tsx +++ b/clients/web/src/test/leakedTimers.test.tsx @@ -16,6 +16,7 @@ import { describe, it, expect, vi } from "vitest"; import { Modal } from "@mantine/core"; import { renderWithMantine } from "./renderWithMantine"; +import { pendingTimerCount } from "./setup"; /** Schedule through the wrapper the net installed, keeping its inferred handle * type so it stays directly acceptable to `window.clearTimeout`. */ @@ -46,11 +47,17 @@ describe("leaked-timer safety net", () => { expect(leaked.callback).not.toHaveBeenCalled(); }); - it("clearTimeout stops tracking, so an explicitly-cleared timer is not double-cleared", () => { + it("clearTimeout untracks the timer", () => { + // Asserted against the net's own bookkeeping, not against "clearTimeout + // doesn't throw" — the latter holds whether or not tracking works, since + // clearTimeout is idempotent, so it proved nothing. That vacuity hid a real + // bug: happy-dom's handle is a `Timeout` object, so the original + // `typeof id === "number"` guard never matched and nothing was untracked. + const before = pendingTimerCount(); const id = scheduleTracked(1000); - expect(() => window.clearTimeout(id)).not.toThrow(); - // Clearing twice must stay a no-op — the net also clears at teardown. - expect(() => window.clearTimeout(id)).not.toThrow(); + expect(pendingTimerCount()).toBe(before + 1); + window.clearTimeout(id); + expect(pendingTimerCount()).toBe(before); }); it("queues a frame that would schedule a timer after the sweep", () => { diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index a564fa5d..c86b3634 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -124,19 +124,48 @@ Object.defineProperty(window, "matchMedia", { // Under `vi.useFakeTimers()` the wrapper is swapped out for vitest's fake // implementation, so nothing is tracked while fake timers are installed — which // is correct, since a fake timer cannot outlive the environment. -const pendingTimers = new Set(); -const pendingFrames = new Set(); +// Handles are held as `unknown`, deliberately. The DOM lib declares these as +// `number`, but happy-dom returns objects at runtime — verified here: +// `setTimeout` yields a `Timeout` and `requestAnimationFrame` an `Immediate`. +// An earlier revision typed the sets `Set` and guarded removal with +// `typeof id === "number"`; that guard never matched, so explicitly-cleared +// timers were never untracked. Harmless in effect (the teardown sweep clears +// them again and `clearTimeout` is idempotent) but the bookkeeping was a +// fiction, and the test asserting it passed vacuously. Treat the handle as +// opaque and pass it straight back to the matching canceller. +const pendingTimers = new Set(); +const pendingFrames = new Set(); const realSetTimeout = window.setTimeout.bind(window); const realClearTimeout = window.clearTimeout.bind(window); const realRequestAnimationFrame = window.requestAnimationFrame.bind(window); const realCancelAnimationFrame = window.cancelAnimationFrame.bind(window); +/** Cancel an opaque handle through the real canceller it came from. */ +function cancelTimer(id: unknown): void { + // The handle came from `realSetTimeout`, so it is exactly what + // `realClearTimeout` expects; only the *declared* type disagrees. + realClearTimeout(id as Parameters[0]); +} + +function cancelFrame(handle: unknown): void { + // As above, for the rAF pair. + realCancelAnimationFrame( + handle as Parameters[0], + ); +} + +/** Test-only introspection, so the regression tests can assert on the + * bookkeeping itself rather than on behavior that would hold anyway. */ +export function pendingTimerCount(): number { + return pendingTimers.size; +} + window.setTimeout = (( handler: TimerHandler, timeout?: number, ...args: unknown[] -): number => { - const id: number = realSetTimeout( +): unknown => { + const id: unknown = realSetTimeout( (...cbArgs: unknown[]) => { pendingTimers.delete(id); if (typeof handler === "function") { @@ -148,34 +177,37 @@ window.setTimeout = (( ); pendingTimers.add(id); return id; - // happy-dom types `setTimeout` with Node's overloads (returning `Timeout`), - // while the DOM lib types it as `number`. The implementation above honors the - // DOM shape the app codes against; this cast bridges the two declarations, - // which TS cannot relate structurally. + // The wrapper's public signature must match the DOM declaration the app codes + // against, while its body traffics in the runtime handle described above. TS + // cannot relate the two, hence the cast. }) as unknown as typeof window.setTimeout; -window.clearTimeout = ((id?: number): void => { - if (typeof id === "number") { +window.clearTimeout = ((id?: unknown): void => { + // No `typeof` guard: the handle is an object here, so a numeric test would + // reject every real one. Anything defined is worth untracking. + if (id !== undefined && id !== null) { pendingTimers.delete(id); } - realClearTimeout(id); - // Same DOM-vs-Node overload mismatch as `setTimeout` above. + cancelTimer(id); + // Same declaration-vs-runtime mismatch as `setTimeout` above. }) as unknown as typeof window.clearTimeout; -window.requestAnimationFrame = ((callback: FrameRequestCallback): number => { - const handle: number = realRequestAnimationFrame((time: number) => { +window.requestAnimationFrame = ((callback: FrameRequestCallback): unknown => { + const handle: unknown = realRequestAnimationFrame((time: number) => { pendingFrames.delete(handle); callback(time); }); pendingFrames.add(handle); return handle; - // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. + // Same declaration-vs-runtime mismatch as `setTimeout` above. }) as unknown as typeof window.requestAnimationFrame; -window.cancelAnimationFrame = ((handle: number): void => { - pendingFrames.delete(handle); - realCancelAnimationFrame(handle); - // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. +window.cancelAnimationFrame = ((handle?: unknown): void => { + if (handle !== undefined && handle !== null) { + pendingFrames.delete(handle); + } + cancelFrame(handle); + // Same declaration-vs-runtime mismatch as `setTimeout` above. }) as unknown as typeof window.cancelAnimationFrame; afterEach(() => { @@ -194,13 +226,13 @@ afterEach(() => { // Cancelling first closes it deterministically: JS is single-threaded and // nothing here yields, so no frame callback can run between these two loops. for (const handle of pendingFrames) { - realCancelAnimationFrame(handle); + cancelFrame(handle); } pendingFrames.clear(); // Then drop the timers. After cleanup(), anything still pending is a leak. for (const id of pendingTimers) { - realClearTimeout(id); + cancelTimer(id); } pendingTimers.clear(); }); From 3e76498366ebe3dd46867084cbc2e38d28cab6d3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 12 Aug 2026 08:34:08 -0400 Subject: [PATCH 63/73] chore: upgrade the MCP TypeScript SDK from 2.0.0-beta.5 to 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK shipped 2.0.0 final on 2026-07-27. Shipping the Inspector against a prerelease of its core protocol library is a supply-chain and reproducibility liability we no longer have a reason to carry: a beta is eligible for unpublish and deprecation in a way a stable release is not. Bumped in the root package.json only — client, core, server, server-legacy. Node resolution walks up, so the root install already serves every client; a per-client declaration would install a second copy that drifts from it (#1970). Verified after install: one copy of each at 2.0.0, no clients/*/node_modules/ @modelcontextprotocol/* at all. ext-apps is a separate package on its own ^1.7.4 line and is untouched. The beta.5 → 2.0.0 delta is seven commits. Reviewed each for call-site impact: - core/src/schemas.ts (+16/-16) is doc-comment only — every hunk rewrites a spec URL from a commit-pinned GitHub blob to the published 2026-07-28 page. No schema surface change, so nothing in core/mcp or core/json is affected. - client 2.0.0 wants zod ^4.2.0; we are on ^4.4.3, so no zod move and no risk of the #1896 dual-copy tsc heap blowup. verify:dep-lockstep stays green and the lockfile delta is exactly the four packages with no transitive churn. - fix(validators) honors declared draft-07/06 JSON Schema dialects instead of rejecting them, which can only widen the set of tool schemas we accept. - The SSE keep-alive and legacyWrap changes are server-side, exercised by the test servers rather than by client code. The one change with real behavioral reach is #2564: the negotiation probe now classifies HTTP 401/403 as an auth failure instead of legacy-era evidence. That is the upstream fix #1807 is blocked on, and it is left for that issue — the directAuthRecovery clause in inspectorClient.ts still intercepts the challenge before the classifier sees it, so this bump does not change connect behavior. #1807 can now delete the workaround and lean on the SDK verdict, but doing it here would smuggle an auth change into a dependency bump. Also corrected the two auth as-built specs, which still claimed beta.4 — already stale before this change. v2_new_spec_impact.md is deliberately left alone: it is a dated pre-upgrade analysis, and editing its premises would rewrite the record rather than update it. npm run ci passes end to end: validate, the per-file >=90 coverage gate, verify:build-gate, all six smokes (including web:app driving connect -> open app -> data-app-status="ready"), and 472 Storybook tests. Closes #1988 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- package-lock.json | 38 +++++++++++----------- package.json | 8 ++--- specification/v2_auth_hardening.md | 2 +- specification/v2_auth_sdk_consolidation.md | 2 +- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index d574fdd9..5b738bc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,11 @@ "license": "MIT", "dependencies": { "@hono/node-server": "^2.0.12", - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", @@ -299,12 +299,12 @@ } }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", - "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", @@ -317,9 +317,9 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", - "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", "license": "MIT", "dependencies": { "zod": "^4.2.0" @@ -399,12 +399,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", - "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" }, "engines": { @@ -412,13 +412,13 @@ } }, "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", - "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0.tgz", + "integrity": "sha512-LnffC1BSqFMHtMQxEz92lqDpHWma+ErV3ghdHDgdkCyYzVcCYKcUT5loq4kflty+Bf9C9qjJqbnphyBWyCqo8Q==", "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0", "content-type": "^1.0.5", "cors": "^2.8.5", "express-rate-limit": "^8.2.1", diff --git a/package.json b/package.json index c52f04c1..b255bb28 100644 --- a/package.json +++ b/package.json @@ -78,11 +78,11 @@ }, "dependencies": { "@hono/node-server": "^2.0.12", - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", diff --git a/specification/v2_auth_hardening.md b/specification/v2_auth_hardening.md index 1595bc9e..78eb26b1 100644 --- a/specification/v2_auth_hardening.md +++ b/specification/v2_auth_hardening.md @@ -6,7 +6,7 @@ As-built status for aligning Inspector with the **authorization hardening** SEPs in the MCP **`2026-07-28`** release — tracked by [#1527](https://github.com/modelcontextprotocol/inspector/issues/1527). -Inspector is on `@modelcontextprotocol/client` **2.0.0-beta.4**. Connect-time standard OAuth is delegated to SDK `auth()`; Inspector owns storage, callbacks, remoting, EMA host flow, and mid-session UX. See [SDK consolidation](v2_auth_sdk_consolidation.md). +Inspector is on `@modelcontextprotocol/client` **2.0.0**. Connect-time standard OAuth is delegated to SDK `auth()`; Inspector owns storage, callbacks, remoting, EMA host flow, and mid-session UX. See [SDK consolidation](v2_auth_sdk_consolidation.md). **Policy:** SEP behavior that can be automated is covered (or should be covered) by unit/integration tests. Hosted-IdP smoke in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) is complementary for real providers — it is **not** required for every SEP once CI covers the requirement. diff --git a/specification/v2_auth_sdk_consolidation.md b/specification/v2_auth_sdk_consolidation.md index f3711bd7..9c044911 100644 --- a/specification/v2_auth_sdk_consolidation.md +++ b/specification/v2_auth_sdk_consolidation.md @@ -4,7 +4,7 @@ #### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) | SDK consolidation -Record of how Inspector uses `@modelcontextprotocol/client` **2.0.0-beta.4** for authorization after the v2 SDK upgrade: what we moved onto the SDK, what we left Inspector-owned and why, and which small SDK API gaps would let us delete more local wire later. +Record of how Inspector uses `@modelcontextprotocol/client` **2.0.0** for authorization after the v2 SDK upgrade: what we moved onto the SDK, what we left Inspector-owned and why, and which small SDK API gaps would let us delete more local wire later. Related as-built specs: [Hardening](v2_auth_hardening.md), [EMA](v2_auth_ema.md), [Mid-session](v2_auth_mid_session.md). From 55878297d9bde86ed4de253350db6a0dc776f18b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 12 Aug 2026 09:46:45 -0400 Subject: [PATCH 64/73] docs: correct the EMA spec's stale SDK-dependency claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that v2_auth_ema.md contradicted itself. Its "TypeScript SDK (implemented)" section claimed Inspector depends on `@modelcontextprotocol/sdk` v1.x only (`^1.29.0`) and that there is "no `@modelcontextprotocol/client` v2 dependency in the tree today" — while the subsection immediately below it documents which v2 client helpers the EMA legs have adopted. Checked rather than assumed: every module the section's table names imports the v2 client (emaFlow, idpOidc, resourceContext, tokenEndpoint, transportProvider, wire, and providers.ts), and there are zero v1 SDK imports in first-party code. The v1 SDK is present in the tree only as a peer pulled in by ext-apps, which AGENTS.md already states must never become a direct dependency. Rewrote the paragraph to say what is true and name the version, so it stays consistent with the two auth specs corrected in the previous commit. The per-row "v1 SDK" attributions in the table below it are wrong for the same reason, but correcting each one means auditing which SDK symbol each leg calls today — a real audit, not a version-string refresh, and out of scope for a dependency bump. Left for a follow-up rather than guessed at here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- specification/v2_auth_ema.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index 6f314d1f..a7a4c917 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -46,7 +46,7 @@ _Audited June 2026 against the [EMA extension spec](https://modelcontextprotocol ### TypeScript SDK (implemented) -Inspector depends on **`@modelcontextprotocol/sdk` v1.x** only (`^1.29.0` in root `package.json`). Standard OAuth and EMA both build on that package — there is **no** `@modelcontextprotocol/client` v2 dependency in the tree today. +Inspector depends on the **v2 SDK packages** — `@modelcontextprotocol/client` / `core` / `server` / `server-legacy` at **2.0.0**, declared in the root `package.json` only. Standard OAuth and EMA both build on `@modelcontextprotocol/client`; every module in the table below imports it. The v1 `@modelcontextprotocol/sdk` is **not** a dependency of this repo and must not become one — it appears in the lock files solely as a `peer` pulled in by `ext-apps`. | Concern | Package / module | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | From cf834633ba99a569ae766083bde166646fc13ac6 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 12 Aug 2026 08:56:24 -0700 Subject: [PATCH 65/73] fix: add eol=lf policy to .gitattributes so Windows checkouts stay clean Co-Authored-By: Claude Fable 5 --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index cf968721..056e3cb9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ +* text=auto eol=lf package-lock.json linguist-generated=true From adde9ccf948a3d23fff93270be6c53188f4d5d0b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 12:13:06 -0400 Subject: [PATCH 66/73] fix: bind loopback by address and pin the sandbox port so containers work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defaults that each break the Inspector inside a container. #1951 — the web UI never loads in a VS Code dev container. `HOST` defaulted to the NAME `localhost`, and `server.listen(port, host)` resolves a name through `dns.lookup` and binds the SINGLE address it gets back. Since Node 17 the default result order is `verbatim`, so on a glibc Linux host — whose /etc/hosts maps localhost to both 127.0.0.1 and ::1 — that is `::1`. The server bound IPv6 loopback only and refused every IPv4 client, including the dev container port forwarder. It presents as an endless spinner rather than a connection error because the forwarder accepts on the host side before failing to connect inward. Default to the ADDRESS 127.0.0.1 instead; an explicit HOST=localhost is still honored as typed. #2008 — the Apps tab is unreachable in any container. The MCP Apps sandbox listened on an OS-assigned port, so it changed every run and could never be named in a devcontainer `forwardPorts`, a `docker run -p`, or a tunnel. Default it to a fixed 6275, mirroring CLIENT_PORT's fixed 6274, and retry once on an OS-assigned port (loudly) if it is taken — the collision a dynamic default never had. Verified in the reporter's exact image (mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie): both listeners come up on 127.0.0.1 and answer 200 on 6274 and 6275/sandbox. Also EXPOSE 6275 and set MCP_SANDBOX_PORT in the image, and document both defaults in the web README, the migration guide, and the root README's Docker section. Signed-off-by: cliffhall --- AGENTS.md | 5 +- Dockerfile | 6 +- README.md | 9 ++ clients/web/README.md | 6 +- clients/web/server/resolve-bind-host.ts | 43 ++++-- clients/web/server/sandbox-controller.ts | 61 ++++++++- .../server/resolve-bind-host.test.ts | 16 ++- .../server/sandbox-controller.test.ts | 127 +++++++++++++++--- .../server/web-server-config.test.ts | 21 +-- docs/v1-to-v2-migration.md | 8 +- 10 files changed, 248 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 21061009..4123ccad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,10 @@ v2/main/ │ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server), │ │ │ # inject-auth-token.ts (embeds the API token into served index.html), │ │ │ # vite-base-config.ts (shared optimizeDeps exclusions), -│ │ │ # resolve-bind-host.ts (bind-host POLICY: refuses an +│ │ │ # resolve-bind-host.ts (bind-host POLICY: defaults to +│ │ │ # 127.0.0.1 — an ADDRESS, never the name `localhost`, which +│ │ │ # listen() resolves to a SINGLE family (::1 on glibc Linux), +│ │ │ # refusing every IPv4 client — #1951; and refuses an │ │ │ # all-interfaces HOST unless DANGEROUSLY_BIND_ALL_INTERFACES; │ │ │ # the all-interfaces DETECTION is core/node/hostUrl.isAllInterfacesHost. │ │ │ # Used by both bind points — web-server-config.ts + vite.config.ts — #1795), diff --git a/Dockerfile b/Dockerfile index f87a32ce..24455398 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,8 +31,12 @@ RUN npm install -g /tmp/inspector.tgz && rm /tmp/inspector.tgz ENV HOST=0.0.0.0 \ DANGEROUSLY_BIND_ALL_INTERFACES=true \ CLIENT_PORT=6274 \ + MCP_SANDBOX_PORT=6275 \ MCP_AUTO_OPEN_ENABLED=false -EXPOSE 6274 +# 6275 is the MCP Apps sandbox, a second listener the browser reaches directly. +# It is only needed for the Apps tab, so it is EXPOSEd but publishing it is +# optional — see the Docker section of the root README. +EXPOSE 6274 6275 # Run as the non-root `node` user the base image ships. The inspector resolves # its runtime-state dir (default catalog, OAuth token storage) from `HOME` diff --git a/README.md b/README.md index 55459ad0..35b1ce6d 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,15 @@ docker build -t mcp-inspector . docker run --rm -p 127.0.0.1:6274:6274 mcp-inspector ``` +**Using the Apps tab? Publish `6275` too.** The MCP Apps sandbox is a second listener the browser reaches directly, on `MCP_SANDBOX_PORT` (default `6275`). Nothing else needs it, so the single-port commands above are fine for ordinary inspection — but the Apps tab renders a blank widget without it: + +```bash +docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \ + ghcr.io/modelcontextprotocol/inspector +``` + +Publish it on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead. + **Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one. **Keeping the servers you add.** The Inspector saves your server list to `$HOME/.mcp-inspector/mcp.json`, which in the image is `/home/node/.mcp-inspector/mcp.json` — inside the container's writable layer, so `--rm` discards it and every run starts with an empty list. Mount a volume there to keep it: diff --git a/clients/web/README.md b/clients/web/README.md index a3facd92..01873854 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -162,7 +162,9 @@ The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer ## Host binding & the origin allow-list -Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. +Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds **`127.0.0.1`** by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. + +**The default is the IPv4 loopback _address_, deliberately not the name `localhost`** (#1951). `server.listen(port, host)` resolves a name through `dns.lookup` and binds the **single** address it gets back; since Node 17 the default result order is `verbatim`, so on a glibc Linux host — whose `/etc/hosts` maps `localhost` to both `127.0.0.1` and `::1` — that first address is `::1`. `HOST=localhost` there binds IPv6 loopback **only** and refuses every IPv4 client. On a desktop this is invisible (the browser resolves `localhost` the same way and lands on `::1` too); it breaks wherever the connection is made by something that pins `127.0.0.1` — a VS Code dev container's port forwarder, `ssh -L 6274:127.0.0.1:6274`, a container healthcheck. Binding the address takes the resolver out of the decision. Browsing to `http://localhost:PORT` still works, since browsers fall back across address families and the loopback allow-list covers all three forms. An explicit `HOST=localhost` is still honored as typed — the single-family bind is then your choice. The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). `ALLOWED_ORIGINS` **replaces** the default list (it does not merge), so **list every origin you'll browse from, including the loopback forms** you still want (`http://localhost:PORT`, `http://127.0.0.1:PORT`, `http://[::1]:PORT`) — otherwise local access stops working. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off. @@ -176,7 +178,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`. -**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today. +**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today. In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard. diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index a6d6fda1..f18945ce 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -26,21 +26,46 @@ function isEnabled(value: string | undefined): boolean { return v === "true" || v === "1"; } +/** + * The default bind host: the IPv4 loopback **address**, deliberately not the + * name `localhost`. + * + * `server.listen(port, host)` resolves a name through `dns.lookup` and binds the + * **single** address it gets back. Since Node 17 the default result order is + * `verbatim`, so Node takes whatever the resolver returns first rather than + * preferring IPv4 — and on a glibc Linux host with the stock `/etc/hosts` + * (`127.0.0.1 localhost` *and* `::1 localhost`) that is `::1`. `HOST=localhost` + * therefore binds IPv6 loopback **only**, and every IPv4 client is refused. + * + * That is invisible on a desktop, where the browser resolves `localhost` the + * same way and lands on `::1` too. It breaks wherever the connection is made by + * something that pins IPv4 `127.0.0.1` instead — a VS Code dev container's port + * forwarder (#1951), an `ssh -L …:127.0.0.1:…` tunnel, a container healthcheck. + * Binding the address directly removes the resolver from the decision, and the + * loopback origin allow-list already covers `localhost`, `127.0.0.1`, and + * `[::1]` alike, so browsing to `http://localhost:PORT` still works (browsers + * fall back across address families; a Node `listen` does not). + */ +export const DEFAULT_BIND_HOST = "127.0.0.1"; + /** * Resolve the bind hostname from `env` (default `process.env`), defaulting to - * `localhost`. Refuses an all-interfaces host (`0.0.0.0` / `::` / empty / their - * legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is explicitly - * enabled — the published Docker image sets it, since a container must bind - * `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) rather than - * silently binding wide open. The returned value is trimmed and de-bracketed - * (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so detection, - * `listen()`, and the origin list all consume the same value; `formatHostForUrl` - * re-adds the brackets wherever a URL is built. + * {@link DEFAULT_BIND_HOST}. Refuses an all-interfaces host (`0.0.0.0` / `::` / + * empty / their legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is + * explicitly enabled — the published Docker image sets it, since a container + * must bind `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) + * rather than silently binding wide open. The returned value is trimmed and + * de-bracketed (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so + * detection, `listen()`, and the origin list all consume the same value; + * `formatHostForUrl` re-adds the brackets wherever a URL is built. + * + * An explicit `HOST=localhost` is honored as typed — the single-family bind + * described on {@link DEFAULT_BIND_HOST} is then the caller's choice, not ours. */ export function resolveBindHostname( env: NodeJS.ProcessEnv = process.env, ): string { - const host = (env.HOST ?? "localhost").trim(); + const host = (env.HOST ?? DEFAULT_BIND_HOST).trim(); if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) { // Show the resolved address when it differs from the typed spelling — the // guard now catches forms the resolver folds to the wildcard (a fullwidth diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index e4b0d53e..50d4c440 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -11,13 +11,14 @@ import { canonicalUrlHost, isAllInterfacesHost, } from "../../../core/node/hostUrl.ts"; +import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); export interface SandboxControllerOptions { /** Port to bind (0 = dynamic). */ port: number; - /** Host to bind (default localhost). */ + /** Host to bind (default {@link DEFAULT_BIND_HOST}). */ host?: string; /** * The backend's origin allow-list (the embedder origins). Used to build the @@ -95,10 +96,28 @@ function parseListenPort(raw: string | undefined): number | undefined { } /** - * Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic). - * An invalid value falls through rather than crashing the boot; a set-but-invalid - * MCP_SANDBOX_PORT (the dedicated knob) is warned so the fall-through isn't - * silent — matching the warn-and-drop precedent for ALLOWED_ORIGINS. + * The default sandbox listen port — **fixed**, deliberately not `0`. + * + * A `0` default lets the OS assign, which is fine on a desktop (the browser + * learns the port from `/api/config`) but unusable anywhere the port has to be + * declared *before* the process starts: a dev container's `forwardPorts`, a + * `docker run -p`, a k8s service, an SSH tunnel. The port changed on every run, + * so it could never be named in that config and the Apps tab was unreachable + * (#2008). `CLIENT_PORT` has always defaulted to a fixed `6274` for the same + * reason; this is its sandbox counterpart. + * + * `6275` sits next to the web port so the pair is easy to forward together. It + * is not a v1 carry-over — v2 has no proxy server on it. + */ +export const DEFAULT_SANDBOX_PORT = 6275; + +/** + * Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → + * {@link DEFAULT_SANDBOX_PORT}. An invalid value falls through rather than + * crashing the boot; a set-but-invalid MCP_SANDBOX_PORT (the dedicated knob) is + * warned so the fall-through isn't silent — matching the warn-and-drop + * precedent for ALLOWED_ORIGINS. An explicit `0` is still honored as "let the + * OS assign" ({@link parseListenPort} accepts it). */ export function resolveSandboxPort(): number { const fromSandbox = parseListenPort(process.env.MCP_SANDBOX_PORT); @@ -107,13 +126,21 @@ export function resolveSandboxPort(): number { `Ignoring invalid MCP_SANDBOX_PORT="${process.env.MCP_SANDBOX_PORT}" (need an integer 0–65535); falling back.`, ); } - return fromSandbox ?? parseListenPort(process.env.SERVER_PORT) ?? 0; + return ( + fromSandbox ?? + parseListenPort(process.env.SERVER_PORT) ?? + DEFAULT_SANDBOX_PORT + ); } export function createSandboxController( options: SandboxControllerOptions, ): SandboxController { - const { port, host = "localhost", allowedOrigins } = options; + // Defaulted to the same address the web server binds, never the name + // `localhost` — a name resolves to one address family and would reintroduce + // the #1951 split (web on IPv4, sandbox on IPv6) for any future call site + // that omits `host`. Both call sites pass `config.sandboxHost` today. + const { port, host = DEFAULT_BIND_HOST, allowedOrigins } = options; let server: Server | null = null; let sandboxUrl: string | null = null; @@ -177,8 +204,28 @@ export function createSandboxController( }); res.end(sandboxHtml); }); + // A taken port used to mean "no sandbox at all", which was tolerable + // only because the default was dynamic and therefore never collided. + // Now that it's fixed (see DEFAULT_SANDBOX_PORT), a second Inspector — + // or anything else already on 6275 — would take the Apps tab down, so + // retry once on an OS-assigned port. The retry is announced loudly: + // whoever pinned MCP_SANDBOX_PORT to forward it needs to know the pin + // didn't take, and a silently-moved port is exactly the failure this + // default was introduced to fix. + let retriedDynamic = false; server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { + if (!retriedDynamic && port !== 0) { + retriedDynamic = true; + console.warn( + `Sandbox: port ${port} in use; falling back to an OS-assigned port. ` + + `The MCP Apps tab will work locally, but the sandbox is no longer ` + + `on a predictable port — set MCP_SANDBOX_PORT to a free one if you ` + + `need to forward it (container / SSH tunnel / reverse proxy).`, + ); + server!.listen(0, host); + return; + } console.error( `Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`, ); diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index c6de0ada..691230a1 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -1,12 +1,24 @@ import { describe, it, expect } from "vitest"; import { BIND_ALL_INTERFACES_ENV, + DEFAULT_BIND_HOST, resolveBindHostname, } from "../../../../server/resolve-bind-host.js"; describe("resolveBindHostname", () => { - it("defaults to localhost when HOST is unset", () => { - expect(resolveBindHostname({})).toBe("localhost"); + // Regression guard for #1951. The default must be an *address*, never the + // name `localhost`: `listen()` resolves a name and binds the single address + // the resolver returns first, which on glibc Linux is `::1` — leaving every + // IPv4 client (a dev container's port forwarder, an `ssh -L` to 127.0.0.1, a + // container healthcheck) refused. See DEFAULT_BIND_HOST. + it("defaults to the IPv4 loopback address, not the name localhost", () => { + expect(resolveBindHostname({})).toBe("127.0.0.1"); + expect(resolveBindHostname({})).toBe(DEFAULT_BIND_HOST); + expect(resolveBindHostname({})).not.toBe("localhost"); + }); + + it("still honors an explicit HOST=localhost as typed", () => { + expect(resolveBindHostname({ HOST: "localhost" })).toBe("localhost"); }); it("returns a loopback HOST unchanged", () => { diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index 5db8d1b3..a1f73f7e 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createServer, type Server } from "node:http"; import { createSandboxController, + DEFAULT_SANDBOX_PORT, resolveSandboxPort, sandboxFrameAncestors, } from "../../../../server/sandbox-controller.js"; @@ -75,7 +76,17 @@ describe("resolveSandboxPort", () => { else process.env.SERVER_PORT = envSnapshot.server; }); - it("returns 0 when no env vars are set", () => { + // Regression guard for #2008: the default must be a FIXED port. An + // OS-assigned one changes every run, so it can never be named in a dev + // container's `forwardPorts`, a `docker run -p`, or an SSH tunnel — leaving + // the Apps tab unreachable in every such environment. + it("returns a fixed default port when no env vars are set", () => { + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); + expect(resolveSandboxPort()).not.toBe(0); + }); + + it("still honors an explicit MCP_SANDBOX_PORT=0 as OS-assigned", () => { + process.env.MCP_SANDBOX_PORT = "0"; expect(resolveSandboxPort()).toBe(0); }); @@ -102,19 +113,19 @@ describe("resolveSandboxPort", () => { expect(resolveSandboxPort()).toBe(9100); }); - it("returns 0 when SERVER_PORT is non-numeric and no MCP_SANDBOX_PORT", () => { + it("falls back to the default when SERVER_PORT is non-numeric and no MCP_SANDBOX_PORT", () => { process.env.SERVER_PORT = "not-a-port"; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); it("ignores empty-string SERVER_PORT", () => { process.env.SERVER_PORT = ""; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); it("ignores negative values", () => { process.env.MCP_SANDBOX_PORT = "-1"; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); it("rejects a partial-parse value (6274abc) rather than binding 6274", () => { @@ -125,7 +136,7 @@ describe("resolveSandboxPort", () => { it("rejects an out-of-range value (70000) so it can't crash listen", () => { process.env.MCP_SANDBOX_PORT = "70000"; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); }); @@ -135,7 +146,8 @@ describe("createSandboxController", () => { try { const { url, port } = await controller.start(); expect(port).toBeGreaterThan(0); - expect(url).toBe(`http://localhost:${port}/sandbox`); + // Default host is now the IPv4 loopback address, not the name (#1951). + expect(url).toBe(`http://127.0.0.1:${port}/sandbox`); expect(controller.getUrl()).toBe(url); const res = await fetch(url); @@ -312,11 +324,11 @@ describe("createSandboxController", () => { } }); - it("resolves with empty values when listen fails (EADDRINUSE)", async () => { - // Bind a placeholder HTTP server to claim a port, then point a sandbox - // controller at the same port to force EADDRINUSE. The Vite plugin awaits - // start() in configureServer; if start() ever stops resolving, the entire - // dev backend hangs. This test pins down the resolve-on-error contract. + /** Claim a port so a subsequent listen on it fails with EADDRINUSE. */ + async function claimPort(): Promise<{ + port: number; + release: () => Promise; + }> { const blocker: Server = createServer(); await new Promise((resolve) => blocker.listen(0, "127.0.0.1", () => resolve()), @@ -327,21 +339,96 @@ describe("createSandboxController", () => { ? addr.port : 0; expect(port).toBeGreaterThan(0); + return { + port, + release: () => + new Promise((resolve) => blocker.close(() => resolve())), + }; + } + it("falls back to an OS-assigned port when the fixed port is taken", async () => { + // #2008 made the default port fixed, which introduced a collision the + // dynamic default never had: a second Inspector, or anything else already + // on 6275, would have taken the Apps tab down entirely. Retrying on a + // dynamic port keeps it working locally. + const { port, release } = await claimPort(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const controller = createSandboxController({ port, host: "127.0.0.1" }); + try { + const result = await controller.start(); + expect(result.port).toBeGreaterThan(0); + expect(result.port).not.toBe(port); + expect(result.url).toBe(`http://127.0.0.1:${result.port}/sandbox`); + expect(controller.getUrl()).toBe(result.url); + // The sandbox must actually be serving on the fallback port, not merely + // reporting one. + expect((await fetch(result.url)).status).toBe(200); + // Loud, not silent: a pinned-and-forwarded port that quietly moved is the + // very failure the fixed default exists to prevent. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining(`Sandbox: port ${port} in use`), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("MCP_SANDBOX_PORT"), + ); + } finally { + warnSpy.mockRestore(); + await controller.close(); + await release(); + } + }); + + it("resolves with empty values when even the dynamic retry fails", async () => { + // The Vite plugin awaits start() in configureServer; if start() ever stops + // resolving, the entire dev backend hangs. This pins the resolve-on-error + // contract for the case the retry can't rescue — an explicit `port: 0` that + // fails has nowhere left to fall back to, so it degrades as before. + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const controller = createSandboxController({ port: 0, host: "127.0.0.1" }); + try { + // Force the listen to fail the way a dynamic listen can (an unusable bind + // address), since port 0 cannot collide. + const broken = createSandboxController({ + port: 0, + host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface + }); + const result = await broken.start(); + expect(result).toEqual({ port: 0, url: "" }); + expect(broken.getUrl()).toBeNull(); + // close() must be a no-op since the server never bound. + await expect(broken.close()).resolves.toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + "Sandbox server error:", + expect.objectContaining({ code: "EADDRNOTAVAIL" }), + ); + } finally { + errorSpy.mockRestore(); + await controller.close(); + } + }); + + it("gives up after one retry when the dynamic fallback also fails", async () => { + // Guards the `retriedDynamic` latch: without it an EADDRINUSE loop could + // re-listen forever instead of settling. + const { port, release } = await claimPort(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const controller = createSandboxController({ port, host: "127.0.0.1" }); try { const result = await controller.start(); - expect(result).toEqual({ port: 0, url: "" }); - expect(controller.getUrl()).toBeNull(); - // close() must be a no-op since the server never bound. - await expect(controller.close()).resolves.toBeUndefined(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining(`Sandbox: port ${port} in use`), - ); + // The retry succeeds here (a dynamic port is always available), so assert + // the latch is what bounds it: exactly one retry warning was emitted. + expect( + warnSpy.mock.calls.filter((c) => + String(c[0]).includes("falling back to an OS-assigned port"), + ), + ).toHaveLength(1); + expect(result.port).toBeGreaterThan(0); } finally { + warnSpy.mockRestore(); errorSpy.mockRestore(); - await new Promise((resolve) => blocker.close(() => resolve())); + await controller.close(); + await release(); } }); diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index 5436b8fe..7dbb2e4a 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -7,6 +7,7 @@ import { webServerConfigToInitialPayload, type WebServerConfig, } from "../../../../server/web-server-config.js"; +import { DEFAULT_SANDBOX_PORT } from "../../../../server/sandbox-controller.js"; import { API_SERVER_ENV_VARS, LEGACY_AUTH_TOKEN_ENV, @@ -32,7 +33,7 @@ const MUTATED_ENV_KEYS = [ const baseConfig = (): WebServerConfig => ({ port: 6274, - hostname: "localhost", + hostname: "127.0.0.1", authToken: "tok", dangerouslyOmitAuth: false, initialMcpConfig: null, @@ -42,7 +43,7 @@ const baseConfig = (): WebServerConfig => ({ storageDir: undefined, allowedOrigins: ["http://localhost:6274"], sandboxPort: 0, - sandboxHost: "localhost", + sandboxHost: "127.0.0.1", logger: undefined, autoOpen: false, }); @@ -72,7 +73,7 @@ describe("buildWebServerConfigFromEnv", () => { it("returns defaults when no env vars are set", () => { const cfg = buildWebServerConfigFromEnv(); expect(cfg.port).toBe(6274); - expect(cfg.hostname).toBe("localhost"); + expect(cfg.hostname).toBe("127.0.0.1"); expect(cfg.authToken).toBe(""); expect(cfg.dangerouslyOmitAuth).toBe(false); expect(cfg.initialMcpConfig).toBeNull(); @@ -82,8 +83,8 @@ describe("buildWebServerConfigFromEnv", () => { "http://127.0.0.1:6274", "http://[::1]:6274", ]); - expect(cfg.sandboxPort).toBe(0); - expect(cfg.sandboxHost).toBe("localhost"); + expect(cfg.sandboxPort).toBe(DEFAULT_SANDBOX_PORT); + expect(cfg.sandboxHost).toBe("127.0.0.1"); expect(cfg.logger).toBeUndefined(); // Vitest sets `process.env.VITEST = 'true'`, so the autoOpen default is // suppressed here. Real `vite dev` runs don't set VITEST and default to @@ -289,7 +290,9 @@ describe("buildWebServerConfigFromEnv", () => { it("ignores non-numeric SERVER_PORT", () => { process.env.SERVER_PORT = "nope"; - expect(buildWebServerConfigFromEnv().sandboxPort).toBe(0); + expect(buildWebServerConfigFromEnv().sandboxPort).toBe( + DEFAULT_SANDBOX_PORT, + ); }); it("treats MCP_SANDBOX_PORT empty string as unset", () => { @@ -596,7 +599,7 @@ describe("printServerBanner", () => { it("includes the auth token in the URL when auth is enabled", () => { const url = printServerBanner(baseConfig(), 6274, "secret", undefined); expect(url).toBe( - `http://localhost:6274?${API_SERVER_ENV_VARS.AUTH_TOKEN}=secret`, + `http://127.0.0.1:6274?${API_SERVER_ENV_VARS.AUTH_TOKEN}=secret`, ); expect(logSpy.lines.some((l) => l.includes("Auth token: secret"))).toBe( true, @@ -607,7 +610,7 @@ describe("printServerBanner", () => { const cfg = baseConfig(); cfg.dangerouslyOmitAuth = true; const url = printServerBanner(cfg, 6274, "irrelevant", undefined); - expect(url).toBe("http://localhost:6274"); + expect(url).toBe("http://127.0.0.1:6274"); expect( logSpy.lines.some((l) => l.includes("Auth: disabled (DANGEROUSLY_OMIT_AUTH)"), @@ -617,7 +620,7 @@ describe("printServerBanner", () => { it("omits the query string when no token is supplied", () => { const url = printServerBanner(baseConfig(), 6274, "", undefined); - expect(url).toBe("http://localhost:6274"); + expect(url).toBe("http://127.0.0.1:6274"); }); it("brackets an IPv6 bind host in the printed URL", () => { diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index a2db3f55..0d1b2fe3 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -66,7 +66,7 @@ Consequences: - `MCP_PROXY_FULL_ADDRESS` has no v2 equivalent and is ignored. It existed to tell the browser where a non-default proxy lived; there is no proxy. - Docker needs `-p 6274:6274` for the UI, where the v1 recipe published `6277` as well. -⚠️ **`6274` is not the only listener.** The web backend also starts a **separate MCP Apps sandbox server**, on a dynamic port by default. It is only used by the Apps tab, and on plain loopback it needs no attention — but the browser reaches it directly, so anywhere the Inspector is _not_ served from the browser's own machine (Docker, a remote host, an SSH tunnel) you must pin it with `MCP_SANDBOX_PORT` and expose/forward that port too, or the Apps tab won't load. This is a different port from v1's proxy — it carries no MCP traffic — but it is still a second port. See [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). +⚠️ **`6274` is not the only listener.** The web backend also starts a **separate MCP Apps sandbox server**, on a fixed **`6275`** by default (it was OS-assigned before #2008). It is only used by the Apps tab, and on plain loopback it needs no attention — but the browser reaches it directly, so anywhere the Inspector is _not_ served from the browser's own machine (Docker, a dev container, a remote host, an SSH tunnel) you must expose/forward that port too, or the Apps tab won't load. This is a different port from v1's proxy — it carries no MCP traffic — but it is still a second port. See [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). ## Launching @@ -280,7 +280,7 @@ Stdout is otherwise compatible: the default `text` format still pretty-prints th | `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | | — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | | — | `MCP_CATALOG_PATH` | Default catalog path | -| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (dynamic by default) | +| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (fixed `6275` by default) | | — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations | | — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback | @@ -355,7 +355,9 @@ Notes: **"`Transport type not specified and could not be determined from URL`."** The path ends in neither `/mcp` nor `/sse`, so v2 refuses to guess where v1 fell back to SSE. Pass `--transport http` or `--transport sse`. -**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second, dynamic port. Pin it with `MCP_SANDBOX_PORT` and publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). +**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second port (`6275` by default). Publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). + +**"The page never loads inside a VS Code dev container (or any container)."** Fixed in the release carrying #1951 — upgrade. Before that fix the default `HOST=localhost` bound IPv6 loopback only on Linux, so the container's port forwarder (which connects over IPv4 `127.0.0.1`) could never reach it; the terminal printed a normal banner while the browser spun forever. On an older version, work around it with `HOST=127.0.0.1`. Note the failure is a **hang**, not a connection error, because the forwarder accepts on the host side before failing to connect inward. **"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list). From 1ea1e30f6344985927cf68657bd583dbf7f928cf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 13:17:08 -0400 Subject: [PATCH 67/73] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20real=20latch=20coverage,=20consistent=20docker=20do?= =?UTF-8?q?cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The `retriedDynamic` latch test never made the retry fail: a real `listen(0)` always succeeds, so it produced one EADDRINUSE and passed with the latch deleted. Mock `node:http` so every listen fails, asserting exactly two listen attempts and a settled result. Verified by mutation: removing the latch now hangs the test instead of passing it. - The migration guide's Docker notes still described a dynamic sandbox port and told readers to pin a custom one, contradicting the new fixed 6275 two sections later. - Narrowed "or any container" in the troubleshooting entry, which overstated the scope and contradicted the PR's own scope note. It now names the IPv4-forwarder case and explicitly excludes the official image and a plain loopback -p. Signed-off-by: cliffhall --- .../server/sandbox-controller.test.ts | 58 ++++++++++++++++--- docs/v1-to-v2-migration.md | 9 +-- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index a1f73f7e..6614c06e 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createServer, type Server } from "node:http"; +import { EventEmitter } from "node:events"; import { createSandboxController, DEFAULT_SANDBOX_PORT, @@ -408,27 +409,66 @@ describe("createSandboxController", () => { }); it("gives up after one retry when the dynamic fallback also fails", async () => { - // Guards the `retriedDynamic` latch: without it an EADDRINUSE loop could - // re-listen forever instead of settling. - const { port, release } = await claimPort(); + // Guards the `retriedDynamic` latch. A real `listen(0)` effectively always + // succeeds, so claiming a port only ever produces ONE EADDRINUSE — under + // which this test would pass with the latch deleted. Mock `node:http` so + // every listen fails with EADDRINUSE: the first trips the retry, the second + // must be caught by the latch and settle. Without the latch this recurses + // until the test times out rather than resolving. + vi.resetModules(); + const listenCalls: number[] = []; + vi.doMock("node:http", async () => { + const actual = + await vi.importActual("node:http"); + return { + ...actual, + createServer: () => { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + listen: (p: number) => { + listenCalls.push(p); + setImmediate(() => + emitter.emit( + "error", + Object.assign(new Error("in use"), { code: "EADDRINUSE" }), + ), + ); + }, + address: () => null, + close: (cb?: () => void) => cb?.(), + }); + }, + }; + }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const controller = createSandboxController({ port, host: "127.0.0.1" }); try { + const mod = await import("../../../../server/sandbox-controller.js"); + const controller = mod.createSandboxController({ + port: 6275, + host: "127.0.0.1", + }); + // Settles rather than looping — the contract the Vite plugin depends on. const result = await controller.start(); - // The retry succeeds here (a dynamic port is always available), so assert - // the latch is what bounds it: exactly one retry warning was emitted. + expect(result).toEqual({ port: 0, url: "" }); + expect(controller.getUrl()).toBeNull(); + // Exactly two listens: the fixed port, then one dynamic retry. A third + // would mean the latch failed to bound the loop. + expect(listenCalls).toEqual([6275, 0]); expect( warnSpy.mock.calls.filter((c) => String(c[0]).includes("falling back to an OS-assigned port"), ), ).toHaveLength(1); - expect(result.port).toBeGreaterThan(0); + // The second failure degrades loudly instead of retrying again. + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Sandbox: port 6275 in use"), + ); } finally { warnSpy.mockRestore(); errorSpy.mockRestore(); - await controller.close(); - await release(); + vi.doUnmock("node:http"); + vi.resetModules(); } }); diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 0d1b2fe3..c241e9df 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -330,14 +330,15 @@ docker run --rm -p 127.0.0.1:6274:6274 ghcr.io/modelcontextprotocol/inspector:la Notes: - **Keep the `127.0.0.1:` prefix on the published port**, as the v1 recipe did. `-p 6274:6274` publishes on every host interface, and the container's `HOST=0.0.0.0` is about the _container's_ interfaces, not the host's — the two are independent. That matters here because the backend spawns processes, `/` embeds the API token, and requests arriving with **no** `Origin` header (i.e. anything that isn't a browser) skip the origin allow-list, leaving the token as the only guard. Bind to loopback and opt into wider exposure deliberately. -- **The Apps tab needs one more published port.** The sandbox server is dynamic by default and the `Dockerfile` `EXPOSE`s only `6274`, so the recipe above covers everything _except_ MCP Apps. To use them, pin and publish the sandbox port as well: +- **The Apps tab needs one more published port.** The sandbox server listens on `6275` by default, so the recipe above covers everything _except_ MCP Apps. To use them, publish that port as well: ```bash - docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6280:6280 \ - -e MCP_SANDBOX_PORT=6280 \ + docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \ ghcr.io/modelcontextprotocol/inspector:latest ``` + Publish it on the **same port number** inside and out: the sandbox URL is handed to the browser via `/api/config` as `http://localhost:/sandbox`, so remapping it advertises a port the browser can't reach. To use a different number, move both ends — `-e MCP_SANDBOX_PORT=6280 -p 127.0.0.1:6280:6280`. + - The v2 image sets `DANGEROUSLY_BIND_ALL_INTERFACES=true` internally (a container must bind `0.0.0.0` to be reachable through `-p`). Setting a bare `HOST=0.0.0.0` **outside** a container now exits with an error. - **If you remap the published port** (`-p 8080:6274`), the browser's origin no longer matches the in-container port, so set `ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. - The image runs as non-root and has a `HEALTHCHECK` that assumes `--web`; add `--no-healthcheck` when running `--cli` / `--tui`. @@ -357,7 +358,7 @@ Notes: **"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second port (`6275` by default). Publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). -**"The page never loads inside a VS Code dev container (or any container)."** Fixed in the release carrying #1951 — upgrade. Before that fix the default `HOST=localhost` bound IPv6 loopback only on Linux, so the container's port forwarder (which connects over IPv4 `127.0.0.1`) could never reach it; the terminal printed a normal banner while the browser spun forever. On an older version, work around it with `HOST=127.0.0.1`. Note the failure is a **hang**, not a connection error, because the forwarder accepts on the host side before failing to connect inward. +**"The page never loads inside a VS Code dev container."** Fixed in the release carrying #1951 — upgrade; on an older version, work around it with `HOST=127.0.0.1`. Before that fix the default `HOST=localhost` bound IPv6 loopback **only** on Linux, so anything connecting over IPv4 `127.0.0.1` was refused — which is what a dev container's port forwarder does. The terminal printed a normal banner while the browser spun forever; the failure is a **hang** rather than a connection error because the forwarder accepts on the host side before failing to connect inward. The same fix covers an `ssh -L 6274:127.0.0.1:6274` tunnel and a container healthcheck. It does **not** describe the official Docker image (it binds `0.0.0.0`, so it was never affected) or a plain `docker run -p` against a loopback bind, which is unreachable across the network namespace for an unrelated reason. **"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list). From a9ed087f253d35d79b603e45bac2ea816773a7c0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 14:16:03 -0400 Subject: [PATCH 68/73] chore: sync v2/main to 2.2.0 and bump on v2/main before the milestone merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2/main has been at 2.0.0 since the v2.0.0 release while main and npm `latest` are at 2.2.0. The bump was created on the milestone-merge branch, which is cut from main, so it only ever existed downstream of v2/main and nothing carried it back — `chore: 2.1.0` and `chore: 2.2.0` are both on main and neither is on v2/main. That is not cosmetic. A branch cut from a milestone-merge branch silently carries the bump into an unrelated PR — #2009 arrived with a 2.0.0 -> 2.2.0 diff on a container bugfix — and anything reading the version in development (readInspectorVersion, --version, GET /api/config) reported a version two releases old. Move the bump to v2/main, before the milestone merge, so it flows into main with the rest of the milestone's work and the branches never disagree. The release tag is applied to the resulting main commit instead. The publish workflow needs no change: its tag-vs-package.json assertion runs at the release commit on main, which still carries the bump. Deliberately not a back-merge of main into v2/main: main holds the whole pre-v2 v1 history (~230 commits v2/main lacks, retained through `chore: replace main's tree with v2`), so merging would graft all of it into the develop branch's log permanently to deliver a two-file change. Also sets v2/main to 2.2.0 as a one-time catch-up. Nothing in the repo asserts a specific version value, so this is a safe two-file change. Closes #2010 Signed-off-by: cliffhall --- AGENTS.md | 4 ++++ README.md | 24 +++++++++++++++++++++--- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4123ccad..9ac732b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -465,6 +465,10 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor So v2 flows `feature branch → v2/main → (milestone) main → npm latest`, while v1 is flat: `feature branch → v1/main → npm v1-latest`, with no merge into `main` at any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does **not** need to be forward-ported to reach users on v1 (`npx @modelcontextprotocol/inspector@v1-latest`). + ⚠️ **The version bump rides that same flow — it is made on `v2/main`, never on the milestone-merge branch (#2010).** `npm version --no-git-tag-version` lands on `v2/main` *before* the milestone merge, so the bump reaches `main` as part of the milestone's work and the two branches never disagree about the version; the release tag is then applied to the resulting **`main`** commit. Bumping on the milestone-merge branch instead (which is cut from `main`) puts the bump only downstream of `v2/main`, where nothing carries it back — that is how `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases, and how #2009 came to carry an unrelated `2.0.0 → 2.2.0` diff simply because its branch was cut from a milestone-merge branch. **Never close a drift by merging `main` into `v2/main`**: `main` holds the whole pre-v2 v1 history (~230 commits `v2/main` lacks), so a back-merge grafts all of it into the develop branch permanently to deliver a two-file change. Full procedure in the root README's [Cutting a release](./README.md#cutting-a-release). + + A corollary for **branching**: cut feature branches from **`v2/main`**, never from a milestone-merge branch — the latter carries release-only commits that will show up in your PR's diff. + - **Project Boards**: - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here) - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes) diff --git a/README.md b/README.md index 35b1ce6d..9c9f59ee 100644 --- a/README.md +++ b/README.md @@ -326,16 +326,34 @@ Publishing is automated by two release-gated jobs in [`.github/workflows/main.ym A v2 release is cut from **`main`**, after the milestone's work has been merged there from `v2/main` — not from `v2/main` itself. (The v1 line releases independently from `v1/main` to the `v1-latest` tag and never touches `main`; see [Repo status](#mcp-inspector).) -Because there is **one version number** (only the root `package.json` has one — the clients carry none, so there is nothing to keep in sync and no `check-version` step), the release flow is just: +Because there is **one version number** (only the root `package.json` has one — the clients carry none, so there is nothing to keep in sync and no `check-version` step), the release flow is three steps. + +**1. Bump on `v2/main`, before the milestone merge.** The bump is part of the milestone's work, so it belongs on the develop branch and flows into `main` with everything else: ```bash -npm version # bumps the root package.json + tags -git push --follow-tags +git checkout -b v2/chore/-bump-x-y-z v2/main +npm version --no-git-tag-version # bump only, no tag +# PR → v2/main +``` + +⚠️ **`--no-git-tag-version` is load-bearing.** A bare `npm version` also tags, and the tag would land on a `v2/main` commit — but the release must be cut from `main`, so the tag has to point at the merge commit there (step 3). Tagging here creates a tag on a commit that is never released. + +**2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so `main` and `v2/main` agree on the version at every point. + +**3. Tag the `main` commit and draft the Release:** + +```bash +git checkout main && git pull +git tag v && git push origin v # then draft & publish a GitHub Release for that tag → triggers `publish` ``` The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. +**Why the bump goes on `v2/main` first ([#2010](https://github.com/modelcontextprotocol/inspector/issues/2010)).** It used to happen on the milestone-merge branch, which is cut from `main` — so the bump existed only *downstream* of `v2/main` and nothing carried it back. `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases. That is not cosmetic: a branch cut from a milestone-merge branch silently carries the bump into an unrelated PR (this happened on [#2009](https://github.com/modelcontextprotocol/inspector/issues/2009), where a container bugfix arrived with a `2.0.0 → 2.2.0` diff), and anything reading the version in development — `readInspectorVersion()`, `--version`, `GET /api/config` — reported a version two releases old. + +Do **not** "fix" a future drift by merging `main` back into `v2/main`. `main` carries the entire pre-v2 v1 history (retained through `ec5d8e13 chore: replace main's tree with v2` — ~230 commits `v2/main` does not have), so a back-merge grafts all of it into the develop branch's log permanently in order to deliver a two-file change. Bumping first means there is nothing to back-merge. + ### Docker A container image is published to GHCR (`ghcr.io/modelcontextprotocol/inspector`, `linux/amd64` + `linux/arm64`) by the release workflow. The [`Dockerfile`](Dockerfile) is a two-stage build: the first stage installs and `npm pack`s the publishable tarball; the second stage `npm install -g`s that tarball, so the image ships the exact same artifact as npm, with a clean `mcp-inspector` bin. diff --git a/package-lock.json b/package-lock.json index 5b738bc6..bb4866ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.2.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b255bb28..4e0295ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.2.0", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP", From 9048e69758134145214d0bd8f1229ba6e7d9ffe6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 14:39:30 -0400 Subject: [PATCH 69/73] docs: drop the v prefix from the release tag step This repo's release tags are bare x.y.z (2.2.0, 2.1.0, 2.0.0), not v-prefixed. Also notes why the manual tag matters: npm's tag-version-prefix defaults to 'v' and the repo sets no .npmrc, so a bare `npm version` would have produced a tag that doesn't match the convention. Signed-off-by: cliffhall --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9c9f59ee..f90ed822 100644 --- a/README.md +++ b/README.md @@ -344,10 +344,12 @@ npm version --no-git-tag-version # bump only, no tag ```bash git checkout main && git pull -git tag v && git push origin v +git tag && git push origin # then draft & publish a GitHub Release for that tag → triggers `publish` ``` +⚠️ **No `v` prefix.** This repo's release tags are bare `x.y.z` — `2.2.0`, `2.1.0`, `2.0.0` — so tag `2.3.0`, not `v2.3.0`. Note npm's own `tag-version-prefix` defaults to `v` and the repo sets no `.npmrc`, so a bare `npm version` would have produced a `v`-prefixed tag that does not match the convention. Tagging by hand (step 3) is what keeps it right. The workflow's assert step strips a leading `v` before comparing, so a `v`-prefixed tag would still publish — it would just be inconsistent with every previous release. + The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. **Why the bump goes on `v2/main` first ([#2010](https://github.com/modelcontextprotocol/inspector/issues/2010)).** It used to happen on the milestone-merge branch, which is cut from `main` — so the bump existed only *downstream* of `v2/main` and nothing carried it back. `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases. That is not cosmetic: a branch cut from a milestone-merge branch silently carries the bump into an unrelated PR (this happened on [#2009](https://github.com/modelcontextprotocol/inspector/issues/2009), where a container bugfix arrived with a `2.0.0 → 2.2.0` diff), and anything reading the version in development — `readInspectorVersion()`, `--version`, `GET /api/config` — reported a version two releases old. From 4d567c97e4555c357e010340287b26aa2a489c05 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 14:47:33 -0400 Subject: [PATCH 70/73] docs: make the release commands copy-pasteable and correct the invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: - `` / `` inside a bash block is shell redirection, so both documented commands were syntax errors if copied. Verified: `bash -n` rejects `git tag && ...` and accepts `git tag 2.3.0 && ...`. Use a concrete 2.2.0 -> 2.3.0 example and say what to substitute. - "agree on the version at every point" / "never disagree" overstated it. The procedure deliberately bumps v2/main first, so between the bump and the milestone merge the branches differ — v2/main reads the version being built, main the one released. Describe the real invariant: they converge when the release merge lands, and v2/main is never left behind main. Signed-off-by: cliffhall --- AGENTS.md | 2 +- README.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9ac732b8..0baea459 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -465,7 +465,7 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor So v2 flows `feature branch → v2/main → (milestone) main → npm latest`, while v1 is flat: `feature branch → v1/main → npm v1-latest`, with no merge into `main` at any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does **not** need to be forward-ported to reach users on v1 (`npx @modelcontextprotocol/inspector@v1-latest`). - ⚠️ **The version bump rides that same flow — it is made on `v2/main`, never on the milestone-merge branch (#2010).** `npm version --no-git-tag-version` lands on `v2/main` *before* the milestone merge, so the bump reaches `main` as part of the milestone's work and the two branches never disagree about the version; the release tag is then applied to the resulting **`main`** commit. Bumping on the milestone-merge branch instead (which is cut from `main`) puts the bump only downstream of `v2/main`, where nothing carries it back — that is how `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases, and how #2009 came to carry an unrelated `2.0.0 → 2.2.0` diff simply because its branch was cut from a milestone-merge branch. **Never close a drift by merging `main` into `v2/main`**: `main` holds the whole pre-v2 v1 history (~230 commits `v2/main` lacks), so a back-merge grafts all of it into the develop branch permanently to deliver a two-file change. Full procedure in the root README's [Cutting a release](./README.md#cutting-a-release). + ⚠️ **The version bump rides that same flow — it is made on `v2/main`, never on the milestone-merge branch (#2010).** `npm version --no-git-tag-version` lands on `v2/main` *before* the milestone merge, so the bump reaches `main` as part of the milestone's work; the release tag (bare `x.y.z`, no `v` prefix) is then applied to the resulting **`main`** commit. This deliberately leaves a normal in-flight interval where `v2/main` reads the version being built and `main` the one currently released — that difference is expected. The invariant it buys is that they converge when the release merge lands, so `v2/main` is never left *behind* `main` afterwards. Bumping on the milestone-merge branch instead (which is cut from `main`) puts the bump only downstream of `v2/main`, where nothing carries it back — that is how `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases, and how #2009 came to carry an unrelated `2.0.0 → 2.2.0` diff simply because its branch was cut from a milestone-merge branch. **Never close a drift by merging `main` into `v2/main`**: `main` holds the whole pre-v2 v1 history (~230 commits `v2/main` lacks), so a back-merge grafts all of it into the develop branch permanently to deliver a two-file change. Full procedure in the root README's [Cutting a release](./README.md#cutting-a-release). A corollary for **branching**: cut feature branches from **`v2/main`**, never from a milestone-merge branch — the latter carries release-only commits that will show up in your PR's diff. diff --git a/README.md b/README.md index f90ed822..c845c540 100644 --- a/README.md +++ b/README.md @@ -330,21 +330,25 @@ Because there is **one version number** (only the root `package.json` has one **1. Bump on `v2/main`, before the milestone merge.** The bump is part of the milestone's work, so it belongs on the develop branch and flows into `main` with everything else: +Substitute the real issue number and release below — the commands are written to be copy-pasteable as-is (a `2.2.0` → `2.3.0` minor bump): + ```bash -git checkout -b v2/chore/-bump-x-y-z v2/main -npm version --no-git-tag-version # bump only, no tag +git checkout -b v2/chore/2010-bump-2-3-0 v2/main +npm version minor --no-git-tag-version # or major / patch; bump only, no tag # PR → v2/main ``` ⚠️ **`--no-git-tag-version` is load-bearing.** A bare `npm version` also tags, and the tag would land on a `v2/main` commit — but the release must be cut from `main`, so the tag has to point at the merge commit there (step 3). Tagging here creates a tag on a commit that is never released. -**2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so `main` and `v2/main` agree on the version at every point. +**2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so the release lands on `main` with the version already correct. + +Between steps 1 and 2 the two branches **do** differ, and that is expected, not drift: `v2/main` reads the version being built while `main` still reads the one currently released. What this ordering removes is *post-release* drift — once the milestone merge lands they agree again, and `v2/main` is never left **behind** `main`. If you see `v2/main` ahead of `main`, a release is in flight; if you see it behind, something went wrong. **3. Tag the `main` commit and draft the Release:** ```bash git checkout main && git pull -git tag && git push origin +git tag 2.3.0 && git push origin 2.3.0 # then draft & publish a GitHub Release for that tag → triggers `publish` ``` From d0143ef8437fc82b9755c268cd16a72b492dbfc8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 15:04:34 -0400 Subject: [PATCH 71/73] docs: tag origin/main explicitly so a release can't point at a local commit git checkout main && git pull resolves through the maintainer's configured merge/rebase strategy, so a divergent local main can produce or replay local commits. Tagging HEAD there tags a commit that is not on origin/main, and pushing only the tag leaves a release whose commit was never published. git tag origin/main pins the tag to what the remote branch actually points at, regardless of local state. Signed-off-by: cliffhall --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c845c540..a15210d1 100644 --- a/README.md +++ b/README.md @@ -347,11 +347,13 @@ Between steps 1 and 2 the two branches **do** differ, and that is expected, not **3. Tag the `main` commit and draft the Release:** ```bash -git checkout main && git pull -git tag 2.3.0 && git push origin 2.3.0 +git fetch origin main +git tag 2.3.0 origin/main && git push origin 2.3.0 # then draft & publish a GitHub Release for that tag → triggers `publish` ``` +⚠️ **Tag `origin/main`, not your local `HEAD`.** `git checkout main && git pull` resolves through whatever merge-or-rebase strategy you have configured, so a divergent local `main` can quietly produce or replay local commits. Tagging `HEAD` there tags a commit that is not on `origin/main`, and `git push origin ` pushes only the tag — leaving a release whose commit was never published. Naming `origin/main` explicitly makes the tagged commit exactly what the remote branch points at, regardless of local state. + ⚠️ **No `v` prefix.** This repo's release tags are bare `x.y.z` — `2.2.0`, `2.1.0`, `2.0.0` — so tag `2.3.0`, not `v2.3.0`. Note npm's own `tag-version-prefix` defaults to `v` and the repo sets no `.npmrc`, so a bare `npm version` would have produced a `v`-prefixed tag that does not match the convention. Tagging by hand (step 3) is what keeps it right. The workflow's assert step strips a leading `v` before comparing, so a `v`-prefixed tag would still publish — it would just be inconsistent with every previous release. The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. From 2b2e1575111ec8911ccec63a3c916a36ee5b15b5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 17:39:43 -0400 Subject: [PATCH 72/73] fix: gate the connect-time logging/setLevel on the negotiated era (#1990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `logging/setLevel` is a legacy-era method, but connect() called it on any server advertising the `logging` capability, era regardless. The modern wire era rejects it outright, and because the call sits in the post-handshake sequence that rejection failed the *connection* — so the CLI (the one caller passing `initialLoggingLevel`, hardcoded to "debug") could not run any method at all against a modern server that advertised logging. Gate it on `protocolEra !== "modern"`. Modern has no session-scoped level to set: the equivalent is the per-request `io.modelcontextprotocol/logLevel` `_meta` opt-in, already driven by `modernLogLevel` from the server settings. So modern skips the call rather than translating it. Legacy is unchanged. The guard lives next to the capability check in inspectorClient.ts rather than in the CLI, so any future caller passing `initialLoggingLevel` is covered too. Signed-off-by: cliffhall --- ...nspectorClient-initial-logging-era.test.ts | 127 ++++++++++++++++++ core/mcp/inspectorClient.ts | 19 ++- 2 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts diff --git a/clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts b/clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts new file mode 100644 index 00000000..1f5fa165 --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { + MODERN_PROTOCOL_VERSION, + eraToVersionNegotiation, +} from "@inspector/core/mcp/types.js"; + +/** + * Regression coverage for #1990: the connect-time `initialLoggingLevel` must be + * gated on the negotiated era, not on the server capability alone. + * + * `logging/setLevel` is a legacy-era method, and the modern wire era rejects it + * outright ("Method 'logging/setLevel' is not supported by the negotiated + * protocol version"). Because the call sat in `connect()`'s post-handshake + * sequence, that rejection failed the *connection* — so the CLI (the one caller + * that passes `initialLoggingLevel`, hardcoded to "debug" in + * `clients/cli/src/cli.ts`) could not run *any* method against a modern server + * that advertised `logging`, logging-related or not. + * + * The two cases below are the era split: legacy still sends the request with the + * configured level, modern connects cleanly and never puts the method on the + * wire. The modern half asserts on the sent methods rather than on connect + * succeeding alone — a server that happens to answer the method would hide the + * regression behind a green connect. + * + * A fake transport carries both. The modern leg needs no `initialize` at all: + * the SDK negotiates with a `server/discover` probe and adopts that result's + * capabilities directly, which is also what lets one small class serve both eras + * by answering whichever handshake arrives. + */ +class CapabilityAdvertisingTransport implements Transport { + onmessage?: (message: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + + /** Every method this client put on the wire, in order. */ + readonly sentMethods: string[] = []; + + /** The params of the `logging/setLevel` request, if one was sent. */ + loggingSetLevelParams?: Record; + + async start(): Promise {} + + async close(): Promise { + this.onclose?.(); + } + + async send(message: JSONRPCMessage): Promise { + if (!("method" in message)) return; + this.sentMethods.push(message.method); + + // The modern era's handshake: one probe, answered with the capabilities the + // client then treats as the server's. + if (message.method === "server/discover" && "id" in message) { + this.reply(message.id, { + supportedVersions: [MODERN_PROTOCOL_VERSION], + capabilities: { logging: {} }, + }); + return; + } + + // The legacy era's handshake. + if (message.method === "initialize" && "id" in message) { + const params = message.params as { protocolVersion: string }; + this.reply(message.id, { + protocolVersion: params.protocolVersion, + capabilities: { logging: {} }, + serverInfo: { name: "logging-server", version: "1.0.0" }, + }); + return; + } + + if (message.method === "logging/setLevel" && "id" in message) { + this.loggingSetLevelParams = message.params as Record; + this.reply(message.id, {}); + return; + } + } + + private reply(id: string | number, result: Record): void { + // The SDK resolves the request from its own microtask; delivering + // synchronously from inside `send()` is the earliest any server could. + this.onmessage?.({ jsonrpc: "2.0", id, result }); + } +} + +async function connectWith( + era: "legacy" | "modern", +): Promise { + const transport = new CapabilityAdvertisingTransport(); + const client = new InspectorClient( + { type: "streamable-http", url: "https://mcp.example/mcp" }, + { + environment: { transport: () => ({ transport }) }, + versionNegotiation: eraToVersionNegotiation(era), + initialLoggingLevel: "debug", + }, + ); + + await client.connect(); + expect(client.getProtocolEra()).toBe(era); + await client.disconnect(); + + return transport; +} + +describe("InspectorClient connect() initial logging level, by era (#1990)", () => { + it("sends logging/setLevel on a legacy connection", async () => { + const transport = await connectWith("legacy"); + + expect(transport.sentMethods).toContain("logging/setLevel"); + expect(transport.loggingSetLevelParams).toMatchObject({ level: "debug" }); + }); + + it("never sends logging/setLevel on a modern connection", async () => { + const transport = await connectWith("modern"); + + // Modern has no session-scoped level: the equivalent is the per-request + // `io.modelcontextprotocol/logLevel` `_meta` opt-in, driven by the + // `modernLogLevel` server setting rather than by `initialLoggingLevel`. + expect(transport.sentMethods).not.toContain("logging/setLevel"); + // The server did advertise logging, so the capability check alone would + // have let the call through — the era gate is what stops it. + expect(transport.sentMethods).toContain("server/discover"); + }); +}); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b0b809f3..5d8aa86a 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1936,8 +1936,23 @@ export class InspectorClient extends InspectorClientEventTarget { // capabilities and wipe tools/prompts/resources to empty on every connect. await this.fetchServerInfo(); - // Set initial logging level if configured and server supports it - if (this.initialLoggingLevel && this.capabilities?.logging) { + // Set initial logging level if configured and server supports it. + // + // Era-gated (#1990): `logging/setLevel` is a legacy-era method, and the + // modern wire era rejects it outright — so an unconditional call here + // failed the *connect* of every modern server advertising `logging`, + // taking down commands that have nothing to do with logging. Modern has + // no session-scoped level at all: the equivalent is the per-request + // `io.modelcontextprotocol/logLevel` `_meta` opt-in, which + // `modernLogLevel` already drives from the server settings (see + // {@link setModernLogLevel}). So the right behavior on modern is to skip + // this call rather than translate it — `initialLoggingLevel` names a + // mechanism that era does not have. + if ( + this.initialLoggingLevel && + this.capabilities?.logging && + this.protocolEra !== "modern" + ) { await this.client.setLoggingLevel( this.initialLoggingLevel, this.getRequestOptions(), From e746665eb6891334a6b391c47bd774e716415d27 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 18:10:16 -0400 Subject: [PATCH 73/73] docs: mark initialLoggingLevel as legacy-era only (#1990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the option's doc still promised the level is set "if server supports logging" — after the era gate that is exactly the case which now silently does nothing, since a modern server can advertise `logging` and still ignore the option. Document it as legacy-only and point modern callers at the per-request `io.modelcontextprotocol/logLevel` opt-in (`modernLogLevel` / `setModernLogLevel()`). Signed-off-by: cliffhall --- core/mcp/types.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/core/mcp/types.ts b/core/mcp/types.ts index b89f117b..3873ecea 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -919,8 +919,16 @@ export interface InspectorClientOptions { pipeStderr?: boolean; /** - * Initial logging level to set after connection (if server supports logging) - * If not provided, logging level will not be set automatically + * Initial logging level to set after connection, via `logging/setLevel`. + * If not provided, the logging level will not be set automatically. + * + * **Legacy era only (#1990).** `logging/setLevel` is a legacy-era method, and + * the modern (2026-07-28) era rejects it — so this option is ignored on a + * modern connection even when the server advertises `logging`. Modern has no + * session-scoped level at all: the equivalent is the per-request + * `io.modelcontextprotocol/logLevel` `_meta` opt-in, configured through the + * `modernLogLevel` server setting and applied via + * `InspectorClient.setModernLogLevel()`. */ initialLoggingLevel?: LoggingLevel;