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
3 changed files with 272 additions and 81 deletions

View file

@ -561,8 +561,14 @@ export interface ServerConfig {
* Advertise the Skills extension (SEP-2640) and serve `skills/list` /
* `skills/get` plus the `skill://` files those entries name. The fixture set
* 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
* 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`
// is opt-in per config and stays OFF in `skills-http.json` until the fixture
// actually serves `resources/directory/read` — advertising a sub-option this
// server would answer `-32601` for would make Connection Info report
// "Supported" for a method that is not (phase 3, #2248).
// Skills extension (SEP-2640): a server-declared extension, advertised bare.
// See `ServerConfig.skills` for why there is no `directoryRead` sub-option
// to turn on.
if (config.skills) {
capabilities.extensions = {
...(capabilities.extensions ?? {}),
[SKILLS_EXTENSION_KEY]: {
...(config.skills.directoryRead ? { directoryRead: true } : {}),
},
[SKILLS_EXTENSION_KEY]: {},
};
// Skill files are fetched through ordinary `resources/read`, so the
// resources capability has to be advertised even when the config registers

View file

@ -68,8 +68,8 @@ export interface ConfigFile {
* `transport.modern`. */
tasksExtension?: boolean;
/** Advertise the Skills extension (SEP-2640) and serve its fixture skills.
* `directoryRead` advertises the `resources/directory/read` sub-option. */
skills?: { directoryRead?: boolean };
* No `directoryRead` sub-option — see {@link ServerConfig.skills}. */
skills?: boolean;
/** Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the nested
* `elicitation` setting — the server half of app-rendered form elicitation
* (#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;
/**
* 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")}`;
@ -218,6 +236,7 @@ export function listSkillsPage(cursor?: string): Record<string, unknown> {
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) } : {}),
};
@ -238,9 +257,9 @@ export function getSkillEntry(uri: string): Record<string, unknown> {
`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`.
return { skill: toEntry(skill) };
return { ...MODERN_RESULT_ENVELOPE, skill: toEntry(skill) };
}
/** The `resources/read` result for a `skill://` file, or `undefined`. */
@ -250,6 +269,7 @@ export function readSkillFile(
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 }],
};
}