feat: support the Skills extension (SEP-2640) with digest verification
Adds detection, enumeration, and conformance checking for the Skills extension (`io.modelcontextprotocol/skills`) — phases 1 and 2 of #2234, which is everything its Acceptance list names. Skills is a *server*-declared extension, read off the connecting server's `capabilities.extensions`, so it is deliberately absent from `ADVERTISABLE_EXTENSIONS` — that registry is what the Inspector advertises and the user toggles, and an entry there would be a meaningless toggle. `skills/list` and `skills/get` are consumer-owned extension methods that neither era codec defines, so the SDK's era gate skips them and they go out as ordinary `client.request` calls with explicit result schemas. The raw-wire channel modern `tasks/*` needs is not used, and the Skills tab is not era-gated: a legacy-era server that declares the extension is serving it. The checks are the point, not the list view. `core/mcp/skills.ts` reports each obligation SEP-2640 states — the name/path invariant, the digest format, the 512-entry and 16 MiB limits, and `resources: "dynamic"`, which means integrity cannot be verified at all. Digest verification hashes the fetched bytes with WebCrypto and returns a mismatch with both digests attached rather than throwing, because showing a mismatch loudly is the whole value proposition. Files are fetched on demand: SEP-2640 is explicit that a `resources/read` of a `SKILL.md` is not a load and confers no standing, so none of the SEP's host machinery is implemented. The `skills-http` fixture serves four skills over two pages, three of them deliberately non-conforming — without those the verification code is untestable. Phase 3 (CLI, TUI, `resources/directory/read`, paged mode) is #2248. Closes #2234 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:
parent
1c1e70820b
commit
63623b754b
1 changed files with 3684 additions and 4 deletions
|
|
@ -144,6 +144,14 @@ import {
|
|||
type ModernDetailedTask,
|
||||
} from "./modernTaskSchemas.js";
|
||||
import { buildClientExtensions } from "./extensions.js";
|
||||
import {
|
||||
GetSkillResultSchema,
|
||||
ListSkillsResultSchema,
|
||||
SKILLS_GET_METHOD,
|
||||
SKILLS_LIST_METHOD,
|
||||
type SkillEntry,
|
||||
} from "./skillsSchemas.js";
|
||||
import { getSkillsExtension, type SkillsExtensionSupport } from "./skills.js";
|
||||
import {
|
||||
getElicitationUiResourceUri,
|
||||
isFormElicitation,
|
||||
|
|
@ -5516,6 +5524,81 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
return { prompts };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Skills extension (SEP-2640) the server declared, or `undefined` when it
|
||||
* declared none. Gates the Skills tab and the skills store the same way
|
||||
* {@link isTasksExtensionNegotiated} gates Tasks — but deliberately without an
|
||||
* era check: `skills/*` are not spec method names in either codec, so a
|
||||
* legacy-era server that declares the extension is serving it (#2234).
|
||||
*/
|
||||
getSkillsExtension(): SkillsExtensionSupport | undefined {
|
||||
return getSkillsExtension(this.capabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of `skills/list` (SEP-2640).
|
||||
*
|
||||
* An ordinary `client.request` with an explicit result schema — the SDK's era
|
||||
* gate skips methods neither codec defines, and `assertCapabilityForMethod`
|
||||
* falls through to a no-op for them, so this is all the extension needs. The
|
||||
* raw-wire path modern `tasks/*` uses would be wrong here: it exists for spec
|
||||
* names the 2026 codec deleted, and taking it would bypass the SDK's response
|
||||
* correlation for nothing (#2234).
|
||||
*/
|
||||
async listSkills(
|
||||
cursor?: string,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ skills: SkillEntry[]; nextCursor?: string }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
const effectiveMeta = this.mergeMeta(metadata);
|
||||
const params: Record<string, unknown> = {
|
||||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
};
|
||||
const response = await this.invokeMcpClient(
|
||||
() =>
|
||||
this.client!.request(
|
||||
{ method: SKILLS_LIST_METHOD, params },
|
||||
ListSkillsResultSchema,
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
{ method: SKILLS_LIST_METHOD },
|
||||
);
|
||||
return {
|
||||
skills: response.skills,
|
||||
nextCursor: response.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One skill entry by URI (`skills/get`, SEP-2640). The SEP settles the entry
|
||||
* shape but not the envelope around it, so the result is normalized through
|
||||
* {@link normalizeGetSkillResult} rather than assuming one form.
|
||||
*/
|
||||
async getSkill(uri: string, metadata?: RequestMetadata): Promise<SkillEntry> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
const effectiveMeta = this.mergeMeta(metadata);
|
||||
const params: Record<string, unknown> = {
|
||||
uri,
|
||||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
};
|
||||
// `GetSkillResultSchema` normalizes both accepted envelopes to the entry,
|
||||
// so there is nothing to unwrap here.
|
||||
return this.invokeMcpClient(
|
||||
() =>
|
||||
this.client!.request(
|
||||
{ method: SKILLS_GET_METHOD, params },
|
||||
GetSkillResultSchema,
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
{ method: SKILLS_GET_METHOD },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a prompt by name
|
||||
* @param name Prompt name
|
||||
|
|
|
|||
Loading…
Reference in a new issue