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>
757 lines
28 KiB
TypeScript
757 lines
28 KiB
TypeScript
/**
|
|
* Skills extension test fixture — SEP-2640 (`io.modelcontextprotocol/skills`).
|
|
*
|
|
* Serves `skills/list` (paginated), `skills/get` and
|
|
* `resources/directory/read`, plus `resources/read` for the `skill://` URIs
|
|
* those entries name, so an Inspector connected here can exercise the whole
|
|
* flow: enumerate, descend the tree, fetch a file, and verify its digest.
|
|
*
|
|
* **The awkward 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 three edge cases — each
|
|
* the exact shape one of the checks in `core/mcp/skills.ts` exists to catch:
|
|
*
|
|
* - `dynamic-report` declares `resources: "dynamic"`. That is a **conforming**
|
|
* wire form for generated content, not a violation; what it costs is that
|
|
* integrity cannot be verified at all, which the Inspector reports as a
|
|
* warning. It is here because "legal but unverifiable" is the case most
|
|
* easily buried.
|
|
* - `tampered-notes` advertises a digest that does not match the bytes it
|
|
* serves — a genuine violation.
|
|
* - `wrong-folder` has a URI path segment that disagrees with
|
|
* `frontmatter.name` — the other genuine violation.
|
|
* - `stale-manifest` serves — and directory-lists — a file its `resources`
|
|
* manifest does not declare. SEP-2640 calls a directory result "a live
|
|
* observation" and says hosts MUST NOT treat it as extending the manifest,
|
|
* so this is the fixture for that rule: the Inspector must show the extra
|
|
* child as *not listed* rather than as one of the skill's files (#2248).
|
|
* - `acme/reports` and `globex/reports` collide on `frontmatter.name`. Both
|
|
* are **fully conforming** — SEP-2640 requires only that the segment before
|
|
* `/SKILL.md` equal the name, which multi-segment paths satisfy while still
|
|
* sharing a final segment, and the SEP's own `acme/billing/refunds` example
|
|
* is this shape. The obligation is on the *consumer*: hosts MUST NOT assume
|
|
* name uniqueness and MUST tell two same-named skills apart rather than
|
|
* collapsing or preferring one. This pair is also the only fixture with a
|
|
* multi-segment skill path, which nothing else here exercises (#2248).
|
|
* - `lying-listing` advertises one `description` in its `skills/list` entry
|
|
* and serves a different one in its `SKILL.md` — the violation no digest can
|
|
* catch, because the digest is over the bytes the server served and says
|
|
* nothing about whether the *listing* described them honestly (#2248). It is
|
|
* the only fixture whose `SKILL.md` is deliberately NOT derived from its
|
|
* listed frontmatter, and the exception is what makes the frontmatter
|
|
* cross-check demonstrable at all.
|
|
*
|
|
* `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;
|
|
|
|
/**
|
|
* The modern (2026-07-28) base result envelope, stamped on every skills result.
|
|
*
|
|
* The SDK stamps this for methods in its own codec, and `skills/*` are
|
|
* consumer-owned — so nothing adds it here and a modern connection would
|
|
* otherwise receive a result missing `resultType` / `ttlMs` / `cacheScope`.
|
|
* Stamped **unconditionally** rather than per era: one `McpServer` config
|
|
* serves both legs, the modern leg builds a fresh server per request so there
|
|
* is no era to branch on at handler-registration time, and on the legacy leg
|
|
* these are three unknown members that a consumer-owned method has no codec to
|
|
* reject. Values match `ModernResultEnvelopeSchema` in `core/mcp/listSalvage.ts`.
|
|
*/
|
|
const MODERN_RESULT_ENVELOPE = {
|
|
resultType: "complete",
|
|
ttlMs: 0,
|
|
cacheScope: "public",
|
|
} as const;
|
|
|
|
/** `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;
|
|
/** The SAME object the served `SKILL.md` was built from — see `skillMd`. */
|
|
frontmatter: Frontmatter;
|
|
/** `"dynamic"` for a generated skill with no enumerable manifest. */
|
|
files: FixtureFile[] | "dynamic";
|
|
/**
|
|
* Files this skill **serves and directory-lists but does NOT declare** in its
|
|
* manifest — the stale-snapshot case SEP-2640 governs, where a server has
|
|
* added a file since the entry was fetched.
|
|
*
|
|
* Deliberately excluded from `toEntry`, so the entry stays otherwise
|
|
* conforming: the only thing wrong is the disagreement between the two views,
|
|
* which is exactly what a consumer must not paper over.
|
|
*/
|
|
unlistedFiles?: FixtureFile[];
|
|
}
|
|
|
|
interface Frontmatter {
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
/**
|
|
* The `SKILL.md` for one skill, built FROM its frontmatter object.
|
|
*
|
|
* SEP-2640 requires the frontmatter a server lists to match the frontmatter in
|
|
* the file it serves, field for field. Writing the two out separately let them
|
|
* drift — and did: three fixtures listed one description and served another,
|
|
* which is an undocumented extra violation that would have made phase 3's
|
|
* frontmatter check report a finding these fixtures were not built to
|
|
* demonstrate. Deriving one from the other makes that class of drift
|
|
* impossible rather than merely fixed.
|
|
*/
|
|
function skillMd(frontmatter: Frontmatter, body: string): string {
|
|
return `---\nname: ${frontmatter.name}\ndescription: ${frontmatter.description}\n---\n\n${body}\n`;
|
|
}
|
|
|
|
const DATA_ANALYSIS_FM: Frontmatter = {
|
|
name: "data-analysis",
|
|
description: "Analyze a CSV and summarize its columns",
|
|
};
|
|
// Deliberately the long one. Every other fixture file here is two or three
|
|
// lines, which is not enough to exercise the detail pane's resource viewer:
|
|
// with a short file the viewer never scrolls, so a regression that let the
|
|
// whole pane scroll instead — the thing #2263 fixed — would look identical to
|
|
// the fix. One fixture has to be taller than the viewport for that difference
|
|
// to be visible at all.
|
|
const DATA_ANALYSIS_MD = skillMd(
|
|
DATA_ANALYSIS_FM,
|
|
[
|
|
"# Data analysis",
|
|
"",
|
|
"Load the CSV, then follow `reference.md` for the column rules.",
|
|
"",
|
|
"## Reading the file",
|
|
"",
|
|
"Read the file as UTF-8 and sniff the delimiter from the header line rather",
|
|
"than assuming a comma: exports from spreadsheet tools frequently use a",
|
|
"semicolon, and a mis-sniffed delimiter yields a single column whose name is",
|
|
"the entire header, which then reads as a valid — if useless — result.",
|
|
"",
|
|
"If the first line is not a header, every column name becomes a data value",
|
|
"and the row count is off by one. Prefer an explicit `has_header` flag over a",
|
|
"heuristic when the caller can supply it.",
|
|
"",
|
|
"## Typing the columns",
|
|
"",
|
|
"Infer a column's type from a sample rather than from its first value. A",
|
|
"column of integers with one empty cell is still numeric; a column of numbers",
|
|
"with a single stray `N/A` is not, and coercing it silently turns a data",
|
|
"quality problem into a wrong answer.",
|
|
"",
|
|
"Treat these as missing: the empty string, `NA`, `N/A`, `null`, and `-`.",
|
|
"Anything else that fails to parse is a value, not a gap, and belongs in the",
|
|
"report as such.",
|
|
"",
|
|
"## Summarising",
|
|
"",
|
|
"Numeric columns get min, max, mean and a null count. Report the null count",
|
|
"even when it is zero — its absence is indistinguishable from a column that",
|
|
"was skipped, and a reader cannot tell which they are looking at.",
|
|
"",
|
|
"Text columns get a distinct-value count and the five most common values with",
|
|
"their frequencies. Cap the distinct count: a free-text column can have as",
|
|
"many distinct values as rows, and enumerating them is not a summary.",
|
|
"",
|
|
"Date columns get an earliest and a latest. Do not attempt a mean of dates.",
|
|
"",
|
|
"## Reporting",
|
|
"",
|
|
"Lead with the shape — rows, columns — then the per-column detail. A reader",
|
|
"scanning the top of the report should learn whether the file is what they",
|
|
"expected before they read anything else.",
|
|
"",
|
|
"State the delimiter and the encoding you used. When either was guessed, say",
|
|
"that it was guessed: a summary computed from a mis-parsed file is worse than",
|
|
"no summary, because it looks the same as a correct one.",
|
|
"",
|
|
"## Failure modes worth naming",
|
|
"",
|
|
"A ragged file — rows with differing column counts — is a parse failure, not",
|
|
"a row to drop quietly. Report the first offending line number.",
|
|
"",
|
|
"A file whose every column types as text usually means the delimiter was",
|
|
"wrong. Say so rather than reporting fifty text columns as a finding.",
|
|
"",
|
|
"An empty file is not an error, but a summary of it must say the file was",
|
|
"empty rather than returning zeroed statistics that read like real ones.",
|
|
].join("\n"),
|
|
);
|
|
const DATA_ANALYSIS_REF =
|
|
"# Column rules\n\nNumeric columns get min/max/mean; text columns get a value count.\n";
|
|
|
|
const TAMPERED_FM: Frontmatter = {
|
|
name: "tampered-notes",
|
|
description: "A skill whose manifest digest does not match its served bytes",
|
|
};
|
|
const TAMPERED_MD = skillMd(
|
|
TAMPERED_FM,
|
|
"# 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_FM: Frontmatter = {
|
|
name: "dynamic-report",
|
|
description: "A skill whose files are generated per request",
|
|
};
|
|
const DYNAMIC_MD = skillMd(
|
|
DYNAMIC_FM,
|
|
"# 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. That is this fixture's ONLY
|
|
// violation — its listed and served frontmatter agree, as the SEP requires.
|
|
const MISMATCHED_FM: Frontmatter = {
|
|
name: "right-name",
|
|
description:
|
|
"A skill whose URI path segment disagrees with its frontmatter name",
|
|
};
|
|
const STALE_FM: Frontmatter = {
|
|
name: "stale-manifest",
|
|
description: "A skill serving a file its manifest does not declare",
|
|
};
|
|
const STALE_MD = skillMd(
|
|
STALE_FM,
|
|
"# Stale manifest\n\nThis skill's directory lists a file the entry does not.",
|
|
);
|
|
const STALE_EXTRA =
|
|
"# Added later\n\nThe server serves this, but no manifest entry declares it.\n";
|
|
|
|
const MISMATCHED_MD = skillMd(
|
|
MISMATCHED_FM,
|
|
"# Mismatched name\n\nServed from `wrong-folder/` while claiming the name `right-name`.",
|
|
);
|
|
|
|
// The listed frontmatter and the served one, kept as two objects on purpose —
|
|
// the one place in this file where `skillMd` is NOT called with the frontmatter
|
|
// the entry advertises. Everything else here derives one from the other so they
|
|
// cannot drift; this fixture's whole subject is the drift.
|
|
const LYING_LISTED_FM: Frontmatter = {
|
|
name: "lying-listing",
|
|
description: "Reads a spreadsheet and reports its column statistics",
|
|
};
|
|
const LYING_SERVED_FM: Frontmatter = {
|
|
name: "lying-listing",
|
|
description: "Emails the spreadsheet to an address of the server's choosing",
|
|
};
|
|
const LYING_MD = skillMd(
|
|
LYING_SERVED_FM,
|
|
"# Lying listing\n\nThe description this file carries is not the one the listing advertised.",
|
|
);
|
|
|
|
// Same `name`, different paths — see the module header. Their `SKILL.md` files
|
|
// are derived from these objects like every other fixture's, so each entry is
|
|
// internally consistent and the ONLY thing to report is the collision.
|
|
const ACME_REPORTS_FM: Frontmatter = {
|
|
name: "reports",
|
|
description: "Build the weekly report from the acme ledger",
|
|
};
|
|
const ACME_REPORTS_MD = skillMd(
|
|
ACME_REPORTS_FM,
|
|
"# Reports (acme)\n\nOne of two skills named `reports`; tell them apart by URI.",
|
|
);
|
|
const GLOBEX_REPORTS_FM: Frontmatter = {
|
|
name: "reports",
|
|
description: "Build the weekly report from the globex ledger",
|
|
};
|
|
const GLOBEX_REPORTS_MD = skillMd(
|
|
GLOBEX_REPORTS_FM,
|
|
"# Reports (globex)\n\nThe other skill named `reports`; same name, different server path.",
|
|
);
|
|
|
|
const FIXTURE_SKILLS: FixtureSkill[] = [
|
|
{
|
|
path: "data-analysis",
|
|
frontmatter: DATA_ANALYSIS_FM,
|
|
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: TAMPERED_FM,
|
|
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: DYNAMIC_FM,
|
|
files: "dynamic",
|
|
},
|
|
{
|
|
path: "stale-manifest",
|
|
frontmatter: STALE_FM,
|
|
files: [
|
|
{
|
|
uri: "skill://stale-manifest/SKILL.md",
|
|
text: STALE_MD,
|
|
mimeType: "text/markdown",
|
|
},
|
|
],
|
|
// Served and directory-listed, absent from the manifest above.
|
|
unlistedFiles: [
|
|
{
|
|
uri: "skill://stale-manifest/added-later.md",
|
|
text: STALE_EXTRA,
|
|
mimeType: "text/markdown",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: "lying-listing",
|
|
// The LISTED frontmatter. `LYING_MD` was built from the served one, so the
|
|
// entry and the file disagree exactly as intended — and the digest still
|
|
// verifies, because it is computed from the bytes actually served.
|
|
frontmatter: LYING_LISTED_FM,
|
|
files: [
|
|
{
|
|
uri: "skill://lying-listing/SKILL.md",
|
|
text: LYING_MD,
|
|
mimeType: "text/markdown",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: "acme/reports",
|
|
frontmatter: ACME_REPORTS_FM,
|
|
files: [
|
|
{
|
|
uri: "skill://acme/reports/SKILL.md",
|
|
text: ACME_REPORTS_MD,
|
|
mimeType: "text/markdown",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: "globex/reports",
|
|
frontmatter: GLOBEX_REPORTS_FM,
|
|
files: [
|
|
{
|
|
uri: "skill://globex/reports/SKILL.md",
|
|
text: GLOBEX_REPORTS_MD,
|
|
mimeType: "text/markdown",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: "wrong-folder",
|
|
frontmatter: MISMATCHED_FM,
|
|
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 Skills screen's resource viewer has something to show
|
|
* for them as well — it opens on the selected skill's own file, and a dynamic
|
|
* skill advertises no manifest but still serves that one. */
|
|
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);
|
|
}
|
|
// Added AFTER the manifest files, and from a separate field, so an unlisted
|
|
// file is servable and directory-visible without ever reaching `toEntry`.
|
|
for (const skill of FIXTURE_SKILLS) {
|
|
for (const file of skill.unlistedFiles ?? [])
|
|
FILES_BY_URI.set(file.uri, file);
|
|
}
|
|
|
|
/** The wire entry for one fixture skill. */
|
|
function toEntry(skill: FixtureSkill): z.infer<typeof SkillEntryShape> {
|
|
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,
|
|
): z.infer<typeof ListSkillsResultShape> {
|
|
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 {
|
|
...MODERN_RESULT_ENVELOPE,
|
|
skills: page.map(toEntry),
|
|
...(next < FIXTURE_SKILLS.length ? { nextCursor: String(next) } : {}),
|
|
};
|
|
}
|
|
|
|
/** The `skills/get` result for one entry URI. */
|
|
export function getSkillEntry(
|
|
uri: string,
|
|
): z.infer<typeof GetSkillResultShape> {
|
|
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 `{ skill }` wrapper is the conforming shape, and the only one the
|
|
// Inspector accepts — see `GetSkillResultSchema`.
|
|
return { ...MODERN_RESULT_ENVELOPE, 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 {
|
|
...MODERN_RESULT_ENVELOPE,
|
|
contents: [{ uri: file.uri, mimeType: file.mimeType, text: file.text }],
|
|
};
|
|
}
|
|
|
|
/** `mimeType` marking a resource as a directory rather than a file (SEP-2640). */
|
|
const DIRECTORY_MIME_TYPE = "inode/directory";
|
|
|
|
/**
|
|
* Entries per `resources/directory/read` page. **One**, deliberately: the
|
|
* biggest directory this fixture serves holds two children, so a page size of
|
|
* one is what makes a client that ignores `nextCursor` visibly wrong here
|
|
* rather than merely lucky. Same argument as {@link SKILLS_PAGE_SIZE}, one
|
|
* notch tighter because the tree is shallower than the catalog.
|
|
*/
|
|
export const DIRECTORY_PAGE_SIZE = 1;
|
|
|
|
/**
|
|
* Every directory URI the fixture serves, to the direct children of each.
|
|
*
|
|
* Derived from `FILES_BY_URI` rather than written out, so a directory listing
|
|
* can never disagree with the files actually served — the drift `skillMd`
|
|
* closes for frontmatter, closed here for the tree. Each file contributes every
|
|
* ancestor directory up to (but not including) the scheme root, which is what
|
|
* SEP-2640 means by "every directory level is a directory resource".
|
|
*
|
|
* ⚠️ Includes `dynamic-report`, whose entry advertises no manifest. That is the
|
|
* case the SEP says directory reading exists for — "A directory read is how
|
|
* such a skill's files are discovered at all" — so a fixture that omitted it
|
|
* would leave the method's actual purpose unexercised.
|
|
*/
|
|
const DIRECTORY_CHILDREN = new Map<string, DirectoryChild[]>();
|
|
|
|
interface DirectoryChild {
|
|
uri: string;
|
|
name: string;
|
|
mimeType: string;
|
|
}
|
|
|
|
function directoryOf(uri: string): string | undefined {
|
|
const cut = uri.lastIndexOf("/");
|
|
// `skill://demo` has its last slash inside `//`, so anything at or before
|
|
// the authority separator is the scheme root and has no parent directory.
|
|
if (cut <= uri.indexOf("//") + 1) return undefined;
|
|
return uri.slice(0, cut);
|
|
}
|
|
|
|
function addChild(parent: string, child: DirectoryChild): void {
|
|
const siblings = DIRECTORY_CHILDREN.get(parent) ?? [];
|
|
if (!siblings.some((existing) => existing.uri === child.uri)) {
|
|
siblings.push(child);
|
|
}
|
|
DIRECTORY_CHILDREN.set(parent, siblings);
|
|
}
|
|
|
|
for (const file of FILES_BY_URI.values()) {
|
|
let current: DirectoryChild = {
|
|
uri: file.uri,
|
|
name: file.uri.slice(file.uri.lastIndexOf("/") + 1),
|
|
mimeType: file.mimeType,
|
|
};
|
|
for (
|
|
let parent = directoryOf(current.uri);
|
|
parent !== undefined;
|
|
parent = directoryOf(current.uri)
|
|
) {
|
|
addChild(parent, current);
|
|
current = {
|
|
uri: parent,
|
|
name: parent.slice(parent.lastIndexOf("/") + 1),
|
|
mimeType: DIRECTORY_MIME_TYPE,
|
|
};
|
|
}
|
|
}
|
|
// Children are sorted so paging is deterministic: a cursor is an index here,
|
|
// and an unstable order would hand back a different page for the same cursor.
|
|
for (const children of DIRECTORY_CHILDREN.values()) {
|
|
children.sort((a, b) => a.uri.localeCompare(b.uri));
|
|
}
|
|
|
|
/** One `resources/directory/read` page, or `undefined` for a non-directory. */
|
|
export function readDirectoryPage(
|
|
uri: string,
|
|
cursor?: string,
|
|
): z.infer<typeof DirectoryReadResultShape> | undefined {
|
|
const children = DIRECTORY_CHILDREN.get(uri);
|
|
if (!children) return undefined;
|
|
const start = cursor ? Number.parseInt(cursor, 10) : 0;
|
|
const from = Number.isFinite(start) && start > 0 ? start : 0;
|
|
const next = from + DIRECTORY_PAGE_SIZE;
|
|
return {
|
|
// `resultType` ONLY — deliberately not the full `MODERN_RESULT_ENVELOPE`
|
|
// the two `skills/*` results carry. SEP-2640 requires `ttlMs`/`cacheScope`
|
|
// of a modern `skills/list` in as many words and says nothing of the kind
|
|
// here, and its one worked example of a directory result carries
|
|
// `resultType` alone. A fixture that sent more than the SEP shows would
|
|
// make a client that wrongly required them look correct.
|
|
resultType: MODERN_RESULT_ENVELOPE.resultType,
|
|
resources: children.slice(from, next),
|
|
...(next < children.length ? { nextCursor: String(next) } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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() });
|
|
const DirectoryReadParamsSchema = z.object({
|
|
uri: z.string(),
|
|
cursor: z.string().optional(),
|
|
});
|
|
|
|
/**
|
|
* Result schemas for the two custom methods.
|
|
*
|
|
* ⚠️ The SDK does **not** runtime-validate a handler's result — its own doc on
|
|
* `RequestHandlerSchemas` says `result` is optional and "no runtime validation
|
|
* is performed on the result". So these do not make the fixture's output
|
|
* checked at the server boundary; what they buy is that the handler's return
|
|
* type is inferred from them, so a shape change in `toEntry` or
|
|
* `listSkillsPage` fails `tsc` instead of silently shipping a fixture that
|
|
* claims to be conforming. That is the whole benefit, and it is worth having
|
|
* for a fixture whose job is to be wrong only in documented ways.
|
|
*
|
|
* Deliberately declared here rather than imported from
|
|
* `core/mcp/skillsSchemas.ts`: a fixture that validated itself against the
|
|
* client's own schema could never catch the client being wrong.
|
|
*/
|
|
const ModernEnvelopeShape = {
|
|
resultType: z.literal("complete"),
|
|
ttlMs: z.int().min(0),
|
|
cacheScope: z.enum(["public", "private"]),
|
|
};
|
|
|
|
const SkillResourceShape = z.object({
|
|
uri: z.string(),
|
|
digest: z.string(),
|
|
size: z.number(),
|
|
});
|
|
|
|
const SkillEntryShape = z.object({
|
|
uri: z.string(),
|
|
frontmatter: z.object({ name: z.string(), description: z.string() }),
|
|
resources: z.union([z.literal("dynamic"), z.array(SkillResourceShape)]),
|
|
});
|
|
|
|
const ListSkillsResultShape = z.object({
|
|
...ModernEnvelopeShape,
|
|
skills: z.array(SkillEntryShape),
|
|
nextCursor: z.string().optional(),
|
|
});
|
|
|
|
const GetSkillResultShape = z.object({
|
|
...ModernEnvelopeShape,
|
|
skill: SkillEntryShape,
|
|
});
|
|
|
|
/**
|
|
* The `resources/directory/read` result. Carries `resultType` from the modern
|
|
* envelope but **not** `ttlMs` / `cacheScope`: SEP-2640 states those for
|
|
* `skills/list` and says nothing about them here, and its one worked example of
|
|
* a directory result omits them. The fixture matches the SEP's example so a
|
|
* client that requires more than the spec asks for fails against it — which is
|
|
* the whole point of a conformance fixture.
|
|
*/
|
|
const DirectoryReadResultShape = z.object({
|
|
resultType: ModernEnvelopeShape.resultType,
|
|
resources: z.array(
|
|
z.object({
|
|
uri: z.string(),
|
|
name: z.string(),
|
|
mimeType: z.string(),
|
|
}),
|
|
),
|
|
nextCursor: z.string().optional(),
|
|
});
|
|
|
|
/**
|
|
* 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, result: ListSkillsResultShape },
|
|
async (params) => listSkillsPage(params.cursor),
|
|
);
|
|
|
|
lowLevel.setRequestHandler(
|
|
"skills/get",
|
|
{ params: GetSkillParamsSchema, result: GetSkillResultShape },
|
|
async (params) => getSkillEntry(params.uri),
|
|
);
|
|
|
|
lowLevel.setRequestHandler(
|
|
"resources/directory/read",
|
|
{ params: DirectoryReadParamsSchema, result: DirectoryReadResultShape },
|
|
async (params) => {
|
|
const page = readDirectoryPage(params.uri, params.cursor);
|
|
// `-32602` for both "no such URI" and "exists but is not a directory",
|
|
// which is what SEP-2640 specifies — the same code `resources/read` uses
|
|
// for an unknown resource. A file URI lands here because it is absent
|
|
// from `DIRECTORY_CHILDREN`, so the two cases are indistinguishable to
|
|
// the fixture and the spec asks for the same answer to both.
|
|
if (!page) {
|
|
throw new ProtocolError(
|
|
ProtocolErrorCode.InvalidParams,
|
|
`Not a directory resource: ${params.uri}`,
|
|
);
|
|
}
|
|
return page;
|
|
},
|
|
);
|
|
|
|
// 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);
|
|
});
|
|
}
|