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>
This commit is contained in:
cliffhall 2026-09-04 22:06:36 -04:00
commit 6a08c77c92
8 changed files with 376 additions and 59 deletions

View file

@ -96,7 +96,9 @@ Nothing _enforces_ the boundary — no path alias keys off it, and the coverage
The Tools, Resources, Prompts and Skills screens each expose a `data-testid` plus a
small set of `data-*` attributes, so a headless driver can `waitForSelector` on
a deterministic signal rather than on visible copy. `scripts/smoke-web-tabs.mjs`
drives all three against `test-servers/configs/web-tabs-http.json` ([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)).
drives the first three of those four against `test-servers/configs/web-tabs-http.json`
([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)); Skills
publishes the same contract but is not smoked yet ([#2234](https://github.com/modelcontextprotocol/inspector/issues/2234)).
Treat them as a public contract, for the same reason as the Apps ones below:
| Attribute | Where | Meaning |

View file

@ -6,6 +6,7 @@ import { sha256Digest, textToBytes } from "@inspector/core/mcp/skills";
import {
renderWithMantine,
screen,
waitFor,
within,
} from "../../../test/renderWithMantine";
import {
@ -335,6 +336,100 @@ describe("SkillsScreen", () => {
expect(preview).toHaveTextContent("from a blob");
});
it("keeps the newest verdict when two verifications of one row overlap", async () => {
// Same row, same manifest — so the manifest key cannot tell these apart.
// Without a per-row attempt token the older read finishing last would
// overwrite the newer verdict and leave the UI reporting stale bytes.
const user = userEvent.setup();
const resolvers: ((value: { text: string }) => void)[] = [];
const onReadSkillFile = vi.fn(
() =>
new Promise<{ text: string }>((resolve) => {
resolvers.push(resolve);
}),
);
renderWithMantine(
<ControlledSkillsScreen
onReadSkillFile={onReadSkillFile}
skills={[
{
...CLEAN_SKILL,
resources: [
{
uri: "skill://data-analysis/SKILL.md",
digest: SELF_DIGEST,
size: textToBytes(SELF_TEXT).byteLength,
},
],
},
]}
/>,
);
await user.click(screen.getByText("data-analysis"));
const manifest = screen.getByTestId("skill-manifest");
const rowVerify = within(manifest).getAllByRole("button", {
name: "Verify",
})[0];
await user.click(rowVerify);
await user.click(rowVerify);
expect(resolvers).toHaveLength(2);
// The SECOND read answers first with the matching bytes, then the first
// read answers with bytes that would verify as a mismatch.
resolvers[1]({ text: SELF_TEXT });
expect(await screen.findByText("verified")).toBeInTheDocument();
resolvers[0]({ text: "stale bytes\n" });
// Still the newer verdict.
expect(await screen.findByText("verified")).toBeInTheDocument();
expect(screen.queryByText("mismatch")).not.toBeInTheDocument();
});
it("disables Verify all while a batch is running", async () => {
// The concurrency cap is per invocation, so a second click would start a
// second pool of four rather than reusing the first.
const user = userEvent.setup();
const pending: ((value: { text: string }) => void)[] = [];
const onReadSkillFile = vi.fn(
() =>
new Promise<{ text: string }>((resolve) => {
pending.push(resolve);
}),
);
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
const verifyAll = screen.getByRole("button", { name: /Verify all/ });
await user.click(verifyAll);
expect(verifyAll).toBeDisabled();
// Release every read the batch started; the button frees only once the
// whole batch settles, not once the first file does.
await waitFor(() => expect(pending.length).toBeGreaterThan(0));
for (const resolve of pending) resolve({ text: SELF_TEXT });
await waitFor(() => expect(verifyAll).not.toBeDisabled());
});
it("renders every duplicate finding rather than collapsing them", async () => {
// Three identical URIs produce two `duplicate-resource` findings with the
// same code and URI. A key built from those alone would make React drop
// the extras — hiding findings in exactly the malformed input this view is
// for.
const user = userEvent.setup();
const dup = {
uri: "skill://data-analysis/SKILL.md",
digest: SELF_DIGEST,
size: textToBytes(SELF_TEXT).byteLength,
};
renderWithMantine(
<ControlledSkillsScreen
skills={[{ ...CLEAN_SKILL, resources: [dup, dup, dup] }]}
/>,
);
await user.click(screen.getByText("data-analysis"));
const issues = screen.getByTestId("skill-issues");
expect(within(issues).getAllByText("duplicate-resource")).toHaveLength(2);
});
it("shows the SKILL.md preview on demand", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
Alert,
Badge,
@ -42,11 +42,18 @@ import {
*/
const VERIFY_CONCURRENCY = 4;
/** Per-row verification progress. */
type FileState =
/**
* Per-row verification progress. `attempt` is the click that produced it: two
* verifications of the SAME row in the SAME manifest (a double click, or a row
* button pressed while "Verify all" is running) are not distinguished by the
* manifest key, so without it an older read finishing last would overwrite the
* newer verdict and leave the UI reporting bytes it no longer fetched.
*/
type FileState = { attempt: number } & (
| { status: "pending" }
| { status: "done"; verification: SkillVerification }
| { status: "error"; message: string };
| { status: "error"; message: string }
);
/**
* Verification verdicts plus the manifest they belong to. Rows are keyed by
@ -277,6 +284,12 @@ export function SkillsScreen({
files: {},
});
const [previewState, setPreviewState] = useState<PreviewState>({ key: null });
// True while a "Verify all" batch is in flight; disables the button so a
// second click cannot stack another pool of workers on top.
const [batchRunning, setBatchRunning] = useState(false);
// Monotonic per-row attempt token. A ref because it is claimed inside an
// event handler, never during render.
const nextAttempt = useRef(0);
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
@ -342,24 +355,32 @@ export function SkillsScreen({
*/
const verifyRow = useCallback(
async (index: number, resource: SkillResource, key: string) => {
// Claimed synchronously, so two verifications of this row are ordered
// before either read starts.
const attempt = (nextAttempt.current += 1);
const write = (state: FileState) =>
setVerification((prev) => {
// `null` is the un-adopted initial manifest; any other mismatch is a
// continuation from a manifest that has since been invalidated.
if (prev.key !== null && prev.key !== key) return prev;
const files = prev.key === key ? prev.files : {};
// A newer attempt for this row already wrote — an older read
// finishing last must not overwrite it.
const held = files[index];
if (held !== undefined && held.attempt > attempt) return prev;
return { key, files: { ...files, [index]: state } };
});
write({ status: "pending" });
write({ attempt, status: "pending" });
try {
const contents = await onReadSkillFile(resource.uri);
const result = await verifySkillResource(
resource,
skillFileBytes(contents),
);
write({ status: "done", verification: result });
write({ attempt, status: "done", verification: result });
} catch (err) {
write({
attempt,
status: "error",
message: err instanceof Error ? err.message : String(err),
});
@ -386,7 +407,13 @@ export function SkillsScreen({
}
};
const workers = Math.min(VERIFY_CONCURRENCY, manifest.length);
void Promise.all(Array.from({ length: workers }, () => worker()));
setBatchRunning(true);
// The concurrency cap is per invocation, so without the button being
// disabled below, a second click would start a second pool of four and a
// third would make it twelve — the flood the cap exists to prevent.
void Promise.all(Array.from({ length: workers }, () => worker())).finally(
() => setBatchRunning(false),
);
}, [manifest, manifestKey, verifyRow]);
const showSkillMd = useCallback(() => {
@ -509,9 +536,15 @@ export function SkillsScreen({
</Alert>
) : (
<IssueStack data-testid="skill-issues">
{issues.map((issue) => (
{issues.map((issue, index) => (
<Alert
key={`${issue.code}:${issue.resourceUri ?? ""}`}
// The index is load-bearing, not decoration: a manifest
// repeating one URI three times yields three
// `duplicate-resource` findings with identical code and
// URI, and a key built from those alone would make
// React drop the extras — hiding findings in exactly
// the malformed input this view exists to inspect.
key={`${index}:${issue.code}:${issue.resourceUri ?? ""}`}
color={issueColor(issue)}
title={issue.code}
>
@ -542,7 +575,8 @@ export function SkillsScreen({
</VerifyButton>
<VerifyButton
onClick={verifyAll}
disabled={manifest.length === 0}
disabled={manifest.length === 0 || batchRunning}
loading={batchRunning}
>
Verify all
</VerifyButton>

View file

@ -9,6 +9,7 @@ import {
checkSkillConformance,
getSkillsExtension,
isSkillsExtensionSupported,
normalizeSkillUri,
sha256Digest,
skillDisplayName,
skillNameFromUri,
@ -112,11 +113,47 @@ describe("skillNameFromUri", () => {
expect(skillNameFromUri("SKILL.md")).toBeUndefined();
});
it("returns undefined for a relative string", () => {
// SEP-2640 requires a full resource URI; treating `demo/SKILL.md` as one
// would let a non-conforming entry report a name and pass the path check.
expect(skillNameFromUri("demo/SKILL.md")).toBeUndefined();
});
it("reads the name off the RESOLVED path, not the raw string", () => {
expect(skillNameFromUri("skill://acme/wrong/../demo/SKILL.md")).toBe(
"demo",
);
});
it("returns undefined when the segment before the suffix is empty", () => {
expect(skillNameFromUri("skill:///SKILL.md")).toBeUndefined();
});
});
describe("normalizeSkillUri", () => {
it("resolves traversal segments", () => {
expect(normalizeSkillUri("skill://acme/billing/refunds/../other.md")).toBe(
"skill://acme/billing/other.md",
);
});
it("rejects a relative string, which is not a resource URI", () => {
expect(normalizeSkillUri("demo/SKILL.md")).toBeUndefined();
});
it("rejects an opaque-path URI, which the parser does not normalize", () => {
// `skill:demo/../x.md` parses but keeps its `..` verbatim, so containment
// could not be decided on it — accepting it would reopen the hole.
expect(normalizeSkillUri("skill:demo/SKILL.md")).toBeUndefined();
});
it("leaves an already-normal URI alone", () => {
expect(normalizeSkillUri("skill://demo/SKILL.md")).toBe(
"skill://demo/SKILL.md",
);
});
});
describe("skillDisplayName", () => {
it("prefers the declared frontmatter name", () => {
expect(skillDisplayName(entry())).toBe("demo");
@ -148,12 +185,14 @@ describe("checkSkillConformance", () => {
expect(issues[0].severity).toBe("error");
});
it("reports a missing description as a warning", () => {
it("reports a missing description as an error", () => {
// SEP-2640 requires `description`, so an absent one is a format violation
// and must not read as "0 errors" in the conformance summary.
const issues = checkSkillConformance(
entry({ frontmatter: { name: "demo" } }),
);
expect(issues.map((i) => i.code)).toEqual(["missing-description"]);
expect(issues[0].severity).toBe("warning");
expect(issues[0].severity).toBe("error");
});
it("reports a URI that does not carry a skill path", () => {
@ -235,6 +274,35 @@ describe("checkSkillConformance", () => {
expect(issues[0].resourceUri).toBe("skill://demo/ref.md");
});
it("reports an entry that traverses out of the skill root", () => {
// The raw string starts with the root; the resolved path does not. A
// prefix check alone would miss this and report `Conforms`.
const issues = checkSkillConformance(
entry({
uri: "skill://demo/refunds/SKILL.md",
frontmatter: { name: "refunds", description: "d" },
resources: [
{ uri: "skill://demo/refunds/SKILL.md", digest: DIGEST, size: 1 },
{ uri: "skill://demo/refunds/../other.md", digest: DIGEST, size: 1 },
],
}),
);
expect(issues.map((i) => i.code)).toEqual(["resource-outside-skill-root"]);
});
it("reports an unparseable manifest entry as outside the root", () => {
// Nothing can establish that a non-URI is inside a root.
const issues = checkSkillConformance(
entry({
resources: [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
{ uri: "not a uri", digest: DIGEST, size: 1 },
],
}),
);
expect(issues.map((i) => i.code)).toEqual(["resource-outside-skill-root"]);
});
it("reports a manifest entry outside the skill root", () => {
const issues = checkSkillConformance(
entry({

View file

@ -6,6 +6,7 @@ import {
SKILLS_MAX_PAGES,
SKILLS_PAGE_LIMIT_MESSAGE,
} from "@inspector/core/mcp/state/managedSkillsState";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function skill(name: string): SkillEntry {
@ -216,6 +217,36 @@ describe("ManagedSkillsState", () => {
expect(state.getError()).toBe(failure);
});
describe("Protocol-entry attribution", () => {
it("attributes a decode rejection to its skills/list Protocol entry", async () => {
// Without this the Protocol tab renders an invalid `skills/list` result
// as a clean success — the same gap every managed list closed in #1953.
client.setStatus("connected");
const rejection = new SdkError(
SdkErrorCode.InvalidResult,
"Invalid result for skills/list: skills required",
);
client.listSkills.mockRejectedValueOnce(rejection);
await expect(state.refresh()).rejects.toThrow(rejection);
expect(client.markResponseRejected).toHaveBeenCalledWith(
"skills/list",
rejection.message,
);
});
it("does NOT attribute a transport failure", async () => {
// No response frame arrived, so the last-answered id still points at an
// EARLIER successful call; marking it would stamp "Rejected by the
// Inspector" onto an exchange that worked.
client.setStatus("connected");
client.listSkills.mockRejectedValueOnce(
new SdkError(SdkErrorCode.ConnectionClosed, "Connection closed"),
);
await expect(state.refresh()).rejects.toThrow();
expect(client.markResponseRejected).not.toHaveBeenCalled();
});
});
it("wraps a non-Error rejection", async () => {
client.setStatus("connected");
client.listSkills.mockRejectedValueOnce("just a string");

View file

@ -91,16 +91,49 @@ export function isSkillsExtensionSupported(
return getSkillsExtension(capabilities) !== undefined;
}
/**
* A skill URI in normalized form, or `undefined` when it is not one.
*
* Two things a raw string comparison gets wrong, and both matter:
*
* 1. **`..` segments.** `skill://acme/billing/refunds/../other.md` starts with
* the advertised root but resolves outside it. Containment has to be decided
* on the resolved path, so every check below goes through the parser.
* 2. **Relative strings.** SEP-2640 requires a full resource URI, and
* `demo/SKILL.md` is not one — it fails to parse and is reported as
* `malformed-uri` rather than quietly treated as a skill path.
*
* An **opaque-path** URI (`skill:demo/SKILL.md`, no authority) parses but is
* NOT normalized by the parser — its `..` segments survive verbatim — so it is
* rejected too: containment could not be decided on it, and silently accepting
* one would reintroduce exactly the hole this function closes.
*/
export function normalizeSkillUri(uri: string): string | undefined {
let parsed: URL;
try {
parsed = new URL(uri);
} catch {
return undefined;
}
return parsed.pathname.startsWith("/") ? parsed.href : undefined;
}
/**
* The final `<skill-path>` segment of a skill URI — the segment *before*
* `/SKILL.md`, not the filename. SEP-2640 requires it to equal
* `frontmatter.name`, which is what makes a skill's name recoverable from its
* URI alone. Returns `undefined` when the URI does not have that shape, which
* is itself a conformance finding.
*
* Read off the **normalized** URI, so a traversal segment cannot produce a
* name the resolved path does not actually carry.
*/
export function skillNameFromUri(uri: string): string | undefined {
if (!uri.endsWith(SKILL_FILE_SUFFIX)) return undefined;
const path = uri.slice(0, -SKILL_FILE_SUFFIX.length);
const normalized = normalizeSkillUri(uri);
if (normalized === undefined || !normalized.endsWith(SKILL_FILE_SUFFIX)) {
return undefined;
}
const path = normalized.slice(0, -SKILL_FILE_SUFFIX.length);
const segment = path.slice(path.lastIndexOf("/") + 1);
return segment.length > 0 ? segment : undefined;
}
@ -171,10 +204,12 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
});
}
if (!entry.frontmatter.description?.trim()) {
// An error, not a warning: SEP-2640 requires `description` on every skill,
// so an absent one is a format violation and must not read as "0 errors".
issues.push({
code: "missing-description",
severity: "warning",
message: "frontmatter.description is missing or empty.",
severity: "error",
message: "frontmatter.description is required but missing or empty.",
});
}
if (uriName === undefined) {
@ -237,12 +272,17 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
const seenUris = new Set<string>();
// Relative references resolve against the skill root, so every manifest entry
// must live under it. A URI outside that prefix is either a typo or a server
// claiming integrity over a file that is not part of this skill. Left
// `undefined` for a malformed entry URI — there is no root to measure
// against, and `malformed-uri` already reports that.
const root = entry.uri.endsWith(SKILL_FILE_SUFFIX)
? `${entry.uri.slice(0, -SKILL_FILE_SUFFIX.length)}/`
: undefined;
// claiming integrity over a file that is not part of this skill. Computed
// from the NORMALIZED entry URI, and compared against normalized resource
// URIs, so a `..` segment cannot walk out of the root while still matching it
// as a string. Left `undefined` for a malformed entry URI — there is no root
// to measure against, and `malformed-uri` already reports that.
const normalizedEntryUri = normalizeSkillUri(entry.uri);
const root =
normalizedEntryUri !== undefined &&
normalizedEntryUri.endsWith(SKILL_FILE_SUFFIX)
? `${normalizedEntryUri.slice(0, -SKILL_FILE_SUFFIX.length)}/`
: undefined;
for (const resource of entry.resources) {
if (seenUris.has(resource.uri)) {
@ -255,13 +295,18 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
});
}
seenUris.add(resource.uri);
if (root !== undefined && !resource.uri.startsWith(root)) {
issues.push({
code: "resource-outside-skill-root",
severity: "error",
message: `Manifest entry is outside the skill root "${root}".`,
resourceUri: resource.uri,
});
if (root !== undefined) {
const normalized = normalizeSkillUri(resource.uri);
// An unparseable entry URI is outside the root by construction: nothing
// can establish that it is inside one.
if (normalized === undefined || !normalized.startsWith(root)) {
issues.push({
code: "resource-outside-skill-root",
severity: "error",
message: `Manifest entry does not resolve inside the skill root "${root}".`,
resourceUri: resource.uri,
});
}
}
if (resource.digest === undefined) {
issues.push({

View file

@ -7,8 +7,7 @@
* not have: a top-level `ServerCapabilities` key to gate on (skills is a
* *server-declared extension*, read from `capabilities.extensions`), and a
* per-list `list_changed` notification to debounce and turn into a sidebar
* indicator
* (SEP-2640 defines none). Subclassing would mean widening the base's
* indicator (SEP-2640 defines none). Subclassing would mean widening the base's
* capability gate and inventing a list-changed event nothing dispatches — two
* changes to shared machinery to serve one caller. The cursor walk below is the
* only behavior actually shared, and it is nine lines.
@ -20,8 +19,8 @@
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { SkillEntry } from "../skillsSchemas.js";
import { LIST_MAX_PAGES } from "../listSalvage.js";
import { SKILLS_LIST_METHOD, type SkillEntry } from "../skillsSchemas.js";
import { LIST_MAX_PAGES, isClientDecodeRejection } from "../listSalvage.js";
import { isTerminalStatus } from "../types.js";
import type { RequestMetadata } from "../types.js";
import { TypedEventTarget } from "../typedEventTarget.js";
@ -201,7 +200,17 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
// auth-recovery wrapper keys off the rejection — but the *state* is left
// alone, so a dead session's failure cannot surface in the live one.
if (isCurrent()) {
this.setError(err instanceof Error ? err : new Error(String(err)));
const error = err instanceof Error ? err : new Error(String(err));
this.setError(error);
// Attribute the failure to the response it came from, so the Protocol
// entry stops rendering a rejected result as a clean success (#1953).
// Must happen in this catch, while the correlation window the client
// documents is still valid — and ONLY for a decode rejection, which is
// what `isClientDecodeRejection` separates from a request that never
// produced a response at all. Same handling every managed list gets.
if (isClientDecodeRejection(err)) {
client.markResponseRejected?.(SKILLS_LIST_METHOD, error.message);
}
}
throw err;
} finally {

View file

@ -12,15 +12,28 @@
* 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.
*
* Handlers are installed straight into the low-level `_requestHandlers` map
* rather than through `setRequestHandler`, the same seam `modern-tasks.ts`
* uses. `skills/*` are consumer-owned extension methods that neither era codec
* defines, so they need no schemas and are era-blind in both directions —
* which is what lets one fixture serve both the legacy and modern legs.
* `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 type { McpServer } from "@modelcontextprotocol/server";
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";
@ -215,7 +228,16 @@ export function getSkillEntry(uri: string): Record<string, unknown> {
const skill = FIXTURE_SKILLS.find(
(candidate) => `skill://${candidate.path}/SKILL.md` === uri,
);
if (!skill) throw new Error(`Unknown skill uri: ${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) };
@ -232,7 +254,11 @@ export function readSkillFile(
};
}
/** The private handler registry the SDK dispatches through. */
/**
* 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,
@ -241,38 +267,45 @@ interface RawHandlerHost {
}
interface UriRequest {
params?: { uri?: string; cursor?: string };
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`.
*
* `resources/read` is wrapped rather than replaced: 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.
*/
export function wireSkillsHandlers(mcpServer: McpServer): void {
const registry = (mcpServer.server as unknown as RawHandlerHost)
._requestHandlers;
const lowLevel = mcpServer.server;
registry.set("skills/list", async (request) => {
const req = request as UriRequest;
return listSkillsPage(req.params?.cursor);
});
lowLevel.setRequestHandler(
"skills/list",
{ params: ListSkillsParamsSchema },
async (params) => listSkillsPage(params.cursor),
);
registry.set("skills/get", async (request) => {
const req = request as UriRequest;
return getSkillEntry(req.params?.uri ?? "");
});
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 Error(`Unknown resource: ${req.params?.uri}`);
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Unknown resource: ${req.params?.uri}`,
);
}
return sdkResourcesRead(request, ctx);
});