inspector/test-servers/src/skills.ts
cliffhall 6a08c77c92 fix: address Copilot review round 3 on #2251
- skills.ts: decide root containment on NORMALIZED URIs. A raw prefix
  check passed `skill://acme/billing/refunds/../other.md`, which starts
  with the advertised root but resolves outside it — a traversal the
  conformance report was reporting as clean.
- skills.ts: `normalizeSkillUri` also rejects a relative string, so
  `demo/SKILL.md` is `malformed-uri` rather than a skill path, and an
  opaque-path URI (`skill:demo/..`), which the parser leaves un-normalized
  and on which containment cannot be decided.
- skills.ts: `missing-description` is an error. SEP-2640 requires it, so
  an absent one must not read as "0 errors" in the conformance summary.
- managedSkillsState: call `markResponseRejected` for a decode rejection,
  as every managed list does. An invalid `skills/list` result was showing
  in the Protocol tab as a clean success.
- SkillsScreen: per-row attempt token. The manifest key cannot tell two
  verifications of the SAME row apart, so a double click (or a row button
  pressed during "Verify all") let an older read finish last and overwrite
  the newer verdict.
- SkillsScreen: disable "Verify all" while a batch runs. The concurrency
  cap is per invocation, so repeated clicks stacked pools — 4, then 8,
  then 12.
- SkillsScreen: include the finding index in each issue alert's key. Three
  identical URIs produce two `duplicate-resource` findings with the same
  code and URI, and React was free to drop the extras — hiding findings in
  exactly the malformed input this view exists to inspect.
- test-servers/skills.ts: register `skills/list` and `skills/get` through
  the PUBLIC `setRequestHandler` with explicit param schemas. The private
  `_requestHandlers` map is now reached only to wrap `resources/read`,
  which has to chain onto the SDK's handler rather than replace it — the
  one thing the public API cannot express, and the comment now says so
  instead of citing the tasks fixture.
- test-servers/skills.ts: raise `-32602` for an unknown `skills/get` URI.
  A plain Error mapped to a generic server failure, making the fixture
  non-conforming outside its three documented bad cases.
- clients/web/README.md: the paragraph named four contracts while still
  saying the smoke "drives all three".

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>
2026-09-04 22:06:36 -04:00

312 lines
11 KiB
TypeScript

/**
* Skills extension test fixture — SEP-2640 (`io.modelcontextprotocol/skills`).
*
* Serves `skills/list` (paginated) and `skills/get`, plus `resources/read` for
* the `skill://` URIs those entries name, so an Inspector connected here can
* exercise the whole flow: enumerate, fetch a file, and verify its digest.
*
* **The non-conforming skills are the point.** A fixture that only served a
* clean skill would leave every verification and conformance path in the
* Inspector untestable, so the set below deliberately includes one `"dynamic"`
* skill, one whose advertised digest does not match the bytes served, and one
* whose URI path segment disagrees with `frontmatter.name`. Each is the exact
* shape one of the checks in `core/mcp/skills.ts` exists to catch.
*
* `skills/list` and `skills/get` are registered through the **public**
* `setRequestHandler`, which accepts a consumer-owned method name as long as
* explicit schemas are supplied — no private-field escape hatch, and the params
* are validated on the way in. That is the difference from `modern-tasks.ts`:
* `tasks/*` are spec names the 2026 codec deleted, so they need the raw seam;
* `skills/*` are in neither codec, which makes them era-blind in both
* directions and lets one fixture serve both the legacy and modern legs.
*
* `resources/read` is the one exception, and it is a *wrap* rather than a
* registration: the fixture must answer `skill://` URIs while leaving every
* other URI to the SDK's own handler, and `setRequestHandler` replaces a
* handler instead of chaining onto it. There is no public "extend this method"
* API, so the existing handler is captured and delegated to.
*/
import { createHash } from "node:crypto";
import * as z from "zod/v4";
import {
ProtocolError,
ProtocolErrorCode,
type McpServer,
} from "@modelcontextprotocol/server";
/** SEP-2133 extension identifier for the Skills extension (SEP-2640). */
export const SKILLS_EXTENSION_KEY = "io.modelcontextprotocol/skills";
/**
* Entries per `skills/list` page. Two, deliberately: the fixture serves four
* skills, so a client that stops after page one sees half the set — which is
* what makes a broken cursor walk visible rather than merely slower.
*/
export const SKILLS_PAGE_SIZE = 2;
/** `sha256:<64 lowercase hex>` over a UTF-8 string, the SEP's digest form. */
function digestOf(text: string): string {
return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
}
/** Byte length of a UTF-8 string, for the manifest's `size`. */
function sizeOf(text: string): number {
return Buffer.byteLength(text, "utf8");
}
interface FixtureFile {
uri: string;
text: string;
mimeType: string;
/**
* Digest to *advertise*, when it should differ from the real one. The
* tampered skill sets this; everywhere else the advertised digest is
* computed from the very bytes served, so a clean skill verifies.
*/
advertisedDigest?: string;
}
interface FixtureSkill {
/** The `<skill-path>` segment; `skill://<path>/SKILL.md` is the entry URI. */
path: string;
frontmatter: Record<string, unknown>;
/** `"dynamic"` for a generated skill with no enumerable manifest. */
files: FixtureFile[] | "dynamic";
}
function skillMd(name: string, description: string, body: string): string {
return `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`;
}
const DATA_ANALYSIS_MD = skillMd(
"data-analysis",
"Analyze a CSV and summarize its columns",
"# Data analysis\n\nLoad the CSV, then follow `reference.md` for the column rules.",
);
const DATA_ANALYSIS_REF =
"# Column rules\n\nNumeric columns get min/max/mean; text columns get a value count.\n";
const TAMPERED_MD = skillMd(
"tampered-notes",
"A skill whose manifest digest does not match its served bytes",
"# Tampered notes\n\nThe digest advertised for `notes.md` is wrong on purpose.",
);
const TAMPERED_NOTES =
"# Notes\n\nThese bytes hash to something other than what the manifest claims.\n";
const DYNAMIC_MD = skillMd(
"dynamic-report",
"A skill whose files are generated per request",
"# Dynamic report\n\nThis skill's file set is generated, so it advertises no manifest.",
);
// The frontmatter says `right-name` while the URI segment says `wrong-folder`,
// breaking the one structural invariant SEP-2640 states outright: the segment
// before /SKILL.md must equal frontmatter.name.
const MISMATCHED_MD = skillMd(
"right-name",
"A skill whose URI path segment disagrees with its frontmatter name",
"# Mismatched name\n\nServed from `wrong-folder/` while claiming the name `right-name`.",
);
const FIXTURE_SKILLS: FixtureSkill[] = [
{
path: "data-analysis",
frontmatter: {
name: "data-analysis",
description: "Analyze a CSV and summarize its columns",
},
files: [
{
uri: "skill://data-analysis/SKILL.md",
text: DATA_ANALYSIS_MD,
mimeType: "text/markdown",
},
{
uri: "skill://data-analysis/reference.md",
text: DATA_ANALYSIS_REF,
mimeType: "text/markdown",
},
],
},
{
path: "tampered-notes",
frontmatter: {
name: "tampered-notes",
description: "A skill whose manifest digest does not match its bytes",
},
files: [
{
uri: "skill://tampered-notes/SKILL.md",
text: TAMPERED_MD,
mimeType: "text/markdown",
},
{
uri: "skill://tampered-notes/notes.md",
text: TAMPERED_NOTES,
mimeType: "text/markdown",
// A syntactically valid digest of the *wrong* bytes, so the failure the
// Inspector reports is a mismatch rather than a malformed-digest
// finding — those are different checks and must stay distinguishable.
advertisedDigest: digestOf("not the bytes this server serves"),
},
],
},
{
path: "dynamic-report",
frontmatter: {
name: "dynamic-report",
description: "A skill whose files are generated per request",
},
files: "dynamic",
},
{
path: "wrong-folder",
frontmatter: {
name: "right-name",
description: "A skill whose URI segment disagrees with its name",
},
files: [
{
uri: "skill://wrong-folder/SKILL.md",
text: MISMATCHED_MD,
mimeType: "text/markdown",
},
],
},
];
/** Every servable `skill://` file, by URI. `dynamic` skills contribute their
* `SKILL.md` too, so the screen's "View SKILL.md" works there as well. */
const FILES_BY_URI = new Map<string, FixtureFile>();
for (const skill of FIXTURE_SKILLS) {
if (skill.files === "dynamic") {
FILES_BY_URI.set(`skill://${skill.path}/SKILL.md`, {
uri: `skill://${skill.path}/SKILL.md`,
text: DYNAMIC_MD,
mimeType: "text/markdown",
});
continue;
}
for (const file of skill.files) FILES_BY_URI.set(file.uri, file);
}
/** The wire entry for one fixture skill. */
function toEntry(skill: FixtureSkill): Record<string, unknown> {
return {
uri: `skill://${skill.path}/SKILL.md`,
frontmatter: skill.frontmatter,
resources:
skill.files === "dynamic"
? "dynamic"
: skill.files.map((file) => ({
uri: file.uri,
digest: file.advertisedDigest ?? digestOf(file.text),
size: sizeOf(file.text),
})),
};
}
/** One `skills/list` page starting at `cursor` (an index, as a string). */
export function listSkillsPage(cursor?: string): Record<string, unknown> {
const start = cursor ? Number.parseInt(cursor, 10) : 0;
// A cursor the fixture never issued is answered as an empty final page
// rather than an error: the Inspector's walk should terminate, and a thrown
// error here would read as a transport failure instead.
const from = Number.isFinite(start) && start > 0 ? start : 0;
const page = FIXTURE_SKILLS.slice(from, from + SKILLS_PAGE_SIZE);
const next = from + SKILLS_PAGE_SIZE;
return {
skills: page.map(toEntry),
...(next < FIXTURE_SKILLS.length ? { nextCursor: String(next) } : {}),
};
}
/** The `skills/get` result for one entry URI. */
export function getSkillEntry(uri: string): Record<string, unknown> {
const skill = FIXTURE_SKILLS.find(
(candidate) => `skill://${candidate.path}/SKILL.md` === uri,
);
// `-32602`, not a plain `Error`: the method contract says an unknown skill
// URI is invalid params, and a generic throw would be mapped to a server
// failure — making the fixture non-conforming outside its three documented
// bad cases, which is the opposite of what it is for.
if (!skill) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Unknown skill uri: ${uri}`,
);
}
// The envelope (`{ skill }`) is the conforming shape, and the only one the
// Inspector accepts — see `GetSkillResultSchema`.
return { skill: toEntry(skill) };
}
/** The `resources/read` result for a `skill://` file, or `undefined`. */
export function readSkillFile(
uri: string,
): Record<string, unknown> | undefined {
const file = FILES_BY_URI.get(uri);
if (!file) return undefined;
return {
contents: [{ uri: file.uri, mimeType: file.mimeType, text: file.text }],
};
}
/**
* The private handler registry the SDK dispatches through. Reached ONLY to wrap
* `resources/read` — see the module header for why that one has no public
* equivalent.
*/
interface RawHandlerHost {
_requestHandlers: Map<
string,
(request: unknown, ctx: unknown) => Promise<unknown>
>;
}
interface UriRequest {
params?: { uri?: string };
}
const ListSkillsParamsSchema = z.object({ cursor: z.string().optional() });
const GetSkillParamsSchema = z.object({ uri: z.string() });
/**
* Wire `skills/list`, `skills/get` and the `skill://` half of `resources/read`
* onto an `McpServer`.
*/
export function wireSkillsHandlers(mcpServer: McpServer): void {
const lowLevel = mcpServer.server;
lowLevel.setRequestHandler(
"skills/list",
{ params: ListSkillsParamsSchema },
async (params) => listSkillsPage(params.cursor),
);
lowLevel.setRequestHandler(
"skills/get",
{ params: GetSkillParamsSchema },
async (params) => getSkillEntry(params.uri),
);
// Wrapped, not registered: a `skill://` URI is answered here and everything
// else falls through to whatever the SDK registered, so a config can serve
// ordinary resources alongside its skills.
const registry = (lowLevel as unknown as RawHandlerHost)._requestHandlers;
const sdkResourcesRead = registry.get("resources/read");
registry.set("resources/read", async (request, ctx) => {
const req = request as UriRequest;
const skillFile = readSkillFile(req.params?.uri ?? "");
if (skillFile) return skillFile;
if (!sdkResourcesRead) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Unknown resource: ${req.params?.uri}`,
);
}
return sdkResourcesRead(request, ctx);
});
}