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:
parent
fd6e60148b
commit
1d765b8af6
6 changed files with 396 additions and 150 deletions
18
test-servers/configs/skills-strict-legacy-http.json
Normal file
18
test-servers/configs/skills-strict-legacy-http.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"serverInfo": {
|
||||
"name": "skills-strict-legacy",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"preset": "echo"
|
||||
}
|
||||
],
|
||||
"resources": [],
|
||||
"skills": true,
|
||||
"skillsRequireClientExtension": true,
|
||||
"transport": {
|
||||
"type": "streamable-http",
|
||||
"port": 3232
|
||||
}
|
||||
}
|
||||
19
test-servers/configs/skills-strict-modern-http.json
Normal file
19
test-servers/configs/skills-strict-modern-http.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"serverInfo": {
|
||||
"name": "skills-strict-modern",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"preset": "echo"
|
||||
}
|
||||
],
|
||||
"resources": [],
|
||||
"skills": true,
|
||||
"skillsRequireClientExtension": true,
|
||||
"transport": {
|
||||
"type": "streamable-http",
|
||||
"port": 3233,
|
||||
"modern": true
|
||||
}
|
||||
}
|
||||
|
|
@ -614,6 +614,14 @@ export interface ServerConfig {
|
|||
* refuse to send the call locally.
|
||||
*/
|
||||
skills?: boolean;
|
||||
/**
|
||||
* With {@link ServerConfig.skills}, answer `skills/list`, `skills/get` and
|
||||
* `resources/directory/read` with `-32601` unless the client declared
|
||||
* `io.modelcontextprotocol/skills` in its own capabilities — a strict
|
||||
* SEP-2133 server (#2373). Off by default so the existing skills fixtures
|
||||
* keep serving any client. Ignored without `skills`.
|
||||
*/
|
||||
skillsRequireClientExtension?: boolean;
|
||||
/**
|
||||
* Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the
|
||||
* nested `elicitation` setting — the server-side half of the app-rendered
|
||||
|
|
@ -1675,7 +1683,9 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
// `skill://` half of resources/read. Wired after the SDK's own handlers so
|
||||
// the resources/read wrapper can delegate non-skill URIs to them.
|
||||
if (config.skills) {
|
||||
wireSkillsHandlers(mcpServer);
|
||||
wireSkillsHandlers(mcpServer, {
|
||||
requireClientExtension: config.skillsRequireClientExtension,
|
||||
});
|
||||
}
|
||||
|
||||
// Extension-gated tools (#1739): start each gated tool disabled, then enable
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ export interface ConfigFile {
|
|||
/** Advertise the Skills extension (SEP-2640) and serve its fixture skills,
|
||||
* including `directoryRead` — see {@link ServerConfig.skills}. */
|
||||
skills?: boolean;
|
||||
/** With `skills`, refuse the skills methods to a client that did not declare
|
||||
* the extension — see {@link ServerConfig.skillsRequireClientExtension}. */
|
||||
skillsRequireClientExtension?: 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. */
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
|
|||
tasks: config.tasks,
|
||||
tasksExtension: config.tasksExtension,
|
||||
skills: config.skills,
|
||||
skillsRequireClientExtension: config.skillsRequireClientExtension,
|
||||
appElicitation: config.appElicitation,
|
||||
maxPageSize: config.maxPageSize,
|
||||
emptyStringCursor: config.emptyStringCursor,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import * as z from "zod/v4";
|
||||
import {
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
type McpServer,
|
||||
|
|
@ -698,29 +699,106 @@ const DirectoryReadResultShape = z.object({
|
|||
nextCursor: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface WireSkillsOptions {
|
||||
/**
|
||||
* Refuse the extension's own methods to a client that did not declare
|
||||
* `io.modelcontextprotocol/skills` in its capabilities (#2373). SEP-2133
|
||||
* negotiates an extension from both sides, and a strict server enforces the
|
||||
* client's half; without this the fixture served every client, which is how
|
||||
* an Inspector that never declared the extension passed against it.
|
||||
*/
|
||||
requireClientExtension?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request's `_meta` envelope as a handler receives it. The SDK types the
|
||||
* envelope's keys as an empty object, so it is read as a plain record and each
|
||||
* value is narrowed where it is used.
|
||||
*/
|
||||
type ReceivedEnvelope = Record<string, unknown> | undefined;
|
||||
|
||||
function hasSkillsExtension(capabilities: unknown): boolean {
|
||||
if (typeof capabilities !== "object" || capabilities === null) return false;
|
||||
const extensions = (capabilities as { extensions?: unknown }).extensions;
|
||||
return (
|
||||
typeof extensions === "object" &&
|
||||
extensions !== null &&
|
||||
SKILLS_EXTENSION_KEY in extensions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw `-32601` when the client did not declare the Skills extension.
|
||||
*
|
||||
* The two eras keep the declaration in different places. A modern
|
||||
* (2026-07-28) request carries it in its own `_meta` envelope, which the SDK
|
||||
* hands the handler as `ctx.mcpReq.envelope` — read first, because that is the
|
||||
* per-request source of truth the SDK points handlers at. A legacy connection
|
||||
* has no envelope, so the value `initialize` declared is the fallback.
|
||||
*
|
||||
* ⚠️ That fallback only exists on a **stateful** legacy connection. A legacy
|
||||
* client reaching a server started with `modern` is served statelessly — a
|
||||
* fresh instance per request that never saw `initialize` — so it has no
|
||||
* declaration to read and is refused. That is why the strict showcase configs
|
||||
* come one per era.
|
||||
*/
|
||||
function assertClientDeclaredSkills(
|
||||
mcpServer: McpServer,
|
||||
method: string,
|
||||
envelope: ReceivedEnvelope,
|
||||
): void {
|
||||
const capabilities =
|
||||
envelope?.[CLIENT_CAPABILITIES_META_KEY] ??
|
||||
mcpServer.server.getClientCapabilities();
|
||||
if (!hasSkillsExtension(capabilities)) {
|
||||
throw new ProtocolError(
|
||||
ProtocolErrorCode.MethodNotFound,
|
||||
`${method} requires the client to declare ${SKILLS_EXTENSION_KEY} in its capabilities`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire `skills/list`, `skills/get` and the `skill://` half of `resources/read`
|
||||
* onto an `McpServer`.
|
||||
*/
|
||||
export function wireSkillsHandlers(mcpServer: McpServer): void {
|
||||
export function wireSkillsHandlers(
|
||||
mcpServer: McpServer,
|
||||
options: WireSkillsOptions = {},
|
||||
): void {
|
||||
const lowLevel = mcpServer.server;
|
||||
// Only the extension's own methods are gated. A `skill://` file is read
|
||||
// through ordinary `resources/read`, a core method the client needs no
|
||||
// extension to call.
|
||||
const gate = (method: string, envelope: ReceivedEnvelope): void => {
|
||||
if (options.requireClientExtension) {
|
||||
assertClientDeclaredSkills(mcpServer, method, envelope);
|
||||
}
|
||||
};
|
||||
|
||||
lowLevel.setRequestHandler(
|
||||
"skills/list",
|
||||
{ params: ListSkillsParamsSchema, result: ListSkillsResultShape },
|
||||
async (params) => listSkillsPage(params.cursor),
|
||||
async (params, ctx) => {
|
||||
gate("skills/list", ctx.mcpReq.envelope);
|
||||
return listSkillsPage(params.cursor);
|
||||
},
|
||||
);
|
||||
|
||||
lowLevel.setRequestHandler(
|
||||
"skills/get",
|
||||
{ params: GetSkillParamsSchema, result: GetSkillResultShape },
|
||||
async (params) => getSkillEntry(params.uri),
|
||||
async (params, ctx) => {
|
||||
gate("skills/get", ctx.mcpReq.envelope);
|
||||
return getSkillEntry(params.uri);
|
||||
},
|
||||
);
|
||||
|
||||
lowLevel.setRequestHandler(
|
||||
"resources/directory/read",
|
||||
{ params: DirectoryReadParamsSchema, result: DirectoryReadResultShape },
|
||||
async (params) => {
|
||||
async (params, ctx) => {
|
||||
gate("resources/directory/read", ctx.mcpReq.envelope);
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue