fix: address Copilot review round 5 on #2251

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>
This commit is contained in:
cliffhall 2026-09-04 23:11:46 -04:00
commit 754ce5bd95
11 changed files with 272 additions and 81 deletions

View file

@ -437,7 +437,7 @@ describe("SkillsScreen", () => {
expect(within(issues).getAllByText("duplicate-resource")).toHaveLength(2); expect(within(issues).getAllByText("duplicate-resource")).toHaveLength(2);
}); });
it("fetches the selected entry through skills/get and reports agreement", async () => { it("fetches the selected entry through skills/get and reports a match", async () => {
// The acceptance criterion this exists for: `skills/get` is one of the two // The acceptance criterion this exists for: `skills/get` is one of the two
// methods the extension requires, and a server author's handler is only // methods the extension requires, and a server author's handler is only
// exercisable if something actually calls it. // exercisable if something actually calls it.
@ -450,13 +450,36 @@ describe("SkillsScreen", () => {
); );
expect(onGetSkill).toHaveBeenCalledWith(CLEAN_SKILL.uri); expect(onGetSkill).toHaveBeenCalledWith(CLEAN_SKILL.uri);
expect( expect(
await screen.findByText("skills/get agrees with skills/list"), await screen.findByText("skills/get matches skills/list"),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("reports a skills/get entry that disagrees with the listing", async () => { it("treats key and manifest order as immaterial when matching", async () => {
// Both describe the same skill, so a disagreement is a server bug that // The manifest is a set and JSON key order carries no meaning, so a server
// only a side-by-side fetch can surface. // that enumerates either differently is not inconsistent — a
// `JSON.stringify` comparison would have called it one.
const user = userEvent.setup();
const onGetSkill = vi.fn().mockResolvedValue({
resources: [...CLEAN_SKILL.resources].reverse(),
frontmatter: {
description: CLEAN_SKILL.frontmatter.description,
name: CLEAN_SKILL.frontmatter.name,
},
uri: CLEAN_SKILL.uri,
});
renderWithMantine(<ControlledSkillsScreen onGetSkill={onGetSkill} />);
await user.click(screen.getByText("data-analysis"));
await user.click(
screen.getByRole("button", { name: /Fetch with skills\/get/ }),
);
expect(
await screen.findByText("skills/get matches skills/list"),
).toBeInTheDocument();
});
it("reports a skills/get entry that differs from the listing", async () => {
// Shown, but not called an error: `skills/get` is a fresh snapshot, so a
// skill that genuinely changed since the listing legitimately differs.
const user = userEvent.setup(); const user = userEvent.setup();
const onGetSkill = vi.fn().mockResolvedValue({ const onGetSkill = vi.fn().mockResolvedValue({
...CLEAN_SKILL, ...CLEAN_SKILL,
@ -468,7 +491,7 @@ describe("SkillsScreen", () => {
screen.getByRole("button", { name: /Fetch with skills\/get/ }), screen.getByRole("button", { name: /Fetch with skills\/get/ }),
); );
expect( expect(
await screen.findByText("skills/get disagrees with skills/list"), await screen.findByText("skills/get returned a different snapshot"),
).toBeInTheDocument(); ).toBeInTheDocument();
// The fetched entry is rendered beside the verdict so the difference is // The fetched entry is rendered beside the verdict so the difference is
// inspectable rather than merely asserted. (Its JSON goes through // inspectable rather than merely asserted. (Its JSON goes through

View file

@ -23,6 +23,7 @@ import { DYNAMIC_RESOURCES } from "@inspector/core/mcp/skillsSchemas.js";
import { import {
checkSkillConformance, checkSkillConformance,
skillDisplayName, skillDisplayName,
skillEntriesMatch,
totalSkillBytes, totalSkillBytes,
verifySkillResource, verifySkillResource,
type SkillIssue, type SkillIssue,
@ -82,13 +83,13 @@ interface PreviewState {
/** /**
* The result of the on-demand `skills/get`, plus the manifest it belongs to. * The result of the on-demand `skills/get`, plus the manifest it belongs to.
* `agrees` records whether the fetched entry matched the one `skills/list` * `matches` records whether the fetched entry describes the same skill as the
* returned — the reason for making the call at all. * one `skills/list` returned — the reason for making the call at all.
*/ */
interface FetchedEntryState { interface FetchedEntryState {
key: string | null; key: string | null;
entry?: SkillEntry; entry?: SkillEntry;
agrees?: boolean; matches?: boolean;
message?: string; message?: string;
} }
@ -483,12 +484,14 @@ export function SkillsScreen({
// started under so a late answer cannot land under another skill. // started under so a late answer cannot land under another skill.
void onGetSkill(selected.uri) void onGetSkill(selected.uri)
.then((entry) => { .then((entry) => {
// Compared field-by-field against what `skills/list` advertised. The // Compared semantically against what `skills/list` advertised — see
// two describe the same skill, so a disagreement is a server bug that // `skillEntriesMatch` for why a `JSON.stringify` comparison would
// only shows up when both are fetched. // report key order and manifest order as differences.
const agrees = JSON.stringify(entry) === JSON.stringify(selected); const matches = skillEntriesMatch(entry, selected);
setFetchedEntry((prev) => setFetchedEntry((prev) =>
prev.key !== null && prev.key !== key ? prev : { key, entry, agrees }, prev.key !== null && prev.key !== key
? prev
: { key, entry, matches },
); );
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
@ -767,20 +770,24 @@ export function SkillsScreen({
{fetched?.entry !== undefined && ( {fetched?.entry !== undefined && (
<Alert <Alert
data-testid="skills-get-result" data-testid="skills-get-result"
color={fetched.agrees ? "green" : "red"} // Yellow, not red: `skills/get` is a fresh point-in-time
// snapshot, so a skill that genuinely changed since the
// listing legitimately differs. Worth showing, not an
// accusation.
color={fetched.matches ? "green" : "yellow"}
title={ title={
fetched.agrees fetched.matches
? "skills/get agrees with skills/list" ? "skills/get matches skills/list"
: "skills/get disagrees with skills/list" : "skills/get returned a different snapshot"
} }
> >
<Stack gap={2}> <Stack gap={2}>
<Text size="sm"> <Text size="sm">
{fetched.agrees {fetched.matches
? "The entry this server returns for this URI is identical to the one it listed." ? "The entry this server returns for this URI describes the same skill it listed (compared ignoring key and manifest order)."
: "The entry this server returns for this URI differs from the one it listed; both describe the same skill, so one of them is wrong."} : "The entry this server returns for this URI differs from the one it listed. `skills/get` is a fresh snapshot, so this is expected if the skill changed since the list was fetched — and a server inconsistency if it did not."}
</Text> </Text>
{!fetched.agrees && ( {!fetched.matches && (
<ContentViewer <ContentViewer
block={{ block={{
type: "text", type: "text",

View file

@ -936,6 +936,57 @@ describe("onGetSkill (#2234)", () => {
"Client is not connected", "Client is not connected",
); );
}); });
it("retries once after a satisfied recovery", async () => {
// Every server command routes through the shared recovery; without it an
// expired authorization would render an error and stop there, with no
// reauthorization and no retry.
const recover = vi.fn().mockResolvedValue(true);
const getSkill = vi
.fn()
.mockRejectedValueOnce(authError())
.mockResolvedValue({
uri: "skill://demo/SKILL.md",
frontmatter: {},
resources: [],
});
const h = harness({
client: client({ getSkill }),
activeServerId: "a",
recovery: { handleCommandScopedAuthRecovery: recover },
});
await expect(
h.api().onGetSkill("skill://demo/SKILL.md"),
).resolves.toMatchObject({ uri: "skill://demo/SKILL.md" });
expect(getSkill).toHaveBeenCalledTimes(2);
});
it("rethrows when the recovery was not satisfied", async () => {
const recover = vi.fn().mockResolvedValue(false);
const h = harness({
client: client({ getSkill: vi.fn().mockRejectedValue(authError()) }),
activeServerId: "a",
recovery: { handleCommandScopedAuthRecovery: recover },
});
await expect(
h.api().onGetSkill("skill://demo/SKILL.md"),
).rejects.toBeInstanceOf(AuthRecoveryRequiredError);
});
it("rethrows a non-auth failure untouched", async () => {
const recover = vi.fn();
const h = harness({
client: client({
getSkill: vi.fn().mockRejectedValue(new Error("-32602")),
}),
activeServerId: "a",
recovery: { handleCommandScopedAuthRecovery: recover },
});
await expect(h.api().onGetSkill("skill://demo/SKILL.md")).rejects.toThrow(
"-32602",
);
expect(recover).not.toHaveBeenCalled();
});
}); });
describe("onRefreshSkills (#2234)", () => { describe("onRefreshSkills (#2234)", () => {

View file

@ -982,9 +982,25 @@ export function useServerCommands({
const onGetSkill = useCallback( const onGetSkill = useCallback(
async (uri: string): Promise<SkillEntry> => { async (uri: string): Promise<SkillEntry> => {
if (!inspectorClient) throw new Error("Client is not connected"); if (!inspectorClient) throw new Error("Client is not connected");
return inspectorClient.getSkill(uri); // Routed through the shared recovery like every other server command
// (#2174). Without it an expired authorization renders an error in the
// panel and stops there — no reauthorization, no retry — which is the
// one thing this hook exists to make impossible.
const get = () => inspectorClient.getSkill(uri);
try {
return await get();
} catch (err) {
if (err instanceof AuthRecoveryRequiredError && activeServerId) {
const satisfied = await handleCommandScopedAuthRecovery(err, {
serverId: activeServerId,
source: "resource",
});
if (satisfied) return get();
}
throw err;
}
}, },
[inspectorClient], [inspectorClient, activeServerId, handleCommandScopedAuthRecovery],
); );
const onRefreshSkills = useCallback(() => { const onRefreshSkills = useCallback(() => {

View file

@ -107,16 +107,18 @@ describe("skillNameFromUri", () => {
); );
}); });
it("reads the name off a domain-native scheme too", () => {
expect(skillNameFromUri("github://acme/repo/data-analysis/SKILL.md")).toBe(
"data-analysis",
);
});
it("returns undefined for a URI that does not end in /SKILL.md", () => { it("returns undefined for a URI that does not end in /SKILL.md", () => {
expect(skillNameFromUri("skill://demo/other.md")).toBeUndefined(); expect(skillNameFromUri("skill://demo/other.md")).toBeUndefined();
// The suffix must include the separator: a bare "SKILL.md" has no segment. // The suffix must include the separator: a bare "SKILL.md" has no segment.
expect(skillNameFromUri("SKILL.md")).toBeUndefined(); expect(skillNameFromUri("SKILL.md")).toBeUndefined();
}); });
it("returns undefined for a non-skill scheme", () => {
expect(skillNameFromUri("https://demo/SKILL.md")).toBeUndefined();
});
it("returns undefined for a relative string", () => { it("returns undefined for a relative string", () => {
// SEP-2640 requires a full resource URI; treating `demo/SKILL.md` as one // 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. // would let a non-conforming entry report a name and pass the path check.
@ -145,12 +147,16 @@ describe("normalizeSkillUri", () => {
expect(normalizeSkillUri("demo/SKILL.md")).toBeUndefined(); expect(normalizeSkillUri("demo/SKILL.md")).toBeUndefined();
}); });
it("rejects a non-skill scheme", () => { it("does not privilege the skill: scheme", () => {
// Checking only that a URI is hierarchical would let this through and then // SEP-2640 only says a server SHOULD use `skill://`, and explicitly allows
// pass the name and root checks — a manifest pointing anywhere on the web, // a domain-native scheme — so rejecting one would hand a conforming server
// reported as conforming. // a false `malformed-uri` and skip its name and root checks.
expect(normalizeSkillUri("https://demo/SKILL.md")).toBeUndefined(); expect(normalizeSkillUri("github://acme/repo/SKILL.md")).toBe(
expect(normalizeSkillUri("file:///demo/SKILL.md")).toBeUndefined(); "github://acme/repo/SKILL.md",
);
expect(normalizeSkillUri("https://demo/a/../SKILL.md")).toBe(
"https://demo/SKILL.md",
);
}); });
it("rejects an opaque-path URI, which the parser does not normalize", () => { it("rejects an opaque-path URI, which the parser does not normalize", () => {
@ -406,10 +412,13 @@ describe("checkSkillConformance", () => {
], ],
}), }),
); );
expect(issues.map((i) => i.code)).toContain("size-limit-exceeded"); const finding = issues.find((i) => i.code === "size-limit-exceeded");
expect(finding?.severity).toBe("warning");
}); });
it("reports a manifest over the 512-entry limit", () => { // Both limits are SHOULD NOTs for a server and MAYs for a host, so exceeding
// one makes a skill less portable rather than invalid.
it("reports a manifest over the 512-entry limit as a warning", () => {
const resources = [ const resources = [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 }, { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
...Array.from({ length: SKILL_MAX_RESOURCE_ENTRIES }, (_unused, i) => ({ ...Array.from({ length: SKILL_MAX_RESOURCE_ENTRIES }, (_unused, i) => ({
@ -419,10 +428,11 @@ describe("checkSkillConformance", () => {
})), })),
]; ];
const issues = checkSkillConformance(entry({ resources })); const issues = checkSkillConformance(entry({ resources }));
expect(issues.map((i) => i.code)).toContain("resource-limit-exceeded"); const finding = issues.find((i) => i.code === "resource-limit-exceeded");
expect(finding?.severity).toBe("warning");
}); });
it("reports a manifest over the 16 MiB limit", () => { it("reports a manifest over the 16 MiB limit as a warning", () => {
const issues = checkSkillConformance( const issues = checkSkillConformance(
entry({ entry({
resources: [ resources: [

View file

@ -52,9 +52,6 @@ export const SKILL_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
/** The suffix every skill URI ends with; the segment before it is the name. */ /** The suffix every skill URI ends with; the segment before it is the name. */
export const SKILL_FILE_SUFFIX = "/SKILL.md"; export const SKILL_FILE_SUFFIX = "/SKILL.md";
/** The URI scheme SEP-2640 defines for skills, as `URL.protocol` spells it. */
export const SKILL_URI_SCHEME = "skill:";
/** `sha256:` followed by exactly 64 lowercase hex characters. */ /** `sha256:` followed by exactly 64 lowercase hex characters. */
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
@ -112,9 +109,13 @@ export function isSkillsExtensionSupported(
* rejected too: containment could not be decided on it, and silently accepting * rejected too: containment could not be decided on it, and silently accepting
* one would reintroduce exactly the hole this function closes. * one would reintroduce exactly the hole this function closes.
* *
* The `skill:` scheme is required. Checking only that the URI is hierarchical * The scheme is deliberately **not** constrained. `skill://` is what SEP-2640
* would let `https://demo/SKILL.md` through and then pass the name and root * recommends and what this repo's fixture serves, but the SEP only says servers
* checks — a manifest pointing anywhere on the web, reported as conforming. * SHOULD use it and explicitly allows a domain-native scheme (`github://…`), so
* requiring `skill:` would hand a conforming server a false `malformed-uri` and
* skip its name and root checks entirely. Containment is scheme-independent
* anyway: it compares a resource against *this entry's own* root, so an entry
* cannot escape its skill whatever scheme it uses.
*/ */
export function normalizeSkillUri(uri: string): string | undefined { export function normalizeSkillUri(uri: string): string | undefined {
let parsed: URL; let parsed: URL;
@ -123,7 +124,6 @@ export function normalizeSkillUri(uri: string): string | undefined {
} catch { } catch {
return undefined; return undefined;
} }
if (parsed.protocol !== SKILL_URI_SCHEME) return undefined;
return parsed.pathname.startsWith("/") ? parsed.href : undefined; return parsed.pathname.startsWith("/") ? parsed.href : undefined;
} }
@ -176,10 +176,11 @@ export type SkillIssueCode =
| "size-limit-exceeded"; | "size-limit-exceeded";
/** /**
* `error` marks a stated requirement of SEP-2640 that the server broke — * `error` marks a **MUST** of SEP-2640 that the server broke, so a manifest
* every MUST, so a manifest reporting "0 errors" really is one the spec * reporting "0 errors" really is one the spec accepts. `warning` covers
* accepts. `warning` is reserved for what is **legal** yet leaves the * everything the spec permits but a consumer still wants told about: the
* Inspector unable to verify integrity: `"dynamic"` resources, the case most * `SHOULD NOT`-exceed interoperability limits, and — above all — `"dynamic"`
* resources, which are legal and leave integrity unverifiable, the case most
* worth surfacing and the one most easily buried. * worth surfacing and the one most easily buried.
*/ */
export type SkillIssueSeverity = "error" | "warning"; export type SkillIssueSeverity = "error" | "warning";
@ -226,7 +227,7 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
issues.push({ issues.push({
code: "malformed-uri", code: "malformed-uri",
severity: "error", severity: "error",
message: `Skill URI must be a "${SKILL_URI_SCHEME}//" URI ending with "${SKILL_FILE_SUFFIX}" and carrying a non-empty path segment before it.`, message: `Skill URI must be a hierarchical URI ending with "${SKILL_FILE_SUFFIX}" and carrying a non-empty path segment before it.`,
}); });
} else if (declaredName && uriName !== declaredName) { } else if (declaredName && uriName !== declaredName) {
// The one structural invariant the spec states outright: the segment before // The one structural invariant the spec states outright: the segment before
@ -250,19 +251,24 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
return issues; return issues;
} }
// Both limits are *interoperability* bounds, not MUSTs: SEP-2640 says a
// server SHOULD NOT exceed them and a host MAY support more. So they are
// warnings — calling a permitted oversized skill an error would contradict
// what `error` means here and tell a server author their skill is invalid
// when it is merely less portable.
if (entry.resources.length > SKILL_MAX_RESOURCE_ENTRIES) { if (entry.resources.length > SKILL_MAX_RESOURCE_ENTRIES) {
issues.push({ issues.push({
code: "resource-limit-exceeded", code: "resource-limit-exceeded",
severity: "error", severity: "warning",
message: `Skill declares ${entry.resources.length} resource entries, above the ${SKILL_MAX_RESOURCE_ENTRIES}-entry limit.`, message: `Skill declares ${entry.resources.length} resource entries, above the ${SKILL_MAX_RESOURCE_ENTRIES}-entry interoperability limit; a host is only required to support up to it.`,
}); });
} }
const totalBytes = totalSkillBytes(entry.resources); const totalBytes = totalSkillBytes(entry.resources);
if (totalBytes > SKILL_MAX_TOTAL_BYTES) { if (totalBytes > SKILL_MAX_TOTAL_BYTES) {
issues.push({ issues.push({
code: "size-limit-exceeded", code: "size-limit-exceeded",
severity: "error", severity: "warning",
message: `Skill resources total ${totalBytes} bytes, above the ${SKILL_MAX_TOTAL_BYTES}-byte (16 MiB) limit.`, message: `Skill resources total ${totalBytes} bytes, above the ${SKILL_MAX_TOTAL_BYTES}-byte (16 MiB) interoperability limit; a host is only required to support up to it.`,
}); });
} }
@ -388,6 +394,50 @@ export function totalSkillBytes(resources: readonly SkillResource[]): number {
); );
} }
/**
* Whether a `skills/get` entry describes the same skill as the `skills/list`
* entry alongside it, compared **semantically** rather than byte-for-byte.
*
* Two things a `JSON.stringify` comparison gets wrong here, and both would
* report a conforming server as broken: object key order is not meaningful in
* JSON, and the resource manifest is a *set*, so a server free to enumerate it
* in any order would look inconsistent for reordering it. Both sides are
* canonicalized — keys sorted recursively, manifest entries sorted by URI —
* before they are compared.
*
* A difference is still worth showing, but it is NOT by itself an error:
* SEP-2640 defines `skills/get` as a fresh point-in-time snapshot, so a skill
* that genuinely changed since the listing legitimately differs. The caller
* presents it as "the snapshot moved" and leaves the judgement to the reader.
*/
export function skillEntriesMatch(a: SkillEntry, b: SkillEntry): boolean {
return canonicalJson(a) === canonicalJson(b);
}
/** Deterministic JSON: object keys sorted recursively, and a skill's manifest
* sorted by URI so its enumeration order carries no meaning. */
function canonicalJson(value: unknown): string {
return JSON.stringify(canonicalize(value));
}
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize);
if (value === null || typeof value !== "object") return value;
const entries = Object.entries(value as Record<string, unknown>)
.map(([key, member]): [string, unknown] => {
// The manifest is a set; only its contents are meaningful.
if (key === "resources" && Array.isArray(member)) {
const sorted = [...(member as SkillResource[])].sort((x, y) =>
String(x?.uri).localeCompare(String(y?.uri)),
);
return [key, sorted.map(canonicalize)];
}
return [key, canonicalize(member)];
})
.sort(([x], [y]) => x.localeCompare(y));
return Object.fromEntries(entries);
}
/** Outcome of comparing a fetched file against its advertised digest. */ /** Outcome of comparing a fetched file against its advertised digest. */
export type SkillVerificationStatus = export type SkillVerificationStatus =
| "verified" | "verified"

View file

@ -60,13 +60,21 @@ as a missing capability rather than an error.
## Skills (SEP-2640) ## Skills (SEP-2640)
`skills-http.json` advertises the `io.modelcontextprotocol/skills` extension `skills-http.json` sets `"skills": true` and serves four skills over two
and serves four skills over two `skills/list` pages. It declares the extension `skills/list` pages. The extension is advertised **bare**: there is deliberately
**bare** — `directoryRead` stays off until the fixture actually serves no `directoryRead` option to turn on, because nothing here serves
`resources/directory/read` (phase 3, [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248)), so `resources/directory/read` and a config that advertised it would produce exactly
Connection Info never reports a sub-option this server would answer `-32601` the false capability this fixture helps catch — Connection Info reporting a
for. Set `"skills": { "directoryRead": true }` in a config to exercise the sub-option supported while the method answers `-32601`. Both come back in
advertised-flag path. phase 3 ([#2248](https://github.com/modelcontextprotocol/inspector/issues/2248)).
Every result carries the modern base envelope (`resultType` / `ttlMs` /
`cacheScope`). `skills/*` are consumer-owned, so the SDK stamps nothing for
them; without it a 2026-era connection would receive a result missing the
envelope. It is stamped unconditionally rather than per era — the modern leg
builds a fresh server per request, so there is no era to branch on when the
handlers are registered, and on the legacy leg they are three extra members no
codec inspects.
It works on **either era**: `skills/list`, `skills/get` and It works on **either era**: `skills/list`, `skills/get` and
`resources/directory/read` are consumer-owned extension methods that neither `resources/directory/read` are consumer-owned extension methods that neither
era codec defines, so the SDK's era gate skips them entirely — which is why era codec defines, so the SDK's era gate skips them entirely — which is why
@ -82,11 +90,11 @@ the Skills tab runs are untestable without them:
| `dynamic-report` | `resources: "dynamic"` — a generated file set, so integrity cannot be verified at all and the tab says so rather than staying silent. | | `dynamic-report` | `resources: "dynamic"` — a generated file set, so integrity cannot be verified at all and the tab says so rather than staying silent. |
| `wrong-folder` | A URI path segment (`wrong-folder`) that disagrees with `frontmatter.name` (`right-name`), the one structural invariant SEP-2640 states outright. | | `wrong-folder` | A URI path segment (`wrong-folder`) that disagrees with `frontmatter.name` (`right-name`), the one structural invariant SEP-2640 states outright. |
Connection Info shows the extension and its `directoryRead` sub-flag; the Connection Info's **Skills Extension Options** section shows the `directoryRead`
Inspector surfaces that flag but does not call `resources/directory/read` yet. sub-flag — against this fixture, a red ✗. The Inspector surfaces the flag but
The wire schema for that result is deliberately absent from does not call `resources/directory/read` yet, and the wire schema for that
`core/mcp/skillsSchemas.ts` too — phase 3 adds it against the normative text result is deliberately absent from `core/mcp/skillsSchemas.ts` too: phase 3 adds
rather than shipping a guess nothing exercises. it against the normative text rather than shipping a guess nothing exercises.
## Cancelling a call ## Cancelling a call

View file

@ -3,9 +3,13 @@
"name": "skills", "name": "skills",
"version": "1.0.0" "version": "1.0.0"
}, },
"tools": [{ "preset": "echo" }], "tools": [
{
"preset": "echo"
}
],
"resources": [], "resources": [],
"skills": {}, "skills": true,
"transport": { "transport": {
"type": "streamable-http", "type": "streamable-http",
"port": 3230 "port": 3230

View file

@ -561,8 +561,14 @@ export interface ServerConfig {
* Advertise the Skills extension (SEP-2640) and serve `skills/list` / * Advertise the Skills extension (SEP-2640) and serve `skills/list` /
* `skills/get` plus the `skill://` files those entries name. The fixture set * `skills/get` plus the `skill://` files those entries name. The fixture set
* deliberately includes non-conforming skills — see `skills.ts`. * deliberately includes non-conforming skills — see `skills.ts`.
*
* There is deliberately **no `directoryRead` option**. The flag would
* advertise `resources/directory/read`, which nothing here serves, so a
* config could produce exactly the false capability this fixture exists to
* help catch — Connection Info reporting "supported" for a method that
* answers `-32601`. It comes back in phase 3 (#2248) with the handler.
*/ */
skills?: { directoryRead?: boolean }; skills?: boolean;
/** /**
* Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the * Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the
* nested `elicitation` setting — the server-side half of the app-rendered * nested `elicitation` setting — the server-side half of the app-rendered
@ -831,17 +837,13 @@ export function createMcpServer(config: ServerConfig): McpServer {
}; };
} }
// Skills extension (SEP-2640): a server-declared extension. `directoryRead` // Skills extension (SEP-2640): a server-declared extension, advertised bare.
// is opt-in per config and stays OFF in `skills-http.json` until the fixture // See `ServerConfig.skills` for why there is no `directoryRead` sub-option
// actually serves `resources/directory/read` — advertising a sub-option this // to turn on.
// server would answer `-32601` for would make Connection Info report
// "Supported" for a method that is not (phase 3, #2248).
if (config.skills) { if (config.skills) {
capabilities.extensions = { capabilities.extensions = {
...(capabilities.extensions ?? {}), ...(capabilities.extensions ?? {}),
[SKILLS_EXTENSION_KEY]: { [SKILLS_EXTENSION_KEY]: {},
...(config.skills.directoryRead ? { directoryRead: true } : {}),
},
}; };
// Skill files are fetched through ordinary `resources/read`, so the // Skill files are fetched through ordinary `resources/read`, so the
// resources capability has to be advertised even when the config registers // resources capability has to be advertised even when the config registers

View file

@ -68,8 +68,8 @@ export interface ConfigFile {
* `transport.modern`. */ * `transport.modern`. */
tasksExtension?: boolean; tasksExtension?: boolean;
/** Advertise the Skills extension (SEP-2640) and serve its fixture skills. /** Advertise the Skills extension (SEP-2640) and serve its fixture skills.
* `directoryRead` advertises the `resources/directory/read` sub-option. */ * No `directoryRead` sub-option — see {@link ServerConfig.skills}. */
skills?: { directoryRead?: boolean }; skills?: boolean;
/** Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the nested /** Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the nested
* `elicitation` setting — the server half of app-rendered form elicitation * `elicitation` setting — the server half of app-rendered form elicitation
* (#1854). Pair with the `app_choose_option` tool + `choose_option_app` resource. */ * (#1854). Pair with the `app_choose_option` tool + `choose_option_app` resource. */

View file

@ -45,6 +45,24 @@ export const SKILLS_EXTENSION_KEY = "io.modelcontextprotocol/skills";
*/ */
export const SKILLS_PAGE_SIZE = 2; 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. */ /** `sha256:<64 lowercase hex>` over a UTF-8 string, the SEP's digest form. */
function digestOf(text: string): string { function digestOf(text: string): string {
return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`; return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
@ -218,6 +236,7 @@ export function listSkillsPage(cursor?: string): Record<string, unknown> {
const page = FIXTURE_SKILLS.slice(from, from + SKILLS_PAGE_SIZE); const page = FIXTURE_SKILLS.slice(from, from + SKILLS_PAGE_SIZE);
const next = from + SKILLS_PAGE_SIZE; const next = from + SKILLS_PAGE_SIZE;
return { return {
...MODERN_RESULT_ENVELOPE,
skills: page.map(toEntry), skills: page.map(toEntry),
...(next < FIXTURE_SKILLS.length ? { nextCursor: String(next) } : {}), ...(next < FIXTURE_SKILLS.length ? { nextCursor: String(next) } : {}),
}; };
@ -238,9 +257,9 @@ export function getSkillEntry(uri: string): Record<string, unknown> {
`Unknown skill uri: ${uri}`, `Unknown skill uri: ${uri}`,
); );
} }
// The envelope (`{ skill }`) is the conforming shape, and the only one the // The `{ skill }` wrapper is the conforming shape, and the only one the
// Inspector accepts — see `GetSkillResultSchema`. // Inspector accepts — see `GetSkillResultSchema`.
return { skill: toEntry(skill) }; return { ...MODERN_RESULT_ENVELOPE, skill: toEntry(skill) };
} }
/** The `resources/read` result for a `skill://` file, or `undefined`. */ /** The `resources/read` result for a `skill://` file, or `undefined`. */
@ -250,6 +269,7 @@ export function readSkillFile(
const file = FILES_BY_URI.get(uri); const file = FILES_BY_URI.get(uri);
if (!file) return undefined; if (!file) return undefined;
return { return {
...MODERN_RESULT_ENVELOPE,
contents: [{ uri: file.uri, mimeType: file.mimeType, text: file.text }], contents: [{ uri: file.uri, mimeType: file.mimeType, text: file.text }],
}; };
} }