fix: declare the skills extension in the Inspector's client capabilities (#2373)

SEP-2133 negotiates an extension from both sides, and a strict server
refuses skills/* to a client that did not declare
io.modelcontextprotocol/skills. The Inspector called those methods
without ever declaring it. Add it to ADVERTISABLE_EXTENSIONS, on by
default, which also gives it a Server Settings toggle.

Give the composable test server skillsRequireClientExtension and two
per-era strict showcase configs, so a strict server is reproducible here.

Writing the modern-era test exposed two defects the existing suite hid:
its "modern" cases set protocolEra on the transport config, which
InspectorClient does not read, so they connected on legacy; and once
they really ran modern, every skills result failed, because the modern
schemas required resultType, which the SDK codec checks and then lifts
off before a caller schema runs. Connect with versionNegotiation and
assert the era, and drop the resultType requirement (and the two modern
schema variants it was the only difference in).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3RsrgCxVoFPokLB4vAkxi
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-14 08:36:37 -04:00
commit 1d765b8af6
3 changed files with 396 additions and 150 deletions

View file

@ -1,6 +1,7 @@
import type { ClientCapabilities } from "@modelcontextprotocol/client";
import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge";
import { TASKS_EXTENSION_KEY } from "./modernTaskSchemas.js";
import { SKILLS_EXTENSION_KEY } from "./skillsSchemas.js";
/**
* Extension identifier for SEP-2350 enterprise-managed authorization. Advertised
@ -91,6 +92,18 @@ export const ADVERTISABLE_EXTENSIONS: readonly AdvertisableExtension[] = [
defaultAdvertised: true,
advertisement: { mimeTypes: [MCP_APP_MIME_TYPE] },
},
{
key: SKILLS_EXTENSION_KEY,
label: "Skills (io.modelcontextprotocol/skills)",
// The Skills extension (SEP-2640). SEP-2133 negotiates an extension from
// both sides, so a server may refuse `skills/list`, `skills/get` and
// `resources/directory/read` to a client that did not declare it — and the
// Inspector calls all three once the server declares its half. Advertised
// by default for that reason; turning it off is how to check a server's
// refusal path. Declared with no settings: SEP-2640 defines none for the
// client side. (#2373)
defaultAdvertised: true,
},
];
export interface BuildClientExtensionsInput {

View file

@ -150,8 +150,6 @@ import {
DirectoryReadResultSchema,
GetSkillEnvelopeSchema,
ListSkillsResultSchema,
ModernGetSkillEnvelopeSchema,
ModernDirectoryReadResultSchema,
ModernListSkillsResultSchema,
RESOURCES_DIRECTORY_READ_METHOD,
SKILLS_EXTENSION_KEY,
@ -5796,21 +5794,16 @@ export class InspectorClient extends InspectorClientEventTarget {
uri,
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
};
// Era-aware for the same reason `skills/list` is: the method is
// consumer-owned, so no SDK codec stamps or checks its envelope. The modern
// variant requires `resultType` — a base-protocol member SEP-2322 puts on
// every modern result — and still not the caching attributes, which
// SEP-2640 leaves open. The envelope is returned whole; `getSkill`
// unwraps.
const resultSchema = this.isModernEra()
? ModernGetSkillEnvelopeSchema
: GetSkillEnvelopeSchema;
// One schema for both eras (#2373): on a modern connection the SDK codec
// has already enforced `resultType` and lifted it off, and the caching
// attributes are left open by SEP-2640, so there is nothing era-specific
// left to require. The envelope is returned whole; `getSkill` unwraps.
try {
return await this.invokeMcpClient(
() =>
this.client!.request(
{ method: SKILLS_GET_METHOD, params },
resultSchema,
GetSkillEnvelopeSchema,
this.getRequestOptions(this.progressTokenOf(metadata)),
),
{ method: SKILLS_GET_METHOD },
@ -5872,19 +5865,16 @@ export class InspectorClient extends InspectorClientEventTarget {
// for page one.
...(cursor !== undefined ? { cursor } : {}),
};
// Era-aware for the same reason `skills/list` is — the method is
// consumer-owned, so no SDK codec stamps or checks its envelope. The modern
// variant requires only `resultType`; see the schema for why it stops
// short of the caching attributes that `skills/list` requires.
const resultSchema = this.isModernEra()
? ModernDirectoryReadResultSchema
: DirectoryReadResultSchema;
// One schema for both eras (#2373): the SDK codec enforces and lifts
// `resultType` on a modern connection, and SEP-2640 requires no caching
// attributes of this method — see `skillsSchemas.ts` for why that differs
// from `skills/list`.
try {
return await this.invokeMcpClient(
() =>
this.client!.request(
{ method: RESOURCES_DIRECTORY_READ_METHOD, params },
resultSchema,
DirectoryReadResultSchema,
this.getRequestOptions(this.progressTokenOf(metadata)),
),
{ method: RESOURCES_DIRECTORY_READ_METHOD },

View file

@ -131,9 +131,18 @@ export type ListSkillsResult = z.infer<typeof ListSkillsResultSchema>;
*
* ⚠️ Picked by `InspectorClient.listSkills` from the negotiated era — a schema
* cannot know it on its own.
*
* ⚠️ **No `resultType` member, and that is not an omission (#2373).** The
* SDK's 2026-07-28 codec checks `resultType` on every result — missing is
* `InvalidResult`, anything but `"complete"` is `UnsupportedResultType` — and
* then **deletes it** before the result reaches the caller's schema. Requiring
* it here therefore rejected every conforming modern page, and did so unseen
* only because the integration suite's "modern" cases were connecting on
* legacy. The caching attributes are different: the codec neither checks nor
* lifts them for a consumer-owned method, so this schema is still the only
* thing that does.
*/
export const ModernListSkillsResultSchema = ListSkillsResultSchema.extend({
resultType: z.literal("complete"),
// A non-negative INTEGER, matching the codec: `ttlMs: -1` or `0.5` is an
// envelope violation, and accepting it here would hide exactly the kind of
// defect this schema exists to surface.
@ -183,21 +192,6 @@ export const GetSkillEnvelopeSchema = z.looseObject({
*/
export type GetSkillEnvelope = z.infer<typeof GetSkillEnvelopeSchema>;
/**
* `skills/get` on a **modern** (2026-07-28+) connection: the envelope plus
* `resultType`, and deliberately still not the caching attributes.
*
* The "left open" quote above covers `ttlMs` / `cacheScope` and **only** those.
* `resultType` is a different thing: SEP-2322 makes it a member of every modern
* result, and SEP-2640's own `skills/get` example carries
* `"resultType": "complete"`. Leaving it optional here while requiring it of
* `resources/directory/read` was an inconsistency in this module rather than a
* distinction the spec draws (Copilot).
*/
export const ModernGetSkillEnvelopeSchema = GetSkillEnvelopeSchema.extend({
resultType: z.literal("complete"),
});
/**
* `skills/get` result, unwrapped to the entry it carries.
*
@ -252,29 +246,28 @@ export const DirectoryReadResultSchema = z.looseObject({
export type DirectoryReadResult = z.infer<typeof DirectoryReadResultSchema>;
/**
* `resources/directory/read` result on a **modern** (2026-07-28+) connection:
* the page plus `resultType`, and deliberately **not** `ttlMs` / `cacheScope`.
/*
* There is deliberately **no modern variant** of the `resources/directory/read`
* or `skills/get` schemas, though `skills/list` has one (#2373).
*
* That asymmetry with {@link ModernListSkillsResultSchema} is the one judgement
* call in this module, so it is written down rather than left to be re-derived:
* On a modern (2026-07-28+) connection the SDK codec already enforces
* `resultType` — the base-protocol member SEP-2322 puts on every modern result
* — and lifts it off before any caller schema runs. What remains is the
* caching attributes, and only `skills/list` is required to carry those:
*
* - For `skills/list` the SEP states the requirement outright — *"In protocol
* versions 2026-07-28 and later, the result also carries … `ttlMs` and
* `cacheScope`"* — so requiring them is quoting the spec.
* - For `skills/list` the SEP states it outright — *"In protocol versions
* 2026-07-28 and later, the result also carries … `ttlMs` and
* `cacheScope`"* — so {@link ModernListSkillsResultSchema} requiring them is
* quoting the spec.
* - For `resources/directory/read` it states **nothing of the kind**, and its
* one worked example of the result carries `resultType: "complete"` and no
* caching attributes at all. Requiring them here would fail a server that
* matched the SEP's own example, which is the failure direction this module
* works hardest to avoid.
* one worked example carries `resultType: "complete"` and no caching
* attributes at all. Requiring them would fail a server that matched the
* SEP's own example, the failure direction this module works hardest to
* avoid.
* - For `skills/get` the SEP leaves the question open in as many words (see
* {@link GetSkillEnvelopeSchema}).
*
* `resultType` is required because it is the base protocol's, not this
* extension's: SEP-2322 makes it a member of every modern result, the SEP's
* example carries it, and `skills/*` being consumer-owned means the SDK codec
* validates none of it — so if this schema does not, nothing does.
*
* ⚠️ Picked by `InspectorClient.readResourceDirectory` from the negotiated era.
* So both eras parse those two results with one schema each. An earlier
* revision required `resultType` on "modern" variants of both; since the codec
* lifts it, they could never pass on a real modern connection.
*/
export const ModernDirectoryReadResultSchema = DirectoryReadResultSchema.extend(
{ resultType: z.literal("complete") },
);