A developer tool for inspecting[Model Context Protocol(MCP) servers. It ships as a single package,`@modelcontextprotocol/inspector`, that provides three ways to inspect a server:
- **Web**— a Vite + React +](https://modelcontextprotocol.io)[Mantinesingle-page app with a Node backend.
- **CLI**— a scriptable command-line client for automation, CI, and fast agent feedback loops.
- **TUI**— an interactive terminal UI built with](https://mantine.dev)[Ink.
All three run through one global`mcp-inspector`binary:
```
`npx @modelcontextprotocol/inspector # web UI (default) npx @modelcontextprotocol/inspector --cli # CLI npx @modelcontextprotocol/inspector --tui # TUI`
```
**Upgrading from v1?**Read the](https://github.com/vadimdemedes/ink)[v1 → v2 migration guide— 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](https://github.com/modelcontextprotocol/inspector/blob/HEAD/docs/v1-to-v2-migration.md)[`AGENTS.md`for branch/board conventions.
v2 is**not**an npm workspace. Each client under`clients/*`keeps its own`package.json`and`node_modules`; shared code lives in`core/`and is consumed via a`@inspector/core`build-time alias (no`package.json`of its own). A single`npm install`at the root cascades installs into every client (see](https://github.com/modelcontextprotocol/inspector/blob/HEAD/AGENTS.md)[Setup).
```
`inspector/ ├── clients/ │ ├── web/ # Web client (Vite + React + Mantine). src/ = browser app; server/ = Node dev/prod backend │ ├── cli/ # CLI client (tsup bundle, @inspector/core alias) │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui ├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json) │ ├── auth/ # OAuth: providers, discovery, storage, endpoint overrides, mid-session recovery (browser/node/remote backends) │ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets │ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union │ │ # schema collapse shared by the web and TUI form builders │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime, state stores, transports, config import, │ │ # and the RFC 6570 URI-template helpers the web form and TUI expand through │ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection) │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers for the OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests ├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, verify-dep-lockstep, pack:verify) ├── docs/ # Task-oriented guides (v1→v2 migration, server configuration, MCP App review, launcher/config plan) ├── specification/ # Design/build specifications ├── AGENTS.md # Contribution rules for agents AND humans (see below) └── README.md # You are here`
```
Each client has its own README with client-specific detail:](#setup)[web·](https://github.com/modelcontextprotocol/inspector/blob/HEAD/clients/web/README.md)[cli·](https://github.com/modelcontextprotocol/inspector/blob/HEAD/clients/cli/README.md)[tui·](https://github.com/modelcontextprotocol/inspector/blob/HEAD/clients/tui/README.md)[launcher.
- ](https://github.com/modelcontextprotocol/inspector/blob/HEAD/clients/launcher/README.md)[Migrating from v1 to v2— the v1 → v2 map: CLI flag mapping,`--config`vs.`--catalog`semantics with before/after examples, the Node engine bump (`>=22.7.5`→`>=22.19.0`), env-var renames, and the sub-packages that no longer ship.
- ](https://github.com/modelcontextprotocol/inspector/blob/HEAD/docs/v1-to-v2-migration.md)[MCP server configuration— which server(s) the Inspector connects to:`--catalog`vs.`--config`, ad-hoc targets, the`--`separator, the file format and its Inspector-specific per-server fields. Shared by all three clients; the cli and tui READMEs delegate their server-options sections to it.
- ](https://github.com/modelcontextprotocol/inspector/blob/HEAD/docs/mcp-server-configuration.md)[Reviewing an MCP App— the CLI-first → one-shot-web recipe for automated App-tool review:`--app-info`probe → deep-link navigate → rendered widget, plus OAuth handoff and proxy support.
- ](https://github.com/modelcontextprotocol/inspector/blob/HEAD/docs/mcp-app-review.md)[Launcher and config consolidation— why the launcher runs a client in-process rather than spawning it, and how the shared config processor fits in.
```
`npm install # root install; postinstall cascades into every client`
```
- **Fresh clone:**run`npm install`at the repo root.
- **After a pull that changes a client's dependencies:**re-run`npm install`at the root to re-sync every client.
The cascade (`scripts/install-clients.mjs`) is dev-only — it exits early when the package is installed as a dependency, and the published tarball ships only each client's`build/`, so end users are unaffected. Set`INSPECTOR_SKIP_CLIENT_INSTALL=1`to skip it.
**Where a dependency is declared.**The MCP SDK packages (`@modelcontextprotocol/client`,`core`,`server`,`server-legacy`,`ext-apps`) live in the**root**`package.json`only — never in a client's. Node resolution walks up, so the root install is on every client's chain, and the root manifest is already what the published tarball resolves against. Declaring them per client installs a second copy that can drift from the root's, which is how two versions of`ext-apps`(and of the transitive v1`@modelcontextprotocol/sdk`) ended up in the tree before](https://github.com/modelcontextprotocol/inspector/blob/HEAD/docs/launcher-config-consolidation-plan.md)[#1970— and a second copy of`client`/`core`is the failure`vitest.shared.mts`carries a`dedupe`workaround for. The same root-only placement holds for anything reached solely through root-owned code with no manifest of its own (`test-servers/src`,`core/`), and`vitest.shared.mts`aliases those to the repo root —`express`and`yaml`, both reached through`test-servers/src`, are the two today.**Whether such a package is a`dependency`or a`devDependency`follows from who consumes it at runtime, not from where it is declared:**anything`core/`imports at runtime must be a root**`dependency`**, because the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent.`express`is test-only and is a devDependency;`yaml`currently sits in`dependencies`.**`vite`and`@vitejs/plugin-react`are root`dependencies`for the same reason, not by mistake**— they look like build tooling, but`clients/web/server/start-vite-dev-server.ts`imports them at runtime for`mcp-inspector --web --dev`, and`clients/web/tsup.runner.config.ts`lists both as`external`, so a published install resolves them from the root manifest. Moving them to`devDependencies`would break`--web --dev`for consumers (and the on-demand`vite build`in`ensure-web-build.ts`) while passing every local check. It does mean they show up under`npm audit --omit=dev`, which is a feature: they really are in the production tree.
For day-to-day web iteration, run Vite directly from the web client (fast HMR, no launcher build needed):
The launcher-driven scripts below run the**built**launcher, so build first (`npm run build`):
```
`npm run web # prod web launcher against clients/web/dist npm run web:dev # web launcher in --dev mode (Vite)`
```
`core/`holds the logic shared by all three clients so that web, CLI, and TUI behave identically. Its entry point is the**`InspectorClient`**class (`core/mcp/`), which owns the connection to an MCP server, the request/response lifecycle, and a set of state stores;`core/react/`exposes React hooks over those stores that both the web and TUI (Ink) React trees consume. OAuth (`core/auth/`) is factored into isomorphic logic plus browser/node/remote backends so the same flows work in the browser, in Node, and against a remote backend.
`core/`intentionally has**no`package.json`**— it is not published on its own. Each client bundles it in via a`@inspector/core`alias:
- **CLI / TUI:**`esbuildOptions.alias`in their`tsup.config.ts`maps`@inspector/core`→ the repo`core/`directory, and`noExternal: ](https://github.com/modelcontextprotocol/inspector/issues/1970)[/^@inspector\/core/]`inlines it into the bundle.
- **Web:**the same alias in`clients/web/vite.config.ts`for the browser app and the Node backend runner.
Publishing`core/`as its own package (e.g. for third parties to build on) is deliberately deferred — see issue[#1636.
## Web client: "dumb components" + Storybook
The v2 web client is built from**presentational ("dumb") components**— they accept data and callbacks as props and contain only display logic, with no direct data fetching or client state. State comes from the`@inspector/core`hooks, wired in near the top of the tree. This keeps components isolated, testable, and documentable.
That approach is what makes**Storybook**first-class here: every screen and element component has a`*.stories.tsx`file (96+ stories) that renders it against fixture props. Storybook**play functions**double as interaction tests, run headless in CI (`npm run ci:storybook`, Chromium via Playwright).
Styling follows a strict Mantine-first convention (theme variants and component props over CSS classes,`--inspector-*`CSS custom properties over raw color literals). The full rules live in](https://github.com/modelcontextprotocol/inspector/issues/1636)[`AGENTS.md`under**React instructions**— read them before touching web UI. Element components live in`clients/web/src/components/elements/`; theme variants in`clients/web/src/theme/`.
`test-servers/`provides**composable MCP servers**used by the integration and smoke suites, so tests exercise a real server over a real transport instead of mocks. A server is assembled from**presets**(fixture factories in`test-servers/src/preset-registry.ts`— tools, resources, prompts, tasks, elicitation, sampling, OAuth, …) and can be driven two ways:
- **In-process**— import the factories (`createTestServerHttp`,`createEchoTool`, …) and run the server inside the test's event loop (used by the HTTP integration paths).
- **As a subprocess**—`test-servers/build/test-server-stdio.js`is spawned as a real stdio child (used by the CLI smoke and stdio integration tests).
Configure a server declaratively with a JSON config (see`test-servers/configs/*.json`) selecting presets, then load it via`--config`. Because the servers are spawned as real subprocesses, the build output must exist first:
```
`npm run test-servers:build # (from clients/web) → tsc -p test-servers, emits test-servers/build/`
```
The Vite alias`@modelcontextprotocol/inspector-test-server`(in`clients/web/vite.config.ts`) points at`test-servers/build/index.js`so`getTestMcpServerPath()`resolves to a real`.js`path.
A streamable-HTTP server can also serve the**modern (2026-07-28) protocol era**via the SDK's`createMcpHandler`:
- Set`transport.modern`in the JSON config —`true`for dual-era stateless serving, or`{ "legacy": "reject" }`for modern-only strict.
- Or pass`modern`on the`ServerConfig`for an in-process`createTestServerHttp`.
This is what lets an Inspector connection negotiating`protocolEra: "auto" | "modern"`reach the modern leg (populated`server/discover`, sessionless). See`test-servers/configs/modern-http.json`.
Each config below is a ready-made server for exercising one feature by hand. Load one with`--config`, and unless noted, connect with**Protocol Era = Modern**.
`mcp-app-http.json`serves the`mcp_app_demo`tool (`_meta.ui.resourceUri`) alongside its`mcp_app_demo_widget`UI resource, so the**Apps**tab has a real App to render. It is a plain streamable-HTTP server — connect with the**default (legacy)**protocol era, not Modern.
Open the Apps tab, select`mcp_app_demo`, give it a title and click**Open App**: the widget renders inside the sandbox iframe and exercises the host-side UI protocol surface — host-context render,`size-changed`,`ui/message`, and a log line into the**App logs**panel. Because the widget is served through the sandbox proxy page, this config is also what reproduces](https://github.com/modelcontextprotocol/inspector/blob/HEAD/AGENTS.md)[#1859(a missing`clients/web/static/sandbox_proxy.html`surfaces here as a "Sandbox not loaded" message in place of the widget) — a failure that only ever appeared in an installed package, never in the repo.
For the scripted version of the same flow (`--app-info`probe → deep link → rendered widget), see](https://github.com/modelcontextprotocol/inspector/issues/1859)[Reviewing an MCP App.
`modern-mrtr-http.json`serves the`mrtr_confirm`tool (preset`mrtr_confirm`,`createMrtrTool`) over the modern leg. Its handler returns`inputRequired(...)`embedding a form elicitation, so invoking it produces a real round-trip:`input_required`→ the client fulfils the embedded elicitation and retries with a new id →`complete`.
The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes. Useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping.
`mrtr-showcase-http.json`bundles every MRTR preset in one server:
Run`mrtr_empty`and answer its single elicitation: the Protocol tab groups the exchange as an MRTR conversation ending**COMPLETE**, and the Results panel says**"Empty result — The tool call completed successfully and returned no content."**On the broken build that same result rendered as**"No results yet"**, the panel's pre-run placeholder (](https://github.com/modelcontextprotocol/inspector/blob/HEAD/docs/mcp-app-review.md)[#1860) — so a call the user had just watched succeed read as a call that never ran. An empty`content`array with no`structuredContent`is a legal`CallToolResult`, and the panel only ever mounts once a result exists, so the placeholder wording could not be true there. (The neighbouring half of the same gap — a result whose payload lives only in`structuredContent`— was closed by](https://github.com/modelcontextprotocol/inspector/issues/1860)[#1908.)
The legacy`collect_elicitation`preset calls`server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there. MRTR is the modern replacement.
#### Network tab — standardized headers and error taxonomy
`modern-network-http.json`covers SEP-2243 / SEP-2575. It serves a`get_weather`tool whose`city`argument carries an`x-mcp-header: "City"`annotation, so a modern client mirrors it to`Mcp-Param-City`.
It also serves four`trigger_*`tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status plus JSON-RPC error body:
Open the Network tab to see the mirrored`Mcp-*`headers highlighted, sentinel values decoded, and each error rendered distinctly.
**`Mcp-Param-*`mirroring is built by the Inspector, not the SDK.**The SDK only mirrors inside`client.callTool()`, and skips it in the browser (`detectProbeEnvironment() !== "browser"`). The Inspector routes`tools/call`through`client.request()`to drive MRTR manually, so it builds the mirrored headers itself (](https://github.com/modelcontextprotocol/inspector/issues/1908)[#1846) — on**every**client, web included, since the web client's upstream request is issued by the Node backend rather than the browser. So`get_weather`is callable from web, CLI, and TUI alike, in both the plain and "Run as task" forms.
- `echo`— plain tool.
- `get_weather`— a**valid**`x-mcp-header: "City"`annotation on its`city`argument.
- `invalid_header_tool`— an annotation using the header name`"Bad Header"`. The space makes it an invalid RFC 9110 token, so the whole tool definition is invalid.
- `trigger_invalid_params`— answered with a real`-32602 Invalid params`error whose message is*not*about a missing tool.
Open the Tools tab:`get_weather`'s detail panel shows a**"Mirrored request headers (SEP-2243)"**section (`city → Mcp-Param-City`), and`invalid_header_tool`appears struck-through under an**"Excluded (SEP-2243)"**divider with the reason on hover. A conforming Streamable HTTP client MUST drop it from`tools/list`; the Inspector surfaces*why*.
Under SDK v2 a`tools/call`rejecting with`-32602`renders as a distinct error panel rather than an`isError`result — headed**"Unknown Tool"**when the message names a missing tool, or**"Invalid Parameters"**otherwise (run`trigger_invalid_params`).
`pagination-http.json`serves 12 tools, 12 resources, and 12 prompts (presets`numbered_tools`/`numbered_resources`/`numbered_prompts`,`count: 12`) with a`maxPageSize`of 4 each, so every list paginates into three pages.
Turn on**"Fetch Lists One Page at a Time"**(Server Settings — the`paginatedLists`setting, or the**Paginated**switch in a list sidebar) and the lists load page 1 only (4 items) with a**Load next page**control and an*N pages loaded*status. Each click fetches the next 4 and appends them; Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect.
`structured-output-http.json`serves`list_items`(nested`structuredContent`— objects inside arrays inside an object, the shape from](https://github.com/modelcontextprotocol/inspector/issues/1846)[#1908),`get_temp`(a flat three-key payload), and`echo`(no`outputSchema`at all). It is a plain streamable-HTTP server — connect with the**default (legacy)**protocol era.
Run`list_items`from the Tools tab: the result panel shows the`content](https://github.com/modelcontextprotocol/inspector/issues/1908)[]`text summary ("Found 2 items.")**and**a collapsible**Structured Output**section rendering the schema-validated payload as pretty-printed, copyable JSON. That section is what v2 was dropping — a tool declaring an`outputSchema`returns its real data there, and the text block usually only summarizes it. Run`echo`to confirm the section is absent when a result carries no`structuredContent`.
`duplicate-tool-names-http.json`serves`get_weather`,`get_temp`,`echo`, and`add`, then repeats`get_weather`and`echo`at the end of`tools/list`with the same`name`and a`(duplicate)`title (`duplicateToolNames`). No preset can produce this shape — the SDK's`registerTool`rejects a repeated name — but a real server can and does, and the Inspector has to render it faithfully.
Connect (default legacy era), open the Tools tab, and type`get`into**Search tools**: the list must narrow to exactly the three`get_*`rows. On the broken build it kept a stale`echo`row, because the sidebar keyed rows by`tool.name`alone and the colliding keys orphaned a child during reconciliation ([#1957).
The duplicated copies are appended rather than placed beside their twin on purpose. React matches a leading run of same-key children first, so a head-adjacent duplicate happens to line up and the defect hides; separating the pair is what makes it observable — and it is also the realistic shape, two tool sources concatenated.
`nullable-fields-http.json`serves`record_shipment`, whose four arguments are each declared with Zod's`.nullish()`— "optional**and**explicitly nullable". That compiles to`anyOf: ](https://github.com/modelcontextprotocol/inspector/issues/1957)[<branch>, { "type": "null" }]`, so the real type (and, for the enum, its`enum`list) sits on a branch rather than at the top level.`get_temp`sits alongside it with a plain, non-nullable`units`enum for comparison. Plain streamable-HTTP — connect with the**default (legacy)**protocol era.
Open the Tools tab and select`record_shipment`:`direction`must render as a**Select**(`envio`/`recebimento`) with a clear button that sets it back to`null`,`reference`as a text input,`quantity`as a number input, and`express`as a checkbox. On the broken build every one of them fell through to the raw-JSON textarea, which re-escaped its own contents on each keystroke until the value was unusable ([#1928). The tool echoes the arguments it received, so the result panel shows exactly what was sent.
The**TUI**had the same gap and is worth checking against the same server (`--tui`, then test`record_shipment`):`direction`is a select,`quantity`an integer field,`express`a boolean. Both clients now share one collapse step —`normalizeNullableUnion`in](https://github.com/modelcontextprotocol/inspector/issues/1928)[`core/json/nullableUnion.ts`— precisely so they cannot drift on which schemas they can render.
`rfc6570-templates-http.json`serves two resource templates straight out of](https://github.com/modelcontextprotocol/inspector/blob/HEAD/core/json/nullableUnion.ts)[#1919—`events_by_topic`(`foobar://events/{topic}`) and`events_by_query`(`foobar://events{?topic}`) — each echoing the URI it was matched against, plus a plain`foobar://events`resource (see below). Plain streamable-HTTP; connect with the**default (legacy)**protocol era.
Open the Resources tab and pick**events_by_topic**, then enter`foo/bar`. The request must go out as`foobar://events/foo%2Fbar`, and the result echoes back the URI the server matched. On the broken build the value was spliced in raw, so the slash created a second path segment and the SDK's matcher answered`-32602 Resource not found: foobar://events/foo/bar`— the exact failure in the issue. The same holds for`?`,`#`,`%`, spaces, and non-ASCII text.
**events_by_query**is the half that was invisible: the old`/\{(\w+)\}/g`scan could not see an expression carrying an operator, so no`topic`input was rendered at all. It now appears, marked**Optional**— RFC 6570 drops the whole expression when the variable is undefined, so reading with the field blank requests`foobar://events`, and filling it in requests`foobar://events?topic=foo%2Fbar`. The URI preview beside the title shows the partially-expanded form as you type, leaving unfilled expressions standing as written.
The plain`foobar://events`resource is registered deliberately, not as filler. The SDK's`UriTemplate.match()`compiles`{?topic}`to a**required**`\?topic=(](https://github.com/modelcontextprotocol/inspector/issues/1919)[^&]+)`, so a template alone cannot serve the blank read —`match("foobar://events")`returns`null`. A real server exposes the unfiltered collection as its own resource; the showcase does the same so that step actually resolves.
The web client and the TUI expand through one shared helper,[`core/mcp/uriTemplate.ts`— the web Resources form directly, the TUI via`InspectorClient.readResourceFromTemplate`— and both derive their**form fields**from its parser too, which is the half that makes the sharing real: a form submits values under the names it rendered, so a parser that mangles a name silently drops the value at expansion time. (The CLI is not a consumer: it has no template form, and its`resources/read`passes the already-expanded`--uri`straight through.)
The SDK's`UriTemplate`is still used, but only to*validate*a template (constructing it is what rejects an unclosed expression). Its expander is not, because it is incomplete in five ways — each measured against the pinned SDK, not inferred:
The`;`and`:3`rows are the ones a user sees directly: on the SDK's parse the form renders fields literally labelled`;id`and`id:3`. The`+`/`#`row is silent corruption rather than over-escaping — an IPv6 literal or an already-encoded path arrives at the server altered.
A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`,`{a,}`,`{?}`) —**withholds the read**rather than sending something. Pick**events_malformed**(`foobar://events/{topic:abc}`) to see it: Read Resource is disabled, the reason is printed under the form, and the preview shows the template as the server declared it. The alternative is worse than it looks:`x://{}`would otherwise expand to`x://`with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published.
Literals are pct-encoded on expansion too (RFC 6570 §3.1):`café/{var}`sends`caf%C3%A9/value`, not raw UTF-8 in the path — something the SDK's expander does not do either. And the*names*a template may use are RFC 6570's`varchar`plus a labelled tolerance for`-`and`~`: the conformance suite rejects`{default-graph-uri}`, but real servers publish such names and the SDK's matcher round-trips them, so the Inspector expands them and marks the variable`conforming: false`rather than refusing a resource that demonstrably works.
An**undefined**variable is what omits its expression — a variable defined as the empty string expands (`x{?q}`gives`x?q=`,`x{;q}`gives`x;q`, per RFC 6570 §3.2.7). The expander honors that distinction, so a caller such as`readResourceFromTemplate`can request either URI. Collapsing the two is a*form*concern, not a template one: both clients seed every declared variable with`""`and a text input cannot express "defined but empty", so each form drops its blanks (`definedValues`) on the way in.
Requiredness is a property of the**expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so`{a,b}`with only`a`filled is expandable and a form must not block it.`requiredGroups`returns one entry per non-omittable expression and`hasRequiredValues`asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}`is satisfied by filling`b`and`c`).
`advertised-extensions-http.json`serves`echo`(always) and a`get_weather`tool**gated on the`io.modelcontextprotocol/tasks`extension**(`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on`notifications/initialized`only when the client declared that extension in its`capabilities.extensions`.
- Connect — the Inspector advertises the Tasks extension by default, so the Tools list shows both`echo`and`get_weather`.
- Open**Server Settings → Advertised Extensions**, uncheck**Tasks (io.modelcontextprotocol/tasks)**, and reconnect.
- The client now advertises no extensions, the server never enables`get_weather`, and the Tools list shows only`echo`.
This is the debugging knob for a server legitimately changing tool registration based on what the client advertises. Legacy stateful leg only — the modern per-request leg has no persistent`oninitialized`.
`logging-legacy-http.json`and`logging-modern-http.json`both serve`logging: true`plus a`send_notification`tool that emits a`notifications/message`at a chosen level. The legacy one is a plain streamable-HTTP server; the modern one sets`transport.modern: true`.
- **Legacy**— the**Logs**tab gives a session-scoped**Set Active Level**selector +**Set**button. Calling`send_notification`streams the log into the panel.
- **Modern**— the same tab instead shows**Log Level per Request**. Pick a level to opt in and the client stamps`_meta](https://github.com/modelcontextprotocol/inspector/blob/HEAD/core/mcp/uriTemplate.ts)["io.modelcontextprotocol/logLevel"]`on every subsequent request (verify in the Network tab's request body). Calling`send_notification`streams the log over the request's SSE response. Set it back to**Off**and the same call is silently gated — the request omits the`logLevel`key, so the log never arrives.
That gating is faithful to the spec ("a server MUST NOT emit`notifications/message`for a request that didn't opt in") because`send_notification`emits through the SDK's request-scoped, threshold-aware`extra.log`(`ctx.mcpReq.log`). On the modern leg it reads the per-request`logLevel`opt-in from the request envelope and drops the message when the client didn't opt in or the level is below the requested severity; on legacy it honors the session level from`logging/setLevel`. Because it emits through the request's`notify`, the modern response upgrades to SSE and the log rides the originating request's stream.
`subscriptions-legacy-http.json`and`subscriptions-modern-http.json`both serve three`numbered_resources`with`subscriptions: true`. The legacy one also serves an`update_resource`tool; the modern one sets`transport.modern: true`.
The modern config deliberately**omits**`update_resource`. The SDK's modern leg is stateless/per-request (`createMcpHandler(() => createMcpServer(config))`), so the tool would run against a throwaway server instance — the content change wouldn't persist for the next`resources/read`, and its`resources/updated`wouldn't reach the separate listen stream. More confusing than useful.