feat: Skills extension phase 3 — CLI, TUI, directory reads, frontmatter check

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>
This commit is contained in:
cliffhall 2026-09-07 18:10:37 -04:00
commit 9a8488681e

View file

@ -60,17 +60,21 @@ as a missing capability rather than an error.
| `subscriptions-never-acknowledged-http.json` **(modern era)** | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
| `tasks-{legacy,modern}-http.json` **(era per file)** | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) |
| `cancellation-modern-http.json` **(modern era)** | Cancelling a call by closing its response stream | [#2140](https://github.com/modelcontextprotocol/inspector/issues/2140) |
| `skills-http.json` **(either era)** | Skills tab: `skills/list`, digest verification, and the non-conforming cases | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234) |
| `skills-http.json` **(either era)** | Skills tab: `skills/list`, `resources/directory/read`, digest verification, the frontmatter cross-check, and the non-conforming cases | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248) |
## Skills (SEP-2640)
`skills-http.json` sets `"skills": true` and serves four skills over two
`skills/list` pages. The extension is advertised **bare**: there is deliberately
no `directoryRead` option to turn on, because nothing here serves
`resources/directory/read` and a config that advertised it would produce exactly
the false capability this fixture helps catch — Connection Info reporting a
sub-option supported while the method answers `-32601`. Both come back in
phase 3 ([#2248](https://github.com/modelcontextprotocol/inspector/issues/2248)).
`skills-http.json` sets `"skills": true` and serves six skills over three
`skills/list` pages. Since
[#2248](https://github.com/modelcontextprotocol/inspector/issues/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.
Every result carries the modern base envelope (`resultType` / `ttlMs` /
`cacheScope`). `skills/*` are consumer-owned, so the SDK stamps nothing for
@ -84,7 +88,7 @@ It works on **either era**: `skills/list`, `skills/get` and
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.
Three of the four skills are deliberately awkward, because the checks the Skills
Four of the six skills are deliberately awkward, because the checks the Skills
tab runs are untestable without them. Only two are actual violations — the
`"dynamic"` form is **conforming**, and is here because "legal but unverifiable"
is the case most easily buried:
@ -95,12 +99,44 @@ is the case most easily buried:
| `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. |
| `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 red ✗. The Inspector surfaces the flag but
does not call `resources/directory/read` yet, and the wire schema for that
result is deliberately absent from `core/mcp/skillsSchemas.ts` too: phase 3 adds
it against the normative text rather than shipping a guess nothing exercises.
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:
```sh
mcp-inspector --cli --server-url http://127.0.0.1:3230/mcp --transport http \
--method skills/list --verify
```
One 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 <kbd>Enter</kbd>.
### Why these five shapes
They are the client-side obligations SEP-2640 makes testable from a hostile
server, which is how the
[`modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance)
harness grades a *client*: it stands up a server and watches what the client
does. Four of its five skills scenarios map onto a fixture here — a digest
mismatch (`tampered-notes`), a size mismatch, a frontmatter mismatch
(`lying-listing`), and a read of a file the manifest does not list
(`stale-manifest`). The fifth, **no-prefetch**, is a negative: it passes only if
connecting and calling `skills/list` produces *no* `resources/read` at 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.
## Cancelling a call