* feat(test-servers): stall an OAuth endpoint so #2319's timeouts can be driven
#2319 put an `AbortSignal.timeout` on five OAuth-path calls that previously
went out with no signal at all. Nothing in the repo could make any of them
fire: the composable server's `oauth` block had no stall, delay or
never-answer option, so all five were covered only by tests injecting a
`fetch` stub.
A stub settles on the client side. The state #2319 is actually about is an
established, idle socket in the Inspector's own Node process — invisible in
browser devtools, and held behind a connection status that deliberately stays
at `connecting`. Only a server that accepts and withholds reproduces it.
Adds `oauth.stallEndpoints` (any of `protected-resource-metadata`,
`as-metadata`, `authorize`, `token`, `revoke`, `register`) plus an optional
`stallMs` to answer late rather than never. Endpoints are named by the CALL,
not the path: the exchange and the refresh share `/oauth/token`, and two of
the documents sit at configurable paths, so a path-keyed option would be both
ambiguous and wrong when `resourceMetadataPath` or `asMetadataPath` moves one.
Per endpoint rather than global, so a test can tell "discovery timed out" from
"token exchange timed out" — the distinction the five separate timeouts exist
to make. An unrecognized name throws at start rather than being ignored, since
a typo would otherwise yield a fixture that answers normally and a timeout test
that fails pointing at the timeout instead of at the config.
Two showcase configs and a `docs/test-servers.md` section covering both, what
to do with them, and what the broken build did.
Teardown needed no new machinery: `TestServerHttp.stop()` already calls
`httpServer.closeAllConnections?.()`, which destroys an established socket
whether or not a response was written. Measured at 0s with a withheld response
in flight, and pinned by a test, because it is the property most likely to be
broken by a future change to the stop path — and it would break as a hang in
`afterEach`, pointing at the wrong test.
Each guard was mutation-checked against the new suite:
middleware never registered -> 7 of 8 failed
match req.url instead of req.path -> 1 of 8 failed
unknown-entry validation removed -> 1 of 8 failed
stall calls next() anyway -> 6 of 8 failed
(unmutated) -> 8 passed
The `req.path` mutation initially escaped: every stallable endpoint but
`authorize` is requested at a bare path, so matching on `req.url` passed the
whole file. The query-string case exists to close that, and its comment says
so.
Closes#2382
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
* fix(test-servers): key the OAuth stall by method, and cover every endpoint (#2383 review)
Five findings from review, all defects in what this PR added.
**Method is part of the endpoint identity.** The middleware collapsed each
endpoint to a path, but two of the paths are caller-supplied: a valid
`asMetadataPath: "/oauth/token"` made stalling `token` also stall the metadata
GET, and vice versa — breaking the per-call contract the option exists to
provide. Keyed on `${METHOD} ${path}` now, with `authorize` carrying both GET
(consent page) and POST (submission).
**Every advertised endpoint is driven, not three of six.** `as-metadata`,
`revoke` and `register` were reachable in config but never requested, so a typo
in any of their hardcoded entries would yield a fixture that quietly answers
normally — the exact failure this option exists to prevent. The suite is now
table-driven over `STALLABLE_OAUTH_ENDPOINTS`, so adding a name without
covering it is impossible.
**`stallMs` is tested.** It was advertised and every fixture used 0, leaving the
delay branch and its timer cleanup able to regress unseen. Three cases now:
answers late rather than never, times out when the caller's budget is shorter
than the delay, and releases the timer when the client aborts first.
**The teardown test waits for a real signal, not a fixed sleep.** A 250ms sleep
that lost the race would have stopped the server before the request arrived; the
fetch would reject because the server closed and the test would pass without
ever exercising `closeAllConnections()` on an established request. It now waits
until the request is actually parked.
⚠️ That signal started as a module-level counter and failed under Vitest, which
can load the fixture module more than once — the middleware incremented one copy
while the test polled another, and the wait timed out with the fixture working
perfectly (verified standalone: parked after 100ms). It is per-server state read
off the instance the test already holds, which makes module identity irrelevant
and scopes the count to one fixture.
**The docs section is top-level.** It had been inserted as `###` directly under
`## Revoking tokens on clear`, so that section's prose became part of the
stalled-fixture subsection.
Re-mutated against the expanded suite; every guard is independently detected:
middleware never registered -> 19 of 20 failed
match req.url instead of req.path -> 2 of 20 failed
ignore method (path-only key) -> 1 of 20 failed
stallMs answers immediately -> 3 of 20 failed
registry never decremented -> 1 of 20 failed
as-metadata path typo -> 2 of 20 failed
(unmutated) -> 20 passed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
* fix(test-servers): validate stallMs, cover both configurable paths and the timer (#2383 review round 2)
Six more findings, all defects in what this PR added.
**The timer-cleanup test did not test timer cleanup.** It observed
`registry.parked`, which a separate `close` listener decrements, so it passed
with the 10s timer still armed — a test whose name and comment claimed
something it could not see. Split in two: the end-to-end case now says only
what it proves (the fixture lets go), and a focused middleware test with fake
timers and a `next` spy checks the actual claim. Removing `clearTimeout` now
fails; before, it did not.
**Both configurable metadata paths are now driven at non-default values.**
`stallTargetsFor` reads `resourceMetadataPath` and `asMetadataPath`, and every
test requested the defaults — so replacing either lookup with its default
literal left the suite green while `stallEndpoints` silently stopped working
for any config that moves the document. That is the exact typo class this
option exists to prevent, and it was the one my own doc comment claimed to
guard.
**`authorize` is driven as a POST as well as a GET.** It is the only endpoint
mapped to two methods, and only one was exercised, so dropping `"POST"` passed
the suite while a consent submission stopped stalling.
**`stallMs` is validated at setup**, like its sibling `stallEndpoints`. A
JSON/YAML config is only *cast* to its interface, so anything can arrive: a
negative or non-numeric value made `stallMs > 0` false and silently became a
PERMANENT stall, and a value past the 32-bit timer range overflows to ~1ms and
answers almost immediately. Both read as "the timeout behaved strangely"
rather than "the config is wrong". `MAX_STALL_MS` mirrors
`MAX_TIMER_DELAY_MS` in `core/auth/requestTimeout.ts`, which exists for the
same reason.
**The docs no longer claim a configurable budget.** They said the failure
arrives "after roughly 30s unless a shorter one is configured". There is no
such setting: `InspectorClient` passes `DEFAULT_OAUTH_REQUEST_TIMEOUT_MS`
straight into the transport chain and passes no budget at all on the auth
chain, so both take the default. Verified in
`core/mcp/inspectorClient.ts:866-889` before changing the text — it now states
the fixed 30 seconds.
**`stop()`'s JSDoc is back above `stop()`.** The new accessor had been inserted
between them, orphaning it.
Each fix mutation-checked against the 26-test suite; every one is detected, and
each by exactly one test:
drop POST from authorize's methods -> 1 of 26 failed
resourceMetadataPath -> default literal -> 1 of 26 failed
asMetadataPath -> default literal -> 1 of 26 failed
clearTimeout on close removed -> 1 of 26 failed
stallMs validation removed -> 1 of 26 failed
(unmutated) -> 26 passed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
* fix(test-servers): validate the stallEndpoints container, reject stallMs null, drop `any` (#2383 review round 3)
Four findings — two inline, two suppressed. All valid.
**`stallEndpoints` is validated as a container, not just by length.** A config
file is cast rather than parsed, so the value can arrive as a string, `null`, or
an object with a `length`. The old `.length === 0` check accepted `""` and
`{ length: 0 }` and returned "no stalling configured" — silently disabling the
capability this startup validation exists to protect — while a non-empty string
failed later with an incidental `.filter is not a function`.
**An explicit `stallMs: null` is rejected rather than defaulted.** `?? 0` turned
it into a valid 0, which skipped every check below and silently produced a
permanent stall. Only an omitted value takes the default now; `null` falls
through to the finite-number check.
**Both `as any` suppressions are gone.** This repo forbids `any` outright and
forbids disabling the rule to satisfy the linter, and I did both — twice. The
doubles are now bridged to `Request`/`Response` with a single justified
`as unknown as` and a comment saying why no narrower option exists: the literal
is missing ~50 properties so a single `as Request` is rejected, and the
middleware reads only `method`, `path` and `res.on`. If it ever reads more, the
double is wrong at runtime instead of being silently absorbed — which is what
the `any` was doing.
Mutation-checked, both new guards detected:
stallEndpoints container check removed -> 1 of 28 failed
stallMs back to `?? 0` (null coerced) -> 1 of 28 failed
(unmutated) -> 28 passed
⚠️ The container mutation initially read as ESCAPED at 28 passed. It had not:
`npm run format` had rewrapped the condition across lines, so the perl pattern
matched nothing and the "mutation" was a no-op. Re-applied by line range it
fails as expected. Worth recording — a mutation that silently fails to apply
reads exactly like a test gap, and I nearly reported it as one.
`npm run check` passes in both clients/cli and clients/web, so the `any` rule
and the type-aware lint are clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
* fix(test-servers): refuse to stall a route the config does not serve (#2383 review round 4)
Three findings, all suppressed comments (no inline threads this round).
**A stall is refused for an endpoint this config does not actually expose.** The
middleware runs *before* Express routing, so it would happily hold a request for
a route that would otherwise 404 — `supportDCR: false` with
`stallEndpoints: ["register"]` produced a hanging registration endpoint instead
of a configuration error, and a timeout test written against it would have
passed for entirely the wrong reason. `servesEndpoint` mirrors the registration
rules in `setupOAuthRoutes`/`setupMetadataEndpoints`: the protected-resource
document is always served, the rest of the local AS exists only in `combined`
mode, `revoke` carries `supportRevocation` and `register` carries `supportDCR`.
It lives next to `stallTargetsFor` so the two stay in step — a new OAuth route
needs an entry in both.
This is the same class as the validation already here, and the reason is the one
this PR keeps restating: a misconfiguration must fail loudly rather than produce
a hang that reads as a timeout.
**The error no longer misreports NaN and Infinity.** `JSON.stringify` returns
the string `"null"` for both — the two values most likely to reach the `stallMs`
check — so the message named a value the author never wrote. `describeValue`
renders non-finite numbers with `String` and keeps quotes on strings, which is
what makes `"600"` distinguishable from `600`.
**The PR description's test count is corrected again** (26 → 30) along with the
mutation table.
Mutation-checked, both new guards detected:
availability check removed -> 1 of 30 failed
describeValue -> JSON.stringify -> 1 of 30 failed
(unmutated) -> 30 passed
The availability test asserts both directions: five contradictory configs throw,
and the protected-resource document (served in every mode) plus the enabled
forms are accepted, so the check cannot pass by over-rejecting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
---------
Signed-off-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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>
- requestAppElicitation.ts, appElicitation.ts, docs/test-servers.md:
ext-apps 2.0.0 does not "predate" #733 (the PR is from July; the
release from September) — it simply does not include it. Reworded as
omission, and the version the appElicitation.ts JSDoc names is now
2.0.0 rather than 1.7.5.
- core/mcp/types.ts: the public `AppRendererClient` JSDoc described the
app-renderer proxy; it is the real SDK `Client`, handed out only while
connected, since #1745 removed the proxy.
- appCapabilities.test.ts: the two comments verified against 1.7.5 now
cite 2.0.0, which the test runs against.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
Every site tagged `ext-apps#702` carried a workaround for ext-apps
peering on the v1 SDK while the Inspector runs v2. With 2.0.0 the
package's `AppBridge` extends the v2 `Protocol`, takes the v2 `Client`
and `Implementation` directly, and registers its list-changed forwarding
on that client with `setNotificationHandler("notifications/…", handler)`,
so:
- core/mcp/apps.ts: `getToolUiResourceUri(tool)` takes the v2 `Tool`
without a cast; `UiMetaShape` is replaced by ext-apps' own
`McpUiToolMeta` / `McpUiResourceMeta`, which resolve under NodeNext
since ext-apps#705. `AppInfo.csp` / `.permissions` / `.visibility`
take the named types too.
- core/mcp/inspectorClient.ts: `getAppRendererClient()` returns the SDK
client itself; the Proxy that translated the 1.x schema-first
`setNotificationHandler(Schema, handler)` into the method-string form,
its `notificationMethodFromSchema` helper and its cached field are
gone. `AppRendererClient` is now `Client` rather than `unknown`.
- createAppBridgeFactory.ts: `new AppBridge(client, HOST_INFO, …)` with
no `as unknown as`; `resolveSourceUri` passes the tool straight through.
- AppRenderer.tsx: `sendToolResult(result)` — its params ARE the v2
`CallToolResult` now.
- requestAppElicitation.ts: the double cast on the `elicitation/create`
send goes too — v2 `Protocol.request` takes the standard `Request`
shape, which `ElicitRequest` is. The helper itself stays: ext-apps#733
is not in 2.0.0.
- core/mcp/extensions.ts: `MCP_APP_MIME_TYPE` re-exports ext-apps'
`RESOURCE_MIME_TYPE` from `/app-bridge`; `UI_EXTENSION_KEY` stays
hardcoded because `EXTENSION_ID` is still `/server`-only in 2.0.0.
Tests: the Proxy-translation test is removed with the Proxy; the
`getAppRendererClient` tests assert the real `Client` instance and that
disconnect withdraws it; the `useInspectorClient` sentinel is a real
(unconnected) `Client`. Comments that cited ext-apps 1.7.5 now cite 2.0.0
where the fact still holds (#733 still unreleased).
Closes#1745
Refs #2309
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 5. Two of three findings taken; the third rests on a false
premise about the SDK and is declined with evidence in the PR thread.
The hint and the input description are part of this fix's user-facing contract —
an HTTPS-only instruction there puts the local-fixture workflow back out of
reach even while the validator stays permissive — and nothing asserted them.
Two cases now do, plus one asserting a loopback http URL raises no error in the
form. Each mutation (restoring either piece of old copy) fails exactly one of
them.
The docs said there was "no loopback exemption at all" before #2305, which
contradicts the sentence above it: the runtime already tolerated an `http://`
already-stored `client_id`, and that asymmetry is what the issue is about. The
gap was in the config/form validator, and the wording now says so.
Also added: a note to use the canonical spelling of the host. `127.1` and
`2130706433` are accepted here and by the SDK, since both canonicalize to
`127.0.0.1` — but a CIMD `client_id` is string-compared by the authorization
server against the URL it dereferenced, so an exotic spelling is a real footgun
independent of what either validator allows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 4, four findings, all correct.
The CIMD-off / CIMD-on pair the smoke guide documents is not a control without
clearing OAuth state first: tokens and the registered client persist in
oauth.json independently of the install-wide CIMD toggle, and the Inspector
reuses valid stored tokens before prompting — so a leftover grant from an
earlier CIMD-on run makes the CIMD-off phase connect and the negative control
silently proves nothing. Both the spec's pair and the walkthrough in
docs/test-servers.md now say to clear state (or isolate MCP_STORAGE_DIR) first.
The composable server announces its MCP endpoint, not the metadata document —
`server-composable.ts` prints a URL ending in `/mcp`. The guide called the
announced URL a metadata URL; it now says to take the origin from that line and
append `/client-metadata.json`.
Two general statements elsewhere in the same file still defined CIMD as always
HTTPS (the glossary row, and the Stytch section's opening), so a reader
following them would have rejected the loopback fixture the same file now
documents. Both note the exemption; the Stytch examples stay HTTPS, which they
are.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
`getCimdClientMetadataUrlError` rejected every non-HTTPS CIMD client metadata
URL with no loopback exemption, and the same check runs against `client.json`
on disk through `refineCimdMetadataUrl`, so it could not be worked around by
hand-editing the file either. Every test server in this repo speaks plain HTTP,
so CIMD could not be driven against any local fixture: #2242 shipped verified by
tests alone, and reproducing it by hand meant standing up a self-signed HTTPS
listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0` in the test server's environment.
Accept plain `http:` on the three literals the SDK itself exempts from its
token-endpoint TLS assertion — `localhost`, `127.0.0.1` and `[::1]`. That is
option 1 in #2305 and matches what the runtime already tolerated:
`core/auth/cimd.ts` notes that an already-stored `client_id` may be an `http://`
URL used by local dev/test metadata servers. Anything outside those three,
`localhost.` and `tenant.app.localhost` included, is still rejected — matching
the SDK literal for literal keeps the two allow-lists from disagreeing about one
URL.
The exemption is a literal set rather than `isLoopbackHost` because
`core/client/config-parse.ts` is browser-safe and `core/node/hostUrl.ts` imports
`node:net`.
The field error now names the exemption instead of saying only "must use
HTTPS", and the form's hint and description say the same, so a developer
pointing the field at a local fixture is told which spellings work.
Closes#2305
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
Validate `oauth.clientMetadataPath` the way the other two metadata paths
are validated — in `loadConfig` for the JSON route and again at server
setup for a `ServerConfig` built in code. This path is not merely
advertised: it becomes the served document's own `client_id`, so an
off-origin or query-bearing value would publish a client id this server
cannot honour.
Derive that `client_id` from `req.originalUrl` rather than from the
registered route, so a document fetched with a query string answers with
a `client_id` byte-identical to the URL it was fetched from — the
equality CIMD turns on.
Correct the docs' fixed-port warning: this fixture configures no
`issuerUrl` and derives the `client_id` from the request, which is
exactly why the integration test can drive it on a harness-chosen port.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGH2ytPzSPxnp4cibaV3Z
Signed-off-by: cliffhall <cliff@futurescale.com>
In CIMD (SEP-991) the `client_id` *is* a URL the authorization server
dereferences to learn the client's metadata. Nothing in this repo served such a
document, so a fixture could advertise `client_id_metadata_document_supported`
and still be unusable — exercising CIMD meant standing up a second host by hand.
That is why #2242 shipped verified by its tests alone, and why the v2.6.0
release smoke recorded it as the one contribution with an observable UI surface
that could not be reached.
The composable server now hosts the document itself when `oauth.clientMetadata`
is set, gated on `supportCIMD` — advertising a client the server would then
refuse to honour is a worse fixture than serving nothing. The document's
`client_id` is derived from the **request** rather than from a configured
issuer, so it stays correct when the harness picks the port, as the integration
test does.
`supportDCR: false` in the fixture is load-bearing rather than incidental. With
DCR available a CIMD failure silently succeeds via dynamic registration and the
reproduction proves nothing — during development a misconfigured run connected
happily and reported `Dynamic (DCR)` with a `test_client_…` id, which reads as
success until you check the client id. With DCR off, CIMD is the only path that
can complete, so reaching a connected state is itself the assertion; the test
pins the absent `registration_endpoint` so it stays that way.
Verified end to end against the fixture: Connection Info read
`Client registration — Client ID Metadata (CIMD)` with the client id equal to
the metadata URL. That drive needed a self-signed HTTPS listener, because the
Inspector requires the CIMD metadata URL to be HTTPS with no loopback
exemption — filed separately as #2305, since it is a validation gap rather than
a fixture one.
Closes#2306
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsmR1iQstcrEzJFmgZXGLi
Signed-off-by: cliffhall <cliff@futurescale.com>
**Verification reads were unbounded.** The 512-entry limit is checked by
`checkSkillConformance` — as a warning, since the SEP makes it an
interoperability bound rather than a MUST — but checking it constrains
nothing, so a hostile server advertising a million entries had the tool
perform a million sequential reads after the report already knew the manifest
was over. Capped at `SKILL_MAX_RESOURCE_ENTRIES`, with the finding still
reported so bounding the work does not silence the reason it was
unnecessary. `manifestListsSelf` is computed over the READ slice, so a
self-entry pushed past the cap still reaches the fallback: the frontmatter
comparison is mandatory and must not be lost to a limit that bounds other
files.
**A malformed skill URI still produced a directory root.** `skillUriIdentity`
falls back to the raw string, so `not a uri/SKILL.md` yielded the root `not a
uri` and enabled the Directory section — letting the UI build a request from
a URI the conformance checks had already rejected. The comment above it
claimed otherwise. `normalizeSkillUri` now, with the rule written down:
identity is for COMPARING two spellings, not for deciding a URI is
well-formed enough to build a request from.
The existing regression test passed against a weaker input that failed for
lacking the suffix and never reached the fallback; it is a table of three
shapes now.
**The test-server guide overstated its own coverage,** claiming four of the
five conformance scenarios map onto fixtures. Three do: there is no
size-mismatch fixture, because the fixture can override an advertised digest
and nothing else, so that path is covered by unit tests. Corrected, with what
adding one would take.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
Two live findings. The review ran against 4fa00d1c — the commit before the
round-2 fixes — so its other three were re-reports of already-fixed issues,
verified present at HEAD rather than re-fixed.
**The TUI printed "Conformance: conforms" directly above "Verification
FAILED".** Self-contradictory in the one place a reader looks for a verdict:
the static checks pass on a skill whose advertised digest is well-formed,
while its bytes do not hash to it. The heading is now "Listing checks", which
names what the section covers — the checks against the entry the listing
returned, which say nothing about the bytes served. Same problem, and the
same resolution, as the web screen's "No structural issues".
**The test-server guide claimed every result carries the full modern
envelope**, which the new directory handler made untrue: `readDirectoryPage`
deliberately emits `resultType` alone, because SEP-2640 states the caching
attributes for a modern `skills/list` and says nothing of the kind here, and
its one worked example carries `resultType` only. The claim is narrowed to
the two `skills/*` results and the exception is documented as the deliberate
choice it is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
SEP-2640: hosts MUST NOT assume name uniqueness, and when two entries in one
listing collide on `name` a host MUST disambiguate them rather than silently
discarding or preferring one.
`checkSkillConformance` structurally cannot see this — it takes one entry and
a collision is a property of the pair — so `checkSkillNameCollisions` walks
the listing and returns a finding per colliding entry, each naming the
others. All three clients merge it into the entry's own findings, so it
carries through the header badge, the CLI report and the TUI row marks with
no new surface.
**It is a `warning`, not an `error`,** and that is the severity split doing
its job. The obligation is on the *consumer*: a server may legitimately
publish two skills with the same name under different paths, and the SEP's
own `acme/billing/refunds` example is exactly that shape. Calling it an error
would tell a conforming author their catalog is invalid. `--verify` therefore
still exits 0 for a collision.
In the web screen it renders as a banner directly under the Conformance
header and is filtered out of the findings list, the same treatment
`dynamic-resources` gets: it changes how everything below it should be read,
and stating one fact twice reads as two findings.
Fixed while adding it: an entry whose only finding was a banner one rendered
an EMPTY findings container instead of "no structural issues", because the
list's presence was decided on the unfiltered set while its contents were
filtered. `listedIssues` is now derived once and used for both.
The fixture gains `acme/reports` + `globex/reports`, both fully conforming
and sharing the name `reports` — also the only fixture with a multi-segment
skill path, which nothing else exercised. Eight skills over four pages now,
and the integration test walks the cursor to exhaustion rather than asserting
a fixed page count, so a future fixture does not require editing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
Five findings, all real.
**Critical — `verifySkills` selected `contents[0]`.** These bytes are hashed
against the file's advertised digest, so accepting a block the server
labelled something else verifies one file's content against another file's
digest, and can report that as `verified`. A false pass is worse than a
missing check. `contentsFor(result, uri)` now selects by NORMALIZED identity,
which keeps the canonicalized-echo case that motivated the original code
while rejecting an unrelated block, and reports a read error when nothing
answers for the URI. This is what `onReadSkillFile` already did; the two
paths hash bytes against digests and must not disagree about which bytes.
**`skills/get` was dispatched without the extension gate** that `skills/list`
had. Hoisted into `assertSkillsSupported` so the two cannot drift — declaring
the extension commits a server to both. Without it, an undeclared server's
-32601 is indistinguishable to a script from the -32602 a declared server
returns for a URI it does not serve.
**The TUI could strand a user on a hidden Skills tab.** The tab left the bar
when the gate went false but `activeTab` did not, so the pane kept rendering
for a server that never declared the extension. Reset to `info`, following
the Auth precedent — additionally gated on `connected`, because this gate
reads a server declaration and would otherwise fire during a reconnect.
**The TUI keyed a verdict by URI**, so a refresh replacing the manifest under
the same URI left hashes computed for the previous snapshot describing the
new one. Keyed on the entry now, as the web screen already was.
**A failed "Load more" discarded the pages on screen and the retry cursor.**
Both are preserved now, and the success and failure paths share one
staleness-guarded `commit` helper so they cannot drift on which results they
may write — which is how they came to disagree.
Two nits: the six-skill counts. The docs sentence was more wrong than
reported (five awkward skills of six, three outright violations) and is
rewritten rather than renumbered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
Closes#2248.
Completes SEP-2640 support across all three clients, and closes the one
obligation #2234 deliberately left open.
- `checkSkillFrontmatterMatch` compares a served SKILL.md's own YAML
frontmatter against the entry the listing advertised, field by field. No
digest can cover this: a digest is taken over the bytes the server served,
so it proves the file was not altered in transit and says nothing about
whether the listing described it honestly. Needs a real YAML parser; `yaml`
was already a root dependency, so this adds no package — but it does newly
put it on core/'s import graph, hence the three bundler `external` lists.
- `resources/directory/read`: result schemas defined against the normative
text, `InspectorClient.readResourceDirectory`, a Directory section on the
Skills screen, and `--method resources/directory/read` in the CLI. The
client refuses the call locally when the server did not declare
`directoryRead`, which is the SEP's MUST NOT. A child the directory lists
but the entry does not is marked `not listed` rather than merged into the
manifest — the SEP calls a directory result a live observation and forbids
treating it as extending the manifest.
- CLI: `skills/list`, `skills/get` and `resources/directory/read`, plus
`--verify` — one NDJSON report per skill, a summary on stderr, exit 7 on a
violation. Reads are sequential; a conforming manifest may declare 512
entries.
- TUI: a Skills pane, shown only when the server declares the extension. Each
row carries its conformance verdict as a glyph as well as a colour, since
the pane is read over ssh and through script(1). Enter verifies.
- `verifySkills` re-throws `AuthRecoveryRequiredError` rather than recording
it per file: it says the session's authorization expired, so absorbing it
would report N identical read failures and swallow the error the TUI and
the web commands key off to reauthorize.
Two open questions settled, both in code comments where they will be found:
`skills/get` carries no caching attributes because SEP-2640 leaves the
question open in as many words, so requiring them would fail a conforming
server; and there is no `PagedSkillsState` because every consumer of this
list is a whole-catalog verdict — computed over page one of three, "this
server's skills conform" is not merely partial but wrong.
The fixture grows two skills, both for checks that were otherwise
undemonstrable: `lying-listing` (listing and file disagree, digest still
verifies) and `stale-manifest` (serves a file its manifest does not declare).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
In round 2 I claimed a single wrapper "stops a future path omitting the banner
clear". That was only true inside useOAuthRecovery. useConnectionLifecycle
hand-wrote the notice-plus-scoped-clear pair three times, so the exact failure
mode I said was impossible was one careless edit away — in the PR that claimed
it.
reportTerminalInsecureTokenEndpoint now lives in lib/ and both hooks route
through it; zero hand-written pairs remain. It is generic over the banner shape
rather than importing ReAuthBannerState, since useOAuthRecovery already imports
this module and naming its type would close a cycle. All it needs is a serverId
to compare.
The helper's header also still said "the three OAuth failure paths". There are
seven. Rather than update the count I removed the enumeration and recorded why:
a list of callers is a comment that rots on the next round, which is what just
happened to it.
Also fixes "a /etc/hosts" -> "an /etc/hosts".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
- The recovery text told users to move the endpoint to `::1`. A bare IPv6
literal is not a legal URL host — `new URL("http://::1/token")` throws — so
anyone copying that into the Token URL override got a parse error from advice
meant to unblock them. Now `[::1]`, in both the notice and the manual
reproduction guide.
The exemption list still reads `::1`, because that is the host the SDK
compares; only the remedy is bracketed, because that is what a user types.
The comment says so, so neither gets "corrected" into the other.
- Dropped SEP-2207 from all nine files it had reached. I took the label from
the SDK's own source comments, which attribute this check to it — but this
repo already uses SEP-2207 for OIDC refresh / offline_access
(specification/v2_auth_hardening.md, plus an e2e test), so grepping it here
would have turned up two unrelated things. Replaced with plain description
rather than a different SEP number: having guessed wrong once, guessing again
is not the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
strictPort was a new test-server config option — plus its validation and 18
tests — invented solely to keep one fixture's hard-coded issuer port honest.
That is infrastructure built for a screenshot, so it goes.
test-servers/src is now byte-identical to v2/main again. The fixture keeps its
fixed port and docs/test-servers.md carries the caveat instead, written to be
actionable rather than merely cautionary: the failure mode is confusing rather
than obvious, since a relocated server leaves discovery pointing at whatever
unrelated process holds 8091, so the note says to check the port before
believing what the flow does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
Closes#2280
InsecureTokenEndpointError appeared nowhere in our source, so it fell through
to the generic auth-failure path and rendered a "Re-authentication required"
banner with a Re-authenticate button. That button can never work: the SDK's
assertSecureTokenEndpoint runs inside executeTokenRequest, the error does not
extend OAuthError, and auth() special-cases it to rethrow rather than start a
fresh /authorize redirect. Clicking re-ran the same flow to the same refusal,
under the raw SDK message, which names the three exempt host literals and
nothing actionable. A terminal configuration error was being presented as a
retryable auth error.
Not specific to *.localhost: it fires for any endpoint outside the SDK's
exemption — host.docker.internal (#1911), a LAN hostname, a reverse-proxy
name, a mistyped scheme.
It is now recognized (core/auth/insecureTokenEndpoint.ts, mirroring
issuerBinding.ts's brand-plus-name classifier and walking cause / data.cause,
since era negotiation and the transport wrappers bury the rejection) and
surfaced as the configuration error it is, naming the endpoint and both ways
out, with no action affordance.
Six paths reach the refusal and all six classify it — the connect handshake,
authenticate() during connect, the satisfied-challenge connect retry, the
post-redirect callback, the command/background path, the deferred tab-visible
resume (which re-armed on every focus, an unbounded loop on a terminal error),
and the banner action. They share one wrapper so a seventh cannot omit the
banner clear, and every clear is scoped to its own serverId: these paths are
asynchronous, so a late continuation for one server must not erase a banner
another raised in the meantime.
Adds oauth-insecure-token-endpoint-http.json to reproduce it, whose issuer is
http://localhost.:8091 — the root-anchored spelling every resolver sends to
loopback but which is none of the SDK's exempt literals. transport.strictPort
keeps that fixture honest, since its port is hard-coded inside the issuer.
Making such an endpoint work has to land in the SDK (typescript-sdk#2591);
this changes the reporting, not the outcome.
Split out of #2282, where it was tangled with the *.localhost origin work for
#1944 — a separate question the reporter is still clarifying.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
The Tools tab rendered every schema-portability finding, always open, above
the argument form. On a server whose schemas are broadly unportable that put
the same wall of text ahead of every tool's first input — and the findings
address the server author, not the caller trying to fill the form.
The section now opens collapsed behind the same disclosure heading the Skills
pane's Conformance section uses, with an `N error(s), M warning(s)` badge. The
expand choice is global rather than per tool (`useSchemaFindingsExpanded`,
stored as `inspector.schemaFindings.expanded`): this panel is reused across
selections, so a per-tool disclosure would re-collapse on every click and
reproduce the same scrolling. The badge stays visible while collapsed and the
tool-list severity icon is untouched, so nothing about a tool's standing is
hidden by the closed state.
Two neighbouring fixes on the same panel:
- `JsonEditor` frames Ace in a bordered `Paper`. Ace paints its background edge
to edge with nothing around it, so "Edit as JSON" replaced a column of
bordered inputs with one borderless slab.
- `ToolDetailPanel`'s body `ScrollArea` overrode the app-wide `type="scroll"`
default with `type="auto"`, parking a permanent bar down the side of any tool
whose form is taller than the panel. `offsetScrollbars` stays, so the form
does not shift sideways when the bar fades in.
`test-servers/configs/unportable-schemas-many-http.json` reproduces the
original complaint: the same four presets as `unportable-schemas-http.json`
carrying 26 findings rather than 5.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU3QYnc95g6EM4vo3hYNet
Signed-off-by: cliffhall <cliff@futurescale.com>
The Resources sidebar keyed every row on its `uri` (and templates on
`uriTemplate`), but nothing in MCP makes those unique — a server may return
the same URI twice, and `ManagedListState.applyItems` replaces the list
wholesale, so a repeat is what the server actually sent. React logged
`Encountered two children with the same key` on every render, and a
filtered-out row survived reconciliation instead of unmounting.
This is the resources counterpart of #1957. The row identity is now the same
`listRowKey(id, sourceIndex)` the Tools sidebar has used since then, extracted
so the two formats cannot drift; `toolRowKey` delegates to it. Applied to all
three Resources lists and to `RootsTable`, whose user-maintained list can
collect a duplicate URI the same way.
Selection stays keyed on the URI, deliberately: unlike a duplicated tool name
(#2001), duplicate rows here denote the same resource — a `resources/read`
takes the URI — so highlighting both is correct. Only the React key needs to
tell them apart.
Adds `ServerConfig.duplicateResourceUris` and the
`duplicate-resource-uris-http.json` showcase config, the `resources/list`
mirror of `duplicateToolNames`, so the defect has a fixture to reproduce it —
appended rather than adjacent, for the reason #1957 documented.
Closes#2206
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yVgAJz4wpkS4mkGk7DQnp
Signed-off-by: cliffhall <cliff@futurescale.com>
An MCP cursor is an opaque string: the spec constrains neither its content
nor its length, so `""` is a `nextCursor` a server may legitimately return
and the client is required to send back verbatim.
`listPrompts`, `listResources`, `listResourceTemplates` and
`listRequestorTasks` built their request params with `cursor ? { cursor } : {}`,
a truthiness check that cannot distinguish "no cursor" from "the cursor is
the empty string". Against a server that paginates with `""` they dropped it
and re-requested page one — a list that silently stops at the first page, or
a walk that loops on it forever. Nothing surfaced an error, because the
request was well-formed; it just asked the wrong question. `listTools` (and
the later `listSkills`) already used `cursor !== undefined`; this brings the
other four into line.
`getReplayableParams` in `protocolReplay.ts` existed partly to mirror the
asymmetry — its `method === "tools/list" || params.cursor !== ""` special
case collapses to `typeof params.cursor === "string"` now that the adapters
agree, and its long comment is updated rather than left describing a state
that no longer exists.
Coverage:
- `inspectorClient-list-cursor.test.ts` asserts the outbound params of all
five adapters — `""` carried, an absent cursor sending no `cursor` key,
a non-empty cursor forwarded unchanged. `listTools` is the control.
- `test-servers/configs/empty-cursor-http.json` (`emptyStringCursor`) hands
out `""` as the cursor for page two, the shape no other fixture produces,
and `empty-cursor.test.ts` walks all three of its lists end to end.
- Removing the fix from any one adapter fails exactly its two cases.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2UfrkQZvtrviMtqURRm4
Signed-off-by: cliffhall <cliff@futurescale.com>
`isOfferable` in `core/json/rootUnion.ts` checked that a merged branch carries
at least one property and that every property value is readable, but never that
each name in `required` resolves to a property a form can render. A branch like
`{ properties: { kind }, required: ["kind", "payload"] }` was therefore offered
in the picker, rendered no control for `payload`, and had the submit-time check
report it missing permanently — the exact dead end the function's own comment
already reasons through for a `false`-schema required field, one case short.
Judge the merge, so a branch requiring only names the ROOT declares —
`anyOf: [{ required: ["email"] }, …]` — stays offerable as before. `hasOwn`
rather than `in`, since an argument legally named `constructor` would otherwise
resolve through the prototype and read as declared.
Declining every branch of a union leaves the web form with neither a picker nor
a field, which is a different dead end. `SchemaForm` now seeds the raw-JSON
editor (#2151) in that one case — a root union that resolved to no branches and
no properties — so the arguments stay expressible. Seeded rather than forced, so
the switch still works, and re-seeded during render (never in an effect) because
the form is reused across tools rather than remounted. A tool that genuinely
takes no arguments is untouched. The TUI has no such editor; there the union is
simply not offered, which is already better than a section that can never be
submitted.
Adds a `record_shipment_by` preset and wires it into the
`root-union-schemas-http.json` showcase so the shape is reproducible by hand,
with docs/test-servers.md covering it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxYT4eKdnx1yRw7x4bW7Ts
Signed-off-by: cliffhall <cliff@futurescale.com>
Most of this round re-reported code the previous commit already changed
(it generated 0 new comments). Two findings were genuinely open.
- SkillsScreen: give "View SKILL.md" and "Fetch with skills/get" the same
attempt ordering `verifyRow` got in round 3. Both were still
last-completion-wins within one manifest: a double click whose older
request settled last replaced the newer result. One shared `isStale`
helper now covers all three actions — a result is dropped when its
manifest was invalidated OR a later click already wrote.
- test-servers/skills.ts + docs: stop calling all three edge cases
"non-conforming". `resources: "dynamic"` is a LEGAL wire form for
generated content — it is unverifiable, not invalid, which is exactly
why the checker reports it as a warning. The prose contradicted the
code it was describing. Now: one legal-but-unverifiable case and two
actual violations.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
Two of these reverse round 4, which asked for the opposite. Where the
readings conflict I took the permissive one: a wrong tightening rejects
conforming servers, a wrong loosening only fails to report something.
- skills.ts: stop requiring the `skill:` scheme. SEP-2640 says a server
SHOULD use `skill://` and explicitly allows a domain-native scheme, so
round 4's requirement handed a conforming `github://…` skill a false
`malformed-uri` and skipped its name and root checks. Containment is
scheme-independent — it compares against THIS entry's own root — so the
traversal fix that requirement came packaged with is unaffected.
- skills.ts: the 512-entry and 16 MiB limits are warnings. Both are
SHOULD NOTs for a server and MAYs for a host, so calling them errors
contradicted the "error = a MUST was broken" contract round 4
established and told authors a permitted skill was invalid.
- skills.ts: `skillEntriesMatch` compares canonically — keys sorted,
manifest sorted by URI. `JSON.stringify` treated key order and manifest
order as differences, though neither carries meaning.
- SkillsScreen: a differing `skills/get` is "a different snapshot"
(yellow), not a disagreement (red). The SEP defines it as a fresh
point-in-time read, so a skill that changed since the listing
legitimately differs.
- useServerCommands: route `onGetSkill` through the command-scoped OAuth
recovery like every other server command. An expired authorization
rendered an error and stopped there — no reauthorization, no retry.
- test-servers/skills.ts: stamp the modern base result envelope
(`resultType` / `ttlMs` / `cacheScope`) on every skills result. The SDK
stamps it only for methods in its own codec, and `skills/*` are
consumer-owned, so a 2026-era connection was receiving results without
it. Unconditional rather than per era: the modern leg builds a server
per request, so there is no era to branch on at registration time, and
on legacy they are three members no codec inspects.
- composable-test-server: remove the `skills.directoryRead` option. It was
publicly settable and advertised a method nothing serves, producing the
exact false capability the fixture exists to help catch. `skills` is now
a plain boolean; the flag returns in phase 3 (#2248) with its handler.
Still not changed: whether a modern `skills/list` RESULT SCHEMA must
require that envelope. Stamping it server-side is free; requiring it
client-side would reject servers on a reading nobody has checked against
the normative text. #2248 owns settling it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
- skills.ts: report a manifest that omits the skill's own SKILL.md
(an empty list included), duplicate URIs, entries outside the skill
root, and a missing size. `Conforms` was reachable for manifests that
break invariants SEP-2640 states.
- skills.ts: cross-check the declared byte length before hashing. A size
that disagrees fails verification on its own — the digest is taken over
the bytes the server served, so agreeing with it says nothing about
whether the manifest describes them.
- skills.ts: copy the view instead of slicing its backing store in
`sha256Digest`. `SharedArrayBuffer.prototype.slice()` returns another
SharedArrayBuffer, which `crypto.subtle.digest` rejects — the cast
claimed to handle the exact input that would have thrown. No cast now.
- skills.ts: state the one obligation NOT checked here — that an entry's
frontmatter matches the fetched SKILL.md's. The digest cannot cover it,
and closing it needs a YAML parser, so it is tracked on #2248.
- managedSkillsState: cap the walk at LIST_MAX_PAGES. The repeated-cursor
guard only catches a server stuck on one cursor; endlessly unique ones
walked forever. Raises rather than truncating, like the salvage walk.
- managedSkillsState: gate every write on a session generation, so a walk
that resolves after a disconnect or destroy cannot repopulate a cleared
store or deliver the previous session's skills into the next.
- inspectorClient: send a cursor when it is `!== undefined`, not when it
is truthy. An opaque cursor may be `""`, and dropping it re-requested
page one — which the store then reported as a repeated-cursor failure.
- SkillsScreen: key verdict invalidation on the manifest (URI + digests +
sizes), not the URI alone. A Refresh that changed the manifest left a
green badge attached to a digest nothing had checked.
- SkillsScreen: epoch-guard every read continuation, so a fetch that
resolves after the selection moved on cannot write into the new one.
- SkillsScreen: bound "Verify all" to 4 concurrent reads. A conforming
manifest may hold 512 files.
- skillsSchemas: drop the guessed `resources/directory/read` result
schema. Nothing calls it, so an unverified shape could sit wrong
indefinitely; phase 3 adds it against the normative text.
- skills-http.json: declare the extension bare. It advertised
`directoryRead: true` with no handler, so Connection Info reported
"Supported" for a method that answers -32601.
- types.ts: restore the Tasks doc comment the Skills interface displaced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
Adds detection, enumeration, and conformance checking for the Skills
extension (`io.modelcontextprotocol/skills`) — phases 1 and 2 of #2234,
which is everything its Acceptance list names.
Skills is a *server*-declared extension, read off the connecting server's
`capabilities.extensions`, so it is deliberately absent from
`ADVERTISABLE_EXTENSIONS` — that registry is what the Inspector advertises
and the user toggles, and an entry there would be a meaningless toggle.
`skills/list` and `skills/get` are consumer-owned extension methods that
neither era codec defines, so the SDK's era gate skips them and they go out
as ordinary `client.request` calls with explicit result schemas. The
raw-wire channel modern `tasks/*` needs is not used, and the Skills tab is
not era-gated: a legacy-era server that declares the extension is serving it.
The checks are the point, not the list view. `core/mcp/skills.ts` reports
each obligation SEP-2640 states — the name/path invariant, the digest
format, the 512-entry and 16 MiB limits, and `resources: "dynamic"`, which
means integrity cannot be verified at all. Digest verification hashes the
fetched bytes with WebCrypto and returns a mismatch with both digests
attached rather than throwing, because showing a mismatch loudly is the
whole value proposition.
Files are fetched on demand: SEP-2640 is explicit that a `resources/read`
of a `SKILL.md` is not a load and confers no standing, so none of the SEP's
host machinery is implemented.
The `skills-http` fixture serves four skills over two pages, three of them
deliberately non-conforming — without those the verification code is
untestable.
Phase 3 (CLI, TUI, `resources/directory/read`, paged mode) is #2248.
Closes#2234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
- skill-manifest: the closing `---` fence must occupy a whole line. `---oops`
was accepted as a terminator, silently truncating the frontmatter and passing
validation — the exact failure the guard exists to catch. Regression cases
added (prefix, trailing whitespace, EOF, CRLF).
- skill-eval: reject a nonzero `claude -p` exit instead of resolving it as an
empty observation. An auth error or rate limit was scoring as "no skill
invoked", which passes every negative case and reads as a trigger miss on
every positive one.
- skill-eval: extract `collectSkillInvocations` / `sampleHit` behind a `main()`
guard and cover them in `skill-eval.test.mjs` — the stream parsing and the
process-outcome handling are unreachable from an eval run's happy path.
- docs/test-servers: annotate the era on every catalogue row. Six legacy rows
carried no annotation under a "unless noted, Modern" default, so the doc sent
readers to the wrong era for the feature they were trying to see.
- docs/quality-gate: `validate` runs four guards, not three; add the
`verify:skills` row and the two new `test:scripts` suites; fix the `#publishing`
fragment left dangling by the split.
- issue-create / pr-flow: delegate the card steps to `/board-ops` rather than
restating its option IDs, which are explicitly unstable.
- issue-triage: the audit counted presence, not cardinality — an issue carrying
both `v1` and `v2` passed a check that claims "exactly one". Same for the five
type labels. Also scope the two milestone checks to board #28.
- AGENTS.md: codify the v1 milestone exception. Every milestone is a v2 release
bucket, so a v1 issue has none to take and the Incoming-milestone invariant
cannot apply to board #11.
- clients/launcher/README: its pointer promised an explanation of why the cli/tui
manifests ship, which the extracted guide does not make.
Signed-off-by: cliffhall <cliff@futurescale.com>
Closes#2163
`AGENTS.md` was one 1,223-line contract loaded in full on every turn, whether
the task was a one-line CSS fix or a release cut. This splits it: rules stay
resident, procedures move to `.claude/skills/`.
- Ten skills under `.claude/skills/`, seven `disable-model-invocation: true`
(invoked by name only, so they cost no listing context and have no trigger
question) and three model-invoked with committed eval cases.
- `AGENTS.md` 1,223 → 380 lines: rules, a skills index, and a project tree
reduced to one line per entry. The removed recipes are not duplicated.
- `README.md` 811 → 105 lines, split into `docs/{architecture,quality-gate,
test-servers,publishing,docker}.md`, and dropped from `CLAUDE.md`'s auto-load.
- `.github/copilot-instructions.md` and its mirroring rule removed — Copilot
reads `AGENTS.md`.
- `npm run verify:skills` (in `validate`, so in `local:gate` and CI) parses each
SKILL.md the way Claude Code does, requires an explicit invocation mode and
eval cases for model-invoked skills, and budgets the listing.
- `npm run skills:eval` runs the committed trigger cases headless.
Signed-off-by: cliffhall <cliff@futurescale.com>