The negative control started all three refused calls up front and
awaited them in turn, so the later rejections sat unhandled while the
first was awaited. Vitest reports that as an unhandled error and fails
the run even with every assertion passing — which is how local:gate
went red on a green suite. Start each call only when it is awaited.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3RsrgCxVoFPokLB4vAkxi
Signed-off-by: cliffhall <cliff@futurescale.com>
Round 2 found text the PR's own changes made stale: the fixture's
option doc, handler summary and repro step still promised -32601 on
modern; the core skills.ts header still said Skills must never be in
ADVERTISABLE_EXTENSIONS; the listSkills and getSkillResult comments
still said the codec validates none of a skills result, or that the
result arrives as the server sent it; and two tests and a docs step
still described Tasks as the only registry extension. The backfill
test also now asserts the Skills declaration is on by default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3RsrgCxVoFPokLB4vAkxi
Signed-off-by: cliffhall <cliff@futurescale.com>
SEP-2575 gives a modern request that needs an undeclared capability its
own error, -32021 MissingRequiredClientCapability with
data.requiredCapabilities; -32601 there claims the method does not
exist. The strict fixture now throws MissingRequiredClientCapabilityError
on the modern leg and keeps -32601 on legacy, which has no such code.
The negative-control test now asserts the code and data per era, and a
new integration test resolves both checked-in strict configs through
loadConfig/resolveConfig, so a misspelled key or a dropped mapping
fails instead of leaving the manual fixtures permissive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3RsrgCxVoFPokLB4vAkxi
Signed-off-by: cliffhall <cliff@futurescale.com>
SEP-2133 negotiates an extension from both sides, and a strict server
refuses skills/* to a client that did not declare
io.modelcontextprotocol/skills. The Inspector called those methods
without ever declaring it. Add it to ADVERTISABLE_EXTENSIONS, on by
default, which also gives it a Server Settings toggle.
Give the composable test server skillsRequireClientExtension and two
per-era strict showcase configs, so a strict server is reproducible here.
Writing the modern-era test exposed two defects the existing suite hid:
its "modern" cases set protocolEra on the transport config, which
InspectorClient does not read, so they connected on legacy; and once
they really ran modern, every skills result failed, because the modern
schemas required resultType, which the SDK codec checks and then lifts
off before a caller schema runs. Connect with versionNegotiation and
assert the era, and drop the resultType requirement (and the two modern
schema variants it was the only difference in).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3RsrgCxVoFPokLB4vAkxi
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses Copilot review round 4: every file carries a header explaining
its purpose, and this new suite had none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses Copilot review round 3. The proxy page's rejection message still
told operators to "update the validation logic", but the embedder check now
reads ALLOWED_ORIGINS, so name that setting as the fix instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 2:
- Slice each chunk to the remaining budget, so one oversized chunk can no
longer carry the recorded body past CHALLENGE_BODY_MAX_BYTES.
- Drop the long-lived-stream exclusion from the challenge branch. It guards
an unbounded read and this one is bounded, so a 401 to an SSE GET now
records its body too.
- Keep truncation out of the string that gets redacted. A cut-off body that
looks like JSON cannot be redacted (redactBody only masks JSON that
parses), so it is replaced by WITHHELD_TRUNCATED_JSON_BODY. Cut-off form
and plain-text bodies are redacted, then marked [truncated].
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG6q6YEDJyJB5H2Hr8nBDS
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses Copilot review round 2. A reverse proxy routes a public address to
one stable port, so neither MCP_SANDBOX_FULL_ADDRESS nor
MCP_APP_ORIGIN_FULL_ADDRESS is used when the listener's port is OS-assigned:
an explicit 0, a config-time collision resolved to 0, or the EADDRINUSE
fallback. Validation warnings also stop echoing the URL path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1. With the tracker below the auth-challenge
interceptor, its background clone kept draining a 401 body after the
interceptor cancelled its own branch — so a body that never ended left the
connection live. Read a 401/403 body up front instead, capped at 64 KiB and
2s, and cancel the clone's reader when either bound is hit so the
interceptor's cancel releases the source. The body now lands on the entry
itself before the challenge is thrown.
Also cover the web path end to end: a streamable-http 401 on
/api/mcp/send reaches the event stream with its status and body.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG6q6YEDJyJB5H2Hr8nBDS
Signed-off-by: cliffhall <cliff@futurescale.com>
The legacy and auto e2e cases both connect to the legacy stdio fixture
whether or not the flag reaches negotiation, since auto falls back. Run
initialize against an HTTP test server that rejects the legacy handshake:
the default must fail, and --protocol-era modern must connect and report
the 2026-07-28 protocol version. Deleting the serverOptions assignment in
cli.ts now fails this case (Copilot review, PR #2372).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ4rrvH6xaCfrst75gJh7x
Signed-off-by: cliffhall <cliff@futurescale.com>
The byte side of the catalog budget compared with `<=`, so a run whose charged
bytes exactly equalled the limit still read one more skill. Use a strict `<`,
matching the skill count and the setting's description. The skill whose reads
cross the limit is still reported in full.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6i1HBSdSS9kL4npZ2dDSe
Signed-off-by: cliffhall <cliff@futurescale.com>
Document MCP_SANDBOX_FULL_ADDRESS and MCP_APP_ORIGIN_FULL_ADDRESS in
docs/environment-variables.md, note that ALLOWED_ORIGINS is now also the
sandbox proxy's embedder allow-list, and list public-address.ts among the
web server files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
- Never echo a refused value into a warning: credentials, query and
fragment are dropped, and an unparseable value is not shown at all.
- Do not adopt MCP_SANDBOX_FULL_ADDRESS / MCP_APP_ORIGIN_FULL_ADDRESS after
the EADDRINUSE fallback: the reverse proxy still routes them to the pinned
port, which another process holds.
- Refuse a public app origin equal to any trusted ancestor in the controller,
where the real (possibly bind-derived, possibly fallback-port) sandbox
origin is known; config time could not see it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
The network tracker wrapped the auth-challenge interceptor, so a 401/403
never reached it as a response: the interceptor cancelled the body and
threw, and the tracker logged only the thrown error. NetworkEntry renders
its response section only when a status is present, so the body — and the
WWW-Authenticate resource_metadata and scope — were never shown.
Move the tracker below the interceptor so it records the real response and
clones its body before the interceptor cancels it, and stop awaiting that
cancel: with the tracker's clone as the other tee branch, the cancel settles
only once the clone reads to the end, which would hang on an unending body.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG6q6YEDJyJB5H2Hr8nBDS
Signed-off-by: cliffhall <cliff@futurescale.com>
- A "dynamic" skill's only read is the fallback SKILL.md fetch, which was not
charged, so a catalog of dynamic skills never advanced catalogBytes and a
configured byte budget bounded nothing. Charge it like a manifest read.
- Add verifySkills cases for a configured byte budget and for dynamic skills.
- Wire onSkillCatalogLimitChange into the ServerSettingsForm story.
- Reword the new field descriptions, JSDoc and config table rows; rename the
route test to cover every invalid value it sends.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6i1HBSdSS9kL4npZ2dDSe
Signed-off-by: cliffhall <cliff@futurescale.com>
`skill:`, `mailto:` and `skill://` also parse to an empty pathname, with an
empty host, so an empty path alone accepted authority-less URIs the guard
exists to reject. Accept the empty path only when a host is present
(Copilot review round 1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ2eszDCUqTmhtTMaznw3W
Signed-off-by: cliffhall <cliff@futurescale.com>
- Scope the CLI pass-through warning to the three port variables; the
CLI normalizes HOST (wildcard -> localhost, others canonicalized).
- ALLOWED_ORIGINS default is derived from HOST: loopback origins, plus
the two wildcard origins for an all-interfaces bind, or only the
configured host's origin for a specific non-loopback host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRJ64MnDTg1TpzCTEV9Xva
Signed-off-by: cliffhall <cliff@futurescale.com>
An ad-hoc --server-url / command launch always negotiated the legacy era,
because protocolEra was only settable per server in mcp.json. A wrapper
that mints a short-lived bearer token therefore had to write that token to
disk to exercise a server under auto or modern.
--protocol-era <legacy|auto|modern> now feeds settings.protocolEra in all
three clients through a shared parseProtocolEra coercer:
- CLI/TUI overlay it in loadServerEntries alongside --header, onto an
ad-hoc target and onto a --catalog/--config entry for that run.
- Web stamps it on the in-memory ad-hoc entry, which the backend lifts
into settings like headers; as with --header it is rejected alongside
--catalog/--config, but unlike --header it applies to stdio too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ4rrvH6xaCfrst75gJh7x
Signed-off-by: cliffhall <cliff@futurescale.com>
- The CLI passes CLIENT_PORT / MCP_SANDBOX_PORT / MCP_APP_ORIGIN_PORT
into its hand-off as-is: no validation, no warning, no fallback.
- MCP_AUTO_OPEN_ENABLED=true also lets CLI interactive OAuth start with
no TTY on stdin or stderr.
- An empty MCP_INSPECTOR_SECRET_STORE counts as unset, silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRJ64MnDTg1TpzCTEV9Xva
Signed-off-by: cliffhall <cliff@futurescale.com>
Add MCP_SANDBOX_FULL_ADDRESS and MCP_APP_ORIGIN_FULL_ADDRESS so a reverse
proxy or ingress deployment can advertise the sandbox proxy URL and the
_meta.ui.domain app origin the browser actually reaches, instead of the
bind-derived http://localhost:<port> ones. The process still listens on
MCP_SANDBOX_PORT / MCP_APP_ORIGIN_PORT.
Both values are validated in public-address.ts and refused (warned, bind-
derived address kept) when malformed or when their origin collapses onto
the Inspector UI's (or, for the app origin, the sandbox's) — the MCP Apps
spec requires host != sandbox origin, and the single-hostname proxy layout
is exactly the one that breaks it. Plain http under an https UI is warned.
The sandbox proxy page's referrer check was a hardcoded
http://localhost|127.0.0.1 pattern no https embedder could match. It now
compares the referrer's parsed origin against an exact list injected by the
controller, derived from the same filtered allow-list as its frame-ancestors
header, with the old loopback check kept only as the no-list fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
RemoteClientTransport tracks open streams only when onFetchStreamUpdate
is registered — the bookkeeping serves that callback alone, and without
it the set would grow by one entry per reconnect.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
- Launcher README: narrow "does not interpret env vars" to server and
client configuration, so it no longer contradicts MCP_DEBUG.
- Document HOME / USERPROFILE as the base of every ~/.mcp-inspector
default, including the cwd fallback when neither is set.
- SERVER_PORT is the sandbox fallback whenever MCP_SANDBOX_PORT is not
a valid port (unset, empty or invalid), not only when unset.
- MCP_INSPECTOR_OAUTH_STATE_PATH is read by the CLI and TUI only; the
web backend always uses <MCP_STORAGE_DIR>/oauth.json.
- MCP_DEBUG / DEBUG are also read by the standalone TUI entry point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRJ64MnDTg1TpzCTEV9Xva
Signed-off-by: cliffhall <cliff@futurescale.com>
`skill://data-analysis` has an authority and an empty path, which RFC 3986
permits, but the guard required a pathname starting with "/". A server echoing
the skill root back in a directory read was therefore labelled "outside this
skill" instead of "not a direct child". Opaque URIs (`skill:demo`) stay
rejected; a path-less root still names no skill file, so it fails every check
that needs one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ2eszDCUqTmhtTMaznw3W
Signed-off-by: cliffhall <cliff@futurescale.com>
Adds a Skills section to Server Settings with Maximum Number of Skills and
Maximum Catalog Size fields, defaulting to SKILL_MAX_CATALOG_SKILLS (256) and
SKILL_MAX_CATALOG_BYTES (64 MiB). The values persist as skillCatalogMaxSkills /
skillCatalogMaxBytes on the server's mcp.json entry (omitted at the default),
are validated as positive integers by the /api/servers routes, and are read by
verifySkills through getServerSettings(), so the CLI's --verify and the TUI
Skills pane both honor them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6i1HBSdSS9kL4npZ2dDSe
Signed-off-by: cliffhall <cliff@futurescale.com>
- MCP_AUTO_OPEN_ENABLED: the TUI never reads it; the CLI's unset
default opens only on a stderr TTY. Migration guide row fixed too.
- Drop the blanket "empty equals unset" rule, which HOST="" and the
path variables contradict; state empty handling per row instead.
- MCP_SANDBOX_PORT / MCP_APP_ORIGIN_PORT accept 0; only CLIENT_PORT
must be fixed.
- NO_COLOR: an empty value counts as unset.
- MCP_INSPECTOR_SECRET_FILE default is ~/.mcp-inspector/secrets.json,
beside the storage directory, not inside it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRJ64MnDTg1TpzCTEV9Xva
Signed-off-by: cliffhall <cliff@futurescale.com>
RemoteClientTransport keeps its open-stream bookkeeping whether or not
the consumer logs requests, so onFetchStreamUpdate works on its own and
close() can synthesize the terminal update.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
Every variable that changes runtime behavior now lives on one page,
docs/environment-variables.md: which client reads it, its default, and
its effect. It covers three variables that were undocumented
(MCP_LOG_FILE, MCP_INSPECTOR_LOG_DIR, LOG_LEVEL), the secret-store
variables previously only in the Docker guide, and the Node TLS
variables that answer #1936 (a self-signed https://localhost server).
The README docs table, the migration guide, docker.md and the four
client READMEs link to it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRJ64MnDTg1TpzCTEV9Xva
Signed-off-by: cliffhall <cliff@futurescale.com>
- fetchTracking: one non-throwing reporter fronts every stream update —
the open (inside the fetch wrapper), each coalesced count (from a
timer) and the close (off the discarded watcher).
- rawWireRequest annotates a request timeout its transport's send
rejects with, so the browser's relay timeout on a raw-wire call reads
like every other.
- RemoteClientTransport reports still-open streams closed on close(),
since the backend watcher's own close cannot reach a channel that the
disconnect has already torn down.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
- fetchTracking: the close report is guarded so a throwing listener
cannot become an unhandled rejection off the discarded watcher; a
bare CR that is the last byte of the stream terminates its event; a
moving event count is coalesced to one report per interval (default
250ms, 0 in tests), with the open and the close still immediate.
- MessageTrackingTransport rolls back a request whose signal was already
aborted before the send began, and suppresses the rollback only for an
abort that lands during the send.
- useTickingClock takes an `enabled` flag; Connection Info installs no
interval when there is no activity section.
- The diagnostics-callbacks test justifies its private-seam cast.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
Resolves the relay-wait conflict with #2028: the wait keeps its
progress-driven re-arm and expires with the SDK's timeout shape.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
- fetchTracking: media types and HTTP methods compare case-insensitively;
a `Request` input's own method is honoured; the SSE line scanner
accepts LF, CRLF and a bare CR, holding a trailing CR until the next
chunk decides whether it is half of a CRLF.
- MessageTrackingTransport does not report a send failure for a request
the caller aborted through `requestSignal`, and InspectorClient keeps a
request whose send failed with a request timeout — both are sent,
unanswered requests, which is the state to keep reporting.
- createTransportNode builds a fetch tracker when any tracking callback
is supplied, not only `onFetchRequest`.
- RemoteClientTransport's relay wait expires with the SDK's own timeout
shape, so the web client's relay timeout is annotated like every other.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
- fetchTracking: count NDJSON streams line by line instead of leaving
them at zero forever; justify the detached watcher promise; document
that a partial SSE block at end-of-stream is dropped, as the SDK's own
parser (no flush) and the SSE spec drop it.
- uncapturedBodyNote: case-insensitive content-type lookup, and a
stream label (SSE / NDJSON) for the Network badge.
- InspectorClient: `openedAt` is when the response headers arrived, not
when the GET went out; the snapshot copies its request records; a
request the transport failed to send is rolled back out of the
unanswered set via a new `trackSendFailure` callback; a raw-wire
timeout is built as the SDK's own timeout shape and annotated the
same way, so callers see one kind of timeout.
- Remote session storage revives the nested `stream.closedAt` Date.
- Hook test models a legacy double with `Object.defineProperty`, not a
double cast.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
The backendHoldingSend fetch mock is already declared with vi.fn<typeof fetch>(),
so the trailing `as unknown as typeof fetch` was redundant and tripped the
repo's no-double-cast rule. Return the typed mock directly; the test project
typechecks without it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
The first story to render a long-lived GET entry tripped axe on the
filled orange badge (white on #f76707, 3.04:1); the light variant is no
better (3.03:1). ThemeBadge already pins status + orange to a mid amber
fill with black text for this reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
- progressTokenOf now delegates to the SDK's ProgressTokenSchema instead of a
bare typeof check, so fractional and past-MAX_SAFE_INTEGER numbers are
rejected (matching isProgressToken in inspectorClient.ts); tests cover both.
- Browser relay timeout tests use fake timers with advanceTimersByTimeAsync and
flush the SSE consumer between pushes, replacing real-sleep windows that a
loaded runner could race (#1596).
- The log-only timeout test holds and awaits its rejection rather than
swallowing it in an empty catch.
- progressToken.test.ts gains a file header; the remote-session malformed-token
fixture uses a single-cast helper instead of `as unknown as`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>