From 210b1e19940ef1dfe9192f72d9b2416c22a3eddd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 13:12:53 -0400 Subject: [PATCH 01/68] fix(web): only DANGEROUSLY_OMIT_AUTH=true/1 disables /api auth (#2331) !!process.env.DANGEROUSLY_OMIT_AUTH read every non-empty string as on, so =false silently disabled the bearer gate. Reuse the DANGEROUSLY_BIND_ALL_INTERFACES parser (exported as isEnvFlagEnabled) so both safety flags fail closed. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- clients/web/server/resolve-bind-host.ts | 14 +++++++---- clients/web/server/web-server-config.ts | 8 +++++-- .../server/web-server-config.test.ts | 24 +++++++++++++++++++ docs/environment-variables.md | 2 +- docs/v1-to-v2-migration.md | 2 +- 5 files changed, 42 insertions(+), 8 deletions(-) diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index f18945ce05..c978392e04 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -18,10 +18,13 @@ export const BIND_ALL_INTERFACES_ENV = "DANGEROUSLY_BIND_ALL_INTERFACES"; /** * An explicit, unambiguous opt-in. Unlike a bare `!!value` (which treats the - * string `"false"` as truthy), only `"true"`/`"1"` (case-insensitive) enable - * the override, so `DANGEROUSLY_BIND_ALL_INTERFACES=false` reads as "off". + * string `"false"` as truthy), only `"true"`/`"1"` (trimmed, case-insensitive) + * enable the override, so `DANGEROUSLY_BIND_ALL_INTERFACES=false` reads as + * "off". Exported so every `DANGEROUSLY_*` safety flag parses the same way — + * `DANGEROUSLY_OMIT_AUTH` once used `!!value`, and `=false` turned auth off + * (#2331). Anything unrecognized fails closed. */ -function isEnabled(value: string | undefined): boolean { +export function isEnvFlagEnabled(value: string | undefined): boolean { const v = value?.trim().toLowerCase(); return v === "true" || v === "1"; } @@ -66,7 +69,10 @@ export function resolveBindHostname( env: NodeJS.ProcessEnv = process.env, ): string { const host = (env.HOST ?? DEFAULT_BIND_HOST).trim(); - if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) { + if ( + isAllInterfacesHost(host) && + !isEnvFlagEnabled(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 // `HOST="0"` renders like `0`, `HOST=0` / `0x0` / `::0` bind `0.0.0.0`), and diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 1b9c453974..55283953e3 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -20,7 +20,7 @@ import { secretStorageSummary } from "../../../core/auth/secret-storage-info.ts" import { readInspectorVersionSafe } from "../../../core/node/version.ts"; import { resolveSandboxPort } from "./sandbox-controller.js"; import { resolveAppOriginPort } from "./app-origin-controller.js"; -import { resolveBindHostname } from "./resolve-bind-host.js"; +import { isEnvFlagEnabled, resolveBindHostname } from "./resolve-bind-host.js"; import { APP_ORIGIN_FULL_ADDRESS_ENV, resolveAppOriginPublicOrigin, @@ -386,7 +386,11 @@ export function buildWebServerConfig( ); } const hostname = resolveBindHostname(); - const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH; + // Only an explicit `true`/`1` omits auth — `!!value` read `=false` as "on" + // and silently disabled the /api/* bearer gate (#2331). + const dangerouslyOmitAuth = isEnvFlagEnabled( + process.env.DANGEROUSLY_OMIT_AUTH, + ); const authToken = dangerouslyOmitAuth ? "" : ((process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] as string | undefined) ?? 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 d3477d115b..8682dd42bf 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 @@ -154,6 +154,30 @@ describe("buildWebServerConfigFromEnv", () => { expect(cfg.authToken).toBe(""); }); + // #2331: `!!value` read every non-empty string as "on", so a deployment that + // set DANGEROUSLY_OMIT_AUTH=false to keep auth on silently turned it off. + it.each(["true", "TRUE", " True ", "1", " 1 "])( + "omits auth for the explicit opt-in DANGEROUSLY_OMIT_AUTH=%j", + (value) => { + process.env.DANGEROUSLY_OMIT_AUTH = value; + process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "ignored"; + const cfg = buildWebServerConfigFromEnv(); + expect(cfg.dangerouslyOmitAuth).toBe(true); + expect(cfg.authToken).toBe(""); + }, + ); + + it.each(["false", "FALSE", "0", "", " ", "no", "yes", "on", "2"])( + "keeps auth on for DANGEROUSLY_OMIT_AUTH=%j", + (value) => { + process.env.DANGEROUSLY_OMIT_AUTH = value; + process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "kept"; + const cfg = buildWebServerConfigFromEnv(); + expect(cfg.dangerouslyOmitAuth).toBe(false); + expect(cfg.authToken).toBe("kept"); + }, + ); + it("uses API_SERVER_ENV_VARS.AUTH_TOKEN when present", () => { process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "primary"; const cfg = buildWebServerConfigFromEnv(); diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 0c7cf6ce6e..4fd0b70cf5 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -16,7 +16,7 @@ These guard the web backend, which spawns processes on request. Read [Host bindi | --------------------------------- | -------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MCP_INSPECTOR_API_TOKEN` | web, CLI | a random token per launch | Bearer token guarding every `/api/*` route (`x-mcp-remote-auth: Bearer `). Set it to use a known token instead of the generated one printed in the launch banner. The CLI reads it only to fill the `autoConnect` parameter of the deep link it emits. | | `MCP_PROXY_AUTH_TOKEN` | web | — | **Deprecated** v1 name for `MCP_INSPECTOR_API_TOKEN`, used only when the new name is unset. | -| `DANGEROUSLY_OMIT_AUTH` | web | unset | Disables the API token entirely. ⚠️ **Any non-empty value turns auth off, including `false` and `0`** — unset the variable to keep auth on. | +| `DANGEROUSLY_OMIT_AUTH` | web | unset | Disables the API token entirely when set to `true` or `1` (trimmed, case-insensitive). Any other value — including `false`, `0` and empty — keeps auth on. | | `HOST` | web, CLI | `127.0.0.1` | Address the web server binds. An all-interfaces host (`0.0.0.0`, `::`, an empty string, and equivalent spellings) is **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES` is enabled. The CLI reads it only to build its deep link. | | `DANGEROUSLY_BIND_ALL_INTERFACES` | web | off | Opts in to an all-interfaces `HOST`. Only `true` or `1` (case-insensitive) enable it, so `false` reads as off. The Docker image sets it. | | `ALLOWED_ORIGINS` | web | derived from `HOST` | Comma-separated origins allowed to call the API. Unset, the list follows `HOST` at `CLIENT_PORT`: the loopback origins for a loopback host, the loopback origins plus `http://0.0.0.0` and `http://[::]` for an all-interfaces bind, and otherwise only the configured host's own origin (so binding a LAN address does **not** also allow `localhost`). **Replaces** the default list rather than adding to it, so list every form you browse from. Each entry must include the scheme (`http://localhost:6274`). The same list is the MCP Apps sandbox proxy's embedder allow-list (its `frame-ancestors` header and its referrer check), so a public Inspector origin must be listed here for the Apps tab to render. | diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 520d6b9a59..727b11324e 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -278,7 +278,7 @@ This table maps v1 names to v2. Defaults, accepted values, and the variables wit | `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 | | +| `DANGEROUSLY_OMIT_AUTH` | same, **stricter** | Only `true` / `1` (trimmed, case-insensitive) disable auth now; v1 treated any non-empty value — even `false` — as on | | `MCP_AUTO_OPEN_ENABLED` | same | Also governs opening the OAuth page in the CLI (`true` opens it even when stderr is not a TTY). The TUI does not read it | | — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | | — | `MCP_CATALOG_PATH` | Default catalog path | From da8c3528cf41d49b70b4eaeb46574ef7379786e2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 13:17:30 -0400 Subject: [PATCH 02/68] feat: per-server setting to suppress the standalone GET notification stream (#2317) A new suppressNotificationStream server setting answers the Streamable HTTP transport's standalone GET with a local 405, which the SDK already treats as 'no standalone stream', so traffic stays POST-only. It persists omit-when-off, is validated by the web backend, reaches createTransportNode on the web, CLI and TUI paths, and is exposed as a checkbox in Server Settings > Options for streamable-http servers. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- .../ServerSettingsForm.stories.tsx | 7 ++ .../ServerSettingsForm.test.tsx | 59 ++++++++++++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 13 ++++ .../ServerSettingsModal.test.tsx | 36 +++++++++ .../ServerSettingsModal.tsx | 11 +++ .../suppressNotificationStreamFetch.test.ts | 54 +++++++++++++ .../web/src/test/core/mcp/serverList.test.ts | 35 +++++++++ .../mcp/remote/server-extra-coverage.test.ts | 34 ++++++++ .../mcp/suppress-notification-stream.test.ts | 78 +++++++++++++++++++ .../node/suppressNotificationStreamFetch.ts | 45 +++++++++++ core/mcp/node/transport.ts | 11 ++- core/mcp/remote/node/server.ts | 14 ++++ core/mcp/serverList.ts | 13 ++++ core/mcp/types.ts | 19 +++++ docs/mcp-server-configuration.md | 1 + 15 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 clients/web/src/test/core/mcp/node/suppressNotificationStreamFetch.test.ts create mode 100644 clients/web/src/test/integration/mcp/suppress-notification-stream.test.ts create mode 100644 core/mcp/node/suppressNotificationStreamFetch.ts diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx index 245feb6686..b79cae5754 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx @@ -112,6 +112,12 @@ function InteractiveRender(args: ServerSettingsFormProps) { settings: { ...args.settings, paginatedLists: value }, }); }} + onSuppressNotificationStreamChange={(value) => { + args.onSuppressNotificationStreamChange(value); + updateArgs({ + settings: { ...args.settings, suppressNotificationStream: value }, + }); + }} onAdvertisedExtensionChange={(key, checked) => { args.onAdvertisedExtensionChange(key, checked); updateArgs({ @@ -182,6 +188,7 @@ const meta: Meta = { onTimeoutChange: fn(), onAutoRefreshChange: fn(), onPaginatedListsChange: fn(), + onSuppressNotificationStreamChange: fn(), onAdvertisedExtensionChange: fn(), onMaxFetchRequestsChange: fn(), onSkillCatalogLimitChange: fn(), diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 26488227b7..04ae83a898 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -68,6 +68,7 @@ const baseHandlers = { onTimeoutChange: vi.fn(), onAutoRefreshChange: vi.fn(), onPaginatedListsChange: vi.fn(), + onSuppressNotificationStreamChange: vi.fn(), onAdvertisedExtensionChange: vi.fn(), onMaxFetchRequestsChange: vi.fn(), onSkillCatalogLimitChange: vi.fn(), @@ -452,6 +453,64 @@ describe("ServerSettingsForm", () => { expect(onAutoRefreshChange).toHaveBeenCalledWith(true); }); + describe("Suppress Notification Stream (#2317)", () => { + const name = /Suppress Notification Stream/; + + it("is unchecked by default and reflects an explicit true", () => { + const { rerender } = renderWithMantine( + , + ); + expect(screen.getByRole("checkbox", { name })).not.toBeChecked(); + rerender( + , + ); + expect(screen.getByRole("checkbox", { name })).toBeChecked(); + }); + + it("invokes onSuppressNotificationStreamChange when toggled", async () => { + const user = userEvent.setup(); + const onSuppressNotificationStreamChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("checkbox", { name })); + expect(onSuppressNotificationStreamChange).toHaveBeenCalledWith(true); + }); + + it.each(["sse", "stdio"] as const)( + "is hidden for a %s server, which has no standalone GET stream", + (serverType) => { + renderWithMantine( + , + ); + expect( + screen.queryByRole("checkbox", { name }), + ).not.toBeInTheDocument(); + }, + ); + }); + it("renders the Advertised Extensions section with Tasks checked by default", () => { renderWithMantine( void; onAutoRefreshChange: (value: boolean) => void; onPaginatedListsChange: (value: boolean) => void; + /** Toggle the standalone `GET` notification stream suppression (#2317). */ + onSuppressNotificationStreamChange: (value: boolean) => void; /** * Toggle whether the Inspector advertises the extension `key` to this server. * `checked` is the new advertise state; the modal folds it into @@ -479,6 +481,7 @@ export function ServerSettingsForm({ onTimeoutChange, onAutoRefreshChange, onPaginatedListsChange, + onSuppressNotificationStreamChange, onAdvertisedExtensionChange, onMaxFetchRequestsChange, onSkillCatalogLimitChange, @@ -708,6 +711,16 @@ export function ServerSettingsForm({ checked={settings.paginatedLists ?? false} onChange={(e) => onPaginatedListsChange(e.currentTarget.checked)} /> + {serverType === "streamable-http" ? ( + + onSuppressNotificationStreamChange(e.currentTarget.checked) + } + /> + ) : null} { ); }); + // #2317 — omit-when-off, so an untouched server writes no field. + it("maps the notification-stream suppression into settings, and back to unset", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + const { rerender } = renderWithMantine( + , + ); + const name = /Suppress Notification Stream/; + await user.click(screen.getByRole("checkbox", { name })); + expect(onSettingsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ suppressNotificationStream: true }), + ); + + rerender( + , + ); + await user.click(screen.getByRole("checkbox", { name })); + expect(onSettingsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ suppressNotificationStream: undefined }), + ); + }); + // #2144 — same omit-the-default shape as the refresh-token pair above. it("maps the revoke-on-clear opt-out into settings, and back to unset", async () => { const user = userEvent.setup(); diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index db2b63a432..1f57b9ecce 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -206,6 +206,14 @@ export function ServerSettingsModal({ onSettingsChange({ ...settings, paginatedLists: value }); } + function handleSuppressNotificationStreamChange(value: boolean) { + // Omit-when-off, so a server that never touched the box writes no field. + onSettingsChange({ + ...settings, + suppressNotificationStream: value ? true : undefined, + }); + } + function handleAdvertisedExtensionChange(key: string, checked: boolean) { const next = { ...settings.advertisedExtensions }; const ext = ADVERTISABLE_EXTENSIONS.find((e) => e.key === key); @@ -306,6 +314,9 @@ export function ServerSettingsModal({ onTimeoutChange={handleTimeoutChange} onAutoRefreshChange={handleAutoRefreshChange} onPaginatedListsChange={handlePaginatedListsChange} + onSuppressNotificationStreamChange={ + handleSuppressNotificationStreamChange + } onAdvertisedExtensionChange={handleAdvertisedExtensionChange} onMaxFetchRequestsChange={handleMaxFetchRequestsChange} onSkillCatalogLimitChange={handleSkillCatalogLimitChange} diff --git a/clients/web/src/test/core/mcp/node/suppressNotificationStreamFetch.test.ts b/clients/web/src/test/core/mcp/node/suppressNotificationStreamFetch.test.ts new file mode 100644 index 0000000000..d53401f46b --- /dev/null +++ b/clients/web/src/test/core/mcp/node/suppressNotificationStreamFetch.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from "vitest"; +import { createSuppressNotificationStreamFetch } from "@inspector/core/mcp/node/suppressNotificationStreamFetch.js"; + +const URL_ = "https://example.com/mcp"; + +function setup() { + const baseFetch = vi.fn(async () => new Response("ok", { status: 200 })); + return { + baseFetch, + fetchFn: createSuppressNotificationStreamFetch(baseFetch), + }; +} + +describe("createSuppressNotificationStreamFetch (#2317)", () => { + it("answers a standalone GET with a local 405 and never sends it", async () => { + const { baseFetch, fetchFn } = setup(); + const res = await fetchFn(URL_, { + method: "GET", + headers: new Headers({ accept: "text/event-stream" }), + }); + expect(res.status).toBe(405); + expect(baseFetch).not.toHaveBeenCalled(); + }); + + it("treats a GET with no init at all as the standalone stream", async () => { + const { baseFetch, fetchFn } = setup(); + expect((await fetchFn(URL_)).status).toBe(405); + expect(baseFetch).not.toHaveBeenCalled(); + }); + + it("reads the method and headers off a Request input", async () => { + const { baseFetch, fetchFn } = setup(); + expect((await fetchFn(new Request(URL_))).status).toBe(405); + const resume = new Request(URL_, { headers: { "Last-Event-ID": "7" } }); + expect((await fetchFn(resume)).status).toBe(200); + const post = new Request(URL_, { method: "POST", body: "{}" }); + expect((await fetchFn(post)).status).toBe(200); + expect(baseFetch).toHaveBeenCalledTimes(2); + }); + + it("passes a resumption GET (Last-Event-ID) through", async () => { + const { baseFetch, fetchFn } = setup(); + const init = { method: "get", headers: { "last-event-id": "42" } }; + expect((await fetchFn(URL_, init)).status).toBe(200); + expect(baseFetch).toHaveBeenCalledWith(URL_, init); + }); + + it.each(["POST", "DELETE"])("passes %s through", async (method) => { + const { baseFetch, fetchFn } = setup(); + const init = { method }; + expect((await fetchFn(URL_, init)).status).toBe(200); + expect(baseFetch).toHaveBeenCalledWith(URL_, init); + }); +}); diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index eb3ebc220e..d660ecd00e 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -347,6 +347,41 @@ describe("serverEntriesToMcpConfig", () => { expect(round).toEqual(original); }); + it("round-trips suppressNotificationStream: lifts true to settings and back to disk (#2317)", () => { + const original: MCPConfig = { + mcpServers: { + delta: { + type: "streamable-http", + url: "https://x.test/mcp", + suppressNotificationStream: true, + }, + }, + }; + const [entry] = mcpConfigToServerEntries(original); + expect(entry?.settings?.suppressNotificationStream).toBe(true); + const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original)); + expect(round).toEqual(original); + }); + + it("drops a non-true suppressNotificationStream on read and omits it on write (#2317)", () => { + const original = { + mcpServers: { + epsilon: { + type: "streamable-http", + url: "https://x.test/mcp", + suppressNotificationStream: false, + }, + }, + } satisfies MCPConfig; + const [entry] = mcpConfigToServerEntries(original); + expect(entry?.settings).toBeDefined(); + expect(entry?.settings?.suppressNotificationStream).toBeUndefined(); + const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original)); + expect( + "suppressNotificationStream" in (round.mcpServers.epsilon ?? {}), + ).toBe(false); + }); + it("round-trips advertisedExtensions: lifts a non-empty map to settings and back to disk", () => { const original: MCPConfig = { mcpServers: { diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 4ebd734b36..bee1d3e33b 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -827,6 +827,40 @@ describe("server.ts supplemental coverage", () => { expect((await res.json()).error).toMatch(/paginatedLists/); }); + it("rejects a non-boolean suppressNotificationStream (#2317)", async () => { + const res = await postSettings({ + ...base, + suppressNotificationStream: "yes", + }); + expect((await res.json()).error).toMatch(/suppressNotificationStream/); + }); + + // #2317 — a 200 only proves the payload validated; read the entry back so + // dropping the field from `normalizeSettings` cannot pass silently. + async function readSuppressNotificationStream() { + const res = await fetch(`${h.baseUrl}/api/servers`); + const body = (await res.json()) as { + mcpServers: Record>; + }; + return body.mcpServers.srv?.suppressNotificationStream; + } + + it("persists suppressNotificationStream through a save (#2317)", async () => { + expect( + (await postSettings({ ...base, suppressNotificationStream: true })) + .status, + ).toBe(200); + expect(await readSuppressNotificationStream()).toBe(true); + }); + + it("writes no suppressNotificationStream field when off (#2317)", async () => { + expect( + (await postSettings({ ...base, suppressNotificationStream: false })) + .status, + ).toBe(200); + expect(await readSuppressNotificationStream()).toBeUndefined(); + }); + it("rejects a negative maxFetchRequests", async () => { const res = await postSettings({ ...base, maxFetchRequests: -2 }); expect((await res.json()).error).toMatch(/maxFetchRequests/); diff --git a/clients/web/src/test/integration/mcp/suppress-notification-stream.test.ts b/clients/web/src/test/integration/mcp/suppress-notification-stream.test.ts new file mode 100644 index 0000000000..d35e27cd41 --- /dev/null +++ b/clients/web/src/test/integration/mcp/suppress-notification-stream.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + createNumberedTools, +} from "@modelcontextprotocol/inspector-test-server"; + +/** + * Live coverage of the `suppressNotificationStream` setting (#2317) through + * the real SDK transport: `createSuppressNotificationStreamFetch` is only + * useful if the transport really does treat the synthetic 405 as "no + * standalone stream" and carry on, which a unit test of the wrapper cannot + * show. The control arm proves the recorder would have seen the GET. + */ +describe("suppressNotificationStream (#2317)", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + + afterEach(async () => { + await client?.disconnect().catch(() => {}); + client = null; + await server?.stop().catch(() => {}); + server = null; + }); + + function settings(suppress: boolean): InspectorServerSettings { + return { + headers: [], + metadata: {}, + env: [], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 1000, + roots: [], + ...(suppress && { suppressNotificationStream: true }), + }; + } + + async function connectRecording(suppress: boolean) { + server = createTestServerHttp({ + serverInfo: createTestServerInfo("suppress-stream-test", "1.0.0"), + tools: createNumberedTools(2), + }); + await server.start(); + const methods: string[] = []; + const recordingFetch: typeof fetch = (input, init) => { + methods.push((init?.method ?? "GET").toUpperCase()); + return fetch(input, init); + }; + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode, fetch: recordingFetch }, + serverSettings: settings(suppress), + }, + ); + await client.connect(); + return { client, methods }; + } + + it("opens the standalone GET stream by default (control)", async () => { + const { client: connected, methods } = await connectRecording(false); + await expect.poll(() => methods.includes("GET")).toBe(true); + expect((await connected.listTools()).tools).toHaveLength(2); + }); + + it("never sends the GET when suppressed, and requests still work", async () => { + const { client: connected, methods } = await connectRecording(true); + expect((await connected.listTools()).tools).toHaveLength(2); + expect(methods).toContain("POST"); + expect(methods).not.toContain("GET"); + }); +}); diff --git a/core/mcp/node/suppressNotificationStreamFetch.ts b/core/mcp/node/suppressNotificationStreamFetch.ts new file mode 100644 index 0000000000..56c6078296 --- /dev/null +++ b/core/mcp/node/suppressNotificationStreamFetch.ts @@ -0,0 +1,45 @@ +/** + * Suppress the Streamable HTTP client's standalone `GET` notification stream + * (#2317). + * + * The SDK's `StreamableHTTPClientTransport` opens a long-lived `GET` SSE stream + * as soon as `notifications/initialized` is accepted, and exposes no option to + * skip it. Against a server that can serve only one request per client at a + * time, that stream occupies the only slot: `initialize` succeeds and every + * later request hangs until the per-request timeout fires (#2187). + * + * The transport already treats `405 Method Not Allowed` on that `GET` as "this + * server offers no standalone stream" and carries on with POST-only traffic, so + * answering the `GET` locally with a synthetic 405 — without sending it — + * reuses the SDK's own spec-conformant path (the client MAY open the stream; + * it is never required to). + * + * Only the *standalone* stream is suppressed. A `GET` carrying + * `Last-Event-ID` is the transport resuming a POST response stream that + * dropped mid-request, which is part of request/response traffic and has to + * keep reaching the server. + */ +export function createSuppressNotificationStreamFetch( + baseFetch: typeof fetch, +): typeof fetch { + return async (input, init) => { + if (isStandaloneStreamRequest(input, init)) { + return new Response(null, { + status: 405, + statusText: "Method Not Allowed", + }); + } + return baseFetch(input, init); + }; +} + +function isStandaloneStreamRequest( + input: Parameters[0], + init: Parameters[1], +): boolean { + const request = input instanceof Request ? input : undefined; + const method = (init?.method ?? request?.method ?? "GET").toUpperCase(); + if (method !== "GET") return false; + const headers = new Headers(init?.headers ?? request?.headers); + return !headers.has("last-event-id"); +} diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts index 08792f0c6c..dbc38b3168 100644 --- a/core/mcp/node/transport.ts +++ b/core/mcp/node/transport.ts @@ -17,6 +17,7 @@ import { createAuthChallengeObserverFetch, } from "./authChallengeFetch.js"; import { createProxyFetch } from "./proxyFetch.js"; +import { createSuppressNotificationStreamFetch } from "./suppressNotificationStreamFetch.js"; /** * Build the wire `headers` record from `settings.headers`, dropping rows with @@ -172,10 +173,18 @@ export function createTransportNode( ...(headers && { headers }), }; + // Outermost, so a suppressed GET is answered before it reaches the + // tracker: it is never sent, and the Network log should not show a + // request the server never saw (#2317). + const httpFetch = + settings?.suppressNotificationStream === true + ? createSuppressNotificationStreamFetch(fetchWithOptionalAuthIntercept) + : fetchWithOptionalAuthIntercept; + const transport = new StreamableHTTPClientTransport(url, { authProvider, requestInit, - fetch: fetchWithOptionalAuthIntercept, + fetch: httpFetch, // SEP-2350: how the transport reacts to a `403 insufficient_scope` // challenge. Defaults to the SDK's `reauthorize` when unset. ...(settings?.oauthOnInsufficientScope && { diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 5257aec1c9..dbeb87ce65 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1957,6 +1957,16 @@ export function createRemoteApp( error: "settings.paginatedLists must be a boolean", }; } + // Optional on the wire; boolean when present, else unset (off) (#2317). + if ( + obj.suppressNotificationStream !== undefined && + typeof obj.suppressNotificationStream !== "boolean" + ) { + return { + ok: false, + error: "settings.suppressNotificationStream must be a boolean", + }; + } // maxFetchRequests is optional on the wire (older clients won't send it); // when present it must be a non-negative number (0 = unlimited), otherwise // it defaults below. @@ -2114,6 +2124,10 @@ export function createRemoteApp( autoRefreshOnListChanged: obj.autoRefreshOnListChanged === true, // Absent → false, matching the read side (omit-on-false on the write side). paginatedLists: obj.paginatedLists === true, + // Absent → unset (off); only an explicit true is carried (#2317). + ...(obj.suppressNotificationStream === true && { + suppressNotificationStream: true, + }), // Absent → product default, matching the read side. The default is the // omit-sentinel in inspectorSettingsToStoredFields, so a client that // didn't send one writes no spurious maxFetchRequests to disk. diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index a0b427006f..9814008ed5 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -163,6 +163,7 @@ type StoredInspectorFields = Pick< | "taskTtl" | "autoRefreshOnListChanged" | "paginatedLists" + | "suppressNotificationStream" | "advertisedExtensions" | "maxFetchRequests" | "skillCatalogMaxSkills" @@ -526,6 +527,7 @@ export function storedFieldsToInspectorSettings( stored.taskTtl !== undefined || stored.autoRefreshOnListChanged !== undefined || stored.paginatedLists !== undefined || + stored.suppressNotificationStream !== undefined || stored.advertisedExtensions !== undefined || stored.maxFetchRequests !== undefined || stored.skillCatalogMaxSkills !== undefined || @@ -576,6 +578,11 @@ export function storedFieldsToInspectorSettings( if (isSkillCatalogLimit(stored.skillCatalogMaxBytes)) { settings.skillCatalogMaxBytes = stored.skillCatalogMaxBytes; } + // Hand-edited non-boolean values are dropped (→ off) rather than coerced; + // only an explicit `true` suppresses the stream (#2317). + if (stored.suppressNotificationStream === true) { + settings.suppressNotificationStream = true; + } // Absent on disk reads back as the default era; the write side then omits the // default so a byte-stable round-trip never injects `protocolEra` into files // that never set it. An unknown literal from a hand-edited file is dropped @@ -717,6 +724,11 @@ export function inspectorSettingsToStoredFields( out.paginatedLists = true; } + // Persist only when enabled — absent reads back as unset (off) (#2317). + if (settings.suppressNotificationStream) { + out.suppressNotificationStream = true; + } + // Persist only when the user has toggled at least one extension override; // an empty map reads back as unset (above), keeping the diff minimal for the // common (no-override) case. @@ -848,6 +860,7 @@ const INSPECTOR_FIELD_KEY_MAP = { taskTtl: true, autoRefreshOnListChanged: true, paginatedLists: true, + suppressNotificationStream: true, advertisedExtensions: true, maxFetchRequests: true, skillCatalogMaxSkills: true, diff --git a/core/mcp/types.ts b/core/mcp/types.ts index f6a6730366..c61c6528aa 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -157,6 +157,13 @@ export type StoredMCPServer = MCPServerConfig & { * false (the default). (#1721) */ paginatedLists?: boolean; + /** + * When true, the Streamable HTTP transport does not open the standalone + * `GET` notification stream, so traffic is POST-only and server-initiated + * messages outside a request's own response stream do not arrive. + * Inspector-specific. Omitted on disk when false (the default). (#2317) + */ + suppressNotificationStream?: boolean; /** * Per-extension overrides for which extensions the Inspector advertises to * this server (keyed by extension id; a present key wins over the registry @@ -928,6 +935,18 @@ export interface InspectorServerSettings { * Default false. Server-wide; the per-list sidebar toggle edits this. (#1721) */ paginatedLists?: boolean; + /** + * When true, a Streamable HTTP connection does not open the standalone `GET` + * notification stream (the client MAY open it; it is never required), so + * request/response traffic is POST-only (#2317). Two uses: a one-click + * diagnostic for a server that cannot serve a second concurrent request — + * which the long-lived stream otherwise occupies, hanging every request + * after `initialize` (#2187) — and an escape hatch that makes such a server + * inspectable. The cost is that server→client messages not tied to a request + * (list_changed, resource updates, standalone logs) do not arrive. Read at + * connect time; no effect on stdio or legacy SSE. Default false. + */ + suppressNotificationStream?: boolean; /** * Maximum number of HTTP fetch requests retained in the Network log for this * server. When exceeded, the oldest entries rotate out (and any deferred diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index f1abe74528..5ad8afaa9a 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -187,6 +187,7 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `taskTtl` | `60000` | TTL in ms for tasks created via "Run as task" (`DEFAULT_TASK_TTL_MS`) | | `autoRefreshOnListChanged` | `false` | Refresh lists automatically on `*/list_changed` instead of only flagging the indicator | | `paginatedLists` | `false` | Fetch tools/resources/prompts one page at a time instead of auto-aggregating | +| `suppressNotificationStream` | `false` | Streamable HTTP only: don't open the standalone `GET` notification stream, so requests and responses use POST only. Server→client messages outside a request's own response stream won't arrive. A diagnostic and escape hatch for a server that times out every request after `initialize` because it cannot serve a second concurrent request ([#2317](https://github.com/modelcontextprotocol/inspector/issues/2317)) | | `advertisedExtensions` | — | Per-extension overrides for what the Inspector declares in `capabilities.extensions` | | `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited | | `skillCatalogMaxSkills` | `256` | The maximum number of skills whose files are read in one verification run (`SKILL_MAX_CATALOG_SKILLS`) — the CLI's `--verify` and the TUI Skills pane. Positive integer; there is no unlimited value | From 2376420b31dbf1ddb003c3acf53f907bf50ab83d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 13:26:58 -0400 Subject: [PATCH 03/68] fix: inline same-document $refs before choosing form widgets (#2321) A Zod schema instance reused by two fields is emitted once under $defs and referenced by a bare $ref with no type, so the web form rendered the second field as a JSON editor and dropped a plain string typed into it. Inline local refs once in core and use it in the web form schema, core argument conversion and the TUI tool form. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- clients/tui/__tests__/ToolTestModal.test.tsx | 46 +++++ clients/tui/src/components/ToolTestModal.tsx | 14 +- .../groups/SchemaForm/SchemaForm.test.tsx | 28 +++ clients/web/src/test/core/jsonUtils.test.ts | 17 ++ clients/web/src/test/core/localRefs.test.ts | 149 ++++++++++++++++ clients/web/src/utils/jsonUtils.ts | 7 +- core/json/jsonUtils.ts | 5 +- core/json/localRefs.ts | 164 ++++++++++++++++++ 8 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 clients/web/src/test/core/localRefs.test.ts create mode 100644 core/json/localRefs.ts diff --git a/clients/tui/__tests__/ToolTestModal.test.tsx b/clients/tui/__tests__/ToolTestModal.test.tsx index bf9af68835..1bf58296e5 100644 --- a/clients/tui/__tests__/ToolTestModal.test.tsx +++ b/clients/tui/__tests__/ToolTestModal.test.tsx @@ -121,6 +121,52 @@ describe("ToolTestModal", () => { api.unmount(); }); + it("resolves a $ref'd union branch before checking its required arguments (#2321)", async () => { + // Unresolved, a `$ref` branch's requirements read as unknown and the call + // would go out missing `address`; inlined, the branch is checked as written. + const callTool = vi.fn(); + const tool = makeTool({ + inputSchema: { + type: "object", + oneOf: [{ $ref: "#/$defs/Email" }, { $ref: "#/$defs/Sms" }], + $defs: { + Email: { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + address: { type: "string" }, + }, + required: ["kind", "address"], + }, + Sms: { + type: "object", + properties: { + kind: { type: "string", const: "sms" }, + phone: { type: "string" }, + }, + required: ["kind", "phone"], + }, + }, + }, + }); + const api = render( + , + ); + await tick(); + setSubmitValue({ __variant: "0", __b0__kind: "email" }); + api.stdin.write("\r"); + await tick(); + await tick(); + expect(callTool).not.toHaveBeenCalled(); + api.unmount(); + }); + it("names every missing required argument (#2123)", async () => { const callTool = vi.fn(); const tool = makeTool({ diff --git a/clients/tui/src/components/ToolTestModal.tsx b/clients/tui/src/components/ToolTestModal.tsx index 1b0b726af5..4e7f693dff 100644 --- a/clients/tui/src/components/ToolTestModal.tsx +++ b/clients/tui/src/components/ToolTestModal.tsx @@ -11,6 +11,7 @@ import { schemaToForm, } from "../utils/schemaToForm.js"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import { inlineLocalRefs } from "@inspector/core/json/localRefs.js"; interface ToolTestModalProps { tool: Tool; @@ -63,8 +64,13 @@ export function ToolTestModal({ }; }, [width, height]); - const formStructure = tool?.inputSchema - ? schemaToForm(tool.inputSchema, tool.name || "Unknown Tool") + // Same-document `$ref`s inlined once, for the form and the decode alike: a + // property declared as a bare `$ref` has no `type` to build a field from, so + // a deduplicated Zod schema would otherwise lose its string input (#2321). + const inputSchema = inlineLocalRefs(tool?.inputSchema); + + const formStructure = inputSchema + ? schemaToForm(inputSchema, tool?.name || "Unknown Tool") : { title: `Test Tool: ${tool?.name || "Unknown"}`, sections: [{ title: "Parameters", fields: [] }], @@ -125,14 +131,14 @@ export function ToolTestModal({ // field names, because ink-form scopes values by name across the whole form // (#2123). This turns them back into the arguments the server declared: // the base fields plus the chosen branch's, and nothing from the others. - const values = decodeFormValues(tool.inputSchema, rawValues); + const values = decodeFormValues(inputSchema, rawValues); // A branch's fields are rendered optional — only one alternative applies to // a call, and requiring every branch's would deadlock a static form — so // the chosen shape's own requirements are checked here instead. Reported // rather than sent: a call known to violate the schema teaches the user // nothing about the server (#2123). - const missing = missingRequiredFields(tool.inputSchema, values, rawValues); + const missing = missingRequiredFields(inputSchema, values, rawValues); if (missing.length > 0) { setResult({ input: values, diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index e8b23b6ddd..45df1ddc6e 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1067,6 +1067,34 @@ describe("SchemaForm nullable unions", () => { expect(onChange).toHaveBeenCalledWith({ direction: "envio" }); }); + it("renders a string input for a property that is a bare $ref to a string (#2321)", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + // What a Zod → JSON Schema converter emits for one `z.string().regex(…)` + // instance used by two fields: the second use is only a pointer, with no + // `type` of its own. Through `toFormSchema`, as the Tools panel does. + const schema = toFormSchema({ + type: "object", + properties: { + dateRangeBegin: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, + dateRangeEnd: { + $ref: "#/$defs/DateString", + description: "end date, yyyy-MM-dd", + }, + }, + $defs: { + DateString: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, + }, + }); + renderWithMantine( + , + ); + await user.type(screen.getByRole("textbox", { name: "dateRangeEnd" }), "2"); + expect(onChange).toHaveBeenCalledWith({ dateRangeEnd: "2" }); + expect(screen.getByText("end date, yyyy-MM-dd")).toBeInTheDocument(); + expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument(); + }); + it("renders a TextInput for a type: [string, null] field", async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index 12f4877b97..d5adcdc205 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -193,6 +193,23 @@ describe("JSON Utils", () => { }, }; + it("coerces a value whose property is a bare $ref (#2321)", () => { + const refTool: Tool = { + name: "ref-tool", + inputSchema: { + type: "object", + properties: { + first: { type: "integer" }, + second: { $ref: "#/$defs/Count" }, + }, + $defs: { Count: { type: "integer" } }, + }, + }; + expect( + convertToolParameters(refTool, { first: "1", second: "2" }), + ).toEqual({ first: 1, second: 2 }); + }); + it("coerces a value whose schema lives on a root union branch (#2123)", () => { const unionTool: Tool = { name: "union-tool", diff --git a/clients/web/src/test/core/localRefs.test.ts b/clients/web/src/test/core/localRefs.test.ts new file mode 100644 index 0000000000..36c01298ac --- /dev/null +++ b/clients/web/src/test/core/localRefs.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { inlineLocalRefs } from "@inspector/core/json/localRefs.js"; + +// Zod → JSON Schema converters deduplicate a reused schema instance into +// `$defs` and point each use at it with a bare `$ref`, which has no `type` for +// a form builder to dispatch on (#2321). +describe("inlineLocalRefs", () => { + const date = { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }; + + it("inlines a $defs reference, letting the use site's siblings win", () => { + const schema = { + type: "object", + properties: { + end: { $ref: "#/$defs/Date", description: "end date" }, + }, + $defs: { Date: { ...date, description: "a date" } }, + }; + const resolved = inlineLocalRefs(schema); + expect(resolved.properties.end).toEqual({ + ...date, + description: "end date", + }); + // The input is never mutated. + expect(schema.properties.end).toEqual({ + $ref: "#/$defs/Date", + description: "end date", + }); + }); + + it("inlines a definitions reference nested in anyOf and items", () => { + const resolved = inlineLocalRefs({ + type: "object", + properties: { + maybe: { anyOf: [{ $ref: "#/definitions/D" }, { type: "null" }] }, + list: { type: "array", items: { $ref: "#/definitions/D" } }, + }, + definitions: { D: date }, + }); + expect(resolved.properties.maybe.anyOf[0]).toEqual(date); + expect(resolved.properties.list.items).toEqual(date); + }); + + it("resolves chained references", () => { + const resolved = inlineLocalRefs({ + properties: { a: { $ref: "#/$defs/A" } }, + $defs: { A: { $ref: "#/$defs/B" }, B: date }, + }); + expect(resolved.properties.a).toEqual(date); + }); + + it("returns the same reference when there is nothing to inline", () => { + const schema = { type: "object", properties: { a: date } }; + expect(inlineLocalRefs(schema)).toBe(schema); + expect(inlineLocalRefs(null)).toBeNull(); + expect(inlineLocalRefs(["x"])).toEqual(["x"]); + }); + + it("returns the same resolved object for repeated calls", () => { + const schema = { + properties: { a: { $ref: "#/$defs/A" } }, + $defs: { A: date }, + }; + expect(inlineLocalRefs(schema)).toBe(inlineLocalRefs(schema)); + }); + + it("stops at a recursive reference instead of looping", () => { + const resolved = inlineLocalRefs({ + properties: { root: { $ref: "#/$defs/Node" } }, + $defs: { + Node: { + type: "object", + properties: { child: { $ref: "#/$defs/Node" } }, + }, + }, + }); + expect(resolved.properties.root).toEqual({ + type: "object", + properties: { child: { $ref: "#/$defs/Node" } }, + }); + }); + + it("leaves remote, unresolvable and non-object references in place", () => { + const resolved = inlineLocalRefs({ + properties: { + remote: { $ref: "https://example.com/s.json" }, + missing: { $ref: "#/$defs/Nope" }, + badEscape: { $ref: "#/$defs/%E0%A4%A" }, + scalar: { $ref: "#/$defs/S/type" }, + pastEnd: { $ref: "#/$defs/L/5" }, + badIndex: { $ref: "#/$defs/L/01" }, + }, + $defs: { S: date, L: [date] }, + }); + expect(resolved.properties).toEqual({ + remote: { $ref: "https://example.com/s.json" }, + missing: { $ref: "#/$defs/Nope" }, + badEscape: { $ref: "#/$defs/%E0%A4%A" }, + scalar: { $ref: "#/$defs/S/type" }, + pastEnd: { $ref: "#/$defs/L/5" }, + badIndex: { $ref: "#/$defs/L/01" }, + }); + }); + + it("follows array indices, escaped segments and the document root", () => { + const resolved = inlineLocalRefs({ + type: "object", + properties: { + indexed: { $ref: "#/$defs/L/0" }, + escaped: { $ref: "#/$defs/a~1b~0c%20d" }, + self: { anyOf: [{ $ref: "#" }] }, + }, + $defs: { L: [date], "a/b~c d": date }, + }); + expect(resolved.properties.indexed).toEqual(date); + expect(resolved.properties.escaped).toEqual(date); + // `#` is the schema being inlined, so it is kept rather than recursed. + expect(resolved.properties.self.anyOf[0]).toEqual({ + type: "object", + properties: expect.any(Object), + $defs: expect.any(Object), + }); + }); + + it("treats data keywords as data, but property NAMES as schemas", () => { + const pointer = { $ref: "#/$defs/D" }; + const resolved = inlineLocalRefs({ + type: "object", + default: pointer, + properties: { + default: pointer, + enum: pointer, + ["__proto__"]: pointer, + }, + $defs: { D: date }, + }); + expect(resolved.default).toBe(pointer); + expect(resolved.properties.default).toEqual(date); + expect(resolved.properties.enum).toEqual(date); + expect(Object.hasOwn(resolved.properties, "__proto__")).toBe(true); + }); + + it("finds a reference that sits only under a data-keyword-named property", () => { + const schema = { + properties: { const: { $ref: "#/$defs/D" } }, + $defs: { D: date }, + }; + expect(inlineLocalRefs(schema).properties.const).toEqual(date); + }); +}); diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 24a31e91d4..a04396e788 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -2,6 +2,7 @@ import { admitsNull, normalizeNullableUnion, } from "@inspector/core/json/nullableUnion.js"; +import { inlineLocalRefs } from "@inspector/core/json/localRefs.js"; import { branchAcceptsValues, declaresAnyFields, @@ -92,8 +93,10 @@ export function toFormSchema(schema: unknown): InspectorFormSchema | null { } // Structural narrow: the SDK schema's fields are a superset of what the form // reads (`type`, `properties`, `required`, `items`, …); the values the form - // never dereferences don't affect rendering. - return schema as InspectorFormSchema; + // never dereferences don't affect rendering. Same-document `$ref`s are + // inlined first, since every widget is chosen by a property's own `type` and + // a deduplicated Zod schema carries none (#2321). + return inlineLocalRefs(schema) as InspectorFormSchema; } export type DataType = diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index 527f626e68..aee0986ec6 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -1,4 +1,5 @@ import type { Tool } from "@modelcontextprotocol/client"; +import { inlineLocalRefs } from "./localRefs.js"; import { normalizeNullableUnion } from "./nullableUnion.js"; import { narrowBySuppliedNames, @@ -317,8 +318,10 @@ export function convertParametersForSchema( // A property's schema can live on a root composition branch rather than on // the root itself (#2123); see `coercionProperties` for how the branch is // identified when it does. + // Same-document `$ref`s are inlined first: a property declared as a bare + // `$ref` has no `type` of its own to convert by (#2321). const { base, branches } = resolveRootUnion( - (inputSchema ?? {}) as RootUnionSchema, + inlineLocalRefs(inputSchema ?? {}) as RootUnionSchema, ); const properties = coercionProperties(base, branches, params); for (const [key, value] of Object.entries(params)) { diff --git a/core/json/localRefs.ts b/core/json/localRefs.ts new file mode 100644 index 0000000000..1b6a1dc7b5 --- /dev/null +++ b/core/json/localRefs.ts @@ -0,0 +1,164 @@ +/** + * Inlining of same-document `$ref`s (`#/$defs/…`, `#/definitions/…`) into the + * schema that uses them, so a form builder sees the referent's `type`. + * + * Every form builder here — the web `SchemaForm`, the TUI's `schemaToForm` — + * and the argument conversion in {@link ./jsonUtils.ts} dispatch on a + * property's own `type`. Zod → JSON Schema converters deduplicate a schema + * instance used twice by emitting it once under `$defs` and pointing both + * uses at it with a bare `$ref`, which carries no `type` at all. So the second + * of two fields sharing `z.string().regex(…)` fell through to the raw JSON + * editor, and a date typed into it was rejected as invalid JSON and dropped + * from the call (#2321). Resolving here, once, keeps the three consumers from + * disagreeing about which fields are strings. + * + * Deliberately narrow: + * - **Local pointers only.** A remote or relative `$ref` names a document this + * code cannot fetch, and is left in place (the field keeps its JSON editor). + * - **Unresolvable pointers are left in place**, for the same reason. + * - **Recursive references stop at the recursion.** A `$ref` to a schema that + * is already being inlined above it is kept as a `$ref`, so a tree type + * renders its first level and edits the rest as JSON rather than looping. + * - **Sibling keywords win over the referent's.** `{ $ref, description }` is + * exactly what `.optional().describe(…)` on a shared instance produces, and + * the description written at the use site is the one the user should see. + */ + +/** Keywords whose values are data, not subschemas — never walked. */ +const DATA_KEYWORDS = new Set(["const", "default", "enum", "examples"]); + +/** + * Keywords whose values map arbitrary NAMES to subschemas. Their keys are + * user-chosen, so a property called `default` is a schema, not data. + */ +const NAME_MAP_KEYWORDS = new Set([ + "properties", + "patternProperties", + "dependentSchemas", + "$defs", + "definitions", +]); + +type JsonRecord = Record; + +/** Whether `key` holds data rather than a subschema, in a schema object. */ +function isDataKey(key: string, inNameMap: boolean): boolean { + return !inNameMap && DATA_KEYWORDS.has(key); +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** The referent of a `#/…` JSON Pointer within `root`, or `undefined`. */ +function resolvePointer(root: unknown, ref: string): unknown { + if (ref === "#") return root; + if (!ref.startsWith("#/")) return undefined; + let current: unknown = root; + for (const raw of ref.slice(2).split("/")) { + let decoded: string; + try { + // A fragment is URI-encoded (`#/$defs/a%20b`) before it is a pointer. + decoded = decodeURIComponent(raw); + } catch { + return undefined; + } + // RFC 6901 escaping, `~1` before `~0` so `~01` decodes to `~1`. + const segment = decoded.replace(/~1/g, "/").replace(/~0/g, "~"); + if (Array.isArray(current)) { + const index = Number(segment); + if (!/^(0|[1-9]\d*)$/.test(segment) || index >= current.length) { + return undefined; + } + current = current[index]; + } else if (isRecord(current) && Object.hasOwn(current, segment)) { + current = current[segment]; + } else { + return undefined; + } + } + return current; +} + +/** Whether any subschema position in `node` holds a `$ref` string. */ +function containsRef(node: unknown, inNameMap = false): boolean { + if (Array.isArray(node)) return node.some((item) => containsRef(item)); + if (!isRecord(node)) return false; + if (!inNameMap && typeof node.$ref === "string") return true; + return Object.entries(node).some( + ([key, value]) => + !isDataKey(key, inNameMap) && + containsRef(value, !inNameMap && NAME_MAP_KEYWORDS.has(key)), + ); +} + +function inline(node: unknown, root: unknown, active: Set): unknown { + if (Array.isArray(node)) { + return node.map((item) => inline(item, root, active)); + } + if (!isRecord(node)) return node; + + const ref = node.$ref; + if (typeof ref === "string" && !active.has(ref)) { + const target = resolvePointer(root, ref); + if (isRecord(target)) { + active.add(ref); + const resolved = inline(target, root, active) as JsonRecord; + active.delete(ref); + const siblings: JsonRecord = { ...node }; + delete siblings.$ref; + return { ...resolved, ...inlineEntries(siblings, root, active, false) }; + } + } + return inlineEntries(node, root, active, false); +} + +function inlineEntries( + node: JsonRecord, + root: unknown, + active: Set, + inNameMap: boolean, +): JsonRecord { + const result: JsonRecord = {}; + for (const [key, value] of Object.entries(node)) { + let next: unknown = value; + if (inNameMap) { + next = inline(value, root, active); + } else if (NAME_MAP_KEYWORDS.has(key) && isRecord(value)) { + next = inlineEntries(value, root, active, true); + } else if (!isDataKey(key, false)) { + next = inline(value, root, active); + } + // `defineProperty`, not assignment: `__proto__` is a legal property name + // in a schema's `properties`, and assigning it would set the prototype. + Object.defineProperty(result, key, { + value: next, + writable: true, + enumerable: true, + configurable: true, + }); + } + return result; +} + +// Keyed on the input object: form panels call this on every render, and a +// fresh tree each time would defeat anything downstream keyed on identity. +const cache = new WeakMap(); + +/** + * `schema` with every resolvable same-document `$ref` replaced by its referent. + * + * Returns `schema` itself — same reference — when it contains no `$ref`, and + * the same resolved object for repeated calls with the same input. Never + * mutates the input. + */ +export function inlineLocalRefs(schema: T): T { + if (!isRecord(schema) || !containsRef(schema)) return schema; + const cached = cache.get(schema); + if (cached !== undefined) return cached as T; + // The result is the input's own shape with references expanded, so it is + // still a `T` to every caller that reads it as one. + const resolved = inline(schema, schema, new Set()) as T; + cache.set(schema, resolved); + return resolved; +} From b95e60fa7a5b3c622f607fb4a6c5f32f403f268f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 13:32:35 -0400 Subject: [PATCH 04/68] fix: stamp Mcp-Method on modern-era notification POSTs (#2385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-28 Streamable HTTP transport requires Mcp-Method on every POSTed message, notifications included, but the SDK derives the standard headers for requests only. Closing a subscriptions/listen stream — which every modern resource unsubscribe does — POSTs a notifications/cancelled without it, and a strict server refuses it with 400. Wrap the Streamable HTTP fetch so a single JSON-RPC notification carrying a modern protocol-version _meta claim gains Mcp-Method and MCP-Protocol-Version, mirroring the SDK's own request rule. Legacy and unclaimed messages pass through untouched. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- .../mcp/node/notificationHeadersFetch.test.ts | 120 ++++++++++++++++++ .../inspectorClient-subscriptions-era.test.ts | 60 ++++++++- core/mcp/node/notificationHeadersFetch.ts | 68 ++++++++++ core/mcp/node/transport.ts | 5 +- 4 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 clients/web/src/test/core/mcp/node/notificationHeadersFetch.test.ts create mode 100644 core/mcp/node/notificationHeadersFetch.ts diff --git a/clients/web/src/test/core/mcp/node/notificationHeadersFetch.test.ts b/clients/web/src/test/core/mcp/node/notificationHeadersFetch.test.ts new file mode 100644 index 0000000000..0df1c0eb97 --- /dev/null +++ b/clients/web/src/test/core/mcp/node/notificationHeadersFetch.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from "vitest"; +import { PROTOCOL_VERSION_META_KEY } from "@modelcontextprotocol/client"; +import { createNotificationHeadersFetch } from "@inspector/core/mcp/node/notificationHeadersFetch.js"; +import { MODERN_PROTOCOL_VERSION } from "@inspector/core/mcp/types.js"; + +const URL_ = "https://example.com/mcp"; + +function cancelled(version: string | undefined): Record { + return { + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { + requestId: 7, + ...(version !== undefined && { + _meta: { [PROTOCOL_VERSION_META_KEY]: version }, + }), + }, + }; +} + +/** Run one call through the wrapper; return the init the base fetch saw. */ +async function send(init: RequestInit | undefined): Promise { + const baseFetch = vi.fn( + async () => new Response(null, { status: 202 }), + ); + await createNotificationHeadersFetch(baseFetch)(URL_, init); + expect(baseFetch).toHaveBeenCalledTimes(1); + return baseFetch.mock.calls[0]![1] ?? {}; +} + +function post(body: unknown, headers?: HeadersInit): RequestInit { + return { + method: "POST", + body: typeof body === "string" ? body : JSON.stringify(body), + headers, + }; +} + +describe("createNotificationHeadersFetch", () => { + it("stamps Mcp-Method and MCP-Protocol-Version on a modern notification", async () => { + const seen = await send( + post(cancelled(MODERN_PROTOCOL_VERSION), { + "content-type": "application/json", + "mcp-session-id": "abc", + }), + ); + const headers = new Headers(seen.headers); + expect(headers.get("mcp-method")).toBe("notifications/cancelled"); + expect(headers.get("mcp-protocol-version")).toBe(MODERN_PROTOCOL_VERSION); + // Existing headers and the body survive. + expect(headers.get("mcp-session-id")).toBe("abc"); + expect(headers.get("content-type")).toBe("application/json"); + expect(seen.body).toBe(JSON.stringify(cancelled(MODERN_PROTOCOL_VERSION))); + expect(headers.has("mcp-name")).toBe(false); + }); + + it("accepts a Headers instance and a lowercase method", async () => { + const init = post( + cancelled(MODERN_PROTOCOL_VERSION), + new Headers({ accept: "application/json" }), + ); + const seen = await send({ ...init, method: "post" }); + const headers = new Headers(seen.headers); + expect(headers.get("mcp-method")).toBe("notifications/cancelled"); + expect(headers.get("accept")).toBe("application/json"); + }); + + it("stamps a revision later than 2026-07-28 too", async () => { + const seen = await send(post(cancelled("2027-01-01"))); + expect(new Headers(seen.headers).get("mcp-protocol-version")).toBe( + "2027-01-01", + ); + }); + + const untouched: [string, RequestInit | undefined][] = [ + ["no init", undefined], + ["a GET", { method: "GET" }], + [ + "a POST with no method", + { body: JSON.stringify(cancelled("2026-07-28")) }, + ], + ["a non-string body", { method: "POST", body: new Blob(["{}"]) }], + ["a non-JSON body", post("not json")], + ["a JSON non-object body", post([cancelled(MODERN_PROTOCOL_VERSION)])], + ["a null body", post("null")], + [ + "a request (has an id)", + post({ ...cancelled(MODERN_PROTOCOL_VERSION), id: 1 }), + ], + ["a response (no method)", post({ jsonrpc: "2.0", result: {} })], + [ + "a non-string method", + post({ ...cancelled(MODERN_PROTOCOL_VERSION), method: 5 }), + ], + ["a legacy-era notification", post(cancelled("2025-11-25"))], + ["an unclaimed notification", post(cancelled(undefined))], + [ + "a notification with no params", + post({ jsonrpc: "2.0", method: "notifications/initialized" }), + ], + [ + "a notification whose _meta is not an object", + post({ jsonrpc: "2.0", method: "x", params: { _meta: "nope" } }), + ], + [ + "a non-string protocol version claim", + post({ + jsonrpc: "2.0", + method: "x", + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 20260728 } }, + }), + ], + ]; + + it.each(untouched)("passes %s through untouched", async (_label, init) => { + const seen = await send(init); + expect(seen).toEqual(init ?? {}); + expect(new Headers(seen.headers).has("mcp-method")).toBe(false); + }); +}); 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 d41f0da28e..e724d8d5ce 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 @@ -1,7 +1,10 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; -import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; +import { + eraToVersionNegotiation, + MODERN_PROTOCOL_VERSION, +} from "@inspector/core/mcp/types.js"; import type { McpSubscription } from "@modelcontextprotocol/client"; import { createTestServerHttp, @@ -101,6 +104,19 @@ describe("resource subscriptions era fork (#1630)", () => { return { connected, messages }; } + /** A fetch that records each string-bodied request's body and final headers. */ + function recordingFetch( + inner: typeof fetch, + sent: { body: string; headers: Headers }[], + ): typeof fetch { + return (input, init) => { + if (typeof init?.body === "string") { + sent.push({ body: init.body, headers: new Headers(init.headers) }); + } + return inner(input, init); + }; + } + function methodsSent(messages: MessageEntry[]): string[] { return messages .filter((m) => m.direction === "request") @@ -196,6 +212,48 @@ describe("resource subscriptions era fork (#1630)", () => { expect(methodsSent(messages)).not.toContain("subscriptions/listen"); }); + it("sends the listen stream's notifications/cancelled with Mcp-Method (#2385)", async () => { + // The SDK POSTs a `notifications/cancelled` when a listen stream closes, + // and stamps the SEP-2243 headers on requests only — so a strict modern + // server refused the unsubscribe `400 Mcp-Method is required`. The test + // server is lenient, so the wire headers are asserted directly. + const started = await startServer({}); + const sent: { body: string; headers: Headers }[] = []; + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { + transport: (config, options) => + createTransportNode(config, { + ...options, + fetchFn: recordingFetch( + options?.fetchFn ?? globalThis.fetch, + sent, + ), + }), + }, + versionNegotiation: eraToVersionNegotiation("modern"), + listChangedNotifications: NO_LIST_CHANGED, + }, + ); + client = connected; + await connected.connect(); + await connected.subscribeToResource(RESOURCE_URI); + await connected.unsubscribeFromResource(RESOURCE_URI); + + await vi.waitFor(() => { + const cancelled = sent.find((request) => + request.body.includes('"notifications/cancelled"'), + ); + expect(cancelled?.headers.get("mcp-method")).toBe( + "notifications/cancelled", + ); + expect(cancelled?.headers.get("mcp-protocol-version")).toBe( + MODERN_PROTOCOL_VERSION, + ); + }); + }); + 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 diff --git a/core/mcp/node/notificationHeadersFetch.ts b/core/mcp/node/notificationHeadersFetch.ts new file mode 100644 index 0000000000..bdfb807133 --- /dev/null +++ b/core/mcp/node/notificationHeadersFetch.ts @@ -0,0 +1,68 @@ +import { PROTOCOL_VERSION_META_KEY } from "@modelcontextprotocol/client"; +import { MODERN_PROTOCOL_VERSION } from "../types.js"; + +/** + * Wrap fetch so a modern-era JSON-RPC **notification** POST carries the + * SEP-2243 standard headers the SDK only stamps on requests (#2385). + * + * The 2026-07-28 Streamable HTTP transport requires `Mcp-Method` on every + * POSTed message — "Notifications also require the `Mcp-Method` header" — but + * the SDK's `_applyBodyDerivedHeaders` returns early for anything that is not + * a request. So the `notifications/cancelled` the SDK sends when a + * `subscriptions/listen` stream closes (every resource unsubscribe re-listens) + * reached a strict server with no `Mcp-Method` and was refused `400 Header + * mismatch: Mcp-Method is required`. + * + * This mirrors the SDK's own request rule exactly, so the two cannot disagree + * about era: the message's `_meta` protocol-version claim is the signal, and a + * message without a modern claim is passed through untouched — a legacy + * exchange never gains a 2026 header. `Mcp-Name` is not added: the spec + * requires it only for `tools/call`, `resources/read` and `prompts/get` + * requests. + * + * Remove once the SDK stamps notifications itself; this wrapper then sets the + * same values the SDK already did. + */ +export function createNotificationHeadersFetch( + baseFetch: typeof fetch, +): typeof fetch { + return (input, init) => { + const method = modernNotificationMethod(init); + if (method === undefined) return baseFetch(input, init); + const headers = new Headers(init?.headers); + headers.set("mcp-protocol-version", method.version); + headers.set("mcp-method", method.method); + return baseFetch(input, { ...init, headers }); + }; +} + +/** + * The method and modern protocol version of a single JSON-RPC notification in + * a POST body, or `undefined` for anything else (a request, a response, a + * batch, a legacy or unclaimed message, a non-string or non-JSON body). + */ +function modernNotificationMethod( + init: RequestInit | undefined, +): { method: string; version: string } | undefined { + if (init?.method?.toUpperCase() !== "POST") return undefined; + if (typeof init.body !== "string") return undefined; + let message: unknown; + try { + message = JSON.parse(init.body); + } catch { + return undefined; + } + if (!isRecord(message) || "id" in message) return undefined; + if (typeof message.method !== "string") return undefined; + const meta = isRecord(message.params) ? message.params._meta : undefined; + const version = isRecord(meta) ? meta[PROTOCOL_VERSION_META_KEY] : undefined; + // Dated revision tokens order lexically — the SDK's own era test. + if (typeof version !== "string" || version < MODERN_PROTOCOL_VERSION) { + return undefined; + } + return { method: message.method, version }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts index 08792f0c6c..1a49c26214 100644 --- a/core/mcp/node/transport.ts +++ b/core/mcp/node/transport.ts @@ -17,6 +17,7 @@ import { createAuthChallengeObserverFetch, } from "./authChallengeFetch.js"; import { createProxyFetch } from "./proxyFetch.js"; +import { createNotificationHeadersFetch } from "./notificationHeadersFetch.js"; /** * Build the wire `headers` record from `settings.headers`, dropping rows with @@ -172,10 +173,12 @@ export function createTransportNode( ...(headers && { headers }), }; + // Outermost, so the network tracker below records the headers that are + // actually sent (#2385). const transport = new StreamableHTTPClientTransport(url, { authProvider, requestInit, - fetch: fetchWithOptionalAuthIntercept, + fetch: createNotificationHeadersFetch(fetchWithOptionalAuthIntercept), // SEP-2350: how the transport reacts to a `403 insufficient_scope` // challenge. Defaults to the SDK's `reauthorize` when unset. ...(settings?.oauthOnInsufficientScope && { From 2b5ddad3321810462f2061373aaea20d9951e640 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 13:34:26 -0400 Subject: [PATCH 05/68] fix: decode the whole $ref fragment before splitting; walk legacy dependencies (#2391 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- clients/web/src/test/core/localRefs.test.ts | 21 +++++++++++++++- core/json/localRefs.ts | 28 +++++++++++++-------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/clients/web/src/test/core/localRefs.test.ts b/clients/web/src/test/core/localRefs.test.ts index 36c01298ac..e7397c258b 100644 --- a/clients/web/src/test/core/localRefs.test.ts +++ b/clients/web/src/test/core/localRefs.test.ts @@ -83,6 +83,7 @@ describe("inlineLocalRefs", () => { const resolved = inlineLocalRefs({ properties: { remote: { $ref: "https://example.com/s.json" }, + notPointer: { $ref: "#Anchor" }, missing: { $ref: "#/$defs/Nope" }, badEscape: { $ref: "#/$defs/%E0%A4%A" }, scalar: { $ref: "#/$defs/S/type" }, @@ -93,6 +94,7 @@ describe("inlineLocalRefs", () => { }); expect(resolved.properties).toEqual({ remote: { $ref: "https://example.com/s.json" }, + notPointer: { $ref: "#Anchor" }, missing: { $ref: "#/$defs/Nope" }, badEscape: { $ref: "#/$defs/%E0%A4%A" }, scalar: { $ref: "#/$defs/S/type" }, @@ -107,12 +109,16 @@ describe("inlineLocalRefs", () => { properties: { indexed: { $ref: "#/$defs/L/0" }, escaped: { $ref: "#/$defs/a~1b~0c%20d" }, + encodedSlashes: { $ref: "#%2F$defs%2FL%2F0" }, + encodedSeparator: { $ref: "#/$defs/N%2Finner" }, self: { anyOf: [{ $ref: "#" }] }, }, - $defs: { L: [date], "a/b~c d": date }, + $defs: { L: [date], "a/b~c d": date, N: { inner: date } }, }); expect(resolved.properties.indexed).toEqual(date); expect(resolved.properties.escaped).toEqual(date); + expect(resolved.properties.encodedSlashes).toEqual(date); + expect(resolved.properties.encodedSeparator).toEqual(date); // `#` is the schema being inlined, so it is kept rather than recursed. expect(resolved.properties.self.anyOf[0]).toEqual({ type: "object", @@ -139,6 +145,19 @@ describe("inlineLocalRefs", () => { expect(Object.hasOwn(resolved.properties, "__proto__")).toBe(true); }); + it("walks the legacy dependencies map by name, passing name lists through", () => { + const resolved = inlineLocalRefs({ + type: "object", + dependencies: { + default: { properties: { x: { $ref: "#/$defs/D" } } }, + other: ["default"], + }, + $defs: { D: date }, + }); + expect(resolved.dependencies.default.properties.x).toEqual(date); + expect(resolved.dependencies.other).toEqual(["default"]); + }); + it("finds a reference that sits only under a data-keyword-named property", () => { const schema = { properties: { const: { $ref: "#/$defs/D" } }, diff --git a/core/json/localRefs.ts b/core/json/localRefs.ts index 1b6a1dc7b5..929b3906ba 100644 --- a/core/json/localRefs.ts +++ b/core/json/localRefs.ts @@ -35,6 +35,9 @@ const NAME_MAP_KEYWORDS = new Set([ "properties", "patternProperties", "dependentSchemas", + // Pre-2019 spelling of `dependentSchemas` (its array values are name lists, + // which the walk passes through untouched). + "dependencies", "$defs", "definitions", ]); @@ -52,19 +55,22 @@ function isRecord(value: unknown): value is JsonRecord { /** The referent of a `#/…` JSON Pointer within `root`, or `undefined`. */ function resolvePointer(root: unknown, ref: string): unknown { - if (ref === "#") return root; - if (!ref.startsWith("#/")) return undefined; + if (!ref.startsWith("#")) return undefined; + let pointer: string; + try { + // The whole fragment is URI-decoded BEFORE it is split into tokens (RFC + // 6901 §6): `#%2F$defs%2FDate` is the pointer `/$defs/Date`, and + // `#/a%2Fb` is the path `a` → `b`. A literal `/` inside a name is `~1`. + pointer = decodeURIComponent(ref.slice(1)); + } catch { + return undefined; + } + if (pointer === "") return root; + if (!pointer.startsWith("/")) return undefined; let current: unknown = root; - for (const raw of ref.slice(2).split("/")) { - let decoded: string; - try { - // A fragment is URI-encoded (`#/$defs/a%20b`) before it is a pointer. - decoded = decodeURIComponent(raw); - } catch { - return undefined; - } + for (const token of pointer.slice(1).split("/")) { // RFC 6901 escaping, `~1` before `~0` so `~01` decodes to `~1`. - const segment = decoded.replace(/~1/g, "/").replace(/~0/g, "~"); + const segment = token.replace(/~1/g, "/").replace(/~0/g, "~"); if (Array.isArray(current)) { const index = Number(segment); if (!/^(0|[1-9]\d*)$/.test(segment) || index >= current.length) { From 134723e2cfcf878730be2911ee9b51bebf4db59c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 13:37:34 -0400 Subject: [PATCH 06/68] docs: frame the notification header stamping as a compatibility workaround (#2392 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- core/mcp/node/notificationHeadersFetch.ts | 24 +++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/core/mcp/node/notificationHeadersFetch.ts b/core/mcp/node/notificationHeadersFetch.ts index bdfb807133..0f9633b177 100644 --- a/core/mcp/node/notificationHeadersFetch.ts +++ b/core/mcp/node/notificationHeadersFetch.ts @@ -5,23 +5,27 @@ import { MODERN_PROTOCOL_VERSION } from "../types.js"; * Wrap fetch so a modern-era JSON-RPC **notification** POST carries the * SEP-2243 standard headers the SDK only stamps on requests (#2385). * - * The 2026-07-28 Streamable HTTP transport requires `Mcp-Method` on every - * POSTed message — "Notifications also require the `Mcp-Method` header" — but - * the SDK's `_applyBodyDerivedHeaders` returns early for anything that is not - * a request. So the `notifications/cancelled` the SDK sends when a - * `subscriptions/listen` stream closes (every resource unsubscribe re-listens) - * reached a strict server with no `Mcp-Method` and was refused `400 Header - * mismatch: Mcp-Method is required`. + * This is a compatibility workaround, not a protocol mandate. The 2026-07-28 + * Streamable HTTP spec defines no client-to-server notifications — closing the + * SSE stream is the cancellation signal, and "header requirements for + * notification POSTs are not defined by this revision". The SDK nonetheless + * POSTs a `notifications/cancelled` whenever a `subscriptions/listen` stream + * closes (every resource unsubscribe re-listens), and its + * `_applyBodyDerivedHeaders` stamps nothing on a non-request. Servers that + * apply their request-header validation to every POST — as SEP-2243's draft + * example did for notifications — refused it `400 Header mismatch: Mcp-Method + * is required`, failing the unsubscribe. Stamping the headers is harmless to a + * server that ignores them. * * This mirrors the SDK's own request rule exactly, so the two cannot disagree * about era: the message's `_meta` protocol-version claim is the signal, and a * message without a modern claim is passed through untouched — a legacy * exchange never gains a 2026 header. `Mcp-Name` is not added: the spec - * requires it only for `tools/call`, `resources/read` and `prompts/get` + * defines it only for `tools/call`, `resources/read` and `prompts/get` * requests. * - * Remove once the SDK stamps notifications itself; this wrapper then sets the - * same values the SDK already did. + * Remove once the SDK stops POSTing that notification on Streamable HTTP, or + * stamps it itself. */ export function createNotificationHeadersFetch( baseFetch: typeof fetch, From 956bf1358bde0bae7224f388549bc9e2a543c963 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 13:39:03 -0400 Subject: [PATCH 07/68] fix: detach the OIDC compat's normal-path body releases (#2389) Neither the non-OK probe's release nor the substituted original's is awaited any more, so a body cancel that never settles can no longer stall discovery or keep the caller's abort from reaching it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- .../core/auth/oidcDiscoveryCompat.test.ts | 71 +++++++++++++++++++ core/auth/oidcDiscoveryCompat.ts | 29 +++++--- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts index c9ea94b987..5cc8d20116 100644 --- a/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts +++ b/clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts @@ -608,6 +608,77 @@ describe("probe cancellation (#2319)", () => { ).rejects.toBe(reason); }); + /** + * A response whose body `cancel()` never settles — the shape `ReadableStream` + * permits — running `onCancel` when the release starts. + */ + function stallingCancelResponse( + status: number, + text: string, + onCancel: () => void = () => {}, + ): Response { + return new Response( + new ReadableStream({ + start(ctrl) { + ctrl.enqueue(new TextEncoder().encode(text)); + ctrl.close(); + }, + cancel() { + onCancel(); + return new Promise(() => {}); + }, + }), + { status }, + ); + } + + it("rejects with the caller's reason when it aborts during a non-OK probe's release (#2389)", async () => { + const caller = new AbortController(); + const reason = new Error("gave up during release"); + const inner = vi.fn((input) => + Promise.resolve( + String(input) === RFC8414 + ? new Response(null, { status: 404 }) + : // The abort lands while the probe's body is being discarded. + stallingCancelResponse(404, "nope", () => caller.abort(reason)), + ), + ); + const wrapped = withRfc8414OidcCompat(inner); + + // Would hang if the release were awaited: the recheck sits after it. + await expect(wrapped(RFC8414, { signal: caller.signal })).rejects.toBe( + reason, + ); + // Stopped at the first candidate rather than probing on. + expect(inner).toHaveBeenCalledTimes(2); + }); + + it("walks past a non-OK probe whose release never settles (#2389)", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const cancelled: string[] = []; + const inner = vi.fn((input) => { + const url = String(input); + if (url === OIDC_APPENDED) return Promise.resolve(json(RFC8414_DOC)); + if (url === OIDC_SUFFIXED) { + return Promise.resolve( + stallingCancelResponse(404, "nope", () => cancelled.push("probe")), + ); + } + return Promise.resolve( + stallingCancelResponse(404, "nope", () => cancelled.push("original")), + ); + }); + const wrapped = withRfc8414OidcCompat(inner); + + // Reaching the second candidate and substituting it is only possible if + // neither the probe's release nor the original's was waited on. + const response = await wrapped(RFC8414); + await expect(response.json()).resolves.toEqual(RFC8414_DOC); + expect(inner).toHaveBeenCalledTimes(3); + // Still released, just not waited on. + expect(cancelled.sort()).toEqual(["original", "probe"]); + }); + it("still falls back to the original response on an ordinary probe failure", async () => { const inner = vi.fn((input) => { if (String(input) === RFC8414) { diff --git a/core/auth/oidcDiscoveryCompat.ts b/core/auth/oidcDiscoveryCompat.ts index ce0ace8766..e92a1971de 100644 --- a/core/auth/oidcDiscoveryCompat.ts +++ b/core/auth/oidcDiscoveryCompat.ts @@ -176,9 +176,10 @@ async function releaseBody(response: Response): Promise { * ⚠️ `ReadableStream.cancel()` adopts the underlying source's cancel promise, * which is permitted never to settle — so awaiting it on a path that is * *propagating a cancellation* can hang the very thing that was meant to end - * (Copilot). Every exceptional exit below uses this: the release is a courtesy - * to the connection pool, and the caller's abort or timeout must reach it - * regardless. The `void` is the documented case where the callee owns its + * (Copilot), and awaiting it on the normal path leaves the caller's abort + * unable to end a stalled release (#2389). Every release below uses this: the + * release is a courtesy to the connection pool, and neither progress nor the + * caller's abort or timeout may wait on it. The `void` is the documented case where the callee owns its * failures — `releaseBody` swallows its own — and the caller genuinely cannot * await. */ @@ -334,12 +335,16 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // Node/undici, and this loop can run on every OAuth attempt — so // release it rather than letting repeated discovery against a 404 // candidate exhaust the origin's pool (Copilot). Same discipline as - // `core/mcp/node/authChallengeFetch.ts`. - await releaseBody(probe); - // Rechecked after the release, which awaits: an abort that lands while - // the body is being discarded must not be answered with the preceding - // response either, and `continue` would otherwise carry on probing for - // a caller that has stopped waiting. + // `core/mcp/node/authChallengeFetch.ts`. Detached, not awaited + // (#2389): the next step — the abort check, the next candidate — must + // not depend on a cancel that is permitted never to settle, or a + // stalled release would hang discovery with the caller's abort unable + // to end it. + releaseBodyDetached(probe); + // Rechecked before moving on: an abort that landed while the probe was + // answering must not be met with the preceding response either, and + // `continue` would otherwise carry on probing for a caller that has + // stopped waiting. if (callerSignal?.aborted) { releaseBodyDetached(response); throw callerSignal.reason; @@ -386,8 +391,10 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch { // (Copilot). const source = probe.url || candidate; // The original failed response is about to be dropped in favour of the - // substitution, so release its connection too. - await releaseBody(response); + // substitution, so release its connection too — detached, for the same + // reason as the non-OK probe above (#2389): the substitution must not + // wait on a cancel that may never settle. + releaseBodyDetached(response); console.warn( `[oauth] ${source} returned RFC 8414 OAuth 2.0 authorization server ` + `metadata, not an OpenID provider document. The MCP TypeScript SDK ` + From 8d682ed2df16bbd5f5887c2014f2d4d2e3b8fde6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 13:52:24 -0400 Subject: [PATCH 08/68] fix: keep constraining $ref siblings, skip embedded resources, bound expansion (#2391 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- clients/web/src/test/core/localRefs.test.ts | 68 +++++++++++- core/json/localRefs.ts | 112 ++++++++++++++++---- 2 files changed, 159 insertions(+), 21 deletions(-) diff --git a/clients/web/src/test/core/localRefs.test.ts b/clients/web/src/test/core/localRefs.test.ts index e7397c258b..acf08be08b 100644 --- a/clients/web/src/test/core/localRefs.test.ts +++ b/clients/web/src/test/core/localRefs.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import { inlineLocalRefs } from "@inspector/core/json/localRefs.js"; +import { + EXPANSION_BUDGET, + inlineLocalRefs, +} from "@inspector/core/json/localRefs.js"; // Zod → JSON Schema converters deduplicate a reused schema instance into // `$defs` and point each use at it with a bare `$ref`, which has no `type` for @@ -165,4 +168,67 @@ describe("inlineLocalRefs", () => { }; expect(inlineLocalRefs(schema).properties.const).toEqual(date); }); + it("merges annotation siblings but declines a $ref whose siblings constrain", () => { + const resolved = inlineLocalRefs({ + properties: { + annotated: { $ref: "#/$defs/E", title: "T", default: "a" }, + widened: { $ref: "#/$defs/E", enum: ["a", "b"] }, + }, + $defs: { E: { type: "string", enum: ["a"] } }, + }); + expect(resolved.properties.annotated).toEqual({ + type: "string", + enum: ["a"], + title: "T", + default: "a", + }); + // Conjunctive in JSON Schema, so a merge would admit "b"; left as written. + expect(resolved.properties.widened).toEqual({ + $ref: "#/$defs/E", + enum: ["a", "b"], + }); + }); + + it("inlines a root $ref beside its definitions", () => { + const resolved = inlineLocalRefs({ + $ref: "#/definitions/Args", + definitions: { Args: { type: "object", properties: { a: date } } }, + }); + expect(resolved).toMatchObject({ + type: "object", + properties: { a: date }, + }); + }); + + it("leaves everything under a nested $id unresolved", () => { + const embedded = { + $id: "https://example.com/inner", + properties: { x: { $ref: "#/$defs/D" } }, + $defs: { D: { type: "integer" } }, + }; + const resolved = inlineLocalRefs({ + $id: "https://example.com/outer", + properties: { outer: { $ref: "#/$defs/D" }, inner: embedded }, + $defs: { D: date }, + }); + expect(resolved.properties.outer).toEqual(date); + expect(resolved.properties.inner).toBe(embedded); + }); + + it("returns the schema unresolved when expansion would exceed the budget", () => { + // Each level uses the previous one twice: 2^20 nodes from 20 definitions. + const $defs: Record = { L0: { type: "string" } }; + for (let n = 1; n <= 20; n++) { + $defs[`L${n}`] = { + type: "object", + properties: { + a: { $ref: `#/$defs/L${n - 1}` }, + b: { $ref: `#/$defs/L${n - 1}` }, + }, + }; + } + const schema = { properties: { top: { $ref: "#/$defs/L20" } }, $defs }; + expect(2 ** 20).toBeGreaterThan(EXPANSION_BUDGET); + expect(inlineLocalRefs(schema)).toBe(schema); + }); }); diff --git a/core/json/localRefs.ts b/core/json/localRefs.ts index 929b3906ba..359aa7c008 100644 --- a/core/json/localRefs.ts +++ b/core/json/localRefs.ts @@ -19,9 +19,21 @@ * - **Recursive references stop at the recursion.** A `$ref` to a schema that * is already being inlined above it is kept as a `$ref`, so a tree type * renders its first level and edits the rest as JSON rather than looping. - * - **Sibling keywords win over the referent's.** `{ $ref, description }` is - * exactly what `.optional().describe(…)` on a shared instance produces, and - * the description written at the use site is the one the user should see. + * - **Only annotation siblings are merged.** `{ $ref, description }` is exactly + * what `.optional().describe(…)` on a shared instance produces, and the + * description written at the use site is the one the user should see. A + * sibling that *constrains* (`enum`, `minLength`, …) applies in conjunction + * with the referent rather than replacing it, which a merge cannot express — + * so a `$ref` carrying one is left unresolved rather than loosened. + * - **Embedded resources are left alone.** A nested `$id` starts a new base + * URI, and a `#/…` pointer beneath it means that resource's root, not the + * document's. Rather than track bases, nothing under a nested `$id` is + * resolved. + * - **Expansion is bounded.** Inlining copies a referent at every use, so a + * chain of definitions each using the previous one twice grows as `2^n` + * from an `O(n)` schema. A server controls the schema, so past + * {@link EXPANSION_BUDGET} nodes the whole schema is returned unresolved + * rather than freezing the form that renders it. */ /** Keywords whose values are data, not subschemas — never walked. */ @@ -42,8 +54,47 @@ const NAME_MAP_KEYWORDS = new Set([ "definitions", ]); +/** + * Keywords that may sit beside a `$ref` without blocking its inlining: they + * annotate or organize, and constrain nothing, so the use site's value can + * safely replace the referent's. + */ +const NON_CONSTRAINT_SIBLINGS = new Set([ + "title", + "description", + "default", + "examples", + "deprecated", + "readOnly", + "writeOnly", + "$comment", + "$schema", + "$id", + "$defs", + "definitions", +]); + +/** Most schema nodes one inlining may produce before it gives up. */ +export const EXPANSION_BUDGET = 10_000; + +/** Thrown to unwind a traversal that has spent {@link EXPANSION_BUDGET}. */ +class BudgetExceeded extends Error {} + type JsonRecord = Record; +interface Traversal { + root: JsonRecord; + /** References being inlined above the current node, for cycle detection. */ + active: Set; + remaining: number; +} + +/** Charge one produced node against the traversal's budget. */ +function spend(traversal: Traversal): void { + traversal.remaining -= 1; + if (traversal.remaining < 0) throw new BudgetExceeded(); +} + /** Whether `key` holds data rather than a subschema, in a schema object. */ function isDataKey(key: string, inNameMap: boolean): boolean { return !inNameMap && DATA_KEYWORDS.has(key); @@ -98,42 +149,52 @@ function containsRef(node: unknown, inNameMap = false): boolean { ); } -function inline(node: unknown, root: unknown, active: Set): unknown { +function inline(node: unknown, traversal: Traversal): unknown { if (Array.isArray(node)) { - return node.map((item) => inline(item, root, active)); + spend(traversal); + return node.map((item) => inline(item, traversal)); } if (!isRecord(node)) return node; + // An embedded resource: its pointers are relative to itself (see the header). + if (node !== traversal.root && typeof node.$id === "string") return node; + spend(traversal); const ref = node.$ref; - if (typeof ref === "string" && !active.has(ref)) { - const target = resolvePointer(root, ref); + const siblingsConstrain = Object.keys(node).some( + (key) => key !== "$ref" && !NON_CONSTRAINT_SIBLINGS.has(key), + ); + if ( + typeof ref === "string" && + !siblingsConstrain && + !traversal.active.has(ref) + ) { + const target = resolvePointer(traversal.root, ref); if (isRecord(target)) { - active.add(ref); - const resolved = inline(target, root, active) as JsonRecord; - active.delete(ref); + traversal.active.add(ref); + const resolved = inline(target, traversal) as JsonRecord; + traversal.active.delete(ref); const siblings: JsonRecord = { ...node }; delete siblings.$ref; - return { ...resolved, ...inlineEntries(siblings, root, active, false) }; + return { ...resolved, ...inlineEntries(siblings, traversal, false) }; } } - return inlineEntries(node, root, active, false); + return inlineEntries(node, traversal, false); } function inlineEntries( node: JsonRecord, - root: unknown, - active: Set, + traversal: Traversal, inNameMap: boolean, ): JsonRecord { const result: JsonRecord = {}; for (const [key, value] of Object.entries(node)) { let next: unknown = value; if (inNameMap) { - next = inline(value, root, active); + next = inline(value, traversal); } else if (NAME_MAP_KEYWORDS.has(key) && isRecord(value)) { - next = inlineEntries(value, root, active, true); + next = inlineEntries(value, traversal, true); } else if (!isDataKey(key, false)) { - next = inline(value, root, active); + next = inline(value, traversal); } // `defineProperty`, not assignment: `__proto__` is a legal property name // in a schema's `properties`, and assigning it would set the prototype. @@ -154,8 +215,8 @@ const cache = new WeakMap(); /** * `schema` with every resolvable same-document `$ref` replaced by its referent. * - * Returns `schema` itself — same reference — when it contains no `$ref`, and - * the same resolved object for repeated calls with the same input. Never + * Returns `schema` itself — same reference — when it contains no `$ref` or + * inlining would exceed {@link EXPANSION_BUDGET}, and the same resolved object for repeated calls with the same input. Never * mutates the input. */ export function inlineLocalRefs(schema: T): T { @@ -164,7 +225,18 @@ export function inlineLocalRefs(schema: T): T { if (cached !== undefined) return cached as T; // The result is the input's own shape with references expanded, so it is // still a `T` to every caller that reads it as one. - const resolved = inline(schema, schema, new Set()) as T; + let resolved: T; + try { + resolved = inline(schema, { + root: schema, + active: new Set(), + remaining: EXPANSION_BUDGET, + }) as T; + } catch (error) { + /* v8 ignore next -- only BudgetExceeded is thrown by the traversal */ + if (!(error instanceof BudgetExceeded)) throw error; + resolved = schema; + } cache.set(schema, resolved); return resolved; } From 4a447ceb24bf88f463fb5431cdf88f140ec5c951 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 14:06:12 -0400 Subject: [PATCH 09/68] fix: suppress only the endpoint's SSE GET, and scope the setting to the legacy era (#2394 review) The transport shares its fetch with OAuth metadata discovery, so matching every headerless GET would have answered discovery with a synthetic 405. Match only a GET to the MCP endpoint asking for text/event-stream without Last-Event-ID. Docs, type comments and the form now say the stream exists only on the legacy era; the checkbox is hidden for a server pinned to modern. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- .../ServerSettingsForm.test.tsx | 22 +++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 9 +- .../suppressNotificationStreamFetch.test.ts | 88 ++++++++++++++----- .../node/suppressNotificationStreamFetch.ts | 48 ++++++++-- core/mcp/node/transport.ts | 5 +- core/mcp/types.ts | 31 ++++--- docs/mcp-server-configuration.md | 2 +- 7 files changed, 163 insertions(+), 42 deletions(-) diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 04ae83a898..d57fa36570 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -492,6 +492,28 @@ describe("ServerSettingsForm", () => { expect(onSuppressNotificationStreamChange).toHaveBeenCalledWith(true); }); + it("is hidden for a server pinned to the modern era, which never opens the stream", () => { + renderWithMantine( + , + ); + expect(screen.queryByRole("checkbox", { name })).not.toBeInTheDocument(); + }); + + it("stays visible for an auto-era server, which may resolve to legacy", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("checkbox", { name })).toBeInTheDocument(); + }); + it.each(["sse", "stdio"] as const)( "is hidden for a %s server, which has no standalone GET stream", (serverType) => { diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index c2b6126b8f..b490c081f6 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -516,6 +516,11 @@ export function ServerSettingsForm({ // `auto` server that is either not yet connected or resolved to modern, keep // it visible. const configuredEra = settings.protocolEra ?? DEFAULT_PROTOCOL_ERA; + // The standalone GET stream exists only on a legacy-era Streamable HTTP + // connection; a pinned-modern server never opens it, so the box would be a + // no-op there (#2317). `auto` keeps it, since it may resolve to legacy. + const showSuppressNotificationStream = + serverType === "streamable-http" && configuredEra !== "modern"; const showModernLogLevel = configuredEra === "modern" || (configuredEra === "auto" && negotiatedEra !== "legacy"); @@ -711,10 +716,10 @@ export function ServerSettingsForm({ checked={settings.paginatedLists ?? false} onChange={(e) => onPaginatedListsChange(e.currentTarget.checked)} /> - {serverType === "streamable-http" ? ( + {showSuppressNotificationStream ? ( onSuppressNotificationStreamChange(e.currentTarget.checked) diff --git a/clients/web/src/test/core/mcp/node/suppressNotificationStreamFetch.test.ts b/clients/web/src/test/core/mcp/node/suppressNotificationStreamFetch.test.ts index d53401f46b..8db313a44e 100644 --- a/clients/web/src/test/core/mcp/node/suppressNotificationStreamFetch.test.ts +++ b/clients/web/src/test/core/mcp/node/suppressNotificationStreamFetch.test.ts @@ -1,54 +1,102 @@ +/** + * Request classification for `createSuppressNotificationStreamFetch` (#2317). + * + * The wrapper sits on a fetch the SDK uses for more than the MCP endpoint — + * OAuth metadata discovery shares it — so the cost of a loose predicate is + * broken auth, not merely a missing stream. These cases pin the match to + * exactly the standalone SSE `GET`: every other request, including the + * near-misses (another path, a non-SSE `Accept`, a `Last-Event-ID` + * resumption), must reach the network. The live-transport half — that the SDK + * really accepts the synthetic 405 and carries on — is + * `integration/mcp/suppress-notification-stream.test.ts`. + */ import { describe, it, expect, vi } from "vitest"; import { createSuppressNotificationStreamFetch } from "@inspector/core/mcp/node/suppressNotificationStreamFetch.js"; -const URL_ = "https://example.com/mcp"; +const ENDPOINT = "https://example.com/mcp"; +const SSE = { accept: "text/event-stream" }; function setup() { const baseFetch = vi.fn(async () => new Response("ok", { status: 200 })); return { baseFetch, - fetchFn: createSuppressNotificationStreamFetch(baseFetch), + fetchFn: createSuppressNotificationStreamFetch( + baseFetch, + new URL(ENDPOINT), + ), }; } describe("createSuppressNotificationStreamFetch (#2317)", () => { - it("answers a standalone GET with a local 405 and never sends it", async () => { + it("answers the standalone SSE GET with a local 405 and never sends it", async () => { const { baseFetch, fetchFn } = setup(); - const res = await fetchFn(URL_, { + const res = await fetchFn(ENDPOINT, { method: "GET", - headers: new Headers({ accept: "text/event-stream" }), + headers: new Headers({ accept: "application/json, text/event-stream" }), }); expect(res.status).toBe(405); expect(baseFetch).not.toHaveBeenCalled(); }); - it("treats a GET with no init at all as the standalone stream", async () => { + it("matches a URL input and a Request input for the endpoint", async () => { const { baseFetch, fetchFn } = setup(); - expect((await fetchFn(URL_)).status).toBe(405); + expect((await fetchFn(new URL(ENDPOINT), { headers: SSE })).status).toBe( + 405, + ); + expect( + (await fetchFn(new Request(ENDPOINT, { headers: SSE }))).status, + ).toBe(405); expect(baseFetch).not.toHaveBeenCalled(); }); - it("reads the method and headers off a Request input", async () => { + it.each([ + [ + "an OAuth protected-resource metadata GET", + "https://example.com/.well-known/oauth-protected-resource/mcp", + { accept: "application/json" }, + ], + [ + "an authorization-server metadata GET on another origin", + "https://auth.example.com/.well-known/oauth-authorization-server", + { accept: "application/json" }, + ], + ["an SSE GET to another path", "https://example.com/other", SSE], + ["an SSE GET with a different query", `${ENDPOINT}?x=1`, SSE], + ["a non-SSE GET to the endpoint", ENDPOINT, { accept: "application/json" }], + ["a GET to the endpoint with no Accept", ENDPOINT, {}], + ["an unparseable URL", "not a url", SSE], + ])("passes %s through", async (_label, url, headers) => { const { baseFetch, fetchFn } = setup(); - expect((await fetchFn(new Request(URL_))).status).toBe(405); - const resume = new Request(URL_, { headers: { "Last-Event-ID": "7" } }); - expect((await fetchFn(resume)).status).toBe(200); - const post = new Request(URL_, { method: "POST", body: "{}" }); - expect((await fetchFn(post)).status).toBe(200); - expect(baseFetch).toHaveBeenCalledTimes(2); + const init = { method: "GET", headers }; + expect((await fetchFn(url, init)).status).toBe(200); + expect(baseFetch).toHaveBeenCalledWith(url, init); }); it("passes a resumption GET (Last-Event-ID) through", async () => { const { baseFetch, fetchFn } = setup(); - const init = { method: "get", headers: { "last-event-id": "42" } }; - expect((await fetchFn(URL_, init)).status).toBe(200); - expect(baseFetch).toHaveBeenCalledWith(URL_, init); + const init = { + method: "get", + headers: { ...SSE, "last-event-id": "42" }, + }; + expect((await fetchFn(ENDPOINT, init)).status).toBe(200); + expect(baseFetch).toHaveBeenCalledWith(ENDPOINT, init); }); it.each(["POST", "DELETE"])("passes %s through", async (method) => { const { baseFetch, fetchFn } = setup(); - const init = { method }; - expect((await fetchFn(URL_, init)).status).toBe(200); - expect(baseFetch).toHaveBeenCalledWith(URL_, init); + const init = { method, headers: SSE }; + expect((await fetchFn(ENDPOINT, init)).status).toBe(200); + expect(baseFetch).toHaveBeenCalledWith(ENDPOINT, init); + }); + + it("reads the method off a Request input", async () => { + const { baseFetch, fetchFn } = setup(); + const post = new Request(ENDPOINT, { + method: "POST", + headers: SSE, + body: "{}", + }); + expect((await fetchFn(post)).status).toBe(200); + expect(baseFetch).toHaveBeenCalledTimes(1); }); }); diff --git a/core/mcp/node/suppressNotificationStreamFetch.ts b/core/mcp/node/suppressNotificationStreamFetch.ts index 56c6078296..2981b51d18 100644 --- a/core/mcp/node/suppressNotificationStreamFetch.ts +++ b/core/mcp/node/suppressNotificationStreamFetch.ts @@ -14,16 +14,27 @@ * reuses the SDK's own spec-conformant path (the client MAY open the stream; * it is never required to). * - * Only the *standalone* stream is suppressed. A `GET` carrying - * `Last-Event-ID` is the transport resuming a POST response stream that - * dropped mid-request, which is part of request/response traffic and has to - * keep reaching the server. + * Only the *standalone* stream is suppressed, and the match is deliberately + * narrow because the SDK routes more than MCP traffic through this fetch: + * + * - **OAuth discovery.** The transport hands the same fetch to protected + * resource and authorization server metadata discovery, which are plain + * `GET`s to other URLs. Suppressing those would break SDK-managed auth. + * So the request must target the MCP endpoint itself and ask for + * `text/event-stream`. + * - **Resumption.** A `GET` carrying `Last-Event-ID` is the transport resuming + * a POST response stream that dropped mid-request. That belongs to + * request/response traffic and must keep reaching the server. + * + * Only the legacy (initialize-handshake) era opens this stream. A modern-era + * connection never sends it, so the wrapper is inert there. */ export function createSuppressNotificationStreamFetch( baseFetch: typeof fetch, + endpoint: URL, ): typeof fetch { return async (input, init) => { - if (isStandaloneStreamRequest(input, init)) { + if (isStandaloneStreamRequest(input, init, endpoint)) { return new Response(null, { status: 405, statusText: "Method Not Allowed", @@ -33,13 +44,38 @@ export function createSuppressNotificationStreamFetch( }; } +function requestUrl(input: Parameters[0]): URL | undefined { + const raw = + input instanceof Request + ? input.url + : input instanceof URL + ? input.href + : input; + try { + return new URL(raw); + } catch { + return undefined; + } +} + function isStandaloneStreamRequest( input: Parameters[0], init: Parameters[1], + endpoint: URL, ): boolean { const request = input instanceof Request ? input : undefined; const method = (init?.method ?? request?.method ?? "GET").toUpperCase(); if (method !== "GET") return false; + const url = requestUrl(input); + if ( + !url || + url.origin !== endpoint.origin || + url.pathname !== endpoint.pathname || + url.search !== endpoint.search + ) { + return false; + } const headers = new Headers(init?.headers ?? request?.headers); - return !headers.has("last-event-id"); + const accept = headers.get("accept")?.toLowerCase() ?? ""; + return accept.includes("text/event-stream") && !headers.has("last-event-id"); } diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts index dbc38b3168..eaf411c295 100644 --- a/core/mcp/node/transport.ts +++ b/core/mcp/node/transport.ts @@ -178,7 +178,10 @@ export function createTransportNode( // request the server never saw (#2317). const httpFetch = settings?.suppressNotificationStream === true - ? createSuppressNotificationStreamFetch(fetchWithOptionalAuthIntercept) + ? createSuppressNotificationStreamFetch( + fetchWithOptionalAuthIntercept, + url, + ) : fetchWithOptionalAuthIntercept; const transport = new StreamableHTTPClientTransport(url, { diff --git a/core/mcp/types.ts b/core/mcp/types.ts index c61c6528aa..d954b8a212 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -158,9 +158,9 @@ export type StoredMCPServer = MCPServerConfig & { */ paginatedLists?: boolean; /** - * When true, the Streamable HTTP transport does not open the standalone - * `GET` notification stream, so traffic is POST-only and server-initiated - * messages outside a request's own response stream do not arrive. + * When true, a legacy-era Streamable HTTP connection does not open the + * standalone `GET` notification stream. See + * {@link InspectorServerSettings.suppressNotificationStream}. * Inspector-specific. Omitted on disk when false (the default). (#2317) */ suppressNotificationStream?: boolean; @@ -936,15 +936,22 @@ export interface InspectorServerSettings { */ paginatedLists?: boolean; /** - * When true, a Streamable HTTP connection does not open the standalone `GET` - * notification stream (the client MAY open it; it is never required), so - * request/response traffic is POST-only (#2317). Two uses: a one-click - * diagnostic for a server that cannot serve a second concurrent request — - * which the long-lived stream otherwise occupies, hanging every request - * after `initialize` (#2187) — and an escape hatch that makes such a server - * inspectable. The cost is that server→client messages not tied to a request - * (list_changed, resource updates, standalone logs) do not arrive. Read at - * connect time; no effect on stdio or legacy SSE. Default false. + * When true, a Streamable HTTP connection on the **legacy** (initialize + * handshake) era does not open the standalone `GET` notification stream, + * which the client MAY open but is never required to (#2317). Two uses: a + * one-click diagnostic for a server that cannot serve a second concurrent + * request — which the long-lived stream otherwise occupies, hanging every + * request after `initialize` (#2187) — and an escape hatch that makes such a + * server inspectable. The cost is that server→client messages not carried on + * a request's own response stream (list_changed, resource updates, + * standalone logs) do not arrive. + * + * Two things it does not change. A `Last-Event-ID` `GET` resuming a dropped + * POST response stream is part of request/response traffic and still goes + * out. And a modern-era connection never opens the standalone stream (its + * notifications arrive over POST `subscriptions/listen`), so the setting has + * no effect there. Read at connect time; no effect on stdio or legacy SSE + * transports. Default false. */ suppressNotificationStream?: boolean; /** diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 5ad8afaa9a..46028f68ac 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -187,7 +187,7 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `taskTtl` | `60000` | TTL in ms for tasks created via "Run as task" (`DEFAULT_TASK_TTL_MS`) | | `autoRefreshOnListChanged` | `false` | Refresh lists automatically on `*/list_changed` instead of only flagging the indicator | | `paginatedLists` | `false` | Fetch tools/resources/prompts one page at a time instead of auto-aggregating | -| `suppressNotificationStream` | `false` | Streamable HTTP only: don't open the standalone `GET` notification stream, so requests and responses use POST only. Server→client messages outside a request's own response stream won't arrive. A diagnostic and escape hatch for a server that times out every request after `initialize` because it cannot serve a second concurrent request ([#2317](https://github.com/modelcontextprotocol/inspector/issues/2317)) | +| `suppressNotificationStream` | `false` | Streamable HTTP, legacy era only: don't open the standalone `GET` notification stream. Server→client messages not carried on a request's own response stream won't arrive. `Last-Event-ID` resumption `GET`s still go out, and modern-era connections are unaffected (they never open this stream). A diagnostic and escape hatch for a server that times out every request after `initialize` because it cannot serve a second concurrent request ([#2317](https://github.com/modelcontextprotocol/inspector/issues/2317)) | | `advertisedExtensions` | — | Per-extension overrides for what the Inspector declares in `capabilities.extensions` | | `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited | | `skillCatalogMaxSkills` | `256` | The maximum number of skills whose files are read in one verification run (`SKILL_MAX_CATALOG_SKILLS`) — the CLI's `--verify` and the TUI Skills pane. Positive integer; there is no unlimited value | From 091bc36b396c993de2be489f30e3736fe7701d10 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 14:09:34 -0400 Subject: [PATCH 10/68] fix: walk only subschema keywords, bound depth, reject bad escapes, keep enumNames (#2391 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- clients/web/src/test/core/localRefs.test.ts | 66 ++++++- core/json/localRefs.ts | 195 +++++++++++++------- 2 files changed, 192 insertions(+), 69 deletions(-) diff --git a/clients/web/src/test/core/localRefs.test.ts b/clients/web/src/test/core/localRefs.test.ts index acf08be08b..39b1841fad 100644 --- a/clients/web/src/test/core/localRefs.test.ts +++ b/clients/web/src/test/core/localRefs.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { EXPANSION_BUDGET, + MAX_DEPTH, inlineLocalRefs, } from "@inspector/core/json/localRefs.js"; @@ -92,8 +93,9 @@ describe("inlineLocalRefs", () => { scalar: { $ref: "#/$defs/S/type" }, pastEnd: { $ref: "#/$defs/L/5" }, badIndex: { $ref: "#/$defs/L/01" }, + badEscape2: { $ref: "#/$defs/A~2B" }, }, - $defs: { S: date, L: [date] }, + $defs: { S: date, L: [date], "A~2B": date }, }); expect(resolved.properties).toEqual({ remote: { $ref: "https://example.com/s.json" }, @@ -103,6 +105,7 @@ describe("inlineLocalRefs", () => { scalar: { $ref: "#/$defs/S/type" }, pastEnd: { $ref: "#/$defs/L/5" }, badIndex: { $ref: "#/$defs/L/01" }, + badEscape2: { $ref: "#/$defs/A~2B" }, }); }); @@ -231,4 +234,65 @@ describe("inlineLocalRefs", () => { expect(2 ** 20).toBeGreaterThan(EXPANSION_BUDGET); expect(inlineLocalRefs(schema)).toBe(schema); }); + it("copies extension keywords as data even when they hold a $ref", () => { + const resolved = inlineLocalRefs({ + type: "object", + "x-vendor": { $ref: "#/$defs/D" }, + properties: { a: { $ref: "#/$defs/D" } }, + $defs: { D: date }, + }); + expect(resolved["x-vendor"]).toEqual({ $ref: "#/$defs/D" }); + expect(resolved.properties.a).toEqual(date); + }); + + it("walks every subschema keyword shape", () => { + const pointer = { $ref: "#/$defs/D" }; + const resolved = inlineLocalRefs({ + not: pointer, + items: [pointer], + prefixItems: [pointer], + patternProperties: { "^x": pointer }, + allOf: "not an array", + properties: "not a map", + $defs: { D: date }, + }); + expect(resolved).toMatchObject({ + not: date, + items: [date], + prefixItems: [date], + patternProperties: { "^x": date }, + allOf: "not an array", + properties: "not a map", + }); + }); + + it("keeps enumNames beside a $ref as an annotation", () => { + const resolved = inlineLocalRefs({ + properties: { c: { $ref: "#/$defs/C", enumNames: ["Red"] } }, + $defs: { C: { type: "string", enum: ["r"] } }, + }); + expect(resolved.properties.c).toEqual({ + type: "string", + enum: ["r"], + enumNames: ["Red"], + }); + }); + + it("returns the schema unresolved past MAX_DEPTH, in either pass", () => { + // Deeper than the stack allows, with the $ref at the bottom: the scan bails. + let deep: Record = { $ref: "#/$defs/D" }; + for (let n = 0; n < 6000; n++) deep = { items: deep }; + const scanned = { ...deep, $defs: { D: date } }; + expect(inlineLocalRefs(scanned)).toBe(scanned); + + // Within the bound where it is declared (`$defs/T`, one level down) but + // past it once inlined under `properties/a` (two down): only inlining bails. + let tall: Record = { type: "string" }; + for (let n = 0; n < MAX_DEPTH - 1; n++) tall = { items: tall }; + const inlined = { + properties: { a: { $ref: "#/$defs/T" } }, + $defs: { T: tall }, + }; + expect(inlineLocalRefs(inlined)).toBe(inlined); + }); }); diff --git a/core/json/localRefs.ts b/core/json/localRefs.ts index 359aa7c008..905e399a26 100644 --- a/core/json/localRefs.ts +++ b/core/json/localRefs.ts @@ -32,23 +32,52 @@ * - **Expansion is bounded.** Inlining copies a referent at every use, so a * chain of definitions each using the previous one twice grows as `2^n` * from an `O(n)` schema. A server controls the schema, so past - * {@link EXPANSION_BUDGET} nodes the whole schema is returned unresolved - * rather than freezing the form that renders it. + * {@link EXPANSION_BUDGET} nodes — or {@link MAX_DEPTH} levels of nesting, + * which would otherwise overflow the stack — the whole schema is returned + * unresolved rather than freezing or crashing the form that renders it. */ -/** Keywords whose values are data, not subschemas — never walked. */ -const DATA_KEYWORDS = new Set(["const", "default", "enum", "examples"]); +/* + * Where subschemas live, and nowhere else. Every other keyword's value is data + * — `const`, `enum`, an `x-vendor` extension — and is copied untouched even + * when it happens to hold a `$ref`-shaped object. Same lists as + * `schemaLint.ts`. + */ + +/** Keywords whose value is one subschema (or, for draft-04 `items`, an array). */ +const SUBSCHEMA_KEYWORDS = new Set([ + "items", + "contains", + "not", + "propertyNames", + "if", + "then", + "else", + "additionalProperties", + "unevaluatedProperties", + "additionalItems", + "unevaluatedItems", + "contentSchema", +]); + +/** Keywords whose value is an array of subschemas. */ +const SUBSCHEMA_ARRAY_KEYWORDS = new Set([ + "allOf", + "anyOf", + "oneOf", + "prefixItems", +]); /** * Keywords whose values map arbitrary NAMES to subschemas. Their keys are * user-chosen, so a property called `default` is a schema, not data. */ -const NAME_MAP_KEYWORDS = new Set([ +const SUBSCHEMA_MAP_KEYWORDS = new Set([ "properties", "patternProperties", "dependentSchemas", - // Pre-2019 spelling of `dependentSchemas` (its array values are name lists, - // which the walk passes through untouched). + // Pre-2019 spelling of `dependentSchemas`; its array values are property + // name lists, which are not schemas and pass through untouched. "dependencies", "$defs", "definitions", @@ -67,6 +96,8 @@ const NON_CONSTRAINT_SIBLINGS = new Set([ "deprecated", "readOnly", "writeOnly", + // Non-standard labels for `enum` values, read by both form builders. + "enumNames", "$comment", "$schema", "$id", @@ -77,8 +108,15 @@ const NON_CONSTRAINT_SIBLINGS = new Set([ /** Most schema nodes one inlining may produce before it gives up. */ export const EXPANSION_BUDGET = 10_000; -/** Thrown to unwind a traversal that has spent {@link EXPANSION_BUDGET}. */ -class BudgetExceeded extends Error {} +/** + * Deepest subschema nesting either pass walks before it gives up — the same + * bound `schemaLint.ts` uses. Both passes recurse, and a server can nest far + * deeper than the call stack allows. + */ +export const MAX_DEPTH = 64; + +/** Thrown to unwind a traversal that hit a bound; the input is returned. */ +class Bail extends Error {} type JsonRecord = Record; @@ -92,12 +130,55 @@ interface Traversal { /** Charge one produced node against the traversal's budget. */ function spend(traversal: Traversal): void { traversal.remaining -= 1; - if (traversal.remaining < 0) throw new BudgetExceeded(); + if (traversal.remaining < 0) throw new Bail(); +} + +/** + * Rebuild `node` with `visit` applied to each subschema it holds directly, + * copying every other value untouched. + */ +function mapSubschemas( + node: JsonRecord, + visit: (child: unknown) => unknown, +): JsonRecord { + const result: JsonRecord = {}; + for (const [key, value] of Object.entries(node)) { + let next: unknown = value; + if (SUBSCHEMA_KEYWORDS.has(key)) { + next = Array.isArray(value) ? value.map(visit) : visit(value); + } else if (SUBSCHEMA_ARRAY_KEYWORDS.has(key) && Array.isArray(value)) { + next = value.map(visit); + } else if (SUBSCHEMA_MAP_KEYWORDS.has(key) && isRecord(value)) { + next = mapNames(value, visit); + } + define(result, key, next); + } + return result; } -/** Whether `key` holds data rather than a subschema, in a schema object. */ -function isDataKey(key: string, inNameMap: boolean): boolean { - return !inNameMap && DATA_KEYWORDS.has(key); +/** A name → subschema map with `visit` applied to each value. */ +function mapNames( + map: JsonRecord, + visit: (child: unknown) => unknown, +): JsonRecord { + const result: JsonRecord = {}; + for (const [name, value] of Object.entries(map)) { + define(result, name, Array.isArray(value) ? value : visit(value)); + } + return result; +} + +/** + * `defineProperty`, not assignment: `__proto__` is a legal property name in a + * schema's `properties`, and assigning it would set the prototype instead. + */ +function define(target: JsonRecord, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); } function isRecord(value: unknown): value is JsonRecord { @@ -120,7 +201,10 @@ function resolvePointer(root: unknown, ref: string): unknown { if (!pointer.startsWith("/")) return undefined; let current: unknown = root; for (const token of pointer.slice(1).split("/")) { - // RFC 6901 escaping, `~1` before `~0` so `~01` decodes to `~1`. + // `~0` and `~1` are the only escapes RFC 6901 defines; anything else makes + // the pointer malformed rather than naming a key that happens to match. + if (/~(?![01])/.test(token)) return undefined; + // `~1` before `~0`, so `~01` decodes to `~1`. const segment = token.replace(/~1/g, "/").replace(/~0/g, "~"); if (Array.isArray(current)) { const index = Number(segment); @@ -137,27 +221,26 @@ function resolvePointer(root: unknown, ref: string): unknown { return current; } -/** Whether any subschema position in `node` holds a `$ref` string. */ -function containsRef(node: unknown, inNameMap = false): boolean { - if (Array.isArray(node)) return node.some((item) => containsRef(item)); +/** Whether any subschema in `node` holds a `$ref` string. */ +function containsRef(node: unknown, depth: number): boolean { + if (depth > MAX_DEPTH) throw new Bail(); if (!isRecord(node)) return false; - if (!inNameMap && typeof node.$ref === "string") return true; - return Object.entries(node).some( - ([key, value]) => - !isDataKey(key, inNameMap) && - containsRef(value, !inNameMap && NAME_MAP_KEYWORDS.has(key)), - ); + if (typeof node.$ref === "string") return true; + let found = false; + mapSubschemas(node, (child) => { + found ||= containsRef(child, depth + 1); + return child; + }); + return found; } -function inline(node: unknown, traversal: Traversal): unknown { - if (Array.isArray(node)) { - spend(traversal); - return node.map((item) => inline(item, traversal)); - } +function inline(node: unknown, traversal: Traversal, depth: number): unknown { + if (depth > MAX_DEPTH) throw new Bail(); if (!isRecord(node)) return node; // An embedded resource: its pointers are relative to itself (see the header). if (node !== traversal.root && typeof node.$id === "string") return node; spend(traversal); + const visit = (child: unknown) => inline(child, traversal, depth + 1); const ref = node.$ref; const siblingsConstrain = Object.keys(node).some( @@ -171,41 +254,14 @@ function inline(node: unknown, traversal: Traversal): unknown { const target = resolvePointer(traversal.root, ref); if (isRecord(target)) { traversal.active.add(ref); - const resolved = inline(target, traversal) as JsonRecord; + const resolved = visit(target) as JsonRecord; traversal.active.delete(ref); const siblings: JsonRecord = { ...node }; delete siblings.$ref; - return { ...resolved, ...inlineEntries(siblings, traversal, false) }; + return { ...resolved, ...mapSubschemas(siblings, visit) }; } } - return inlineEntries(node, traversal, false); -} - -function inlineEntries( - node: JsonRecord, - traversal: Traversal, - inNameMap: boolean, -): JsonRecord { - const result: JsonRecord = {}; - for (const [key, value] of Object.entries(node)) { - let next: unknown = value; - if (inNameMap) { - next = inline(value, traversal); - } else if (NAME_MAP_KEYWORDS.has(key) && isRecord(value)) { - next = inlineEntries(value, traversal, true); - } else if (!isDataKey(key, false)) { - next = inline(value, traversal); - } - // `defineProperty`, not assignment: `__proto__` is a legal property name - // in a schema's `properties`, and assigning it would set the prototype. - Object.defineProperty(result, key, { - value: next, - writable: true, - enumerable: true, - configurable: true, - }); - } - return result; + return mapSubschemas(node, visit); } // Keyed on the input object: form panels call this on every render, and a @@ -215,26 +271,29 @@ const cache = new WeakMap(); /** * `schema` with every resolvable same-document `$ref` replaced by its referent. * - * Returns `schema` itself — same reference — when it contains no `$ref` or - * inlining would exceed {@link EXPANSION_BUDGET}, and the same resolved object for repeated calls with the same input. Never + * Returns `schema` itself — same reference — when it contains no `$ref`, or + * when inlining would exceed {@link EXPANSION_BUDGET} or {@link MAX_DEPTH}; + * and the same resolved object for repeated calls with the same input. Never * mutates the input. */ export function inlineLocalRefs(schema: T): T { - if (!isRecord(schema) || !containsRef(schema)) return schema; + if (!isRecord(schema)) return schema; const cached = cache.get(schema); if (cached !== undefined) return cached as T; // The result is the input's own shape with references expanded, so it is // still a `T` to every caller that reads it as one. let resolved: T; try { - resolved = inline(schema, { - root: schema, - active: new Set(), - remaining: EXPANSION_BUDGET, - }) as T; + resolved = containsRef(schema, 0) + ? (inline( + schema, + { root: schema, active: new Set(), remaining: EXPANSION_BUDGET }, + 0, + ) as T) + : schema; } catch (error) { - /* v8 ignore next -- only BudgetExceeded is thrown by the traversal */ - if (!(error instanceof BudgetExceeded)) throw error; + /* v8 ignore next -- Bail is the only thing either traversal throws */ + if (!(error instanceof Bail)) throw error; resolved = schema; } cache.set(schema, resolved); From 3d513846af9c954e7c877cfa44f3c053211b7b86 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 15:57:52 -0400 Subject: [PATCH 11/68] fix(core): reframe type-union as a portability trade, not a defect (#2286) Keep the rule at warning severity (it never fails --strict's exit 6), but reword its message to acknowledge that providers recommend the array form for nullable fields, and name the concrete consumer class it is unportable to instead of an unevidenced 'several MCP clients'. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- clients/web/src/test/core/schemaLint.test.ts | 5 +++++ core/json/schemaLint.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/schemaLint.test.ts b/clients/web/src/test/core/schemaLint.test.ts index cb2db9d206..e4fdfe3766 100644 --- a/clients/web/src/test/core/schemaLint.test.ts +++ b/clients/web/src/test/core/schemaLint.test.ts @@ -303,7 +303,12 @@ describe("lintToolSchemas — type-union", () => { }), ); expect(rules(findings)).toEqual(["type-union"]); + // A warning, not an error: `--strict` exits 6 only on error findings, and + // the array form is provider-recommended, so it must not fail CI (#2286). expect(findings[0]!.severity).toBe("warning"); + // Framed as a portability trade that acknowledges the provider guidance. + expect(findings[0]!.issue).toContain("some model providers recommend it"); + expect(findings[0]!.issue).toContain("less portable"); expect(findings[0]!.suggestion).toContain( '{"anyOf": [{"type": "null"}, {"type": "boolean"}]}', ); diff --git a/core/json/schemaLint.ts b/core/json/schemaLint.ts index e6d82c3166..6a9596e536 100644 --- a/core/json/schemaLint.ts +++ b/core/json/schemaLint.ts @@ -482,12 +482,22 @@ function lintNode( // omission — a different contract, not the same one spelled portably. // `anyOf` branches each carrying a single `type` are equivalent, and this // lint treats them as portable. + // + // Deliberately a `warning` and worded as a trade, not a defect (#2286). + // The array form is what some model providers' own tool guidance + // recommends for a nullable field (OpenAI's structured outputs), so an + // author may be using it on purpose. What it costs is portability to a + // consumer that translates tool schemas into a single-`type` dialect — + // the OpenAPI 3.0 subset Gemini's function declarations use, where `type` + // is one enum value and nullability is `nullable: true`. A warning never + // fails the CLI's `--strict` exit code (only `error` does), so keeping the + // rule informs without turning a deliberate choice into a red CI job. add( ctx, "type-union", "warning", path, - `\`type\` is an array (${JSON.stringify(type)}). The array form is legal JSON Schema, but several MCP clients read \`type\` as a single string and either reject the tool or drop the constraint.`, + `\`type\` is an array (${JSON.stringify(type)}). This is legal JSON Schema, and some model providers recommend it for nullable fields, but it is less portable: a client that maps tool schemas onto a single-\`type\` dialect (such as the OpenAPI subset used for Gemini function declarations) may reject the tool or drop the constraint.`, `Split it into \`anyOf\` branches, each with a single \`type\` — \`{"anyOf": [${type .map((t) => `{"type": "${t}"}`) .join( From 0a05f9ff5fdf7597bde1bf8e3c3c1bb79c721faf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 16:02:58 -0400 Subject: [PATCH 12/68] docs: document read-only mcp.json consumption by external tooling (#1912) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/mcp-server-configuration.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 46028f68ac..9bb8c99ba4 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -272,6 +272,20 @@ A catalog carrying these fields: } ``` +## Reading this file from other tools + +A catalog you have already reviewed in the Inspector is a natural input for other tooling — a CI job or a reliability harness that connects to the same servers non-interactively. Reading the file is a supported interoperability use case. It is **not a versioned interchange format**: the Inspector makes no compatibility promise beyond what this page documents, the Inspector-specific fields above grow as features land, and a standard MCP client-configuration shape may supersede this one. Pin the Inspector version you validated against, and say so in your own documentation. + +A tool that consumes the file should: + +- **Treat it as read-only.** Don't rewrite it, and don't convert it into another format as a copy that users then maintain. The Inspector owns what it writes back — it omits fields equal to their defaults and upgrades older shapes (such as the pair-array `metadata`) on save — so a second writer drifts from it. +- **Preserve stdio argument boundaries.** `command` and each `args` element are separate argv entries. Spawn them directly rather than joining them into a string for a shell, which re-splits on whitespace and interprets quoting, globs and metacharacters. Keep `cwd` and `env` as given. +- **Decide on unknown fields explicitly.** Either honor an Inspector-specific field, or reject the entry naming the field you don't support. Silently ignoring one can change behavior — `protocolEra`, `headers` or `oauth` alter what connects and how. +- **Keep credential values out of its output.** `env`, `headers` and `oauth.clientSecret` routinely hold secrets. Don't copy their values into logs, reports, evidence bundles or generated files; key names are usually enough. +- **Describe its own scope without implying endorsement.** Which fields and Inspector versions it supports is that tool's claim to document; reading this file does not make it Inspector- or MCP-certified. + +Connecting is not side-effect free: calling a server's tools can change state wherever that server acts, so a tool that goes beyond reading the file should leave that decision to its user. + ## Per-client behavior | | Web | CLI | TUI | From 2ca982c11fde28e1428d24299dff1d25ea5183b0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 16:03:11 -0400 Subject: [PATCH 13/68] docs(core): admit the dialect-portability evidence class in schemaLint's contract (#2395 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- core/json/schemaLint.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core/json/schemaLint.ts b/core/json/schemaLint.ts index 6a9596e536..c2b98e0d47 100644 --- a/core/json/schemaLint.ts +++ b/core/json/schemaLint.ts @@ -16,7 +16,12 @@ * conformance check would report nothing on essentially every real server. * What bites instead is the narrower subset each consumer accepts, which is * what the rules below encode. Every rule is a construct that is legal JSON - * Schema and is known to be refused or quietly mishandled by real MCP clients. + * Schema and is either known to be refused or quietly mishandled by real MCP + * clients, or — a weaker class, and only ever at `warning` severity — outside + * a documented schema dialect that consumers translate tool schemas into + * (`type-union`, #2286). A rule in the weaker class names that dialect at its + * call site, and moves to the stronger class only once a shipping client that + * mishandles it is recorded there. * * Kept pure and dependency-free so all three clients share one verdict: the * CLI's `--strict` report, the TUI's tool detail pane, and the web Tools tab From c12adbcfb626073cbec4f157785ef348cb1eb26f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 16:07:08 -0400 Subject: [PATCH 14/68] docs: note that the Inspector keeps env and client secrets out of mcp.json (#2396 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/mcp-server-configuration.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 9bb8c99ba4..959c5c15fc 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -279,9 +279,10 @@ A catalog you have already reviewed in the Inspector is a natural input for othe A tool that consumes the file should: - **Treat it as read-only.** Don't rewrite it, and don't convert it into another format as a copy that users then maintain. The Inspector owns what it writes back — it omits fields equal to their defaults and upgrades older shapes (such as the pair-array `metadata`) on save — so a second writer drifts from it. -- **Preserve stdio argument boundaries.** `command` and each `args` element are separate argv entries. Spawn them directly rather than joining them into a string for a shell, which re-splits on whitespace and interprets quoting, globs and metacharacters. Keep `cwd` and `env` as given. +- **Preserve stdio argument boundaries.** `command` and each `args` element are separate argv entries. Spawn them directly rather than joining them into a string for a shell, which re-splits on whitespace and interprets quoting, globs and metacharacters. Keep `cwd` and the `env` key set as given. - **Decide on unknown fields explicitly.** Either honor an Inspector-specific field, or reject the entry naming the field you don't support. Silently ignoring one can change behavior — `protocolEra`, `headers` or `oauth` alter what connects and how. -- **Keep credential values out of its output.** `env`, `headers` and `oauth.clientSecret` routinely hold secrets. Don't copy their values into logs, reports, evidence bundles or generated files; key names are usually enough. +- **Expect secrets to be absent, and supply them itself.** The Inspector does not write secret values to this file: when it saves an entry it moves each stdio `env` value, `oauth.clientSecret` and the enterprise IdP client secret into its own secret store (the OS keychain, or `~/.mcp-inspector/secrets.json` — see [Where secrets go](./docker.md)), leaving each `env` key in place with an empty value and the client secret omitted. That store is not part of the file's interface, so a file-only reader sees `"API_KEY": ""` and cannot tell an intentionally empty value from a stored one. Inject those values from the tool's own secret source, or reject the entry naming the missing key — don't launch the server with the empty placeholders. +- **Keep credential values out of its output.** A hand-written or imported file can still carry plaintext in `env` or `oauth.clientSecret`, and `headers` stay in the file as written. Don't copy those values into logs, reports, evidence bundles or generated files; key names are usually enough. - **Describe its own scope without implying endorsement.** Which fields and Inspector versions it supports is that tool's claim to document; reading this file does not make it Inspector- or MCP-certified. Connecting is not side-effect free: calling a server's tools can change state wherever that server acts, so a tool that goes beyond reading the file should leave that decision to its user. From ae9c1865b912140ccc977bdfffe184084f61cb56 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 16:07:15 -0400 Subject: [PATCH 15/68] test(core): pin the named dialect in the type-union message (#2395 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- clients/web/src/test/core/schemaLint.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/clients/web/src/test/core/schemaLint.test.ts b/clients/web/src/test/core/schemaLint.test.ts index e4fdfe3766..b3bad294a0 100644 --- a/clients/web/src/test/core/schemaLint.test.ts +++ b/clients/web/src/test/core/schemaLint.test.ts @@ -309,6 +309,10 @@ describe("lintToolSchemas — type-union", () => { // Framed as a portability trade that acknowledges the provider guidance. expect(findings[0]!.issue).toContain("some model providers recommend it"); expect(findings[0]!.issue).toContain("less portable"); + // A weaker-class rule must name its dialect, not a generic "some clients". + expect(findings[0]!.issue).toContain( + "OpenAPI subset used for Gemini function declarations", + ); expect(findings[0]!.suggestion).toContain( '{"anyOf": [{"type": "null"}, {"type": "boolean"}]}', ); From d6d8b8e0efd822fcc9088b2418f5dd2197f8e426 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 16:10:56 -0400 Subject: [PATCH 16/68] docs: scope the secret-stripping note to the catalog fields it covers (#2396 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/mcp-server-configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 959c5c15fc..d5b12954ca 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -281,8 +281,8 @@ A tool that consumes the file should: - **Treat it as read-only.** Don't rewrite it, and don't convert it into another format as a copy that users then maintain. The Inspector owns what it writes back — it omits fields equal to their defaults and upgrades older shapes (such as the pair-array `metadata`) on save — so a second writer drifts from it. - **Preserve stdio argument boundaries.** `command` and each `args` element are separate argv entries. Spawn them directly rather than joining them into a string for a shell, which re-splits on whitespace and interprets quoting, globs and metacharacters. Keep `cwd` and the `env` key set as given. - **Decide on unknown fields explicitly.** Either honor an Inspector-specific field, or reject the entry naming the field you don't support. Silently ignoring one can change behavior — `protocolEra`, `headers` or `oauth` alter what connects and how. -- **Expect secrets to be absent, and supply them itself.** The Inspector does not write secret values to this file: when it saves an entry it moves each stdio `env` value, `oauth.clientSecret` and the enterprise IdP client secret into its own secret store (the OS keychain, or `~/.mcp-inspector/secrets.json` — see [Where secrets go](./docker.md)), leaving each `env` key in place with an empty value and the client secret omitted. That store is not part of the file's interface, so a file-only reader sees `"API_KEY": ""` and cannot tell an intentionally empty value from a stored one. Inject those values from the tool's own secret source, or reject the entry naming the missing key — don't launch the server with the empty placeholders. -- **Keep credential values out of its output.** A hand-written or imported file can still carry plaintext in `env` or `oauth.clientSecret`, and `headers` stay in the file as written. Don't copy those values into logs, reports, evidence bundles or generated files; key names are usually enough. +- **Expect secrets to be absent, and supply them itself.** When the Inspector saves an entry it moves two kinds of value out of this file — each stdio `env` value and `oauth.clientSecret` — into its own secret store (the OS keychain, or `~/.mcp-inspector/secrets.json` — see [Where secrets go](./docker.md)), leaving each `env` key in place with an empty value and the client secret omitted. Nothing else is stripped: `headers` are saved as written, so they can still hold a credential. That store is not part of the file's interface, so a file-only reader sees `"API_KEY": ""` and cannot tell an intentionally empty value from a stored one. Inject those values from the tool's own secret source, or reject the entry naming the missing key — don't launch the server with the empty placeholders. +- **Keep credential values out of its output.** A hand-written or imported file can still carry plaintext in `env` or `oauth.clientSecret`, and any saved `headers` value may be one. Don't copy those values into logs, reports, evidence bundles or generated files; key names are usually enough. - **Describe its own scope without implying endorsement.** Which fields and Inspector versions it supports is that tool's claim to document; reading this file does not make it Inspector- or MCP-certified. Connecting is not side-effect free: calling a server's tools can change state wherever that server acts, so a tool that goes beyond reading the file should leave that decision to its user. From 626d1b962bcdda7d928bf1dbfd92baa37f0b01c5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 16:12:17 -0400 Subject: [PATCH 17/68] fix(core): caveat the null branch of the type-union suggestion (#2395 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- clients/web/src/test/core/schemaLint.test.ts | 16 ++++++++++++++++ core/json/schemaLint.ts | 15 ++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/schemaLint.test.ts b/clients/web/src/test/core/schemaLint.test.ts index b3bad294a0..6cecffc49b 100644 --- a/clients/web/src/test/core/schemaLint.test.ts +++ b/clients/web/src/test/core/schemaLint.test.ts @@ -316,6 +316,22 @@ describe("lintToolSchemas — type-union", () => { expect(findings[0]!.suggestion).toContain( '{"anyOf": [{"type": "null"}, {"type": "boolean"}]}', ); + // The `null` branch is not expressible in the named OpenAPI 3.0 dialect, + // so a null union's suggestion must say so rather than overclaim. + expect(findings[0]!.suggestion).toContain("nullable: true"); + }); + + it("omits the null-branch caveat when the union has no null", () => { + const findings = lintToolSchemas( + tool({ + inputSchema: { + type: "object", + properties: { a: { type: ["string", "number"] } }, + }, + }), + ); + expect(rules(findings)).toEqual(["type-union"]); + expect(findings[0]!.suggestion).not.toContain("nullable"); }); it("never suggests un-requiring the property as the equivalent fix", () => { diff --git a/core/json/schemaLint.ts b/core/json/schemaLint.ts index c2b98e0d47..737cc675ae 100644 --- a/core/json/schemaLint.ts +++ b/core/json/schemaLint.ts @@ -470,6 +470,17 @@ function walk( }); } +/** + * Appended to the `type-union` suggestion when the union includes `null`. + * `anyOf` fixes the single-`type` problem, but OpenAPI 3.0 — the dialect the + * warning names — has no `null` type at all and spells nullability + * `nullable: true`, so the `{"type": "null"}` branch is not itself portable + * there (#2395 review). The suggestion stays JSON Schema rather than + * recommending `nullable`, which is not a JSON Schema keyword, and says so. + */ +const NULL_BRANCH_CAVEAT = + " A dialect with no `null` type, such as OpenAPI 3.0, still cannot express the `null` branch directly; there nullability is written `nullable: true`, which is not JSON Schema, so no single spelling is portable to both."; + /** Rules that apply to a single schema object, ignoring its children. */ function lintNode( node: SchemaRecord, @@ -507,7 +518,9 @@ function lintNode( .map((t) => `{"type": "${t}"}`) .join( ", ", - )}]}\`. (Making the property optional instead is a different contract: absent is not the same as \`null\`.)`, + )}]}\`. (Making the property optional instead is a different contract: absent is not the same as \`null\`.)${ + type.includes("null") ? NULL_BRANCH_CAVEAT : "" + }`, ); } From 9bab0a7694b78545c7cf148a4c6c5681936eb8b5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 16:15:01 -0400 Subject: [PATCH 18/68] docs: note the memory secret store keeps existing plaintext in mcp.json (#2396 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/mcp-server-configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index d5b12954ca..1bbd89fdb1 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -281,7 +281,7 @@ A tool that consumes the file should: - **Treat it as read-only.** Don't rewrite it, and don't convert it into another format as a copy that users then maintain. The Inspector owns what it writes back — it omits fields equal to their defaults and upgrades older shapes (such as the pair-array `metadata`) on save — so a second writer drifts from it. - **Preserve stdio argument boundaries.** `command` and each `args` element are separate argv entries. Spawn them directly rather than joining them into a string for a shell, which re-splits on whitespace and interprets quoting, globs and metacharacters. Keep `cwd` and the `env` key set as given. - **Decide on unknown fields explicitly.** Either honor an Inspector-specific field, or reject the entry naming the field you don't support. Silently ignoring one can change behavior — `protocolEra`, `headers` or `oauth` alter what connects and how. -- **Expect secrets to be absent, and supply them itself.** When the Inspector saves an entry it moves two kinds of value out of this file — each stdio `env` value and `oauth.clientSecret` — into its own secret store (the OS keychain, or `~/.mcp-inspector/secrets.json` — see [Where secrets go](./docker.md)), leaving each `env` key in place with an empty value and the client secret omitted. Nothing else is stripped: `headers` are saved as written, so they can still hold a credential. That store is not part of the file's interface, so a file-only reader sees `"API_KEY": ""` and cannot tell an intentionally empty value from a stored one. Inject those values from the tool's own secret source, or reject the entry naming the missing key — don't launch the server with the empty placeholders. +- **Expect secrets to be absent, and supply them itself.** When the Inspector saves an entry to a durable secret store (the OS keychain, or `~/.mcp-inspector/secrets.json` — see [Where secrets go](./docker.md)) it moves two kinds of value out of this file — each stdio `env` value and `oauth.clientSecret` — leaving each `env` key in place with an empty value and the client secret omitted. Under the session-only `memory` store it keeps plaintext that was already on disk, so the file is not the only durable copy lost, while new or changed values still stay out of it; the same file can therefore hold a mix of placeholders and real values. Nothing else is stripped: `headers` are saved as written, so they can still hold a credential. That store is not part of the file's interface, so a file-only reader sees `"API_KEY": ""` and cannot tell an intentionally empty value from a stored one. Inject those values from the tool's own secret source, or reject the entry naming the missing key — don't launch the server with the empty placeholders. - **Keep credential values out of its output.** A hand-written or imported file can still carry plaintext in `env` or `oauth.clientSecret`, and any saved `headers` value may be one. Don't copy those values into logs, reports, evidence bundles or generated files; key names are usually enough. - **Describe its own scope without implying endorsement.** Which fields and Inspector versions it supports is that tool's claim to document; reading this file does not make it Inspector- or MCP-certified. From e9cdf32da26783b07fe78d46eaeb3a109089f0fb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 16:19:11 -0400 Subject: [PATCH 19/68] docs: clarify the memory-store wording and flag stdio command execution (#2396 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/mcp-server-configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 1bbd89fdb1..cdfc1690bd 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -281,11 +281,11 @@ A tool that consumes the file should: - **Treat it as read-only.** Don't rewrite it, and don't convert it into another format as a copy that users then maintain. The Inspector owns what it writes back — it omits fields equal to their defaults and upgrades older shapes (such as the pair-array `metadata`) on save — so a second writer drifts from it. - **Preserve stdio argument boundaries.** `command` and each `args` element are separate argv entries. Spawn them directly rather than joining them into a string for a shell, which re-splits on whitespace and interprets quoting, globs and metacharacters. Keep `cwd` and the `env` key set as given. - **Decide on unknown fields explicitly.** Either honor an Inspector-specific field, or reject the entry naming the field you don't support. Silently ignoring one can change behavior — `protocolEra`, `headers` or `oauth` alter what connects and how. -- **Expect secrets to be absent, and supply them itself.** When the Inspector saves an entry to a durable secret store (the OS keychain, or `~/.mcp-inspector/secrets.json` — see [Where secrets go](./docker.md)) it moves two kinds of value out of this file — each stdio `env` value and `oauth.clientSecret` — leaving each `env` key in place with an empty value and the client secret omitted. Under the session-only `memory` store it keeps plaintext that was already on disk, so the file is not the only durable copy lost, while new or changed values still stay out of it; the same file can therefore hold a mix of placeholders and real values. Nothing else is stripped: `headers` are saved as written, so they can still hold a credential. That store is not part of the file's interface, so a file-only reader sees `"API_KEY": ""` and cannot tell an intentionally empty value from a stored one. Inject those values from the tool's own secret source, or reject the entry naming the missing key — don't launch the server with the empty placeholders. +- **Expect secrets to be absent, and supply them itself.** When the Inspector saves an entry to a durable secret store (the OS keychain, or `~/.mcp-inspector/secrets.json` — see [Where secrets go](./docker.md)) it moves two kinds of value out of this file — each stdio `env` value and `oauth.clientSecret` — leaving each `env` key in place with an empty value and the client secret omitted. Under the session-only `memory` store it keeps plaintext that was already on disk, so `mcp.json` stays the durable copy of those values rather than a store that is lost on exit, while new or changed values still stay out of it; the same file can therefore hold a mix of placeholders and real values. Nothing else is stripped: `headers` are saved as written, so they can still hold a credential. That store is not part of the file's interface, so a file-only reader sees `"API_KEY": ""` and cannot tell an intentionally empty value from a stored one. Inject those values from the tool's own secret source, or reject the entry naming the missing key — don't launch the server with the empty placeholders. - **Keep credential values out of its output.** A hand-written or imported file can still carry plaintext in `env` or `oauth.clientSecret`, and any saved `headers` value may be one. Don't copy those values into logs, reports, evidence bundles or generated files; key names are usually enough. - **Describe its own scope without implying endorsement.** Which fields and Inspector versions it supports is that tool's claim to document; reading this file does not make it Inspector- or MCP-certified. -Connecting is not side-effect free: calling a server's tools can change state wherever that server acts, so a tool that goes beyond reading the file should leave that decision to its user. +Connecting is not side-effect free. Connecting to a stdio entry **runs its `command`** on the consumer's machine before any MCP message is exchanged, and calling a server's tools can change state wherever that server acts. A tool that goes beyond reading the file should leave both decisions to its user — authorizing the launch, not only the tool calls. ## Per-client behavior From 3b78545cabd8f85ee07bbfba84bc23b0b9458108 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 17:02:21 -0400 Subject: [PATCH 20/68] feat: run skills:eval through the GitHub Copilot CLI (#2397) AGENT=copilot drives the same committed cases through `copilot`, which discovers .claude/skills/ as-is (verified on 1.0.85), so no second copy of any skill is needed. Tool availability is bounded with --available-tools, the turn budget is enforced by stopping the process, and every report line names the agent it measured. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- AGENTS.md | 6 + docs/skill-authoring.md | 72 +++++++++ scripts/lib/claude-cli.mjs | 43 +++++- scripts/lib/claude-cli.test.mjs | 32 +++- scripts/skill-eval.mjs | 254 +++++++++++++++++++++++++++----- scripts/skill-eval.test.mjs | 202 ++++++++++++++++++++++++- 6 files changed, 569 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d05cc5a74d..e57b8ce69e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -297,6 +297,12 @@ node/field/option IDs, and the option-deletion hazard` was cut at `#28`, so 90 expose a description that only fires on one narrow phrasing; `npm run skills:eval` actually runs them (it needs the `claude` CLI and real model calls, so it is deliberately **not** in the gate — run it when adding a skill or editing a model-invoked description). + **The skills serve GitHub Copilot users too, from where they are.** The + Copilot CLI discovers `.claude/skills/` alongside `.github/skills/`, so a + Copilot session reaches the same procedures with no second copy — and none + may be added. `AGENT=copilot npm run skills:eval` measures that side with the + same cases; one run measures one agent and never folds the two rates together + (#2397, details in `docs/skill-authoring.md`). Two things learned writing the first set, both of which make a case measure the wrong thing: a prompt whose answer is **already in this file** is not a trigger case — the model answers correctly without the skill, and the case diff --git a/docs/skill-authoring.md b/docs/skill-authoring.md index 8582c73259..10187743e5 100644 --- a/docs/skill-authoring.md +++ b/docs/skill-authoring.md @@ -443,6 +443,78 @@ metered calls, it is non-deterministic by construction, and it goes red on a rate limit. A case below threshold is a signal to investigate, not a build break. +## Measuring GitHub Copilot + +Some maintainers work on this repo with the [GitHub Copilot +CLI](https://www.npmjs.com/package/@github/copilot), so the same committed cases +also run through it (#2397): + +```sh +npm install -g @github/copilot # then sign in once: `copilot`, then `/login` +AGENT=copilot npm run skills:eval +AGENT=copilot RUNS=5 npm run skills:eval -- pr-flow +``` + +**No coercion is needed: Copilot reads `.claude/skills/` as it is.** Its project +skill sources are `.github/skills/`, `.agents/skills/` **and** `.claude/skills/`, +and `copilot skill list` on 1.0.85 shows all ten of ours. So there is no symlink, +no `.github/skills/` copy and no pointer file, and there must not be one — two +copies of a procedure is how the stale one gets read. + +What was verified about how Copilot treats the files, on 1.0.85: + +- **It honors `disable-model-invocation: true`, but only as far as its tool + goes.** Its `skill` tool refused `release` with `Skill not found`, and the + model then opened `.claude/skills/release/SKILL.md` with `view` and read it + anyway. A name-only skill is kept out of the automatic listing, not made + unreadable — which is equally true of Claude, which can `Read` the file. +- **`AGENTS.md` is loaded as custom instructions**, so the rule in + [Do not write a case `AGENTS.md` already answers](#do-not-write-a-case-agentsmd-already-answers) + applies to Copilot runs unchanged. + +How the Copilot run differs from the Claude run, since a rate only means +something next to the harness that produced it: + +| | Claude | Copilot | +| --- | --- | --- | +| Turn budget | `--max-turns` | none exists; `runPrompt` stops the process once the stream shows that many model calls | +| Availability (the real bound) | `--tools Read,Glob,Grep,Skill` | `--available-tools view,glob,grep,skill` | +| Pre-approval only | `--allowedTools` | `--allow-tool` | +| Unconditional deny | `--disallowedTools` by tool name | `--deny-tool shell`, `write`, `url` — permission **kinds**, not names | +| MCP servers | `--strict-mcp-config` | `--disable-builtin-mcps`; it does not read `.mcp.json` | +| Model | whatever `claude` defaults to | whatever Copilot's model picker defaults to (Claude Sonnet 5 when measured) | + +**Each invocation measures one agent, and every heading and summary line names +it.** The two rates come from different models behind different harnesses, so +they are compared side by side and never summed, for the same reason first-move +and hand-off cases are not. + +To probe one prompt the way the Copilot run does: + +```sh +printf '%s' "" \ + | copilot --output-format json \ + --available-tools view,glob,grep,skill --allow-tool view,glob,grep,skill \ + --deny-tool shell --deny-tool write --deny-tool url \ + --disable-builtin-mcps --disallow-temp-dir --no-ask-user --no-auto-update \ + | jq -r 'select(.type == "assistant.message") | .data.toolRequests[] + | if .name == "skill" then "skill:" + .arguments.skill else .name end' \ + | head -3 +``` + +⚠️ Like the Claude probe above, **these flags are a copy of `agentArgs` in +`scripts/skill-eval.mjs`** — change both in the same edit. Unlike the Claude +probe, nothing here stops the run after the first move, so `head -3` only +trims the output; the session carries on until it answers. + +**First measurement** (2026-09-16, Copilot CLI 1.0.85, Claude Sonnet 5, +`RUNS=3`, the full suite): **63/63 first-move cases at 100%**, every negative +clean, and **1/2 hand-off cases** — `testing → test-servers` at 100% on the +integration-test prompt and 33% on the pagination one. Read the hand-off the +way the section above says to: a noisy second-hop measurement at `RUNS=3`, not +a Copilot-specific defect, until a `RUNS=5` Claude run of the same case says +otherwise. + ## Checklist for a new or edited skill 1. Frontmatter opens on line 1 (no BOM, no blank line), YAML is valid, and any diff --git a/scripts/lib/claude-cli.mjs b/scripts/lib/claude-cli.mjs index d7cbdb62f6..060fa37681 100644 --- a/scripts/lib/claude-cli.mjs +++ b/scripts/lib/claude-cli.mjs @@ -12,6 +12,10 @@ // space-joined string, so any argument holding a metacharacter becomes syntax — // so arguments go through the same `winShellArgs` quoting the npm/npx call // sites use. This is deliberately the ONLY place that decides either question. +// +// The same two questions apply to every agent CLI the skills eval drives — +// `copilot` is an npm-installed `.cmd` shim on Windows too (#2397) — so the +// decision is made once, for any command, and the `claude` helpers delegate. import { spawnSync } from "node:child_process"; import { winShellArgs } from "./win-shell-args.mjs"; @@ -28,9 +32,27 @@ export function claudeSpawnArgs( args, options = {}, platform = process.platform, +) { + return cliSpawnArgs("claude", args, options, platform); +} + +/** + * `spawn` arguments for any npm-installed agent CLI, correct on every platform. + * + * @param {string} command + * @param {string[]} args + * @param {object} [options] Passed through to the spawn call. + * @param {string} [platform] Defaults to the current platform; injectable for tests. + * @returns {{ command: string, args: string[], options: object }} + */ +export function cliSpawnArgs( + command, + args, + options = {}, + platform = process.platform, ) { return { - command: "claude", + command, args: winShellArgs(args, platform), options: { ...options, shell: platform === "win32" }, }; @@ -53,11 +75,26 @@ export function claudeSpawnArgs( * @param {{ spawn?: typeof spawnSync, platform?: string }} [io] * @returns {T | null} */ -export function probeClaudeVersion( +export function probeClaudeVersion(parseVersion, io = {}) { + return probeCliVersion("claude", parseVersion, io); +} + +/** + * Read any agent CLI's version, or null when there is no usable one. + * + * @template T + * @param {string} cli + * @param {(text: string) => T | null} parseVersion + * @param {{ spawn?: typeof spawnSync, platform?: string }} [io] + * @returns {T | null} + */ +export function probeCliVersion( + cli, parseVersion, { spawn = spawnSync, platform } = {}, ) { - const { command, args, options } = claudeSpawnArgs( + const { command, args, options } = cliSpawnArgs( + cli, ["--version"], { encoding: "utf8" }, platform, diff --git a/scripts/lib/claude-cli.test.mjs b/scripts/lib/claude-cli.test.mjs index 066fa58cbe..b590da109d 100644 --- a/scripts/lib/claude-cli.test.mjs +++ b/scripts/lib/claude-cli.test.mjs @@ -7,7 +7,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { claudeSpawnArgs, probeClaudeVersion } from "./claude-cli.mjs"; +import { + claudeSpawnArgs, + cliSpawnArgs, + probeClaudeVersion, + probeCliVersion, +} from "./claude-cli.mjs"; test("spawns without a shell off Windows", () => { const { command, args, options } = claudeSpawnArgs( @@ -74,3 +79,28 @@ test("probeClaudeVersion asks for the shell on Windows", () => { assert.equal(seen.shell, true); assert.equal(seen.encoding, "utf8"); }); + +test("the generic helpers name the command they were given (#2397)", () => { + // `copilot` is an npm `.cmd` shim on Windows as well, so it takes the same + // shell-and-quoting decision rather than a second copy of it. + const { command, options, args } = cliSpawnArgs( + "copilot", + ["--prompt", "a & b"], + {}, + "win32", + ); + assert.equal(command, "copilot"); + assert.equal(options.shell, true); + assert.deepEqual(args, ["--prompt", '"a & b"']); + + let spawned; + const version = probeCliVersion("copilot", (t) => t.trim(), { + spawn: (c) => { + spawned = c; + return { status: 0, stdout: "GitHub Copilot CLI 1.0.85.\n" }; + }, + platform: "linux", + }); + assert.equal(spawned, "copilot"); + assert.equal(version, "GitHub Copilot CLI 1.0.85."); +}); diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index ed7fc245f1..6862232f56 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -29,6 +29,17 @@ // npm run skills:eval -- testing # one skill's cases // npm run skills:eval -- testing test-servers # several skills' cases // RUNS=5 THRESHOLD=0.8 npm run skills:eval +// AGENT=copilot npm run skills:eval # the same cases, driven through GitHub Copilot +// +// Two agents, measured separately (#2397). Some maintainers work on this repo +// with the GitHub Copilot CLI, which discovers project skills from +// `.claude/skills/` as well as `.github/skills/` (verified on 1.0.85: `copilot +// skill list` shows all ten). Discovery is not triggering, though, so the same +// committed cases run through `copilot` when `AGENT=copilot`. One invocation +// measures ONE agent and says which in every heading: a Copilot rate and a +// Claude rate come from different models behind different harnesses, so they +// are never folded into one number, for the same reason first-move and +// hand-off cases are not. // // Two kinds of case, measured and reported separately (#2204). A `expect` case // is a FIRST-MOVE measurement: one turn, does the model reach for the skill @@ -41,7 +52,7 @@ import { spawn } from "node:child_process"; import { readFileSync, existsSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { claudeSpawnArgs, probeClaudeVersion } from "./lib/claude-cli.mjs"; +import { cliSpawnArgs, probeCliVersion } from "./lib/claude-cli.mjs"; import { isChainCase, parseClaudeVersion, @@ -94,6 +105,10 @@ const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4); */ const CHAIN_MAX_TURNS = Number(process.env.CHAIN_MAX_TURNS ?? 14); +/** The agent CLIs this eval can drive. `claude` is the default. */ +export const AGENTS = ["claude", "copilot"]; +const AGENT = process.env.AGENT ?? "claude"; + /** Collect the committed cases for every model-invoked skill (optionally one). */ /** * Collect the committed cases, and the set of skill names that are OURS. @@ -243,6 +258,57 @@ export function collectSkillInvocations(text, turnOffset = 0) { return { invoked, rest, result, nextTurn: turn }; } +/** + * Extract the skills the Copilot CLI's `skill` tool was asked for, from a chunk + * of `copilot --output-format json` output (#2397). + * + * The same contract as `collectSkillInvocations`, over a different event shape. + * Copilot emits one `assistant.message` per model call, carrying every tool + * request that call made in `data.toolRequests` — so one such event is one + * turn, and two `skill` requests inside it are concurrent guesses rather than a + * hand-off, exactly as for Claude. The terminal event is `{type: "result", + * exitCode}`; it is reported as `success` for exit 0 and `exit_` + * otherwise, so `runRejection` classifies both CLIs with one table. + * + * A request is counted whether or not the tool then succeeded. That matches + * the Claude side, which scores the `tool_use` block, and it is what a trigger + * eval asks: did the model reach for the skill. (Copilot refuses a request + * for a `disable-model-invocation: true` skill with "Skill not found", and + * no case here names such a skill.) + * + * @param {string} text Newline-delimited JSON events; a trailing partial line + * is held back. + * @param {number} [turnOffset] + * @returns {{ invoked: {payload: string, turn: number}[], rest: string, + * result: string | null, nextTurn: number }} + */ +export function collectCopilotSkillInvocations(text, turnOffset = 0) { + const lines = text.split("\n"); + const rest = lines.pop() ?? ""; + const invoked = []; + let result = null; + let turn = turnOffset; + for (const line of lines) { + if (!line.trim()) continue; + let evt; + try { + evt = JSON.parse(line); + } catch { + continue; + } + if (evt?.type === "result") { + result = evt.exitCode === 0 ? "success" : `exit_${evt.exitCode}`; + } + if (evt?.type !== "assistant.message") continue; + turn++; + for (const req of evt.data?.toolRequests ?? []) { + if (req?.name !== "skill") continue; + invoked.push({ payload: JSON.stringify(req.arguments ?? {}), turn }); + } + } + return { invoked, rest, result, nextTurn: turn }; +} + /** * The skill names one recorded invocation asked for. * @@ -262,7 +328,13 @@ function entryNames(entry) { * reject exactly the runs the eval is trying to count. Verified against the * CLI: a firing prompt ends `{subtype: "error_max_turns", num_turns: 2}`. */ -const CONCLUSIVE_RESULTS = new Set(["success", "error_max_turns"]); +const CONCLUSIVE_RESULTS = new Set([ + "success", + "error_max_turns", + // Copilot has no `--max-turns`; `runPrompt` stops it at the budget itself and + // records this. It is the same observation as `error_max_turns` (#2397). + "turn_budget", +]); /** * Whether a finished run produced a usable observation. @@ -447,6 +519,95 @@ const DISALLOWED_TOOLS = [ "KillShell", ]; +/** + * The Copilot CLI's equivalents of the three lists above (#2397). + * + * Its permission model has the same split Claude's does, under other names: + * `--available-tools` decides what the model can SEE ("disables all other + * tools"), while `--allow-tool` / `--deny-tool` only decide approval and + * "do not expose tools that were filtered out" — so availability is the + * restriction here too, and the deny patterns are the unconditional second + * layer. `view`/`glob`/`grep` are Copilot's read tools and `skill` is its + * skill loader, confirmed from a live run's `toolRequests`. + * + * Copilot's deny list takes permission KINDS rather than tool names: `shell` + * (every shell command), `write` (every file-modifying tool) and `url` (every + * URL the shell or web-fetch tools would reach). `--disable-builtin-mcps` + * drops the one MCP server it ships (`github-mcp-server`); it does not read + * this checkout's `.mcp.json`, and any server a contributor configured is + * invisible past `--available-tools` anyway. + */ +const COPILOT_AVAILABLE_TOOLS = ["view", "glob", "grep", "skill"]; +const COPILOT_DENIED_KINDS = ["shell", "write", "url"]; + +/** + * The command line for one headless run of `agent`. + * + * @param {string} agent One of `AGENTS`. + * @param {number} maxTurns + * @returns {string[]} + */ +export function agentArgs(agent, maxTurns) { + if (agent === "copilot") { + return [ + // No `-p`: with none, the CLI reads the prompt from piped stdin, which + // keeps it out of argv for the same reasons as the Claude run below. + "--output-format", + "json", + "--available-tools", + COPILOT_AVAILABLE_TOOLS.join(","), + "--allow-tool", + COPILOT_AVAILABLE_TOOLS.join(","), + ...COPILOT_DENIED_KINDS.flatMap((kind) => ["--deny-tool", kind]), + "--disable-builtin-mcps", + "--disallow-temp-dir", + "--no-ask-user", + // A measurement should run the CLI version it reports, not one it + // downloaded partway through the suite. + "--no-auto-update", + ]; + } + if (agent !== "claude") throw new Error(`unknown agent \`${agent}\``); + return [ + "-p", + "--output-format", + "stream-json", + "--verbose", + "--max-turns", + String(maxTurns), + // Keep the run read-only, across every turn it is given: what the + // harness needs, minus what it must never do, minus every MCP server + // this checkout or the contributor happens to configure. + "--tools", + ALLOWED_TOOLS.join(","), + "--allowedTools", + ALLOWED_TOOLS.join(","), + "--disallowedTools", + DISALLOWED_TOOLS.join(","), + "--strict-mcp-config", + ]; +} + +/** + * Stop a child and everything it started. + * + * On Windows the CLI runs under `cmd.exe`, so `child.kill()` would end the + * shell and orphan the agent still spending model calls; `taskkill /T` takes + * the tree. + * + * @param {import("node:child_process").ChildProcess} child + * @param {string} platform + */ +function killTree(child, platform) { + if (platform === "win32" && child.pid !== undefined) { + spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); + return; + } + child.kill("SIGTERM"); +} + /** * Drive one fresh session and return the payloads the `Skill` tool was called * with. @@ -468,6 +629,8 @@ export function runPrompt( cwd = ROOT, platform = process.platform, maxTurns = 1, + agent = "claude", + killFn = killTree, } = {}, ) { return new Promise((resolve, reject) => { @@ -477,29 +640,21 @@ export function runPrompt( // through a shell, and `cmd.exe` would re-parse any prompt containing a // metacharacter as syntax (Copilot). It also keeps the prompt out of the // process table. - const { command, args, options } = claudeSpawnArgs( - [ - "-p", - "--output-format", - "stream-json", - "--verbose", - "--max-turns", - String(maxTurns), - // Keep the run read-only, across every turn it is given: what the - // harness needs, minus what it must never do, minus every MCP server - // this checkout or the contributor happens to configure. - "--tools", - ALLOWED_TOOLS.join(","), - "--allowedTools", - ALLOWED_TOOLS.join(","), - "--disallowedTools", - DISALLOWED_TOOLS.join(","), - "--strict-mcp-config", - ], + const { command, args, options } = cliSpawnArgs( + agent, + agentArgs(agent, maxTurns), { cwd, stdio: ["pipe", "pipe", "inherit"] }, platform, ); const p = spawnFn(command, args, options); + const collect = + agent === "copilot" + ? collectCopilotSkillInvocations + : collectSkillInvocations; + // Copilot has no `--max-turns`, so the budget is enforced from outside: once + // the stream has shown `maxTurns` model calls, the run has made every move + // it is being scored on, and the rest would only spend metered calls. + let stopped = false; let buf = ""; const invoked = []; @@ -508,20 +663,23 @@ export function runPrompt( // stream rather than restarting at each read. let turnOffset = 0; p.stdout.on("data", (chunk) => { - const parsed = collectSkillInvocations( - buf + chunk.toString(), - turnOffset, - ); + if (stopped) return; + const parsed = collect(buf + chunk.toString(), turnOffset); buf = parsed.rest; turnOffset = parsed.nextTurn; for (const entry of parsed.invoked) invoked.push(entry); if (parsed.result !== null) result = parsed.result; + if (agent === "copilot" && turnOffset >= maxTurns && result === null) { + stopped = true; + result = "turn_budget"; + killFn(p, platform); + } }); p.on("error", reject); p.on("close", (code) => { const rejection = runRejection({ result, code }); if (rejection !== null) { - reject(new Error(`\`claude -p\` ${rejection} for prompt: ${prompt}`)); + reject(new Error(`\`${agent}\` ${rejection} for prompt: ${prompt}`)); return; } resolve(invoked); @@ -530,6 +688,16 @@ export function runPrompt( }); } +/** + * Read `copilot --version` (`GitHub Copilot CLI 1.0.85.`) down to its version. + * + * @param {string} text + * @returns {string | null} + */ +export function parseCopilotVersion(text) { + return /^GitHub Copilot CLI (\d+\.\d+\.\d+)/m.exec(text)?.[1] ?? null; +} + async function pool(items, n, fn) { const out = new Array(items.length); let i = 0; @@ -574,7 +742,8 @@ export function passesThreshold(rate, threshold, strict) { * @param {object[]} cases * @param {{c: object, invoked: Iterable}[]} results One per sample. * @param {Set | null} ours - * @param {{threshold: number, chainThreshold: number, chainMaxTurns: number}} opts + * @param {{threshold: number, chainThreshold: number, chainMaxTurns: number, + * agent?: string}} opts * @returns {{ lines: string[], failed: number }} */ export function formatReport(cases, results, ours, opts) { @@ -603,15 +772,18 @@ export function formatReport(cases, results, ours, opts) { const direct = cases.filter((c) => !isChainCase(c)); const chained = cases.filter(isChainCase); + // Every heading names the agent, so a Copilot report pasted next to a Claude + // one cannot be read as the same measurement (#2397). + const agent = opts.agent ?? "claude"; const directShort = group( direct, - "First move (1 turn)", + `First move (1 turn) — ${agent}`, opts.threshold, false, ); const chainedShort = group( chained, - `Hand-off (${opts.chainMaxTurns} turns)`, + `Hand-off (${opts.chainMaxTurns} turns) — ${agent}`, opts.chainThreshold, true, ); @@ -624,13 +796,13 @@ export function formatReport(cases, results, ours, opts) { lines.push(""); if (direct.length > 0) { lines.push( - `${direct.length - directShort}/${direct.length} first-move cases at or above ${opts.threshold * 100}%.`, + `${direct.length - directShort}/${direct.length} ${agent} first-move cases at or above ${opts.threshold * 100}%.`, ); } lines.push( chained.length === 0 ? "No hand-off cases in this selection." - : `${chained.length - chainedShort}/${chained.length} hand-off cases above ${opts.chainThreshold * 100}%.`, + : `${chained.length - chainedShort}/${chained.length} ${agent} hand-off cases above ${opts.chainThreshold * 100}%.`, ); return { lines, failed }; } @@ -660,9 +832,23 @@ async function main() { ); process.exit(1); } - if (probeClaudeVersion(parseClaudeVersion) === null) { + if (!AGENTS.includes(AGENT)) { + console.error( + `skills:eval — AGENT must be one of ${AGENTS.join(", ")} (got \`${AGENT}\`).`, + ); + process.exit(1); + } + const version = probeCliVersion( + AGENT, + AGENT === "claude" ? parseClaudeVersion : parseCopilotVersion, + ); + if (version === null) { console.error( - "skills:eval — no usable `claude` CLI on PATH. This eval needs one.", + AGENT === "claude" + ? "skills:eval — no usable `claude` CLI on PATH. This eval needs one." + : "skills:eval — no usable `copilot` CLI on PATH. Install it with " + + "`npm install -g @github/copilot` and sign in (`copilot` then " + + "`/login`, or export COPILOT_GITHUB_TOKEN), then re-run.", ); process.exit(1); } @@ -680,6 +866,7 @@ async function main() { c, invoked: await runPrompt(c.prompt, { maxTurns: isChainCase(c) ? CHAIN_MAX_TURNS : 1, + agent: AGENT, }), })); @@ -687,6 +874,7 @@ async function main() { threshold: THRESHOLD, chainThreshold: CHAIN_THRESHOLD, chainMaxTurns: CHAIN_MAX_TURNS, + agent: AGENT, }); for (const line of lines) console.log(line); process.exit(failed > 0 ? 1 : 0); diff --git a/scripts/skill-eval.test.mjs b/scripts/skill-eval.test.mjs index 97d4b5b5aa..75982279d0 100644 --- a/scripts/skill-eval.test.mjs +++ b/scripts/skill-eval.test.mjs @@ -23,6 +23,9 @@ import { passesThreshold, collectCases, collectSkillInvocations, + collectCopilotSkillInvocations, + agentArgs, + parseCopilotVersion, runRejection, invokedSkillNames, runPrompt, @@ -478,8 +481,8 @@ test("the report keeps the two measurements in separate columns", () => { const text = lines.join("\n"); assert.match(text, /First move \(1 turn\)/); assert.match(text, /Hand-off \(14 turns\)/); - assert.match(text, /1\/1 first-move cases at or above 80%\./); - assert.match(text, /0\/1 hand-off cases above 50%\./); + assert.match(text, /1\/1 claude first-move cases at or above 80%\./); + assert.match(text, /0\/1 claude hand-off cases above 50%\./); // One summary line per kind, and no line that merges them. assert.equal(text.match(/cases (at or above|above)/g).length, 2); assert.equal(failed, 1, "the chained case is short, the direct one is not"); @@ -524,7 +527,7 @@ test("a single-kind selection reports only that kind, and says so", () => { ); const text = chainOnly.lines.join("\n"); assert.doesNotMatch(text, /first-move cases/); - assert.match(text, /1\/1 hand-off cases above 50%\./); + assert.match(text, /1\/1 claude hand-off cases above 50%\./); assert.equal(chainOnly.failed, 0); }); @@ -737,3 +740,196 @@ test("collection fails loudly rather than silently shrinking the set", () => { ); rmSync(root, { recursive: true, force: true }); }); + +// --- Copilot (#2397) -------------------------------------------------------- + +/** One Copilot model call, with the tool requests it made. */ +const copilotMessage = (...requests) => + JSON.stringify({ + type: "assistant.message", + data: { + toolRequests: requests.map(([name, args]) => ({ + name, + arguments: args, + type: "function", + })), + }, + }) + "\n"; +const copilotSkill = (skill) => ["skill", { skill }]; +const copilotResult = (exitCode) => + JSON.stringify({ type: "result", exitCode }) + "\n"; + +test("collectCopilotSkillInvocations reads skill requests, one turn per model call", () => { + const text = + copilotMessage(copilotSkill("pr-flow"), ["view", { path: "/x" }]) + + JSON.stringify({ type: "assistant.message_delta", data: {} }) + + "\nnot json\n" + + copilotMessage(copilotSkill("board-ops"), copilotSkill("issue-create")) + + copilotMessage() + + copilotResult(0); + const parsed = collectCopilotSkillInvocations(text); + assert.deepEqual(parsed.invoked, [ + { payload: '{"skill":"pr-flow"}', turn: 1 }, + { payload: '{"skill":"board-ops"}', turn: 2 }, + { payload: '{"skill":"issue-create"}', turn: 2 }, + ]); + assert.equal(parsed.nextTurn, 3); + assert.equal(parsed.result, "success"); + // Two skills in one model call are not a hand-off, exactly as for Claude. + assert.equal(chainHit(["board-ops", "issue-create"], parsed.invoked), false); + assert.equal(chainHit(["pr-flow", "board-ops"], parsed.invoked), true); +}); + +test("collectCopilotSkillInvocations maps the exit code and holds back a partial line", () => { + const whole = copilotMessage(copilotSkill("testing")); + const first = collectCopilotSkillInvocations(whole.slice(0, 20)); + assert.deepEqual(first.invoked, []); + const second = collectCopilotSkillInvocations( + first.rest + whole.slice(20) + copilotResult(1), + first.nextTurn, + ); + assert.equal(second.invoked.length, 1); + assert.equal(second.result, "exit_1"); + assert.match(runRejection({ result: second.result, code: 1 }), /exit_1/); + // A request with no arguments is recorded, and simply names nothing. + const bare = collectCopilotSkillInvocations( + JSON.stringify({ + type: "assistant.message", + data: { toolRequests: [{ name: "skill" }] }, + }) + "\n", + ); + assert.deepEqual(bare.invoked, [{ payload: "{}", turn: 1 }]); +}); + +test("a Copilot run bounds availability, not just approval", () => { + const args = agentArgs("copilot", 1); + const after = (flag) => args[args.indexOf(flag) + 1]; + // `--available-tools` is what the model can see; `--allow-tool` only spares + // a prompt, so it alone would bound nothing. + assert.equal(after("--available-tools"), "view,glob,grep,skill"); + assert.equal(after("--allow-tool"), "view,glob,grep,skill"); + const denied = args.flatMap((a, i) => + args[i - 1] === "--deny-tool" ? [a] : [], + ); + assert.deepEqual(denied, ["shell", "write", "url"]); + for (const flag of [ + "--disable-builtin-mcps", + "--no-ask-user", + "--no-auto-update", + ]) { + assert.ok(args.includes(flag), `${flag} must be set`); + } + // The prompt arrives on stdin; `-p` would demand it in argv. + assert.ok(!args.includes("-p") && !args.includes("--prompt")); + // Copilot has no turn flag at all — the budget is enforced by `runPrompt`. + assert.ok(!args.includes("--max-turns")); + assert.throws(() => agentArgs("cursor", 1), /unknown agent `cursor`/); +}); + +/** A fake child that emits the given chunks, recording whether it was killed. */ +function fakeCopilot(chunks, { code = 0 } = {}) { + const state = { command: null, killed: 0, written: null }; + const spawnFn = (command) => { + state.command = command; + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stdin = { end: (t) => (state.written = t) }; + queueMicrotask(() => { + for (const c of chunks) child.stdout.emit("data", Buffer.from(c)); + child.emit("close", state.killed > 0 ? null : code); + }); + return child; + }; + const killFn = () => state.killed++; + return { state, spawnFn, killFn }; +} + +test("runPrompt stops a Copilot run once it has made its budgeted moves", async () => { + const { state, spawnFn, killFn } = fakeCopilot([ + copilotMessage(copilotSkill("pr-flow")), + copilotMessage(copilotSkill("board-ops")), + ]); + const invoked = await runPrompt("take it to a PR", { + agent: "copilot", + spawnFn, + killFn, + platform: "linux", + }); + assert.equal(state.command, "copilot"); + assert.equal(state.written, "take it to a PR"); + assert.equal(state.killed, 1, "stopped exactly once"); + // Only the first move is scored: the second call arrived after the stop. + assert.deepEqual( + invoked.map((e) => e.payload), + ['{"skill":"pr-flow"}'], + ); +}); + +test("runPrompt accepts a Copilot run that finished inside its budget", async () => { + const { state, spawnFn, killFn } = fakeCopilot([ + copilotMessage(copilotSkill("testing")), + copilotMessage(), + copilotResult(0), + ]); + const invoked = await runPrompt("p", { + agent: "copilot", + maxTurns: 14, + spawnFn, + killFn, + }); + assert.equal(state.killed, 0); + assert.equal(sampleHit("testing", invoked), true); +}); + +test("runPrompt rejects a Copilot run that never observed anything", async () => { + // An unauthenticated CLI prints its login hint and exits 1 with no events; + // that must not read as "no skill fired". + const { spawnFn, killFn } = fakeCopilot(["To authenticate…\n"], { + code: 1, + }); + await assert.rejects( + runPrompt("p", { agent: "copilot", spawnFn, killFn }), + /`copilot` produced no terminal `result` event \(exit 1\)/, + ); + const failed = fakeCopilot([copilotResult(2)], { code: 2 }); + await assert.rejects( + runPrompt("p", { agent: "copilot", ...failed }), + /ended `exit_2`/, + ); +}); + +test("parseCopilotVersion reads the CLI's banner", () => { + assert.equal( + parseCopilotVersion("GitHub Copilot CLI 1.0.85.\nRun 'copilot update'"), + "1.0.85", + ); + assert.equal(parseCopilotVersion("copilot: command not found"), null); +}); + +test("the report names the agent it measured", () => { + const direct = { prompt: "d", expect: "testing" }; + const text = formatReport( + [direct], + samples(direct, 3, 3), + new Set(["testing"]), + { + ...OPTS, + agent: "copilot", + }, + ).lines.join("\n"); + assert.match(text, /First move \(1 turn\) — copilot/); + assert.match(text, /1\/1 copilot first-move cases at or above 80%\./); + assert.doesNotMatch(text, /claude/); +}); + +test("an unknown AGENT is rejected before anything runs", () => { + const res = spawnSync(process.execPath, [SCRIPT_PATH], { + env: { ...process.env, AGENT: "cursor" }, + encoding: "utf8", + }); + assert.equal(res.status, 1); + assert.match( + res.stderr, + /AGENT must be one of claude, copilot \(got `cursor`\)/, + ); +}); From 4badc53025ea4ec6ccdce1f0908af7fe10b2e5f6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 17:12:24 -0400 Subject: [PATCH 21/68] fix: never score a Copilot turn past the budget from a coalesced chunk (#2398 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- scripts/skill-eval.mjs | 10 +++++++++- scripts/skill-eval.test.mjs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index 6862232f56..4976c69e07 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -667,7 +667,15 @@ export function runPrompt( const parsed = collect(buf + chunk.toString(), turnOffset); buf = parsed.rest; turnOffset = parsed.nextTurn; - for (const entry of parsed.invoked) invoked.push(entry); + for (const entry of parsed.invoked) { + // A pipe does not preserve event boundaries, so one chunk can carry a + // model call past the budget — even the whole rest of the run, result + // included. Scoring is bounded by turn number rather than by when the + // stop happened to land (Copilot). Claude's own `--max-turns` already + // bounds its stream. + if (agent === "copilot" && entry.turn > maxTurns) continue; + invoked.push(entry); + } if (parsed.result !== null) result = parsed.result; if (agent === "copilot" && turnOffset >= maxTurns && result === null) { stopped = true; diff --git a/scripts/skill-eval.test.mjs b/scripts/skill-eval.test.mjs index 75982279d0..b39a32426f 100644 --- a/scripts/skill-eval.test.mjs +++ b/scripts/skill-eval.test.mjs @@ -865,6 +865,23 @@ test("runPrompt stops a Copilot run once it has made its budgeted moves", async ); }); +test("runPrompt never scores a Copilot turn past the budget, however the pipe splits it", async () => { + // Both calls and the result in ONE chunk: stopping the process is too late + // to keep turn 2 out, and the result event alone would skip the stop. + const { spawnFn, killFn } = fakeCopilot([ + copilotMessage() + + copilotMessage(copilotSkill("board-ops")) + + copilotResult(0), + ]); + const invoked = await runPrompt("p", { + agent: "copilot", + spawnFn, + killFn, + }); + assert.deepEqual(invoked, []); + assert.equal(sampleHit(null, invoked, new Set(["board-ops"])), true); +}); + test("runPrompt accepts a Copilot run that finished inside its budget", async () => { const { state, spawnFn, killFn } = fakeCopilot([ copilotMessage(copilotSkill("testing")), From 57f23bc3ccd004ad3e34d2ec221d42ee0579621b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 17:23:51 -0400 Subject: [PATCH 22/68] fix: surface the Copilot sign-in hint, name the agent on the empty hand-off line, pin --disallow-temp-dir (#2398 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- scripts/skill-eval.mjs | 13 +++++++++++-- scripts/skill-eval.test.mjs | 11 ++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index 4976c69e07..216b022cb6 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -687,7 +687,16 @@ export function runPrompt( p.on("close", (code) => { const rejection = runRejection({ result, code }); if (rejection !== null) { - reject(new Error(`\`${agent}\` ${rejection} for prompt: ${prompt}`)); + // An unauthenticated `copilot` answers `--version` fine, then prints + // its login hint to the stdout this run captures and exits with no + // events — so the one actionable fact would otherwise be swallowed. + const hint = + agent === "copilot" && result === null + ? " — if it is not signed in, run `copilot` and `/login`, or export COPILOT_GITHUB_TOKEN" + : ""; + reject( + new Error(`\`${agent}\` ${rejection}${hint} for prompt: ${prompt}`), + ); return; } resolve(invoked); @@ -809,7 +818,7 @@ export function formatReport(cases, results, ours, opts) { } lines.push( chained.length === 0 - ? "No hand-off cases in this selection." + ? `No ${agent} hand-off cases in this selection.` : `${chained.length - chainedShort}/${chained.length} ${agent} hand-off cases above ${opts.chainThreshold * 100}%.`, ); return { lines, failed }; diff --git a/scripts/skill-eval.test.mjs b/scripts/skill-eval.test.mjs index b39a32426f..e8259e32a8 100644 --- a/scripts/skill-eval.test.mjs +++ b/scripts/skill-eval.test.mjs @@ -513,7 +513,10 @@ test("a single-kind selection reports only that kind, and says so", () => { new Set(["test-servers"]), OPTS, ); - assert.match(only.lines.join("\n"), /No hand-off cases in this selection\./); + assert.match( + only.lines.join("\n"), + /No claude hand-off cases in this selection\./, + ); assert.doesNotMatch(only.lines.join("\n"), /Hand-off \(14 turns\)/); assert.equal(only.failed, 0); @@ -814,6 +817,7 @@ test("a Copilot run bounds availability, not just approval", () => { assert.deepEqual(denied, ["shell", "write", "url"]); for (const flag of [ "--disable-builtin-mcps", + "--disallow-temp-dir", "--no-ask-user", "--no-auto-update", ]) { @@ -906,12 +910,12 @@ test("runPrompt rejects a Copilot run that never observed anything", async () => }); await assert.rejects( runPrompt("p", { agent: "copilot", spawnFn, killFn }), - /`copilot` produced no terminal `result` event \(exit 1\)/, + /`copilot` produced no terminal `result` event \(exit 1\) — if it is not signed in, run `copilot` and `\/login`/, ); const failed = fakeCopilot([copilotResult(2)], { code: 2 }); await assert.rejects( runPrompt("p", { agent: "copilot", ...failed }), - /ended `exit_2`/, + (e) => /ended `exit_2`/.test(e.message) && !/signed in/.test(e.message), ); }); @@ -936,6 +940,7 @@ test("the report names the agent it measured", () => { ).lines.join("\n"); assert.match(text, /First move \(1 turn\) — copilot/); assert.match(text, /1\/1 copilot first-move cases at or above 80%\./); + assert.match(text, /No copilot hand-off cases in this selection\./); assert.doesNotMatch(text, /claude/); }); From ce20b2efb6f932538faa2c7d4a11a0755be34cea Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 17:40:56 -0400 Subject: [PATCH 23/68] docs: re-measure the Copilot hand-offs at RUNS=5 and track the pagination gap in #2399 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/skill-authoring.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/skill-authoring.md b/docs/skill-authoring.md index 10187743e5..c07c30567e 100644 --- a/docs/skill-authoring.md +++ b/docs/skill-authoring.md @@ -507,13 +507,19 @@ printf '%s' "" \ probe, nothing here stops the run after the first move, so `head -3` only trims the output; the session carries on until it answers. -**First measurement** (2026-09-16, Copilot CLI 1.0.85, Claude Sonnet 5, -`RUNS=3`, the full suite): **63/63 first-move cases at 100%**, every negative -clean, and **1/2 hand-off cases** — `testing → test-servers` at 100% on the -integration-test prompt and 33% on the pagination one. Read the hand-off the -way the section above says to: a noisy second-hop measurement at `RUNS=3`, not -a Copilot-specific defect, until a `RUNS=5` Claude run of the same case says -otherwise. +**First measurement** (2026-09-16, Copilot CLI 1.0.85, Claude Sonnet 5): the +full suite at `RUNS=3` put **63/63 first-move cases at 100%**, every negative +clean. The hand-offs were then re-measured at `RUNS=5`, since `RUNS=3` is too +coarse to read a chain: + +| Hand-off case | Copilot, `RUNS=5` | Claude, `RUNS=5` (#2247) | +| --- | --- | --- | +| `testing → test-servers`, "Write an integration test that exercises tool listing end to end." | 100% | 100% | +| `testing → test-servers`, "Add end-to-end coverage for the tool-list pagination path." | **40%** | 100% | + +So the pagination prompt is a **Copilot-specific shortfall**, not noise: it held +below the 50% bar at both sample sizes, while the same case clears 100% under +Claude. First moves transfer; this one hand-off does not yet (#2399). ## Checklist for a new or edited skill From eb44b0d7de899fee337fa4f3c4de4bbaa8aca98d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 17:55:13 -0400 Subject: [PATCH 24/68] fix: stop Copilot's whole process group at the turn budget; record user-invocable behavior (#2398 review) SIGTERM to the copilot Node wrapper does not reach the native binary it starts, which kept running its model call. Copilot runs now lead their own process group, killTree signals the group, and an interrupted eval stops every run still in flight. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- AGENTS.md | 2 +- docs/skill-authoring.md | 13 +++++-- scripts/skill-eval.mjs | 60 +++++++++++++++++++++++++++------ scripts/skill-eval.test.mjs | 67 +++++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e57b8ce69e..3a4bf713d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,7 +295,7 @@ node/field/option IDs, and the option-deletion hazard` was cut at `#28`, so 90 variance: each prompt is scored on its own `passes / RUNS`, so more prompts steady nothing, they cover more of the ways someone might reach the skill and expose a description that only fires on one narrow phrasing; `npm run skills:eval` actually runs them (it needs the - `claude` CLI and real model calls, so it is deliberately **not** in the gate — + selected agent's CLI — `claude` by default, `copilot` with `AGENT=copilot` — and real model calls, so it is deliberately **not** in the gate — run it when adding a skill or editing a model-invoked description). **The skills serve GitHub Copilot users too, from where they are.** The Copilot CLI discovers `.claude/skills/` alongside `.github/skills/`, so a diff --git a/docs/skill-authoring.md b/docs/skill-authoring.md index c07c30567e..1ddd388a7c 100644 --- a/docs/skill-authoring.md +++ b/docs/skill-authoring.md @@ -418,8 +418,8 @@ CHAIN_THRESHOLD=0.4 CHAIN_MAX_TURNS=20 npm run skills:eval -- test-servers The summary is two lines, never one: ``` -7/7 first-move cases at or above 80%. -2/2 hand-off cases above 50%. +7/7 claude first-move cases at or above 80%. +2/2 claude hand-off cases above 50%. ``` Narrowing the run never narrows what a **negative** case is scored against — a @@ -467,7 +467,14 @@ What was verified about how Copilot treats the files, on 1.0.85: goes.** Its `skill` tool refused `release` with `Skill not found`, and the model then opened `.claude/skills/release/SKILL.md` with `view` and read it anyway. A name-only skill is kept out of the automatic listing, not made - unreadable — which is equally true of Claude, which can `Read` the file. + unreadable — which is equally true of Claude, which can `Read` the file. It + still offers `/release` in its interactive slash-command menu. +- **It honors `user-invocable: false` in that menu.** Driven through a real + pty, typing `/pro` lists `pr-flow` and `pre-push-gate` but not + `project-structure`, while `/testin` lists `testing`. The model can still load + it through its `skill` tool, which is what `user-invocable: false` is for. + (Headless `-p "/name"` is no test of this: prompt mode does not expand slash + commands, so the model simply loads the named skill as a tool call.) - **`AGENTS.md` is loaded as custom instructions**, so the rule in [Do not write a case `AGENTS.md` already answers](#do-not-write-a-case-agentsmd-already-answers) applies to Copilot runs unchanged. diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index 216b022cb6..0f77193a7d 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -591,23 +591,45 @@ export function agentArgs(agent, maxTurns) { /** * Stop a child and everything it started. * - * On Windows the CLI runs under `cmd.exe`, so `child.kill()` would end the - * shell and orphan the agent still spending model calls; `taskkill /T` takes - * the tree. - * - * @param {import("node:child_process").ChildProcess} child + * `copilot` is a Node wrapper around a native binary, and SIGTERM to the + * wrapper does NOT reach the binary: measured on 1.0.85, the native process was + * still running (and still spending its model call) ten seconds later. So on + * POSIX a Copilot run is spawned as the leader of its own process group and the + * whole group is signalled (Copilot). On Windows the CLI runs under `cmd.exe`, + * where `taskkill /T` takes the tree. + * + * @param {{ pid?: number, kill: (signal: string) => unknown }} child * @param {string} platform + * @param {{ spawnFn?: typeof spawn, killProcess?: typeof process.kill }} [io] */ -function killTree(child, platform) { - if (platform === "win32" && child.pid !== undefined) { - spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { +export function killTree( + child, + platform, + { spawnFn = spawn, killProcess = process.kill } = {}, +) { + if (child.pid === undefined) return; + if (platform === "win32") { + spawnFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", }); return; } - child.kill("SIGTERM"); + try { + killProcess(-child.pid, "SIGTERM"); + } catch { + // The group is already gone — the run finished as the budget was reached. + } } +/** + * Copilot runs still in flight, so an interrupted eval can stop them. + * + * A child in its own process group no longer receives the terminal's Ctrl-C, + * which is the price of being able to signal the group — so without this, an + * interrupted suite would leave every in-flight session running unattended. + */ +const liveCopilotRuns = new Set(); + /** * Drive one fresh session and return the payloads the `Skill` tool was called * with. @@ -643,10 +665,20 @@ export function runPrompt( const { command, args, options } = cliSpawnArgs( agent, agentArgs(agent, maxTurns), - { cwd, stdio: ["pipe", "pipe", "inherit"] }, + { + cwd, + stdio: ["pipe", "pipe", "inherit"], + // Its own process group, so `killTree` can reach the native binary the + // wrapper starts. Windows has no groups; `taskkill /T` covers it. + ...(agent === "copilot" && platform !== "win32" + ? { detached: true } + : {}), + }, platform, ); const p = spawnFn(command, args, options); + const run = { child: p, platform, killFn }; + if (agent === "copilot") liveCopilotRuns.add(run); const collect = agent === "copilot" ? collectCopilotSkillInvocations @@ -685,6 +717,7 @@ export function runPrompt( }); p.on("error", reject); p.on("close", (code) => { + liveCopilotRuns.delete(run); const rejection = runRejection({ result, code }); if (rejection !== null) { // An unauthenticated `copilot` answers `--version` fine, then prints @@ -878,6 +911,13 @@ async function main() { process.exit(1); } + for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => { + for (const run of liveCopilotRuns) run.killFn(run.child, run.platform); + process.exit(130); + }); + } + const jobs = cases.flatMap((c) => Array.from({ length: RUNS }, () => c)); const results = await pool(jobs, CONCURRENCY, async (c) => ({ c, diff --git a/scripts/skill-eval.test.mjs b/scripts/skill-eval.test.mjs index e8259e32a8..b6214039b7 100644 --- a/scripts/skill-eval.test.mjs +++ b/scripts/skill-eval.test.mjs @@ -26,6 +26,7 @@ import { collectCopilotSkillInvocations, agentArgs, parseCopilotVersion, + killTree, runRejection, invokedSkillNames, runPrompt, @@ -955,3 +956,69 @@ test("an unknown AGENT is rejected before anything runs", () => { /AGENT must be one of claude, copilot \(got `cursor`\)/, ); }); + +test("a Copilot run gets its own process group on POSIX, and none on Windows", () => { + // The native binary the wrapper starts ignores the wrapper's SIGTERM, so + // only a group signal reaches it. + const seen = {}; + for (const platform of ["linux", "win32"]) { + const { spawnFn, killFn } = fakeCopilot([copilotResult(0)]); + runPrompt("p", { + agent: "copilot", + platform, + killFn, + spawnFn: (c, a, options) => { + seen[platform] = options.detached; + return spawnFn(c, a, options); + }, + }).catch(() => {}); + } + assert.equal(seen.linux, true); + assert.equal(seen.win32, undefined); + // Claude is bounded by `--max-turns` and never killed, so it stays attached. + let claudeDetached; + runPrompt("p", { + platform: "linux", + spawnFn: (_c, _a, options) => { + claudeDetached = options.detached; + const c = new EventEmitter(); + c.stdout = new EventEmitter(); + c.stdin = { end: () => {} }; + queueMicrotask(() => c.emit("close", 0)); + return c; + }, + }).catch(() => {}); + assert.equal(claudeDetached, undefined); +}); + +test("killTree signals the whole group on POSIX and the tree on Windows", () => { + const kills = []; + killTree( + { pid: 4242, kill: () => assert.fail("not the leader alone") }, + "darwin", + { + killProcess: (pid, signal) => kills.push([pid, signal]), + }, + ); + assert.deepEqual(kills, [[-4242, "SIGTERM"]]); + + // A group that already exited is not an error. + assert.doesNotThrow(() => + killTree({ pid: 4242, kill: () => {} }, "linux", { + killProcess: () => { + throw Object.assign(new Error("ESRCH"), { code: "ESRCH" }); + }, + }), + ); + + const spawned = []; + killTree({ pid: 4242, kill: () => {} }, "win32", { + spawnFn: (c, a) => spawned.push([c, ...a]), + }); + assert.deepEqual(spawned, [["taskkill", "/pid", "4242", "/T", "/F"]]); + + // A child that never started has nothing to stop. + killTree({ pid: undefined, kill: () => {} }, "linux", { + killProcess: () => assert.fail("no pid, no signal"), + }); +}); From 54f15c048baa78c1e3a9853630a65492b9138e5c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 18:06:34 -0400 Subject: [PATCH 25/68] fix: stop in-flight Copilot groups when a sample rejects, not only on a signal (#2398 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- scripts/skill-eval.mjs | 24 +++++++++++++++++++++++- scripts/skill-eval.test.mjs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index 0f77193a7d..735643d77a 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -630,6 +630,27 @@ export function killTree( */ const liveCopilotRuns = new Set(); +/** + * Stop every Copilot run still in flight. + * + * Every way the eval can end early has to come through here: an interrupt, and + * just as much one sample rejecting — `pool` rejects on the first failure and + * `main().catch` exits while the other detached groups are still running, and + * a process exit does not reach them (Copilot). + * + * @param {Set<{child: object, platform: string, killFn: Function}>} [runs] + * @returns {number} How many runs were signalled. + */ +export function stopLiveCopilotRuns(runs = liveCopilotRuns) { + let n = 0; + for (const run of runs) { + run.killFn(run.child, run.platform); + n++; + } + runs.clear(); + return n; +} + /** * Drive one fresh session and return the payloads the `Skill` tool was called * with. @@ -913,7 +934,7 @@ async function main() { for (const signal of ["SIGINT", "SIGTERM"]) { process.once(signal, () => { - for (const run of liveCopilotRuns) run.killFn(run.child, run.platform); + stopLiveCopilotRuns(); process.exit(130); }); } @@ -942,6 +963,7 @@ if ( path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { main().catch((e) => { + stopLiveCopilotRuns(); console.error(e.message ?? e); process.exit(1); }); diff --git a/scripts/skill-eval.test.mjs b/scripts/skill-eval.test.mjs index b6214039b7..d9a38d0046 100644 --- a/scripts/skill-eval.test.mjs +++ b/scripts/skill-eval.test.mjs @@ -27,6 +27,7 @@ import { agentArgs, parseCopilotVersion, killTree, + stopLiveCopilotRuns, runRejection, invokedSkillNames, runPrompt, @@ -1022,3 +1023,33 @@ test("killTree signals the whole group on POSIX and the tree on Windows", () => killProcess: () => assert.fail("no pid, no signal"), }); }); + +test("stopLiveCopilotRuns stops every run in flight, once", () => { + // The early-exit path: one sample rejects, `main().catch` exits, and the + // other detached groups would otherwise keep spending model calls. + const stopped = []; + const runs = new Set( + ["a", "b"].map((id) => ({ + child: { id }, + platform: "linux", + killFn: (child, platform) => stopped.push([child.id, platform]), + })), + ); + assert.equal(stopLiveCopilotRuns(runs), 2); + assert.deepEqual(stopped, [ + ["a", "linux"], + ["b", "linux"], + ]); + assert.equal(stopLiveCopilotRuns(runs), 0, "nothing is signalled twice"); +}); + +test("a rejected Copilot run leaves the in-flight set", async () => { + // A run that closed is no longer live, so the cleanup never signals a pid + // that may since have been reused. + const { spawnFn, killFn } = fakeCopilot([copilotResult(3)], { code: 3 }); + await assert.rejects( + runPrompt("p", { agent: "copilot", spawnFn, killFn }), + /exit_3/, + ); + assert.equal(stopLiveCopilotRuns(), 0); +}); From cf6d4b962d846ff4dd84f78a6f07bb2472b4e7ca Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 18:16:10 -0400 Subject: [PATCH 26/68] docs: re-align the H2 2026 roadmap with the published MCP roadmap (#2400) The first draft was built from the 2026-03-05 roadmap because the current one was unreadable at the time. Rewrite Track A against the five priority areas of the 2026-08-22 roadmap, move efforts it no longer lists (Server Cards, Interceptors, grouping, streamed results, file pickers, gateways) to watch-only, record what has shipped since the first draft, and add an Official extensions section with per-client support and an extension-watch process for keeping up as extensions are approved. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 773 ++++++++++++++++-------------- 1 file changed, 402 insertions(+), 371 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 64da1509ce..e7b2803840 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -6,7 +6,7 @@ **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 +**Status:** Draft for WG review — **revised 2026-09-16** against the published MCP roadmap of 2026-08-22 (#2400) --- @@ -15,36 +15,33 @@ - [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) + - [3.1 Agentic messaging primitives](#31-agentic-messaging-primitives) + - [3.2 HTTP-native transport unification and hardening](#32-http-native-transport-unification-and-hardening) + - [3.3 Agent identity and enterprise-ready security](#33-agent-identity-and-enterprise-ready-security) + - [3.4 Improved primitives](#34-improved-primitives) + - [3.5 Improved SDK developer experience](#35-improved-sdk-developer-experience) + - [3.6 Conformance and validation](#36-conformance-and-validation) + - [3.7 Off the published roadmap — watch only](#37-off-the-published-roadmap--watch-only) +- [4. Official extensions](#4-official-extensions) +- [5. Track B — experience work we choose](#5-track-b--experience-work-we-choose) + - [5.1 The zoomable timeline (headline)](#51-the-zoomable-timeline-headline) + - [5.2 Session record, replay, and share](#52-session-record-replay-and-share) + - [5.3 Diff and compare](#53-diff-and-compare) + - [5.4 Command palette and global search](#54-command-palette-and-global-search) + - [5.5 Saved calls and collections](#55-saved-calls-and-collections) + - [5.6 Assertions and CI flows](#56-assertions-and-ci-flows) + - [5.7 Observability export](#57-observability-export) + - [5.8 Connection Doctor](#58-connection-doctor) + - [5.9 Server management and portability](#59-server-management-and-portability) + - [5.10 Large servers: grouping and performance](#510-large-servers-grouping-and-performance) + - [5.11 Workspace and layout](#511-workspace-and-layout) + - [5.12 Accessibility and keyboard-first operation](#512-accessibility-and-keyboard-first-operation) + - [5.13 Onboarding](#513-onboarding) + - [5.14 Plugin architecture](#514-plugin-architecture) +- [6. Sequencing](#6-sequencing) +- [7. What we are deliberately not doing](#7-what-we-are-deliberately-not-doing) +- [8. Open questions](#8-open-questions) +- [9. Sources](#9-sources) --- @@ -63,24 +60,47 @@ This document splits the next six months into those two kinds of work, so that n 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. +> **Sourcing note.** The first draft (#1980) was written when the MCP roadmap could not be read +> directly, and was built from the 2026-03-05 public page plus WG charters. This revision (#2400) +> re-aligns §3 with the **published** roadmap at +> [`modelcontextprotocol.io/development/roadmap`](https://modelcontextprotocol.io/development/roadmap), +> last updated **2026-08-22**, which organizes the next spec cycle into five priority areas — +> §3.1 to §3.5 follow them one to one. The roadmap itself states it "reflects current thinking +> rather than firm commitments" and carries **no per-item dates**, only a "six to twelve months" +> window, so the phase placements in §6 remain our estimate. It also adds §4, a standing +> section for **official extensions**, which the roadmap does not list and which we must track +> separately. + +### Already shipped since the first draft + +Worth recording, because much of the first draft's "build now" list is done and should not +be re-planned: + +| Item | Issue(s) | +| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Last-Event-ID` resumption | [#920](https://github.com/modelcontextprotocol/inspector/issues/920) | +| `server.json` support | [#922](https://github.com/modelcontextprotocol/inspector/issues/922) | +| Discover checkmarks for task extensions | [#1887](https://github.com/modelcontextprotocol/inspector/issues/1887) | +| Strict JSON Schema validation | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015) | +| The argument editor workstream (all six issues) | [#1853](https://github.com/modelcontextprotocol/inspector/issues/1853), [#1856](https://github.com/modelcontextprotocol/inspector/issues/1856), [#1885](https://github.com/modelcontextprotocol/inspector/issues/1885), [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928), [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919), [#1910](https://github.com/modelcontextprotocol/inspector/issues/1910) | +| Connection fixes (version-negotiation DX, `https://localhost`, dev containers, ghost entry) | [#962](https://github.com/modelcontextprotocol/inspector/issues/962), [#1936](https://github.com/modelcontextprotocol/inspector/issues/1936), [#1951](https://github.com/modelcontextprotocol/inspector/issues/1951), [#1914](https://github.com/modelcontextprotocol/inspector/issues/1914) | +| Server config: paste-JSON, custom headers, auth URL overrides, file-backed secrets | [#904](https://github.com/modelcontextprotocol/inspector/issues/904), [#1915](https://github.com/modelcontextprotocol/inspector/issues/1915), [#1906](https://github.com/modelcontextprotocol/inspector/issues/1906), [#1950](https://github.com/modelcontextprotocol/inspector/issues/1950) | +| Enterprise-Managed Authorization; IdP OIDC option | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509), [#1937](https://github.com/modelcontextprotocol/inspector/issues/1937) | +| Skills over MCP (SEP-2640) across web, CLI and TUI | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248) | + +Closed as **not planned**, so not carried forward: custom transports ([#1741](https://github.com/modelcontextprotocol/inspector/issues/1741)), the configurable-proxy base ([#1684](https://github.com/modelcontextprotocol/inspector/issues/1684)), the readiness summary ([#1916](https://github.com/modelcontextprotocol/inspector/issues/1916)), full panel collapse ([#928](https://github.com/modelcontextprotocol/inspector/issues/928)), `*.localhost` domains ([#1944](https://github.com/modelcontextprotocol/inspector/issues/1944)), and the trusted-local-host OAuth HTTP exception ([#1911](https://github.com/modelcontextprotocol/inspector/issues/1911)). --- ## 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% | +| | **Track A — Spec-following** | **Track B — Experience** | +| --------------------------- | -------------------------------------------------------------------- | ------------------------------------------- | +| **Driver** | MCP roadmap, WG deliverables, SEP acceptance, approved extensions | Our own judgment about the tool | +| **Trigger to start** | A SEP reaches Draft with a Tier-1 SDK reference impl, or is Final | 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 @@ -90,227 +110,243 @@ 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. +Worth stating plainly, because it shapes the priorities below. The roadmap's SDK area makes +the **conformance test suite** the source of truth that SDKs and quickstarts are validated +against, and SEP-2484 (Final) requires conformance tests for Standards Track SEPs to reach +Final. 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 +server is correct."_ Several items below (the conformance runner, assertions, cache-hint +validation, the capability diff) 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: +§3.1–§3.5 mirror the five priority areas of the published roadmap, in its order. Each states +the upstream area, 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. +- 🟢 **Build now** — the shape is known (the SEP is Final, or the work is ours alone); 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. +### 3.1 Agentic messaging primitives + +**Upstream:** Triggers & Events, Agents, and Transports WGs. Messaging beyond +request/response: work that runs for minutes, servers that push, results that stream, and +steering work mid-flight. This period: **server-initiated events** ("channels and +subscriptions for push delivery, including webhooks") and a **composition review** so Tasks, +triggers, `subscriptions/listen` and progress notifications share "a lifecycle, a cancellation +model, [and] an error surface". **Beyond this period:** Tasks (SEP-2663) toward eventual +inclusion in core. + +**Read:** Two changes from the first draft. First, **Tasks moving into core is no longer a +this-period item**, so the raw-wire Tasks channel stays for the whole horizon and its +retirement drops out of the plan. Second, the composition review names the exact thing a +timeline can show better than any list: three kinds of "not done yet" work side by side. That +argues for **one lane for in-flight work** rather than a tasks lane and a subscriptions lane. + +The webhook half remains the largest architectural change on the horizon for us. Every +Inspector surface assumes we initiated the connection; a webhook makes us a **server** that +must be publicly reachable, which a tool usually run on `localhost` is not. Start the design +conversation now and bring it to the WG as implementation feedback. + +| Feature | Confidence | Notes | +| ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| **In-flight work lane** — tasks, open `subscriptions/listen` streams and progress-reporting requests as spans on one timeline lane (§5.1) | 🟢 | All three already exist in the 2026-07-28 spec. Makes composition gaps (mismatched cancellation, divergent errors) visible, which the WG can use. | +| **Cancellation and error comparison** — show how each in-flight kind ended (completed, cancelled, errored, server-closed) with the same vocabulary | 🟢 | A small, direct contribution to the composition review. | +| **Callback receiver** — backend-hosted endpoint registered as a push target | 🔴 | Design now, build when the SEP lands. Security review mandatory: an inbound public endpoint on a process that spawns subprocesses. | +| **Local reachability story** — tunnel integration or documented guidance | 🔴 | Likely the hardest UX problem of the six months. | +| **Delivery log with ordering and duplicate assertions** | 🔴 | The conformance value: did events arrive in order? were any redelivered? | +| **`Mcp-Name` header on Tasks over Streamable HTTP** | 🟡 | [#1917](https://github.com/modelcontextprotocol/inspector/issues/1917) — blocked upstream. | +| **Tasks extension → core migration** | 🔴 | Moved to "Beyond" upstream. Keep the era-conditional exposure; the legacy `capabilities.tasks` path must keep working. | + +### 3.2 HTTP-native transport unification and hardening + +**Upstream:** Transports WG. "The 2026-07-28 release made a remote MCP server a normal HTTP +workload." The goal is **one transport model**: **HTTP over stdio** (Streamable HTTP as the +single binding, possibly HTTP/2 over stdin/stdout for multiplexing) and **caching** — SEP-2549 +(Final) added `ttlMs` and `cacheScope` to list results and resource reads, with **ETags** next, +including for tool-call results. **Beyond:** standardized error handling across all surfaces, +capability scoping for tool lists after SEP-2575, and a secure way to hand servers +configuration. + +**Read:** The first draft's §3.1 (stateless Streamable HTTP, session creation / resumption / +migration) is **largely obsolete**: SEP-2575 (stateless) and SEP-2567 (sessionless, explicit +state handles) are Final and already shipped. A "session lifecycle lane" describes a model the +spec has left behind; what remains to show is **state handles**. Caching, on the other hand, is +Final and we already parse the fields — we just do not render them, and a client that shows +cache hints is exactly how a server author finds out theirs are wrong. + +HTTP over stdio would change how every stdio server connects, and our transport layer is +where the Inspector is thinnest over the SDK. Watch closely. + +| Feature | Confidence | Notes | +| --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- | +| **Cache hint display** — `ttlMs` / `cacheScope` on every list and resource read, with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. Today the fields appear only in our tests. | +| **Cache behavior checks** — flag a re-fetch the hints said was unnecessary, and a list that changed inside its declared TTL | 🟢 | Inspector-shaped: nobody else observes both the hint and the reality. | +| **State handle view** — surface SEP-2567 state handles as first-class values, not opaque fields | 🟢 | Replaces the first draft's "session lifecycle lane". | +| **ETag support** — send `If-None-Match`, show 304s and version changes | 🟡 | Build when the SEP reaches Draft with an SDK impl. | +| **HTTP over stdio** | 🔴 | Watch. If it lands, the Network screen becomes meaningful for stdio servers too — a large win. | +| **Standardized error rendering** | 🔴 | "Beyond". Our Protocol-vs-Network error split (#1628) is the seam to adopt it into. | + +### 3.3 Agent identity and enterprise-ready security + +**Upstream:** Agent Identity WG (forming this period), coordinated with the IETF OAuth and +WIMSE WGs. MCP authorization assumes a person at a browser; increasingly the caller is an +agent. This period: **finalize DPoP** and drive adoption; an opinionated **agent identity and +delegation** model built on **Workload Identity Federation** (SEP-1933), **ID-JAG** as used by +Enterprise-Managed Authorization, and **RFC 8693 token exchange**. **Beyond:** +human-presence attestation. + +**Read:** DPoP was 🔴 in the first draft and is now a named deliverable, so it moves up. Our +EMA work (#1509) already gives us the ID-JAG leg, which makes the Inspector a credible test +client for the whole identity chain. The first draft's audit trails, gateway mode and +configuration portability are **no longer on the MCP roadmap**; OTLP export and the audit +transcript are still worth building, but as our own Track B work (§5.7), not as spec-following. + +| Feature | Confidence | Notes | +| --------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- | +| **OAuth Client Credentials extension** — client-secret and JWT-bearer assertion flows | 🟢 | An **approved** official extension (§4) we do not support. No upstream dependency. | +| **Token exchange (RFC 8693) test flow** | 🟡 | Named in the roadmap; the RFC is stable, the MCP profile of it is not. | +| **DPoP** — generate a proof key, send `DPoP` proofs, show proof/nonce exchange in the Network view | 🟡 | Design against SEP-1932; build when it is Final or has a Tier-1 SDK impl. | +| **Workload Identity Federation** | 🟡 | SEP-1933. Needs a way to present a workload credential from a developer machine — design first. | +| **Human-presence attestation** | 🔴 | "Beyond". | + +### 3.4 Improved primitives + +**Upstream:** Core Primitives WG (forming this period); File Uploads WG. This period: a +**`tools/call` result-shape redesign** to resolve the `content` vs `structuredContent` +confusion; **progressive discovery**, where clients learn tools and resources as needed +instead of ingesting the whole catalog, interacting with the caching work; and a review of +**primitive annotations** (audience and priority), which "most implementers haven't adopted" +and which may be deprecated. The File Uploads WG continues on **scoped file operations and +filesystem-like resource semantics** (range reads, hierarchical listing). + +**Read:** Every item here touches a panel we own. The result-shape redesign rewrites the tool +result view; progressive discovery breaks the assumption behind every list we render (that +`*/list` returns everything); and a possible annotation deprecation means we should not invest +in richer annotation rendering now. The first draft's §3.6 (streamed and reference results) +and §3.8 (the SEP-2356 file picker) are **not on the published roadmap** and move to watch. + +What we _can_ do now is show the problem the redesign is solving: a server returning +`content` and `structuredContent` that disagree is a real bug today. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------ | +| **`content` / `structuredContent` consistency check** — flag results where the two disagree or one is missing | 🟢 | Useful today, and implementation evidence for the Core Primitives WG. | +| **New tool result shape** | 🔴 | WG still forming. Keep both renderings behind the era seam when it lands. | +| **Progressive discovery** | 🔴 | Design the lists (§5.10) so "not loaded yet" is a state, not an empty list. | +| **Annotation-driven confirmation** before a `destructiveHint` call | 🟢 | Tool annotations are not the audience/priority content annotations under review. Small and obviously correct. | +| **Richer audience / priority annotation rendering** | 🔴 | Paused: may be deprecated. | +| **Range reads and hierarchical resource listing** | 🟡 | We already render `resources/directory/read` for Skills (#2248); generalize it when the File Uploads WG publishes a SEP. | + +### 3.5 Improved SDK developer experience + +**Upstream:** SDK WG with the Core Maintainers. This period: **the extension contract** — +which role an extension binds (host, client, server, agent), what each does when the +capability is declared, what SDKs must support natively, packaging, and capability additions +as versioned changes; and **the generated-artifacts experiment** — generate a Tier-1 SDK and its +quickstarts from the spec, validated against the conformance suite. + +**Read:** The extension contract decides how we present extensions: today our capability view +lists advertised extension ids, and a contract that names roles and versions gives us +something to validate declarations against. The generated-artifacts experiment makes the +conformance suite central, which strengthens §3.6. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------- | +| **Extension declaration view** — for each advertised extension: identifier, settings object, whether the Inspector supports it | 🟢 | Buildable on today's negotiation (#1738); extend with role and version once the contract lands. | +| **Extension contract validation** | 🟡 | Validate a server's declaration against the contract once published. | +| **Run generated quickstart servers as fixtures** | 🔴 | If the experiment publishes them, they are free test servers. | + +### 3.6 Conformance and validation + +**Upstream:** Standing investment rather than a priority area — the conformance suite, SDK +tiers ([SEP-1730](https://modelcontextprotocol.io/seps/1730-sdks-tiering-system)), and +[SEP-2484](https://modelcontextprotocol.io/seps/2484-conformance-tests-required-for-final-seps) +(Final), which requires conformance tests for Standards Track SEPs to reach Final. §3.5 makes +the suite the validation target for generated SDKs. **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**. +already have a CLI that exits non-zero. The runner itself needs agreement with the suite's +maintainers on a programmatic interface; the **assertion engine** it would share with §5.6 +does not. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **Conformance runner** — run the suite against a connected server, render pass/fail per assertion | 🟡 | **Action: open a conversation with the conformance maintainers.** Build the shared assertion engine (§5.6) first. | +| **`mcp-inspector --conformance` for CI** | 🟡 | Same engine, CLI report, exit code. | +| **Strict schema validation with actionable errors** | ✅ | Shipped — [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015). | + +### 3.7 Off the published roadmap — watch only + +The first draft planned build work for several WG efforts that the 2026-08-22 roadmap does not +list. They are not cancelled upstream — WGs keep working outside the priority areas — but the +roadmap says SEPs outside those areas "expect a longer queue", so **we do not schedule build +work for them this horizon**. Each keeps a tracking issue and a liaison. + +| Effort | First-draft plan | Now | +| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Server Cards** (SEP-2127) | Card preview, card-vs-reality diff, `--card-lint` in Phase 3 | 🔴 Watch. [#1857](https://github.com/modelcontextprotocol/inspector/issues/1857)'s **registry** half does not depend on it (§5.9). | +| **Interceptors** (SEP-1763) | Test bench, audit mode, CLI invocation in Phase 4 | 🔴 Watch. The WG's unowned "CLI client for interceptor invocation" is still worth raising (§8). | +| **Primitive grouping** (IG) | Grouped sidebars | The **UX** half proceeds as Track B (§5.10) on client-side heuristics; no spec data source is expected this horizon. | +| **Streamed and reference results** | Incremental rendering, reference handles | 🔴 Watch. Payload truncation in §5.10 covers the large-result case today. | +| **File picker from `FileInputDescriptor`** (SEP-2356) | `SchemaForm` + elicitation picker | 🔴 Watch. The File Uploads WG's published direction is now filesystem-like resources (§3.4). | +| **Gateways, audit trails, configuration portability** | Gateway mode; OTLP as spec work | Gateway mode dropped. OTLP and the audit transcript continue as Track B (§5.7). | + +--- -| 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. Official extensions + +The MCP roadmap does not list extensions, but **approved extensions are spec-following work** — +a client that ignores them stops being a reference client. The list lives at +[`/extensions/overview`](https://modelcontextprotocol.io/extensions/overview), implementations +are recorded in the community-maintained +[client matrix](https://modelcontextprotocol.io/extensions/client-matrix), and extensions reach +official status through the Extensions Track of +[SEP-2133](https://modelcontextprotocol.io/seps/2133-extensions), usually after incubating in an +`experimental-ext-*` repository. + +### Current support (as of 2026-09-16) + +| Extension | Identifier | Web | CLI | TUI | Upstream matrix | Notes | +| -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ------------------------ | ------------------------------------------------------------------------------------------------------- | +| MCP Apps | `io.modelcontextprotocol/ui` | ✅ | — | — | ❌ not listed | Apps tab. Rendering an app needs a browser, so CLI/TUI absence is by design. | +| Tasks | `io.modelcontextprotocol/tasks` | ✅ | ✅ | ❌ | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). No TUI Tasks pane yet. | +| Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | +| Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | ✅ | ✅ | ❌ not listed | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). | +| OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | ❌ | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | + +**Actions:** implement OAuth Client Credentials; and, with maintainer sign-off, open a PR on +`modelcontextprotocol/modelcontextprotocol` to correct the Inspector's row in the client matrix. + +### Keeping up as extensions are approved + +We picked up Skills because someone noticed, not because anything told us. Make it a +mechanism, the way SDK releases already are: + +- **An extension-watch sweep**, modelled on `scripts/sdk-watch.mjs`: on a schedule, list the + org's `ext-*` and `experimental-ext-*` repositories and the extension identifiers on + `/extensions/overview`, compare with a committed list of the ones we have assessed, and file + one issue per new entry. It **files issues, never PRs**, and trusts only markers the + automation wrote, exactly as the SDK watch does. +- **Official extension** → a `v2` + `enhancement` issue to implement it, milestoned at triage. +- **Experimental extension** → a `v2` + `question` tracking issue, so we can design against it + before its SEP (the 🟡 rule) without committing build capacity. +- **This table is the record.** An extension is added here when its issue is filed, and its + cells move as support lands. --- -## 4. Track B — experience work we choose +## 5. Track B — experience work we choose -Nothing in this section waits on a SEP. Ordered by leverage, not by effort. +Nothing in this section waits on a SEP or another project. Ordered by leverage, not by effort. -### 4.1 The zoomable timeline (headline) +### 5.1 The zoomable timeline (headline) **Committed.** The single feature that most changes what the Inspector _is_. @@ -327,7 +363,7 @@ at a glance. 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 + `client → server` · `server → client` · notifications · **in-flight work** (tasks, subscriptions, progress — §3.1) · 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. @@ -342,20 +378,20 @@ at a glance. - **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). +**Deliberately out of scope for v1 of this feature:** cross-server correlation (needs §5.11), +and OTLP-shaped nesting (needs §5.7). -### 4.2 Session record, replay, and share +### 5.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. +serialization format as the audit transcript (§5.7) — **build the format once**. Replay also +gives us fixtures: a recorded session is a regression test. -### 4.3 Diff and compare +### 5.3 Diff and compare Two sessions, or two servers, side by side. Concretely: @@ -364,166 +400,154 @@ Two sessions, or two servers, side by side. Concretely: - **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. +The payload differ is a **shared primitive**: capability diff, session diff, the cache checks +(§3.2) and any later card-vs-reality or interceptor view are the same widget with different +inputs. Build it as a component first, then wire the consumers. -### 4.4 Command palette and global search +### 5.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 +### 5.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. +any protocol client, and it composes directly with §5.6. -### 4.6 Assertions and CI flows +### 5.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). +engine with the conformance runner (§3.6). -### 4.7 The argument editor workstream +### 5.7 Observability export -Six open issues are all the same defect class — the argument editor is not schema-aware: +Moved here from the first draft's enterprise section: the roadmap no longer lists audit trails, +but we hold the entire session and cannot export it in any pipeline-shaped form. -| 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 | +- **OTLP export** — emit the session as OpenTelemetry spans; show trace/span ids from `_meta` + (SEP-414) inline; "copy as trace". +- **Structured audit transcript** — the §5.2 session file, documented as a stable format. -**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. +### 5.8 Connection Doctor -### 4.8 Connection Doctor +The individual connection bugs have been fixed (§1), but a failure is still reported as a +single error. 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. -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)). +### 5.9 Server management and portability -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. +Most of the first draft's list has shipped (§1). What remains is +[#1857](https://github.com/modelcontextprotocol/inspector/issues/1857), rich server configuration, +whose **registry** half — browse an MCP Registry, pick a server, generate its configuration +form from `server.json` — needs nothing but the Registry API and our existing `server.json` +support (#922). Its Server Card half waits on SEP-2127 (§3.7). -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)). +### 5.10 Large servers: grouping and performance -### 4.9 Server management and portability +A 1000-tool server or a long-running session should not degrade. -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)). +- **Grouped / tree lists with group-aware search**, built on client-side heuristics (name + prefixes, annotations). No spec data source is expected this horizon (§3.7). +- **Virtualize** the long lists and logs; cap in-memory protocol history; truncate large + payloads by default with explicit expansion. +- Design lists so **"not loaded yet" is a state**, ready for progressive discovery (§3.4). -### 4.10 Workspace and layout +### 5.11 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. +server, and density modes. 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 +### 5.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 +### 5.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 +### 5.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. +[#1025](https://github.com/modelcontextprotocol/inspector/issues/1025) recorded the placeholder +spec. The multiplier on everything above — custom panels 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 +## 6. 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) +signal is expected; Track B items are placed to unblock Track A wherever possible. Phase 1 is +annotated with what has already shipped. -_Build the general surfaces the rest of the plan renders into, and clear the debt that makes -first impressions bad._ +### Phase 1 — Foundations (~`v2.2` – `v2.9`, Aug–Sep 2026) -- 🅑 **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` +- ✅ `Last-Event-ID` resumption (#920); discover checkmarks (#1887); `server.json` (#922) +- ✅ Argument editor workstream (six issues); connection fixes (§1) +- ✅ Skills over MCP (#2234, #2248); Enterprise-Managed Authorization (#1509) +- 🅑 **Zoomable timeline v1** — carried into Phase 2 +- 🅑 **Connection Doctor** (§5.8) — carried into Phase 2 -### Phase 2 — Artifacts and comparison (~`v2.8` – `v2.13`, Sep–Nov 2026) +### Phase 2 — Artifacts, comparison, and cheap spec wins (~`v2.10` – `v2.15`, Oct–Nov 2026) -_Make sessions into things you can keep, share, and compare._ +_Make sessions into things you can keep, share, and compare; take the Final-SEP and extension +items that need no upstream work._ -- 🅑 **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) +- 🅑 **Zoomable timeline v1**, including the **in-flight work lane** (§3.1) +- 🅑 **Session record / replay / share** (§5.2) — format shared with the audit transcript +- 🅑 **Diff primitive** (§5.3) — then capability diff (#1034) +- 🅑 **Command palette and global search** (§5.4); **Connection Doctor** (§5.8) +- 🅐 **Cache hint display and checks** (§3.2) — SEP-2549 is Final +- 🅐 **OAuth Client Credentials extension** (§3.3, §4) +- 🅐 **`content` / `structuredContent` consistency check** and **destructive-call confirmation** (§3.4) +- 🅐 **Extension-watch sweep** (§4) -### Phase 3 — Automation and spec catch-up (~`v2.14` – `v2.20`, Nov 2026 – Jan 2027) +### Phase 3 — Automation (~`v2.16` – `v2.21`, Nov 2026 – Jan 2027) -_Turn the Inspector into something you can run in CI, and absorb the SEPs that have landed._ +_Turn the Inspector into something you can run in CI._ -- 🅑 **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) +- 🅑 **Saved calls / collections** (§5.5) → **assertions and CI flows** (§5.6) +- 🅐 **Conformance runner** (§3.6) — shares the assertion engine, if the maintainers agree an interface +- 🅑 **OTLP export** (§5.7); **registry browsing** (§5.9) +- 🅑 **Grouping and performance at scale** (§5.10); accessibility pass (§5.12) +- 🅐 **State handle view** (§3.2); **extension declaration view** (§3.5) -### Phase 4 — Frontier (~`v2.21` – `v2.27`, Jan–Feb 2027) +### Phase 4 — Frontier (~`v2.22` – `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) +- 🅐 **DPoP**, **token exchange**, **Workload Identity Federation** (§3.3) — as each reaches Final or a Tier-1 SDK impl +- 🅐 **Server-initiated events receiver** (§3.1) — design throughout, build only if the SEP lands +- 🅐 **ETags** (§3.2); **extension contract validation** (§3.5) +- 🅑 **Plugin architecture** (§5.14) — designed against surfaces that now exist +- 🅑 Workspace and layout (§5.11); onboarding (§5.13) ### Standing commitments across all phases -- **Weekly milestone cadence** and the pre-push gate (`npm run local:gate`, renamed off `ci` in #2146) are unchanged. +- **Weekly milestone cadence** and the pre-push gate (`npm run local: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. +- **Re-read the MCP roadmap when it changes.** It carries a "Last updated" date; a change there + is the trigger to revisit §3 and §6, the way #2400 revisited this draft. +- **WG liaison**: attend Triggers & Events, Transports, Agents, Agent Identity, Core Primitives, + and SDK 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 +## 7. What we are deliberately not doing Stating these so they are decisions rather than oversights. @@ -531,40 +555,47 @@ Stating these so they are decisions rather than oversights. 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 scheduling build work outside the published priority areas** (§3.7). A WG effort that + the roadmap does not list gets a liaison, not milestones. - **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). +- **Not adding transports beyond what the spec blesses.** Custom transports were closed as not + planned ([#1741](https://github.com/modelcontextprotocol/inspector/issues/1741)). +- **Not investing in audience/priority annotation rendering** while their deprecation is under + review (§3.4). +- **Not building a second extension mechanism.** Anything pluggable runs on the plugin + architecture (§5.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. +## 8. Open questions + +For WG discussion. + +1. **Do we claim the Interceptors WG's "CLI client for interceptor invocation and testing"?** + It is unowned and describes our CLI, but Interceptors is no longer on the published roadmap + (§3.7). If yes, it needs its own allocation rather than borrowed Phase 4 capacity. +2. **How far do we take the conformance role?** §3.6 and §5.6 point at "the Inspector tells + you whether your server is correct." With conformance now central to the SDK area (§3.5), + that is worth an explicit yes or no, and possibly a charter amendment. +3. **Who owns the server-initiated 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. +4. **Should the Inspector feed the composition review directly?** The in-flight work lane + (§3.1) produces exactly the evidence the review needs; decide whether we bring it to the + Agents / Triggers & Events WGs as a demo. 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. +6. **Timeline v1 scope.** The §5.1 sketch is deliberately broad. Which parts are v1 and which + are follow-ups should be settled before it starts. --- -## 8. Sources +## 9. 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) +- [MCP Roadmap](https://modelcontextprotocol.io/development/roadmap) (last updated 2026-08-22) +- [Extensions overview](https://modelcontextprotocol.io/extensions/overview) · [Extension support matrix](https://modelcontextprotocol.io/extensions/client-matrix) · [SEP-2133: Extensions](https://modelcontextprotocol.io/seps/2133-extensions) +- Final SEPs cited: [SEP-2549 (TTL for list results)](https://modelcontextprotocol.io/seps/2549-TTL-for-list-results) · [SEP-2567 (sessionless)](https://modelcontextprotocol.io/seps/2567-sessionless-mcp) · [SEP-2575 (stateless)](https://modelcontextprotocol.io/seps/2575-stateless-mcp) · [SEP-2663 (Tasks extension)](https://modelcontextprotocol.io/seps/2663-tasks-extension) · [SEP-2640 (Skills extension)](https://modelcontextprotocol.io/seps/2640-skills-extension) · [SEP-2484 (conformance tests)](https://modelcontextprotocol.io/seps/2484-conformance-tests-required-for-final-seps) +- WG charters: [Inspector V2](https://modelcontextprotocol.io/community/working-groups/inspector-v2) · [Triggers & Events](https://modelcontextprotocol.io/community/working-groups/triggers-events) · [Agents](https://modelcontextprotocol.io/community/working-groups/agents) · [File Uploads](https://modelcontextprotocol.io/community/working-groups/file-uploads) · [SDK](https://modelcontextprotocol.io/community/working-groups/sdk) +- [SDK tiers and conformance testing](https://modelcontextprotocol.io/community/sdk-tiers) - 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 c0bef16f6db033613a4e8a0ecc34f421b09f6fa9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 18:55:55 -0400 Subject: [PATCH 27/68] docs: link the unblocked-work artifact at the top of the roadmap (#2400) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index e7b2803840..a95370696c 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -8,6 +8,10 @@ **Owner:** [Inspector V2 WG](https://modelcontextprotocol.io/community/working-groups/inspector-v2) **Status:** Draft for WG review — **revised 2026-09-16** against the published MCP roadmap of 2026-08-22 (#2400) +## Work we can start now, no external blockers + +[Inspector Unblocked Work](https://claude.ai/artifact/MTFGsTVbKCqMchA1JYHo83) lists the roadmap items that depend on nothing outside this repo. + --- ## Table of Contents From d8a73561bbf077704d24acc6df53651e9d0917fb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 19:05:51 -0400 Subject: [PATCH 28/68] docs(skills): load test-servers before searching on e2e coverage prompts (#2399) Copilot followed testing's conditional pointer on the pagination prompt only 40% of the time: it grepped to answer the condition and never came back. The pointer now names end-to-end/integration coverage of an MCP operation as the signal to load test-servers first. Copilot RUNS=5: 60%, 60% (was 40%). Claude RUNS=5: 100% (unchanged). Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- .claude/skills/testing/SKILL.md | 10 ++++++++++ docs/skill-authoring.md | 26 +++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 6da0df69b5..59604fd620 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -16,6 +16,16 @@ test goes, how to run it, and how to clear the gate. **If it does, load the `test-servers` skill now — that is step one, before choosing a location or writing a line.** +⚠️ **Load it before searching the code, not after.** A task phrased as +end-to-end or integration coverage of an MCP operation — listing tools, +paginating a list, calling a tool, reading a resource — almost always stands a +fixture up, so treat that phrasing as the answer to the question above and load +`test-servers` *first*. Grepping for an existing test to copy is not a +substitute: the fixture you find that way (a config under +`test-servers/configs/`) does not tell you which of the three shapes below +drives it, or that it can be stale. If the skill then shows the case needs no +fixture, you have lost one skill load. + The condition is **"does this test depend on a fixture from `test-servers/`?"** — not which tier it lands in, and not which directory it lands in. There are two ways to depend on one, and they need different halves of that skill: diff --git a/docs/skill-authoring.md b/docs/skill-authoring.md index 1ddd388a7c..567633c21b 100644 --- a/docs/skill-authoring.md +++ b/docs/skill-authoring.md @@ -524,9 +524,29 @@ coarse to read a chain: | `testing → test-servers`, "Write an integration test that exercises tool listing end to end." | 100% | 100% | | `testing → test-servers`, "Add end-to-end coverage for the tool-list pagination path." | **40%** | 100% | -So the pagination prompt is a **Copilot-specific shortfall**, not noise: it held -below the 50% bar at both sample sizes, while the same case clears 100% under -Claude. First moves transfer; this one hand-off does not yet (#2399). +So the pagination prompt was a **Copilot-specific shortfall**, not noise: it +held below the 50% bar at both sample sizes, while the same case cleared 100% +under Claude. + +**Where it stopped** (#2399), from five recorded Copilot runs of that prompt: +two loaded `testing` and then went straight to `grep`, never following its +pointer; two opened with `grep` and loaded no skill at all. The pointer was +conditional on "does this test use a `test-servers/` fixture?", and a prompt +about pagination does not say so, so the model went to the code to find out and +did not come back once it found `pagination-http.json`. The fix is in +`testing`'s body only (the description, and so the listing, is unchanged): the +pointer now names end-to-end or integration coverage of an MCP operation as the +signal to load `test-servers` **before** searching the code. + +| Hand-off case | Copilot, `RUNS=5`, after | Claude, `RUNS=5`, after | +| --- | --- | --- | +| `testing → test-servers`, "Write an integration test that exercises tool listing end to end." | 100%, 100% | 100% | +| `testing → test-servers`, "Add end-to-end coverage for the tool-list pagination path." | **60%, 60%** | 100% | + +Two independent Copilot runs are shown because 3/5 sits one run above the bar. +It clears it without lowering the Claude rate, but it is the weakest hand-off +measured under either agent, and the first case to re-check when a Copilot +release changes the default model. ## Checklist for a new or edited skill From d7017340dad97382e2f986cdd0b23bff7a7b23ca Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 19:09:12 -0400 Subject: [PATCH 29/68] docs: account for all five recorded Copilot runs (#2402 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/skill-authoring.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/skill-authoring.md b/docs/skill-authoring.md index 567633c21b..945b472736 100644 --- a/docs/skill-authoring.md +++ b/docs/skill-authoring.md @@ -528,9 +528,12 @@ So the pagination prompt was a **Copilot-specific shortfall**, not noise: it held below the 50% bar at both sample sizes, while the same case cleared 100% under Claude. -**Where it stopped** (#2399), from five recorded Copilot runs of that prompt: -two loaded `testing` and then went straight to `grep`, never following its -pointer; two opened with `grep` and loaded no skill at all. The pointer was +**Where it stopped** (#2399), from five recorded Copilot runs of that prompt, +two of which made the hand-off: one loaded `testing` then `test-servers` as its +first two moves; one opened with `grep` and reached `testing → test-servers` +only after about ten searches, inside the turn budget. Of the three misses, two +loaded `testing` and then went straight to `grep`, never following its pointer, +and one searched the code throughout without loading any skill. The pointer was conditional on "does this test use a `test-servers/` fixture?", and a prompt about pagination does not say so, so the model went to the code to find out and did not come back once it found `pagination-http.json`. The fix is in From 298cce3d587818ef520ba7285bc4e0463e60569d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 19:17:55 -0400 Subject: [PATCH 30/68] docs: address Copilot review on the roadmap realignment (#2401 review) Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 154 +++++++++++++++--------------- 1 file changed, 78 insertions(+), 76 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index a95370696c..96d46552c9 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -80,17 +80,17 @@ work is non-negotiable but bounded, and the remaining capacity is ours to direct Worth recording, because much of the first draft's "build now" list is done and should not be re-planned: -| Item | Issue(s) | -| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Last-Event-ID` resumption | [#920](https://github.com/modelcontextprotocol/inspector/issues/920) | -| `server.json` support | [#922](https://github.com/modelcontextprotocol/inspector/issues/922) | -| Discover checkmarks for task extensions | [#1887](https://github.com/modelcontextprotocol/inspector/issues/1887) | -| Strict JSON Schema validation | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015) | -| The argument editor workstream (all six issues) | [#1853](https://github.com/modelcontextprotocol/inspector/issues/1853), [#1856](https://github.com/modelcontextprotocol/inspector/issues/1856), [#1885](https://github.com/modelcontextprotocol/inspector/issues/1885), [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928), [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919), [#1910](https://github.com/modelcontextprotocol/inspector/issues/1910) | -| Connection fixes (version-negotiation DX, `https://localhost`, dev containers, ghost entry) | [#962](https://github.com/modelcontextprotocol/inspector/issues/962), [#1936](https://github.com/modelcontextprotocol/inspector/issues/1936), [#1951](https://github.com/modelcontextprotocol/inspector/issues/1951), [#1914](https://github.com/modelcontextprotocol/inspector/issues/1914) | -| Server config: paste-JSON, custom headers, auth URL overrides, file-backed secrets | [#904](https://github.com/modelcontextprotocol/inspector/issues/904), [#1915](https://github.com/modelcontextprotocol/inspector/issues/1915), [#1906](https://github.com/modelcontextprotocol/inspector/issues/1906), [#1950](https://github.com/modelcontextprotocol/inspector/issues/1950) | -| Enterprise-Managed Authorization; IdP OIDC option | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509), [#1937](https://github.com/modelcontextprotocol/inspector/issues/1937) | -| Skills over MCP (SEP-2640) across web, CLI and TUI | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248) | +| Item | Issue(s) | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Last-Event-ID` resumption | [#920](https://github.com/modelcontextprotocol/inspector/issues/920) | +| `server.json` support | [#922](https://github.com/modelcontextprotocol/inspector/issues/922) | +| Discover checkmarks for task extensions | [#1887](https://github.com/modelcontextprotocol/inspector/issues/1887) | +| Strict JSON Schema validation | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015) | +| The argument editor workstream (all six issues) | [#1853](https://github.com/modelcontextprotocol/inspector/issues/1853), [#1856](https://github.com/modelcontextprotocol/inspector/issues/1856), [#1885](https://github.com/modelcontextprotocol/inspector/issues/1885), [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928), [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919), [#1910](https://github.com/modelcontextprotocol/inspector/issues/1910) | +| Connection fixes (version-negotiation DX, `https://localhost`, dev containers, ghost entry) | [#962](https://github.com/modelcontextprotocol/inspector/issues/962), [#1936](https://github.com/modelcontextprotocol/inspector/issues/1936), [#1951](https://github.com/modelcontextprotocol/inspector/issues/1951), [#1914](https://github.com/modelcontextprotocol/inspector/issues/1914) | +| Server config: paste-JSON, custom headers, auth URL overrides, file-backed secrets | [#904](https://github.com/modelcontextprotocol/inspector/issues/904), [#1915](https://github.com/modelcontextprotocol/inspector/issues/1915), [#1906](https://github.com/modelcontextprotocol/inspector/issues/1906), [#1950](https://github.com/modelcontextprotocol/inspector/issues/1950) | +| Enterprise-Managed Authorization; IdP OIDC option | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509), [#1937](https://github.com/modelcontextprotocol/inspector/issues/1937) | +| Skills over MCP (SEP-2640) across web, CLI and TUI | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248) | Closed as **not planned**, so not carried forward: custom transports ([#1741](https://github.com/modelcontextprotocol/inspector/issues/1741)), the configurable-proxy base ([#1684](https://github.com/modelcontextprotocol/inspector/issues/1684)), the readiness summary ([#1916](https://github.com/modelcontextprotocol/inspector/issues/1916)), full panel collapse ([#928](https://github.com/modelcontextprotocol/inspector/issues/928)), `*.localhost` domains ([#1944](https://github.com/modelcontextprotocol/inspector/issues/1944)), and the trusted-local-host OAuth HTTP exception ([#1911](https://github.com/modelcontextprotocol/inspector/issues/1911)). @@ -98,13 +98,13 @@ Closed as **not planned**, so not carried forward: custom transports ([#1741](ht ## 2. The two tracks -| | **Track A — Spec-following** | **Track B — Experience** | -| --------------------------- | -------------------------------------------------------------------- | ------------------------------------------- | -| **Driver** | MCP roadmap, WG deliverables, SEP acceptance, approved extensions | Our own judgment about the tool | -| **Trigger to start** | A SEP reaches Draft with a Tier-1 SDK reference impl, or is Final | 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% | +| | **Track A — Spec-following** | **Track B — Experience** | +| --------------------------- | ----------------------------------------------------------------- | ------------------------------------------- | +| **Driver** | MCP roadmap, WG deliverables, SEP acceptance, approved extensions | Our own judgment about the tool | +| **Trigger to start** | A SEP reaches Draft with a Tier-1 SDK reference impl, or is Final | 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 @@ -136,6 +136,7 @@ the upstream area, our read on what it means for the Inspector, and a concrete f - 🟢 **Build now** — the shape is known (the SEP is Final, or the work is ours alone); 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. +- ✅ **Shipped** — already in the Inspector; listed for completeness, not scheduled. ### 3.1 Agentic messaging primitives @@ -158,15 +159,15 @@ Inspector surface assumes we initiated the connection; a webhook makes us a **se must be publicly reachable, which a tool usually run on `localhost` is not. Start the design conversation now and bring it to the WG as implementation feedback. -| Feature | Confidence | Notes | -| ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| **In-flight work lane** — tasks, open `subscriptions/listen` streams and progress-reporting requests as spans on one timeline lane (§5.1) | 🟢 | All three already exist in the 2026-07-28 spec. Makes composition gaps (mismatched cancellation, divergent errors) visible, which the WG can use. | -| **Cancellation and error comparison** — show how each in-flight kind ended (completed, cancelled, errored, server-closed) with the same vocabulary | 🟢 | A small, direct contribution to the composition review. | -| **Callback receiver** — backend-hosted endpoint registered as a push target | 🔴 | Design now, build when the SEP lands. Security review mandatory: an inbound public endpoint on a process that spawns subprocesses. | -| **Local reachability story** — tunnel integration or documented guidance | 🔴 | Likely the hardest UX problem of the six months. | -| **Delivery log with ordering and duplicate assertions** | 🔴 | The conformance value: did events arrive in order? were any redelivered? | -| **`Mcp-Name` header on Tasks over Streamable HTTP** | 🟡 | [#1917](https://github.com/modelcontextprotocol/inspector/issues/1917) — blocked upstream. | -| **Tasks extension → core migration** | 🔴 | Moved to "Beyond" upstream. Keep the era-conditional exposure; the legacy `capabilities.tasks` path must keep working. | +| Feature | Confidence | Notes | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| **In-flight work lane** — tasks, open `subscriptions/listen` streams and progress-reporting requests as spans on one timeline lane (§5.1) | 🟢 | All three already exist in the 2026-07-28 spec. Makes composition gaps (mismatched cancellation, divergent errors) visible, which the WG can use. | +| **Cancellation and error comparison** — show how each in-flight kind ended (completed, cancelled, errored, server-closed) with the same vocabulary | 🟢 | A small, direct contribution to the composition review. | +| **Callback receiver** — backend-hosted endpoint registered as a push target | 🔴 | Design now, build when the SEP lands. Security review mandatory: an inbound public endpoint on a process that spawns subprocesses. | +| **Local reachability story** — tunnel integration or documented guidance | 🔴 | Likely the hardest UX problem of the six months. | +| **Delivery log with ordering and duplicate assertions** | 🔴 | The conformance value: did events arrive in order? were any redelivered? | +| **`Mcp-Name` header on Tasks over Streamable HTTP** | 🟡 | [#1917](https://github.com/modelcontextprotocol/inspector/issues/1917) — blocked upstream. | +| **Tasks extension → core migration** | 🔴 | Moved to "Beyond" upstream. Keep the era-conditional exposure; the legacy `capabilities.tasks` path must keep working. | ### 3.2 HTTP-native transport unification and hardening @@ -188,14 +189,14 @@ cache hints is exactly how a server author finds out theirs are wrong. HTTP over stdio would change how every stdio server connects, and our transport layer is where the Inspector is thinnest over the SDK. Watch closely. -| Feature | Confidence | Notes | -| --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- | -| **Cache hint display** — `ttlMs` / `cacheScope` on every list and resource read, with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. Today the fields appear only in our tests. | -| **Cache behavior checks** — flag a re-fetch the hints said was unnecessary, and a list that changed inside its declared TTL | 🟢 | Inspector-shaped: nobody else observes both the hint and the reality. | -| **State handle view** — surface SEP-2567 state handles as first-class values, not opaque fields | 🟢 | Replaces the first draft's "session lifecycle lane". | -| **ETag support** — send `If-None-Match`, show 304s and version changes | 🟡 | Build when the SEP reaches Draft with an SDK impl. | -| **HTTP over stdio** | 🔴 | Watch. If it lands, the Network screen becomes meaningful for stdio servers too — a large win. | -| **Standardized error rendering** | 🔴 | "Beyond". Our Protocol-vs-Network error split (#1628) is the seam to adopt it into. | +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Cache hint display** — `ttlMs` / `cacheScope` on every list and resource read, with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. The runtime already parses the hints and honors them through the SDK list cache; the gap is showing them. | +| **Cache behavior observations** — note a re-fetch of a still-fresh result, and a list that changed inside its declared TTL, as diagnostics rather than errors | 🟢 | Inspector-shaped: nobody else observes both the hint and the reality. `ttlMs` is a freshness hint, so both are compliant. | +| **Stateful-tool workflow investigation** — how to help a user carry an SEP-2567-style handle from one tool result into the next call | 🟡 | Replaces the first draft's "session lifecycle lane". The protocol has no concept of a handle (it is ordinary tool data), so a generic view would be inference; investigate before designing. | +| **ETag support** — send `If-None-Match`, show 304s and version changes | 🟡 | Build when the SEP reaches Draft with an SDK impl. | +| **HTTP over stdio** | 🔴 | Watch. If it lands, the Network screen becomes meaningful for stdio servers too — a large win. | +| **Standardized error rendering** | 🔴 | "Beyond". Our Protocol-vs-Network error split (#1628) is the seam to adopt it into. | ### 3.3 Agent identity and enterprise-ready security @@ -212,13 +213,13 @@ client for the whole identity chain. The first draft's audit trails, gateway mod configuration portability are **no longer on the MCP roadmap**; OTLP export and the audit transcript are still worth building, but as our own Track B work (§5.7), not as spec-following. -| Feature | Confidence | Notes | -| --------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- | -| **OAuth Client Credentials extension** — client-secret and JWT-bearer assertion flows | 🟢 | An **approved** official extension (§4) we do not support. No upstream dependency. | -| **Token exchange (RFC 8693) test flow** | 🟡 | Named in the roadmap; the RFC is stable, the MCP profile of it is not. | -| **DPoP** — generate a proof key, send `DPoP` proofs, show proof/nonce exchange in the Network view | 🟡 | Design against SEP-1932; build when it is Final or has a Tier-1 SDK impl. | -| **Workload Identity Federation** | 🟡 | SEP-1933. Needs a way to present a workload credential from a developer machine — design first. | -| **Human-presence attestation** | 🔴 | "Beyond". | +| Feature | Confidence | Notes | +| -------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- | +| **OAuth Client Credentials extension** — client-secret and JWT-bearer assertion flows | 🟢 | An **approved** official extension (§4) we do not support. No upstream dependency. | +| **Token exchange (RFC 8693) test flow** | 🟡 | Named in the roadmap; the RFC is stable, the MCP profile of it is not. | +| **DPoP** — generate a proof key, send `DPoP` proofs, show proof/nonce exchange in the Network view | 🟡 | Design against SEP-1932; build when it is Final or has a Tier-1 SDK impl. | +| **Workload Identity Federation** | 🟡 | SEP-1933. Needs a way to present a workload credential from a developer machine — design first. | +| **Human-presence attestation** | 🔴 | "Beyond". | ### 3.4 Improved primitives @@ -239,14 +240,14 @@ and §3.8 (the SEP-2356 file picker) are **not on the published roadmap** and mo What we _can_ do now is show the problem the redesign is solving: a server returning `content` and `structuredContent` that disagree is a real bug today. -| Feature | Confidence | Notes | -| ------------------------------------------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------ | -| **`content` / `structuredContent` consistency check** — flag results where the two disagree or one is missing | 🟢 | Useful today, and implementation evidence for the Core Primitives WG. | -| **New tool result shape** | 🔴 | WG still forming. Keep both renderings behind the era seam when it lands. | -| **Progressive discovery** | 🔴 | Design the lists (§5.10) so "not loaded yet" is a state, not an empty list. | -| **Annotation-driven confirmation** before a `destructiveHint` call | 🟢 | Tool annotations are not the audience/priority content annotations under review. Small and obviously correct. | -| **Richer audience / priority annotation rendering** | 🔴 | Paused: may be deprecated. | -| **Range reads and hierarchical resource listing** | 🟡 | We already render `resources/directory/read` for Skills (#2248); generalize it when the File Uploads WG publishes a SEP. | +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | +| **`content` / `structuredContent` consistency check** — flag results where both are present and disagree, or where a declared `outputSchema` requires `structuredContent` and it is missing | 🟢 | Useful today, and implementation evidence for the Core Primitives WG. | +| **New tool result shape** | 🔴 | WG still forming. Keep both renderings behind the era seam when it lands. | +| **Progressive discovery** | 🔴 | Design the lists (§5.10) so "not loaded yet" is a state, not an empty list. | +| **Annotation-driven confirmation** before a `destructiveHint` call | 🟢 | Tool annotations are not the audience/priority content annotations under review. Small and obviously correct. | +| **Richer audience / priority annotation rendering** | 🔴 | Paused: may be deprecated. | +| **Range reads and hierarchical resource listing** | 🟡 | We already render `resources/directory/read` for Skills (#2248); generalize it when the File Uploads WG publishes a SEP. | ### 3.5 Improved SDK developer experience @@ -261,11 +262,11 @@ lists advertised extension ids, and a contract that names roles and versions giv something to validate declarations against. The generated-artifacts experiment makes the conformance suite central, which strengthens §3.6. -| Feature | Confidence | Notes | -| ------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------- | +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------- | | **Extension declaration view** — for each advertised extension: identifier, settings object, whether the Inspector supports it | 🟢 | Buildable on today's negotiation (#1738); extend with role and version once the contract lands. | -| **Extension contract validation** | 🟡 | Validate a server's declaration against the contract once published. | -| **Run generated quickstart servers as fixtures** | 🔴 | If the experiment publishes them, they are free test servers. | +| **Extension contract validation** | 🟡 | Validate a server's declaration against the contract once published. | +| **Run generated quickstart servers as fixtures** | 🔴 | If the experiment publishes them, they are free test servers. | ### 3.6 Conformance and validation @@ -280,10 +281,10 @@ already have a CLI that exits non-zero. The runner itself needs agreement with t maintainers on a programmatic interface; the **assertion engine** it would share with §5.6 does not. -| Feature | Confidence | Notes | -| ------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| **Conformance runner** — run the suite against a connected server, render pass/fail per assertion | 🟡 | **Action: open a conversation with the conformance maintainers.** Build the shared assertion engine (§5.6) first. | -| **`mcp-inspector --conformance` for CI** | 🟡 | Same engine, CLI report, exit code. | +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Conformance runner** — run the suite against a connected server, render pass/fail per assertion | 🟡 | **Action: open a conversation with the conformance maintainers.** Build the shared assertion engine (§5.6) first. | +| **`mcp-inspector --conformance` for CI** | 🟡 | Same engine, CLI report, exit code. | | **Strict schema validation with actionable errors** | ✅ | Shipped — [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015). | ### 3.7 Off the published roadmap — watch only @@ -293,14 +294,14 @@ list. They are not cancelled upstream — WGs keep working outside the priority roadmap says SEPs outside those areas "expect a longer queue", so **we do not schedule build work for them this horizon**. Each keeps a tracking issue and a liaison. -| Effort | First-draft plan | Now | -| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Server Cards** (SEP-2127) | Card preview, card-vs-reality diff, `--card-lint` in Phase 3 | 🔴 Watch. [#1857](https://github.com/modelcontextprotocol/inspector/issues/1857)'s **registry** half does not depend on it (§5.9). | -| **Interceptors** (SEP-1763) | Test bench, audit mode, CLI invocation in Phase 4 | 🔴 Watch. The WG's unowned "CLI client for interceptor invocation" is still worth raising (§8). | -| **Primitive grouping** (IG) | Grouped sidebars | The **UX** half proceeds as Track B (§5.10) on client-side heuristics; no spec data source is expected this horizon. | -| **Streamed and reference results** | Incremental rendering, reference handles | 🔴 Watch. Payload truncation in §5.10 covers the large-result case today. | -| **File picker from `FileInputDescriptor`** (SEP-2356) | `SchemaForm` + elicitation picker | 🔴 Watch. The File Uploads WG's published direction is now filesystem-like resources (§3.4). | -| **Gateways, audit trails, configuration portability** | Gateway mode; OTLP as spec work | Gateway mode dropped. OTLP and the audit transcript continue as Track B (§5.7). | +| Effort | First-draft plan | Now | +| ----------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| **Server Cards** (SEP-2127) | Card preview, card-vs-reality diff, `--card-lint` in Phase 3 | 🔴 Watch. [#1857](https://github.com/modelcontextprotocol/inspector/issues/1857)'s **registry** half does not depend on it (§5.9). | +| **Interceptors** (SEP-1763) | Test bench, audit mode, CLI invocation in Phase 4 | 🔴 Watch. The WG's unowned "CLI client for interceptor invocation" is still worth raising (§8). | +| **Primitive grouping** (IG) | Grouped sidebars | The **UX** half proceeds as Track B (§5.10) on client-side heuristics; no spec data source is expected this horizon. | +| **Streamed and reference results** | Incremental rendering, reference handles | 🔴 Watch. Payload truncation in §5.10 covers the large-result case today. | +| **File picker from `FileInputDescriptor`** (SEP-2356) | `SchemaForm` + elicitation picker | 🔴 Watch. The File Uploads WG's published direction is now filesystem-like resources (§3.4). | +| **Gateways, audit trails, configuration portability** | Gateway mode; OTLP as spec work | Gateway mode dropped. OTLP and the audit transcript continue as Track B (§5.7). | --- @@ -317,13 +318,13 @@ official status through the Extensions Track of ### Current support (as of 2026-09-16) -| Extension | Identifier | Web | CLI | TUI | Upstream matrix | Notes | -| -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ------------------------ | ------------------------------------------------------------------------------------------------------- | -| MCP Apps | `io.modelcontextprotocol/ui` | ✅ | — | — | ❌ not listed | Apps tab. Rendering an app needs a browser, so CLI/TUI absence is by design. | -| Tasks | `io.modelcontextprotocol/tasks` | ✅ | ✅ | ❌ | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). No TUI Tasks pane yet. | -| Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | -| Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | ✅ | ✅ | ❌ not listed | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). | -| OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | ❌ | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | +| Extension | Identifier | Web | CLI | TUI | Upstream matrix | Notes | +| -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| MCP Apps | `io.modelcontextprotocol/ui` | ✅ | 🟡 | — | ❌ not listed | Apps tab. Columns are rendering support: rendering needs a browser, so the CLI has only the `--app-info` metadata probe and the TUI nothing. The shared client still advertises the extension from CLI and TUI. | +| Tasks | `io.modelcontextprotocol/tasks` | ✅ | ❌ | ❌ | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). The CLI's one-shot mode rejects `tasks/*`; no TUI Tasks pane yet. | +| Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | +| Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | ✅ | ✅ | ❌ not listed | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). | +| OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | ❌ | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | **Actions:** implement OAuth Client Credentials; and, with maintainer sign-off, open a PR on `modelcontextprotocol/modelcontextprotocol` to correct the Inspector's row in the client matrix. @@ -513,7 +514,7 @@ items that need no upstream work._ - 🅑 **Session record / replay / share** (§5.2) — format shared with the audit transcript - 🅑 **Diff primitive** (§5.3) — then capability diff (#1034) - 🅑 **Command palette and global search** (§5.4); **Connection Doctor** (§5.8) -- 🅐 **Cache hint display and checks** (§3.2) — SEP-2549 is Final +- 🅐 **Cache hint display and observations** (§3.2) — SEP-2549 is Final - 🅐 **OAuth Client Credentials extension** (§3.3, §4) - 🅐 **`content` / `structuredContent` consistency check** and **destructive-call confirmation** (§3.4) - 🅐 **Extension-watch sweep** (§4) @@ -526,7 +527,7 @@ _Turn the Inspector into something you can run in CI._ - 🅐 **Conformance runner** (§3.6) — shares the assertion engine, if the maintainers agree an interface - 🅑 **OTLP export** (§5.7); **registry browsing** (§5.9) - 🅑 **Grouping and performance at scale** (§5.10); accessibility pass (§5.12) -- 🅐 **State handle view** (§3.2); **extension declaration view** (§3.5) +- 🅐 **Stateful-tool workflow investigation** (§3.2); **extension declaration view** (§3.5) ### Phase 4 — Frontier (~`v2.22` – `v2.27`, Jan–Feb 2027) @@ -560,7 +561,8 @@ Stating these so they are decisions rather than oversights. - **Not chasing pre-Draft SEPs.** 🔴 items get a tracking issue and a WG liaison, not code. We were burned by this in v1. - **Not scheduling build work outside the published priority areas** (§3.7). A WG effort that - the roadmap does not list gets a liaison, not milestones. + the roadmap does not list gets a liaison, not milestones. Approved official extensions (§4) + are exempt: they count as spec-following work even though the roadmap does not list them. - **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.** Custom transports were closed as not From 3961bd17cc8a892f65799041d78494326b3153ae Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 19:47:24 -0400 Subject: [PATCH 31/68] docs: address Copilot round 2 on the roadmap realignment (#2401 review) Admit approved official extensions in the Track A trigger, drop the already-shipped missing-structuredContent case from the consistency check, describe the upstream matrix's Inspector cells as blank rather than unlisted, and file extension-watch issues with the current milestone as sdk-watch does. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 96d46552c9..299e70536c 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -101,7 +101,7 @@ Closed as **not planned**, so not carried forward: custom transports ([#1741](ht | | **Track A — Spec-following** | **Track B — Experience** | | --------------------------- | ----------------------------------------------------------------- | ------------------------------------------- | | **Driver** | MCP roadmap, WG deliverables, SEP acceptance, approved extensions | Our own judgment about the tool | -| **Trigger to start** | A SEP reaches Draft with a Tier-1 SDK reference impl, or is Final | Whenever we have capacity | +| **Trigger to start** | A SEP reaches Draft with a Tier-1 SDK reference impl, or is Final; or an extension is approved as official (§4) | 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% | @@ -242,7 +242,7 @@ What we _can_ do now is show the problem the redesign is solving: a server retur | Feature | Confidence | Notes | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | -| **`content` / `structuredContent` consistency check** — flag results where both are present and disagree, or where a declared `outputSchema` requires `structuredContent` and it is missing | 🟢 | Useful today, and implementation evidence for the Core Primitives WG. | +| **`content` / `structuredContent` consistency check** — flag results where both are present and disagree | 🟢 | Useful today, and implementation evidence for the Core Primitives WG. A missing `structuredContent` under a declared `outputSchema` is already flagged by `validateToolOutput` (shipped). | | **New tool result shape** | 🔴 | WG still forming. Keep both renderings behind the era seam when it lands. | | **Progressive discovery** | 🔴 | Design the lists (§5.10) so "not loaded yet" is a state, not an empty list. | | **Annotation-driven confirmation** before a `destructiveHint` call | 🟢 | Tool annotations are not the audience/priority content annotations under review. Small and obviously correct. | @@ -320,14 +320,14 @@ official status through the Extensions Track of | Extension | Identifier | Web | CLI | TUI | Upstream matrix | Notes | | -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| MCP Apps | `io.modelcontextprotocol/ui` | ✅ | 🟡 | — | ❌ not listed | Apps tab. Columns are rendering support: rendering needs a browser, so the CLI has only the `--app-info` metadata probe and the TUI nothing. The shared client still advertises the extension from CLI and TUI. | +| MCP Apps | `io.modelcontextprotocol/ui` | ✅ | 🟡 | — | Inspector row, cell blank | Apps tab. Columns are rendering support: rendering needs a browser, so the CLI has only the `--app-info` metadata probe and the TUI nothing. The shared client still advertises the extension from CLI and TUI. | | Tasks | `io.modelcontextprotocol/tasks` | ✅ | ❌ | ❌ | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). The CLI's one-shot mode rejects `tasks/*`; no TUI Tasks pane yet. | | Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | -| Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | ✅ | ✅ | ❌ not listed | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). | +| Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | ✅ | ✅ | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). | | OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | ❌ | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | **Actions:** implement OAuth Client Credentials; and, with maintainer sign-off, open a PR on -`modelcontextprotocol/modelcontextprotocol` to correct the Inspector's row in the client matrix. +`modelcontextprotocol/modelcontextprotocol` to fill in the Inspector row's blank Apps and Enterprise Auth cells and update its Skills cell in the client matrix. ### Keeping up as extensions are approved @@ -339,7 +339,7 @@ mechanism, the way SDK releases already are: `/extensions/overview`, compare with a committed list of the ones we have assessed, and file one issue per new entry. It **files issues, never PRs**, and trusts only markers the automation wrote, exactly as the SDK watch does. -- **Official extension** → a `v2` + `enhancement` issue to implement it, milestoned at triage. +- **Official extension** → a `v2` + `enhancement` issue to implement it, filed with the current milestone as `sdk-watch` does; only when no dated milestone is open is it left unmilestoned for triage to place in Incoming. - **Experimental extension** → a `v2` + `question` tracking issue, so we can design against it before its SEP (the 🟡 rule) without committing build capacity. - **This table is the record.** An extension is added here when its issue is filed, and its From 9741759d8687eacf567e7000b5aaa33cb56f9c10 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 16 Sep 2026 20:00:02 -0400 Subject: [PATCH 32/68] docs: address Copilot round 3 on the roadmap realignment (#2401 review) Scope the tool result check to the one relationship the spec defines (structuredContent SHOULD be accompanied by its serialized JSON in a TextContent block), and describe the upstream matrix update as partial-support notation, since its single Inspector row cannot express per-client support. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 299e70536c..3674eab1e9 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -237,12 +237,13 @@ result view; progressive discovery breaks the assumption behind every list we re in richer annotation rendering now. The first draft's §3.6 (streamed and reference results) and §3.8 (the SEP-2356 file picker) are **not on the published roadmap** and move to watch. -What we _can_ do now is show the problem the redesign is solving: a server returning -`content` and `structuredContent` that disagree is a real bug today. +What we _can_ do now is show one concrete symptom of the problem the redesign is solving: a +server that returns `structuredContent` without the serialized-JSON text block the spec asks +for breaks older clients today. | Feature | Confidence | Notes | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | -| **`content` / `structuredContent` consistency check** — flag results where both are present and disagree | 🟢 | Useful today, and implementation evidence for the Core Primitives WG. A missing `structuredContent` under a declared `outputSchema` is already flagged by `validateToolOutput` (shipped). | +| **Serialized-JSON check for `structuredContent`** — when a result carries `structuredContent`, flag the absence of a `TextContent` block holding its serialized JSON, the one relationship the spec defines (a SHOULD, "for backwards compatibility"). Reported as a diagnostic, never an error; any other text is a legitimate summary and is not compared | 🟢 | Useful today, and implementation evidence for the Core Primitives WG. A missing `structuredContent` under a declared `outputSchema` is already flagged by `validateToolOutput` (shipped). | | **New tool result shape** | 🔴 | WG still forming. Keep both renderings behind the era seam when it lands. | | **Progressive discovery** | 🔴 | Design the lists (§5.10) so "not loaded yet" is a state, not an empty list. | | **Annotation-driven confirmation** before a `destructiveHint` call | 🟢 | Tool annotations are not the audience/priority content annotations under review. Small and obviously correct. | @@ -327,7 +328,9 @@ official status through the Extensions Track of | OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | ❌ | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | **Actions:** implement OAuth Client Credentials; and, with maintainer sign-off, open a PR on -`modelcontextprotocol/modelcontextprotocol` to fill in the Inspector row's blank Apps and Enterprise Auth cells and update its Skills cell in the client matrix. +`modelcontextprotocol/modelcontextprotocol` to update the Inspector row. That matrix has one row per client, +so it cannot show per-client support: mark Apps and Skills as partial with a link explaining the split (Apps renders in +Web only; the CLI has a metadata probe), or propose separate Web/CLI/TUI rows. Enterprise Auth can be a plain check. ### Keeping up as extensions are approved @@ -516,7 +519,7 @@ items that need no upstream work._ - 🅑 **Command palette and global search** (§5.4); **Connection Doctor** (§5.8) - 🅐 **Cache hint display and observations** (§3.2) — SEP-2549 is Final - 🅐 **OAuth Client Credentials extension** (§3.3, §4) -- 🅐 **`content` / `structuredContent` consistency check** and **destructive-call confirmation** (§3.4) +- 🅐 **Serialized-JSON check for `structuredContent`** and **destructive-call confirmation** (§3.4) - 🅐 **Extension-watch sweep** (§4) ### Phase 3 — Automation (~`v2.16` – `v2.21`, Nov 2026 – Jan 2027) From a9453bda5459fe0c8197a8053600365402f955d3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 10:17:02 -0400 Subject: [PATCH 33/68] docs: address Copilot round 4 on the roadmap realignment (#2401 review) Accuracy fixes: Last-Event-ID is legacy-only; #1005/#1015 shipped a schema portability lint, not a validator; Tasks is an extension, not base spec; the session lifecycle lane is obsolete only for modern connections; streamed results are deprioritized, not absent; payload truncation is planned; the extension sweep is idempotent via issue markers with a maintainer-kept table; the matrix's OAuth cell is blank, not unsupported. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 36 +++++++++++++++++-------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 3674eab1e9..2e2e085134 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -82,10 +82,10 @@ be re-planned: | Item | Issue(s) | | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Last-Event-ID` resumption | [#920](https://github.com/modelcontextprotocol/inspector/issues/920) | +| `Last-Event-ID` resumption (legacy Streamable HTTP only; the 2026-07-28 era removed SSE resumability) | [#920](https://github.com/modelcontextprotocol/inspector/issues/920) | | `server.json` support | [#922](https://github.com/modelcontextprotocol/inspector/issues/922) | | Discover checkmarks for task extensions | [#1887](https://github.com/modelcontextprotocol/inspector/issues/1887) | -| Strict JSON Schema validation | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015) | +| Tool-schema portability lint (`--strict`) | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015) | | The argument editor workstream (all six issues) | [#1853](https://github.com/modelcontextprotocol/inspector/issues/1853), [#1856](https://github.com/modelcontextprotocol/inspector/issues/1856), [#1885](https://github.com/modelcontextprotocol/inspector/issues/1885), [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928), [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919), [#1910](https://github.com/modelcontextprotocol/inspector/issues/1910) | | Connection fixes (version-negotiation DX, `https://localhost`, dev containers, ghost entry) | [#962](https://github.com/modelcontextprotocol/inspector/issues/962), [#1936](https://github.com/modelcontextprotocol/inspector/issues/1936), [#1951](https://github.com/modelcontextprotocol/inspector/issues/1951), [#1914](https://github.com/modelcontextprotocol/inspector/issues/1914) | | Server config: paste-JSON, custom headers, auth URL overrides, file-backed secrets | [#904](https://github.com/modelcontextprotocol/inspector/issues/904), [#1915](https://github.com/modelcontextprotocol/inspector/issues/1915), [#1906](https://github.com/modelcontextprotocol/inspector/issues/1906), [#1950](https://github.com/modelcontextprotocol/inspector/issues/1950) | @@ -161,7 +161,7 @@ conversation now and bring it to the WG as implementation feedback. | Feature | Confidence | Notes | | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| **In-flight work lane** — tasks, open `subscriptions/listen` streams and progress-reporting requests as spans on one timeline lane (§5.1) | 🟢 | All three already exist in the 2026-07-28 spec. Makes composition gaps (mismatched cancellation, divergent errors) visible, which the WG can use. | +| **In-flight work lane** — tasks, open `subscriptions/listen` streams and progress-reporting requests as spans on one timeline lane (§5.1) | 🟢 | `subscriptions/listen` and progress are in the 2026-07-28 spec; Tasks is the official `io.modelcontextprotocol/tasks` extension (§4). Makes composition gaps (mismatched cancellation, divergent errors) visible, which the WG can use. | | **Cancellation and error comparison** — show how each in-flight kind ended (completed, cancelled, errored, server-closed) with the same vocabulary | 🟢 | A small, direct contribution to the composition review. | | **Callback receiver** — backend-hosted endpoint registered as a push target | 🔴 | Design now, build when the SEP lands. Security review mandatory: an inbound public endpoint on a process that spawns subprocesses. | | **Local reachability story** — tunnel integration or documented guidance | 🔴 | Likely the hardest UX problem of the six months. | @@ -179,10 +179,12 @@ including for tool-call results. **Beyond:** standardized error handling across capability scoping for tool lists after SEP-2575, and a secure way to hand servers configuration. -**Read:** The first draft's §3.1 (stateless Streamable HTTP, session creation / resumption / -migration) is **largely obsolete**: SEP-2575 (stateless) and SEP-2567 (sessionless, explicit -state handles) are Final and already shipped. A "session lifecycle lane" describes a model the -spec has left behind; what remains to show is **state handles**. Caching, on the other hand, is +**Read:** For **modern** (2026-07-28) connections, the first draft's §3.1 (stateless Streamable +HTTP, session creation / resumption / migration) is **largely obsolete**: SEP-2575 (stateless) +and SEP-2567 (sessionless) are Final and already shipped, so a session lifecycle lane has nothing +to show there. The Inspector is still a dual-era client, though, and **legacy** Streamable HTTP +keeps `initialize` and session-scoped state; a session lifecycle lane for legacy connections stays +a valid, **deferred** timeline follow-up (§5.1) rather than being dropped. Caching, on the other hand, is Final and we already parse the fields — we just do not render them, and a client that shows cache hints is exactly how a server author finds out theirs are wrong. @@ -235,7 +237,8 @@ filesystem-like resource semantics** (range reads, hierarchical listing). result view; progressive discovery breaks the assumption behind every list we render (that `*/list` returns everything); and a possible annotation deprecation means we should not invest in richer annotation rendering now. The first draft's §3.6 (streamed and reference results) -and §3.8 (the SEP-2356 file picker) are **not on the published roadmap** and move to watch. +and §3.8 (the SEP-2356 file picker) are **not prioritized deliverables for this period** — the +roadmap mentions "results that stream" only in framing — so they move to watch. What we _can_ do now is show one concrete symptom of the problem the redesign is solving: a server that returns `structuredContent` without the serialized-JSON text block the spec asks @@ -300,7 +303,7 @@ work for them this horizon**. Each keeps a tracking issue and a liaison. | **Server Cards** (SEP-2127) | Card preview, card-vs-reality diff, `--card-lint` in Phase 3 | 🔴 Watch. [#1857](https://github.com/modelcontextprotocol/inspector/issues/1857)'s **registry** half does not depend on it (§5.9). | | **Interceptors** (SEP-1763) | Test bench, audit mode, CLI invocation in Phase 4 | 🔴 Watch. The WG's unowned "CLI client for interceptor invocation" is still worth raising (§8). | | **Primitive grouping** (IG) | Grouped sidebars | The **UX** half proceeds as Track B (§5.10) on client-side heuristics; no spec data source is expected this horizon. | -| **Streamed and reference results** | Incremental rendering, reference handles | 🔴 Watch. Payload truncation in §5.10 covers the large-result case today. | +| **Streamed and reference results** | Incremental rendering, reference handles | 🔴 Watch. Planned payload truncation (§5.10) will cover the large-result case; result views render full payloads today. | | **File picker from `FileInputDescriptor`** (SEP-2356) | `SchemaForm` + elicitation picker | 🔴 Watch. The File Uploads WG's published direction is now filesystem-like resources (§3.4). | | **Gateways, audit trails, configuration portability** | Gateway mode; OTLP as spec work | Gateway mode dropped. OTLP and the audit transcript continue as Track B (§5.7). | @@ -325,7 +328,7 @@ official status through the Extensions Track of | Tasks | `io.modelcontextprotocol/tasks` | ✅ | ❌ | ❌ | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). The CLI's one-shot mode rejects `tasks/*`; no TUI Tasks pane yet. | | Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | | Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | ✅ | ✅ | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). | -| OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | ❌ | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | +| OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | Inspector row, cell blank | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | **Actions:** implement OAuth Client Credentials; and, with maintainer sign-off, open a PR on `modelcontextprotocol/modelcontextprotocol` to update the Inspector row. That matrix has one row per client, @@ -339,14 +342,15 @@ mechanism, the way SDK releases already are: - **An extension-watch sweep**, modelled on `scripts/sdk-watch.mjs`: on a schedule, list the org's `ext-*` and `experimental-ext-*` repositories and the extension identifiers on - `/extensions/overview`, compare with a committed list of the ones we have assessed, and file - one issue per new entry. It **files issues, never PRs**, and trusts only markers the - automation wrote, exactly as the SDK watch does. + `/extensions/overview`, and file one issue per entry it has not filed before. As in the SDK watch, the **issue markers + are the source of truth** for idempotency: an entry whose marker is on an existing issue (open or + closed) authored by the automation is skipped, so nothing needs committing back. It **files + issues, never PRs**, and trusts only markers the automation wrote. - **Official extension** → a `v2` + `enhancement` issue to implement it, filed with the current milestone as `sdk-watch` does; only when no dated milestone is open is it left unmilestoned for triage to place in Incoming. - **Experimental extension** → a `v2` + `question` tracking issue, so we can design against it before its SEP (the 🟡 rule) without committing build capacity. -- **This table is the record.** An extension is added here when its issue is filed, and its - cells move as support lands. +- **This table is maintainer-maintained.** The sweep never edits it; a maintainer adds a row when + an extension's issue is triaged and moves its cells as support lands. --- @@ -502,7 +506,7 @@ annotated with what has already shipped. ### Phase 1 — Foundations (~`v2.2` – `v2.9`, Aug–Sep 2026) -- ✅ `Last-Event-ID` resumption (#920); discover checkmarks (#1887); `server.json` (#922) +- ✅ `Last-Event-ID` resumption, legacy only (#920); discover checkmarks (#1887); `server.json` (#922) - ✅ Argument editor workstream (six issues); connection fixes (§1) - ✅ Skills over MCP (#2234, #2248); Enterprise-Managed Authorization (#1509) - 🅑 **Zoomable timeline v1** — carried into Phase 2 From 5157b0a16cfb6980c7ca2a77687bd71c1447a9c8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 10:35:31 -0400 Subject: [PATCH 34/68] docs: address Copilot round 5 on the roadmap realignment (#2401 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the shipped history (EMA predates the first draft; #1936 shipped guidance, not a fix), finish the portability-lint rename in §3.6, note Phase 1 is selective, clarify the roadmap's extension coverage, add the Transports WG charter, fix a comma splice, and defer the extension sweep's marker-label and bootstrap rules to its own design issue. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 2e2e085134..85b10016a1 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -87,9 +87,9 @@ be re-planned: | Discover checkmarks for task extensions | [#1887](https://github.com/modelcontextprotocol/inspector/issues/1887) | | Tool-schema portability lint (`--strict`) | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015) | | The argument editor workstream (all six issues) | [#1853](https://github.com/modelcontextprotocol/inspector/issues/1853), [#1856](https://github.com/modelcontextprotocol/inspector/issues/1856), [#1885](https://github.com/modelcontextprotocol/inspector/issues/1885), [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928), [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919), [#1910](https://github.com/modelcontextprotocol/inspector/issues/1910) | -| Connection fixes (version-negotiation DX, `https://localhost`, dev containers, ghost entry) | [#962](https://github.com/modelcontextprotocol/inspector/issues/962), [#1936](https://github.com/modelcontextprotocol/inspector/issues/1936), [#1951](https://github.com/modelcontextprotocol/inspector/issues/1951), [#1914](https://github.com/modelcontextprotocol/inspector/issues/1914) | +| Connection fixes (version-negotiation DX, dev containers, ghost entry) and self-signed `https://localhost` guidance (documented trust configuration, not a code fix) | [#962](https://github.com/modelcontextprotocol/inspector/issues/962), [#1936](https://github.com/modelcontextprotocol/inspector/issues/1936), [#1951](https://github.com/modelcontextprotocol/inspector/issues/1951), [#1914](https://github.com/modelcontextprotocol/inspector/issues/1914) | | Server config: paste-JSON, custom headers, auth URL overrides, file-backed secrets | [#904](https://github.com/modelcontextprotocol/inspector/issues/904), [#1915](https://github.com/modelcontextprotocol/inspector/issues/1915), [#1906](https://github.com/modelcontextprotocol/inspector/issues/1906), [#1950](https://github.com/modelcontextprotocol/inspector/issues/1950) | -| Enterprise-Managed Authorization; IdP OIDC option | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509), [#1937](https://github.com/modelcontextprotocol/inspector/issues/1937) | +| IdP OIDC option (EMA itself, #1509, predates the first draft) | [#1937](https://github.com/modelcontextprotocol/inspector/issues/1937) | | Skills over MCP (SEP-2640) across web, CLI and TUI | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248) | Closed as **not planned**, so not carried forward: custom transports ([#1741](https://github.com/modelcontextprotocol/inspector/issues/1741)), the configurable-proxy base ([#1684](https://github.com/modelcontextprotocol/inspector/issues/1684)), the readiness summary ([#1916](https://github.com/modelcontextprotocol/inspector/issues/1916)), full panel collapse ([#928](https://github.com/modelcontextprotocol/inspector/issues/928)), `*.localhost` domains ([#1944](https://github.com/modelcontextprotocol/inspector/issues/1944)), and the trusted-local-host OAuth HTTP exception ([#1911](https://github.com/modelcontextprotocol/inspector/issues/1911)). @@ -289,7 +289,7 @@ does not. | ------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Conformance runner** — run the suite against a connected server, render pass/fail per assertion | 🟡 | **Action: open a conversation with the conformance maintainers.** Build the shared assertion engine (§5.6) first. | | **`mcp-inspector --conformance` for CI** | 🟡 | Same engine, CLI report, exit code. | -| **Strict schema validation with actionable errors** | ✅ | Shipped — [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015). | +| **Tool-schema portability lint (`--strict`)** — not a full JSON Schema validator | ✅ | Shipped — [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015). | ### 3.7 Off the published roadmap — watch only @@ -311,9 +311,9 @@ work for them this horizon**. Each keeps a tracking issue and a liaison. ## 4. Official extensions -The MCP roadmap does not list extensions, but **approved extensions are spec-following work** — +The MCP roadmap mentions Tasks (§3.1) but carries no inventory of official extensions, and **approved extensions are spec-following work** — a client that ignores them stops being a reference client. The list lives at -[`/extensions/overview`](https://modelcontextprotocol.io/extensions/overview), implementations +[`/extensions/overview`](https://modelcontextprotocol.io/extensions/overview); implementations are recorded in the community-maintained [client matrix](https://modelcontextprotocol.io/extensions/client-matrix), and extensions reach official status through the Extensions Track of @@ -345,7 +345,9 @@ mechanism, the way SDK releases already are: `/extensions/overview`, and file one issue per entry it has not filed before. As in the SDK watch, the **issue markers are the source of truth** for idempotency: an entry whose marker is on an existing issue (open or closed) authored by the automation is skipped, so nothing needs committing back. It **files - issues, never PRs**, and trusts only markers the automation wrote. + issues, never PRs**. Two details are left to the sweep's own design issue: which labels a trusted + marker issue must also carry (as `sdk-watch` requires), and the first-run bootstrap for extensions + already tracked by hand-filed issues (Skills, EMA), so that it does not file duplicates. - **Official extension** → a `v2` + `enhancement` issue to implement it, filed with the current milestone as `sdk-watch` does; only when no dated milestone is open is it left unmilestoned for triage to place in Incoming. - **Experimental extension** → a `v2` + `question` tracking issue, so we can design against it before its SEP (the 🟡 rule) without committing build capacity. @@ -502,13 +504,13 @@ diff, and session format exist would mean designing it against the wrong surface 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 is -annotated with what has already shipped. +annotated with a selection of what has already shipped; §1 has the full list. ### Phase 1 — Foundations (~`v2.2` – `v2.9`, Aug–Sep 2026) - ✅ `Last-Event-ID` resumption, legacy only (#920); discover checkmarks (#1887); `server.json` (#922) - ✅ Argument editor workstream (six issues); connection fixes (§1) -- ✅ Skills over MCP (#2234, #2248); Enterprise-Managed Authorization (#1509) +- ✅ Skills over MCP (#2234, #2248) - 🅑 **Zoomable timeline v1** — carried into Phase 2 - 🅑 **Connection Doctor** (§5.8) — carried into Phase 2 @@ -608,7 +610,7 @@ For WG discussion. - [MCP Roadmap](https://modelcontextprotocol.io/development/roadmap) (last updated 2026-08-22) - [Extensions overview](https://modelcontextprotocol.io/extensions/overview) · [Extension support matrix](https://modelcontextprotocol.io/extensions/client-matrix) · [SEP-2133: Extensions](https://modelcontextprotocol.io/seps/2133-extensions) - Final SEPs cited: [SEP-2549 (TTL for list results)](https://modelcontextprotocol.io/seps/2549-TTL-for-list-results) · [SEP-2567 (sessionless)](https://modelcontextprotocol.io/seps/2567-sessionless-mcp) · [SEP-2575 (stateless)](https://modelcontextprotocol.io/seps/2575-stateless-mcp) · [SEP-2663 (Tasks extension)](https://modelcontextprotocol.io/seps/2663-tasks-extension) · [SEP-2640 (Skills extension)](https://modelcontextprotocol.io/seps/2640-skills-extension) · [SEP-2484 (conformance tests)](https://modelcontextprotocol.io/seps/2484-conformance-tests-required-for-final-seps) -- WG charters: [Inspector V2](https://modelcontextprotocol.io/community/working-groups/inspector-v2) · [Triggers & Events](https://modelcontextprotocol.io/community/working-groups/triggers-events) · [Agents](https://modelcontextprotocol.io/community/working-groups/agents) · [File Uploads](https://modelcontextprotocol.io/community/working-groups/file-uploads) · [SDK](https://modelcontextprotocol.io/community/working-groups/sdk) +- WG charters: [Inspector V2](https://modelcontextprotocol.io/community/working-groups/inspector-v2) · [Triggers & Events](https://modelcontextprotocol.io/community/working-groups/triggers-events) · [Agents](https://modelcontextprotocol.io/community/working-groups/agents) · [Transports](https://modelcontextprotocol.io/community/working-groups/transports) · [File Uploads](https://modelcontextprotocol.io/community/working-groups/file-uploads) · [SDK](https://modelcontextprotocol.io/community/working-groups/sdk) - [SDK tiers and conformance testing](https://modelcontextprotocol.io/community/sdk-tiers) - 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 a2430e70a6e7a30a47d0196100c4064d730b8831 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 10:53:57 -0400 Subject: [PATCH 35/68] docs: address Copilot round 6 on the roadmap realignment (#2401 review) Scope cache hints to the SEP-2549 methods, mark CLI/TUI Tasks as partial (shared-client polling without direct controls), link the CLI/TUI Apps advertisement bug (#2403), complete the sweep's bootstrap list, and describe experimental incubation as optional per SEP-2133. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 85b10016a1..fb98856f43 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -193,7 +193,7 @@ where the Inspector is thinnest over the SDK. Watch closely. | Feature | Confidence | Notes | | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Cache hint display** — `ttlMs` / `cacheScope` on every list and resource read, with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. The runtime already parses the hints and honors them through the SDK list cache; the gap is showing them. | +| **Cache hint display** — `ttlMs` / `cacheScope` on the SEP-2549 surfaces (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`; extension methods such as `skills/list` carry none), with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. The runtime already parses the hints and honors them through the SDK list cache; the gap is showing them. | | **Cache behavior observations** — note a re-fetch of a still-fresh result, and a list that changed inside its declared TTL, as diagnostics rather than errors | 🟢 | Inspector-shaped: nobody else observes both the hint and the reality. `ttlMs` is a freshness hint, so both are compliant. | | **Stateful-tool workflow investigation** — how to help a user carry an SEP-2567-style handle from one tool result into the next call | 🟡 | Replaces the first draft's "session lifecycle lane". The protocol has no concept of a handle (it is ordinary tool data), so a generic view would be inference; investigate before designing. | | **ETag support** — send `If-None-Match`, show 304s and version changes | 🟡 | Build when the SEP reaches Draft with an SDK impl. | @@ -317,15 +317,15 @@ a client that ignores them stops being a reference client. The list lives at are recorded in the community-maintained [client matrix](https://modelcontextprotocol.io/extensions/client-matrix), and extensions reach official status through the Extensions Track of -[SEP-2133](https://modelcontextprotocol.io/seps/2133-extensions), usually after incubating in an -`experimental-ext-*` repository. +[SEP-2133](https://modelcontextprotocol.io/seps/2133-extensions), optionally after incubating in an +`experimental-ext-*` repository (encouraged, not required). ### Current support (as of 2026-09-16) | Extension | Identifier | Web | CLI | TUI | Upstream matrix | Notes | | -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| MCP Apps | `io.modelcontextprotocol/ui` | ✅ | 🟡 | — | Inspector row, cell blank | Apps tab. Columns are rendering support: rendering needs a browser, so the CLI has only the `--app-info` metadata probe and the TUI nothing. The shared client still advertises the extension from CLI and TUI. | -| Tasks | `io.modelcontextprotocol/tasks` | ✅ | ❌ | ❌ | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). The CLI's one-shot mode rejects `tasks/*`; no TUI Tasks pane yet. | +| MCP Apps | `io.modelcontextprotocol/ui` | ✅ | 🟡 | — | Inspector row, cell blank | Apps tab. Columns are rendering support: rendering needs a browser, so the CLI has only the `--app-info` metadata probe and the TUI nothing. The shared client still advertises the extension from CLI and TUI, which is a compatibility bug tracked in [#2403](https://github.com/modelcontextprotocol/inspector/issues/2403). | +| Tasks | `io.modelcontextprotocol/tasks` | ✅ | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). CLI and TUI advertise the extension and poll server-directed task handles through the shared client, but have no direct `tasks/*` controls: the CLI's one-shot mode rejects them, and the TUI has no Tasks pane. | | Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | | Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | ✅ | ✅ | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). | | OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | Inspector row, cell blank | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | @@ -347,7 +347,7 @@ mechanism, the way SDK releases already are: closed) authored by the automation is skipped, so nothing needs committing back. It **files issues, never PRs**. Two details are left to the sweep's own design issue: which labels a trusted marker issue must also carry (as `sdk-watch` requires), and the first-run bootstrap for extensions - already tracked by hand-filed issues (Skills, EMA), so that it does not file duplicates. + already tracked by hand-filed issues (Apps #1740, Tasks #1887, Skills #2234, EMA #1509), so that it does not file duplicates. - **Official extension** → a `v2` + `enhancement` issue to implement it, filed with the current milestone as `sdk-watch` does; only when no dated milestone is open is it left unmilestoned for triage to place in Incoming. - **Experimental extension** → a `v2` + `question` tracking issue, so we can design against it before its SEP (the 🟡 rule) without committing build capacity. From 223579856f11f9f20f615b101786c2c08b81a5a4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 11:12:40 -0400 Subject: [PATCH 36/68] docs: address Copilot round 7 on the roadmap realignment (#2401 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include modern skills/list (SEP-2640 requires ttlMs/cacheScope) in the cache-hint surfaces, soften the serialized-JSON consequence to match its SHOULD, describe CLI/TUI Tasks as core support with no user-facing surface, and note that #1857 continues in §5.9. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index fb98856f43..563f746ce8 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -193,7 +193,7 @@ where the Inspector is thinnest over the SDK. Watch closely. | Feature | Confidence | Notes | | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Cache hint display** — `ttlMs` / `cacheScope` on the SEP-2549 surfaces (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`; extension methods such as `skills/list` carry none), with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. The runtime already parses the hints and honors them through the SDK list cache; the gap is showing them. | +| **Cache hint display** — `ttlMs` / `cacheScope` on the SEP-2549 surfaces (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`), plus modern (2026-07-28+) `skills/list`, which SEP-2640 requires to carry both fields; legacy `skills/list` and `skills/get` carry none, with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. The runtime already parses the hints and honors them through the SDK list cache; the gap is showing them. | | **Cache behavior observations** — note a re-fetch of a still-fresh result, and a list that changed inside its declared TTL, as diagnostics rather than errors | 🟢 | Inspector-shaped: nobody else observes both the hint and the reality. `ttlMs` is a freshness hint, so both are compliant. | | **Stateful-tool workflow investigation** — how to help a user carry an SEP-2567-style handle from one tool result into the next call | 🟡 | Replaces the first draft's "session lifecycle lane". The protocol has no concept of a handle (it is ordinary tool data), so a generic view would be inference; investigate before designing. | | **ETag support** — send `If-None-Match`, show 304s and version changes | 🟡 | Build when the SEP reaches Draft with an SDK impl. | @@ -242,7 +242,7 @@ roadmap mentions "results that stream" only in framing — so they move to watch What we _can_ do now is show one concrete symptom of the problem the redesign is solving: a server that returns `structuredContent` without the serialized-JSON text block the spec asks -for breaks older clients today. +for can hide its structured data from older clients today. | Feature | Confidence | Notes | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | @@ -305,7 +305,7 @@ work for them this horizon**. Each keeps a tracking issue and a liaison. | **Primitive grouping** (IG) | Grouped sidebars | The **UX** half proceeds as Track B (§5.10) on client-side heuristics; no spec data source is expected this horizon. | | **Streamed and reference results** | Incremental rendering, reference handles | 🔴 Watch. Planned payload truncation (§5.10) will cover the large-result case; result views render full payloads today. | | **File picker from `FileInputDescriptor`** (SEP-2356) | `SchemaForm` + elicitation picker | 🔴 Watch. The File Uploads WG's published direction is now filesystem-like resources (§3.4). | -| **Gateways, audit trails, configuration portability** | Gateway mode; OTLP as spec work | Gateway mode dropped. OTLP and the audit transcript continue as Track B (§5.7). | +| **Gateways, audit trails, configuration portability** | Gateway mode; OTLP as spec work | Gateway mode dropped. OTLP and the audit transcript continue as Track B (§5.7); rich server configuration and registry browsing (#1857) continue in §5.9. | --- @@ -325,7 +325,7 @@ official status through the Extensions Track of | Extension | Identifier | Web | CLI | TUI | Upstream matrix | Notes | | -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | MCP Apps | `io.modelcontextprotocol/ui` | ✅ | 🟡 | — | Inspector row, cell blank | Apps tab. Columns are rendering support: rendering needs a browser, so the CLI has only the `--app-info` metadata probe and the TUI nothing. The shared client still advertises the extension from CLI and TUI, which is a compatibility bug tracked in [#2403](https://github.com/modelcontextprotocol/inspector/issues/2403). | -| Tasks | `io.modelcontextprotocol/tasks` | ✅ | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). CLI and TUI advertise the extension and poll server-directed task handles through the shared client, but have no direct `tasks/*` controls: the CLI's one-shot mode rejects them, and the TUI has no Tasks pane. | +| Tasks | `io.modelcontextprotocol/tasks` | ✅ | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). CLI and TUI advertise the extension and the shared core supports it, but neither exposes a user-facing task surface: the CLI's one-shot mode rejects `tasks/*`, and the TUI has no Tasks pane. | | Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | | Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | ✅ | ✅ | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). | | OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | Inspector row, cell blank | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | From 1683fa4ea383873b109e2c15218201b12f6d54dc Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 11:32:02 -0400 Subject: [PATCH 37/68] docs: address Copilot round 8 on the roadmap realignment (#2401 review) Qualify the 2026-07-28 baseline with #1917, move ETags to watch, scope what the runtime already honors for cache hints, file experimental-extension issues unmilestoned for Incoming, mark Skills as a plain matrix check, and describe server configuration as a Beyond item rather than absent. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 563f746ce8..21067fb4b0 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -55,7 +55,7 @@ Through v1, the Inspector was a **follow-along project**. The spec moved, we cha 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, +That constraint has lifted. v2 meets the 2026-07-28 spec across all three clients (one known exception, #1917, waits on an SDK release), 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. @@ -166,7 +166,7 @@ conversation now and bring it to the WG as implementation feedback. | **Callback receiver** — backend-hosted endpoint registered as a push target | 🔴 | Design now, build when the SEP lands. Security review mandatory: an inbound public endpoint on a process that spawns subprocesses. | | **Local reachability story** — tunnel integration or documented guidance | 🔴 | Likely the hardest UX problem of the six months. | | **Delivery log with ordering and duplicate assertions** | 🔴 | The conformance value: did events arrive in order? were any redelivered? | -| **`Mcp-Name` header on Tasks over Streamable HTTP** | 🟡 | [#1917](https://github.com/modelcontextprotocol/inspector/issues/1917) — blocked upstream. | +| **`Mcp-Name` header on Tasks over Streamable HTTP** | 🟡 | [#1917](https://github.com/modelcontextprotocol/inspector/issues/1917) — a current non-conformance: the fix is merged upstream but unreleased, so the pinned SDK still omits the header SEP-2663 requires. Waits on the next SDK release. | | **Tasks extension → core migration** | 🔴 | Moved to "Beyond" upstream. Keep the era-conditional exposure; the legacy `capabilities.tasks` path must keep working. | ### 3.2 HTTP-native transport unification and hardening @@ -193,10 +193,10 @@ where the Inspector is thinnest over the SDK. Watch closely. | Feature | Confidence | Notes | | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Cache hint display** — `ttlMs` / `cacheScope` on the SEP-2549 surfaces (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`), plus modern (2026-07-28+) `skills/list`, which SEP-2640 requires to carry both fields; legacy `skills/list` and `skills/get` carry none, with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. The runtime already parses the hints and honors them through the SDK list cache; the gap is showing them. | +| **Cache hint display** — `ttlMs` / `cacheScope` on the SEP-2549 surfaces (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`), plus modern (2026-07-28+) `skills/list`, which SEP-2640 requires to carry both fields; legacy `skills/list` and `skills/get` carry none, with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. The runtime parses the hints everywhere and honors them through the SDK cache for the four `*/list` methods; `resources/read` and `skills/list` go through plain requests that validate but do not honor them, so this item includes that plumbing as well as the display. | | **Cache behavior observations** — note a re-fetch of a still-fresh result, and a list that changed inside its declared TTL, as diagnostics rather than errors | 🟢 | Inspector-shaped: nobody else observes both the hint and the reality. `ttlMs` is a freshness hint, so both are compliant. | | **Stateful-tool workflow investigation** — how to help a user carry an SEP-2567-style handle from one tool result into the next call | 🟡 | Replaces the first draft's "session lifecycle lane". The protocol has no concept of a handle (it is ordinary tool data), so a generic view would be inference; investigate before designing. | -| **ETag support** — send `If-None-Match`, show 304s and version changes | 🟡 | Build when the SEP reaches Draft with an SDK impl. | +| **ETag support** — send `If-None-Match`, show 304s and version changes | 🔴 | Watch until a SEP reaches Draft with an SDK impl. | | **HTTP over stdio** | 🔴 | Watch. If it lands, the Network screen becomes meaningful for stdio servers too — a large win. | | **Standardized error rendering** | 🔴 | "Beyond". Our Protocol-vs-Network error split (#1628) is the seam to adopt it into. | @@ -211,8 +211,9 @@ human-presence attestation. **Read:** DPoP was 🔴 in the first draft and is now a named deliverable, so it moves up. Our EMA work (#1509) already gives us the ID-JAG leg, which makes the Inspector a credible test -client for the whole identity chain. The first draft's audit trails, gateway mode and -configuration portability are **no longer on the MCP roadmap**; OTLP export and the audit +client for the whole identity chain. The first draft's audit trails and gateway mode are **no longer on the MCP roadmap**, and +configuration ("providing servers with configuration options in a secure way") is now a +"Beyond" item (§3.2), outside this horizon; OTLP export and the audit transcript are still worth building, but as our own Track B work (§5.7), not as spec-following. | Feature | Confidence | Notes | @@ -332,8 +333,8 @@ official status through the Extensions Track of **Actions:** implement OAuth Client Credentials; and, with maintainer sign-off, open a PR on `modelcontextprotocol/modelcontextprotocol` to update the Inspector row. That matrix has one row per client, -so it cannot show per-client support: mark Apps and Skills as partial with a link explaining the split (Apps renders in -Web only; the CLI has a metadata probe), or propose separate Web/CLI/TUI rows. Enterprise Auth can be a plain check. +so it cannot show per-client support: mark Apps as partial with a link explaining the split (Apps renders in +Web only; the CLI has a metadata probe), or propose separate Web/CLI/TUI rows. Skills and Enterprise Auth can be plain checks. ### Keeping up as extensions are approved @@ -349,8 +350,9 @@ mechanism, the way SDK releases already are: marker issue must also carry (as `sdk-watch` requires), and the first-run bootstrap for extensions already tracked by hand-filed issues (Apps #1740, Tasks #1887, Skills #2234, EMA #1509), so that it does not file duplicates. - **Official extension** → a `v2` + `enhancement` issue to implement it, filed with the current milestone as `sdk-watch` does; only when no dated milestone is open is it left unmilestoned for triage to place in Incoming. -- **Experimental extension** → a `v2` + `question` tracking issue, so we can design against it - before its SEP (the 🟡 rule) without committing build capacity. +- **Experimental extension** → a `v2` + `question` tracking issue, filed **unmilestoned and + unboarded** so triage places it in Incoming (the documented exception for unapproved work); it + gets a milestone only if a maintainer approves design work against it before its SEP. - **This table is maintainer-maintained.** The sweep never edits it; a maintainer adds a row when an extension's issue is triaged and moves its cells as support lands. From 070fe36e6e8cccb8db92b35193fef5cf82bbd85f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 11:50:19 -0400 Subject: [PATCH 38/68] docs: address Copilot round 9 on the roadmap realignment (#2401 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Date the support snapshot 2026-09-17, mark web Tasks partial pending #1917, keep server configuration out of the off-roadmap row, source extension ids from each spec, make Phase 4 ETags and contract validation conditional, and tighten the §3.7 and client-matrix wording. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 21067fb4b0..6e45fa62da 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -294,8 +294,8 @@ does not. ### 3.7 Off the published roadmap — watch only -The first draft planned build work for several WG efforts that the 2026-08-22 roadmap does not -list. They are not cancelled upstream — WGs keep working outside the priority areas — but the +The first draft planned build work for several WG efforts that are not priority deliverables in +the 2026-08-22 roadmap (some, such as streamed results, appear only in its framing). They are not cancelled upstream — WGs keep working outside the priority areas — but the roadmap says SEPs outside those areas "expect a longer queue", so **we do not schedule build work for them this horizon**. Each keeps a tracking issue and a liaison. @@ -306,7 +306,7 @@ work for them this horizon**. Each keeps a tracking issue and a liaison. | **Primitive grouping** (IG) | Grouped sidebars | The **UX** half proceeds as Track B (§5.10) on client-side heuristics; no spec data source is expected this horizon. | | **Streamed and reference results** | Incremental rendering, reference handles | 🔴 Watch. Planned payload truncation (§5.10) will cover the large-result case; result views render full payloads today. | | **File picker from `FileInputDescriptor`** (SEP-2356) | `SchemaForm` + elicitation picker | 🔴 Watch. The File Uploads WG's published direction is now filesystem-like resources (§3.4). | -| **Gateways, audit trails, configuration portability** | Gateway mode; OTLP as spec work | Gateway mode dropped. OTLP and the audit transcript continue as Track B (§5.7); rich server configuration and registry browsing (#1857) continue in §5.9. | +| **Gateways, audit trails** | Gateway mode; OTLP as spec work | Gateway mode dropped. OTLP and the audit transcript continue as Track B (§5.7). (Secure server configuration is not off the roadmap: it is a "Beyond" item, §3.2; our rich server configuration and registry browsing, #1857, continue in §5.9.) | --- @@ -321,19 +321,19 @@ official status through the Extensions Track of [SEP-2133](https://modelcontextprotocol.io/seps/2133-extensions), optionally after incubating in an `experimental-ext-*` repository (encouraged, not required). -### Current support (as of 2026-09-16) +### Current support (as of 2026-09-17) | Extension | Identifier | Web | CLI | TUI | Upstream matrix | Notes | | -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | MCP Apps | `io.modelcontextprotocol/ui` | ✅ | 🟡 | — | Inspector row, cell blank | Apps tab. Columns are rendering support: rendering needs a browser, so the CLI has only the `--app-info` metadata probe and the TUI nothing. The shared client still advertises the extension from CLI and TUI, which is a compatibility bug tracked in [#2403](https://github.com/modelcontextprotocol/inspector/issues/2403). | -| Tasks | `io.modelcontextprotocol/tasks` | ✅ | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). CLI and TUI advertise the extension and the shared core supports it, but neither exposes a user-facing task surface: the CLI's one-shot mode rejects `tasks/*`, and the TUI has no Tasks pane. | +| Tasks | `io.modelcontextprotocol/tasks` | 🟡 | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). Web is partial until #1917 ships: modern `tasks/*` requests omit the required `Mcp-Name` header, so strict servers reject them. CLI and TUI advertise the extension and the shared core supports it, but neither exposes a user-facing task surface: the CLI's one-shot mode rejects `tasks/*`, and the TUI has no Tasks pane. | | Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | | Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | ✅ | ✅ | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). | | OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | Inspector row, cell blank | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | **Actions:** implement OAuth Client Credentials; and, with maintainer sign-off, open a PR on -`modelcontextprotocol/modelcontextprotocol` to update the Inspector row. That matrix has one row per client, -so it cannot show per-client support: mark Apps as partial with a link explaining the split (Apps renders in +`modelcontextprotocol/modelcontextprotocol` to update the Inspector row. That matrix has one row per product, +so it cannot represent the Inspector's separate Web, CLI and TUI clients: mark Apps as partial with a link explaining the split (Apps renders in Web only; the CLI has a metadata probe), or propose separate Web/CLI/TUI rows. Skills and Enterprise Auth can be plain checks. ### Keeping up as extensions are approved @@ -342,8 +342,9 @@ We picked up Skills because someone noticed, not because anything told us. Make mechanism, the way SDK releases already are: - **An extension-watch sweep**, modelled on `scripts/sdk-watch.mjs`: on a schedule, list the - org's `ext-*` and `experimental-ext-*` repositories and the extension identifiers on - `/extensions/overview`, and file one issue per entry it has not filed before. As in the SDK watch, the **issue markers + org's `ext-*` and `experimental-ext-*` repositories, use `/extensions/overview` for official + membership and read each extension's identifier from its own specification or repository (the + overview lists names and links, not identifiers), and file one issue per entry it has not filed before. As in the SDK watch, the **issue markers are the source of truth** for idempotency: an entry whose marker is on an existing issue (open or closed) authored by the automation is skipped, so nothing needs committing back. It **files issues, never PRs**. Two details are left to the sweep's own design issue: which labels a trusted @@ -546,7 +547,7 @@ _The items whose shape we cannot yet commit to, plus the multiplier._ - 🅐 **DPoP**, **token exchange**, **Workload Identity Federation** (§3.3) — as each reaches Final or a Tier-1 SDK impl - 🅐 **Server-initiated events receiver** (§3.1) — design throughout, build only if the SEP lands -- 🅐 **ETags** (§3.2); **extension contract validation** (§3.5) +- 🅐 **ETags** (§3.2), only if a SEP reaches Draft with an SDK impl; **extension contract validation** (§3.5), only once the contract is published - 🅑 **Plugin architecture** (§5.14) — designed against surfaces that now exist - 🅑 Workspace and layout (§5.11); onboarding (§5.13) From 1a6df1450170aa85ed3b89e7054d2f78b79e6a04 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 12:10:15 -0400 Subject: [PATCH 39/68] docs: address Copilot round 10 on the roadmap realignment (#2401 review) Make #1917 a three-client Tasks limitation, mark CLI/TUI EMA partial, add modern skills/get to the cache surfaces (#2404), link SEP-1932/1933, keep spill-to-disk so capped history still reaches the session file, and qualify Track B's no-dependency claim. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 6e45fa62da..a03ca2eee3 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -193,7 +193,7 @@ where the Inspector is thinnest over the SDK. Watch closely. | Feature | Confidence | Notes | | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Cache hint display** — `ttlMs` / `cacheScope` on the SEP-2549 surfaces (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`), plus modern (2026-07-28+) `skills/list`, which SEP-2640 requires to carry both fields; legacy `skills/list` and `skills/get` carry none, with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. The runtime parses the hints everywhere and honors them through the SDK cache for the four `*/list` methods; `resources/read` and `skills/list` go through plain requests that validate but do not honor them, so this item includes that plumbing as well as the display. | +| **Cache hint display** — `ttlMs` / `cacheScope` on the SEP-2549 surfaces (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`), plus modern (2026-07-28+) `skills/list` and `skills/get`, which the stable ext-skills spec requires to carry both fields (our `skills/get` validation still treats them as optional: [#2404](https://github.com/modelcontextprotocol/inspector/issues/2404)); legacy results carry none, with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. The runtime parses the hints everywhere and honors them through the SDK cache for the four `*/list` methods; `resources/read`, `skills/list` and `skills/get` go through plain requests that validate but do not honor them, so this item includes that plumbing as well as the display. | | **Cache behavior observations** — note a re-fetch of a still-fresh result, and a list that changed inside its declared TTL, as diagnostics rather than errors | 🟢 | Inspector-shaped: nobody else observes both the hint and the reality. `ttlMs` is a freshness hint, so both are compliant. | | **Stateful-tool workflow investigation** — how to help a user carry an SEP-2567-style handle from one tool result into the next call | 🟡 | Replaces the first draft's "session lifecycle lane". The protocol has no concept of a handle (it is ordinary tool data), so a generic view would be inference; investigate before designing. | | **ETag support** — send `If-None-Match`, show 304s and version changes | 🔴 | Watch until a SEP reaches Draft with an SDK impl. | @@ -205,7 +205,7 @@ where the Inspector is thinnest over the SDK. Watch closely. **Upstream:** Agent Identity WG (forming this period), coordinated with the IETF OAuth and WIMSE WGs. MCP authorization assumes a person at a browser; increasingly the caller is an agent. This period: **finalize DPoP** and drive adoption; an opinionated **agent identity and -delegation** model built on **Workload Identity Federation** (SEP-1933), **ID-JAG** as used by +delegation** model built on **Workload Identity Federation** ([SEP-1933](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933)), **ID-JAG** as used by Enterprise-Managed Authorization, and **RFC 8693 token exchange**. **Beyond:** human-presence attestation. @@ -220,7 +220,7 @@ transcript are still worth building, but as our own Track B work (§5.7), not as | -------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- | | **OAuth Client Credentials extension** — client-secret and JWT-bearer assertion flows | 🟢 | An **approved** official extension (§4) we do not support. No upstream dependency. | | **Token exchange (RFC 8693) test flow** | 🟡 | Named in the roadmap; the RFC is stable, the MCP profile of it is not. | -| **DPoP** — generate a proof key, send `DPoP` proofs, show proof/nonce exchange in the Network view | 🟡 | Design against SEP-1932; build when it is Final or has a Tier-1 SDK impl. | +| **DPoP** — generate a proof key, send `DPoP` proofs, show proof/nonce exchange in the Network view | 🟡 | Design against [SEP-1932](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932); build when it is Final or has a Tier-1 SDK impl. | | **Workload Identity Federation** | 🟡 | SEP-1933. Needs a way to present a workload credential from a developer machine — design first. | | **Human-presence attestation** | 🔴 | "Beyond". | @@ -326,9 +326,9 @@ official status through the Extensions Track of | Extension | Identifier | Web | CLI | TUI | Upstream matrix | Notes | | -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | MCP Apps | `io.modelcontextprotocol/ui` | ✅ | 🟡 | — | Inspector row, cell blank | Apps tab. Columns are rendering support: rendering needs a browser, so the CLI has only the `--app-info` metadata probe and the TUI nothing. The shared client still advertises the extension from CLI and TUI, which is a compatibility bug tracked in [#2403](https://github.com/modelcontextprotocol/inspector/issues/2403). | -| Tasks | `io.modelcontextprotocol/tasks` | 🟡 | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). Web is partial until #1917 ships: modern `tasks/*` requests omit the required `Mcp-Name` header, so strict servers reject them. CLI and TUI advertise the extension and the shared core supports it, but neither exposes a user-facing task surface: the CLI's one-shot mode rejects `tasks/*`, and the TUI has no Tasks pane. | +| Tasks | `io.modelcontextprotocol/tasks` | 🟡 | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). All three clients are partial until #1917 ships: the shared `InspectorClient` issues modern `tasks/*` requests without the required `Mcp-Name` header, so strict servers reject them. Separately, CLI and TUI advertise the extension and the shared core supports it, but neither exposes a user-facing task surface: the CLI's one-shot mode rejects `tasks/*`, and the TUI has no Tasks pane. | | Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | -| Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | ✅ | ✅ | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). | +| Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | 🟡 | 🟡 | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). CLI and TUI work only from hand-edited `client.json` / `mcp.json`: there is no Client Settings surface, and terminal EMA follow-ups remain (`specification/v2_auth_ema.md`). | | OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | Inspector row, cell blank | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | **Actions:** implement OAuth Client Credentials; and, with maintainer sign-off, open a PR on @@ -361,7 +361,7 @@ mechanism, the way SDK releases already are: ## 5. Track B — experience work we choose -Nothing in this section waits on a SEP or another project. Ordered by leverage, not by effort. +No item here waits on a SEP or another project to **start**. Some later parts depend on each other or on Track A (for example, cross-server timeline correlation needs §5.11, and the assertion engine is shared with §3.6). Ordered by leverage, not by effort. ### 5.1 The zoomable timeline (headline) @@ -472,7 +472,7 @@ A 1000-tool server or a long-running session should not degrade. - **Grouped / tree lists with group-aware search**, built on client-side heuristics (name prefixes, annotations). No spec data source is expected this horizon (§3.7). -- **Virtualize** the long lists and logs; cap in-memory protocol history; truncate large +- **Virtualize** the long lists and logs; cap in-memory protocol history with spill-to-disk, so evicted entries still reach the §5.2 session file; truncate large payloads by default with explicit expansion. - Design lists so **"not loaded yet" is a state**, ready for progressive discovery (§3.4). From 8be1cd6f168de978a139520f33423ab1bcb99420 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 12:28:21 -0400 Subject: [PATCH 40/68] docs: address Copilot round 11 on the roadmap realignment (#2401 review) Split the Tasks row's two partial-support reasons (the Streamable-HTTP-only Mcp-Name gap fixed by #1917, and the CLI/TUI surface gap it does not fix), describe the CLI/TUI EMA limitation as a missing settings surface, name #1225's v1 closure in the sweep bootstrap, and cite SEP-414. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index a03ca2eee3..23fb7f5d82 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -326,9 +326,9 @@ official status through the Extensions Track of | Extension | Identifier | Web | CLI | TUI | Upstream matrix | Notes | | -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | MCP Apps | `io.modelcontextprotocol/ui` | ✅ | 🟡 | — | Inspector row, cell blank | Apps tab. Columns are rendering support: rendering needs a browser, so the CLI has only the `--app-info` metadata probe and the TUI nothing. The shared client still advertises the extension from CLI and TUI, which is a compatibility bug tracked in [#2403](https://github.com/modelcontextprotocol/inspector/issues/2403). | -| Tasks | `io.modelcontextprotocol/tasks` | 🟡 | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). All three clients are partial until #1917 ships: the shared `InspectorClient` issues modern `tasks/*` requests without the required `Mcp-Name` header, so strict servers reject them. Separately, CLI and TUI advertise the extension and the shared core supports it, but neither exposes a user-facing task surface: the CLI's one-shot mode rejects `tasks/*`, and the TUI has no Tasks pane. | +| Tasks | `io.modelcontextprotocol/tasks` | 🟡 | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). All three clients are currently partial, for two separate reasons. (1) Over Streamable HTTP, the shared `InspectorClient` sends modern `tasks/*` requests without the `Mcp-Name` header SEP-2663 requires, so strict servers reject them; stdio is unaffected. This is fixed by #1917. (2) CLI and TUI have no user-facing task surface: `mcp-inspector --cli` rejects `tasks/*` (they are not in `ONE_SHOT_METHODS`), and the TUI has no Tasks pane. #1917 does not change that. | | Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | -| Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | 🟡 | 🟡 | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). CLI and TUI work only from hand-edited `client.json` / `mcp.json`: there is no Client Settings surface, and terminal EMA follow-ups remain (`specification/v2_auth_ema.md`). | +| Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | 🟡 | 🟡 | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). CLI and TUI have no in-client Client Settings surface; they consume the `client.json` / `mcp.json` and keychain state the web settings flows write (or hand-edited files), and terminal EMA follow-ups remain (`specification/v2_auth_ema.md`). | | OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | Inspector row, cell blank | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | **Actions:** implement OAuth Client Credentials; and, with maintainer sign-off, open a PR on @@ -349,7 +349,7 @@ mechanism, the way SDK releases already are: closed) authored by the automation is skipped, so nothing needs committing back. It **files issues, never PRs**. Two details are left to the sweep's own design issue: which labels a trusted marker issue must also carry (as `sdk-watch` requires), and the first-run bootstrap for extensions - already tracked by hand-filed issues (Apps #1740, Tasks #1887, Skills #2234, EMA #1509), so that it does not file duplicates. + already tracked by hand-filed issues (Apps #1740, Tasks #1887, Skills #2234, EMA #1509), so that it does not file duplicates. OAuth Client Credentials is the exception: its only hand-filed issue, #1225, was closed on the frozen v1 line, so a new v2 issue is filed for it deliberately, cross-referencing #1225. - **Official extension** → a `v2` + `enhancement` issue to implement it, filed with the current milestone as `sdk-watch` does; only when no dated milestone is open is it left unmilestoned for triage to place in Incoming. - **Experimental extension** → a `v2` + `question` tracking issue, filed **unmilestoned and unboarded** so triage places it in Incoming (the documented exception for unapproved work); it @@ -612,7 +612,7 @@ For WG discussion. - [MCP Roadmap](https://modelcontextprotocol.io/development/roadmap) (last updated 2026-08-22) - [Extensions overview](https://modelcontextprotocol.io/extensions/overview) · [Extension support matrix](https://modelcontextprotocol.io/extensions/client-matrix) · [SEP-2133: Extensions](https://modelcontextprotocol.io/seps/2133-extensions) -- Final SEPs cited: [SEP-2549 (TTL for list results)](https://modelcontextprotocol.io/seps/2549-TTL-for-list-results) · [SEP-2567 (sessionless)](https://modelcontextprotocol.io/seps/2567-sessionless-mcp) · [SEP-2575 (stateless)](https://modelcontextprotocol.io/seps/2575-stateless-mcp) · [SEP-2663 (Tasks extension)](https://modelcontextprotocol.io/seps/2663-tasks-extension) · [SEP-2640 (Skills extension)](https://modelcontextprotocol.io/seps/2640-skills-extension) · [SEP-2484 (conformance tests)](https://modelcontextprotocol.io/seps/2484-conformance-tests-required-for-final-seps) +- Final SEPs cited: [SEP-2549 (TTL for list results)](https://modelcontextprotocol.io/seps/2549-TTL-for-list-results) · [SEP-2567 (sessionless)](https://modelcontextprotocol.io/seps/2567-sessionless-mcp) · [SEP-2575 (stateless)](https://modelcontextprotocol.io/seps/2575-stateless-mcp) · [SEP-2663 (Tasks extension)](https://modelcontextprotocol.io/seps/2663-tasks-extension) · [SEP-2640 (Skills extension)](https://modelcontextprotocol.io/seps/2640-skills-extension) · [SEP-2484 (conformance tests)](https://modelcontextprotocol.io/seps/2484-conformance-tests-required-for-final-seps) · [SEP-414 (request `_meta`, trace context)](https://modelcontextprotocol.io/seps/414-request-meta) - WG charters: [Inspector V2](https://modelcontextprotocol.io/community/working-groups/inspector-v2) · [Triggers & Events](https://modelcontextprotocol.io/community/working-groups/triggers-events) · [Agents](https://modelcontextprotocol.io/community/working-groups/agents) · [Transports](https://modelcontextprotocol.io/community/working-groups/transports) · [File Uploads](https://modelcontextprotocol.io/community/working-groups/file-uploads) · [SDK](https://modelcontextprotocol.io/community/working-groups/sdk) - [SDK tiers and conformance testing](https://modelcontextprotocol.io/community/sdk-tiers) - 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) From b4efb47193889aed67855a50eaa3100d7693fbf6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 12:45:58 -0400 Subject: [PATCH 41/68] docs: address Copilot round 12 on the roadmap realignment (#2401 review) Index the roadmap in the README, add the unblocked-work section to the ToC, propose a Tasks column in the upstream matrix, scope SEP-2484 to observable behavior, cite SEP-2624 for Interceptors, and tie the Mcp-Name fix to the SDK release #1917 tracks. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- README.md | 1 + docs/inspector-roadmap-2026-h2.md | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 29b74bd93b..4543a2f3db 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Each client has its own README with client-specific detail: | [Reviewing an MCP App](./docs/mcp-app-review.md) | The CLI-first → one-shot-web recipe for automated App-tool review | | [Smoke-testing an MCP server](./docs/cli-smoke-testing.md) | The connect → list → call → assert workflow for a shell or CI job: `--format json` + `jq`, the exit-code map, and keeping OAuth non-interactive | | [Launcher and config consolidation](./docs/launcher-config-consolidation-plan.md) | Why the launcher runs a client in-process rather than spawning it | +| [Roadmap, Aug 2026 → Feb 2027](./docs/inspector-roadmap-2026-h2.md) | The six-month plan: spec-following work aligned to the published MCP roadmap, official extension support, and the experience work we choose | ## Testing and the quality gate diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 23fb7f5d82..7f54725283 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -16,6 +16,7 @@ ## Table of Contents +- [Work we can start now, no external blockers](#work-we-can-start-now-no-external-blockers) - [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) @@ -116,7 +117,7 @@ general surfaces early so the spec work that lands later is cheap to display.** Worth stating plainly, because it shapes the priorities below. The roadmap's SDK area makes the **conformance test suite** the source of truth that SDKs and quickstarts are validated -against, and SEP-2484 (Final) requires conformance tests for Standards Track SEPs to reach +against, and SEP-2484 (Final) requires conformance tests for Standards Track SEPs that change observable protocol behavior to reach Final. The Inspector is the most visible MCP client in the ecosystem and is already the thing people reach for when a server misbehaves. @@ -302,7 +303,7 @@ work for them this horizon**. Each keeps a tracking issue and a liaison. | Effort | First-draft plan | Now | | ----------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | **Server Cards** (SEP-2127) | Card preview, card-vs-reality diff, `--card-lint` in Phase 3 | 🔴 Watch. [#1857](https://github.com/modelcontextprotocol/inspector/issues/1857)'s **registry** half does not depend on it (§5.9). | -| **Interceptors** (SEP-1763) | Test bench, audit mode, CLI invocation in Phase 4 | 🔴 Watch. The WG's unowned "CLI client for interceptor invocation" is still worth raising (§8). | +| **Interceptors** ([SEP-2624](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2624); originally SEP-1763) | Test bench, audit mode, CLI invocation in Phase 4 | 🔴 Watch. The WG's unowned "CLI client for interceptor invocation" is still worth raising (§8). | | **Primitive grouping** (IG) | Grouped sidebars | The **UX** half proceeds as Track B (§5.10) on client-side heuristics; no spec data source is expected this horizon. | | **Streamed and reference results** | Incremental rendering, reference handles | 🔴 Watch. Planned payload truncation (§5.10) will cover the large-result case; result views render full payloads today. | | **File picker from `FileInputDescriptor`** (SEP-2356) | `SchemaForm` + elicitation picker | 🔴 Watch. The File Uploads WG's published direction is now filesystem-like resources (§3.4). | @@ -326,7 +327,7 @@ official status through the Extensions Track of | Extension | Identifier | Web | CLI | TUI | Upstream matrix | Notes | | -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | MCP Apps | `io.modelcontextprotocol/ui` | ✅ | 🟡 | — | Inspector row, cell blank | Apps tab. Columns are rendering support: rendering needs a browser, so the CLI has only the `--app-info` metadata probe and the TUI nothing. The shared client still advertises the extension from CLI and TUI, which is a compatibility bug tracked in [#2403](https://github.com/modelcontextprotocol/inspector/issues/2403). | -| Tasks | `io.modelcontextprotocol/tasks` | 🟡 | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). All three clients are currently partial, for two separate reasons. (1) Over Streamable HTTP, the shared `InspectorClient` sends modern `tasks/*` requests without the `Mcp-Name` header SEP-2663 requires, so strict servers reject them; stdio is unaffected. This is fixed by #1917. (2) CLI and TUI have no user-facing task surface: `mcp-inspector --cli` rejects `tasks/*` (they are not in `ONE_SHOT_METHODS`), and the TUI has no Tasks pane. #1917 does not change that. | +| Tasks | `io.modelcontextprotocol/tasks` | 🟡 | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). All three clients are currently partial, for two separate reasons. (1) Over Streamable HTTP, the shared `InspectorClient` sends modern `tasks/*` requests without the `Mcp-Name` header SEP-2663 requires, so strict servers reject them; stdio is unaffected. It is fixed once the upstream SDK change tracked by #1917 is released. (2) CLI and TUI have no user-facing task surface: `mcp-inspector --cli` rejects `tasks/*` (they are not in `ONE_SHOT_METHODS`), and the TUI has no Tasks pane. #1917 does not change that. | | Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | | Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | 🟡 | 🟡 | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). CLI and TUI have no in-client Client Settings surface; they consume the `client.json` / `mcp.json` and keychain state the web settings flows write (or hand-edited files), and terminal EMA follow-ups remain (`specification/v2_auth_ema.md`). | | OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | Inspector row, cell blank | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | @@ -334,7 +335,7 @@ official status through the Extensions Track of **Actions:** implement OAuth Client Credentials; and, with maintainer sign-off, open a PR on `modelcontextprotocol/modelcontextprotocol` to update the Inspector row. That matrix has one row per product, so it cannot represent the Inspector's separate Web, CLI and TUI clients: mark Apps as partial with a link explaining the split (Apps renders in -Web only; the CLI has a metadata probe), or propose separate Web/CLI/TUI rows. Skills and Enterprise Auth can be plain checks. +Web only; the CLI has a metadata probe), or propose separate Web/CLI/TUI rows. Skills and Enterprise Auth can be plain checks. The same PR should propose a **Tasks** column: Tasks is an official extension the matrix cannot currently represent at all. ### Keeping up as extensions are approved From 4bc4643dcd59b534cd3e400cc0fef173443bf5b3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 13:06:42 -0400 Subject: [PATCH 42/68] docs: address Copilot round 13 on the roadmap realignment (#2401 review) Scope the spec-baseline exception to the base protocol, stop overstating the connection fixes, and make the extensions overview the authoritative official set for the extension-watch sweep. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 7f54725283..b1a1160038 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -56,7 +56,7 @@ Through v1, the Inspector was a **follow-along project**. The spec moved, we cha 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 (one known exception, #1917, waits on an SDK release), on SDK v2, +That constraint has lifted. v2 meets the 2026-07-28 spec across all three clients (one known base-protocol exception, #1917, waits on an SDK release; open extension gaps are tracked in §4), 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. @@ -342,9 +342,10 @@ Web only; the CLI has a metadata probe), or propose separate Web/CLI/TUI rows. S We picked up Skills because someone noticed, not because anything told us. Make it a mechanism, the way SDK releases already are: -- **An extension-watch sweep**, modelled on `scripts/sdk-watch.mjs`: on a schedule, list the - org's `ext-*` and `experimental-ext-*` repositories, use `/extensions/overview` for official - membership and read each extension's identifier from its own specification or repository (the +- **An extension-watch sweep**, modelled on `scripts/sdk-watch.mjs`: on a schedule, treat + `/extensions/overview` as the authoritative set of official extensions (Tasks, for one, has no + `ext-*` repository), enumerate the org's `experimental-ext-*` repositories to discover + experimental entries and `ext-*` repositories only to enrich official ones, and read each extension's identifier from its own specification or repository (the overview lists names and links, not identifiers), and file one issue per entry it has not filed before. As in the SDK watch, the **issue markers are the source of truth** for idempotency: an entry whose marker is on an existing issue (open or closed) authored by the automation is skipped, so nothing needs committing back. It **files @@ -453,7 +454,7 @@ but we hold the entire session and cannot export it in any pipeline-shaped form. ### 5.8 Connection Doctor -The individual connection bugs have been fixed (§1), but a failure is still reported as a +The connection fixes listed in §1 have shipped (and #1944 and #1911 were closed as not planned), but a failure is still reported as a single error. 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 From 85f4ecc165921e7d8c4742b11a56c248ecb96350 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 13:22:56 -0400 Subject: [PATCH 43/68] docs: address Copilot round 14 on the roadmap realignment (#2401 review) Label #1917 a Tasks-extension gap, date the revision 2026-09-17, show legacy cache hints when present, make the Apps columns strictly about rendering, and define Skills checks as inspection support (the Inspector is not a host). Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index b1a1160038..9a85d0b55f 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -6,7 +6,7 @@ **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 — **revised 2026-09-16** against the published MCP roadmap of 2026-08-22 (#2400) +**Status:** Draft for WG review — **revised 2026-09-17** against the published MCP roadmap of 2026-08-22 (#2400) ## Work we can start now, no external blockers @@ -56,7 +56,7 @@ Through v1, the Inspector was a **follow-along project**. The spec moved, we cha 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 (one known base-protocol exception, #1917, waits on an SDK release; open extension gaps are tracked in §4), on SDK v2, +That constraint has lifted. v2 meets the 2026-07-28 spec across all three clients (the open gaps are in official extensions, not the base protocol: the Tasks-extension `Mcp-Name` header, #1917, waits on an SDK release, and the rest are tracked in §4), 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. @@ -194,7 +194,7 @@ where the Inspector is thinnest over the SDK. Watch closely. | Feature | Confidence | Notes | | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Cache hint display** — `ttlMs` / `cacheScope` on the SEP-2549 surfaces (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`), plus modern (2026-07-28+) `skills/list` and `skills/get`, which the stable ext-skills spec requires to carry both fields (our `skills/get` validation still treats them as optional: [#2404](https://github.com/modelcontextprotocol/inspector/issues/2404)); legacy results carry none, with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. The runtime parses the hints everywhere and honors them through the SDK cache for the four `*/list` methods; `resources/read`, `skills/list` and `skills/get` go through plain requests that validate but do not honor them, so this item includes that plumbing as well as the display. | +| **Cache hint display** — `ttlMs` / `cacheScope` on the SEP-2549 surfaces (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`), plus modern (2026-07-28+) `skills/list` and `skills/get`, which the stable ext-skills spec requires to carry both fields (our `skills/get` validation still treats them as optional: [#2404](https://github.com/modelcontextprotocol/inspector/issues/2404)); legacy results do not require the fields but are shown when a server sends them, with freshness countdown and "stale" marking | 🟢 | SEP-2549 is Final. The runtime parses the hints everywhere and honors them through the SDK cache for the four `*/list` methods; `resources/read`, `skills/list` and `skills/get` go through plain requests that validate but do not honor them, so this item includes that plumbing as well as the display. | | **Cache behavior observations** — note a re-fetch of a still-fresh result, and a list that changed inside its declared TTL, as diagnostics rather than errors | 🟢 | Inspector-shaped: nobody else observes both the hint and the reality. `ttlMs` is a freshness hint, so both are compliant. | | **Stateful-tool workflow investigation** — how to help a user carry an SEP-2567-style handle from one tool result into the next call | 🟡 | Replaces the first draft's "session lifecycle lane". The protocol has no concept of a handle (it is ordinary tool data), so a generic view would be inference; investigate before designing. | | **ETag support** — send `If-None-Match`, show 304s and version changes | 🔴 | Watch until a SEP reaches Draft with an SDK impl. | @@ -326,16 +326,16 @@ official status through the Extensions Track of | Extension | Identifier | Web | CLI | TUI | Upstream matrix | Notes | | -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| MCP Apps | `io.modelcontextprotocol/ui` | ✅ | 🟡 | — | Inspector row, cell blank | Apps tab. Columns are rendering support: rendering needs a browser, so the CLI has only the `--app-info` metadata probe and the TUI nothing. The shared client still advertises the extension from CLI and TUI, which is a compatibility bug tracked in [#2403](https://github.com/modelcontextprotocol/inspector/issues/2403). | +| MCP Apps | `io.modelcontextprotocol/ui` | ✅ | — | — | Inspector row, cell blank | Apps tab. Columns are rendering support: rendering needs a browser, so neither CLI nor TUI renders Apps (the CLI does offer an `--app-info` metadata probe). The shared client still advertises the extension from CLI and TUI, which is a compatibility bug tracked in [#2403](https://github.com/modelcontextprotocol/inspector/issues/2403). | | Tasks | `io.modelcontextprotocol/tasks` | 🟡 | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). All three clients are currently partial, for two separate reasons. (1) Over Streamable HTTP, the shared `InspectorClient` sends modern `tasks/*` requests without the `Mcp-Name` header SEP-2663 requires, so strict servers reject them; stdio is unaffected. It is fixed once the upstream SDK change tracked by #1917 is released. (2) CLI and TUI have no user-facing task surface: `mcp-inspector --cli` rejects `tasks/*` (they are not in `ONE_SHOT_METHODS`), and the TUI has no Tasks pane. #1917 does not change that. | -| Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). | +| Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). Checks mean full **inspection** support (list, get, digest verification). Host behaviors (activation, per-skill consent, content-bound approval) are out of scope by design, since the Inspector is not a host (`core/mcp/skills.ts`); that is also why the upstream matrix says "Partial". | | Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | 🟡 | 🟡 | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). CLI and TUI have no in-client Client Settings surface; they consume the `client.json` / `mcp.json` and keychain state the web settings flows write (or hand-edited files), and terminal EMA follow-ups remain (`specification/v2_auth_ema.md`). | | OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | Inspector row, cell blank | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | **Actions:** implement OAuth Client Credentials; and, with maintainer sign-off, open a PR on `modelcontextprotocol/modelcontextprotocol` to update the Inspector row. That matrix has one row per product, so it cannot represent the Inspector's separate Web, CLI and TUI clients: mark Apps as partial with a link explaining the split (Apps renders in -Web only; the CLI has a metadata probe), or propose separate Web/CLI/TUI rows. Skills and Enterprise Auth can be plain checks. The same PR should propose a **Tasks** column: Tasks is an official extension the matrix cannot currently represent at all. +Web only; the CLI has a metadata probe), or propose separate Web/CLI/TUI rows. Enterprise Auth can be a plain check; Skills stays "Partial" upstream, because the Inspector is not a host. The same PR should propose a **Tasks** column: Tasks is an official extension the matrix cannot currently represent at all. ### Keeping up as extensions are approved From d7c1f9fb4d5a958e1cb1f7a6a380841db98657c5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 17 Sep 2026 13:41:38 -0400 Subject: [PATCH 44/68] docs: address Copilot round 15 on the roadmap realignment (#2401 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope §3.6's SEP-2484 restatement to observable protocol behavior, and name the open modern skills/get validation gap (#2404) in the Skills row. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md index 9a85d0b55f..62a0a3a49a 100644 --- a/docs/inspector-roadmap-2026-h2.md +++ b/docs/inspector-roadmap-2026-h2.md @@ -279,7 +279,7 @@ conformance suite central, which strengthens §3.6. **Upstream:** Standing investment rather than a priority area — the conformance suite, SDK tiers ([SEP-1730](https://modelcontextprotocol.io/seps/1730-sdks-tiering-system)), and [SEP-2484](https://modelcontextprotocol.io/seps/2484-conformance-tests-required-for-final-seps) -(Final), which requires conformance tests for Standards Track SEPs to reach Final. §3.5 makes +(Final), which requires conformance tests for Standards Track SEPs that change observable protocol behavior to reach Final. §3.5 makes the suite the validation target for generated SDKs. **Read:** A conformance suite needs a driver and a report. We are the natural driver, and we @@ -328,7 +328,7 @@ official status through the Extensions Track of | -------------------------------- | ---------------------------------------------------------- | --- | --- | --- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | MCP Apps | `io.modelcontextprotocol/ui` | ✅ | — | — | Inspector row, cell blank | Apps tab. Columns are rendering support: rendering needs a browser, so neither CLI nor TUI renders Apps (the CLI does offer an `--app-info` metadata probe). The shared client still advertises the extension from CLI and TUI, which is a compatibility bug tracked in [#2403](https://github.com/modelcontextprotocol/inspector/issues/2403). | | Tasks | `io.modelcontextprotocol/tasks` | 🟡 | 🟡 | 🟡 | No column in the matrix | Raw-wire channel; stays for the horizon (§3.1). All three clients are currently partial, for two separate reasons. (1) Over Streamable HTTP, the shared `InspectorClient` sends modern `tasks/*` requests without the `Mcp-Name` header SEP-2663 requires, so strict servers reject them; stdio is unaffected. It is fixed once the upstream SDK change tracked by #1917 is released. (2) CLI and TUI have no user-facing task surface: `mcp-inspector --cli` rejects `tasks/*` (they are not in `ONE_SHOT_METHODS`), and the TUI has no Tasks pane. #1917 does not change that. | -| Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). Checks mean full **inspection** support (list, get, digest verification). Host behaviors (activation, per-skill consent, content-bound approval) are out of scope by design, since the Inspector is not a host (`core/mcp/skills.ts`); that is also why the upstream matrix says "Partial". | +| Skills over MCP | `io.modelcontextprotocol/skills` | ✅ | ✅ | ✅ | "Partial" (CLI README) | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248). Checks mean the inspection surface is complete (list, get, digest and frontmatter verification), with one open validation gap: modern `skills/get` results missing the now-required cache fields are still accepted ([#2404](https://github.com/modelcontextprotocol/inspector/issues/2404)). Host behaviors (activation, per-skill consent, content-bound approval) are out of scope by design, since the Inspector is not a host (`core/mcp/skills.ts`); that is also why the upstream matrix says "Partial". | | Enterprise-Managed Authorization | `io.modelcontextprotocol/enterprise-managed-authorization` | ✅ | 🟡 | 🟡 | Inspector row, cell blank | [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). CLI and TUI have no in-client Client Settings surface; they consume the `client.json` / `mcp.json` and keychain state the web settings flows write (or hand-edited files), and terminal EMA follow-ups remain (`specification/v2_auth_ema.md`). | | OAuth Client Credentials | `io.modelcontextprotocol/oauth-client-credentials` | ❌ | ❌ | ❌ | Inspector row, cell blank | **Gap** (§3.3). [#1225](https://github.com/modelcontextprotocol/inspector/issues/1225) was closed only because v1 is frozen. | From 8dee4cf08ec3f90e7b66b2b96788d90798db2b7c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 18 Sep 2026 20:13:09 -0400 Subject: [PATCH 45/68] docs: add the security-advisory skill and carve advisory drafts out of the board audit (#2443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A privately reported vulnerability arrives through GitHub's advisory flow, never as an issue, and none of that procedure was written down. Sixteen advisories sit in `triage` today with hand-made draft cards on board #28. - New `security-advisory` skill: the draft card and its `[GHSA-` title prefix; verifying WHO OWNS THE CODE PATH before assessing severity (#2409 was a loopback/HTTPS finding that lived in `@modelcontextprotocol/client`, not here, and was withdrawn); accepting vs closing; the private fork; publishing; the public issue afterwards. Accepting and publishing are marked human-gated — both are outward-facing and publishing is irreversible. - Records the API facts that are easy to get wrong: the private-fork POST CREATES a fork rather than probing for one (read `.private_fork` first), there is no comment API for advisories in REST or GraphQL so reporter coordination is UI-only, deleting a private fork needs `delete_repo`, and the board step cannot be automated — no `PROJECT_TOKEN` exists in this org and `GITHUB_TOKEN` cannot hold `organization projects: write`. - AGENTS.md: carve advisory drafts out of "no draft cards" as the single exception, and add the skill to the index. Refresh the listing-budget figure. - issue-triage: the board audit's `non-Issue on a board` check counted all 16 drafts and would have stayed permanently non-zero. It now excludes drafts by TITLE PREFIX only, so a stray draft is still reported — and by title, since a draft has no issue number to print. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- .claude/skills/issue-triage/SKILL.md | 20 ++- .claude/skills/security-advisory/SKILL.md | 161 ++++++++++++++++++ .../skills/security-advisory/evals/evals.json | 34 ++++ AGENTS.md | 8 +- 4 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/security-advisory/SKILL.md create mode 100644 .claude/skills/security-advisory/evals/evals.json diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index 0511119e14..bdff1d00dc 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -200,7 +200,7 @@ count means the board contradicts a rule, not that the rule needs revisiting. | Check | Invariant | Fix | | --- | --- | --- | | Double-boarded | An issue has a card on **one** board, the one matching its version label | Delete the wrong-board card | -| Non-Issue items | **Only issues go on a board** — never PRs, never drafts | Delete the item | +| Non-Issue items | **Only issues go on a board** — never PRs, never drafts, *except* an advisory draft titled `[GHSA-…]` | Delete the item | | No Status | Every card carries a Status | Set one — `Incoming` if unmilestoned, else by where it actually is | | `Incoming` **with** a milestone (#28) | Incoming ⇔ no milestone | Approval was never recorded: move to **Todo**, or clear the milestone | | Past Incoming **without** a milestone (#28) | Everything past Incoming ⇔ milestoned | Claims an approval nobody made: milestone it, or move back to Incoming | @@ -235,7 +235,12 @@ jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b | [own($b)[] | select(.content.type=="Issue") | {n:.content.number, s:.status}] as $B11 | { "double-boarded": [$B28[].n | select(. as $n | [$B11[].n]|index($n))], - "non-Issue on a board": [(own($a)[], own($b)[]) | select(.content.type!="Issue") | .content.number], + # An advisory draft card is the ONE legitimate non-Issue item (see AGENTS.md). + # It is identified by its `[GHSA-` title prefix and nothing else, so a stray + # draft is still reported. Reports the TITLE, since a draft has no number. + "non-Issue on a board": [(own($a)[], own($b)[]) | select(.content.type!="Issue") + | (.content.title // "(untitled)") + | select(startswith("[GHSA-") | not)], "no Status": [($B28[], $B11[]) | select(.s==null) | .n], "Incoming w/ milestone": [$B28[] | select(.s=="Incoming" and ms(.n)!=null) | .n], "past Incoming, no ms": [$B28[] | select(.s!=null and .s!="Incoming" and .s!="Done" @@ -286,6 +291,17 @@ Two things the queries must account for, both learned the hard way: and that check then reports `0` while the invariant it states (no drafts) is being violated (Copilot). The filter admits an item with no repository and excludes only cards that name a *different* one. +- **Advisory drafts are carved out of that check by TITLE, not by type.** A + GitHub security advisory is private until it is published, so it is tracked by + a draft card titled `[GHSA-xxxx-yyyy-zzzz] - …` — the one exception `AGENTS.md` + grants to "no draft cards", and the `security-advisory` skill is the flow. There + are enough of them open at any time that counting them would pin this check + permanently non-zero, and a check that never prints `0` stops being read at + all. The discriminator is deliberately the **title prefix** and nothing + broader: exempting *all* drafts, or every card whose Status is `Incoming`, + would let an ordinary stray draft through, which is the defect the check + exists for. So a draft titled anything else is still reported — by title, + since a draft has no issue number to print. - **`$M` holds closed issues too** — the lookup is built from `gh issue list --state all`, which it has to be, because the last check reads closed issues' state reasons. So `isopen` is not there to cope with a missing diff --git a/.claude/skills/security-advisory/SKILL.md b/.claude/skills/security-advisory/SKILL.md new file mode 100644 index 0000000000..70df8fba58 --- /dev/null +++ b/.claude/skills/security-advisory/SKILL.md @@ -0,0 +1,161 @@ +--- +name: security-advisory +description: "Take a privately reported vulnerability through this repo's security advisory flow — board it, verify who owns the code path, accept or reject, fix it in the private fork, publish, then file the public issue. Use when a vulnerability is reported privately; when deciding whether an advisory is ours to fix; when looking up or creating its private fork; when answering a reporter; or when a GHSA-titled board card needs handling." +disable-model-invocation: false +--- + +# Handling a security advisory + +Private vulnerability reporting is enabled on this repo and +[`SECURITY.md`](../../../SECURITY.md) routes every report to it — the issue +chooser deliberately has no security template, because a vulnerability report +must not open a public issue. So an advisory never arrives as an issue, and for +most of its life it must **not** become one. + +Two steps in this flow are **outward-facing and irreversible-ish, and both stay +human-gated**: **accepting** an advisory (the reporter sees it) and +**publishing** it (it becomes public, assigns a CVE, and credits the reporter — +there is no unpublish). Never automate either, never bulk-apply them, and never +take either step because a checklist said to. Everything else here is mechanics. + +Related: `/board-ops` (the card IDs and recipes), `/issue-create` and +`/pr-flow` for the public issue and the eventual release. + +## The flow + +| # | Step | Gate | +| --- | --- | --- | +| 1 | Advisory lands in state `triage` → **draft card** on board #28 | Mechanical | +| 2 | **Verify the claim — including who owns the code path** | Judgment | +| 3 | Valid → **accept** (`triage` → `draft`); invalid → close with a reason | **Human only** | +| 4 | Create the **private fork**, fix and review there | Mechanical | +| 5 | Merge, release, then **publish** the advisory | **Human only** | +| 6 | After the release, file the public (closed) issue and convert the card | Mechanical | + +### 1. Board it as a draft card + +An advisory is private, so a public issue tracking it would disclose it before a +fix exists. It therefore gets a **draft card** — the one documented exception to +[`AGENTS.md`](../../../AGENTS.md#issue-driven-work-style)'s "every board item is +a real GitHub issue". + +- **Title:** `[GHSA-xxxx-yyyy-zzzz] - `. That `[GHSA-` prefix + is not cosmetic: the board audit in `/issue-triage` keys its draft carve-out + on it, so a card titled any other way is reported as a stray draft. +- **Body:** `**Advisory:** ` on the first line, then severity and + reported date, then the advisory description. The link first, because a + maintainer reading the card has no other route back to the private advisory. +- **Status `Incoming`**, plus a Priority scored with the `/issue-triage` rubric. + `Incoming` is correct even though somebody clearly triaged it to make the + card: nobody has approved shipping a fix yet, and a draft card has no + milestone to carry the approval. + +The card is made **by hand**. There is no `PROJECT_TOKEN` in this org and +`organization projects: write` is a permission `GITHUB_TOKEN` structurally +cannot hold, so a board write is unreachable from Actions — the same constraint +`AGENTS.md` records for the dependency sweeps. **Do not propose a nightly +workflow for this;** that approach was tried and abandoned for exactly this +reason. + +```sh +gh api repos/modelcontextprotocol/inspector/security-advisories \ + --jq '.[] | select(.state=="triage") + | "\(.ghsa_id)\t\(.severity)\t\(.summary)"' +``` + +### 2. Verify the claim — and who owns the code path + +Before assessing severity, establish that the vulnerable code is **ours**. A +report can be entirely accurate about behavior the Inspector merely exhibits +because an SDK does it. + +⚠️ **This is not hypothetical.** #2409 — a loopback/HTTPS-exemption finding — +read as an Inspector defect and turned out to live in +`@modelcontextprotocol/client` (`typescript-sdk#2591`). The reporter withdrew +it. Had ownership been checked after the severity assessment rather than before, +the fix would have been written against the wrong repo. + +So: reproduce it, find the code, and check whether that code is first-party or +reached through a dependency. An advisory against upstream code is closed here +with a pointer to the upstream issue — it is not ours to accept or publish. + +### 3. Accept, or close + +**Valid and ours → accept.** In the UI this is "Accept and open as draft"; it +moves the advisory `triage` → `draft`. The state is readable as `state` and +`submission.accepted` on the API object. + +**Invalid, out of scope, or upstream → close** with a comment saying which, and +why. A reporter who is told nothing reasonably assumes they were ignored. + +⚠️ **Accepting is a human act, always.** It is visible to the reporter and it +commits this project to treating the report as a real vulnerability. Nothing in +this skill authorizes taking it — surface the recommendation and let a +maintainer click. + +⚠️ **There is no comment API for security advisories.** Not in REST (the +advisory object exposes no comments endpoint) and not in GraphQL +(`RepositoryAdvisory` is not commentable, and no advisory-comment mutation +exists). Comments are **UI-only**, so every exchange with a reporter is manual — +you cannot script the reply, and you cannot read the thread back with `gh`. + +### 4. The private fork + +Accepted advisories are fixed in a **private fork** GitHub creates for the +advisory: a private repo named `-` in the org. + +⚠️ **Read `private_fork` FIRST. The POST is not a probe — it CREATES one.** +Calling it to "check whether a fork exists" makes one, in the org, which then +needs cleaning up. This was learned the hard way. + +```sh +# Idempotency check — does one already exist? +gh api repos/modelcontextprotocol/inspector/security-advisories/ \ + --jq '.private_fork // "none"' + +# Only if that printed "none": +gh api -X POST \ + repos/modelcontextprotocol/inspector/security-advisories//forks +# → 202 Accepted; the fork appears shortly afterwards. +``` + +⚠️ **Deleting a private fork needs the `delete_repo` OAuth scope, which a +default `gh` token does not carry.** So a fork created by mistake is not +something you can quietly undo — it takes a re-scoped token or an admin in the +UI. That asymmetry is the whole reason for the read-first rule above. + +Fix and review inside the fork. Its PRs and commits are private, so none of the +normal public review flow applies; the diff comes back to `v2/main` as an +ordinary commit at merge time. + +### 5. Merge, release, publish + +Publish **after** the fix has shipped in a release, never before — publishing +discloses the vulnerability, so doing it while users have no upgrade available +hands out a working exploit. + +⚠️ **Publishing is irreversible and human-gated.** It makes the advisory public, +requests a **CVE**, and credits the reporter. There is no undo. Same rule as +accepting: recommend, never perform. + +### 6. File the public issue afterwards + +Once the advisory is published, the work becomes ordinary board history: file a +public issue recording what shipped, **close it** (the work is already done), +and convert the draft card to that issue so the board stops carrying a draft. +Label and milestone it per `/issue-create`; `Done` is correct here, because the +fix genuinely shipped. + +## API facts worth not re-deriving + +All verified against the live API. + +| Thing | Fact | +| --- | --- | +| States | `triage` → `draft` (accepted) → `published`; or `closed` | +| Accepted? | `submission.accepted` on the advisory object, alongside `state` | +| Private fork | `POST …/security-advisories/{ghsa_id}/forks` → `202`, private repo `-` in the org | +| Fork idempotency | Read `.private_fork` first — the POST creates, it does not probe | +| Fork deletion | Needs the `delete_repo` OAuth scope; a default `gh` token lacks it | +| Comments | **No API at all**, REST or GraphQL. UI-only | +| Board writes | Not automatable — no `PROJECT_TOKEN`, and `GITHUB_TOKEN` cannot hold `organization projects: write` | diff --git a/.claude/skills/security-advisory/evals/evals.json b/.claude/skills/security-advisory/evals/evals.json new file mode 100644 index 0000000000..06743379e9 --- /dev/null +++ b/.claude/skills/security-advisory/evals/evals.json @@ -0,0 +1,34 @@ +[ + { + "prompt": "Someone just reported a vulnerability against this repo privately. What do I do with it?", + "expect": "security-advisory" + }, + { + "prompt": "How do I fix a privately reported vulnerability without the patch being visible before the release goes out?", + "expect": "security-advisory" + }, + { + "prompt": "How do I reply to a reporter who filed a vulnerability privately?", + "expect": "security-advisory" + }, + { + "prompt": "A privately reported vulnerability turns out to be in an upstream package rather than our own code. How do I close it out?", + "expect": "security-advisory" + }, + { + "prompt": "When is it safe to make a privately reported vulnerability public, and who makes that call here?", + "expect": "security-advisory" + }, + { + "prompt": "Walk me through taking an accepted vulnerability report all the way to a shipped, disclosed fix.", + "expect": "security-advisory" + }, + { + "prompt": "What is the capital of Portugal?", + "expect": null + }, + { + "prompt": "Rename the local variable `tmp` to `buffer` in this snippet: `const tmp = 1; return tmp + 1;`", + "expect": null + } +] diff --git a/AGENTS.md b/AGENTS.md index 3a4bf713d0..5fc98cae06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ users invoke them by name. | [`pr-flow`](.claude/skills/pr-flow/SKILL.md) | Branch naming, DCO signoff, screenshots, opening the PR, requesting a Copilot review, responding, closing out | Model-invoked, or `/pr-flow` | | [`pre-push-gate`](.claude/skills/pre-push-gate/SKILL.md) | Running `npm run local:gate` and diagnosing a failing stage | Model-invoked, or `/pre-push-gate` | | [`release`](.claude/skills/release/SKILL.md) | Cutting a release: bump on `v2/main`, milestone merge, tag `origin/main`, publish | `/release` | +| [`security-advisory`](.claude/skills/security-advisory/SKILL.md) | A privately reported vulnerability end to end: the draft card, verifying who owns the code path, accepting, the private fork, publishing, the public issue afterwards | Model-invoked, or `/security-advisory` | | [`test-servers`](.claude/skills/test-servers/SKILL.md) | Picking and running a showcase test server; the stale-build hazard | Model-invoked, or `/test-servers` | Longer-form human documentation lives in [`docs/`](./docs) — see the table in the @@ -255,7 +256,7 @@ node/field/option IDs, and the option-deletion hazard` was cut at `#28`, so 90 skill at all: it is absent from the listing and the Skill tool refuses it. The costs are asymmetric — a spurious load costs ~250 characters, a missed one costs a wrong base branch or an unsigned commit — and the budget is not tight - (nine of the ten are model-invoked today and total ~3.2k of 4k). Reserve + (ten of the eleven are model-invoked today and total ~3.7k of 4k). Reserve `true` for a procedure that is genuinely only ever started deliberately — `release` is the only one left, because nobody cuts a release by implication. ⚠️ **A `true` skill cannot be reached by another skill either.** If a @@ -267,7 +268,7 @@ node/field/option IDs, and the option-deletion hazard` was cut at `#28`, so 90 cases (n=4) and `testing` from 3/5 to 2/5, while the six new skills all measured 100% and every negative case stayed clean. So the ceiling is attention, not characters — we were at 2.8k of a 4k budget throughout _that - experiment_ (it is ~3.2k now; the point is that nothing was near the cap). Adding + experiment_ (it is ~3.7k now; the point is that nothing was near the cap). Adding a skill therefore has a cost paid by the _existing_ ones, which only `skills:eval` can see. **Re-run the full eval after any flip _or description edit_**, not just the changed skill's own cases. @@ -341,7 +342,7 @@ node/field/option IDs, and the option-deletion hazard` was cut at `#28`, so 90 overflows, and drops the least-invoked entries **first** — which are exactly the model-invoked skills that must fire on their own. `verify:skills` prints the current cost against the budget recorded in `scripts/lib/skill-manifest.mjs` - (3,234/4,000 characters as of this writing) and fails when it is exceeded. Raise + (3,679/4,000 characters as of this writing) and fails when it is exceeded. Raise the budget deliberately, or tighten a description; each entry is capped at 1,536 characters regardless, so **put the key use case first**. @@ -364,6 +365,7 @@ skills; the rules are here. - **Before starting work, check the board for the relevant item.** - **Every board item is a real GitHub issue.** No draft cards. Before creating a new issue, check the board for a matching item — **never create a duplicate**. + - **The one exception is a GitHub security advisory**, which is tracked by a **draft card** titled `[GHSA-xxxx-yyyy-zzzz] - `. An advisory is private until it is published, so a real issue would disclose the vulnerability before a fix exists — the thing the whole advisory flow is for. The card is made by hand (no `PROJECT_TOKEN` exists in this org, and `GITHUB_TOKEN` cannot hold `organization projects: write`), and it is converted to a real issue once the advisory is published. The `[GHSA-` prefix is load-bearing: it is what the board audit's draft carve-out keys on, so **any other draft card is still a defect to delete**. The flow itself — verifying who owns the code path, accepting, the private fork, publishing — is the `security-advisory` skill. **Accepting and publishing an advisory are outward-facing and stay human-gated; never automate or bulk-apply either.** - **Only issues go on a board — never PRs.** A PR gets the `v2` label but is tracked through its linked issue's card (via `Closes #N`), not its own board item. - **Label by version — every issue and every PR, no exceptions.** Exactly one of `v1` (work targeting `v1/main`, the deprecated security-fix-only line) or `v2` (active development; the default for anything new). There is no unlabeled state and no "decide later": an issue with neither label belongs to no version line and is invisible to every version-filtered query. Set it at **create time** (`gh issue create --label v2 …`), never by backfilling. **If the target version isn't obvious, it's `v2`.** - **Label by type — exactly one of `bug` / `enhancement` / `documentation` / `chore` / `question`** on every issue you create or triage. The version label says which line the work belongs to; the type label says what kind of work it is, and the two are independent. Don't force the binary: pressing a docs task or a dependency pin into `enhancement` degrades it to "not a bug", at which point filtering by it stops telling you anything. A **PR** needs no type label — it is classified through the issue it closes. From 41efd17cafc0a03355ff62170216a7b708cc93e9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 18 Sep 2026 21:24:24 -0400 Subject: [PATCH 46/68] docs: address Copilot review round 1 on #2444 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven of nine findings were defects in what this PR added. - Upstream advisories: "close with a pointer to the upstream issue" would have disclosed an unfixed vulnerability on someone else's behalf. Route it through the upstream's own private channel; reference a public issue only after they publish. - Step 6 was impossible as written: GitHub's "Convert to issue" creates a NEW issue from the draft and cannot bind a card to one filed separately. Convert first, then label/milestone/close/move, and skip /issue-create's add-card step. - Closing a rejected or upstream advisory now says to DELETE its draft card — nothing shipped, and the audit's non-Issue check no longer looks at it. - Drop the /pr-flow pointer from Related and say why: it requires a public issue and a public PR, which is the disclosure this flow delays. - `--paginate` on the triage-advisory listing; the endpoint pages at 30 and a truncated inventory reads as "nothing pending". - Record the Priority arithmetic in the draft body — a draft card has no comments, so /issue-triage's "record the score in a comment" cannot be met. - The exemption needed a replacement check. $B28/$B11 are Issue-only, so the non-Issue check was the ONLY one seeing a draft; exempting it alone made a half-made advisory card invisible to the whole audit. Added `GHSA draft missing Status/Priority`, verified against a synthetic set. - board-ops and issue-create both still said draft cards are never allowed, contradicting the new AGENTS.md carve-out. Both now name the exception. Declined: chain eval cases for the /board-ops, /issue-triage and /issue-create pointers. docs/skill-authoring.md puts a chain case in the TARGET skill's file and requires it be measured, and skills:eval is deliberately outside the gate. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 6 ++ .claude/skills/issue-create/SKILL.md | 5 +- .claude/skills/issue-triage/SKILL.md | 17 ++++++ .claude/skills/security-advisory/SKILL.md | 68 +++++++++++++++++++---- 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index 0a8f582510..c5a103578f 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -29,6 +29,12 @@ belong to the field", so the mistake is at least loud. **Only issues go on a board — never PRs, never draft cards.** A PR is tracked through the card of the issue it closes. +**The one exception is a GitHub security advisory**, tracked by a draft card +titled `[GHSA-xxxx-yyyy-zzzz] - …` because a real issue would disclose it before +a fix exists. The flow is `/security-advisory`; every recipe below applies to +that card unchanged, except that an advisory draft is found by title rather than +by issue number. + ## V2 board (#28) IDs The project node id and the field ids are stable. The **option** ids are **not** — diff --git a/.claude/skills/issue-create/SKILL.md b/.claude/skills/issue-create/SKILL.md index 5fe54eca06..9e50fde1a3 100644 --- a/.claude/skills/issue-create/SKILL.md +++ b/.claude/skills/issue-create/SKILL.md @@ -29,7 +29,10 @@ query, and an unmilestoned one drops out of release planning silently. **Never create a duplicate.** Check the board for a matching item first. **Never create a draft card** (a board card with no issue number) — every board -item is a real GitHub issue. +item is a real GitHub issue. The single exception is a **GitHub security +advisory**, which is private until it is published and so cannot be tracked by +an issue at all; see `/security-advisory`. Nothing you reach through *this* +flow is that case. ## 0. Check the board first diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index bdff1d00dc..dbd3b81cf1 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -202,6 +202,7 @@ count means the board contradicts a rule, not that the rule needs revisiting. | Double-boarded | An issue has a card on **one** board, the one matching its version label | Delete the wrong-board card | | Non-Issue items | **Only issues go on a board** — never PRs, never drafts, *except* an advisory draft titled `[GHSA-…]` | Delete the item | | No Status | Every card carries a Status | Set one — `Incoming` if unmilestoned, else by where it actually is | +| GHSA draft missing Status/Priority | An exempted advisory draft still carries both | Set them — `/security-advisory` | | `Incoming` **with** a milestone (#28) | Incoming ⇔ no milestone | Approval was never recorded: move to **Todo**, or clear the milestone | | Past Incoming **without** a milestone (#28) | Everything past Incoming ⇔ milestoned | Claims an approval nobody made: milestone it, or move back to Incoming | | Wrong board for label | `v1` → #11, `v2` → #28 | Move the card to the right board | @@ -241,6 +242,15 @@ jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b "non-Issue on a board": [(own($a)[], own($b)[]) | select(.content.type!="Issue") | (.content.title // "(untitled)") | select(startswith("[GHSA-") | not)], + # $B28/$B11 hold only Issue items, so the Status and Priority checks below + # cannot see an advisory draft. Exempting drafts from the check above would + # therefore have made a half-made advisory card invisible to the whole + # audit; this is the narrow replacement. + "GHSA draft missing Status/Priority": + [own($a)[] | select(.content.type=="DraftIssue" + and ((.content.title // "") | startswith("[GHSA-"))) + | select(.status==null or .priority==null) + | (.content.title[0:24])], "no Status": [($B28[], $B11[]) | select(.s==null) | .n], "Incoming w/ milestone": [$B28[] | select(.s=="Incoming" and ms(.n)!=null) | .n], "past Incoming, no ms": [$B28[] | select(.s!=null and .s!="Incoming" and .s!="Done" @@ -302,6 +312,13 @@ Two things the queries must account for, both learned the hard way: would let an ordinary stray draft through, which is the defect the check exists for. So a draft titled anything else is still reported — by title, since a draft has no issue number to print. + ⚠️ **The exemption had to come with a replacement check.** `$B28` and `$B11` + are built from `Issue` items only, so the `no Status` and `no Priority` + checks never see a draft — before the carve-out the non-Issue check was the + *only* thing looking at one, and exempting drafts there alone would have made + a half-made advisory card invisible to the entire audit. Hence + `GHSA draft missing Status/Priority`, which reads the item-level `.status` + and `.priority` that `item-list` exposes for a draft as it does for an issue. - **`$M` holds closed issues too** — the lookup is built from `gh issue list --state all`, which it has to be, because the last check reads closed issues' state reasons. So `isopen` is not there to cope with a missing diff --git a/.claude/skills/security-advisory/SKILL.md b/.claude/skills/security-advisory/SKILL.md index 70df8fba58..15b3fb8d73 100644 --- a/.claude/skills/security-advisory/SKILL.md +++ b/.claude/skills/security-advisory/SKILL.md @@ -18,8 +18,13 @@ human-gated**: **accepting** an advisory (the reporter sees it) and there is no unpublish). Never automate either, never bulk-apply them, and never take either step because a checklist said to. Everything else here is mechanics. -Related: `/board-ops` (the card IDs and recipes), `/issue-create` and -`/pr-flow` for the public issue and the eventual release. +Related: `/board-ops` (the card IDs and recipes) and `/issue-create`, for the +public issue **after** publication. + +⚠️ **`/pr-flow` does not apply to the fix itself.** It requires a public issue +and a public PR against `v2/main` — the disclosure this flow exists to delay. +The fix is reviewed inside the private fork (step 4), and `/pr-flow` becomes +relevant only once the advisory is published. ## The flow @@ -49,6 +54,16 @@ a real GitHub issue". `Incoming` is correct even though somebody clearly triaged it to make the card: nobody has approved shipping a fix yet, and a draft card has no milestone to carry the approval. + ⚠️ **Put the score's arithmetic in the draft body**, under the description. + `/issue-triage` says to record it as an issue comment, and a draft card has + no comments — so without this the Priority is a bare word with nothing behind + it, and a later re-scoring cannot tell a judgment from a guess. Write the two + axes, the bonuses you claimed, and the total, exactly as the comment form + would. + ⚠️ **Set both fields.** The board audit's non-Issue check now exempts + `[GHSA-` drafts, so a half-made card no longer trips it; the audit carries a + narrow replacement check (see `/issue-triage`) and it is the only thing + looking. The card is made **by hand**. There is no `PROJECT_TOKEN` in this org and `organization projects: write` is a permission `GITHUB_TOKEN` structurally @@ -58,7 +73,9 @@ workflow for this;** that approach was tried and abandoned for exactly this reason. ```sh -gh api repos/modelcontextprotocol/inspector/security-advisories \ +# --paginate: this endpoint returns 30 per page, and an inventory that silently +# stops at the first page is worse than none — it reads as "nothing pending". +gh api --paginate repos/modelcontextprotocol/inspector/security-advisories \ --jq '.[] | select(.state=="triage") | "\(.ghsa_id)\t\(.severity)\t\(.summary)"' ``` @@ -76,8 +93,17 @@ it. Had ownership been checked after the severity assessment rather than before, the fix would have been written against the wrong repo. So: reproduce it, find the code, and check whether that code is first-party or -reached through a dependency. An advisory against upstream code is closed here -with a pointer to the upstream issue — it is not ours to accept or publish. +reached through a dependency. An advisory against upstream code is not ours to +accept or publish. + +⚠️ **"Upstream's problem" is not a reason to say it in public.** A genuine +unfixed vulnerability handed to a public upstream issue is disclosed — by us, +on someone else's behalf, before they have a fix. Route it through **that +project's own private reporting channel** (its `SECURITY.md`, or its advisory +form), and only reference a public upstream issue once the upstream has +published. Where the reporter would rather carry it over themselves, say so and +let them. #2409 took the benign version of this path: the reporter withdrew the +report here and raised it upstream. ### 3. Accept, or close @@ -88,6 +114,12 @@ moves the advisory `triage` → `draft`. The state is readable as `state` and **Invalid, out of scope, or upstream → close** with a comment saying which, and why. A reporter who is told nothing reasonably assumes they were ignored. +⚠️ **Closing an advisory leaves its draft card behind — delete it.** Nothing +shipped, so `Done` would be a false record and `Incoming` would claim work is +still queued; `AGENTS.md` deletes a card in exactly this situation, and a +rejected advisory's card is now invisible to the audit's non-Issue check by +construction. The delete recipe is in `/board-ops`. + ⚠️ **Accepting is a human act, always.** It is visible to the reporter and it commits this project to treating the report as a real vulnerability. Nothing in this skill authorizes taking it — surface the recommendation and let a @@ -138,13 +170,25 @@ hands out a working exploit. requests a **CVE**, and credits the reporter. There is no undo. Same rule as accepting: recommend, never perform. -### 6. File the public issue afterwards - -Once the advisory is published, the work becomes ordinary board history: file a -public issue recording what shipped, **close it** (the work is already done), -and convert the draft card to that issue so the board stops carrying a draft. -Label and milestone it per `/issue-create`; `Done` is correct here, because the -fix genuinely shipped. +### 6. Convert the card afterwards + +Once the advisory is published, the work becomes ordinary board history and the +draft card becomes a real issue. + +⚠️ **Convert FIRST — the order is not interchangeable.** GitHub's "Convert to +issue" creates a **new** issue from the draft; there is no way to point an +existing card at an issue you filed separately. Filing the issue by hand and +then converting produces two issues and two cards, which is why this step reads +the way it does: + +1. **Convert the draft card to an issue** on board #28 (the card keeps its + place and its field values; the issue is created from the card's title and + body). +2. Apply `v2` and a type label, and a milestone — the one the fix shipped in. + Do **not** run `/issue-create`'s add-card step: the card already exists. +3. **Close it.** The work shipped before the issue existed. +4. Move the card to **`Done`** — correct here, because the fix genuinely + shipped. ## API facts worth not re-deriving From 2bba258f3897aa112b6fc46de263288a2390302b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 18 Sep 2026 21:40:11 -0400 Subject: [PATCH 47/68] docs: address Copilot review round 2 on #2444 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three findings were defects in what this PR added. - The audit's carve-out matched on the title prefix alone, across BOTH boards and ANY non-Issue type. So a pull request titled `[GHSA-…]` — a plausible title for a security fix — was exempted, and so was an advisory draft misfiled on #11, where the replacement field check does not look either, so both checks would have read 0. The exemption is now `DraftIssue` AND the `[GHSA-` prefix AND board #28; #11 reports every non-Issue item it carries. Verified on a synthetic set: the exempt draft passes, while a stray draft, a GHSA-titled PR and a #11 advisory draft are all still reported. - board-ops claimed every recipe applied to an advisory draft unchanged. It does not: a draft has no repository and no issue number, so every `select(.content.repository==… and .content.number==…)` matches nothing and `item-add --url` has no URL. Added the title-based item-id lookup, keyed on the bracketed GHSA id rather than on words from the free-text summary. - The step table still said step 6 files the public issue and then converts the card — the impossible ordering the body below it was fixed to reject. The row now names conversion as what creates the issue. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 20 +++++++++++++++++--- .claude/skills/issue-triage/SKILL.md | 23 +++++++++++++++++------ .claude/skills/security-advisory/SKILL.md | 2 +- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index c5a103578f..4eb7bd2d5a 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -31,9 +31,23 @@ through the card of the issue it closes. **The one exception is a GitHub security advisory**, tracked by a draft card titled `[GHSA-xxxx-yyyy-zzzz] - …` because a real issue would disclose it before -a fix exists. The flow is `/security-advisory`; every recipe below applies to -that card unchanged, except that an advisory draft is found by title rather than -by issue number. +a fix exists. The flow is `/security-advisory`. + +⚠️ **A draft card has no repository and no issue number, so the lookups below +cannot find one.** Every `select(.content.repository==… and .content.number==…)` +matches nothing against a draft, and `item-add --url` has no URL to be given. +Look it up by **title** instead, then feed that item id to `item-edit` or +`item-delete` exactly as usual: + +```sh +ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ + --jq '.items[] | select(.content.type=="DraftIssue") + | select(.content.title | startswith("[GHSA-xxxx-yyyy-zzzz]")) | .id') +``` + +Match on the **bracketed GHSA id**, not on words from the summary — a summary is +free text and two advisories can share one. Advisory drafts live on #28 only; +`/issue-triage`'s audit reports one found anywhere else. ## V2 board (#28) IDs diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index dbd3b81cf1..ba7f02f52d 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -200,7 +200,7 @@ count means the board contradicts a rule, not that the rule needs revisiting. | Check | Invariant | Fix | | --- | --- | --- | | Double-boarded | An issue has a card on **one** board, the one matching its version label | Delete the wrong-board card | -| Non-Issue items | **Only issues go on a board** — never PRs, never drafts, *except* an advisory draft titled `[GHSA-…]` | Delete the item | +| Non-Issue items | **Only issues go on a board** — never PRs, never drafts, *except* a `[GHSA-…]` **draft** on **#28** | Delete the item | | No Status | Every card carries a Status | Set one — `Incoming` if unmilestoned, else by where it actually is | | GHSA draft missing Status/Priority | An exempted advisory draft still carries both | Set them — `/security-advisory` | | `Incoming` **with** a milestone (#28) | Incoming ⇔ no milestone | Approval was never recorded: move to **Todo**, or clear the milestone | @@ -237,11 +237,15 @@ jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b | { "double-boarded": [$B28[].n | select(. as $n | [$B11[].n]|index($n))], # An advisory draft card is the ONE legitimate non-Issue item (see AGENTS.md). - # It is identified by its `[GHSA-` title prefix and nothing else, so a stray - # draft is still reported. Reports the TITLE, since a draft has no number. - "non-Issue on a board": [(own($a)[], own($b)[]) | select(.content.type!="Issue") - | (.content.title // "(untitled)") - | select(startswith("[GHSA-") | not)], + # The exemption is narrowed three ways, and each one matters: DRAFTS only + # (a GHSA-titled PR is still reported), board #28 ONLY (an advisory has no + # business on #11), and the `[GHSA-` title prefix (a stray draft is still + # reported). Reports the TITLE, since a draft has no number. + "non-Issue on a board": [(own($a)[] | select(.content.type!="Issue" + and ((.content.type=="DraftIssue" + and ((.content.title // "") | startswith("[GHSA-"))) | not))), + (own($b)[] | select(.content.type!="Issue"))] + | map(.content.title // "(untitled)"), # $B28/$B11 hold only Issue items, so the Status and Priority checks below # cannot see an advisory draft. Exempting drafts from the check above would # therefore have made a half-made advisory card invisible to the whole @@ -312,6 +316,13 @@ Two things the queries must account for, both learned the hard way: would let an ordinary stray draft through, which is the defect the check exists for. So a draft titled anything else is still reported — by title, since a draft has no issue number to print. + ⚠️ **The title prefix alone is not enough, because a title is not a type and + not a board.** Matched on its own it would also exempt a **pull request** + whose title happens to start `[GHSA-` — a plausible title for a security fix + — and an advisory draft misfiled on **#11**, where the replacement field check + below does not look either, so both checks would read `0`. The exemption is + therefore `DraftIssue` **and** `[GHSA-` **and** board #28; #11 still reports + every non-Issue item it carries. ⚠️ **The exemption had to come with a replacement check.** `$B28` and `$B11` are built from `Issue` items only, so the `no Status` and `no Priority` checks never see a draft — before the carve-out the non-Issue check was the diff --git a/.claude/skills/security-advisory/SKILL.md b/.claude/skills/security-advisory/SKILL.md index 15b3fb8d73..ba28af92d0 100644 --- a/.claude/skills/security-advisory/SKILL.md +++ b/.claude/skills/security-advisory/SKILL.md @@ -35,7 +35,7 @@ relevant only once the advisory is published. | 3 | Valid → **accept** (`triage` → `draft`); invalid → close with a reason | **Human only** | | 4 | Create the **private fork**, fix and review there | Mechanical | | 5 | Merge, release, then **publish** the advisory | **Human only** | -| 6 | After the release, file the public (closed) issue and convert the card | Mechanical | +| 6 | After the release, **convert** the draft card — that is what creates the public issue | Mechanical | ### 1. Board it as a draft card From b2d44fff73c64669f89e9963ec2429c6b87811dd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 19 Sep 2026 08:51:04 -0400 Subject: [PATCH 48/68] docs: address Copilot review round 3 on #2444 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings were defects in what this PR added. - The skill assumed v2 throughout: the fix "comes back to `v2/main`", and step 6 applied the `v2` label unconditionally. But an advisory is very nearly the ONLY work the v1 line ever receives — SECURITY.md supports v1 for security fixes only, published under `v1-latest` — so the skill was wrong exactly where v1 matters most. A v1-only advisory would have been merged to a branch where the bug does not exist; one affecting both lines could have been published with v1 still unpatched, which is the worst outcome this flow can produce. Step 2 now ends in a SET of affected lines, with the per-line branch and dist-tag table and the note that the two publish independently so a v1 fix is not forward-ported. Step 4 merges to each affected branch; step 5 states that "shipped" means shipped on every affected line before publishing. Step 6's labels, milestone and board branch by line — `v1` takes no milestone (every milestone is a v2 release bucket) and is carded on #11, which has no Priority field. The reporter's v2/v1/both answer is described as what SECURITY.md actually is — a request in "What to Include", not a required form field — so it is read and then verified rather than trusted. - The frontmatter description and the Related line still said to "file" the public issue, contradicting step 6, which round 1 established must CONVERT the existing draft card. Both now say convert. Listing budget re-checked: 3,711/4,000, and the recorded figure in AGENTS.md moved with it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- .claude/skills/security-advisory/SKILL.md | 75 +++++++++++++++++++---- AGENTS.md | 4 +- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/.claude/skills/security-advisory/SKILL.md b/.claude/skills/security-advisory/SKILL.md index ba28af92d0..793868e4de 100644 --- a/.claude/skills/security-advisory/SKILL.md +++ b/.claude/skills/security-advisory/SKILL.md @@ -1,6 +1,6 @@ --- name: security-advisory -description: "Take a privately reported vulnerability through this repo's security advisory flow — board it, verify who owns the code path, accept or reject, fix it in the private fork, publish, then file the public issue. Use when a vulnerability is reported privately; when deciding whether an advisory is ours to fix; when looking up or creating its private fork; when answering a reporter; or when a GHSA-titled board card needs handling." +description: "Take a privately reported vulnerability through this repo's security advisory flow — board it, verify who owns the code path, accept or reject, fix it in the private fork, ship to every affected release line, publish, then convert the card. Use when a vulnerability is reported privately; when deciding whether an advisory is ours to fix; when looking up or creating its private fork; when answering a reporter; or when a GHSA-titled board card needs handling." disable-model-invocation: false --- @@ -19,22 +19,24 @@ there is no unpublish). Never automate either, never bulk-apply them, and never take either step because a checklist said to. Everything else here is mechanics. Related: `/board-ops` (the card IDs and recipes) and `/issue-create`, for the -public issue **after** publication. +labels and milestone the converted card takes **after** publication. The public +issue is not *filed* — step 6 **converts** the draft card, which is what creates +it. ⚠️ **`/pr-flow` does not apply to the fix itself.** It requires a public issue -and a public PR against `v2/main` — the disclosure this flow exists to delay. -The fix is reviewed inside the private fork (step 4), and `/pr-flow` becomes -relevant only once the advisory is published. +and a public PR against the release branch — the disclosure this flow exists to +delay. The fix is reviewed inside the private fork (step 4), and `/pr-flow` +becomes relevant only once the advisory is published. ## The flow | # | Step | Gate | | --- | --- | --- | | 1 | Advisory lands in state `triage` → **draft card** on board #28 | Mechanical | -| 2 | **Verify the claim — including who owns the code path** | Judgment | +| 2 | **Verify the claim** — who owns the code path, and **which release lines are affected** | Judgment | | 3 | Valid → **accept** (`triage` → `draft`); invalid → close with a reason | **Human only** | | 4 | Create the **private fork**, fix and review there | Mechanical | -| 5 | Merge, release, then **publish** the advisory | **Human only** | +| 5 | Merge **to every affected line**, release each, then **publish** the advisory | **Human only** | | 6 | After the release, **convert** the draft card — that is what creates the public issue | Mechanical | ### 1. Board it as a draft card @@ -80,7 +82,7 @@ gh api --paginate repos/modelcontextprotocol/inspector/security-advisories \ | "\(.ghsa_id)\t\(.severity)\t\(.summary)"' ``` -### 2. Verify the claim — and who owns the code path +### 2. Verify the claim — who owns the code path, and which lines it affects Before assessing severity, establish that the vulnerable code is **ours**. A report can be entirely accurate about behavior the Inspector merely exhibits @@ -105,6 +107,32 @@ published. Where the reporter would rather carry it over themselves, say so and let them. #2409 took the benign version of this path: the reporter withdrew the report here and raised it upstream. +#### Which release lines are affected — ask it here, not at merge time + +⚠️ **An advisory is very nearly the only work the v1 line ever receives**, so +this is exactly where assuming v2 does the most damage. `SECURITY.md` supports +v1 for **security fixes only**, published under the `v1-latest` dist-tag, and +its "What to Include" asks the reporter to state "whether it affects v2, v1, or +both". Read what they said and then check it yourself — it is a request, not a +required form field, so it is often absent and it is never authoritative when +present. A v1-only advisory assumed to be +v2 gets merged to a branch where the bug does not exist, and one affecting both +lines leaves v1 **unpatched** while the advisory is published, which is the +worst outcome this whole flow can produce. + +So the outcome of step 2 is a **set** of affected lines, and each one is +shipped on its own terms: + +| Line | Branch | Flow | Publishes to | +| --- | --- | --- | --- | +| v2 | `v2/main` | `fix branch → v2/main → (milestone) main` | `latest` | +| v1 | `v1/main` | `fix branch → v1/main`, flat — **no merge into `main`** | `v1-latest` | + +**The two lines publish independently under separate dist-tags, so a v1 fix is +not forward-ported** — if v2 is affected too, that is a second fix on `v2/main`, +not a merge. Branch names carry the version segment either way +(`v1/fix/…`, `v2/fix/…`). + ### 3. Accept, or close **Valid and ours → accept.** In the UI this is "Accept and open as draft"; it @@ -157,15 +185,24 @@ something you can quietly undo — it takes a re-scoped token or an admin in the UI. That asymmetry is the whole reason for the read-first rule above. Fix and review inside the fork. Its PRs and commits are private, so none of the -normal public review flow applies; the diff comes back to `v2/main` as an -ordinary commit at merge time. +normal public review flow applies; the diff comes back as an ordinary commit at +merge time, **to the branch of each line step 2 found affected** — `v2/main` +for v2, `v1/main` for v1. -### 5. Merge, release, publish +### 5. Merge to every affected line, release, publish Publish **after** the fix has shipped in a release, never before — publishing discloses the vulnerability, so doing it while users have no upgrade available hands out a working exploit. +⚠️ **"Shipped" means shipped on *every* affected line.** The two lines release +independently under separate dist-tags, so v2 reaching `latest` says nothing +about `v1-latest`. Publishing with one line still unpatched discloses a live +vulnerability to the users who have no fix — and they are the users least able +to move, since v1 is the deprecated line they are on because upgrading is hard. +Cutting each release is `/release` for v2; a v1 fix publishes straight from +`v1/main`. + ⚠️ **Publishing is irreversible and human-gated.** It makes the advisory public, requests a **CVE**, and credits the reporter. There is no undo. Same rule as accepting: recommend, never perform. @@ -184,8 +221,19 @@ the way it does: 1. **Convert the draft card to an issue** on board #28 (the card keeps its place and its field values; the issue is created from the card's title and body). -2. Apply `v2` and a type label, and a milestone — the one the fix shipped in. - Do **not** run `/issue-create`'s add-card step: the card already exists. +2. Apply a **type label** and the **version label of the line the fix shipped + on**, then a milestone — and those two are not independent: + + | Affected | Version label | Milestone | Board | + | --- | --- | --- | --- | + | v2 | `v2` | the release the fix shipped in | #28 — the converted card is already there | + | v1 | `v1` | **none** — every milestone is a v2 release bucket | **#11**, which has no Priority field | + | both | one issue per line, labelled and boarded as above | | | + + Do **not** run `/issue-create`'s add-card step for the converted card: it + already exists. A **v1** issue does need a card created on #11, because the + draft lived on #28 — and a v1 advisory's draft card on #28 is deleted once + its #11 issue exists, rather than left behind claiming v2 work. 3. **Close it.** The work shipped before the issue existed. 4. Move the card to **`Done`** — correct here, because the fix genuinely shipped. @@ -203,3 +251,4 @@ All verified against the live API. | Fork deletion | Needs the `delete_repo` OAuth scope; a default `gh` token lacks it | | Comments | **No API at all**, REST or GraphQL. UI-only | | Board writes | Not automatable — no `PROJECT_TOKEN`, and `GITHUB_TOKEN` cannot hold `organization projects: write` | +| Affected lines | `SECURITY.md` **asks** for v2 / v1 / both — a request, not a required field. Read it, never rely on it | diff --git a/AGENTS.md b/AGENTS.md index 5fc98cae06..b5944e8198 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ users invoke them by name. | [`pr-flow`](.claude/skills/pr-flow/SKILL.md) | Branch naming, DCO signoff, screenshots, opening the PR, requesting a Copilot review, responding, closing out | Model-invoked, or `/pr-flow` | | [`pre-push-gate`](.claude/skills/pre-push-gate/SKILL.md) | Running `npm run local:gate` and diagnosing a failing stage | Model-invoked, or `/pre-push-gate` | | [`release`](.claude/skills/release/SKILL.md) | Cutting a release: bump on `v2/main`, milestone merge, tag `origin/main`, publish | `/release` | -| [`security-advisory`](.claude/skills/security-advisory/SKILL.md) | A privately reported vulnerability end to end: the draft card, verifying who owns the code path, accepting, the private fork, publishing, the public issue afterwards | Model-invoked, or `/security-advisory` | +| [`security-advisory`](.claude/skills/security-advisory/SKILL.md) | A privately reported vulnerability end to end: the draft card, who owns the code path, which release lines are affected, accepting, the private fork, publishing, converting the card | Model-invoked, or `/security-advisory` | | [`test-servers`](.claude/skills/test-servers/SKILL.md) | Picking and running a showcase test server; the stale-build hazard | Model-invoked, or `/test-servers` | Longer-form human documentation lives in [`docs/`](./docs) — see the table in the @@ -342,7 +342,7 @@ node/field/option IDs, and the option-deletion hazard` was cut at `#28`, so 90 overflows, and drops the least-invoked entries **first** — which are exactly the model-invoked skills that must fire on their own. `verify:skills` prints the current cost against the budget recorded in `scripts/lib/skill-manifest.mjs` - (3,679/4,000 characters as of this writing) and fails when it is exceeded. Raise + (3,711/4,000 characters as of this writing) and fails when it is exceeded. Raise the budget deliberately, or tighten a description; each entry is capped at 1,536 characters regardless, so **put the key use case first**. From 4da164ac3fa7c89e77914e4d780ef63dc51b0725 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 19 Sep 2026 09:07:31 -0400 Subject: [PATCH 49/68] docs: address Copilot review round 4 on #2444 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four accepted, one accepted in part. - The card body no longer carries the vulnerability description. Project access and advisory access are SEPARATE permission sets, so the board's audience is not the advisory's audience. The boards are private, so this is a wider audience than intended rather than a public leak — but a repro or PoC belongs with the people handling it. Link plus triage metadata only. - Publishing does NOT assign a CVE or credit the reporter. A CVE request is optional and a credit must be explicitly added and then accepted. Both are now stated as things to do BEFORE publishing, which is more useful than the overclaim was: an unadded reporter is simply never credited and nothing reports it. - The card now moves In Progress / In Review with the work. It being private is the reason to do this, not to skip it — the fork is invisible to anyone not on the advisory, so the card is the only signal the work exists. - The "both lines" case gets a deterministic sequence: the draft converts exactly once, so v2 inherits the conversion on #28 and v1 is filed separately on #11. A v1-ONLY advisory deletes the #28 draft rather than converting it, since a converted card would put a v1 issue on #28. - The `/release` pointer added in round 3 is removed. It was a dead end twice over: `release` is `disable-model-invocation: true`, which AGENTS.md says a skill cannot reach, and it moves through two PUBLIC PRs. Declined the second half of that last one — designing a security-release path that publishes without the public-PR sequence. The premise does not hold: the patch stops being secret at MERGE, since merging the private fork puts an ordinary public commit on the release branch, so no release path can keep it private. Stated that directly instead, with the actionable consequence: the merge-to-publish window is exposure, not secrecy, so merge close to the release. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- .claude/skills/security-advisory/SKILL.md | 94 +++++++++++++++++++---- 1 file changed, 78 insertions(+), 16 deletions(-) diff --git a/.claude/skills/security-advisory/SKILL.md b/.claude/skills/security-advisory/SKILL.md index 793868e4de..c33ac6ced3 100644 --- a/.claude/skills/security-advisory/SKILL.md +++ b/.claude/skills/security-advisory/SKILL.md @@ -12,11 +12,18 @@ chooser deliberately has no security template, because a vulnerability report must not open a public issue. So an advisory never arrives as an issue, and for most of its life it must **not** become one. -Two steps in this flow are **outward-facing and irreversible-ish, and both stay -human-gated**: **accepting** an advisory (the reporter sees it) and -**publishing** it (it becomes public, assigns a CVE, and credits the reporter — -there is no unpublish). Never automate either, never bulk-apply them, and never -take either step because a checklist said to. Everything else here is mechanics. +Two steps in this flow are **outward-facing, and both stay human-gated**: +**accepting** an advisory (the reporter sees it) and **publishing** it (it +becomes public, and there is no unpublish). Never automate either, never +bulk-apply them, and never take either step because a checklist said to. +Everything else here is mechanics. + +⚠️ **A CVE and the credits are *choices made at publish time*, not effects of +publishing.** Requesting a CVE is an optional action on the advisory, and a +credit appears only when someone is explicitly added **and accepts** it. They +are named here because they are the parts a maintainer must not forget — the +reporter's credit especially, since nothing prompts for it — not because +publishing performs them. Related: `/board-ops` (the card IDs and recipes) and `/issue-create`, for the labels and milestone the converted card takes **after** publication. The public @@ -50,8 +57,18 @@ a real GitHub issue". is not cosmetic: the board audit in `/issue-triage` keys its draft carve-out on it, so a card titled any other way is reported as a stray draft. - **Body:** `**Advisory:** ` on the first line, then severity and - reported date, then the advisory description. The link first, because a - maintainer reading the card has no other route back to the private advisory. + reported date. The link first, because a maintainer reading the card has no + other route back to the private advisory. + ⚠️ **Do not copy the vulnerability description onto the card.** Project + access and advisory access are **separate permission sets**, so the board's + audience is not the advisory's audience — anyone with project access reads + the card, whether or not they are an advisory collaborator. The boards are + private ([`/issue-triage`](../issue-triage/SKILL.md)), so this is a wider + audience than intended rather than a public leak, but a reproduction or a PoC + is the part worth keeping to the people handling it. The card carries the + **link and triage metadata only**; the link is how a reader with access gets + the details, and the absence of details is how a reader without access is + told they do not have them. - **Status `Incoming`**, plus a Priority scored with the `/issue-triage` rubric. `Incoming` is correct even though somebody clearly triaged it to make the card: nobody has approved shipping a fix yet, and a draft card has no @@ -189,6 +206,15 @@ normal public review flow applies; the diff comes back as an ordinary commit at merge time, **to the branch of each line step 2 found affected** — `v2/main` for v2, `v1/main` for v1. +⚠️ **Move the card as the work moves.** `AGENTS.md`'s lifecycle applies to this +card like any other: **`In Progress`** when the fix is started, **`In Review`** +when the fork's PR is open. The card being private is not a reason to skip it — +it is the reason to do it, since the fork is invisible to everyone who is not on +the advisory, and this card is the only place the rest of the team can see the +work exists at all. A card that sits in `Incoming` until it jumps to `Done` +reports "unreviewed, nobody committed to it" for the entire time somebody is +actively fixing it. + ### 5. Merge to every affected line, release, publish Publish **after** the fix has shipped in a release, never before — publishing @@ -200,12 +226,29 @@ independently under separate dist-tags, so v2 reaching `latest` says nothing about `v1-latest`. Publishing with one line still unpatched discloses a live vulnerability to the users who have no fix — and they are the users least able to move, since v1 is the deprecated line they are on because upgrading is hard. -Cutting each release is `/release` for v2; a v1 fix publishes straight from -`v1/main`. -⚠️ **Publishing is irreversible and human-gated.** It makes the advisory public, -requests a **CVE**, and credits the reporter. There is no undo. Same rule as -accepting: recommend, never perform. +⚠️ **The patch stops being secret at MERGE, not at publish — and no release +path changes that.** Merging the private fork puts an ordinary public commit on +`v2/main` or `v1/main`, readable by anyone, and a v2 release then moves it +through **two public PRs** on its way to `main`. So the window between merge and +publish is not a period of secrecy to protect; it is a period of **exposure to +anyone reading commits**, which is why it should be short. Merge close to the +release rather than early, and publish as soon as the release is out. + +**Do not hand this off to the release skill.** It is `disable-model-invocation: +true`, so a pointer to it from here is a dead end for the model anyway — a +maintainer invokes `/release` themselves. Say which lines need a release and +stop there. A v1 fix takes no merge into `main` at all and publishes straight +from `v1/main`, so it does not go through that procedure. + +⚠️ **Publishing is irreversible and human-gated.** It makes the advisory +public, and there is no undo. Same rule as accepting: recommend, never perform. + +**Before publishing, do the two things publishing will not do for you:** +request the **CVE** (optional, and the advisory is the only place to ask) and +**add the reporter to the credits** — a credit is an explicit addition the +person then has to accept, so an unadded reporter is simply never credited, and +that is the failure nobody notices because nothing reports it. ### 6. Convert the card afterwards @@ -228,12 +271,31 @@ the way it does: | --- | --- | --- | --- | | v2 | `v2` | the release the fix shipped in | #28 — the converted card is already there | | v1 | `v1` | **none** — every milestone is a v2 release bucket | **#11**, which has no Priority field | - | both | one issue per line, labelled and boarded as above | | | + | both | **two issues**, one per line — see below | | | Do **not** run `/issue-create`'s add-card step for the converted card: it - already exists. A **v1** issue does need a card created on #11, because the - draft lived on #28 — and a v1 advisory's draft card on #28 is deleted once - its #11 issue exists, rather than left behind claiming v2 work. + already exists. + + **The draft converts exactly once, so "both" needs a stated order.** Every + issue carries exactly one version label and lives on one board, and there is + only ever one draft card — so one line inherits it and the other gets a + fresh issue: + + 1. **Convert the draft into the `v2` issue on #28.** v2 takes the + conversion because the draft is already on #28 and v2 is the line with a + milestone to record. + 2. **File the `v1` issue separately** through `/issue-create` — `v1`, a type + label, **no milestone**, and a card on **#11** (Status only; that board + has no Priority field). This one *is* filed rather than converted, which + is not a contradiction of step 6: there is no second draft to convert. + 3. Cross-link the two so neither reads as the whole story, then **close + both** and move both cards to `Done`. + + **For a v1-only advisory** the draft is on the wrong board and cannot be + moved there by converting: file the `v1` issue on #11 as in (2), then + **delete** the #28 draft rather than converting it — a converted card would + put a `v1` issue on #28, which the board audit reports as a wrong-board + card. 3. **Close it.** The work shipped before the issue existed. 4. Move the card to **`Done`** — correct here, because the fix genuinely shipped. From 856c82f5d90072452f21420cb1251ce4070fc4ee Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 19 Sep 2026 09:23:27 -0400 Subject: [PATCH 50/68] docs: address Copilot review round 5 on #2444 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three findings were internal contradictions in text this PR added. - Step 1 required a rubric Priority, whose FIRST axis is severity, while step 2 says ownership is established before severity — so the procedure forced the exact order #2409 is the warning against. The step-1 score is now explicitly PROVISIONAL, recorded as such and dated in the draft body, and re-scored at the end of step 2 when ownership and the affected lines are known. The provisional line stays when re-scored so the change of view is legible. - The flow table, the frontmatter description and the Related line all made conversion universal and triggered on "after the release". Both were wrong: a v1-only advisory FILES on #11 and deletes the draft, and the trigger is PUBLICATION, since the release ships the fix while the advisory can still be private — a public issue opened in that gap describes an undisclosed vulnerability. All three sites are now per-line and post-publication, and step 6 states the reason rather than just the rule. - AGENTS.md's carve-out said the draft is always converted once published, contradicting the per-line procedure. It now states the per-line outcome. Listing budget re-checked after the description edit: 3,729/4,000, with the recorded figure moved to match. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: cliffhall --- .claude/skills/security-advisory/SKILL.md | 62 ++++++++++++++++------- AGENTS.md | 4 +- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/.claude/skills/security-advisory/SKILL.md b/.claude/skills/security-advisory/SKILL.md index c33ac6ced3..ce2594d243 100644 --- a/.claude/skills/security-advisory/SKILL.md +++ b/.claude/skills/security-advisory/SKILL.md @@ -1,6 +1,6 @@ --- name: security-advisory -description: "Take a privately reported vulnerability through this repo's security advisory flow — board it, verify who owns the code path, accept or reject, fix it in the private fork, ship to every affected release line, publish, then convert the card. Use when a vulnerability is reported privately; when deciding whether an advisory is ours to fix; when looking up or creating its private fork; when answering a reporter; or when a GHSA-titled board card needs handling." +description: "Take a privately reported vulnerability through this repo's security advisory flow — board it, verify who owns the code path, accept or reject, fix it in the private fork, ship to every affected release line, publish, then turn the card into public tracking. Use when a vulnerability is reported privately; when deciding whether an advisory is ours to fix; when looking up or creating its private fork; when answering a reporter; or when a GHSA-titled board card needs handling." disable-model-invocation: false --- @@ -26,9 +26,15 @@ reporter's credit especially, since nothing prompts for it — not because publishing performs them. Related: `/board-ops` (the card IDs and recipes) and `/issue-create`, for the -labels and milestone the converted card takes **after** publication. The public -issue is not *filed* — step 6 **converts** the draft card, which is what creates -it. +labels, milestone and board that public tracking takes — **after publication, +never merely after the release**, since the release ships the fix while the +advisory may still be private. + +⚠️ **How that tracking is created depends on the affected lines**, and only the +v2 path is a conversion: a v2 issue is **converted** from the draft (filing one +separately would duplicate both the issue and the card), while a v1 issue is +**filed** on #11, because the draft is on #28 and cannot move there. Step 6 has +the per-line sequence. ⚠️ **`/pr-flow` does not apply to the fix itself.** It requires a public issue and a public PR against the release branch — the disclosure this flow exists to @@ -44,7 +50,7 @@ becomes relevant only once the advisory is published. | 3 | Valid → **accept** (`triage` → `draft`); invalid → close with a reason | **Human only** | | 4 | Create the **private fork**, fix and review there | Mechanical | | 5 | Merge **to every affected line**, release each, then **publish** the advisory | **Human only** | -| 6 | After the release, **convert** the draft card — that is what creates the public issue | Mechanical | +| 6 | **After publication**, turn the card into public tracking — per line: convert (v2), or file on #11 and delete the draft (v1) | Mechanical | ### 1. Board it as a draft card @@ -69,16 +75,25 @@ a real GitHub issue". **link and triage metadata only**; the link is how a reader with access gets the details, and the absence of details is how a reader without access is told they do not have them. -- **Status `Incoming`**, plus a Priority scored with the `/issue-triage` rubric. - `Incoming` is correct even though somebody clearly triaged it to make the - card: nobody has approved shipping a fix yet, and a draft card has no - milestone to carry the approval. - ⚠️ **Put the score's arithmetic in the draft body**, under the description. - `/issue-triage` says to record it as an issue comment, and a draft card has - no comments — so without this the Priority is a bare word with nothing behind - it, and a later re-scoring cannot tell a judgment from a guess. Write the two - axes, the bonuses you claimed, and the total, exactly as the comment form - would. +- **Status `Incoming`**, plus a **provisional** Priority scored with the + `/issue-triage` rubric. `Incoming` is correct even though somebody clearly + triaged it to make the card: nobody has approved shipping a fix yet, and a + draft card has no milestone to carry the approval. + ⚠️ **Provisional is not a hedge — it is the only honest score at this + point.** The rubric's first axis is *severity*, and step 2 says ownership is + established **before** severity, precisely because #2409 looked severe right + up until it turned out not to be ours. At step 1 you have a report and + nothing verified, so score what the report claims, mark it provisional in the + body, and **re-score it at the end of step 2**, when you know whether the + code is ours and which lines it reaches. An advisory that turns out to be + upstream has its card deleted rather than re-scored (step 3). + ⚠️ **Put the score's arithmetic in the draft body**, marked provisional and + dated. `/issue-triage` says to record it as an issue comment, and a draft + card has no comments — so without this the Priority is a bare word with + nothing behind it, and the step-2 re-score cannot tell what it is revising. + Write the two axes, the bonuses you claimed, and the total, exactly as the + comment form would; leave the provisional line in place when you re-score and + add the new one under it, so the change of view is legible. ⚠️ **Set both fields.** The board audit's non-Issue check now exempts `[GHSA-` drafts, so a half-made card no longer trips it; the audit carries a narrow replacement check (see `/issue-triage`) and it is the only thing @@ -150,6 +165,12 @@ not forward-ported** — if v2 is affected too, that is a second fix on `v2/main not a merge. Branch names carry the version segment either way (`v1/fix/…`, `v2/fix/…`). +**Now re-score the card's Priority**, replacing the provisional one from step 1. +This is the first point at which the rubric's severity axis has anything solid +under it: you know the code is ours, you have reproduced it, and you know how +many lines it reaches — and "affects both lines" is itself a severity input the +provisional score could not have had. + ### 3. Accept, or close **Valid and ours → accept.** In the UI this is "Accept and open as draft"; it @@ -250,10 +271,15 @@ request the **CVE** (optional, and the advisory is the only place to ask) and person then has to accept, so an unadded reporter is simply never credited, and that is the failure nobody notices because nothing reports it. -### 6. Convert the card afterwards +### 6. After publication, turn the card into public tracking + +**The trigger is publication, not the release.** The release ships the fix +while the advisory can still be private, and a public issue opened in that gap +describes a vulnerability the advisory has not disclosed yet. Wait for step 5 +to finish. -Once the advisory is published, the work becomes ordinary board history and the -draft card becomes a real issue. +Once it has, the work becomes ordinary board history — by conversion for v2, by +filing for v1. ⚠️ **Convert FIRST — the order is not interchangeable.** GitHub's "Convert to issue" creates a **new** issue from the draft; there is no way to point an diff --git a/AGENTS.md b/AGENTS.md index b5944e8198..63d6b8cf6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -342,7 +342,7 @@ node/field/option IDs, and the option-deletion hazard` was cut at `#28`, so 90 overflows, and drops the least-invoked entries **first** — which are exactly the model-invoked skills that must fire on their own. `verify:skills` prints the current cost against the budget recorded in `scripts/lib/skill-manifest.mjs` - (3,711/4,000 characters as of this writing) and fails when it is exceeded. Raise + (3,729/4,000 characters as of this writing) and fails when it is exceeded. Raise the budget deliberately, or tighten a description; each entry is capped at 1,536 characters regardless, so **put the key use case first**. @@ -365,7 +365,7 @@ skills; the rules are here. - **Before starting work, check the board for the relevant item.** - **Every board item is a real GitHub issue.** No draft cards. Before creating a new issue, check the board for a matching item — **never create a duplicate**. - - **The one exception is a GitHub security advisory**, which is tracked by a **draft card** titled `[GHSA-xxxx-yyyy-zzzz] - `. An advisory is private until it is published, so a real issue would disclose the vulnerability before a fix exists — the thing the whole advisory flow is for. The card is made by hand (no `PROJECT_TOKEN` exists in this org, and `GITHUB_TOKEN` cannot hold `organization projects: write`), and it is converted to a real issue once the advisory is published. The `[GHSA-` prefix is load-bearing: it is what the board audit's draft carve-out keys on, so **any other draft card is still a defect to delete**. The flow itself — verifying who owns the code path, accepting, the private fork, publishing — is the `security-advisory` skill. **Accepting and publishing an advisory are outward-facing and stay human-gated; never automate or bulk-apply either.** + - **The one exception is a GitHub security advisory**, which is tracked by a **draft card** titled `[GHSA-xxxx-yyyy-zzzz] - `. An advisory is private until it is published, so a real issue would disclose the vulnerability before a fix exists — the thing the whole advisory flow is for. The card is made by hand (no `PROJECT_TOKEN` exists in this org, and `GITHUB_TOKEN` cannot hold `organization projects: write`), and it becomes public tracking **once the advisory is published** — never merely once the fix ships, since the release can precede publication. How depends on the line: a `v2` issue is **converted** from the draft on #28, while a `v1` issue is **filed** on #11 and the #28 draft is deleted, because a draft cannot convert onto another board and a `v1` issue on #28 is a wrong-board card. An advisory affecting both lines produces one issue per line. The `[GHSA-` prefix is load-bearing: it is what the board audit's draft carve-out keys on, so **any other draft card is still a defect to delete**. The flow itself — verifying who owns the code path, accepting, the private fork, publishing — is the `security-advisory` skill. **Accepting and publishing an advisory are outward-facing and stay human-gated; never automate or bulk-apply either.** - **Only issues go on a board — never PRs.** A PR gets the `v2` label but is tracked through its linked issue's card (via `Closes #N`), not its own board item. - **Label by version — every issue and every PR, no exceptions.** Exactly one of `v1` (work targeting `v1/main`, the deprecated security-fix-only line) or `v2` (active development; the default for anything new). There is no unlabeled state and no "decide later": an issue with neither label belongs to no version line and is invisible to every version-filtered query. Set it at **create time** (`gh issue create --label v2 …`), never by backfilling. **If the target version isn't obvious, it's `v2`.** - **Label by type — exactly one of `bug` / `enhancement` / `documentation` / `chore` / `question`** on every issue you create or triage. The version label says which line the work belongs to; the type label says what kind of work it is, and the two are independent. Don't force the binary: pressing a docs task or a dependency pin into `enhancement` degrades it to "not a bug", at which point filtering by it stops telling you anything. A **PR** needs no type label — it is classified through the issue it closes. From 50a9634d29782b0dad86c089874d505f44f6205e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 20:15:17 -0400 Subject: [PATCH 51/68] docs: address Copilot review round 6 on #2444 Keep the advisory card's score to numbers and rubric names: the /issue-triage comment template follows each axis with free-text justification, which for an advisory is the impact and affected surface. Make the skills-index entry say public tracking is per line (v2 converts the draft; v1 files on #11) instead of a universal conversion. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/security-advisory/SKILL.md | 12 +++++++++--- AGENTS.md | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.claude/skills/security-advisory/SKILL.md b/.claude/skills/security-advisory/SKILL.md index ce2594d243..c0ac63da1c 100644 --- a/.claude/skills/security-advisory/SKILL.md +++ b/.claude/skills/security-advisory/SKILL.md @@ -91,9 +91,15 @@ a real GitHub issue". dated. `/issue-triage` says to record it as an issue comment, and a draft card has no comments — so without this the Priority is a bare word with nothing behind it, and the step-2 re-score cannot tell what it is revising. - Write the two axes, the bonuses you claimed, and the total, exactly as the - comment form would; leave the provisional line in place when you re-score and - add the new one under it, so the change of view is legible. + Write the two axes, the bonuses you claimed, and the total as **numbers and + rubric names only** — `Severity 4`, `+1 security`, `Total 6`. That is + deliberately *not* the comment form: its template follows each axis with a + free-text justification ("Severity 3 — a real feature is broken…"), and for + an advisory that justification is the impact and the affected surface, which + is exactly what the warning above keeps off the card. The reasoning behind a + number belongs in the private advisory. Leave the provisional line in place + when you re-score and add the new one under it, so the change of view is + legible. ⚠️ **Set both fields.** The board audit's non-Issue check now exempts `[GHSA-` drafts, so a half-made card no longer trips it; the audit carries a narrow replacement check (see `/issue-triage`) and it is the only thing diff --git a/AGENTS.md b/AGENTS.md index 63d6b8cf6c..ce472adb21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ users invoke them by name. | [`pr-flow`](.claude/skills/pr-flow/SKILL.md) | Branch naming, DCO signoff, screenshots, opening the PR, requesting a Copilot review, responding, closing out | Model-invoked, or `/pr-flow` | | [`pre-push-gate`](.claude/skills/pre-push-gate/SKILL.md) | Running `npm run local:gate` and diagnosing a failing stage | Model-invoked, or `/pre-push-gate` | | [`release`](.claude/skills/release/SKILL.md) | Cutting a release: bump on `v2/main`, milestone merge, tag `origin/main`, publish | `/release` | -| [`security-advisory`](.claude/skills/security-advisory/SKILL.md) | A privately reported vulnerability end to end: the draft card, who owns the code path, which release lines are affected, accepting, the private fork, publishing, converting the card | Model-invoked, or `/security-advisory` | +| [`security-advisory`](.claude/skills/security-advisory/SKILL.md) | A privately reported vulnerability end to end: the draft card, who owns the code path, which release lines are affected, accepting, the private fork, publishing, public tracking per line (v2 converts; v1 files) | Model-invoked, or `/security-advisory` | | [`test-servers`](.claude/skills/test-servers/SKILL.md) | Picking and running a showcase test server; the stale-build hazard | Model-invoked, or `/test-servers` | Longer-form human documentation lives in [`docs/`](./docs) — see the table in the From f8b35210b47daa163f3622de4ad6cff958cf54cb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 20:16:46 -0400 Subject: [PATCH 52/68] docs: add a secret-storage guide for every runtime (#2447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the secret-store selection, file-store and keychain-hand-off material out of the Docker guide into docs/secret-storage.md, since the file fallback applies to any host without a keychain (Linux without libsecret, headless/SSH, Termux), not only containers. docker.md keeps the container-specific parts; environment-variables.md, mcp-server-configuration.md and the README link to the new guide. Also corrects the env-vars table, which listed headers as a stored secret — headers stay in mcp.json. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- README.md | 3 +- docs/docker.md | 35 +++------- docs/environment-variables.md | 4 +- docs/mcp-server-configuration.md | 2 +- docs/secret-storage.md | 111 +++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 31 deletions(-) create mode 100644 docs/secret-storage.md diff --git a/README.md b/README.md index 4543a2f3db..db59ab69e1 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,8 @@ Each client has its own README with client-specific detail: | [Writing a skill](./docs/skill-authoring.md) | How to write a skill description that actually fires, and eval cases that measure it — the case shapes that work, and the tuning loop | | [Test servers](./docs/test-servers.md) | The composable test servers and the showcase config for every feature — what to run, what to click, and what the broken build did | | [Publishing](./docs/publishing.md) | What ships in the tarball, the packaging invariants, and `pack:verify` | -| [Docker](./docs/docker.md) | Running the container image — ports, volumes, and where secrets go | +| [Docker](./docs/docker.md) | Running the container image — ports, volumes, and making secrets durable in a container | +| [Where secrets are stored](./docs/secret-storage.md) | How the secret store is chosen on every runtime — OS keychain, `secrets.json` or memory — plus file encryption, locking, and moving back to a keychain | | [Migrating from v1 to v2](./docs/v1-to-v2-migration.md) | CLI flag mapping, `--config` vs. `--catalog`, the Node engine bump, env-var renames | | [Environment variables](./docs/environment-variables.md) | Every variable that changes runtime behavior — auth, ports, storage, the secret store, logging, proxies — plus the Node TLS variables for a self-signed server | | [MCP server configuration](./docs/mcp-server-configuration.md) | Which server(s) the Inspector connects to, and the config file format | diff --git a/docs/docker.md b/docs/docker.md index 5b06c0f543..745f7faaaa 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -42,17 +42,16 @@ docker run --rm -p 127.0.0.1:6274:6274 \ The same volume also persists OAuth tokens and stored state, so an authorized server stays authorized across runs. Use `-e MCP_CATALOG_PATH=/some/other/path.json` to put the catalog somewhere else — mount a volume covering whatever directory you point it at. If you **bind-mount a host directory** instead of a named volume (`-v "$PWD/inspector-data:/home/node/.mcp-inspector"`), the directory keeps its host ownership, so on Linux add `--user "$(id -u):$(id -g)"` or `chown` it to uid `1000` — otherwise the non-root `node` user can't write and adding a server fails with `EACCES`. -**Where secrets go, and how to make them survive (#1950).** The Inspector keeps the values it deliberately does _not_ write to `mcp.json` — an OAuth client secret, an enterprise IdP client secret, each stdio `env:` value — in the **OS keychain**. A container has no keychain (the published image has no D-Bus session), so on startup the Inspector probes for one and falls back, saying so in the logs and in a permanent footer at the bottom of the Client Settings and Server Settings dialogs. Which fallback you get depends on whether the directory it would write to is going to survive: +**Where secrets go, and how to make them survive (#1950).** The Inspector keeps an OAuth client secret, an enterprise IdP client secret and each stdio `env:` value out of `mcp.json`, in the OS keychain. A container has no keychain (the published image has no D-Bus session), so it falls back, and which fallback you get depends on whether the secrets directory is going to survive: -| Situation | Store | Secrets survive a restart? | -| -------------------------------------------------------------- | -------------------------------------------- | -------------------------- | -| Keychain reachable (a normal desktop install) | OS keychain | Yes | -| Container, **no volume** on `/home/node/.mcp-inspector` | Memory | No — session only | -| Container **with** that volume, or any host without a keychain | `~/.mcp-inspector/secrets.json`, mode `0600` | Yes | +| Situation | Store | Secrets survive a restart? | +| ------------------------------------------------------- | -------------------------------------------- | -------------------------- | +| **No volume** on `/home/node/.mcp-inspector` | Memory | No — session only | +| **With** that volume | `~/.mcp-inspector/secrets.json`, mode `0600` | Yes | -So the same volume that keeps your server list also switches secrets from session-scoped to durable — nothing extra to configure. The in-memory default for an unmounted container is deliberate: a file in the writable layer is discarded by `--rm` and by every image update, and promising durability it can't deliver is worse than declining to. +So the same volume that keeps your server list also switches secrets from session-scoped to durable — nothing extra to configure. The in-memory default for an unmounted container is deliberate: a file in the writable layer is discarded by `--rm` and by every image update, and promising durability it can't deliver is worse than declining to. If you relocate storage with `-e MCP_STORAGE_DIR=…` or `-e MCP_INSPECTOR_SECRET_FILE=…`, mount the volume at that directory instead. -**A file-backed store is unencrypted unless you give it a key.** Set `MCP_INSPECTOR_SECRET_KEY` and the file is encrypted with AES-256-GCM (the passphrase is stretched with scrypt against a per-file random salt). Without it the file is still `0600`, but the values are readable to anyone who can read the file — which the startup log and the settings footer both say, every session, in a warning tone: +The file is **unencrypted unless you give it a key**. Pass a generated, high-entropy passphrase with `-e`: ```bash docker run --rm -p 127.0.0.1:6274:6274 \ @@ -61,25 +60,7 @@ docker run --rm -p 127.0.0.1:6274:6274 \ ghcr.io/modelcontextprotocol/inspector ``` -**Use a high-entropy passphrase — generated, not chosen.** The random salt stops an attacker precomputing a table across files; it does nothing against _guessing_, and the scrypt cost is deliberately low because the derivation runs on every read and write. Anyone who obtains `secrets.json` can therefore test candidate passphrases quickly and offline, so treat this value like any other credential rather than like a memorable password. - -Setting the passphrase later is safe — the next write upgrades an existing plaintext file in place. Until that write happens the existing values really are still readable, and the banner and footer keep saying so rather than reporting the file as encrypted the moment the variable appears. **Changing or losing the passphrase is not safe**: a file that can no longer be decrypted is read as empty and _refuses to be written_, rather than being silently replaced with a new one holding only your latest secret. Restore the original passphrase, or delete `secrets.json` and re-enter the values. - -The Inspector writes the file `0600` and re-tightens it at startup if something loosened it. If it _cannot_ — the file belongs to another user, or the mount is read-only — it says so in the log rather than continuing to describe the file as protected, since on that box the mode claim above is not true. - -**Two Inspectors, one file.** Within a process, mutations are serialized per file path, so a web session's own concurrent saves cannot lose each other. Across processes — a CLI run beside a web session — each mutation takes an exclusive lock on `secrets.json.lock` for the whole read-modify-write, using [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) (the same library npm itself locks with). The lock expires 10 seconds after its holder stops refreshing it, so an Inspector that is killed mid-save does not leave the file unwritable. - -Two running Inspectors are therefore genuinely serialized. What a lock file cannot make single-winner is the *takeover of a lock whose holder died* — that needs a compare-and-swap on a directory entry (`renameat2`) which Node does not expose, and it is what an earlier hand-rolled attempt failed three review rounds on. `proper-lockfile` does not close that race either. The window opens only after a holder dies without releasing. - -The Inspector adds one thing on top: every lock-directory removal the library makes on its behalf — on release, and from its exit handler — is guarded by a check that the directory is still the one it created (by inode and birth time, which survive the library's own refresh but not a delete-and-recreate). That matters because those removals are otherwise unconditional, so a holder whose lock had been replaced would delete the *winner's* lock on the way out, turning one compromised writer into two unprotected ones. It also surfaces the takeover as a warning. Treat all of this as **best-effort**: the guard is still a check followed by an act, so it makes the destructive case rare rather than impossible, and it rests on filesystem metadata that not every filesystem reports. - -Which is why, underneath the lock, each mutation still reads the file, applies its change, writes, then reads back and compares the whole map; if something wrote in between it re-applies onto what was left and retries, failing loudly after five lost rounds rather than returning as though the value were saved. That check is what still catches a clobber inside that window — and it covers what no lock can, since a lock only orders the writers that *take* it: an editor, a restored backup, or an Inspector older than this release. - -If another process holds the lock and will not let go, the save **fails** rather than going ahead unlocked — waiting past the stale window first, so a crashed Inspector resolves itself rather than failing everyone else's saves. Writing alongside a writer you can see is the one case where degrading would lose the secret it was trying to protect. - -It is also what covers the lock being unavailable. This store exists for boxes where the usual mechanism isn't there, so a directory that can't hold a lock file — a read-only `$HOME`, a mount owned by another uid — makes the save proceed unlocked with a warning, rather than turning every `set` into a failure on exactly the deployments the store was written for. - -Three env vars affect where the file lands. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, bypassing the probe. `MCP_INSPECTOR_SECRET_FILE` names the file. Failing both, the file follows `MCP_STORAGE_DIR` — the same variable that relocates OAuth tokens and `client.json` — so mounting a volume at your configured storage directory is enough to make secrets durable there. These variables apply outside a container too; every runtime variable is listed in [Environment variables](./environment-variables.md). +⚠️ Keep passing the **same** passphrase on every run: a file that can no longer be decrypted is read as empty and refuses to be written. Everything else about the store — the selection order, the file's location, encryption, permissions, locking, and choosing a store explicitly with `MCP_INSPECTOR_SECRET_STORE` — applies to every runtime and is in [Where secrets are stored](./secret-storage.md). **Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once: diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 4fd0b70cf5..7648770814 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -71,13 +71,13 @@ Every default above that starts with `~` is built from the home directory the pr ## Secret store -Where server secrets (headers, client secrets) are kept. The details — the keychain probe, the file format, encryption and locking — are in the [Docker guide](./docker.md); these variables apply to every install, not only containers. +Where server secrets (OAuth client secrets, the enterprise IdP client secret, stdio `env:` values) are kept. How the store is chosen, and the details of the file store — its location, encryption, permissions and locking — are in [Where secrets are stored](./secret-storage.md); these variables apply to every install, not only containers. | Variable | Read by | Default | Effect | | ---------------------------- | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. An empty or whitespace-only value counts as unset and silently runs automatic selection; any other value is ignored with a warning and also falls back to automatic selection. | | `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `~/.mcp-inspector/secrets.json` | Path of the file store. Lookup order: this variable, then `secrets.json` in `MCP_STORAGE_DIR` when that is set, then `~/.mcp-inspector/secrets.json`. ⚠️ The default sits **beside** the storage directory, not inside it. | -| `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store; an empty or whitespace-only value counts as unset. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see the Docker guide before rotating it. | +| `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store; an empty or whitespace-only value counts as unset. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see [Where secrets are stored](./secret-storage.md#encryption) before rotating it. | When no store is configured, the choice also depends on whether the Inspector is running in a container, which it detects from `KUBERNETES_SERVICE_HOST` (or Docker's and Podman's marker files). That variable is set by the orchestrator, not by you. diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index cdfc1690bd..3a9c5add14 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -281,7 +281,7 @@ A tool that consumes the file should: - **Treat it as read-only.** Don't rewrite it, and don't convert it into another format as a copy that users then maintain. The Inspector owns what it writes back — it omits fields equal to their defaults and upgrades older shapes (such as the pair-array `metadata`) on save — so a second writer drifts from it. - **Preserve stdio argument boundaries.** `command` and each `args` element are separate argv entries. Spawn them directly rather than joining them into a string for a shell, which re-splits on whitespace and interprets quoting, globs and metacharacters. Keep `cwd` and the `env` key set as given. - **Decide on unknown fields explicitly.** Either honor an Inspector-specific field, or reject the entry naming the field you don't support. Silently ignoring one can change behavior — `protocolEra`, `headers` or `oauth` alter what connects and how. -- **Expect secrets to be absent, and supply them itself.** When the Inspector saves an entry to a durable secret store (the OS keychain, or `~/.mcp-inspector/secrets.json` — see [Where secrets go](./docker.md)) it moves two kinds of value out of this file — each stdio `env` value and `oauth.clientSecret` — leaving each `env` key in place with an empty value and the client secret omitted. Under the session-only `memory` store it keeps plaintext that was already on disk, so `mcp.json` stays the durable copy of those values rather than a store that is lost on exit, while new or changed values still stay out of it; the same file can therefore hold a mix of placeholders and real values. Nothing else is stripped: `headers` are saved as written, so they can still hold a credential. That store is not part of the file's interface, so a file-only reader sees `"API_KEY": ""` and cannot tell an intentionally empty value from a stored one. Inject those values from the tool's own secret source, or reject the entry naming the missing key — don't launch the server with the empty placeholders. +- **Expect secrets to be absent, and supply them itself.** When the Inspector saves an entry to a durable secret store (the OS keychain, or `~/.mcp-inspector/secrets.json` — see [Where secrets are stored](./secret-storage.md)) it moves two kinds of value out of this file — each stdio `env` value and `oauth.clientSecret` — leaving each `env` key in place with an empty value and the client secret omitted. Under the session-only `memory` store it keeps plaintext that was already on disk, so `mcp.json` stays the durable copy of those values rather than a store that is lost on exit, while new or changed values still stay out of it; the same file can therefore hold a mix of placeholders and real values. Nothing else is stripped: `headers` are saved as written, so they can still hold a credential. That store is not part of the file's interface, so a file-only reader sees `"API_KEY": ""` and cannot tell an intentionally empty value from a stored one. Inject those values from the tool's own secret source, or reject the entry naming the missing key — don't launch the server with the empty placeholders. - **Keep credential values out of its output.** A hand-written or imported file can still carry plaintext in `env` or `oauth.clientSecret`, and any saved `headers` value may be one. Don't copy those values into logs, reports, evidence bundles or generated files; key names are usually enough. - **Describe its own scope without implying endorsement.** Which fields and Inspector versions it supports is that tool's claim to document; reading this file does not make it Inspector- or MCP-certified. diff --git a/docs/secret-storage.md b/docs/secret-storage.md new file mode 100644 index 0000000000..ed19dc1aba --- /dev/null +++ b/docs/secret-storage.md @@ -0,0 +1,111 @@ +# Where secrets are stored + +The Inspector keeps a few values out of `mcp.json` and `client.json` and puts them in a **secret store** instead. This guide explains which store you get, why, where it lives, and how to change it. It applies to every runtime: a desktop install, a Linux server or SSH session, Android/Termux, and a container. For the container-specific parts (volumes, ownership), also read the [Docker guide](./docker.md). + +## What counts as a secret + +Three kinds of value are stored as secrets: + +| Value | Saved from | +| --------------------------------- | -------------------------------------- | +| A server's OAuth client secret | The server's OAuth settings | +| The enterprise IdP client secret | Client Settings (install-level) | +| Each stdio server's `env:` value | A stdio server's environment variables | + +They are kept out of `mcp.json` so that sharing, committing or syncing the file does not leak credentials (#1356). When the Inspector saves an entry to a durable store, it leaves each `env` key in `mcp.json` with an empty value and omits the client secret; the real values live in the store. `headers` are **not** moved: they are saved in `mcp.json` exactly as written, so a header that carries a credential stays in the file. [MCP server configuration](./mcp-server-configuration.md) describes what that means for other tools reading the same file. + +## How the store is chosen + +At startup every client (web, CLI and TUI) picks one store, in this order: + +1. **`MCP_INSPECTOR_SECRET_STORE`**, if it is `keyring`, `file` or `memory` (case-insensitive). That store is used and nothing is probed. An empty value counts as unset. Any other value is ignored with a warning, and selection continues as if it were unset. +2. **The OS keychain**, if a probe can reach it: Keychain on macOS, Credential Manager on Windows, and the Secret Service (libsecret, for example GNOME Keyring or KWallet) on Linux. Entries are stored under the service name `mcp-inspector`. Most desktop installs stop here. +3. **A fallback**, when the probe fails. The Inspector says so on startup and names the store it picked (see [Where the active store is reported](#where-the-active-store-is-reported)): + - `memory` if it is running in a container **and** the directory the secrets file would go in is not on a mounted volume, because a file in a container's writable layer is lost on `docker run --rm` and on every image update; + - `file` everywhere else. + +| Where you run it | Store | Secrets survive a restart? | +| ----------------------------------------------------------------------- | ----------------------------------- | -------------------------- | +| Desktop macOS or Windows, or Linux with a Secret Service running | OS keychain | Yes | +| Linux without libsecret or a Secret Service | File (`secrets.json`, mode `0600`) | Yes | +| Headless server or SSH session with no D-Bus session | File | Yes | +| Android/Termux | File | Yes | +| Container with **no volume** on the secrets directory | Memory | No, this session only | +| Container **with** a volume on the secrets directory | File | Yes | +| Any of the above with `MCP_INSPECTOR_SECRET_STORE` set | The store you named | Unless you named `memory` | + +The Inspector decides that it is in a container from `KUBERNETES_SERVICE_HOST`, Docker's `/.dockerenv`, Podman's `/run/.containerenv`, or the process's cgroup. The container check only chooses between `memory` and `file`; the mount check is what actually decides. + +The choice is made once per process. Installing a keychain while the Inspector is running takes effect on the next start. + +### The memory store + +`memory` keeps secrets for this process only; nothing is written anywhere and they are gone when it exits. Because it is not durable, the Inspector does **not** remove plaintext values that are already in `mcp.json` or `client.json` while it is active: in that case the file on disk is still the durable copy. New or changed values are still kept out of the file. + +## The file store + +### Where the file is + +The path is the first of these that applies: + +1. `MCP_INSPECTOR_SECRET_FILE`, if set; +2. `secrets.json` inside `MCP_STORAGE_DIR`, if that is set; +3. `~/.mcp-inspector/secrets.json`. + +⚠️ The default sits **beside** the default storage directory (`~/.mcp-inspector/storage`), not inside it. Setting `MCP_STORAGE_DIR` moves the secrets file together with the OAuth state and `client.json`. + +### Encryption + +**A file store is unencrypted unless you give it a passphrase.** Set `MCP_INSPECTOR_SECRET_KEY` and the file is encrypted with AES-256-GCM, with the passphrase stretched by scrypt against a per-file random salt. Without it, the file is still mode `0600`, but anyone who can read the file can read the values. The startup log and the settings footer say so every session, as a warning. + +**Use a high-entropy passphrase: generate it, don't choose it.** The random salt stops an attacker from precomputing a table across files, but it does nothing against guessing. The scrypt cost is deliberately low because the derivation runs on every read and write. Anyone who obtains `secrets.json` can therefore test candidate passphrases quickly and offline, so treat this value like any other credential, not like a memorable password. + +**Adding a passphrase later is safe.** The next write upgrades an existing plaintext file in place. Until that write happens the existing values are still readable, and the banner and footer keep saying so. They do not report the file as encrypted just because the variable is now set. + +**Changing or losing the passphrase is not safe.** A file that can no longer be decrypted is read as empty, and the Inspector **refuses to write to it** rather than replacing it with a new file that holds only your latest secret. To recover, restore the original passphrase, or delete `secrets.json` and enter the values again. + +### Permissions + +The Inspector writes the file with mode `0600` and tightens it again at startup if something loosened it. If it _cannot_ tighten it (the file belongs to another user, or the mount is read-only), it says so in the log and the footer instead of continuing to describe the file as protected. + +### Two Inspectors, one file + +Within a process, changes are serialized per file path, so a web session's own concurrent saves cannot overwrite each other. Across processes, for example a CLI run next to a web session, each change takes an exclusive lock on `secrets.json.lock` for the whole read-modify-write. The lock uses [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile), the same library npm uses for its own locks. The lock expires 10 seconds after its holder stops refreshing it, so an Inspector that is killed mid-save does not leave the file unwritable. + +So two running Inspectors are genuinely serialized. What a lock file cannot make single-winner is the _takeover of a lock whose holder died_. That needs a compare-and-swap on a directory entry (`renameat2`), which Node does not expose, and `proper-lockfile` does not close that race either. The window only opens after a holder dies without releasing its lock. + +The Inspector adds one check on top. Every lock-directory removal the library makes on its behalf, on release and from its exit handler, first checks that the directory is still the one it created (by inode and birth time, which survive the library's own refresh but not a delete-and-recreate). Without that check the removals are unconditional, so a holder whose lock had been replaced would delete the _winner's_ lock on the way out, turning one compromised writer into two unprotected ones. The check also reports the takeover as a warning. Treat all of this as **best-effort**: the check is still followed by a separate act, so it makes the destructive case rare rather than impossible, and it relies on filesystem metadata that not every filesystem reports. + +That is why, under the lock, each change still reads the file, applies the change, writes, then reads back and compares the whole map. If something wrote in between, it re-applies the change to what is there now and retries, and it fails loudly after five lost rounds instead of reporting the value as saved. That check catches a clobber inside the takeover window. It also covers writers that no lock can order, because a lock only orders the writers that _take_ it: an editor, a restored backup, or an older Inspector. + +If another process holds the lock and does not release it, the save **fails** rather than going ahead unlocked. It waits past the stale window first, so a crashed Inspector clears itself instead of failing everyone else's saves. When there is another writer you can see, writing anyway is the one case where continuing would lose the secret the save was meant to protect. + +The same read-back check covers a lock that cannot be taken at all. The file store exists for machines where the usual mechanism is missing, so when a directory cannot hold a lock file (a read-only `$HOME`, or a mount owned by another uid), the save goes ahead unlocked with a warning. Otherwise every save would fail on exactly the setups this store was written for. + +## Getting a keychain back + +If you install libsecret (or start a Secret Service) on a machine that was using the file store, the next start probes successfully, selects the keychain, and **moves the contents of `secrets.json` into it**: + +- **The keychain wins on conflict.** A value already in the keychain is kept; the file's value is treated as the older copy. +- **The file is removed only when every value was copied.** If the hand-off is partial, the file is left as it was and the next start tries again. +- **An unreadable file is not deleted.** If the file cannot be decrypted (the passphrase changed or is now unset), the Inspector reports it and leaves the file in place. + +A successful move prints a message naming the file it removed. The same hand-off runs when you select the keychain explicitly with `MCP_INSPECTOR_SECRET_STORE=keyring`. It does not run in the other direction: choosing `file` or `memory` does not copy anything out of the keychain. + +## Where the active store is reported + +- **On startup**, every client prints a warning on stderr when it falls back from the keychain, including the keychain error, and another when the file is unencrypted, has loose permissions, or cannot be read. The web client's startup banner also has a `Secrets:` line on every run. +- **`GET /api/config`** (web) includes a `secretStorage` object describing the active store. +- **In the web UI**, a footer at the bottom of the **Client Settings**, **Server Settings** and **Add / Edit / Clone server** dialogs names the store, and turns into a warning when it is memory-only, unencrypted, loosely permissioned, or unreadable. It is shown where you type a secret, not only once at startup. + +## Changing the store + +| To | Set | +| ------------------------------------------ | -------------------------------------------------------------------- | +| Always use the keychain | `MCP_INSPECTOR_SECRET_STORE=keyring` | +| Use a file even though a keychain exists | `MCP_INSPECTOR_SECRET_STORE=file` | +| Never write secrets to disk | `MCP_INSPECTOR_SECRET_STORE=memory` | +| Put the file somewhere else | `MCP_INSPECTOR_SECRET_FILE=/path/to/secrets.json`, or `MCP_STORAGE_DIR` | +| Encrypt the file | `MCP_INSPECTOR_SECRET_KEY=` | + +Every variable is also listed in [Environment variables](./environment-variables.md#secret-store). From c0c2a20bb63295ce5b3ab0aa4a01d993a83c7f18 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 20:29:06 -0400 Subject: [PATCH 53/68] chore: add the project LICENSE and point every manifest at it (#2406) v2 shipped with no LICENSE file anywhere in the repository or the npm tarball, while four manifests declared plain "MIT". Restore the state v1/main is in (#1017, #1036): the root LICENSE carries the MCP licensing-transition notice with the Apache-2.0, MIT and CC-BY-4.0 terms, the published manifest says "SEE LICENSE IN LICENSE", and the private client manifests point at the root file. README's License section now describes the file instead of saying "MIT." npm always packs a root LICENSE regardless of "files", so it lands in the tarball (verified with npm pack --dry-run). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- LICENSE | 216 +++++++++++++++++++++++++++++ README.md | 2 +- clients/cli/package-lock.json | 2 +- clients/cli/package.json | 2 +- clients/launcher/package-lock.json | 2 +- clients/launcher/package.json | 2 +- clients/tui/package-lock.json | 2 +- clients/tui/package.json | 2 +- clients/web/package-lock.json | 1 + clients/web/package.json | 1 + package-lock.json | 2 +- package.json | 2 +- 12 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..4a93985763 --- /dev/null +++ b/LICENSE @@ -0,0 +1,216 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/README.md b/README.md index 4543a2f3db..69d32dbfb9 100644 --- a/README.md +++ b/README.md @@ -107,4 +107,4 @@ A key rule worth surfacing here: **all work is issue-driven.** Before starting, ## License -MIT. +See [`LICENSE`](./LICENSE). The MCP project is transitioning from the MIT License to Apache-2.0: new code contributions are licensed under Apache-2.0, documentation (excluding specifications) under CC-BY-4.0, and contributions whose authors originally licensed them under MIT and have not granted relicensing consent remain under MIT. The file carries the full Apache-2.0 and MIT texts and links the CC-BY-4.0 legal code. diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index a8e46a61ee..9d514f2812 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@modelcontextprotocol/inspector-cli", - "license": "MIT", + "license": "SEE LICENSE IN ../../LICENSE", "bin": { "mcp-inspector-cli": "build/index.js" }, diff --git a/clients/cli/package.json b/clients/cli/package.json index 5bcf04cd9c..79ef02d32b 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/inspector-cli", "private": true, "description": "CLI for the Model Context Protocol inspector", - "license": "MIT", + "license": "SEE LICENSE IN ../../LICENSE", "type": "module", "main": "build/index.js", "exports": { diff --git a/clients/launcher/package-lock.json b/clients/launcher/package-lock.json index 555e8caf2c..cf98415272 100644 --- a/clients/launcher/package-lock.json +++ b/clients/launcher/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@modelcontextprotocol/inspector-launcher", - "license": "MIT", + "license": "SEE LICENSE IN ../../LICENSE", "bin": { "mcp-inspector": "build/index.js" } diff --git a/clients/launcher/package.json b/clients/launcher/package.json index 4ea7560f20..9b5781b587 100644 --- a/clients/launcher/package.json +++ b/clients/launcher/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/inspector-launcher", "private": true, "description": "Launcher for MCP Inspector (web, CLI, TUI)", - "license": "MIT", + "license": "SEE LICENSE IN ../../LICENSE", "type": "module", "main": "build/index.js", "bin": { diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index d4907b518a..6f92f76913 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@modelcontextprotocol/inspector-tui", - "license": "MIT", + "license": "SEE LICENSE IN ../../LICENSE", "dependencies": { "ink": "^6.0.0", "ink-form": "^2.0.1", diff --git a/clients/tui/package.json b/clients/tui/package.json index 42f8571e0f..81c6ddb922 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/inspector-tui", "private": true, "description": "Terminal User Interface (TUI) for the Model Context Protocol inspector", - "license": "MIT", + "license": "SEE LICENSE IN ../../LICENSE", "type": "module", "main": "build/index.js", "exports": { diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index c666dc79a9..9f7affb97d 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "name": "@modelcontextprotocol/inspector-web", + "license": "SEE LICENSE IN ../../LICENSE", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^8.0.0", diff --git a/clients/web/package.json b/clients/web/package.json index 4c8cb79571..1c84c32503 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -1,6 +1,7 @@ { "name": "@modelcontextprotocol/inspector-web", "private": true, + "license": "SEE LICENSE IN ../../LICENSE", "type": "module", "main": "build/index.js", "bin": { diff --git a/package-lock.json b/package-lock.json index 65ffe271e9..641a95b421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@modelcontextprotocol/inspector", "version": "2.7.0", "hasInstallScript": true, - "license": "MIT", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@hono/node-server": "^2.0.12", "@modelcontextprotocol/client": "2.0.0", diff --git a/package.json b/package.json index 15423643b0..294ce33425 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "type": "git", "url": "git+https://github.com/modelcontextprotocol/inspector.git" }, - "license": "MIT", + "license": "SEE LICENSE IN LICENSE", "author": "The MCP Maintainers and Community", "type": "module", "bin": { From 14cde9e93898c6f5803677c382485402eadc56af Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 20:33:43 -0400 Subject: [PATCH 54/68] docs: address Copilot review round 1 on secret-storage guide (#2448) Selection is lazy for CLI/TUI; whitespace-only store values are unset; MCP_STORAGE_DIR moves client.json only for web; the salt is per write; docker.md says to mount the secrets file's parent directory and not the file alone; publishing.md and environment-variables.md wording. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- docs/docker.md | 2 +- docs/environment-variables.md | 2 +- docs/publishing.md | 6 ++++-- docs/secret-storage.md | 18 +++++++++--------- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/docker.md b/docs/docker.md index 745f7faaaa..56bb91970b 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -49,7 +49,7 @@ The same volume also persists OAuth tokens and stored state, so an authorized se | **No volume** on `/home/node/.mcp-inspector` | Memory | No — session only | | **With** that volume | `~/.mcp-inspector/secrets.json`, mode `0600` | Yes | -So the same volume that keeps your server list also switches secrets from session-scoped to durable — nothing extra to configure. The in-memory default for an unmounted container is deliberate: a file in the writable layer is discarded by `--rm` and by every image update, and promising durability it can't deliver is worse than declining to. If you relocate storage with `-e MCP_STORAGE_DIR=…` or `-e MCP_INSPECTOR_SECRET_FILE=…`, mount the volume at that directory instead. +So the same volume that keeps your server list also switches secrets from session-scoped to durable — nothing extra to configure. The in-memory default for an unmounted container is deliberate: a file in the writable layer is discarded by `--rm` and by every image update, and promising durability it can't deliver is worse than declining to. The check looks at the **directory that holds the secrets file**, so if you relocate it with `-e MCP_STORAGE_DIR=…` or `-e MCP_INSPECTOR_SECRET_FILE=…`, mount a volume at that file's parent directory. Don't bind-mount the file on its own: it is not recognized as durable, so you get the memory store, and even with `-e MCP_INSPECTOR_SECRET_STORE=file` it cannot be written, because every save replaces the file by renaming a temporary file over it. The file is **unencrypted unless you give it a key**. Pass a generated, high-entropy passphrase with `-e`: diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 7648770814..44d247c185 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -71,7 +71,7 @@ Every default above that starts with `~` is built from the home directory the pr ## Secret store -Where server secrets (OAuth client secrets, the enterprise IdP client secret, stdio `env:` values) are kept. How the store is chosen, and the details of the file store — its location, encryption, permissions and locking — are in [Where secrets are stored](./secret-storage.md); these variables apply to every install, not only containers. +Where the Inspector's secrets (OAuth client secrets, the enterprise IdP client secret, stdio `env:` values) are kept. How the store is chosen, and the details of the file store — its location, encryption, permissions and locking — are in [Where secrets are stored](./secret-storage.md); these variables apply to every install, not only containers. | Variable | Read by | Default | Effect | | ---------------------------- | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/publishing.md b/docs/publishing.md index bad47b7f7c..452d55b863 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -31,5 +31,7 @@ job or the coverage gate red. ## Docker -The container image and everything about running it — ports, volumes, where -secrets go — is in [Running the Inspector in Docker](./docker.md). +The container image and everything about running it — ports, volumes, making +secrets durable — is in [Running the Inspector in Docker](./docker.md). How the +secret store is chosen on every runtime is in +[Where secrets are stored](./secret-storage.md). diff --git a/docs/secret-storage.md b/docs/secret-storage.md index ed19dc1aba..dafe1b5afa 100644 --- a/docs/secret-storage.md +++ b/docs/secret-storage.md @@ -16,11 +16,11 @@ They are kept out of `mcp.json` so that sharing, committing or syncing the file ## How the store is chosen -At startup every client (web, CLI and TUI) picks one store, in this order: +Each process picks one store, once, the first time it needs it: the web backend at startup, the CLI and TUI on their first access to a secret. Every client (web, CLI and TUI) goes through the same selection, in this order: -1. **`MCP_INSPECTOR_SECRET_STORE`**, if it is `keyring`, `file` or `memory` (case-insensitive). That store is used and nothing is probed. An empty value counts as unset. Any other value is ignored with a warning, and selection continues as if it were unset. +1. **`MCP_INSPECTOR_SECRET_STORE`**, if it is `keyring`, `file` or `memory` (case-insensitive). That store is used and nothing is probed. An empty or whitespace-only value counts as unset. Any other value is ignored with a warning, and selection continues as if it were unset. 2. **The OS keychain**, if a probe can reach it: Keychain on macOS, Credential Manager on Windows, and the Secret Service (libsecret, for example GNOME Keyring or KWallet) on Linux. Entries are stored under the service name `mcp-inspector`. Most desktop installs stop here. -3. **A fallback**, when the probe fails. The Inspector says so on startup and names the store it picked (see [Where the active store is reported](#where-the-active-store-is-reported)): +3. **A fallback**, when the probe fails. The Inspector says so on stderr when it selects the store, and names the store it picked (see [Where the active store is reported](#where-the-active-store-is-reported)): - `memory` if it is running in a container **and** the directory the secrets file would go in is not on a mounted volume, because a file in a container's writable layer is lost on `docker run --rm` and on every image update; - `file` everywhere else. @@ -32,7 +32,7 @@ At startup every client (web, CLI and TUI) picks one store, in this order: | Android/Termux | File | Yes | | Container with **no volume** on the secrets directory | Memory | No, this session only | | Container **with** a volume on the secrets directory | File | Yes | -| Any of the above with `MCP_INSPECTOR_SECRET_STORE` set | The store you named | Unless you named `memory` | +| Any of the above with `MCP_INSPECTOR_SECRET_STORE` set | The store you named | Not with `memory`; with `file` in a container, only if the file is on a volume | The Inspector decides that it is in a container from `KUBERNETES_SERVICE_HOST`, Docker's `/.dockerenv`, Podman's `/run/.containerenv`, or the process's cgroup. The container check only chooses between `memory` and `file`; the mount check is what actually decides. @@ -52,13 +52,13 @@ The path is the first of these that applies: 2. `secrets.json` inside `MCP_STORAGE_DIR`, if that is set; 3. `~/.mcp-inspector/secrets.json`. -⚠️ The default sits **beside** the default storage directory (`~/.mcp-inspector/storage`), not inside it. Setting `MCP_STORAGE_DIR` moves the secrets file together with the OAuth state and `client.json`. +⚠️ The default sits **beside** the default storage directory (`~/.mcp-inspector/storage`), not inside it. Setting `MCP_STORAGE_DIR` moves the secrets file together with the OAuth state (`oauth.json`), for every client. It also moves `client.json` for the **web** backend only; the CLI and TUI find `client.json` through `MCP_CLIENT_CONFIG_PATH` instead. ### Encryption -**A file store is unencrypted unless you give it a passphrase.** Set `MCP_INSPECTOR_SECRET_KEY` and the file is encrypted with AES-256-GCM, with the passphrase stretched by scrypt against a per-file random salt. Without it, the file is still mode `0600`, but anyone who can read the file can read the values. The startup log and the settings footer say so every session, as a warning. +**A file store is unencrypted unless you give it a passphrase.** Set `MCP_INSPECTOR_SECRET_KEY` and the file is encrypted with AES-256-GCM, with the passphrase stretched by scrypt against a random salt that is regenerated on every write. Without it, the file is still mode `0600`, but anyone who can read the file can read the values. The startup log and the settings footer say so every session, as a warning. -**Use a high-entropy passphrase: generate it, don't choose it.** The random salt stops an attacker from precomputing a table across files, but it does nothing against guessing. The scrypt cost is deliberately low because the derivation runs on every read and write. Anyone who obtains `secrets.json` can therefore test candidate passphrases quickly and offline, so treat this value like any other credential, not like a memorable password. +**Use a high-entropy passphrase: generate it, don't choose it.** The random salt stops an attacker from precomputing a table, but it does nothing against guessing. The scrypt cost is deliberately low because the derivation runs on every read and write. Anyone who obtains `secrets.json` can therefore test candidate passphrases quickly and offline, so treat this value like any other credential, not like a memorable password. **Adding a passphrase later is safe.** The next write upgrades an existing plaintext file in place. Until that write happens the existing values are still readable, and the banner and footer keep saying so. They do not report the file as encrypted just because the variable is now set. @@ -66,7 +66,7 @@ The path is the first of these that applies: ### Permissions -The Inspector writes the file with mode `0600` and tightens it again at startup if something loosened it. If it _cannot_ tighten it (the file belongs to another user, or the mount is read-only), it says so in the log and the footer instead of continuing to describe the file as protected. +The Inspector writes the file with mode `0600` and tightens it again when the store is selected if something loosened it. If it _cannot_ tighten it (the file belongs to another user, or the mount is read-only), it says so in the log and the footer instead of continuing to describe the file as protected. ### Two Inspectors, one file @@ -94,7 +94,7 @@ A successful move prints a message naming the file it removed. The same hand-off ## Where the active store is reported -- **On startup**, every client prints a warning on stderr when it falls back from the keychain, including the keychain error, and another when the file is unencrypted, has loose permissions, or cannot be read. The web client's startup banner also has a `Secrets:` line on every run. +- **When the store is selected** (at startup for the web backend, on first use for the CLI and TUI), every client prints a warning on stderr if it falls back from the keychain, including the keychain error, and another if the file is unencrypted, has loose permissions, or cannot be read. The web client's startup banner also has a `Secrets:` line on every run. - **`GET /api/config`** (web) includes a `secretStorage` object describing the active store. - **In the web UI**, a footer at the bottom of the **Client Settings**, **Server Settings** and **Add / Edit / Clone server** dialogs names the store, and turns into a warning when it is memory-only, unencrypted, loosely permissioned, or unreadable. It is shown where you type a secret, not only once at startup. From ccc831c795d21a1e6d817eaf33830fd6cb2e5a88 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 20:41:07 -0400 Subject: [PATCH 55/68] docs: address Copilot review round 8 on #2444 Parameterize the GHSA id in board-ops' draft-card lookup, which matched the literal placeholder, and fail loudly when nothing matches. Make accepting the advisory the approval act that moves a GHSA draft Incoming -> Todo, and record it as an exemption to the Incoming <=> milestone invariant in AGENTS.md and in the audit notes, since a draft card cannot carry a milestone. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 4 +++- .claude/skills/issue-triage/SKILL.md | 7 ++++++- .claude/skills/security-advisory/SKILL.md | 7 +++++++ AGENTS.md | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index 4eb7bd2d5a..3a863334e5 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -40,9 +40,11 @@ Look it up by **title** instead, then feed that item id to `item-edit` or `item-delete` exactly as usual: ```sh +GHSA=GHSA-xxxx-yyyy-zzzz # the advisory's real id ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ --jq '.items[] | select(.content.type=="DraftIssue") - | select(.content.title | startswith("[GHSA-xxxx-yyyy-zzzz]")) | .id') + | select(.content.title | startswith("['"$GHSA"']")) | .id') +[ -n "$ITEM_ID" ] || echo "no draft card titled [$GHSA] on #28" >&2 ``` Match on the **bracketed GHSA id**, not on words from the summary — a summary is diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index ba7f02f52d..0fa0f112fd 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -295,7 +295,12 @@ Two things the queries must account for, both learned the hard way: - **The two milestone checks are #28-only.** Every milestone in this repo is a v2 release bucket, so a `v1` issue has none it could take — running the Incoming⇔milestone invariant over board #11 would flag every card on it for a - state it cannot reach. + state it cannot reach. They also read **Issue items only**, which is what + exempts a `[GHSA-` advisory draft on #28: it cannot carry a milestone, and its + approval is the advisory's **acceptance** (`/security-advisory` step 3), + which lives on the advisory rather than the board. The audit cannot see that, + so a GHSA draft past `Incoming` is correct once its advisory is accepted and + is not reported. - **Count the labels; don't test for presence.** The invariant is *exactly one*, so a predicate that only asks "is any version label present" passes an issue carrying **both** `v1` and `v2` — which belongs to two lines at once diff --git a/.claude/skills/security-advisory/SKILL.md b/.claude/skills/security-advisory/SKILL.md index c0ac63da1c..404feca08c 100644 --- a/.claude/skills/security-advisory/SKILL.md +++ b/.claude/skills/security-advisory/SKILL.md @@ -183,6 +183,13 @@ provisional score could not have had. moves the advisory `triage` → `draft`. The state is readable as `state` and `submission.accepted` on the API object. +**Then move the card `Incoming` → `Todo`.** On #28 the approval act is +normally assigning a milestone, and a draft card cannot carry one — so for an +advisory draft, **accepting the advisory is the approval**, and it is what +licenses the card to leave `Incoming`. `AGENTS.md` records this as the +advisory exemption to its `Incoming` ⇔ milestone invariant. The milestone +arrives with the public issue in step 6, where the ordinary rule resumes. + **Invalid, out of scope, or upstream → close** with a comment saying which, and why. A reporter who is told nothing reasonably assumes they were ignored. diff --git a/AGENTS.md b/AGENTS.md index ce472adb21..1c0b7d9068 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -371,7 +371,7 @@ skills; the rules are here. - **Label by type — exactly one of `bug` / `enhancement` / `documentation` / `chore` / `question`** on every issue you create or triage. The version label says which line the work belongs to; the type label says what kind of work it is, and the two are independent. Don't force the binary: pressing a docs task or a dependency pin into `enhancement` degrades it to "not a bug", at which point filtering by it stops telling you anything. A **PR** needs no type label — it is classified through the issue it closes. - **Every v2 issue you create gets a milestone.** Milestones are _release_ buckets, so pick by when the work ships. Never leave a v2 issue you filed unmilestoned pending a decision. Two exceptions, both deliberate: an issue that arrives **unboarded** stays unmilestoned in `Incoming` until a maintainer approves it — there, the _absence_ of a milestone is the signal; and **every milestone is a v2 release bucket, so a `v1` issue has none to take**. Say so when filing one rather than dropping it in a v2.x bucket. - **Every v2 board item has a Priority.** Priority is a **board field**, not a label, so an unboarded issue has nowhere to store it. Derive it with the rubric in the `issue-triage` skill rather than asserting it. Board #11 has no Priority field; a v1 issue gets a Status and nothing else. -- **`Incoming` ⇔ no milestone; everything past it ⇔ milestoned — on board #28.** Board #11 is exempt for the reason above: a v1 issue has no bucket to take, so its Status is set on its own and the audit's milestone checks do not apply to it. The rest of the invariant is unchanged: assigning the milestone _is_ the approval act, so the two always go together. `Todo` asserts a maintainer signed off, so never park an unreviewed issue there — that erases the distinction and quietly promotes unreviewed work into the queue. An issue created through the documented flow skips `Incoming` entirely, because filing it _was_ the approval. +- **`Incoming` ⇔ no milestone; everything past it ⇔ milestoned — on board #28.** Board #11 is exempt for the reason above: a v1 issue has no bucket to take, so its Status is set on its own and the audit's milestone checks do not apply to it. A `[GHSA-` **advisory draft** on #28 is exempt too, for a different reason: a draft card cannot carry a milestone, so its approval act is **accepting the advisory**, which moves it `Incoming` → `Todo`; its milestone arrives with the public issue after publication. The rest of the invariant is unchanged: assigning the milestone _is_ the approval act, so the two always go together. `Todo` asserts a maintainer signed off, so never park an unreviewed issue there — that erases the distinction and quietly promotes unreviewed work into the queue. An issue created through the documented flow skips `Incoming` entirely, because filing it _was_ the approval. - **`Done` means the work shipped.** Exactly two things earn a card a place in Done: its **PR merged**, or it is a **parent whose last sub-issue closed**. Anything else — duplicate, won't fix, not planned, obsolete, superseded — means nothing shipped, so the card is **deleted**. Done is read as the record of what a milestone actually delivered; a duplicate sitting there makes that record wrong in a way nobody can detect later. Deleting a card touches the board only — the issue keeps its labels and comments and stays searchable forever. - **When work begins**, create a feature branch and set Status to **In Progress**. **Branch names start with the target version segment** — `v2/fix/2071-oauth-resource-metadata`, `v1/fix/proxy-ssrf-pin` — matching the base branches themselves. - **When work is complete**, run `npm run format` then `npm run local:gate`, **sign off every commit** (`git commit -s` — the DCO check is a hard merge gate with no partial credit), open a PR against the matching base branch with **`Closes #` as the body's first line**, and set Status to **In Review**. From 67b23bd554ea28af91657353f766f507d2348a1d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 20:44:22 -0400 Subject: [PATCH 56/68] docs: address Copilot review round 2 on secret-storage guide (#2448) Point passphrase recovery at the configured secrets-file path, and describe the keychain hand-off accurately: existing keychain entries are kept, not copied, and the file is removed once every entry is accounted for. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- docs/secret-storage.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/secret-storage.md b/docs/secret-storage.md index dafe1b5afa..661e5f061f 100644 --- a/docs/secret-storage.md +++ b/docs/secret-storage.md @@ -62,7 +62,7 @@ The path is the first of these that applies: **Adding a passphrase later is safe.** The next write upgrades an existing plaintext file in place. Until that write happens the existing values are still readable, and the banner and footer keep saying so. They do not report the file as encrypted just because the variable is now set. -**Changing or losing the passphrase is not safe.** A file that can no longer be decrypted is read as empty, and the Inspector **refuses to write to it** rather than replacing it with a new file that holds only your latest secret. To recover, restore the original passphrase, or delete `secrets.json` and enter the values again. +**Changing or losing the passphrase is not safe.** A file that can no longer be decrypted is read as empty, and the Inspector **refuses to write to it** rather than replacing it with a new file that holds only your latest secret. To recover, restore the original passphrase, or delete the secrets file at its configured path (see [Where the file is](#where-the-file-is); the path is also shown in the startup warning and the settings footer) and enter the values again. ### Permissions @@ -86,8 +86,8 @@ The same read-back check covers a lock that cannot be taken at all. The file sto If you install libsecret (or start a Secret Service) on a machine that was using the file store, the next start probes successfully, selects the keychain, and **moves the contents of `secrets.json` into it**: -- **The keychain wins on conflict.** A value already in the keychain is kept; the file's value is treated as the older copy. -- **The file is removed only when every value was copied.** If the hand-off is partial, the file is left as it was and the next start tries again. +- **The keychain wins on conflict.** A value already in the keychain is kept and the file's value is not copied, because it is treated as the older copy. Only entries the keychain does not have are written. +- **The file is removed only when every entry is accounted for**, meaning each one either was already in the keychain or was written there. If any entry could not be handled, or a keychain read or write fails, the file is left as it was and the next start tries again. A file with no entries is also left in place. - **An unreadable file is not deleted.** If the file cannot be decrypted (the passphrase changed or is now unset), the Inspector reports it and leaves the file in place. A successful move prints a message naming the file it removed. The same hand-off runs when you select the keychain explicitly with `MCP_INSPECTOR_SECRET_STORE=keyring`. It does not run in the other direction: choosing `file` or `memory` does not copy anything out of the keychain. From 2b96781e67f43da126886cc483326466ff991649 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 21:08:58 -0400 Subject: [PATCH 57/68] docs: name the lock after the configured secrets file (#2448 review) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- docs/secret-storage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/secret-storage.md b/docs/secret-storage.md index 661e5f061f..26d6fc6ba5 100644 --- a/docs/secret-storage.md +++ b/docs/secret-storage.md @@ -70,7 +70,7 @@ The Inspector writes the file with mode `0600` and tightens it again when the st ### Two Inspectors, one file -Within a process, changes are serialized per file path, so a web session's own concurrent saves cannot overwrite each other. Across processes, for example a CLI run next to a web session, each change takes an exclusive lock on `secrets.json.lock` for the whole read-modify-write. The lock uses [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile), the same library npm uses for its own locks. The lock expires 10 seconds after its holder stops refreshing it, so an Inspector that is killed mid-save does not leave the file unwritable. +Within a process, changes are serialized per file path, so a web session's own concurrent saves cannot overwrite each other. Across processes, for example a CLI run next to a web session, each change takes an exclusive lock on `.lock`, a lock directory beside the secrets file named after it (`secrets.json.lock` by default), for the whole read-modify-write. The lock uses [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile), the same library npm uses for its own locks. The lock expires 10 seconds after its holder stops refreshing it, so an Inspector that is killed mid-save does not leave the file unwritable. So two running Inspectors are genuinely serialized. What a lock file cannot make single-winner is the _takeover of a lock whose holder died_. That needs a compare-and-swap on a directory entry (`renameat2`), which Node does not expose, and `proper-lockfile` does not close that race either. The window only opens after a holder dies without releasing its lock. From cc73b5bfb019b085495aecdf222a8a85574c7f83 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 22:56:21 -0400 Subject: [PATCH 58/68] feat: MCP_INSPECTOR_SECRET_KEY_FILE, a Docker warning and a threat model (#2447) Read the file-store passphrase from a file named by MCP_INSPECTOR_SECRET_KEY_FILE, so Docker and Compose secrets can supply it without putting it in the environment. Both variables set, or a key file that is missing, unreadable or empty, is a key problem: the store refuses to read or write rather than falling back to plaintext, and reports it as "File (unreadable)" with the reason. docker.md gains a warning that mounting the volume turns on plaintext file storage unless a key is supplied, with KEY_FILE examples for docker run and Compose; secret-storage.md gains a threat model for the file store. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .../SecretStorageFooter.tsx | 2 +- .../auth/node/file-secret-store.test.ts | 146 ++++++++++++++++++ core/auth/node/file-secret-store.ts | 107 ++++++++++++- core/auth/secret-storage-info.ts | 2 +- docs/docker.md | 46 ++++-- docs/environment-variables.md | 1 + docs/secret-storage.md | 27 +++- 7 files changed, 310 insertions(+), 21 deletions(-) diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx index 9795555742..751772e7d0 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -189,7 +189,7 @@ function footerTooltip(info: SecretStorageInfo): string | undefined { parts.push( info.pendingEncryption ? "Re-encrypted the next time a secret is saved." - : "Set MCP_INSPECTOR_SECRET_KEY to encrypt.", + : "Set MCP_INSPECTOR_SECRET_KEY or MCP_INSPECTOR_SECRET_KEY_FILE to encrypt.", ); } if (info.looseMode !== undefined) { diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 8ab9643392..cd62273bd7 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -16,6 +16,9 @@ import * as path from "node:path"; import { FileSecretStore, readSecretFilePermissions, + resolveSecretPassphrase, + SECRET_KEY_ENV, + SECRET_KEY_FILE_ENV, SecretFileKeyMismatchError, tightenSecretFilePermissions, } from "@inspector/core/auth/node/file-secret-store.js"; @@ -1531,3 +1534,146 @@ describe("tightenSecretFilePermissions reports what it could not fix", () => { }); }); }); + +describe("resolveSecretPassphrase (MCP_INSPECTOR_SECRET_KEY_FILE, #2447)", () => { + const keyFile = (): string => path.join(tmpDir, "secret-key"); + + it("returns nothing when neither variable is set", () => { + expect(resolveSecretPassphrase({})).toEqual({}); + }); + + it("uses MCP_INSPECTOR_SECRET_KEY verbatim", () => { + expect( + resolveSecretPassphrase({ [SECRET_KEY_ENV]: " pass phrase " }), + ).toEqual({ passphrase: " pass phrase " }); + }); + + it("reads the key file, stripping only the trailing line break", async () => { + await fs.writeFile(keyFile(), " from file \r\n\n"); + expect( + resolveSecretPassphrase({ [SECRET_KEY_FILE_ENV]: keyFile() }), + ).toEqual({ passphrase: " from file " }); + }); + + it("treats a blank MCP_INSPECTOR_SECRET_KEY as unset, so the file is used", async () => { + await fs.writeFile(keyFile(), "from-file\n"); + expect( + resolveSecretPassphrase({ + [SECRET_KEY_ENV]: " ", + [SECRET_KEY_FILE_ENV]: keyFile(), + }), + ).toEqual({ passphrase: "from-file" }); + }); + + it("resolves a relative key-file path against the working directory", async () => { + await fs.writeFile(keyFile(), "relative\n"); + const relative = path.relative(process.cwd(), keyFile()); + expect( + resolveSecretPassphrase({ [SECRET_KEY_FILE_ENV]: relative }), + ).toEqual({ passphrase: "relative" }); + }); + + it("refuses both variables at once rather than picking one", async () => { + await fs.writeFile(keyFile(), "from-file\n"); + const result = resolveSecretPassphrase({ + [SECRET_KEY_ENV]: "direct", + [SECRET_KEY_FILE_ENV]: keyFile(), + }); + expect(result.passphrase).toBeUndefined(); + expect(result.problem).toMatch( + /both MCP_INSPECTOR_SECRET_KEY and MCP_INSPECTOR_SECRET_KEY_FILE are set/, + ); + }); + + it("reports a missing key file as a problem, not as no passphrase", () => { + const result = resolveSecretPassphrase({ + [SECRET_KEY_FILE_ENV]: path.join(tmpDir, "nope"), + }); + expect(result.passphrase).toBeUndefined(); + expect(result.problem).toMatch(/could not be read: .*ENOENT/); + }); + + it("reports an empty key file as a problem", async () => { + await fs.writeFile(keyFile(), "\n"); + const result = resolveSecretPassphrase({ + [SECRET_KEY_FILE_ENV]: keyFile(), + }); + expect(result.passphrase).toBeUndefined(); + expect(result.problem).toMatch(/is empty/); + }); +}); + +describe("FileSecretStore with MCP_INSPECTOR_SECRET_KEY_FILE (#2447)", () => { + const keyFile = (): string => path.join(tmpDir, "secret-key"); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("encrypts with the passphrase read from the file", async () => { + await fs.writeFile(keyFile(), "hunter2\n"); + vi.stubEnv(SECRET_KEY_ENV, ""); + vi.stubEnv(SECRET_KEY_FILE_ENV, keyFile()); + const store = new FileSecretStore({ filePath: filePath() }); + expect(store.encrypted).toBe(true); + expect(store.keyProblem).toBeUndefined(); + await store.set("alpha", "env:A", "super-secret"); + const raw = await fs.readFile(filePath(), "utf-8"); + expect(JSON.parse(raw).encryption).toBe("aes-256-gcm"); + // The same passphrase supplied directly opens it: the newline is not + // part of the key. + const direct = new FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + expect(await direct.get("alpha", "env:A")).toBe("super-secret"); + }); + + it("refuses to write, rather than writing plaintext, when the key file is missing", async () => { + vi.stubEnv(SECRET_KEY_ENV, ""); + vi.stubEnv(SECRET_KEY_FILE_ENV, path.join(tmpDir, "missing-key")); + const store = new FileSecretStore({ filePath: filePath() }); + expect(store.encrypted).toBe(false); + expect(store.keyProblem).toMatch(/could not be read/); + await expect(store.set("alpha", "env:A", "v")).rejects.toThrow( + SecretStoreUnavailableError, + ); + await expect(store.set("alpha", "env:A", "v")).rejects.toThrow( + /Refusing to read or write it rather than store secrets unencrypted/, + ); + expect(existsSyncFile(filePath())).toBe(false); + expect(await store.get("alpha", "env:A")).toBeNull(); + await expect(store.readAll()).rejects.toThrow(SecretStoreUnavailableError); + expect(await store.readOnDiskEncryption()).toEqual({ + state: "unreadable", + detail: expect.stringMatching( + /MCP_INSPECTOR_SECRET_KEY_FILE .* could not be read/, + ), + }); + }); + + it("leaves an existing file untouched while the key is unavailable", async () => { + const plain = new FileSecretStore({ filePath: filePath() }); + await plain.set("alpha", "env:A", "1"); + const before = await fs.readFile(filePath(), "utf-8"); + vi.stubEnv(SECRET_KEY_ENV, "direct"); + vi.stubEnv(SECRET_KEY_FILE_ENV, keyFile()); + const store = new FileSecretStore({ filePath: filePath() }); + expect(store.keyProblem).toMatch(/both/); + await expect(store.set("alpha", "env:B", "2")).rejects.toThrow( + SecretStoreUnavailableError, + ); + await expect(store.delete("alpha", "env:A")).resolves.toBeUndefined(); + expect(await fs.readFile(filePath(), "utf-8")).toBe(before); + }); + + it("an explicit passphrase option ignores the environment", () => { + vi.stubEnv(SECRET_KEY_FILE_ENV, path.join(tmpDir, "missing-key")); + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + expect(store.keyProblem).toBeUndefined(); + expect(store.encrypted).toBe(true); + }); +}); diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index 4d81275188..1de24cc2de 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -59,6 +59,7 @@ */ import * as crypto from "node:crypto"; +import { readFileSync } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { readStoreFile, writeStoreFile } from "../../storage/store-io.js"; @@ -108,6 +109,74 @@ const SCRYPT_P = 1; /** Env var holding the passphrase. Absent → the file is written in the clear. */ export const SECRET_KEY_ENV = "MCP_INSPECTOR_SECRET_KEY"; +/** + * Env var naming a file that holds the passphrase — the `_FILE` convention + * Docker and Compose secrets are built around (#2447). A secret mounted at + * `/run/secrets/` never appears in `docker inspect`, the process + * environment, or a Compose file, which is where `SECRET_KEY_ENV` has to + * live and where it is readable by anyone who can reach the container. + */ +export const SECRET_KEY_FILE_ENV = "MCP_INSPECTOR_SECRET_KEY_FILE"; + +/** + * Where the passphrase came from, or why it could not be had. + * + * `problem` is the state this type exists for. A key file that is named but + * missing, unreadable or empty is a user who asked for encryption and did not + * get a key — and treating that as "no passphrase" would write every secret + * in the clear, the one outcome they configured this to prevent. So it is + * carried as its own state, and the store refuses to read or write under it. + */ +export interface SecretPassphrase { + passphrase?: string; + problem?: string; +} + +/** + * Resolve the passphrase from `SECRET_KEY_ENV` or `SECRET_KEY_FILE_ENV`. + * + * Both set is a misconfiguration rather than a precedence question: they can + * disagree, and silently preferring one would encrypt with a key the user did + * not mean — which is only discovered when the file will not open. Refusing + * names both variables at startup instead. + * + * The file's trailing line break is stripped (`echo … > key` writes one, and + * the passphrase is not meant to include it); anything else is kept as-is, + * matching how the env var is used verbatim. Blank after that is a problem, + * not "off": unlike an empty `MCP_INSPECTOR_SECRET_KEY=`, pointing at a file + * is an explicit request for encryption. + */ +export function resolveSecretPassphrase( + env: NodeJS.ProcessEnv = process.env, +): SecretPassphrase { + const direct = env[SECRET_KEY_ENV]; + const hasDirect = direct !== undefined && direct.trim() !== ""; + const keyFile = env[SECRET_KEY_FILE_ENV]?.trim(); + if (hasDirect && keyFile) { + return { + problem: `both ${SECRET_KEY_ENV} and ${SECRET_KEY_FILE_ENV} are set; set only one`, + }; + } + if (hasDirect) return { passphrase: direct }; + if (!keyFile) return {}; + const resolved = path.resolve(keyFile); + let contents: string; + try { + contents = readFileSync(resolved, "utf-8"); + } catch (err) { + return { + // `String(err)` rather than `.message`: `readFileSync` only ever throws + // an `Error`, so an `instanceof` guard would be a branch no test can take. + problem: `${SECRET_KEY_FILE_ENV} (${resolved}) could not be read: ${String(err)}`, + }; + } + const passphrase = contents.replace(/(\r?\n)+$/, ""); + if (passphrase.trim() === "") { + return { problem: `${SECRET_KEY_FILE_ENV} (${resolved}) is empty` }; + } + return { passphrase }; +} + interface KdfParams { algorithm: "scrypt"; salt: string; @@ -168,8 +237,8 @@ export class SecretFileKeyMismatchError extends SecretStoreUnavailableError { constructor(filePath: string, hasKey: boolean) { super( hasKey - ? `The secrets file at ${filePath} could not be decrypted with the current ${SECRET_KEY_ENV}. Refusing to write, which would overwrite the existing secrets. Restore the original passphrase, or delete the file to start over.` - : `The secrets file at ${filePath} is encrypted but ${SECRET_KEY_ENV} is not set. Refusing to write, which would overwrite the existing secrets. Set the passphrase this file was written with, or delete the file to start over.`, + ? `The secrets file at ${filePath} could not be decrypted with the current ${SECRET_KEY_ENV} (or ${SECRET_KEY_FILE_ENV}). Refusing to write, which would overwrite the existing secrets. Restore the original passphrase, or delete the file to start over.` + : `The secrets file at ${filePath} is encrypted but ${SECRET_KEY_ENV} is not set (nor ${SECRET_KEY_FILE_ENV}). Refusing to write, which would overwrite the existing secrets. Set the passphrase this file was written with, or delete the file to start over.`, ); this.name = "SecretFileKeyMismatchError"; } @@ -294,9 +363,10 @@ export interface FileSecretStoreOptions { /** Absolute path of the secrets file. */ filePath: string; /** - * Passphrase. Defaults to `process.env[SECRET_KEY_ENV]`; an empty or - * whitespace-only value counts as absent, since `MCP_INSPECTOR_SECRET_KEY=` - * in a compose file is a user who meant "off", not a one-character key. + * Passphrase. Defaults to {@link resolveSecretPassphrase} over + * `process.env`; an empty or whitespace-only value counts as absent, since + * `MCP_INSPECTOR_SECRET_KEY=` in a compose file is a user who meant "off", + * not a one-character key. */ passphrase?: string; } @@ -304,10 +374,20 @@ export interface FileSecretStoreOptions { export class FileSecretStore implements SecretStore { readonly filePath: string; private readonly passphrase: string | undefined; + /** + * Why a configured key could not be obtained. While set, every read throws + * and every write refuses — see {@link SecretPassphrase}. + */ + readonly keyProblem: string | undefined; constructor(options: FileSecretStoreOptions) { this.filePath = options.filePath; - const raw = options.passphrase ?? process.env[SECRET_KEY_ENV]; + const resolved = + options.passphrase !== undefined + ? { passphrase: options.passphrase } + : resolveSecretPassphrase(); + const raw = resolved.passphrase; this.passphrase = raw && raw.trim() ? raw : undefined; + this.keyProblem = resolved.problem; } /** @@ -348,6 +428,12 @@ export class FileSecretStore implements SecretStore { * `set` is in fact about to refuse. */ async readOnDiskEncryption(): Promise { + // Before looking at the file at all: with no key to open it and no + // permission to write it in the clear, neither "absent" (which would fall + // back to reporting the write policy) nor "plaintext" is true of it. + if (this.keyProblem !== undefined) { + return { state: "unreadable", detail: this.keyProblem }; + } let raw: string | null; try { raw = await readStoreFile(this.filePath); @@ -412,7 +498,7 @@ export class FileSecretStore implements SecretStore { state: "unreadable", detail: err instanceof SecretFileKeyMismatchError - ? `it cannot be decrypted with the current ${SECRET_KEY_ENV}` + ? `it cannot be decrypted with the current ${SECRET_KEY_ENV} (or ${SECRET_KEY_FILE_ENV})` : err instanceof Error ? err.message : String(err), @@ -469,6 +555,13 @@ export class FileSecretStore implements SecretStore { } private async readMap(): Promise | null> { + // Every read and write passes through here, so this one check is what + // stops a missing key file from degrading to plaintext writes. + if (this.keyProblem !== undefined) { + throw new SecretStoreUnavailableError( + `The secrets file at ${this.filePath} cannot be used: ${this.keyProblem}. Refusing to read or write it rather than store secrets unencrypted.`, + ); + } const raw = await readStoreFile(this.filePath); if (raw === null) return null; diff --git a/core/auth/secret-storage-info.ts b/core/auth/secret-storage-info.ts index c63220e2ca..05c0539926 100644 --- a/core/auth/secret-storage-info.ts +++ b/core/auth/secret-storage-info.ts @@ -200,7 +200,7 @@ export function secretStorageCaveat( if (info.plaintext) { return info.pendingEncryption ? "Existing secrets in this file are still unencrypted (file mode 0600). They are re-encrypted the next time a secret is saved." - : "Secrets are stored unencrypted (file mode 0600). Set MCP_INSPECTOR_SECRET_KEY to encrypt them."; + : "Secrets are stored unencrypted (file mode 0600). Set MCP_INSPECTOR_SECRET_KEY or MCP_INSPECTOR_SECRET_KEY_FILE to encrypt them."; } return undefined; } diff --git a/docs/docker.md b/docs/docker.md index 56bb91970b..94a6c82f12 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -51,16 +51,42 @@ The same volume also persists OAuth tokens and stored state, so an authorized se So the same volume that keeps your server list also switches secrets from session-scoped to durable — nothing extra to configure. The in-memory default for an unmounted container is deliberate: a file in the writable layer is discarded by `--rm` and by every image update, and promising durability it can't deliver is worse than declining to. The check looks at the **directory that holds the secrets file**, so if you relocate it with `-e MCP_STORAGE_DIR=…` or `-e MCP_INSPECTOR_SECRET_FILE=…`, mount a volume at that file's parent directory. Don't bind-mount the file on its own: it is not recognized as durable, so you get the memory store, and even with `-e MCP_INSPECTOR_SECRET_STORE=file` it cannot be written, because every save replaces the file by renaming a temporary file over it. -The file is **unencrypted unless you give it a key**. Pass a generated, high-entropy passphrase with `-e`: - -```bash -docker run --rm -p 127.0.0.1:6274:6274 \ - -v mcp-inspector-data:/home/node/.mcp-inspector \ - -e MCP_INSPECTOR_SECRET_KEY="$MY_PASSPHRASE" \ - ghcr.io/modelcontextprotocol/inspector -``` - -⚠️ Keep passing the **same** passphrase on every run: a file that can no longer be decrypted is read as empty and refuses to be written. Everything else about the store — the selection order, the file's location, encryption, permissions, locking, and choosing a store explicitly with `MCP_INSPECTOR_SECRET_STORE` — applies to every runtime and is in [Where secrets are stored](./secret-storage.md). +> [!WARNING] +> **Mounting that volume turns on file storage of secrets, and without a key the file is plaintext.** Every OAuth client secret, IdP client secret and stdio `env:` value you save is then written to `secrets.json` on the volume, readable by anyone who can read the volume: root and every member of the `docker` group on the host, and anyone who gets a backup, snapshot or copy of it. Mode `0600` only keeps out other non-root users. +> +> **Give it a key, and keep that key only where the Inspector can read it.** Generate one (for example `openssl rand -base64 32 > secret-key`), keep it out of the volume, backups and any repository that holds the secrets file, and hand it to the container **as a file** with `MCP_INSPECTOR_SECRET_KEY_FILE`, not as an environment variable: +> +> ```bash +> docker run --rm -p 127.0.0.1:6274:6274 \ +> -v mcp-inspector-data:/home/node/.mcp-inspector \ +> -v "$HOME/.config/mcp-inspector/secret-key:/run/secrets/mcp_inspector_secret_key:ro" \ +> -e MCP_INSPECTOR_SECRET_KEY_FILE=/run/secrets/mcp_inspector_secret_key \ +> ghcr.io/modelcontextprotocol/inspector +> ``` +> +> Or with Compose secrets: +> +> ```yaml +> services: +> inspector: +> image: ghcr.io/modelcontextprotocol/inspector +> ports: ["127.0.0.1:6274:6274"] +> volumes: ["mcp-inspector-data:/home/node/.mcp-inspector"] +> environment: +> MCP_INSPECTOR_SECRET_KEY_FILE: /run/secrets/mcp_inspector_secret_key +> secrets: [mcp_inspector_secret_key] +> secrets: +> mcp_inspector_secret_key: +> file: ./secret-key +> volumes: +> mcp-inspector-data: +> ``` +> +> A key passed as a file stays out of `docker inspect`, the container's environment, your shell history and the Compose file. The container runs as uid `1000`, so the key file must be readable by that uid; without Swarm, Compose secrets are bind mounts that keep the host file's owner and mode. `MCP_INSPECTOR_SECRET_KEY` still works, but a key passed that way is readable by anyone who can run `docker inspect` or `docker exec` against the container. If the key file is missing, unreadable or empty, or both variables are set, the Inspector **refuses to read or write the secrets file** rather than falling back to plaintext, and says why in the log and the settings footer. +> +> **Even encrypted, secrets on disk carry moderate risk.** Encryption protects against the file leaking **on its own**. It does not protect against anyone who can also reach the key, which on a single host usually includes root and the `docker` group. Read [what the file store protects against](./secret-storage.md#what-the-file-store-protects-against) before relying on it. If that is not acceptable, don't mount the volume (secrets then stay in memory for the session), or run the Inspector outside a container, where it uses the OS keychain. + +⚠️ Keep supplying the **same** passphrase on every run: a file that can no longer be decrypted is read as empty and refuses to be written. Everything else about the store — the selection order, the file's location, encryption, permissions, locking, and choosing a store explicitly with `MCP_INSPECTOR_SECRET_STORE` — applies to every runtime and is in [Where secrets are stored](./secret-storage.md). **Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once: diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 44d247c185..56bd7601ed 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -78,6 +78,7 @@ Where the Inspector's secrets (OAuth client secrets, the enterprise IdP client s | `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. An empty or whitespace-only value counts as unset and silently runs automatic selection; any other value is ignored with a warning and also falls back to automatic selection. | | `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `~/.mcp-inspector/secrets.json` | Path of the file store. Lookup order: this variable, then `secrets.json` in `MCP_STORAGE_DIR` when that is set, then `~/.mcp-inspector/secrets.json`. ⚠️ The default sits **beside** the storage directory, not inside it. | | `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store; an empty or whitespace-only value counts as unset. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see [Where secrets are stored](./secret-storage.md#encryption) before rotating it. | +| `MCP_INSPECTOR_SECRET_KEY_FILE` | web, CLI, TUI | unset | Path of a file holding the passphrase; trailing line breaks are removed. Use this for Docker or Compose secrets, so the key stays out of the environment. Setting it together with `MCP_INSPECTOR_SECRET_KEY` is an error. ⚠️ If the file is missing, unreadable or empty, the file store refuses to read or write rather than fall back to plaintext. | When no store is configured, the choice also depends on whether the Inspector is running in a container, which it detects from `KUBERNETES_SERVICE_HOST` (or Docker's and Podman's marker files). That variable is set by the orchestrator, not by you. diff --git a/docs/secret-storage.md b/docs/secret-storage.md index 26d6fc6ba5..0a1cd77424 100644 --- a/docs/secret-storage.md +++ b/docs/secret-storage.md @@ -56,10 +56,12 @@ The path is the first of these that applies: ### Encryption -**A file store is unencrypted unless you give it a passphrase.** Set `MCP_INSPECTOR_SECRET_KEY` and the file is encrypted with AES-256-GCM, with the passphrase stretched by scrypt against a random salt that is regenerated on every write. Without it, the file is still mode `0600`, but anyone who can read the file can read the values. The startup log and the settings footer say so every session, as a warning. +**A file store is unencrypted unless you give it a passphrase.** Set `MCP_INSPECTOR_SECRET_KEY`, or point `MCP_INSPECTOR_SECRET_KEY_FILE` at a file containing it, and the file is encrypted with AES-256-GCM, with the passphrase stretched by scrypt against a random salt that is regenerated on every write. Without it, the file is still mode `0600`, but anyone who can read the file can read the values. The startup log and the settings footer say so every session, as a warning. **Use a high-entropy passphrase: generate it, don't choose it.** The random salt stops an attacker from precomputing a table, but it does nothing against guessing. The scrypt cost is deliberately low because the derivation runs on every read and write. Anyone who obtains `secrets.json` can therefore test candidate passphrases quickly and offline, so treat this value like any other credential, not like a memorable password. +**Prefer the key file.** `MCP_INSPECTOR_SECRET_KEY_FILE` reads the passphrase from a file, with trailing line breaks removed, so it never has to sit in the environment, a shell profile, an `.env` file or a Compose file. It is the variable Docker and Compose secrets are built for (see the [Docker guide](./docker.md)). Setting both variables is an error. If the key file is missing, unreadable or empty, or both are set, the file store **refuses to read or write** instead of falling back to plaintext: saves fail, and the startup warning and settings footer report the file as unreadable, with the reason. + **Adding a passphrase later is safe.** The next write upgrades an existing plaintext file in place. Until that write happens the existing values are still readable, and the banner and footer keep saying so. They do not report the file as encrypted just because the variable is now set. **Changing or losing the passphrase is not safe.** A file that can no longer be decrypted is read as empty, and the Inspector **refuses to write to it** rather than replacing it with a new file that holds only your latest secret. To recover, restore the original passphrase, or delete the secrets file at its configured path (see [Where the file is](#where-the-file-is); the path is also shown in the startup warning and the settings footer) and enter the values again. @@ -68,6 +70,27 @@ The path is the first of these that applies: The Inspector writes the file with mode `0600` and tightens it again when the store is selected if something loosened it. If it _cannot_ tighten it (the file belongs to another user, or the mount is read-only), it says so in the log and the footer instead of continuing to describe the file as protected. +### What the file store protects against + +The file store is a fallback for machines without a keychain, and it is weaker than a keychain. Treat keeping secrets in it, even encrypted, as a **moderate risk**. Here is what it does and does not defend against. + +**Without a passphrase (plaintext, mode `0600`):** + +- ✅ Other non-root users on the same machine, as long as the mode holds. +- ❌ Root, and on a container host, every member of the `docker` group, which is equivalent to root. +- ❌ Anyone who gets a copy of the file: a backup, a disk or volume snapshot, a synced home directory, or an accidental `git add` of a bind-mounted directory. +- ❌ Any program running as your user, including the stdio MCP servers the Inspector starts. + +**With `MCP_INSPECTOR_SECRET_KEY` set (AES-256-GCM):** + +- ✅ **The file leaking on its own.** A backup, snapshot, copy or commit of `secrets.json` is useless without the key, _provided_ the passphrase is high-entropy (see [Encryption](#encryption)) and the key did not leak with it. This is the threat encryption at rest is for. +- ❌ **Anyone who can read the key where it lives.** With `MCP_INSPECTOR_SECRET_KEY` the key is in the Inspector's environment, readable through `/proc//environ` by the same user or root, through `docker inspect` and `docker exec` for a container, and wherever you stored it for launching, such as a shell profile, an `.env` file or a Compose file. `MCP_INSPECTOR_SECRET_KEY_FILE` narrows this to whoever can read the key file, but the Inspector must be able to read it, so the same user can too. If the key sits next to the secrets file (in the same backup, volume or repository), encryption buys nothing. +- ❌ **Root on the host, or the `docker` group.** They can read both the file and the key, or the process memory holding the decrypted values. +- ❌ **Code running as the same user.** The Inspector does **not** pass its own environment to the stdio servers it starts: they get a short allowlist (`HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM`, `USER` on macOS and Linux) plus their configured `env:`. But a server runs as the same user, so it can open the secrets file directly and can usually read the Inspector's environment through `/proc`. Only run servers you would trust with these secrets. +- ❌ **A weak passphrase.** Anyone with the file can guess offline. + +In short, encryption turns "the file leaked" into "the file **and** the key leaked". It does not help against anyone who already has access to the machine or the container as root, or as the user the Inspector runs as. When that is not acceptable, use a keychain (install libsecret or run a Secret Service on Linux), or `MCP_INSPECTOR_SECRET_STORE=memory` and re-enter secrets each session. + ### Two Inspectors, one file Within a process, changes are serialized per file path, so a web session's own concurrent saves cannot overwrite each other. Across processes, for example a CLI run next to a web session, each change takes an exclusive lock on `.lock`, a lock directory beside the secrets file named after it (`secrets.json.lock` by default), for the whole read-modify-write. The lock uses [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile), the same library npm uses for its own locks. The lock expires 10 seconds after its holder stops refreshing it, so an Inspector that is killed mid-save does not leave the file unwritable. @@ -106,6 +129,6 @@ A successful move prints a message naming the file it removed. The same hand-off | Use a file even though a keychain exists | `MCP_INSPECTOR_SECRET_STORE=file` | | Never write secrets to disk | `MCP_INSPECTOR_SECRET_STORE=memory` | | Put the file somewhere else | `MCP_INSPECTOR_SECRET_FILE=/path/to/secrets.json`, or `MCP_STORAGE_DIR` | -| Encrypt the file | `MCP_INSPECTOR_SECRET_KEY=` | +| Encrypt the file | `MCP_INSPECTOR_SECRET_KEY_FILE=/path/to/key-file` (preferred), or `MCP_INSPECTOR_SECRET_KEY=` | Every variable is also listed in [Environment variables](./environment-variables.md#secret-store). From bf4beba225ac70a0f3b531467e57b29709cadba3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 23:08:27 -0400 Subject: [PATCH 59/68] fix: treat a blank MCP_INSPECTOR_SECRET_KEY_FILE as a key problem (#2448 review) A set-but-empty key-file variable is a template that failed to expand, not a request for plaintext; also strip a lone trailing CR, and assert both key variables in the caveat and footer tooltip tests. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .../SecretStorageFooter.test.tsx | 13 ++++++++ .../core/auth/secret-storage-info.test.ts | 4 ++- .../auth/node/file-secret-store.test.ts | 25 ++++++++++++++++ core/auth/node/file-secret-store.ts | 30 +++++++++++++------ docs/environment-variables.md | 2 +- docs/secret-storage.md | 2 +- 6 files changed, 64 insertions(+), 12 deletions(-) diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx index 84717d96d8..b171b918c5 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx @@ -109,6 +109,19 @@ describe("SecretStorageFooter", () => { expect(band).toHaveAttribute("data-tone", "warn"); }); + it("offers both key variables in the plaintext tooltip", async () => { + // Either variable clears the condition, and the file form is the one a + // container should use (#2447), so the advice names both. + const user = userEvent.setup(); + renderWithMantine(); + await user.hover(screen.getByRole("button", { name: /Copy secrets file/ })); + expect( + await screen.findByText( + /Set MCP_INSPECTOR_SECRET_KEY or MCP_INSPECTOR_SECRET_KEY_FILE to encrypt\./, + ), + ).toBeInTheDocument(); + }); + it("does not claim exposure in the tooltip when encryption is unknown", async () => { // Mirrors `secretStorageCaveat`: `plaintext` is absent alongside // `encryptionUnknown`, and a two-way `!== false` test would assert that diff --git a/clients/web/src/test/core/auth/secret-storage-info.test.ts b/clients/web/src/test/core/auth/secret-storage-info.test.ts index 481f9e0d5b..b9cbae2cc1 100644 --- a/clients/web/src/test/core/auth/secret-storage-info.test.ts +++ b/clients/web/src/test/core/auth/secret-storage-info.test.ts @@ -82,7 +82,9 @@ describe("secretStorageCaveat", () => { it("names the fix for an unencrypted file, not just the problem", () => { const caveat = secretStorageCaveat(plaintextFile); expect(caveat).toContain("unencrypted"); - expect(caveat).toContain("MCP_INSPECTOR_SECRET_KEY"); + expect(caveat).toContain( + "Set MCP_INSPECTOR_SECRET_KEY or MCP_INSPECTOR_SECRET_KEY_FILE to encrypt them.", + ); }); it("changes the advice once a passphrase is set but not yet applied", () => { diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index cd62273bd7..3025237c34 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -1593,6 +1593,31 @@ describe("resolveSecretPassphrase (MCP_INSPECTOR_SECRET_KEY_FILE, #2447)", () => expect(result.problem).toMatch(/could not be read: .*ENOENT/); }); + it("strips a lone trailing carriage return", async () => { + await fs.writeFile(keyFile(), "classic-mac\r"); + expect( + resolveSecretPassphrase({ [SECRET_KEY_FILE_ENV]: keyFile() }), + ).toEqual({ passphrase: "classic-mac" }); + }); + + it("reports a blank MCP_INSPECTOR_SECRET_KEY_FILE as a problem, not as unset", () => { + // A template whose path did not expand must not quietly mean plaintext. + const result = resolveSecretPassphrase({ [SECRET_KEY_FILE_ENV]: " " }); + expect(result.passphrase).toBeUndefined(); + expect(result.problem).toBe( + "MCP_INSPECTOR_SECRET_KEY_FILE is set but empty", + ); + }); + + it("still refuses both variables when the file variable is blank", () => { + const result = resolveSecretPassphrase({ + [SECRET_KEY_ENV]: "direct", + [SECRET_KEY_FILE_ENV]: "", + }); + expect(result.passphrase).toBeUndefined(); + expect(result.problem).toMatch(/both .* are set/); + }); + it("reports an empty key file as a problem", async () => { await fs.writeFile(keyFile(), "\n"); const result = resolveSecretPassphrase({ diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index 1de24cc2de..f2369b8ce5 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -140,25 +140,37 @@ export interface SecretPassphrase { * not mean — which is only discovered when the file will not open. Refusing * names both variables at startup instead. * - * The file's trailing line break is stripped (`echo … > key` writes one, and - * the passphrase is not meant to include it); anything else is kept as-is, - * matching how the env var is used verbatim. Blank after that is a problem, - * not "off": unlike an empty `MCP_INSPECTOR_SECRET_KEY=`, pointing at a file - * is an explicit request for encryption. + * The file's trailing line breaks (`\n`, `\r\n` or a lone `\r`) are stripped + * (`echo … > key` writes one, and the passphrase is not meant to include it); + * anything else is kept as-is, matching how the env var is used verbatim. + * + * Blank is a problem, not "off", at both levels — a blank + * `SECRET_KEY_FILE_ENV` and a key file that is empty after stripping. Unlike + * an empty `MCP_INSPECTOR_SECRET_KEY=`, naming a key file is an explicit + * request for encryption. */ export function resolveSecretPassphrase( env: NodeJS.ProcessEnv = process.env, ): SecretPassphrase { const direct = env[SECRET_KEY_ENV]; const hasDirect = direct !== undefined && direct.trim() !== ""; - const keyFile = env[SECRET_KEY_FILE_ENV]?.trim(); - if (hasDirect && keyFile) { + // Presence, not content, decides whether a key file was asked for. Unlike a + // blank `MCP_INSPECTOR_SECRET_KEY=` (a user switching encryption off), a + // blank `MCP_INSPECTOR_SECRET_KEY_FILE=` is almost always a template whose + // path did not expand — and reading it as "unset" would write plaintext. + const rawKeyFile = env[SECRET_KEY_FILE_ENV]; + const wantsKeyFile = rawKeyFile !== undefined; + const keyFile = rawKeyFile?.trim(); + if (hasDirect && wantsKeyFile) { return { problem: `both ${SECRET_KEY_ENV} and ${SECRET_KEY_FILE_ENV} are set; set only one`, }; } if (hasDirect) return { passphrase: direct }; - if (!keyFile) return {}; + if (!wantsKeyFile) return {}; + if (!keyFile) { + return { problem: `${SECRET_KEY_FILE_ENV} is set but empty` }; + } const resolved = path.resolve(keyFile); let contents: string; try { @@ -170,7 +182,7 @@ export function resolveSecretPassphrase( problem: `${SECRET_KEY_FILE_ENV} (${resolved}) could not be read: ${String(err)}`, }; } - const passphrase = contents.replace(/(\r?\n)+$/, ""); + const passphrase = contents.replace(/[\r\n]+$/, ""); if (passphrase.trim() === "") { return { problem: `${SECRET_KEY_FILE_ENV} (${resolved}) is empty` }; } diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 56bd7601ed..23e756b1f5 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -78,7 +78,7 @@ Where the Inspector's secrets (OAuth client secrets, the enterprise IdP client s | `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. An empty or whitespace-only value counts as unset and silently runs automatic selection; any other value is ignored with a warning and also falls back to automatic selection. | | `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `~/.mcp-inspector/secrets.json` | Path of the file store. Lookup order: this variable, then `secrets.json` in `MCP_STORAGE_DIR` when that is set, then `~/.mcp-inspector/secrets.json`. ⚠️ The default sits **beside** the storage directory, not inside it. | | `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store; an empty or whitespace-only value counts as unset. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see [Where secrets are stored](./secret-storage.md#encryption) before rotating it. | -| `MCP_INSPECTOR_SECRET_KEY_FILE` | web, CLI, TUI | unset | Path of a file holding the passphrase; trailing line breaks are removed. Use this for Docker or Compose secrets, so the key stays out of the environment. Setting it together with `MCP_INSPECTOR_SECRET_KEY` is an error. ⚠️ If the file is missing, unreadable or empty, the file store refuses to read or write rather than fall back to plaintext. | +| `MCP_INSPECTOR_SECRET_KEY_FILE` | web, CLI, TUI | unset | Path of a file holding the passphrase; trailing line breaks are removed. Use this for Docker or Compose secrets, so the key stays out of the environment. Setting it together with `MCP_INSPECTOR_SECRET_KEY` is an error, and so is setting it to an empty value. ⚠️ If the file is missing, unreadable or empty, the file store refuses to read or write rather than fall back to plaintext. | When no store is configured, the choice also depends on whether the Inspector is running in a container, which it detects from `KUBERNETES_SERVICE_HOST` (or Docker's and Podman's marker files). That variable is set by the orchestrator, not by you. diff --git a/docs/secret-storage.md b/docs/secret-storage.md index 0a1cd77424..188321e370 100644 --- a/docs/secret-storage.md +++ b/docs/secret-storage.md @@ -60,7 +60,7 @@ The path is the first of these that applies: **Use a high-entropy passphrase: generate it, don't choose it.** The random salt stops an attacker from precomputing a table, but it does nothing against guessing. The scrypt cost is deliberately low because the derivation runs on every read and write. Anyone who obtains `secrets.json` can therefore test candidate passphrases quickly and offline, so treat this value like any other credential, not like a memorable password. -**Prefer the key file.** `MCP_INSPECTOR_SECRET_KEY_FILE` reads the passphrase from a file, with trailing line breaks removed, so it never has to sit in the environment, a shell profile, an `.env` file or a Compose file. It is the variable Docker and Compose secrets are built for (see the [Docker guide](./docker.md)). Setting both variables is an error. If the key file is missing, unreadable or empty, or both are set, the file store **refuses to read or write** instead of falling back to plaintext: saves fail, and the startup warning and settings footer report the file as unreadable, with the reason. +**Prefer the key file.** `MCP_INSPECTOR_SECRET_KEY_FILE` reads the passphrase from a file, with trailing line breaks removed, so it never has to sit in the environment, a shell profile, an `.env` file or a Compose file. It is the variable Docker and Compose secrets are built for (see the [Docker guide](./docker.md)). Setting both variables is an error, and so is setting `MCP_INSPECTOR_SECRET_KEY_FILE` to an empty value: unlike an empty `MCP_INSPECTOR_SECRET_KEY`, which switches encryption off, it is taken as a key file that failed to arrive. If the key file is missing, unreadable or empty, the variable is blank, or both are set, the file store **refuses to read or write** instead of falling back to plaintext: saves fail, and the startup warning and settings footer report the file as unreadable, with the reason. **Adding a passphrase later is safe.** The next write upgrades an existing plaintext file in place. Until that write happens the existing values are still readable, and the banner and footer keep saying so. They do not report the file as encrypted just because the variable is now set. From d8f8895e62919bb730de6c9b94dd19b740519c77 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 23:15:37 -0400 Subject: [PATCH 60/68] docs: loudly call out the plaintext fallback on hosts with no keychain (#2447) A [!WARNING] after the selection table in secret-storage.md, and in the README and environment-variables.md, for Linux without libsecret, headless/SSH and Termux, where secrets land in a plaintext file without the user asking. The fallback and caveat warnings on stderr now end with a link to the guide. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- README.md | 3 ++ .../auth/node/secret-store-selection.test.ts | 44 +++++++++++++++++++ core/auth/node/secret-store-selection.ts | 18 ++++++++ docs/environment-variables.md | 3 ++ docs/secret-storage.md | 13 +++++- 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index db59ab69e1..c0a39945ae 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ npx @modelcontextprotocol/inspector --cli # CLI npx @modelcontextprotocol/inspector --tui # TUI ``` +> [!WARNING] +> **On a machine with no OS keychain, secrets are saved to a plaintext file by default.** That covers Linux without libsecret or a Secret Service, headless and SSH sessions, Termux, and containers with a mounted volume. OAuth client secrets and stdio `env:` values then go to `~/.mcp-inspector/secrets.json`, unencrypted unless you supply a key. See [Where secrets are stored](./docs/secret-storage.md) for how to get a keychain back, encrypt the file, or keep secrets in memory only. + > **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. diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index e560a47927..b31e1c518a 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -26,6 +26,7 @@ import { chooseFallbackKind, isOnMountPoint, parseSecretStoreEnv, + SECRET_STORAGE_DOCS_URL, warnAboutSecretStorage, } from "@inspector/core/auth/node/secret-store-selection.js"; import { @@ -38,6 +39,7 @@ const ENV_KEYS = [ "MCP_INSPECTOR_SECRET_STORE", "MCP_INSPECTOR_SECRET_FILE", "MCP_INSPECTOR_SECRET_KEY", + "MCP_INSPECTOR_SECRET_KEY_FILE", "MCP_STORAGE_DIR", "KUBERNETES_SERVICE_HOST", ]; @@ -433,6 +435,48 @@ describe("warnAboutSecretStorage", () => { expect(warn).not.toHaveBeenCalled(); }); + it("points a plaintext fallback at the secret-storage guide", () => { + // The Linux-without-libsecret case (#2447): the one run where a user + // gets a plaintext file without asking for it. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + warnAboutSecretStorage({ + kind: "file", + reason: "fallback", + durable: true, + path: "/home/u/.mcp-inspector/secrets.json", + plaintext: true, + detail: "no Secret Service", + }); + const output = warn.mock.calls.flat().join("\n"); + expect(output).toContain("Secrets are stored unencrypted"); + expect(output).toContain(SECRET_STORAGE_DOCS_URL); + expect(SECRET_STORAGE_DOCS_URL).toMatch(/\/docs\/secret-storage\.md$/); + }); + + it("points a configured store with a caveat at the guide too", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + warnAboutSecretStorage({ + kind: "memory", + reason: "configured", + durable: false, + }); + expect(warn.mock.calls.flat().join("\n")).toContain( + SECRET_STORAGE_DOCS_URL, + ); + }); + + it("does not print the guide link for a configured store with nothing to say", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + warnAboutSecretStorage({ + kind: "file", + reason: "configured", + durable: true, + path: "/x/secrets.json", + plaintext: false, + }); + expect(warn).not.toHaveBeenCalled(); + }); + it("announces a fallback with no cause to name", () => { // `detail` is optional — an explicitly configured store has no keychain // error behind it, and the banner must not print an empty error line. diff --git a/core/auth/node/secret-store-selection.ts b/core/auth/node/secret-store-selection.ts index 3a55ec1f52..6603d88fa3 100644 --- a/core/auth/node/secret-store-selection.ts +++ b/core/auth/node/secret-store-selection.ts @@ -88,6 +88,14 @@ export const SECRET_FILE_ENV = "MCP_INSPECTOR_SECRET_FILE"; */ export const STORAGE_DIR_ENV = "MCP_STORAGE_DIR"; +/** + * The user-facing guide the fallback and caveat warnings point to. On + * `main`, the release branch, so it describes the behavior that was + * published rather than whatever `v2/main` is mid-way through. + */ +export const SECRET_STORAGE_DOCS_URL = + "https://github.com/modelcontextprotocol/inspector/blob/main/docs/secret-storage.md"; + const KINDS: SecretStoreKind[] = ["keyring", "file", "memory"]; export interface ResolvedSecretStore { @@ -462,6 +470,16 @@ export function warnAboutSecretStorage(info: SecretStorageInfo): void { } const caveat = secretStorageCaveat(info); if (caveat) console.warn(`[mcp-inspector] ${caveat}`); + // Only after something was actually said: the ordinary keychain run stays + // silent. This is the one place a headless or SSH user is guaranteed to + // look, and the fallback it reports — a plaintext file on a Linux box with + // no Secret Service — is otherwise explained only in docs they would have + // no reason to open (#2447). + if (info.reason === "fallback" || caveat) { + console.warn( + `[mcp-inspector] How the secret store is chosen, and how to secure it: ${SECRET_STORAGE_DOCS_URL}`, + ); + } } let resolved: Promise | undefined; diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 23e756b1f5..3e6a74f65b 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -73,6 +73,9 @@ Every default above that starts with `~` is built from the home directory the pr Where the Inspector's secrets (OAuth client secrets, the enterprise IdP client secret, stdio `env:` values) are kept. How the store is chosen, and the details of the file store — its location, encryption, permissions and locking — are in [Where secrets are stored](./secret-storage.md); these variables apply to every install, not only containers. +> [!WARNING] +> On a host with no OS keychain (Linux without libsecret or a Secret Service, headless or SSH sessions, Termux), the Inspector **automatically** stores secrets in a file that is **plaintext** unless `MCP_INSPECTOR_SECRET_KEY_FILE` or `MCP_INSPECTOR_SECRET_KEY` is set. See [the warning in Where secrets are stored](./secret-storage.md#how-the-store-is-chosen). + | Variable | Read by | Default | Effect | | ---------------------------- | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. An empty or whitespace-only value counts as unset and silently runs automatic selection; any other value is ignored with a warning and also falls back to automatic selection. | diff --git a/docs/secret-storage.md b/docs/secret-storage.md index 188321e370..47c2f19adc 100644 --- a/docs/secret-storage.md +++ b/docs/secret-storage.md @@ -34,6 +34,17 @@ Each process picks one store, once, the first time it needs it: the web backend | Container **with** a volume on the secrets directory | File | Yes | | Any of the above with `MCP_INSPECTOR_SECRET_STORE` set | The store you named | Not with `memory`; with `file` in a container, only if the file is on a volume | +> [!WARNING] +> **With no keychain, secrets go to a plaintext file, and you did not have to ask for it.** On a host where the keychain probe fails (Linux without libsecret or a running Secret Service such as GNOME Keyring or KWallet, a headless server or SSH session with no D-Bus session, or Android/Termux), the Inspector falls back **automatically** to `~/.mcp-inspector/secrets.json`. Unless you supply a key, that file is **unencrypted**. Mode `0600` keeps out other non-root users, but not root, not backups or copies of your home directory, and not any program running as you. The only signs are a warning on stderr when the store is selected and the footer in the web settings dialogs. +> +> Pick one: +> +> - **Get a keychain back**: install libsecret and run a Secret Service (for example `gnome-keyring`), or run the Inspector inside a desktop session. On the next start the Inspector moves the file's secrets into the keychain and deletes the file ([details](#getting-a-keychain-back)). +> - **Encrypt the file**: supply a generated key with `MCP_INSPECTOR_SECRET_KEY_FILE` (preferred) or `MCP_INSPECTOR_SECRET_KEY` ([details](#encryption)). +> - **Don't write secrets to disk at all**: `MCP_INSPECTOR_SECRET_STORE=memory`, and re-enter them each session. +> +> Even encrypted, secrets on disk carry moderate risk. See [what the file store protects against](#what-the-file-store-protects-against). + The Inspector decides that it is in a container from `KUBERNETES_SERVICE_HOST`, Docker's `/.dockerenv`, Podman's `/run/.containerenv`, or the process's cgroup. The container check only chooses between `memory` and `file`; the mount check is what actually decides. The choice is made once per process. Installing a keychain while the Inspector is running takes effect on the next start. @@ -117,7 +128,7 @@ A successful move prints a message naming the file it removed. The same hand-off ## Where the active store is reported -- **When the store is selected** (at startup for the web backend, on first use for the CLI and TUI), every client prints a warning on stderr if it falls back from the keychain, including the keychain error, and another if the file is unencrypted, has loose permissions, or cannot be read. The web client's startup banner also has a `Secrets:` line on every run. +- **When the store is selected** (at startup for the web backend, on first use for the CLI and TUI), every client prints a warning on stderr if it falls back from the keychain, including the keychain error, and another if the file is unencrypted, has loose permissions, or cannot be read. Either warning is followed by a link to this guide. The web client's startup banner also has a `Secrets:` line on every run. - **`GET /api/config`** (web) includes a `secretStorage` object describing the active store. - **In the web UI**, a footer at the bottom of the **Client Settings**, **Server Settings** and **Add / Edit / Clone server** dialogs names the store, and turns into a warning when it is memory-only, unencrypted, loosely permissioned, or unreadable. It is shown where you type a secret, not only once at startup. From 8e4cfff8e76dbd4df5d3d00a5ff75657341c873b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 23:32:53 -0400 Subject: [PATCH 61/68] docs: key-file wording, stale comments and a safer Docker key example (#2448 review) Source comments name MCP_INSPECTOR_SECRET_KEY_FILE and the per-write salt; the 'both set' conflict is qualified to a non-blank MCP_INSPECTOR_SECRET_KEY; the Docker example creates the key file 0600 under ~/.config (not in the project directory) and explains the uid 1000 ownership it needs. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- core/auth/node/file-secret-store.ts | 16 ++++++++++------ core/auth/node/secret-store.ts | 3 ++- docs/docker.md | 13 ++++++++++--- docs/environment-variables.md | 2 +- docs/secret-storage.md | 2 +- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index f2369b8ce5..f82099004b 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -9,8 +9,9 @@ * off disk. Writing secrets back to disk here is therefore a decision, not * an oversight, and it is bounded three ways: the file is separate from * `mcp.json` (so a user pasting their catalog into an issue does not paste - * their secrets), it is written `0600`, and it is encrypted whenever - * `MCP_INSPECTOR_SECRET_KEY` is set. What it buys is the alternative: + * their secrets), it is written `0600`, and it is encrypted whenever a + * passphrase is supplied, through `MCP_INSPECTOR_SECRET_KEY` or the file + * named by `MCP_INSPECTOR_SECRET_KEY_FILE`. What it buys is the alternative: * before this, those users could not persist a secret at all — `set` threw * and the route answered 503. * @@ -30,10 +31,13 @@ * of them you hold an OAuth client secret for. That index is worth * roughly as much to an attacker as some of the values. * - * **Key.** `MCP_INSPECTOR_SECRET_KEY` is a passphrase, not a key: it is - * stretched with scrypt against a per-file random salt stored beside the - * ciphertext, so the same passphrase produces a different key for a - * different file and a precomputed table buys an attacker nothing. + * **Key.** The value of `MCP_INSPECTOR_SECRET_KEY`, or the contents of the + * file named by `MCP_INSPECTOR_SECRET_KEY_FILE` (see + * {@link resolveSecretPassphrase}), is a passphrase, not a key: it is + * stretched with scrypt against a random salt, regenerated on every write + * and stored beside the ciphertext, so the same passphrase produces a + * different key for every write and a precomputed table buys an attacker + * nothing. * * That is **not** a licence to use a short, memorable one. The salt * defeats precomputation; it does nothing against guessing, and the cost diff --git a/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index 49612b4925..2ea87a4b49 100644 --- a/core/auth/node/secret-store.ts +++ b/core/auth/node/secret-store.ts @@ -285,7 +285,8 @@ const PROBE_ACCOUNT = "__inspector:probe"; * - `KeyringSecretStore` — the OS keychain, and the default wherever one * is reachable. * - `FileSecretStore` — `~/.mcp-inspector/secrets.json`, `0600`, - * encrypted when `MCP_INSPECTOR_SECRET_KEY` is set. The fallback on a + * encrypted when `MCP_INSPECTOR_SECRET_KEY` or + * `MCP_INSPECTOR_SECRET_KEY_FILE` supplies a passphrase. The fallback on a * host with no keychain, and on a container with a mounted volume. * - `InMemorySecretStore` — the session-scoped store. Used by the test * suite (so CI needs no libsecret), and as the container fallback when diff --git a/docs/docker.md b/docs/docker.md index 94a6c82f12..ae39754f07 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -54,7 +54,14 @@ So the same volume that keeps your server list also switches secrets from sessio > [!WARNING] > **Mounting that volume turns on file storage of secrets, and without a key the file is plaintext.** Every OAuth client secret, IdP client secret and stdio `env:` value you save is then written to `secrets.json` on the volume, readable by anyone who can read the volume: root and every member of the `docker` group on the host, and anyone who gets a backup, snapshot or copy of it. Mode `0600` only keeps out other non-root users. > -> **Give it a key, and keep that key only where the Inspector can read it.** Generate one (for example `openssl rand -base64 32 > secret-key`), keep it out of the volume, backups and any repository that holds the secrets file, and hand it to the container **as a file** with `MCP_INSPECTOR_SECRET_KEY_FILE`, not as an environment variable: +> **Give it a key, and keep that key only where the Inspector can read it.** Generate one into a file only you can read, outside the volume, backups and any repository that holds the secrets file: +> +> ```bash +> mkdir -p ~/.config/mcp-inspector +> (umask 077 && openssl rand -base64 32 > ~/.config/mcp-inspector/secret-key) +> ``` +> +> Then hand it to the container **as a file** with `MCP_INSPECTOR_SECRET_KEY_FILE`, not as an environment variable: > > ```bash > docker run --rm -p 127.0.0.1:6274:6274 \ @@ -77,12 +84,12 @@ So the same volume that keeps your server list also switches secrets from sessio > secrets: [mcp_inspector_secret_key] > secrets: > mcp_inspector_secret_key: -> file: ./secret-key +> file: ${HOME}/.config/mcp-inspector/secret-key > volumes: > mcp-inspector-data: > ``` > -> A key passed as a file stays out of `docker inspect`, the container's environment, your shell history and the Compose file. The container runs as uid `1000`, so the key file must be readable by that uid; without Swarm, Compose secrets are bind mounts that keep the host file's owner and mode. `MCP_INSPECTOR_SECRET_KEY` still works, but a key passed that way is readable by anyone who can run `docker inspect` or `docker exec` against the container. If the key file is missing, unreadable or empty, or both variables are set, the Inspector **refuses to read or write the secrets file** rather than falling back to plaintext, and says why in the log and the settings footer. +> A key passed as a file stays out of `docker inspect`, the container's environment, your shell history and the Compose file. The container runs as uid `1000`, and without Swarm, Compose secrets are bind mounts that keep the host file's owner and mode, so the `0600` file must be owned by uid `1000`. On a Linux host where your uid is not `1000`, `sudo chown 1000 ~/.config/mcp-inspector/secret-key`; if the container can't read it, the log and the settings footer say the key file could not be read. Don't loosen the mode to make it readable instead: that hands the key to every other user on the host, and anyone who also gets a copy of the secrets file can then open it. `MCP_INSPECTOR_SECRET_KEY` still works, but a key passed that way is readable by anyone who can run `docker inspect` or `docker exec` against the container. If the key file is missing, unreadable or empty, `MCP_INSPECTOR_SECRET_KEY_FILE` is set to an empty value, or it is set together with a non-blank `MCP_INSPECTOR_SECRET_KEY`, the Inspector **refuses to read or write the secrets file** rather than falling back to plaintext, and says why in the log and the settings footer. > > **Even encrypted, secrets on disk carry moderate risk.** Encryption protects against the file leaking **on its own**. It does not protect against anyone who can also reach the key, which on a single host usually includes root and the `docker` group. Read [what the file store protects against](./secret-storage.md#what-the-file-store-protects-against) before relying on it. If that is not acceptable, don't mount the volume (secrets then stay in memory for the session), or run the Inspector outside a container, where it uses the OS keychain. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 3e6a74f65b..8ea1ba6123 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -81,7 +81,7 @@ Where the Inspector's secrets (OAuth client secrets, the enterprise IdP client s | `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. An empty or whitespace-only value counts as unset and silently runs automatic selection; any other value is ignored with a warning and also falls back to automatic selection. | | `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `~/.mcp-inspector/secrets.json` | Path of the file store. Lookup order: this variable, then `secrets.json` in `MCP_STORAGE_DIR` when that is set, then `~/.mcp-inspector/secrets.json`. ⚠️ The default sits **beside** the storage directory, not inside it. | | `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store; an empty or whitespace-only value counts as unset. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see [Where secrets are stored](./secret-storage.md#encryption) before rotating it. | -| `MCP_INSPECTOR_SECRET_KEY_FILE` | web, CLI, TUI | unset | Path of a file holding the passphrase; trailing line breaks are removed. Use this for Docker or Compose secrets, so the key stays out of the environment. Setting it together with `MCP_INSPECTOR_SECRET_KEY` is an error, and so is setting it to an empty value. ⚠️ If the file is missing, unreadable or empty, the file store refuses to read or write rather than fall back to plaintext. | +| `MCP_INSPECTOR_SECRET_KEY_FILE` | web, CLI, TUI | unset | Path of a file holding the passphrase; trailing line breaks are removed. Use this for Docker or Compose secrets, so the key stays out of the environment. Setting it together with a non-blank `MCP_INSPECTOR_SECRET_KEY` is an error, and so is setting it to an empty value. ⚠️ If the file is missing, unreadable or empty, the file store refuses to read or write rather than fall back to plaintext. | When no store is configured, the choice also depends on whether the Inspector is running in a container, which it detects from `KUBERNETES_SERVICE_HOST` (or Docker's and Podman's marker files). That variable is set by the orchestrator, not by you. diff --git a/docs/secret-storage.md b/docs/secret-storage.md index 47c2f19adc..e276c0ee65 100644 --- a/docs/secret-storage.md +++ b/docs/secret-storage.md @@ -71,7 +71,7 @@ The path is the first of these that applies: **Use a high-entropy passphrase: generate it, don't choose it.** The random salt stops an attacker from precomputing a table, but it does nothing against guessing. The scrypt cost is deliberately low because the derivation runs on every read and write. Anyone who obtains `secrets.json` can therefore test candidate passphrases quickly and offline, so treat this value like any other credential, not like a memorable password. -**Prefer the key file.** `MCP_INSPECTOR_SECRET_KEY_FILE` reads the passphrase from a file, with trailing line breaks removed, so it never has to sit in the environment, a shell profile, an `.env` file or a Compose file. It is the variable Docker and Compose secrets are built for (see the [Docker guide](./docker.md)). Setting both variables is an error, and so is setting `MCP_INSPECTOR_SECRET_KEY_FILE` to an empty value: unlike an empty `MCP_INSPECTOR_SECRET_KEY`, which switches encryption off, it is taken as a key file that failed to arrive. If the key file is missing, unreadable or empty, the variable is blank, or both are set, the file store **refuses to read or write** instead of falling back to plaintext: saves fail, and the startup warning and settings footer report the file as unreadable, with the reason. +**Prefer the key file.** `MCP_INSPECTOR_SECRET_KEY_FILE` reads the passphrase from a file, with trailing line breaks removed, so it never has to sit in the environment, a shell profile, an `.env` file or a Compose file. It is the variable Docker and Compose secrets are built for (see the [Docker guide](./docker.md)). Setting a non-blank `MCP_INSPECTOR_SECRET_KEY` together with `MCP_INSPECTOR_SECRET_KEY_FILE` is an error; a blank `MCP_INSPECTOR_SECRET_KEY` still counts as unset, so the key file is used. Setting `MCP_INSPECTOR_SECRET_KEY_FILE` to an empty value is also an error: unlike an empty `MCP_INSPECTOR_SECRET_KEY`, which switches encryption off, it is taken as a key file that failed to arrive. If the key file is missing, unreadable or empty, the variable is blank, or both are set, the file store **refuses to read or write** instead of falling back to plaintext: saves fail, and the startup warning and settings footer report the file as unreadable, with the reason. **Adding a passphrase later is safe.** The next write upgrades an existing plaintext file in place. Until that write happens the existing values are still readable, and the banner and footer keep saying so. They do not report the file as encrypted just because the variable is now set. From b4af7e4a3e955ffcf7b5647b51f4eefe53bf280f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 22 Sep 2026 23:44:46 -0400 Subject: [PATCH 62/68] fix: refuse a key file that is the secrets file itself (#2448 review) Reading secrets.json as the passphrase and then overwriting it with ciphertext would change the key under the file on the next start. Checked by path and by inode, before reading. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .../auth/node/file-secret-store.test.ts | 38 +++++++++++++++++++ core/auth/node/file-secret-store.ts | 35 +++++++++++++++-- core/auth/secret-storage-info.ts | 6 +-- docs/environment-variables.md | 2 +- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 3025237c34..711977c4f2 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -1692,6 +1692,44 @@ describe("FileSecretStore with MCP_INSPECTOR_SECRET_KEY_FILE (#2447)", () => { expect(await fs.readFile(filePath(), "utf-8")).toBe(before); }); + it("refuses a key file that is the secrets file itself", async () => { + // Otherwise the plaintext JSON becomes the passphrase, the next save + // replaces it with ciphertext, and the following start cannot open it. + const plain = new FileSecretStore({ filePath: filePath() }); + await plain.set("alpha", "env:A", "1"); + const before = await fs.readFile(filePath(), "utf-8"); + vi.stubEnv(SECRET_KEY_ENV, ""); + vi.stubEnv(SECRET_KEY_FILE_ENV, filePath()); + const store = new FileSecretStore({ filePath: filePath() }); + expect(store.keyProblem).toMatch(/is the secrets file itself/); + await expect(store.set("alpha", "env:B", "2")).rejects.toThrow( + SecretStoreUnavailableError, + ); + expect(await fs.readFile(filePath(), "utf-8")).toBe(before); + }); + + it("refuses a symlink or hard link to the secrets file", async () => { + await fs.writeFile(filePath(), "{}\n"); + const link = path.join(tmpDir, "key-symlink"); + const hard = path.join(tmpDir, "key-hardlink"); + await fs.symlink(filePath(), link); + await fs.link(filePath(), hard); + for (const keyPath of [link, hard]) { + expect( + resolveSecretPassphrase({ [SECRET_KEY_FILE_ENV]: keyPath }, filePath()) + .problem, + ).toMatch(/is the secrets file itself/); + } + }); + + it("matches by path when the secrets file does not exist yet", () => { + const result = resolveSecretPassphrase( + { [SECRET_KEY_FILE_ENV]: filePath() }, + filePath(), + ); + expect(result.problem).toMatch(/is the secrets file itself/); + }); + it("an explicit passphrase option ignores the environment", () => { vi.stubEnv(SECRET_KEY_FILE_ENV, path.join(tmpDir, "missing-key")); const store = new FileSecretStore({ diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index f82099004b..1d7292b686 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -63,7 +63,7 @@ */ import * as crypto from "node:crypto"; -import { readFileSync } from "node:fs"; +import { readFileSync, statSync } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { readStoreFile, writeStoreFile } from "../../storage/store-io.js"; @@ -136,6 +136,23 @@ export interface SecretPassphrase { problem?: string; } +/** + * Whether two paths name the same file. Equal resolved paths always do; past + * that, matching device and inode catch a symlink or hard link. A path that + * cannot be stat'd (it does not exist yet, as a secrets file before its first + * save often does) matches only by path. + */ +function isSameFile(a: string, b: string): boolean { + if (path.resolve(a) === path.resolve(b)) return true; + try { + const sa = statSync(a); + const sb = statSync(b); + return sa.dev === sb.dev && sa.ino === sb.ino; + } catch { + return false; + } +} + /** * Resolve the passphrase from `SECRET_KEY_ENV` or `SECRET_KEY_FILE_ENV`. * @@ -155,6 +172,7 @@ export interface SecretPassphrase { */ export function resolveSecretPassphrase( env: NodeJS.ProcessEnv = process.env, + secretFilePath?: string, ): SecretPassphrase { const direct = env[SECRET_KEY_ENV]; const hasDirect = direct !== undefined && direct.trim() !== ""; @@ -176,6 +194,16 @@ export function resolveSecretPassphrase( return { problem: `${SECRET_KEY_FILE_ENV} is set but empty` }; } const resolved = path.resolve(keyFile); + // A key file that *is* the secrets file reads the plaintext JSON as the + // passphrase, and the next save replaces it with ciphertext — so on the + // following start the key has changed to that ciphertext and nothing it + // wrote can be opened again. Checked before reading, by path and by inode + // (a symlink or hard link names the same file under another path). + if (secretFilePath !== undefined && isSameFile(resolved, secretFilePath)) { + return { + problem: `${SECRET_KEY_FILE_ENV} (${resolved}) is the secrets file itself; point it at a separate key file`, + }; + } let contents: string; try { contents = readFileSync(resolved, "utf-8"); @@ -400,7 +428,7 @@ export class FileSecretStore implements SecretStore { const resolved = options.passphrase !== undefined ? { passphrase: options.passphrase } - : resolveSecretPassphrase(); + : resolveSecretPassphrase(process.env, this.filePath); const raw = resolved.passphrase; this.passphrase = raw && raw.trim() ? raw : undefined; this.keyProblem = resolved.problem; @@ -420,7 +448,8 @@ export class FileSecretStore implements SecretStore { * no file yet, or when it cannot be read or parsed. * * Separate from {@link encrypted} because the two genuinely disagree for a - * whole session: adding `MCP_INSPECTOR_SECRET_KEY` to an install that + * whole session: adding a passphrase (`MCP_INSPECTOR_SECRET_KEY` or + * `MCP_INSPECTOR_SECRET_KEY_FILE`) to an install that * already has a plaintext file flips `encrypted` to true immediately, * while the bytes stay readable until the next `set`. A descriptor built * from the policy would tell that user "File (encrypted)" while their diff --git a/core/auth/secret-storage-info.ts b/core/auth/secret-storage-info.ts index 05c0539926..2b9b84d61f 100644 --- a/core/auth/secret-storage-info.ts +++ b/core/auth/secret-storage-info.ts @@ -45,9 +45,9 @@ export interface SecretStorageInfo { * True when the secrets file is *currently* in the clear. Read off the * file's own envelope rather than off whether a passphrase is * configured, because those two disagree for a whole session: adding - * `MCP_INSPECTOR_SECRET_KEY` to an install that already has a plaintext - * file makes the next write encrypt, while the existing bytes stay - * readable until then. Reporting the intent would tell that user their + * `MCP_INSPECTOR_SECRET_KEY` (or `MCP_INSPECTOR_SECRET_KEY_FILE`) to an + * install that already has a plaintext file makes the next write encrypt, + * while the existing bytes stay readable until then. Reporting the intent would tell that user their * secrets were encrypted while they were not. * * **File-only, and omitted entirely for the other kinds** — not "false". diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 8ea1ba6123..cc25f2d134 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -81,7 +81,7 @@ Where the Inspector's secrets (OAuth client secrets, the enterprise IdP client s | `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. An empty or whitespace-only value counts as unset and silently runs automatic selection; any other value is ignored with a warning and also falls back to automatic selection. | | `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `~/.mcp-inspector/secrets.json` | Path of the file store. Lookup order: this variable, then `secrets.json` in `MCP_STORAGE_DIR` when that is set, then `~/.mcp-inspector/secrets.json`. ⚠️ The default sits **beside** the storage directory, not inside it. | | `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store; an empty or whitespace-only value counts as unset. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see [Where secrets are stored](./secret-storage.md#encryption) before rotating it. | -| `MCP_INSPECTOR_SECRET_KEY_FILE` | web, CLI, TUI | unset | Path of a file holding the passphrase; trailing line breaks are removed. Use this for Docker or Compose secrets, so the key stays out of the environment. Setting it together with a non-blank `MCP_INSPECTOR_SECRET_KEY` is an error, and so is setting it to an empty value. ⚠️ If the file is missing, unreadable or empty, the file store refuses to read or write rather than fall back to plaintext. | +| `MCP_INSPECTOR_SECRET_KEY_FILE` | web, CLI, TUI | unset | Path of a file holding the passphrase; trailing line breaks are removed. Use this for Docker or Compose secrets, so the key stays out of the environment. Setting it together with a non-blank `MCP_INSPECTOR_SECRET_KEY` is an error, and so is setting it to an empty value. ⚠️ If the file is missing, unreadable or empty, or is the secrets file itself, the file store refuses to read or write rather than fall back to plaintext. | When no store is configured, the choice also depends on whether the Inspector is running in a container, which it detects from `KUBERNETES_SERVICE_HOST` (or Docker's and Podman's marker files). That variable is set by the orchestrator, not by you. From 8a78c7420e3b6fe701fbe614fb9ea9adce483bc8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 00:12:35 -0400 Subject: [PATCH 63/68] docs(skills): stop board lookups from trusting item-list --limit (#2451) gh project item-list truncates silently past --limit, and board #28 now holds more than the 500 the lookups asked for, so existing cards read as missing. Issue cards are now looked up from the issue's projectItems, which is independent of board size. Draft-card lookups and whole-board dumps (GHSA lookup, snapshot, recovery, triage sweep and audit) use --limit 2000 and assert .items|length == .totalCount, failing closed on a truncated listing or a failed gh call. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 102 +++++++++++++++++++-------- .claude/skills/issue-triage/SKILL.md | 26 +++++-- 2 files changed, 91 insertions(+), 37 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index 3a863334e5..ddd17ce53c 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -26,6 +26,28 @@ The two projects have their own field and option IDs and none of them are interchangeable — a #28 id passed to #11 is rejected with "option Id does not belong to the field", so the mistake is at least loud. +## Finding a card without trusting `--limit` + +⚠️ **`gh project item-list --limit N` truncates silently.** Past `N` it returns +the first `N` items with no error and no warning, so a `select` over the result +matches nothing and a card that exists reads as missing. Board #28 passed 500 +items in September 2026 — double the figure quoted here two months earlier — and +the old `--limit 500` lookups reported a carded issue as unboarded and 16 GHSA +drafts as absent in one session (#2451). A limit is a guess about the board's +size; don't make the recipes depend on it being right. + +- **An issue's card is looked up from the issue**, which is independent of board + size — see [Move an existing card](#move-an-existing-card). +- **A draft card or a whole-board dump** (the GHSA lookup, the snapshot, the + recovery dump, `/issue-triage`'s sweep and audit) genuinely needs the full + listing. Those recipes use a limit with headroom **and** compare the result's + `.items | length` against the `.totalCount` that `item-list --format json` + also returns, so a truncated listing fails loudly instead of passing as + complete. The check also catches a failed `gh` call, whose empty output has + neither key. Where a later step reads the dump from a file, an incomplete dump + is deleted, so that step fails on the missing file rather than running on + partial data. + **Only issues go on a board — never PRs, never draft cards.** A PR is tracked through the card of the issue it closes. @@ -33,18 +55,21 @@ through the card of the issue it closes. titled `[GHSA-xxxx-yyyy-zzzz] - …` because a real issue would disclose it before a fix exists. The flow is `/security-advisory`. -⚠️ **A draft card has no repository and no issue number, so the lookups below -cannot find one.** Every `select(.content.repository==… and .content.number==…)` -matches nothing against a draft, and `item-add --url` has no URL to be given. -Look it up by **title** instead, then feed that item id to `item-edit` or -`item-delete` exactly as usual: +⚠️ **A draft card has no repository and no issue number, so the issue-side +lookup below cannot find one**, and `item-add --url` has no URL to be given. +Look it up by **title** in the full listing instead, then feed that item id to +`item-edit` or `item-delete` exactly as usual: ```sh GHSA=GHSA-xxxx-yyyy-zzzz # the advisory's real id -ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ - --jq '.items[] | select(.content.type=="DraftIssue") - | select(.content.title | startswith("['"$GHSA"']")) | .id') -[ -n "$ITEM_ID" ] || echo "no draft card titled [$GHSA] on #28" >&2 +BOARD=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000) +if jq -e '(.items | length) == .totalCount' <<<"$BOARD" >/dev/null; then + ITEM_ID=$(jq -r '.items[] | select(.content.type=="DraftIssue") + | select(.content.title | startswith("['"$GHSA"']")) | .id' <<<"$BOARD") + [ -n "$ITEM_ID" ] || echo "no draft card titled [$GHSA] on #28" >&2 +else + echo "item-list incomplete or failed — raise --limit; not concluding anything" >&2 +fi ``` Match on the **bracketed GHSA id**, not on words from the summary — a summary is @@ -145,21 +170,30 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" \ ### Move an existing card -Look the item id up by issue number rather than re-adding it. Keep `--limit` -above the board's item count (~265 as of 2026-08-01) — past it `item-list` -truncates **silently**, `select` matches nothing, and `item-edit --id ""` fails -with an opaque node-resolution error rather than saying the limit was too low. - -⚠️ **Filter by repository, not by number alone.** These are **org** projects and -issue numbers are **repo-local**, so an unfiltered `select` can match another -repo's issue that happens to share the number — board #11 really does carry a -`modelcontextprotocol/servers` card — and then moves or deletes the wrong card, -or passes two ids at once (Copilot). +Look the item id up **from the issue** rather than re-adding it. An issue's +`projectItems` lists the cards it has on every board, so the lookup does not +depend on how many items the board holds (see [Finding a card without trusting +`--limit`](#finding-a-card-without-trusting---limit)). Select the card by the +board's **node id**, not its number: project numbers are per-owner, and an issue +can also sit on a user-owned project that happens to be numbered 28. Querying +through the repository also means the issue number cannot match another repo's +issue — board #11 really does carry a `modelcontextprotocol/servers` card. For +#11, swap in its node id `PVT_kwDOCt2Azc4BA5sz`. + +Check the id is non-empty before using it: `item-edit --id ""` fails with an +opaque node-resolution error rather than saying the card was not found. The +`|| ITEM_ID=` matters too — on a GraphQL error (a number that is a PR, not an +issue; a rate limit) `gh api` still prints the raw error JSON to stdout, which +would otherwise land in `ITEM_ID` as a non-empty "id". ```sh -ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ - --jq '.items[] | select(.content.repository=="modelcontextprotocol/inspector" - and .content.number==) | .id') +N= +ITEM_ID=$(gh api graphql -F n="$N" -f query='query($n:Int!){ + repository(owner:"modelcontextprotocol",name:"inspector"){issue(number:$n){ + projectItems(first:20){nodes{id project{id}}}}}}' \ + --jq '.data.repository.issue.projectItems.nodes[] + | select(.project.id=="PVT_kwDOCt2Azc4BJVxt") | .id') || ITEM_ID= +[ -n "$ITEM_ID" ] || echo "#$N has no card on #28 (or the lookup failed)" >&2 # e.g. Status → In Review, when its PR opens gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" \ --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 159c8a02 @@ -172,10 +206,8 @@ not planned / obsolete / superseded shipped nothing, so its card is **deleted**, not parked in Done: ```sh -ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ - --jq '.items[] | select(.content.repository=="modelcontextprotocol/inspector" - and .content.number==) | .id') -gh project item-delete 28 --owner modelcontextprotocol --id "$ITEM_ID" +# ITEM_ID from the issue-side lookup in "Move an existing card" above. +[ -n "$ITEM_ID" ] && gh project item-delete 28 --owner modelcontextprotocol --id "$ITEM_ID" ``` Deleting the card removes it from the board only — **the issue itself is @@ -220,7 +252,8 @@ Safe alternatives, in order of preference: **including its `id`**, appending only the new one. `ProjectV2SingleSelectFieldOptionInput.id` is an optional `String`, so a mixed list works. Verify afterward that no card lost its value — snapshot - `gh project item-list … --format json` before and after and diff; don't just + `gh project item-list … --format json --limit 2000` before and after, check + each is complete the way the snapshot below does, and diff; don't just spot-check. Send those dumps to `$BOARD_TMP` too, for the reason above. Both the `Incoming` Status option and the Urgent/High/Medium/Low Priority @@ -242,11 +275,17 @@ PR (Copilot). ```sh BOARD_TMP=$(mktemp -d) -gh project item-list 28 --owner modelcontextprotocol --format json --limit 600 \ +gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 \ > "$BOARD_TMP/board-snapshot.json" -echo "snapshot: $BOARD_TMP/board-snapshot.json" # note the path; you need it to recover +# A truncated snapshot cannot restore the cards it dropped — refuse to proceed on one. +jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-snapshot.json" >/dev/null \ + && echo "snapshot: $BOARD_TMP/board-snapshot.json" \ + || { echo "SNAPSHOT INCOMPLETE — raise --limit and retake it before editing options" >&2 + rm -f "$BOARD_TMP/board-snapshot.json"; } ``` +Note the printed path; you need it to recover. + ### Recovering from a deleted option This has happened twice — once via the API (~197 items, reconstructed by @@ -264,8 +303,11 @@ and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`. BOARD_TMP=${BOARD_TMP:-$(mktemp -d)} # 1. Which cards lost their value, and what did they hold? -gh project item-list 28 --owner modelcontextprotocol --format json --limit 600 \ +gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 \ > "$BOARD_TMP/board-broken.json" +jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-broken.json" >/dev/null \ + || { echo "board-broken.json INCOMPLETE — raise --limit and re-run" >&2 + rm -f "$BOARD_TMP/board-broken.json"; } # so the steps below fail, not undercount jq -r '[.items[]|select(.status==null)|.id]' "$BOARD_TMP/board-broken.json" \ > "$BOARD_TMP/lost-ids.json" jq -r --slurpfile L "$BOARD_TMP/lost-ids.json" '($L[0]) as $lost diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index 0fa0f112fd..5904e080b2 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -51,13 +51,19 @@ double-boarded (a real defect a past sweep introduced — #1929 reproduced it). D=$(mktemp -d) gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 \ --json number,milestone > "$D/open.json" -# Union of BOTH boards, filtered to this repo — org boards can hold other repos' issues. +# item-list truncates SILENTLY past --limit (and a failed call writes nothing), and a +# missing card reads as an "unboarded" issue that then gets double-carded — so an +# incomplete dump is deleted, and the steps below fail on the missing file. for P in 28 11; do - gh project item-list $P --owner modelcontextprotocol --format json --limit 700 \ - | jq '[.items[] | select(.content.type=="Issue" - and .content.repository=="modelcontextprotocol/inspector") - | .content.number]' -done | jq -s 'add' > "$D/boarded.json" + gh project item-list $P --owner modelcontextprotocol --format json --limit 2000 > "$D/b$P.json" + jq -e '(.items | length) == .totalCount' "$D/b$P.json" >/dev/null \ + || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; } +done +# Union of BOTH boards, filtered to this repo — org boards can hold other repos' issues. +jq -s '[.[].items[] | select(.content.type=="Issue" + and .content.repository=="modelcontextprotocol/inspector") + | .content.number]' "$D/b28.json" "$D/b11.json" > "$D/boarded.json" \ + || rm -f "$D/boarded.json" # Prints the destination too: milestoned already → Todo, otherwise → Incoming. jq -r --slurpfile b "$D/boarded.json" \ '.[] | select(.number as $n | ($b[0]|index($n))|not) @@ -218,8 +224,14 @@ D=$(mktemp -d); R=modelcontextprotocol/inspector # the last check below reads closed issues' state reasons. gh issue list --repo $R --state all --limit 2000 \ --json number,state,stateReason,labels,milestone > "$D/i.json" +# item-list truncates SILENTLY past --limit (and a failed call writes nothing); an +# incomplete dump would make every check below lie, so it is deleted and the audit +# fails on the missing file instead. for P in 28 11; do gh project item-list $P --owner modelcontextprotocol \ - --format json --limit 700 > "$D/b$P.json"; done + --format json --limit 2000 > "$D/b$P.json" + jq -e '(.items | length) == .totalCount' "$D/b$P.json" >/dev/null \ + || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; } +done jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b11.json" --arg R "$R" ' ($o[0] | map({key:(.number|tostring), value:{st:.state, sr:(.stateReason // ""), lab:[.labels[].name], ms:(.milestone.title // null)}}) | from_entries) as $M From eabbfc4d2d5b60db444a546ceefa00fe7de12d22 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 00:33:11 -0400 Subject: [PATCH 64/68] docs(skills): fail closed on a missing ITEM_ID and an incomplete dump (#2452 review) Clear ITEM_ID before the GHSA lookup so a failed listing cannot leave an earlier id in place; guard item-edit and item-delete on a non-empty id, and split the issue-side lookup from the Status edit so the delete recipe does not inherit it; end every dump guard with false so an incomplete dump returns non-zero; widen projectItems to first:100. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 36 ++++++++++++++++++++-------- .claude/skills/issue-triage/SKILL.md | 4 ++-- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index ddd17ce53c..35b57011ec 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -62,6 +62,7 @@ Look it up by **title** in the full listing instead, then feed that item id to ```sh GHSA=GHSA-xxxx-yyyy-zzzz # the advisory's real id +ITEM_ID= # never let an earlier lookup's id survive a failed one BOARD=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000) if jq -e '(.items | length) == .totalCount' <<<"$BOARD" >/dev/null; then ITEM_ID=$(jq -r '.items[] | select(.content.type=="DraftIssue") @@ -180,23 +181,33 @@ through the repository also means the issue number cannot match another repo's issue — board #11 really does carry a `modelcontextprotocol/servers` card. For #11, swap in its node id `PVT_kwDOCt2Azc4BA5sz`. -Check the id is non-empty before using it: `item-edit --id ""` fails with an +The mutation runs only on a non-empty id: `item-edit --id ""` fails with an opaque node-resolution error rather than saying the card was not found. The `|| ITEM_ID=` matters too — on a GraphQL error (a number that is a PR, not an issue; a rate limit) `gh api` still prints the raw error JSON to stdout, which -would otherwise land in `ITEM_ID` as a non-empty "id". +would otherwise land in `ITEM_ID` as a non-empty "id". `first:100` is the +connection's maximum page; it counts the boards one issue is on, not the cards +on a board, so it has no board-size exposure. ```sh N= ITEM_ID=$(gh api graphql -F n="$N" -f query='query($n:Int!){ repository(owner:"modelcontextprotocol",name:"inspector"){issue(number:$n){ - projectItems(first:20){nodes{id project{id}}}}}}' \ + projectItems(first:100){nodes{id project{id}}}}}}' \ --jq '.data.repository.issue.projectItems.nodes[] | select(.project.id=="PVT_kwDOCt2Azc4BJVxt") | .id') || ITEM_ID= [ -n "$ITEM_ID" ] || echo "#$N has no card on #28 (or the lookup failed)" >&2 -# e.g. Status → In Review, when its PR opens -gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" \ - --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 159c8a02 +``` + +Then edit it — e.g. Status → In Review, when its PR opens: + +```sh +if [ -n "$ITEM_ID" ]; then + gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" \ + --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 159c8a02 +else + echo "no ITEM_ID — nothing edited" >&2 +fi ``` ### Delete a card @@ -206,8 +217,13 @@ not planned / obsolete / superseded shipped nothing, so its card is **deleted**, not parked in Done: ```sh -# ITEM_ID from the issue-side lookup in "Move an existing card" above. -[ -n "$ITEM_ID" ] && gh project item-delete 28 --owner modelcontextprotocol --id "$ITEM_ID" +# ITEM_ID from the issue-side LOOKUP block in "Move an existing card" above — +# the lookup only, not the item-edit that follows it. +if [ -n "$ITEM_ID" ]; then + gh project item-delete 28 --owner modelcontextprotocol --id "$ITEM_ID" +else + echo "no ITEM_ID — nothing deleted" >&2 +fi ``` Deleting the card removes it from the board only — **the issue itself is @@ -281,7 +297,7 @@ gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-snapshot.json" >/dev/null \ && echo "snapshot: $BOARD_TMP/board-snapshot.json" \ || { echo "SNAPSHOT INCOMPLETE — raise --limit and retake it before editing options" >&2 - rm -f "$BOARD_TMP/board-snapshot.json"; } + rm -f "$BOARD_TMP/board-snapshot.json"; false; } ``` Note the printed path; you need it to recover. @@ -307,7 +323,7 @@ gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 > "$BOARD_TMP/board-broken.json" jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-broken.json" >/dev/null \ || { echo "board-broken.json INCOMPLETE — raise --limit and re-run" >&2 - rm -f "$BOARD_TMP/board-broken.json"; } # so the steps below fail, not undercount + rm -f "$BOARD_TMP/board-broken.json"; false; } # so the steps below fail, not undercount jq -r '[.items[]|select(.status==null)|.id]' "$BOARD_TMP/board-broken.json" \ > "$BOARD_TMP/lost-ids.json" jq -r --slurpfile L "$BOARD_TMP/lost-ids.json" '($L[0]) as $lost diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index 5904e080b2..ba34eca105 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -57,7 +57,7 @@ gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 \ for P in 28 11; do gh project item-list $P --owner modelcontextprotocol --format json --limit 2000 > "$D/b$P.json" jq -e '(.items | length) == .totalCount' "$D/b$P.json" >/dev/null \ - || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; } + || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; false; } done # Union of BOTH boards, filtered to this repo — org boards can hold other repos' issues. jq -s '[.[].items[] | select(.content.type=="Issue" @@ -230,7 +230,7 @@ gh issue list --repo $R --state all --limit 2000 \ for P in 28 11; do gh project item-list $P --owner modelcontextprotocol \ --format json --limit 2000 > "$D/b$P.json" jq -e '(.items | length) == .totalCount' "$D/b$P.json" >/dev/null \ - || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; } + || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; false; } done jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b11.json" --arg R "$R" ' ($o[0] | map({key:(.number|tostring), value:{st:.state, sr:(.stateReason // ""), From 4964bb6231de4491fd00aa0d8d87ec480e68054f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 00:41:33 -0400 Subject: [PATCH 65/68] docs(skills): name every id a #11 card needs, not just the lookup's (#2452 review) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index 35b57011ec..ce9b4f58e6 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -178,8 +178,13 @@ depend on how many items the board holds (see [Finding a card without trusting board's **node id**, not its number: project numbers are per-owner, and an issue can also sit on a user-owned project that happens to be numbered 28. Querying through the repository also means the issue number cannot match another repo's -issue — board #11 really does carry a `modelcontextprotocol/servers` card. For -#11, swap in its node id `PVT_kwDOCt2Azc4BA5sz`. +issue — board #11 really does carry a `modelcontextprotocol/servers` card. + +**For a v1 card on #11, swap every #28 id, not just the lookup's.** #11's node +id `PVT_kwDOCt2Azc4BA5sz` goes in both the lookup's `select` and the edit's +`--project-id`; the edit also takes #11's own Status field +`PVTSSF_lADOCt2Azc4BA5szzgzkS-g` and an option id from [its +table](#v1-board-11-ids); and a delete is `item-delete 11`. The mutation runs only on a non-empty id: `item-edit --id ""` fails with an opaque node-resolution error rather than saying the card was not found. The From 68dc5f53510296c07681daec3fd684d6d952028f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 00:49:48 -0400 Subject: [PATCH 66/68] docs(skills): gate the recovery steps on a complete dump (#2452 review) A deleted dump still let the redirect create an empty lost-ids.json, so the re-apply loop ran zero times and exited 0. lost-ids.json is now written only inside the completeness check, and step 3 refuses to run without it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 39 +++++++++++++++++++------------ 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index ce9b4f58e6..5acbcf98ab 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -323,28 +323,37 @@ and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`. # 0. Same temp dir the snapshot went to — keep every dump out of the worktree. BOARD_TMP=${BOARD_TMP:-$(mktemp -d)} -# 1. Which cards lost their value, and what did they hold? +# 1. Which cards lost their value, and what did they hold? lost-ids.json is +# written ONLY from a complete dump — step 3 refuses to run without it, so an +# incomplete dump cannot become a re-apply loop that silently does nothing. +rm -f "$BOARD_TMP/lost-ids.json" gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 \ > "$BOARD_TMP/board-broken.json" -jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-broken.json" >/dev/null \ - || { echo "board-broken.json INCOMPLETE — raise --limit and re-run" >&2 - rm -f "$BOARD_TMP/board-broken.json"; false; } # so the steps below fail, not undercount -jq -r '[.items[]|select(.status==null)|.id]' "$BOARD_TMP/board-broken.json" \ - > "$BOARD_TMP/lost-ids.json" -jq -r --slurpfile L "$BOARD_TMP/lost-ids.json" '($L[0]) as $lost - | [.items[] | select(.id as $i | $lost|index($i)) | .status // "(none)"] - | group_by(.) | map({s:.[0],c:length}) | .[] | "was \(.s): \(.c)"' \ - "$BOARD_TMP/board-snapshot.json" +if jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-broken.json" >/dev/null; then + jq -r '[.items[]|select(.status==null)|.id]' "$BOARD_TMP/board-broken.json" \ + > "$BOARD_TMP/lost-ids.json" || rm -f "$BOARD_TMP/lost-ids.json" + jq -r --slurpfile L "$BOARD_TMP/lost-ids.json" '($L[0]) as $lost + | [.items[] | select(.id as $i | $lost|index($i)) | .status // "(none)"] + | group_by(.) | map({s:.[0],c:length}) | .[] | "was \(.s): \(.c)"' \ + "$BOARD_TMP/board-snapshot.json" +else + echo "board-broken.json INCOMPLETE — raise --limit and re-run step 1" >&2 + rm -f "$BOARD_TMP/board-broken.json" +fi # 2. Recreate the option, echoing every surviving option's id (see above). # NOTE: the recreated option gets a NEW id — the deleted one never comes back. # 3. Re-apply it to the orphaned cards. -for id in $(jq -r '.[]' "$BOARD_TMP/lost-ids.json"); do - gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$id" \ - --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id - sleep 0.4 -done +if [ -s "$BOARD_TMP/lost-ids.json" ]; then + for id in $(jq -r '.[]' "$BOARD_TMP/lost-ids.json"); do + gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$id" \ + --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id + sleep 0.4 + done +else + echo "no lost-ids.json — step 1 did not complete; nothing re-applied" >&2 +fi ``` Step 1's grouping is the safety check: confirm the orphaned set is exactly the From 24b1a12f0db0b8cc41e421761eedec30b3159b3f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 00:54:46 -0400 Subject: [PATCH 67/68] docs(skills): refuse to re-apply without a usable snapshot (#2452 review) The snapshot is the only record of what the orphaned cards held, and an incomplete one is now deleted, so step 1 drops lost-ids.json when the snapshot report cannot run. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index 5acbcf98ab..c34f1c1652 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -324,8 +324,9 @@ and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`. BOARD_TMP=${BOARD_TMP:-$(mktemp -d)} # 1. Which cards lost their value, and what did they hold? lost-ids.json is -# written ONLY from a complete dump — step 3 refuses to run without it, so an -# incomplete dump cannot become a re-apply loop that silently does nothing. +# kept ONLY when the dump is complete AND the snapshot reports what those cards +# held — step 3 refuses to run without it, so neither a truncated dump nor a +# missing snapshot can turn into a silent no-op or an unconfirmed re-apply. rm -f "$BOARD_TMP/lost-ids.json" gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 \ > "$BOARD_TMP/board-broken.json" @@ -335,7 +336,9 @@ if jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-broken.json" >/dev jq -r --slurpfile L "$BOARD_TMP/lost-ids.json" '($L[0]) as $lost | [.items[] | select(.id as $i | $lost|index($i)) | .status // "(none)"] | group_by(.) | map({s:.[0],c:length}) | .[] | "was \(.s): \(.c)"' \ - "$BOARD_TMP/board-snapshot.json" + "$BOARD_TMP/board-snapshot.json" \ + || { echo "no usable snapshot — cannot confirm what these cards held; not re-applying" >&2 + rm -f "$BOARD_TMP/lost-ids.json"; } else echo "board-broken.json INCOMPLETE — raise --limit and re-run step 1" >&2 rm -f "$BOARD_TMP/board-broken.json" From f9dba02d2ad5f54d9b838a90d1671884217631a0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 01:09:43 -0400 Subject: [PATCH 68/68] chore: bump version to 2.8.0 (#2453) npm audit --audit-level=high reports 0 vulnerabilities across the root and all four client installs, so no audit fixes precede the bump. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 641a95b421..3a3ec4d2a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.7.0", + "version": "2.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.7.0", + "version": "2.8.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { diff --git a/package.json b/package.json index 294ce33425..01bef624a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.7.0", + "version": "2.8.0", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP",