The catalogue below is the reference. For how to build and run one, use the /test-servers skill.
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.jsis 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.modernin the JSON config —truefor dual-era stateless serving, or{ "legacy": "reject" }for modern-only strict. - Or pass
modernon theServerConfigfor an in-processcreateTestServerHttp.
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 connect with the era its row names — every row says
which, because the two are not interchangeable and the wrong one usually presents
as a missing capability rather than an error.
| Config | Demonstrates | Issue |
|---|---|---|
mcp-app-http.json (legacy era) |
An MCP App (UI resource + app tool) in the Apps tab | #1859 |
app-elicitation-http.json (legacy era) |
An MCP App rendering a form elicitation | #1854 |
mcp-app-domain-http.json (legacy era) |
An MCP App asking for a dedicated origin (_meta.ui.domain) |
#2056 |
modern-mrtr-http.json (modern era) |
A single MRTR round-trip | — |
mrtr-showcase-http.json (modern era) |
Every MRTR preset in one server | #1860 |
modern-network-http.json (modern era) |
Network tab: Mcp-* headers + error taxonomy |
#1628 |
xmcpheader-modern-http.json (modern era) |
Tools tab: x-mcp-header mirroring and exclusions |
#1632 |
pagination-http.json (legacy era) |
Page-by-page list fetching | #1721 |
empty-cursor-http.json (legacy era) |
Pagination whose page-two cursor is "" |
#2220 |
structured-output-http.json (legacy era) |
Tools tab: a result's structuredContent section |
#1908 |
duplicate-tool-names-http.json (legacy era) |
A tools/list that repeats a tool name |
#1957 |
duplicate-resource-uris-http.json (legacy era) |
A resources/list that repeats a resource URI |
#2206 |
nullable-fields-http.json (legacy era) |
Tools tab: nullable (anyOf + null) arguments |
#1928 |
root-union-schemas-http.json (legacy era) |
Tool schemas whose arguments are a root anyOf / oneOf, including one no branch of which can be offered |
#2123, #2224 |
unportable-schemas-http.json (legacy era) |
Tool schemas a real client rejects, flagged in all three clients | #1005 |
unportable-schemas-many-http.json (legacy era) |
The same constructs at volume — 26 findings over four tools, enough to bury the argument form | #2205 |
rfc6570-templates-http.json (legacy era) |
Resources tab: RFC 6570 resource-template expansion | #1919 |
advertised-extensions-http.json (legacy era) |
Tool registration gated on advertised extensions | #1739 |
oauth-custom-resource-metadata-http.json (legacy era) |
OAuth discovery driven by the challenge's resource_metadata |
#2071 |
oauth-revocation-http.json / oauth-no-revocation-http.json (legacy era) |
RFC 7009 token revocation on clear, with and without a revocation_endpoint |
#2144 |
oauth-rfc8414-at-oidc-path-http.json (legacy era) |
Plain OAuth 2.0 AS metadata served at the OIDC well-known path | #2172 |
oauth-insecure-token-endpoint-http.json (legacy era) |
A token endpoint the SDK refuses to post credentials to | #2280 |
oauth-cimd-http.json (legacy era) |
URL-based client IDs (CIMD / SEP-991), DCR deliberately off | #2242 |
oauth-stalled-{token,discovery}-http.json (legacy era) |
An OAuth endpoint that accepts the request and never answers, so the OAuth-path request timeouts fire against a real idle socket | #2382 |
logging-{legacy,modern}-http.json (era per file) |
Logging, both eras | #1629 |
subscriptions-{legacy,modern}-http.json (era per file) |
Resource subscriptions, both eras | #1630 |
subscriptions-never-acknowledged-http.json (modern era) |
A subscriptions/listen answered with a bare result |
#2097 |
tasks-{legacy,modern}-http.json (era per file) |
Tasks, both eras | #1631 |
cancellation-modern-http.json (modern era) |
Cancelling a call by closing its response stream | #2140 |
skills-http.json (either era) |
Skills tab: skills/list, resources/directory/read, digest verification, the frontmatter cross-check, and the non-conforming cases |
#2234, #2248 |
skills-strict-{legacy,modern}-http.json (era per file) |
The same skills, served only to a client that declared io.modelcontextprotocol/skills itself (SEP-2133) |
#2373 |
skills-http.json sets "skills": true and serves eight skills over four
skills/list pages. Since
#2248 that one
flag also declares directoryRead: true and registers the
resources/directory/read handler. The declaration and the handler are one
switch on purpose: the sub-flag's whole hazard is advertising a method nothing
answers — Connection Info reporting a sub-option "Supported" while the method
returns -32601 — and a config that cannot express the declaration without the
handler cannot reach it. To exercise the undeclared case, connect to any
config without "skills", where the Inspector must refuse to send the call
locally rather than letting the server answer it.
skills-http.json serves any client, including one that never declared the
extension. SEP-2133 negotiates an extension from both sides, and a strict
server refuses skills/* to a client that did not declare
io.modelcontextprotocol/skills itself. The Inspector shipped without that
declaration and passed against this fixture regardless
(#2373).
skills-strict-legacy-http.json (port 3232) and
skills-strict-modern-http.json (port 3233) add
"skillsRequireClientExtension": true, which refuses skills/list,
skills/get and resources/directory/read unless the client declared it
(skill files still come through ordinary resources/read, which needs no
extension). The refusal differs by era: a modern request gets SEP-2575's
-32021 MissingRequiredClientCapability (HTTP 400) with the Skills extension
under data.requiredCapabilities; a legacy one, whose era has no such code,
gets -32601. _meta envelope; a legacy one relies on what
initialize declared, which only a stateful legacy server keeps. A legacy
client reaching the modern file is served statelessly, holds no declaration,
and is refused however it is configured — so there is one file per era rather
than one for both:
- Connect — the Inspector declares the Skills extension by default, so the
Skills tab lists the same eight skills. The client's declaration is visible
in the
initializerequest (Legacy) or in each request'sio.modelcontextprotocol/clientCapabilities_metaentry (Modern). - Open Server Settings → Advertised Extensions, uncheck Skills (io.modelcontextprotocol/skills), and reconnect.
skills/listnow fails — with-32021MissingRequiredClientCapability on the modern file and-32601on the legacy one, the refusal a strict server sends in each era.
Both skills/* results carry the full modern base envelope (resultType /
ttlMs / cacheScope). They are consumer-owned methods, so the SDK stamps
nothing for them; without it a 2026-era connection would receive a result
missing the envelope. It is stamped unconditionally rather than per era — the
modern leg builds a fresh server per request, so there is no era to branch on
when the handlers are registered, and on the legacy leg they are three extra
members no codec inspects.
resources/directory/read deliberately carries resultType alone.
SEP-2640 states the caching attributes for a modern skills/list in as many
words and says nothing of the kind for this method, whose one worked example
carries resultType and nothing else. A fixture sending more than the SEP shows
would make a client that wrongly required them look correct, which is the
opposite of what a conformance fixture is for — so readDirectoryPage stops
where the spec does. On the client, the SDK's modern codec enforces
resultType and lifts it off before the Inspector's own schema runs, so
DirectoryReadResultSchema serves both eras and requires no caching attributes
(#2373).
It works on either era: skills/list, skills/get and
resources/directory/read are consumer-owned extension methods that neither
era codec defines, so the SDK's era gate skips them entirely — which is why
this fixture, unlike the tasks ones, needs no per-era variant.
Seven of the eight skills are deliberately awkward, because the checks the
Skills tab runs are untestable without them. Only three are outright
violations (tampered-notes, lying-listing, wrong-folder — the three
--verify fails on). The rest are subtler and none is an error on its own: the
"dynamic" form is conforming, and is here because "legal but unverifiable"
is the case most easily buried; stale-manifest's entry is fully conforming
too, with the defect living in the disagreement between its manifest and its
directory listing; and the two reports skills are both entirely valid, with
the obligation falling on whoever consumes them:
| Skill | What it exercises |
|---|---|
data-analysis |
The clean case — Verify all reports verified for every file. |
tampered-notes |
An advertised digest that does not match the bytes served, so verification reports a digest mismatch with both digests shown. |
dynamic-report |
resources: "dynamic" — a legal form for generated content. No manifest is advertised, so integrity cannot be verified at all; reported as a warning, not an error. |
wrong-folder |
A URI path segment (wrong-folder) that disagrees with frontmatter.name (right-name), the one structural invariant SEP-2640 states outright. |
stale-manifest |
A skill that serves and directory-lists a file its resources manifest does not declare. Its entry is otherwise fully conforming and verifies clean, so the disagreement between the two views is the only defect — and only a directory read can see it. SEP-2640 calls a directory result "a live observation" and says hosts MUST NOT treat it as extending the manifest, so the Directory section marks the extra child not listed rather than showing it as one of the skill's files. |
acme/reports + globex/reports |
Two conforming skills sharing the name reports. SEP-2640 requires only that the segment before /SKILL.md equal frontmatter.name, which multi-segment paths satisfy while still sharing a final segment — its own acme/billing/refunds example is this shape. Hosts MUST NOT assume name uniqueness and MUST tell the two apart rather than collapsing or preferring one, so the Inspector reports a duplicate-name warning on both and shows each skill's URI beside its name. Also the only fixture with a multi-segment skill path. |
lying-listing |
A skills/list entry advertising one description while the served SKILL.md carries another. Its digest verifies — a digest is taken over the bytes the server served and says nothing about whether the listing described them honestly — so this is the one violation only the frontmatter cross-check can catch. |
Connection Info's Skills Extension Options section shows the directoryRead
sub-flag — against this fixture, a green ✓. The Skills screen then renders a
Directory section for the selected skill: press Read directory to list the
skill root's children, click a directory row to descend, Up to come back, and
Load more to page. Pages are one child each here, so a client that ignores
nextCursor is visibly wrong rather than merely lucky. dynamic-report is the
case the method actually exists for — it advertises no manifest, so a directory
read is the only way its files are discoverable at all.
From the CLI, the same catalog reports itself:
mcp-inspector --cli --server-url http://127.0.0.1:3230/mcp --transport http \
--method skills/list --verifyOne JSON report per skill on stdout, a one-line summary on stderr, and exit 7
when any skill fails — which it does here, on tampered-notes (digest),
wrong-folder (name) and lying-listing (frontmatter). The TUI's Skills
pane runs the same checks for one selected skill on Enter.
They are the client-side obligations SEP-2640 makes testable from a hostile
server, which is how the
modelcontextprotocol/conformance
harness grades a client: it stands up a server and watches what the client
does. Three of its five skills scenarios map onto a fixture here — a digest
mismatch (tampered-notes), a frontmatter mismatch (lying-listing), and a
read of a file the manifest does not list (stale-manifest).
The other two are covered, but not by this fixture, and the distinction is worth keeping honest:
- Size mismatch has no fixture.
test-servers/src/skills.tscan override an advertised digest and nothing else, so the size path — whichverifySkillResourcechecks first, before hashing — is exercised by unit tests rather than against a live server. Adding it would mean anadvertisedSizeoverride beside the digest one. - No-prefetch is a negative and could not have a fixture: it passes only if
connecting and calling
skills/listproduces noresources/readat all. The Inspector satisfies it structurally — nothing is fetched until a user selects a skill or presses Verify, which is why every round trip on the Skills screen is a button rather than an effect.
cancellation-modern-http.json serves slow_task, which reports progress once
a second for up to 60 seconds and stops early if it is cancelled, printing how
far it got to the server's terminal. Connect with Protocol Era = Modern.
Watch the terminal you started the server in, run slow_task from the Tools
tab, and click Cancel after a few seconds. The progress must stop
immediately, the Inspector must report the call cancelled, and the server must
print [slow_task] cancelled after Ns. On the broken build the progress kept
arriving until the tool completed all 60 seconds and the server printed
completed all 60s without being cancelled, because the Inspector was sending
the wrong cancellation signal
(#2140).
The server's terminal is the place to watch, not the Inspector's result panel: cancellation closes the very stream the tool's result would travel on, so on a successful cancel the tool's return value is undeliverable by construction and the Inspector shows a cancelled call rather than a result. Which is the point — what has to stop is the work, and only the server can report that.
The 2026-07-28 spec makes this
transport-specific:
for Streamable HTTP, closing the request's SSE response stream is the
cancellation signal, and a notifications/cancelled is "neither required nor
expected"; stdio, which has no per-request stream to close, keeps the
notification. A spec-compliant server therefore answers the notification 202 Accepted and drops it — which is precisely what the reporter observed, with the
task running on to completion while the Inspector reported it cancelled.
The SDK already implements that fork, off transport.hasPerRequestStream. Every
Inspector connection is wrapped in MessageTrackingTransport (it feeds the
Protocol and Network tabs), which did not forward the flag — so the SDK saw
undefined and took the stdio branch on every client, CLI and TUI included.
The web client needed two more links in the chain: its browser-side transport
answers for the real upstream one that lives on the Node backend, and the abort
has to survive the POST /api/mcp/send hop to reach it.
Watch it on the wire in the Protocol tab: cancelling now emits no
notifications/cancelled frame at all, and the tools/call entry ends as an
aborted request rather than a completed one. Switch the same server to
Legacy and the notification comes back — that era has no per-stream
mechanism, so it is still the correct signal there.
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 #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 Reviewing an MCP App.
mcp-app-domain-http.json serves the same mcp_app_demo widget as above, with one addition: its UI resource declares _meta.ui.domain. Plain streamable-HTTP; connect with the default (legacy) protocol era.
That field is how a server asks its host for a stable, dedicated origin. Without one, an App renders into a srcdoc frame sandboxed without allow-same-origin, so its document has an opaque origin and every request it makes carries Origin: null — which no CORS policy, OAuth callback, or API-key allowlist can admit (#2056).
Open the Apps tab and run mcp_app_demo. The widget renders identically to mcp-app-http.json — the difference is not visual. Inspect the inner iframe in devtools: on this server it is served from http://127.0.0.1:6278/app-document/<id> and location.origin is that real origin, where on mcp-app-http.json it is about:srcdoc with an origin of null. (The host is whatever the Inspector bound to — 127.0.0.1 by default, an address rather than the name localhost, for the reason resolve-bind-host.ts documents.)
The spec makes domain's format host-dependent, and the Inspector owns no domain infrastructure — so it reads any non-empty value as a request rather than an address, and answers with a real loopback origin of its own. See MCP App dedicated origins for the full contract, including what the one shared origin does and does not isolate, and how every failure falls back to the default render rather than blanking the app.
app-elicitation-http.json serves app_choose_option alongside the choose_option_app UI resource (ui://demo/choose-option.html). The tool sends a completely ordinary form elicitation/create — the only thing added is _meta.ui.resourceUri naming that app. Plain streamable-HTTP; connect with the default (legacy) protocol era.
Run app_choose_option from the Tools tab. The server's app renders in a modal instead of the built-in elicitation form, and clicking Option A, Decline, or Cancel returns the standard ElicitResult straight to the server, which echoes it into the tool result.
App rendering is selected only when all four conditions hold (#1854, per ext-apps#733 / SEP-3118):
- the client advertises
elicitation.form; - the client advertises
extensions["io.modelcontextprotocol/ui"].mimeTypesincludingtext/html;profile=mcp-app; - both the client and the server advertise the nested
elicitationsetting on that same extension; - the request carries a valid absolute
ui://URI in_meta.ui.resourceUri.
Only the web client advertises the nested client-side setting, and only because it has a sandbox renderer to back it; the CLI and TUI advertise the MIME type (they know what an App is) but never claim they can resolve an elicitation through one, so the same server falls back to their native prompts. Turning Server Settings → Advertised Extensions → MCP Apps UI off, or turning form elicitation off, removes the claim on web too.
Everything else falls back to the built-in elicitation form, by design: metadata that is absent or not an absolute ui:// URI, a resource that fails to load, a sandbox or bridge that fails to initialize, an app that did not advertise elicitation, a request that times out, and any result that is not a valid ElicitResult for the requested schema. An explicit decline or cancel is not a fallback — it is a completed elicitation and goes back to the server as-is.
The Inspector speaks the ext-apps#733 wire protocol but does not yet consume its helpers: #733 is not yet in a published release — the installed
@modelcontextprotocol/ext-apps(2.0.0) does not include it.core/mcp/appElicitation.tsandclients/web/src/components/elements/AppRenderer/requestAppElicitation.tsmirror it exactly and are marked for deletion in favour of the package's own exports once a release containing it ships.
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:
| Preset | Behavior |
|---|---|
mrtr_confirm |
Single round |
mrtr_two_step |
Two elicitation rounds via requestState |
mrtr_sample |
Embedded sampling → the Sampling panel |
mrtr_roots |
Embedded roots/list, auto-answered silently from configured roots (no modal) |
mrtr_edge |
An inputRequests-only round, then a requestState-only round |
mrtr_empty |
Completes with an empty result — no content, no structuredContent |
mrtr_loop |
Never completes → trips the MRTR_MAX_ROUNDS bound |
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 (#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
#1908.)
The legacy
collect_elicitationpreset callsserver.elicitInput, which errors on the 2026-07-28 leg — server→client requests aren't allowed there. MRTR is the modern replacement.
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:
| Tool | Response |
|---|---|
trigger_header_mismatch |
400 / -32020 |
trigger_missing_capability |
400 / -32021 |
trigger_unsupported_version |
400 / -32022 (with data.supported) |
trigger_method_not_found |
404 / -32601 |
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 insideclient.callTool(), and skips it in the browser (detectProbeEnvironment() !== "browser"). The Inspector routestools/callthroughclient.request()to drive MRTR manually, so it builds the mirrored headers itself (#1846) — on every client, web included, since the web client's upstream request is issued by the Node backend rather than the browser. Soget_weatheris callable from web, CLI, and TUI alike, in both the plain and "Run as task" forms.
xmcpheader-modern-http.json serves:
echo— plain tool.get_weather— a validx-mcp-header: "City"annotation on itscityargument.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 paramserror 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.
empty-cursor-http.json is the same 12-item, 4-per-page server with one difference: it hands out the empty string as the cursor for page two (emptyStringCursor), and the usual numeric index for page three. An MCP cursor is opaque — the spec constrains neither its content nor its length — so "" is a legal nextCursor and a client must send it back verbatim.
Every other fixture's cursor is a non-empty string, which is why this one exists: a client that builds its request params with a truthiness check (cursor ? { cursor } : {}) cannot tell "" from "no cursor", so it drops it and re-requests page one. Nothing errors — the request is well-formed, it just asks the wrong question — and the symptom is a list that stops after four items, or a page walk that never advances (#2220).
Connect with the default (legacy) era, turn Paginated on, and click Load next page twice on any of the three lists: the count must go 4 → 8 → 12 and the control must disappear at the end. On a build carrying the old guard the second click returns items 1–4 again.
structured-output-http.json serves list_items (nested structuredContent — objects inside arrays inside an object, the shape from #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[] 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.
duplicate-resource-uris-http.json is the resources/list counterpart: it serves resource_1 … resource_4, then repeats test://resource_1 and test://resource_3 at the end of the list with the same uri and a (duplicate) title (duplicateResourceUris). Unreachable through a preset for the same reason — registerResource keys on the URI — and appended rather than adjacent for the same reason as above.
Connect (default legacy era) and open the Resources tab. With the browser console open, the URIs section must list all six rows and log no Encountered two children with the same key warning; typing resource_2 into Search must narrow it to exactly one row. On the broken build the sidebar keyed rows by resource.uri alone, so the warning repeated on every render and a filtered-out row survived reconciliation (#2206).
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: [<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 core/json/nullableUnion.ts — precisely so they cannot drift on which schemas they can render.
root-union-schemas-http.json serves three tools whose arguments are declared as a composition at the root of inputSchema rather than as a flat properties map — echo with an anyOf beside its own message property, get_weather with an OpenAPI-style discriminator over a oneOf, and record_shipment_by with a oneOf neither of whose branches can be offered. Plain streamable-HTTP — connect with the default (legacy) protocol era.
The 2026-07-28 revision makes this shape explicitly legal: type: "object" is required at the root, and beyond that "any JSON Schema 2020-12 keyword may appear alongside type, including composition keywords (oneOf, anyOf, allOf, not)".
Open the Tools tab and select echo. Above the fields is a Variant picker listing the union's alternatives — labelled from each branch's title, else its discriminator const, else its position — and choosing one swaps in that branch's fields with the discriminator already filled in. A field the schema pins with const renders read-only, and is filled in automatically only where the schema also requires it: const constrains a value that is present rather than demanding one, so an optional pinned field stays omittable.
The two tools show the two halves of the old behavior. On the broken build echo rendered its root message and nothing from either branch, so it could only ever be called with half its arguments; get_weather, whose fields live entirely on its oneOf, rendered nothing but the Execute Tool button — no picker, no fields, not even the raw-JSON editor a union-typed property falls back to (#2123).
Switching branches drops the values that belonged to the outgoing one. They are no longer on screen, so the user can neither see nor clear them, and submitting them would describe a shape the call is not making.
The TUI has the same gap and is worth checking against the same server (--tui, then test echo). ink-form is static — there is no picker to hide the alternatives behind — so each branch becomes its own section, preceded by a Variant select naming which one the call means. The fields in a branch section are rendered optional whatever the branch says: only one alternative applies to a call, so requiring them would build a form that can never be submitted. That makes the form satisfiable, not the call, so the chosen branch's own required list is checked at submit and reported — never sent as a call already known to violate the schema.
The sections are not as independent as they look, which is why the select is not cosmetic: ink-form keeps one value object for the whole form, keyed by field name alone, so two branches both declaring kind would be one field and the later section's initial value would decide what the earlier one submits. Each branch's fields are therefore rendered under a prefixed name and translated back on submit, where every branch but the chosen one is dropped.
The CLI has no form at all, but the same flattening decides how --tool-arg values are typed: a branch's count: { "type": "number" } is what turns --tool-arg count=3 into 3 rather than "3". Which branch is inferred rather than chosen — a discriminated union pins its discriminator with const, so the supplied arguments either identify one branch or they do not. When nothing identifies one, only the names every branch that declares them types the same way are coerced; a name one branch calls a number and another a boolean is passed through as the string it was typed as, rather than run through an arbitrary branch's schema.
All three read one helper, core/json/rootUnion.ts, so they cannot drift on which schemas they can render.
What it declines to flatten is as deliberate as what it flattens, and every case falls back to whatever the schema's own properties describe rather than claiming something untrue:
- A union whose members are not all field-carrying object schemas — including one whose member
typerules objects out, since tool arguments are a JSON object and such a member can never match. A picker whose options render nothing is no better than no picker. - A branch requiring a name nothing declares.
requiredlists names, not declarations, so{ "properties": { "by": … }, "required": ["by", "address"] }is legal and says nothing about whataddressaccepts. Every form builder enumeratespropertiesalone, so no control is rendered for it while the submit-time check reports it missing permanently — an option the picker offers and the user can never complete (#2224). The name only has to be declared somewhere the merge reaches: a branch requiring one the root declares —anyOf: [{ "required": ["email"] }, …], an ordinary way to say "one of these two" — is offered as before. - A branch that restates a constraint the root already states. The two are conjunctive, so root
minimum: 10under branchminimum: 0is still 10, disjointenums leave nothing satisfiable, andtype: "string"undertype: "number"describes a value that cannot exist — rendering either side would accept what the schema rejects. A property both declare compatibly is merged rather than replaced, so a root'sminimumsurvives a branch'smaximum, and a disagreement abouttitle/descriptionis not a conflict at all. - A composition member stating anything the merge cannot apply. Only
type,propertiesandrequiredare folded in, so a member carrying a nestedallOf/anyOf, anot, anadditionalProperties, or a$refwould have that constraint erased along with the keyword — turning an unsatisfiable schema (allOf: [false, …]admits nothing) into a fillable form.allOfmembers are checked against the accumulated merge rather than the root alone, so two of them contradicting each other is caught even when neither contradicts the root. - A
oneOfwhose alternatives are not mutually exclusive.oneOfdemands that exactly one alternative match, which flattening cannot preserve — the branches are offered as if any would do. It is only safe with a discriminator: a property every branch pins to aconstof its own and requires, since an optional one leaves{}matching every branch. An undiscriminatedoneOfis declined;anyOfmakes no such claim and is offered either way. - A union that adds fields under a restrictive root
additionalProperties. That keyword constrains whatever its siblingpropertiesdoes not name, so a rootadditionalProperties: falserejects every field the branches add — flattening would move them beside the keyword, where they read as allowed. An empty schema ({}) constrains nothing and is treated as permissive. - A schema carrying both
oneOfandanyOf— independent keywords a value satisfies together, not two spellings of one union, so reading one and dropping the other omits real constraints while looking complete. Satisfying both honestly means the cross product of their alternatives, which no real schema has yet asked for. not, which is not interpreted at all: there is no faithful form for "anything except this".
Declining changes what renders, never whether the tool is treated as taking arguments: a declined union still has fields, so an App tool carrying one still asks for them rather than auto-invoking with {}.
record_shipment_by is the case where declining leaves nothing on screen: its fields live entirely on branches that are all declined, so the web form has neither a picker nor a field to show. It opens the Edit as JSON editor instead of rendering a blank form the user would have to work out for themselves (#2224) — the arguments are still expressible, just not as fields. The switch is only seeded, so turning it back off works normally, and a tool that genuinely takes no arguments is left alone. The TUI has no such editor, so there the tool renders an empty Parameters section — no longer a form with a branch section that could never be submitted.
unportable-schemas-http.json serves four tools, three of whose advertised
schemas carry constructs that are legal JSON Schema and are refused or
mishandled by real MCP clients:
| Tool | What it carries |
|---|---|
get_temp |
outputSchema.properties.data as a bare true — what Go's jsonschema package emits for interface{}, the case reported in #1005 |
echo |
an array-form "type": ["null","boolean"], and an opts property that constrains nothing |
add |
a property pointing at a remote $ref |
get_weather |
nothing — left clean, so a flagged tool sits beside an unflagged one |
Plain streamable-HTTP — connect with the default (legacy) protocol era.
Every tool here is still runnable: the override replaces only the
advertised schema, and the flagship bare-true rides get_temp (which
returns structured content) rather than echo — see the
The Inspector is where a server author looks first, so a construct that will
fail downstream is named here rather than passed through silently. All three
clients report the same verdict from
core/json/schemaLint.ts, each with the room it
has:
mcp-inspector --cli http://127.0.0.1:6603/mcp --method tools/list --strict # exits 6unportable-schemas-many-http.json (port 6613, legacy era) is the same four
presets carrying 26 findings — echo 11, add 6, get_temp 5 on its
output schema, get_weather 4 — which is what a server generated from a
codebase that spells every nullable field "type": ["string", "null"] actually
looks like.
It exists for #2205
rather than for the rules themselves. Broken, selecting any tool here filled the
whole detail panel with findings and pushed the argument form off the bottom —
not one input field was reachable without scrolling, on every tool switch, and
the findings address the server author rather than the caller who is trying to
fill the form. Fixed, the section opens collapsed behind its
N error(s), M warning(s) badge and the form is on screen immediately; the
expand choice is global, so opening it once keeps it open across tools.
- CLI —
--strictprints the full report (path, issue, suggested fix) on stderr and exits6on an error-severity finding; without it, one summary line. See Schema portability. - TUI — the tools list marks
get_tempwith a red!andecho/addwith a yellow?; the detail pane lists each finding under Schema Portability. - Web — the Tools sidebar row carries the same flag as a hover-labelled icon, and selecting the tool shows a Schema portability section above the argument form.
This is deliberately not a JSON Schema validator. A census of 617 public
servers (14,804 tool schemas) reported on that issue found 0 that fail the
SDK's own ListToolsResultSchema.safeParse, so a conformance check would
report nothing on essentially every real server. What bites is the narrower
subset each consumer accepts, and each rule here is a construct known to be
refused or degraded by a shipping client. The schemas are supplied through the
test server's rawToolSchemas override, because the Zod-built presets cannot
express any of them — which is the same reason a real server hits this only
when its schemas come from another generator.
outputSchema override of your own, put it on a tool that
returns structured content. A conforming client validates a tool result
against the advertised output schema, so an override on a preset that returns
none makes every call to it fail with "declares an output schema but returned
no structured content" — a confusing thing to hit from a fixture. That is why
the bare true rides get_temp here and not echo.
rfc6570-templates-http.json serves two resource templates straight out of #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://eventsresource is registered deliberately, not as filler. The SDK'sUriTemplate.match()compiles{?topic}to a required\?topic=([^&]+), so a template alone cannot serve the blank read —match("foobar://events")returnsnull. 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:
| Shape | SDK behavior |
|---|---|
{a,b} |
raw-joins the values — no encoding, operator prefix dropped |
{;id} |
; is missing from its operator list, so the variable parses as ;id |
{id:3} |
the prefix modifier is folded into the name, giving id:3 |
{+v} / {#v} |
encodeURI mangles reserved [/] ([::1] → %5B::1%5D) and double-encodes pct-triplets (%2F → %252F) |
{v} |
encodeURIComponent leaves the sub-delims !'()* bare, which RFC 6570 requires encoded |
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
echoandget_weather. - Open Server Settings → Advertised Extensions, uncheck Tasks (io.modelcontextprotocol/tasks), and reconnect.
- The client no longer advertises the Tasks extension (it still advertises the others, such as MCP Apps UI and Skills), the server never enables
get_weather, and the Tools list shows onlyecho.
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.
oauth-custom-resource-metadata-http.json is an OAuth-protected server (combined AS + resource, DCR enabled) that serves its RFC 9728 protected-resource metadata document only from /custom/protected-resource, and advertises it on every 401:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://127.0.0.1:8082/custom/protected-resource"The default /.well-known/oauth-protected-resource route is deliberately left unserved, so a client that ignores the advertised URL cannot discover the document at all. Plain streamable-HTTP — connect with the default (legacy) protocol era.
Add the server, click Connect, and watch the Inspector's first protected-resource metadata request in the Network tab: it must go to /custom/protected-resource. On the broken build the challenge's resource_metadata was parsed and then dropped before the SDK's auth() ever saw it (#2071), so discovery probed locations derived from the MCP server URL, 404'd, and authorization failed for any server that puts the document somewhere other than the well-known path.
The same server is worth running against --cli / --tui, which reach it by a different route: with no stored token in the legacy era the Inspector connects with no auth provider (so the SDK cannot open a browser before the callback server is listening), the 401 surfaces as the SDK's headerless UnauthorizedError, and the client calls authenticate() with no challenge in hand. The transport therefore observes every 401/403 passively, so the advertised URL is still available on that path.
The value now rides the normalized AuthChallenge as a string — it has to be serializable, because the web client's challenge crosses the remote-backend boundary as JSON — and is converted to a URL at the OAuth boundary, where it is handed to auth() as resourceMetadataUrl and to the CIMD pre-registration probe, which runs before auth() and would otherwise do its own default-location discovery. A malformed value is ignored rather than surfaced, matching the SDK's own WWW-Authenticate parser: discovery falls back to the default locations instead of failing the whole authorization on a bad header. The callback leg needs nothing extra — SDK auth() persists the URL in its discovery state, so it survives both the web full-page redirect and the CLI/TUI loopback callback.
oauth-insecure-token-endpoint-http.json is an ordinary combined AS + resource server with one thing changed: oauth.issuerUrl is http://localhost.:8091, so its advertised token_endpoint is http://localhost.:8091/oauth/token. Plain streamable-HTTP — connect with the default (legacy) protocol era.
EADDRINUSE — but its issuerUrl is a fixed string, so a relocated server announces 8092 while all its OAuth metadata still points at whatever unrelated process holds 8091. The symptom is confusing rather than obvious: discovery reaches the wrong process, and the refusal this fixture exists to demonstrate either never fires or fires for the wrong reason. If the flow does not end at the notice described below, check that 8091 is actually this server (lsof -nP -iTCP:8091 -sTCP:LISTEN) before believing anything you see.
The trailing dot is the whole trick, and it is doing real work rather than being a curiosity. localhost. is the root-anchored spelling of localhost: every resolver on the machine sends it to the loopback interface, so the fixture is reachable and the flow runs for real — but the SDK's assertSecureTokenEndpoint exempts only the three literals localhost, 127.0.0.1 and ::1, and localhost. is none of them. So the credential-carrying request is refused with InsecureTokenEndpointError while everything else about the server works. It is the same over-narrow exemption that makes http://tenant.app.localhost:3300 fail (#1944, typescript-sdk#2591), reproducible without an /etc/hosts entry or dnsmasq.
Add the server, click Connect, and complete the authorization. The redirect comes back with a code, the Inspector goes to exchange it, and the SDK refuses.
What you should see is a red, non-expiring "Token endpoint is not secure" notification naming the endpoint and the two things that resolve it — serve it over HTTPS, or move it to one of the three hosts the SDK exempts — localhost, 127.0.0.1, or [::1] (bracketed, since a bare IPv6 literal is not a legal URL host). It stays until you close it (autoClose: false stops it expiring on a timer; Mantine's own close control still dismisses it, which is what you want for a message you have finished reading). There is deliberately no action button.
Note the second option is phrased as a spelling change, not a networking one. localhost. already is loopback, and so is tenant.app.localhost; what they are outside is a three-literal allow-list. Telling a reader to "use a loopback host" when they demonstrably already are is what sends them off to debug their resolver instead of their configuration.
On the broken build you got a "Re-authentication required" banner with a Re-authenticate button (#2280). That button could never work: InsecureTokenEndpointError does not extend OAuthError, and auth() special-cases it to rethrow rather than start a fresh /authorize redirect, so clicking it re-ran the same flow to the same refusal. The only text on screen was the raw SDK message, which names the three exempt literals and says nothing about which lever to reach for.
Note that the fix here is presentational only. Making a *.localhost token endpoint actually work has to land in the SDK — the assertion runs inside executeTokenRequest, takes no options, and there is no hook the Inspector could reach.
oauth-cimd-http.json is a combined AS + resource server that advertises
client_id_metadata_document_supported: true and — the part that makes it usable — hosts the client
metadata document itself, at /client-metadata.json. Plain streamable-HTTP; connect with the
default (legacy) protocol era.
Hosting the document is the whole reason this fixture exists. In CIMD the client_id is a URL that
the authorization server dereferences to learn the client's metadata, so a server that merely
advertises support is only half a fixture: exercising it still meant standing up a second host by
hand. That is why #2242 shipped
verified by its tests alone, and the v2.6.0 release ledger recorded it as the one row that had an
observable UI surface but no way to reach it.
supportDCR is false on purpose. With both registration paths available a successful connection
proves nothing about which one ran — precisely the confusion #2242 was about, where Connection Info
reported Dynamic (DCR) for a connection that never issued a POST /oauth/register. With DCR off,
CIMD is the only way the flow can complete, so reaching a connected state is the assertion.
client.json
(~/.mcp-inspector/storage/client.json) as cimd: { enabled: true, clientMetadataUrl }, reachable
from Client settings, not from a server's own OAuth settings. A clientMetadataUrl written into a
catalog entry's oauth block is silently ignored — and with supportDCR: true the connection then
succeeds via DCR, which looks like CIMD working until you read the client id.
Use this server's own document as the clientMetadataUrl — /client-metadata.json on the origin
the server announced on startup (http://127.0.0.1:8092/client-metadata.json when it got its
configured port; read the announced URL, since this fixture walks upward on EADDRINUSE like every
other one here). The Inspector requires HTTPS except on the three
loopback literals localhost, 127.0.0.1 and [::1]
(getCimdClientMetadataUrlError in core/client/config-parse.ts, applied to client.json on disk as
well as to the settings form), which is the same exemption the SDK applies to token endpoints and what
the runtime already tolerated for an already-stored client_id. Give the URL the host the server is
actually listening on; a host outside those three — localhost. and tenant.app.localhost included,
even though both resolve to loopback — is still rejected, deliberately, so this allow-list and the
SDK's cannot disagree about one URL.
http:// URL as an
already-stored client_id, which is precisely the asymmetry that issue is about; there was simply no
way to get such a value past validation and into client.json. So driving this flow meant standing
up a throwaway self-signed HTTPS listener to hold the document and setting
NODE_TLS_REJECT_UNAUTHORIZED=0 in the test server's environment so its own fetch of that document
would succeed. That workaround is no longer needed — if you find it in a script or an older note,
delete it.
http://127.1/… and http://2130706433/… are accepted
by the validator (and by the SDK) because both canonicalize to 127.0.0.1 — but a CIMD client_id
is compared as a string by the authorization server against the URL it dereferenced, so an
exotic spelling can fail that comparison on a server that normalizes differently than the one here.
Paste the origin the fixture announced rather than an equivalent you typed yourself.
~/.mcp-inspector/storage/oauth.json independently of the install-wide
CIMD toggle, and the Inspector reuses valid stored tokens before prompting — so a leftover grant can
carry a run that the registration path never actually completed, which is the same
"it connected, therefore CIMD worked" trap supportDCR: false exists to close. Use Clear OAuth
state and disconnect (Server Settings → Authorization), or point MCP_STORAGE_DIR at a throwaway
directory, which additionally survives a restarted fixture having forgotten a client it once issued.
With that in place: set the metadata URL in Client settings, connect, and open Connection Info.
It should read Client registration — Client ID Metadata (CIMD) with the client id equal to the
metadata URL, which is what CIMD means and what distinguishes it from a DCR-issued
test_client_…. On the broken build it read Dynamic (DCR) for exactly this flow
(#2242).
clientMetadata.redirectUris must list the callback for the port you are running. The
Inspector's browser redirect is <web origin>/oauth/callback and the CLI/TUI's is
http://127.0.0.1:6276/oauth/callback; the authorization server checks the incoming redirect_uri
against this list. The shipped fixture lists 6274 (the default), 6330 and 6276; on any
other port the flow fails with Invalid redirect_uri, which reads like a CIMD problem and is not one.
Add your port to the config rather than debugging the registration path.
This fixture has no fixed-issuerUrl hazard, unlike several of the ones above: it configures no
issuerUrl, and the document's client_id is derived from the request it was fetched over — query
string included — so a server that walked to another port on EADDRINUSE still publishes a
client_id equal to the URL you fetched, and the integration test drives it on a harness-chosen port
for exactly that reason. The fixed-port dependency that does bite is redirect_uris, above.
Two OAuth-protected servers (combined AS + resource, DCR, refresh tokens) that are ordinary in every respect but one: a single endpoint accepts the request and withholds the response forever. Plain streamable-HTTP — connect with the default (legacy) protocol era.
oauth-stalled-token-http.jsonstallsPOST /oauth/token, so the token exchange and the refresh — which share that endpoint — both hang.oauth-stalled-discovery-http.jsonstallsGET /.well-known/oauth-protected-resource, so the stall happens before a client has any authorization server to talk to.
What to do. Connect to either from the Inspector and start an OAuth flow. Each should fail with an OAuthRequestTimeoutError naming the endpoint it gave up on and the budget it used, after 30 seconds — DEFAULT_OAUTH_REQUEST_TIMEOUT_MS. That wait is not configurable from the UI or from a config file: InspectorClient passes the constant straight into its transport chain and passes no budget at all on the auth chain, so both take the default. Budget it for a full 30s of waiting per attempt. Every other endpoint on the same server answers normally, so the failure points at one call rather than at the server.
What the broken build did. Before #2319, every OAuth-path call went out with no AbortSignal at all and these fixtures produced an unbounded spinner rather than an error — and a silent one, because a connect-time auth error deliberately holds the connection status at connecting on the theory that a redirect is about to end the attempt. The stall is server-side, in the Inspector's own Node process, so browser devtools showed no pending request while a socket sat established and idle.
Why the fixture exists at all. #2319's own tests inject a fetch stub, which settles on the client side and cannot produce an established idle socket — the precise state the issue is about. Until #2382 added oauth.stallEndpoints, nothing in the repo could make those five timeouts fire.
Configuring your own. Any config's oauth block takes stallEndpoints — any of protected-resource-metadata, as-metadata, authorize, token, revoke, register — plus an optional stallMs to answer late instead of never. Endpoints are named by the call, not the path, because the token exchange and the refresh share /oauth/token and two of the documents sit at configurable paths. An unrecognized name throws when the server starts rather than being ignored, so a typo cannot quietly produce a fixture that answers normally.
oauth-revocation-http.json and oauth-no-revocation-http.json are the same OAuth-protected server (combined AS + resource, DCR, refresh tokens) differing in one thing: the first advertises a revocation_endpoint, the second advertises none. Plain streamable-HTTP — connect with the default (legacy) protocol era.
Add either server, connect and complete authorization, then use Clear OAuth state and disconnect (Server Settings → Authorization) and watch the Network tab.
- On
oauth-revocation-http.jsonaPOST /oauth/revokegoes out naming the refresh token. RFC 7009 §2.1 asks the authorization server to invalidate the access tokens issued under the same grant, so one request ends both halves — the fixture implements that linkage, so re-sending the old bearer token to/mcpafterwards gets a 401. The request is built from the stored state before the local clear and sent after it, so the clear never waits on the network; in the Network tab the POST therefore follows the local teardown rather than preceding it. - On
oauth-no-revocation-http.jsonnothing is sent at all, and the clear behaves exactly as it did before the feature existed. That no-op path is what makes this safe against every authorization server with no RFC 7009 support (#2144).
On the broken build both servers behaved like the second: the Inspector deleted its local copy and the grant stayed valid at the authorization server until it expired on its own — which for a refresh token is a long time, by design.
Uncheck Revoke tokens on clear in the same panel (persisted as oauth.revokeOnClear: false) and the first server behaves like the second. That is not only an escape hatch: a client that disconnects still holding live tokens is a case worth reproducing when the server is the thing under test.
The same behavior is reachable from the other clients — the TUI's Clear OAuth State, and the CLI's --relogin (with --no-revoke as the per-run opt-out).
oauth-rfc8414-at-oidc-path-http.json is an OAuth-protected server (combined AS + resource, DCR enabled) whose RFC 8414 authorization-server metadata is served only from /.well-known/openid-configuration. It is a plain OAuth 2.0 authorization server — no ID tokens, no jwks_uri, no sub claims — and RFC 8414 §5 explicitly permits that filename for general OAuth metadata. /.well-known/oauth-authorization-server is deliberately left unserved. Plain streamable-HTTP — connect with the default (legacy) protocol era.
Add the server and click Connect: authorization must proceed normally. On the broken build it failed before the browser ever opened, with a ZodError naming three fields the server had no reason to publish:
"path":["jwks_uri"] … "path":["subject_types_supported"] … "path":["id_token_signing_alg_values_supported"]
The cause is upstream. discoverAuthorizationServerMetadata in @modelcontextprotocol/client@2.0.0 picks its validation schema from the well-known filename that resolved, not from the document that came back — anything found at openid-configuration is parsed as OpenID Connect Discovery 1.0 provider metadata, which requires those three fields. And because the parse throws rather than continuing the candidate loop, discovery aborts outright instead of falling through (#2172, filed upstream as typescript-sdk#2733).
core/auth/oidcDiscoveryCompat.ts works around it without fabricating anything. When the RFC 8414 candidate comes back 4xx, it fetches the OIDC candidates the SDK would try next; if one returns a document that satisfies OAuthMetadataSchema but fails the OIDC schema, that document is returned as the response to the RFC 8414 request — so the SDK validates it under the schema that actually describes it. A genuine OpenID provider document is left alone and takes the SDK's normal OIDC leg. Issuer validation is untouched, since the substituted document is the one the server published, issuer included.
The metadata shown in the Auth tab is exactly what the server sent — no invented jwks_uri. In the Network tab the substituted response is captured against the RFC 8414 URL (the wrapper sits on the base fetch, below the tracker, because that is the only seam that also covers the discovery the SDK runs from inside the transport), so it carries an x-inspector-oauth-metadata-source response header naming the URL its body was actually fetched from. The same URL is printed as a console warning.
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_notificationstreams 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["io.modelcontextprotocol/logLevel"]on every subsequent request (verify in the Network tab's request body). Callingsend_notificationstreams the log over the request's SSE response. Set it back to Off and the same call is silently gated — the request omits thelogLevelkey, 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.
- Legacy — open a resource in the Resources tab and click Subscribe. The client sends
resources/subscribeand the Subscriptions section lists the URI with no stream chrome. Callupdate_resourcewith that URI and the server updates the content and emitsnotifications/resources/updated, stamping the subscribed tile's last-updated time. - Modern — the same Subscribe instead sends
subscriptions/listen(its filter carriesresourceSubscriptionsplus theresourcesListChangedopt-in) and resolves onnotifications/subscriptions/acknowledged. The Subscriptions section then shows a stream-status badge (Connecting…→Listening) in its header, and reconnects by re-listing if the long-lived stream drops.
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.
So the live update-notification round-trip is demonstrated on the legacy (stateful-session) server, and the modern server is for the subscribe/listen/badge behavior. The Inspector's receive path is era-transparent, so a real stateful modern server that routes resources/updated onto the listen stream drives the subscribed tile the same way.
subscriptions-never-acknowledged-http.json serves the same three numbered_resources on the modern leg. It acknowledges the first subscriptions/listen that subscribes to a resource normally, and answers every resource-subscription listen after it with a bare JSON-RPC result instead of a notifications/subscriptions/acknowledged. A listen carrying only list-change opt-ins is always acknowledged — including the one the Inspector opens at connect time (#1920), which is why the counting is per resource-subscription listen: otherwise that connect-time listen spends the allowance before you have clicked anything and the very first Subscribe is refused. Connect with Protocol Era = Modern.
That result is not a malformed message. On the 2026-07-28 era the listen request is long-lived, and the result for its id is reserved as the graceful-closure marker — so a server sending it up front is saying "acknowledged and closed in the same breath". It is deliberately bare, with no resultType discriminator, matching the payload from the original report rather than the spec's example.
Open the Resources tab and Subscribe to resource_1: an ordinary acknowledged stream, badge Listening. Now Subscribe to resource_2. Changing the filter re-lists, this one is refused, and:
- the subscribe fails with the reason spelled out — "The server closed the subscription without acknowledging it… Not retrying";
- the Subscriptions badge turns an orange Not acknowledged and the panel carries the same sentence as a notice;
- the Protocol tab shows exactly one further
subscriptions/listen.
On the broken build that second click produced eight subscriptions/listen requests with increasing ids over roughly a minute, the badge flickering Reconnecting… between them, and a final bare Stream ended that said nothing about why (#2097, split out of #2063, where it read as the Inspector "accepting" an invalid response). The condition is deterministic — the server answers the same way every time — so retrying it is noise, not recovery.
The first resource-subscription listen is acknowledged so the badge is reachable at all: it is gated on a live subscription, which a server refusing from the outset never lets you hold. That variant — refuse every listen, the literal shape in the report — is what the integration tests drive; it is the same code path, minus the badge. And never-acknowledged is a status of its own rather than Stream ended on purpose: ended covers the two expected closes (a server tearing an established stream down, and reconnection abandoned after repeated failures), and reading a deterministic conformance failure as either of them is the silence the issue is about.
Legacy (tasks-legacy-http.json) advertises capabilities.tasks (tasks: { list, cancel }) with the simple_task / progress_task / elicitation_task presets. Run one of those tools with Run as task on, and the Tasks tab lists it (populated via tasks/list), polls tasks/get, fetches the payload with the blocking tasks/result, and cancels with tasks/cancel.
Modern (tasks-modern-http.json) sets transport.modern: true and tasksExtension: true, advertising the io.modelcontextprotocol/tasks extension (SEP-2663) and serving modern_task / modern_input_task. The Tasks tab is gated on the negotiated extension, not capabilities.tasks.
- Run
modern_taskas a task — thetools/callreturns aCreateTaskResult(resultType: "task", visible in the Protocol/Network tabs), the client pollstasks/get(notasks/list), and the completed task inlines its result (no blockingtasks/result). - Run
modern_input_task— the task moves toinput_required, surfacing an embedded elicitation through the pending-request modal. Answering it sendstasks/updatewith theinputResponses, and the next poll completes.
SDK v2 removed all tasks support and era-gates the tasks/* spec methods out of the modern era on both sides. So the Inspector drives the extension itself — the resultType: "task" frame is rewritten at the transport into a CallToolResult carrying the handle, and tasks/get / update / cancel ride a raw-wire request channel with the full modern envelope. The test server serves tasks/* from an Express interceptor ahead of the SDK handler, since the SDK's modern leg would answer them -32601.
The Tasks tab's Refresh re-polls the handles already known to the client — modern has no server-side task list.