diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 03cf55f5..8fe9a610 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -58,6 +58,10 @@ import { ResourcesNotFoundErrorScenario } from './server/resources'; +import { SkillsDirectoryReadScenario } from './server/skills/directory'; +import { SkillsIndexScenario } from './server/skills/index'; +import { SkillsManifestScenario } from './server/skills/manifest'; + import { PromptsListScenario, PromptsGetSimpleScenario, @@ -151,7 +155,15 @@ const pendingClientScenariosList: ClientScenario[] = [ new TasksDispatchScenario(), new TasksStatusNotificationsScenario(), new TasksRequiredTaskErrorScenario(), - new TasksMrtrCompositionScenario() + new TasksMrtrCompositionScenario(), + + // SEP-2640 Skills extension. Pending because the everything-server does not + // implement io.modelcontextprotocol/skills; targeted runs point at a + // SEP-2640-conformant fixture via + // `npm start -- server --scenario sep-2640-skills-* --url `. + new SkillsDirectoryReadScenario(), + new SkillsIndexScenario(), + new SkillsManifestScenario() ]; // All client scenarios @@ -203,6 +215,12 @@ const allClientScenariosList: ClientScenario[] = [ // Resources error handling (SEP-2164) new ResourcesNotFoundErrorScenario(), + // Skills extension (SEP-2640). Fixture-dependent (needs a SEP-2640 server); + // each scenario SKIPs cleanly when the extension is not declared. + new SkillsDirectoryReadScenario(), + new SkillsIndexScenario(), + new SkillsManifestScenario(), + // Prompts scenarios new PromptsListScenario(), new PromptsGetSimpleScenario(), diff --git a/src/scenarios/server/skills/directory.ts b/src/scenarios/server/skills/directory.ts new file mode 100644 index 00000000..27be77bd --- /dev/null +++ b/src/scenarios/server/skills/directory.ts @@ -0,0 +1,396 @@ +/** + * SEP-2640 Skills extension — the `resources/directory/read` surface (added in + * spec commit 2e04c48d, 2026-06-09). + * + * One scenario, six checks (per AGENTS.md "fewer scenarios, more checks"). + * Each check's verbatim spec quote lives next to its check ID in + * src/seps/sep-2640.yaml. + * + * Capability gating reads the declared capability from `server/discover` + * (mirrors `tasks/capability.ts`): the checks run only when the server declares + * `io.modelcontextprotocol/skills.directoryRead: true`. An undeclared optional + * capability is a SKIP (not a failure); a declared-but-broken one fails. + * + * Discovery is dynamic and brand-neutral: the directory to exercise is derived + * from `skill://index.json` or `resources/list`, hardcoding no fixture URI, so + * the scenario passes against any conformant SEP-2640 server. When no directory + * (or no subdirectory) can be discovered, that check reports the missing + * prerequisite via untestableCheck (issue #248), never a silent green. + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import { Connection, JsonRpcError, type RunContext } from '../../../connection'; +import { untestableCheck } from '../../untestable'; +import { + SKILLS_EXTENSION_ID, + SKILL_MANIFEST_FILENAME, + SEP_2640_REF, + JSONRPC_METHOD_NOT_FOUND, + JSONRPC_INVALID_PARAMS, + type SkillIndex, + type SkillResource, + skillsCapability, + directoryReadDeclared, + skillsCheck, + listAllResources, + readSkillIndexText, + skillNameFromManifestUri +} from './helpers'; + +const DIRECTORY_MIME = 'inode/directory'; + +const CAPABILITY_ID = 'sep-2640-capability-directory-read-flag'; +const METHOD_ID = 'sep-2640-directory-read-method-registered'; +const SHAPE_ID = 'sep-2640-directory-read-result-resources-shape'; +const SUBDIR_ID = 'sep-2640-directory-read-subdir-mimetype'; +const INVALID_PARAMS_ID = 'sep-2640-directory-read-invalid-params'; +const PAGINATION_ID = 'sep-2640-directory-read-pagination'; + +const ALL_IDS = [ + CAPABILITY_ID, + METHOD_ID, + SHAPE_ID, + SUBDIR_ID, + INVALID_PARAMS_ID, + PAGINATION_ID +]; + +interface DirectoryReadResult { + resources?: SkillResource[]; + nextCursor?: string; +} + +/** A directory to exercise plus, when known, a non-directory resource under it. */ +interface DirectoryTarget { + dirUri: string; + /** A known file (non-directory) resource, used for the -32602 negative path. */ + fileUri?: string; +} + +/** The skill root directory URI for a SKILL.md URI (strip the trailing file). */ +function skillRootFromManifestUri(uri: string): string | undefined { + if (skillNameFromManifestUri(uri) === undefined) return undefined; + return uri.slice(0, uri.length - `/${SKILL_MANIFEST_FILENAME}`.length); +} + +/** + * Discover a directory resource to exercise, brand-neutrally: prefer a skill + * root derived from a skill-md SKILL.md (index first, then resources/list), + * then any `inode/directory` resource in resources/list. + */ +async function discoverDirectory( + conn: Connection +): Promise { + // 1. skill-md entry in skill://index.json — its SKILL.md URL gives us both a + // directory (the skill root) and a known file (the SKILL.md itself). + const idx = await readSkillIndexText(conn); + if (!('error' in idx) && typeof idx.text === 'string') { + try { + const index = JSON.parse(idx.text) as SkillIndex; + const entry = (index.skills ?? []).find( + (e) => + e.type === 'skill-md' && + typeof e.url === 'string' && + skillRootFromManifestUri(e.url) !== undefined + ); + if (entry?.url) { + return { + dirUri: skillRootFromManifestUri(entry.url)!, + fileUri: entry.url + }; + } + } catch { + // A malformed index is the index scenario's concern; keep discovering. + } + } + + const resources = await listAllResources(conn); + + // 2. A SKILL.md in resources/list — derive the skill root the same way. + const manifest = resources.find( + (r) => skillRootFromManifestUri(r.uri) !== undefined + ); + if (manifest) { + return { + dirUri: skillRootFromManifestUri(manifest.uri)!, + fileUri: manifest.uri + }; + } + + // 3. Any directory resource, using a non-directory sibling for the -32602 + // path when one is listed. + const dir = resources.find((r) => r.mimeType === DIRECTORY_MIME); + if (dir) { + const file = resources.find((r) => r.mimeType !== DIRECTORY_MIME); + return { dirUri: dir.uri, fileUri: file?.uri }; + } + + return undefined; +} + +export class SkillsDirectoryReadScenario implements ClientScenario { + name = 'sep-2640-skills-directory'; + readonly source = { extensionId: SKILLS_EXTENSION_ID } as const; + description = `SEP-2640 Skills extension: resources/directory/read surface (added in spec commit 2e04c48d, 2026-06-09). + +**Endpoint**: \`resources/directory/read\` (gated by \`io.modelcontextprotocol/skills.directoryRead: true\`) + +**Requirements covered** (each check carries a verbatim spec excerpt in src/seps/sep-2640.yaml): + +- \`sep-2640-capability-directory-read-flag\` — server declared directoryRead (read from server/discover) +- \`sep-2640-directory-read-method-registered\` — a declaring server supports the method on a served directory (MUST) +- \`sep-2640-directory-read-result-resources-shape\` — result has resources[] of direct children (MUST) +- \`sep-2640-directory-read-subdir-mimetype\` — subdirectory children carry \`inode/directory\` (MUST) +- \`sep-2640-directory-read-invalid-params\` — a non-directory URI returns \`-32602\` (MUST) +- \`sep-2640-directory-read-pagination\` — \`nextCursor\` round-trips per resources/list (single-page is conformant) + +**Gating & discovery**: the checks SKIP when the skills extension or its \`directoryRead\` flag is undeclared. The directory to exercise is discovered dynamically from \`skill://index.json\` / \`resources/list\` — no fixture URI is hardcoded.`; + + async run(ctx: RunContext): Promise { + const conn = await ctx.connect(); + try { + // === Capability gating via server/discover (not error-inference) === + const skills = await skillsCapability(conn); + if (!skills) { + const reason = + 'Server did not declare the io.modelcontextprotocol/skills extension; directoryRead checks not applicable.'; + return ALL_IDS.map((id) => + skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason }) + ); + } + if (!directoryReadDeclared(skills)) { + const reason = + 'Server declared the skills extension but not directoryRead: true; the resources/directory/read checks are optional and not applicable.'; + return ALL_IDS.map((id) => + skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason }) + ); + } + + const checks: ConformanceCheck[] = []; + + // Check 1: capability declared (observed directly from server/discover). + checks.push( + skillsCheck( + CAPABILITY_ID, + 'Server declared io.modelcontextprotocol/skills.directoryRead: true under capabilities.extensions.', + 'SUCCESS', + { details: { directoryRead: true } } + ) + ); + + // === Discover a directory to exercise (brand-neutral) === + const target = await discoverDirectory(conn); + if (!target) { + const reason = + 'no directory resource discoverable via skill://index.json or resources/list to exercise resources/directory/read'; + const rest: Array<[string, string]> = [ + [ + METHOD_ID, + 'A declaring server MUST support the method on a served directory.' + ], + [ + SHAPE_ID, + 'Result carries resources[] of the directory’s direct children.' + ], + [SUBDIR_ID, 'Subdirectory children carry mimeType inode/directory.'], + [ + INVALID_PARAMS_ID, + 'A non-directory URI yields -32602 Invalid params.' + ], + [ + PAGINATION_ID, + 'nextCursor round-trips per the resources/list contract.' + ] + ]; + for (const [id, desc] of rest) { + checks.push( + untestableCheck(id, id, desc, reason, [SEP_2640_REF], 'FAILURE') + ); + } + return checks; + } + + // === Happy path: list the discovered directory === + let happy: DirectoryReadResult | undefined; + let happyErr: unknown; + try { + happy = await conn.request( + 'resources/directory/read', + { uri: target.dirUri } + ); + } catch (e) { + happyErr = e; + } + + // Check 2: method registered (declared -> MUST be supported). + const methodNotFound = + happyErr instanceof JsonRpcError && + happyErr.code === JSONRPC_METHOD_NOT_FOUND; + checks.push( + skillsCheck( + METHOD_ID, + 'A server that declares directoryRead MUST support resources/directory/read on a served skill directory.', + happy !== undefined ? 'SUCCESS' : 'FAILURE', + happy !== undefined + ? { details: { uri: target.dirUri } } + : { + errorMessage: methodNotFound + ? `resources/directory/read returned -32601 for ${target.dirUri} despite the server declaring directoryRead: true` + : `resources/directory/read on ${target.dirUri} failed: ${ + happyErr instanceof Error + ? happyErr.message + : String(happyErr) + }` + } + ) + ); + + // Check 3: result shape — resources[] of Resource objects. + const shapeErrs: string[] = []; + if (!Array.isArray(happy?.resources)) { + shapeErrs.push('result.resources is not an array'); + } else { + happy.resources.forEach((r, i) => { + if (typeof r.uri !== 'string') { + shapeErrs.push(`resources[${i}].uri is not a string`); + } + }); + } + checks.push( + skillsCheck( + SHAPE_ID, + 'The result contains resources[] listing the directory’s direct children, each with at least a uri.', + happy === undefined + ? 'FAILURE' + : shapeErrs.length === 0 + ? 'SUCCESS' + : 'FAILURE', + happy === undefined + ? { errorMessage: 'directory read did not return a result' } + : shapeErrs.length === 0 + ? { details: { childCount: happy.resources?.length ?? 0 } } + : { errorMessage: shapeErrs.join('; ') } + ) + ); + + // Check 4: subdirectory mime marker. A directory whose fixture exposes no + // child subdirectory cannot exercise this — report it untestable, not a + // pass and not a failure of the server. + const subdirChild = Array.isArray(happy?.resources) + ? happy.resources.find((r) => r.mimeType === DIRECTORY_MIME) + : undefined; + if (subdirChild) { + checks.push( + skillsCheck( + SUBDIR_ID, + 'A subdirectory child is listed as a directory resource (mimeType inode/directory) so clients can descend.', + 'SUCCESS', + { details: { subdirectoryUri: subdirChild.uri } } + ) + ); + } else { + checks.push( + untestableCheck( + SUBDIR_ID, + SUBDIR_ID, + 'A subdirectory child is listed with mimeType inode/directory.', + `no child with mimeType ${DIRECTORY_MIME} under ${target.dirUri}; the served directory exposes no subdirectory to exercise this check`, + [SEP_2640_REF], + 'FAILURE' + ) + ); + } + + // Check 5: non-directory URI -> -32602. Needs a known non-directory + // resource; prefer the discovered fileUri, else a non-directory child. + const nonDirUri = + target.fileUri ?? + (Array.isArray(happy?.resources) + ? happy.resources.find( + (r) => typeof r.uri === 'string' && r.mimeType !== DIRECTORY_MIME + )?.uri + : undefined); + if (nonDirUri === undefined) { + checks.push( + untestableCheck( + INVALID_PARAMS_ID, + INVALID_PARAMS_ID, + 'A non-directory URI yields -32602 Invalid params.', + 'no non-directory resource discoverable to probe the -32602 path', + [SEP_2640_REF], + 'FAILURE' + ) + ); + } else { + let invalidOk = false; + let invalidDetail = ''; + try { + await conn.request('resources/directory/read', { + uri: nonDirUri + }); + invalidDetail = `expected -32602 for non-directory URI ${nonDirUri}, got a successful result`; + } catch (e) { + if (e instanceof JsonRpcError && e.code === JSONRPC_INVALID_PARAMS) { + invalidOk = true; + } else if (e instanceof JsonRpcError) { + invalidDetail = `expected -32602 for ${nonDirUri}, got ${e.code}: ${e.message}`; + } else { + invalidDetail = `expected -32602, got non-JsonRpcError: ${ + e instanceof Error ? e.message : String(e) + }`; + } + } + checks.push( + skillsCheck( + INVALID_PARAMS_ID, + 'resources/directory/read on a non-directory URI MUST return -32602 (Invalid params).', + invalidOk ? 'SUCCESS' : 'FAILURE', + invalidOk + ? { details: { nonDirectoryUri: nonDirUri } } + : { errorMessage: invalidDetail } + ) + ); + } + + // Check 6: pagination contract (single-page is conformant). + let paginationOk = false; + let paginationDetail = ''; + const firstCursor = happy?.nextCursor; + if (happy === undefined) { + paginationDetail = 'no directory result to evaluate pagination'; + } else if (!firstCursor) { + paginationOk = true; + paginationDetail = 'single-page response (no nextCursor)'; + } else { + try { + const second = await conn.request( + 'resources/directory/read', + { uri: target.dirUri, cursor: firstCursor } + ); + paginationOk = Array.isArray(second.resources); + paginationDetail = paginationOk + ? `nextCursor round-tripped: ${firstCursor}` + : 'follow-up call returned non-array resources'; + } catch (e) { + paginationDetail = `follow-up call with cursor failed: ${ + e instanceof Error ? e.message : String(e) + }`; + } + } + checks.push( + skillsCheck( + PAGINATION_ID, + 'nextCursor round-trips per the resources/list contract (single-page responses are conformant).', + paginationOk ? 'SUCCESS' : 'FAILURE', + paginationOk + ? { details: { paginationDetail } } + : { errorMessage: paginationDetail } + ) + ); + + return checks; + } finally { + await conn.close(); + } + } +} diff --git a/src/scenarios/server/skills/helpers.ts b/src/scenarios/server/skills/helpers.ts new file mode 100644 index 00000000..d32022f9 --- /dev/null +++ b/src/scenarios/server/skills/helpers.ts @@ -0,0 +1,227 @@ +/** + * Shared helpers for the SEP-2640 (Skills extension) server-conformance + * scenarios under this directory. + * + * The scenarios treat the server-under-test as an arbitrary SEP-2640 server: + * capability is read from `server/discover` (never inferred from an error), and + * every skill is discovered dynamically from `skill://index.json` and + * `resources/list` — no fixture-specific URI is hardcoded, so the checks pass + * against any conformant server, not just one implementation's fixture. + */ + +import type { + CheckStatus, + ConformanceCheck, + SpecReference +} from '../../../types'; +import type { Connection } from '../../../connection'; +import { JsonRpcError } from '../../../connection'; +import { parse as parseYaml } from 'yaml'; + +export const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; +export const SKILL_URI_SCHEME = 'skill://'; +export const SKILL_INDEX_URI = 'skill://index.json'; +export const SKILL_MANIFEST_FILENAME = 'SKILL.md'; +export const SKILLS_META_PREFIX = 'io.modelcontextprotocol.skills/'; + +/** `sha256:{hex}` with exactly 64 lowercase hex characters (SEP-2640 index). */ +export const SKILL_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; + +/** The SEP enumerated `skills[].type` values. */ +export const SKILL_TYPES = ['skill-md', 'archive'] as const; + +export const JSONRPC_METHOD_NOT_FOUND = -32601; +export const JSONRPC_INVALID_PARAMS = -32602; + +export const SEP_2640_REF: SpecReference = { + id: 'SEP-2640', + url: 'https://modelcontextprotocol.io/seps/2640-skills-extension#specification' +}; + +/** A `resources/list` / directory-read entry (only the fields we inspect). */ +export interface SkillResource { + uri: string; + name?: string; + description?: string; + mimeType?: string; + _meta?: Record; +} + +/** One `skills[]` entry of the `skill://index.json` document. */ +export interface SkillIndexEntry { + name?: string; + type?: string; + description?: string; + url?: string; + digest?: string; + [key: string]: unknown; +} + +/** The parsed `skill://index.json` document. */ +export interface SkillIndex { + $schema?: string; + skills?: SkillIndexEntry[]; + [key: string]: unknown; +} + +/** First text content of a `resources/read`, with its mimeType and `_meta`. */ +export interface ResourceText { + text: string; + mimeType?: string; + meta?: Record; +} + +/** + * Build a check carrying the SEP-2640 reference. Per AGENTS.md the same `id` + * flips `status` + `errorMessage` between SUCCESS and FAILURE rather than + * branching into distinct slugs. + */ +export function skillsCheck( + id: string, + description: string, + status: CheckStatus, + extras: Partial = {} +): ConformanceCheck { + return { + id, + name: id, + description, + status, + timestamp: new Date().toISOString(), + specReferences: [SEP_2640_REF], + ...extras + }; +} + +/** + * The skills extension object declared under `capabilities.extensions`, or + * `undefined` when the server did not declare it. Reads the declared capability + * from `server/discover` (mirrors `tasks/capability.ts`) — an undeclared + * optional extension is a SKIP, never inferred from a `-32601`. + */ +export async function skillsCapability( + conn: Connection +): Promise | undefined> { + const discovered = await conn.discover(); + const caps = (discovered.capabilities as Record) ?? {}; + const extensions = caps.extensions as Record | undefined; + const skills = extensions?.[SKILLS_EXTENSION_ID]; + return skills && typeof skills === 'object' + ? (skills as Record) + : undefined; +} + +/** + * Whether the skills extension declares `directoryRead: true`. + * + * SEP-2640's capability-declaration example places the flag directly on the + * extension object (`extensions[id].directoryRead`). SEP-2133 extension + * negotiation — which SEP-2640 normatively defers to ("Per SEP-2133 extension + * negotiation") — wraps settings in a `{ specVersion, stability, config }` + * envelope, putting the flag at `extensions[id].config.directoryRead`. The two + * SEPs are inconsistent on nesting, so a brand-neutral conformance check accepts + * either location rather than privileging one reading of an ambiguous spec. + * (The inconsistency is worth a WG clarification; see the scenario docs.) + */ +export function directoryReadDeclared( + skills: Record +): boolean { + if (skills.directoryRead === true) return true; + const config = skills.config as Record | undefined; + return config?.directoryRead === true; +} + +/** Everything from `resources/list`, paginating until `nextCursor` clears. */ +export async function listAllResources( + conn: Connection +): Promise { + const out: SkillResource[] = []; + let cursor: string | undefined; + do { + const page = await conn.request<{ + resources?: SkillResource[]; + nextCursor?: string; + }>('resources/list', cursor ? { cursor } : undefined); + out.push(...(page.resources ?? [])); + cursor = page.nextCursor; + } while (cursor); + return out; +} + +/** + * Read `skill://index.json`. Returns the raw JSON text (for parse-error + * reporting) or a `JsonRpcError` when the server declines the well-known index + * — a permitted MAY (SEP-2640 §Enumeration): the catalog may be unenumerable. + */ +export async function readSkillIndexText( + conn: Connection +): Promise<{ text?: string; mimeType?: string } | { error: JsonRpcError }> { + try { + const res = await conn.request<{ + contents?: Array<{ text?: string; mimeType?: string }>; + }>('resources/read', { uri: SKILL_INDEX_URI }); + const entry = (res.contents ?? []).find((c) => typeof c.text === 'string'); + return { text: entry?.text, mimeType: entry?.mimeType }; + } catch (e) { + if (e instanceof JsonRpcError) return { error: e }; + throw e; + } +} + +/** Read a resource's first text content plus its mimeType and `_meta`. */ +export async function readResourceText( + conn: Connection, + uri: string +): Promise { + const res = await conn.request<{ + contents?: Array<{ + text?: string; + mimeType?: string; + _meta?: Record; + }>; + }>('resources/read', { uri }); + const entry = (res.contents ?? []).find((c) => typeof c.text === 'string'); + if (!entry || typeof entry.text !== 'string') return undefined; + return { text: entry.text, mimeType: entry.mimeType, meta: entry._meta }; +} + +/** + * The skill name recoverable from a `SKILL.md` resource URI: the final segment + * of ``, i.e. the last path segment before the trailing + * `SKILL.md`. Returns `undefined` when the URI is not a `skill://…/SKILL.md`. + * + * skill://org/team/deploy/SKILL.md -> "deploy" + * skill://lint/SKILL.md -> "lint" + */ +export function skillNameFromManifestUri(uri: string): string | undefined { + if (!uri.startsWith(SKILL_URI_SCHEME)) return undefined; + const parts = uri + .slice(SKILL_URI_SCHEME.length) + .split('/') + .filter((p) => p.length > 0); + if (parts.length < 2) return undefined; + if (parts[parts.length - 1] !== SKILL_MANIFEST_FILENAME) return undefined; + return parts[parts.length - 2]; +} + +/** + * Extract and parse the YAML frontmatter block at the head of a `SKILL.md`. + * Returns `undefined` when there is no leading `---` delimited block or it does + * not parse to an object. + */ +export function parseFrontmatter( + markdown: string +): Record | undefined { + // Tolerate a leading UTF-8 BOM before the opening `---` fence. + const body = markdown.charCodeAt(0) === 0xfeff ? markdown.slice(1) : markdown; + const match = body.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/); + if (!match) return undefined; + try { + const parsed = parseYaml(match[1]) as unknown; + return parsed && typeof parsed === 'object' + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} diff --git a/src/scenarios/server/skills/index.ts b/src/scenarios/server/skills/index.ts new file mode 100644 index 00000000..bb9d1f33 --- /dev/null +++ b/src/scenarios/server/skills/index.ts @@ -0,0 +1,270 @@ +/** + * SEP-2640 Skills extension — `skill://index.json` enumeration surface. + * + * One scenario, many checks (per AGENTS.md "fewer scenarios, more checks"). + * Each check's verbatim spec quote lives next to its check ID in + * src/seps/sep-2640.yaml, keeping the YAML and this scenario in lock-step. + * + * All discovery is dynamic and brand-neutral: the scenario reads the well-known + * `skill://index.json` and validates whatever entries it finds, hardcoding no + * fixture-specific skill name or URI. When the server does not declare the + * skills extension the checks are SKIPPED (an optional, undeclared capability); + * when the server declines the index (a permitted MAY) or serves an empty index + * the index-shape checks are SKIPPED (legitimately not applicable), never + * failed against a conformant server. + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { RunContext } from '../../../connection'; +import { + SKILLS_EXTENSION_ID, + SKILL_INDEX_URI, + SKILL_URI_SCHEME, + SKILL_TYPES, + SKILL_DIGEST_PATTERN, + type SkillIndex, + type SkillIndexEntry, + skillsCapability, + skillsCheck, + readSkillIndexText +} from './helpers'; + +const ENTRY_CHECK_IDS = [ + 'sep-2640-index-entry-type-enum', + 'sep-2640-index-name-required', + 'sep-2640-index-digest-required', + 'sep-2640-skill-uri-scheme' +] as const; + +const ALL_CHECK_IDS = ['sep-2640-server-expose-index', ...ENTRY_CHECK_IDS]; + +export class SkillsIndexScenario implements ClientScenario { + name = 'sep-2640-skills-index'; + readonly source = { extensionId: SKILLS_EXTENSION_ID } as const; + description = `SEP-2640 Skills extension: the \`skill://index.json\` enumeration index. + +**Resource**: \`skill://index.json\` (read via \`resources/read\`, \`mimeType\` \`application/json\`) + +**Requirements covered** (each check carries a verbatim spec excerpt in src/seps/sep-2640.yaml): + +- \`sep-2640-server-expose-index\` — server exposes a readable \`skill://index.json\` (SHOULD; a server MAY decline for an unenumerable catalog) +- \`sep-2640-index-entry-type-enum\` — every \`skills[].type\` is \`"skill-md"\` or \`"archive"\` (MUST) +- \`sep-2640-index-name-required\` — every entry carries a non-empty \`name\` (required field) +- \`sep-2640-index-digest-required\` — a present \`skills[].digest\` is \`sha256:{64 hex}\` (MUST) +- \`sep-2640-skill-uri-scheme\` — index entry URLs use the \`skill://\` scheme (SHOULD; another scheme is permitted only when listed in the index) + +**Discovery is dynamic**: the scenario reads whatever skills the index enumerates. Undeclared extension, a declined index, or an empty index all SKIP cleanly.`; + + async run(ctx: RunContext): Promise { + const conn = await ctx.connect(); + try { + const skills = await skillsCapability(conn); + if (!skills) { + const reason = + 'Server did not declare the io.modelcontextprotocol/skills extension; index checks not applicable.'; + return ALL_CHECK_IDS.map((id) => + skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason }) + ); + } + + const checks: ConformanceCheck[] = []; + + // === server-expose-index (SHOULD, with an explicit MAY-decline) === + const read = await readSkillIndexText(conn); + if ('error' in read) { + const reason = `Server declined skill://index.json (code ${read.error.code}); permitted MAY — the catalog may be large or unenumerable. Hosts MUST NOT treat this as proof of no skills.`; + checks.push( + skillsCheck( + 'sep-2640-server-expose-index', + 'Server SHOULD expose a readable skill://index.json enumerating the skills it serves.', + 'SKIPPED', + { errorMessage: reason } + ) + ); + for (const id of ENTRY_CHECK_IDS) { + checks.push( + skillsCheck(id, 'No skill://index.json to inspect.', 'SKIPPED', { + errorMessage: reason + }) + ); + } + return checks; + } + + if (read.text === undefined) { + const reason = + 'resources/read on skill://index.json returned no text content; the index resource is exposed but unreadable.'; + checks.push( + skillsCheck( + 'sep-2640-server-expose-index', + 'Server SHOULD expose a readable skill://index.json enumerating the skills it serves.', + 'FAILURE', + { errorMessage: reason } + ) + ); + for (const id of ENTRY_CHECK_IDS) { + checks.push( + skillsCheck( + id, + 'No readable index content to inspect.', + 'SKIPPED', + { + errorMessage: reason + } + ) + ); + } + return checks; + } + + let index: SkillIndex; + try { + index = JSON.parse(read.text) as SkillIndex; + } catch (e) { + const reason = `skill://index.json content is not valid JSON: ${ + e instanceof Error ? e.message : String(e) + }`; + checks.push( + skillsCheck( + 'sep-2640-server-expose-index', + 'Server SHOULD expose a readable skill://index.json whose content is a JSON index.', + 'FAILURE', + { errorMessage: reason } + ) + ); + for (const id of ENTRY_CHECK_IDS) { + checks.push( + skillsCheck(id, 'Index did not parse as JSON.', 'SKIPPED', { + errorMessage: reason + }) + ); + } + return checks; + } + + checks.push( + skillsCheck( + 'sep-2640-server-expose-index', + 'Server SHOULD expose a readable skill://index.json whose content is a JSON index of the skills it serves.', + 'SUCCESS', + { + details: { + uri: SKILL_INDEX_URI, + mimeType: read.mimeType, + skillCount: Array.isArray(index.skills) ? index.skills.length : 0 + } + } + ) + ); + + const entries: SkillIndexEntry[] = Array.isArray(index.skills) + ? index.skills + : []; + + // An exposed-but-empty index is valid: a partial/empty index is + // permitted, and hosts MUST NOT read "no skills" from it. Nothing to + // validate at the entry level, so SKIP those checks cleanly. + if (entries.length === 0) { + const reason = + 'skill://index.json is exposed but lists no skills; entry-level checks not applicable (an empty index is permitted).'; + for (const id of ENTRY_CHECK_IDS) { + checks.push( + skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason }) + ); + } + return checks; + } + + // === index-entry-type-enum (MUST) === + const typeErrs = entries + .map((e, i) => + SKILL_TYPES.includes(e.type as (typeof SKILL_TYPES)[number]) + ? null + : `skills[${i}].type=${JSON.stringify(e.type)} is not one of ${SKILL_TYPES.join('|')}` + ) + .filter((x): x is string => x !== null); + checks.push( + skillsCheck( + 'sep-2640-index-entry-type-enum', + 'Every skills[].type MUST be "skill-md" or "archive".', + typeErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + typeErrs.length === 0 + ? { details: { entryCount: entries.length } } + : { errorMessage: typeErrs.join('; ') } + ) + ); + + // === index-name-required (required field) === + const nameErrs = entries + .map((e, i) => + typeof e.name === 'string' && e.name.length > 0 + ? null + : `skills[${i}].name is missing or empty` + ) + .filter((x): x is string => x !== null); + checks.push( + skillsCheck( + 'sep-2640-index-name-required', + 'Every index entry carries a non-empty name (matching the SKILL.md frontmatter name and the final skill-path segment).', + nameErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + nameErrs.length === 0 + ? { details: { entryCount: entries.length } } + : { errorMessage: nameErrs.join('; ') } + ) + ); + + // === index-digest-required (MUST — validate the format when present) === + const withDigest = entries.filter((e) => e.digest !== undefined); + const digestErrs = withDigest + .map((e, i) => + typeof e.digest === 'string' && SKILL_DIGEST_PATTERN.test(e.digest) + ? null + : `skills[${i}].digest=${JSON.stringify(e.digest)} is not sha256:{64 lowercase hex}` + ) + .filter((x): x is string => x !== null); + checks.push( + skillsCheck( + 'sep-2640-index-digest-required', + 'Every present skills[].digest MUST be formatted as sha256:{hex} with 64 lowercase hex characters.', + digestErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + digestErrs.length === 0 + ? { + details: { + entriesWithDigest: withDigest.length, + entriesWithoutDigest: entries.length - withDigest.length + } + } + : { errorMessage: digestErrs.join('; ') } + ) + ); + + // === skill-uri-scheme (SHOULD) === + // Servers SHOULD use skill://; another scheme is permitted only when the + // skill is listed in the index (SEP-2640 §URI convention), so a non- + // skill:// URL is a SHOULD deviation, not a hard failure. + const nonSkillScheme = entries + .map((e, i) => + typeof e.url === 'string' && !e.url.startsWith(SKILL_URI_SCHEME) + ? `skills[${i}].url=${JSON.stringify(e.url)}` + : null + ) + .filter((x): x is string => x !== null); + checks.push( + skillsCheck( + 'sep-2640-skill-uri-scheme', + 'Skill resource URLs in the index SHOULD use the skill:// URI scheme.', + nonSkillScheme.length === 0 ? 'SUCCESS' : 'WARNING', + nonSkillScheme.length === 0 + ? { details: { entryCount: entries.length } } + : { + errorMessage: `Entries use a non-skill:// scheme (permitted only when indexed): ${nonSkillScheme.join(', ')}` + } + ) + ); + + return checks; + } finally { + await conn.close(); + } + } +} diff --git a/src/scenarios/server/skills/manifest.ts b/src/scenarios/server/skills/manifest.ts new file mode 100644 index 00000000..7a4f1fd6 --- /dev/null +++ b/src/scenarios/server/skills/manifest.ts @@ -0,0 +1,367 @@ +/** + * SEP-2640 Skills extension — the `SKILL.md` manifest resource. + * + * One scenario, many checks (per AGENTS.md "fewer scenarios, more checks"). + * Each check's verbatim spec quote lives next to its check ID in + * src/seps/sep-2640.yaml. + * + * Discovery is dynamic and brand-neutral: the scenario finds a `skill-md` + * skill's `SKILL.md` resource from `resources/list` (preferred — it carries the + * Resource `name`/`description` metadata) or falls back to the first `skill-md` + * entry in `skill://index.json`, hardcoding no fixture skill. Undeclared + * extension SKIPs; a declared extension with no discoverable `SKILL.md` reports + * the missing prerequisite via untestableCheck (issue #248), never a silent + * green. + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import { JsonRpcError, type RunContext } from '../../../connection'; +import { untestableCheck } from '../../untestable'; +import { + SKILLS_EXTENSION_ID, + SKILLS_META_PREFIX, + SEP_2640_REF, + type SkillIndex, + type SkillResource, + skillsCapability, + skillsCheck, + listAllResources, + readSkillIndexText, + readResourceText, + skillNameFromManifestUri, + parseFrontmatter +} from './helpers'; + +const MIMETYPE_ID = 'sep-2640-skillmd-mimetype'; +const METADATA_NAME_ID = 'sep-2640-skillmd-metadata-name'; +const METADATA_DESCRIPTION_ID = 'sep-2640-skillmd-metadata-description'; +const FINAL_SEGMENT_ID = 'sep-2640-final-segment-equals-name'; +const META_PREFIX_ID = 'sep-2640-meta-prefix'; + +const MARKDOWN_MIME = 'text/markdown'; + +/** A SKILL.md resource URI is skill:///SKILL.md. */ +function isManifestUri(uri: string): boolean { + return skillNameFromManifestUri(uri) !== undefined; +} + +/** A `_meta` key that already carries a reverse-domain namespace (`vendor.tld/…`). */ +function isNamespacedMetaKey(key: string): boolean { + return /^[a-z0-9-]+(\.[a-z0-9-]+)+\//i.test(key); +} + +export class SkillsManifestScenario implements ClientScenario { + name = 'sep-2640-skills-manifest'; + readonly source = { extensionId: SKILLS_EXTENSION_ID } as const; + description = `SEP-2640 Skills extension: the \`SKILL.md\` manifest resource. + +**Resource**: \`skill:///SKILL.md\` (read via \`resources/read\`) + +**Requirements covered** (each check carries a verbatim spec excerpt in src/seps/sep-2640.yaml): + +- \`sep-2640-skillmd-mimetype\` — the SKILL.md resource \`mimeType\` SHOULD be \`text/markdown\` +- \`sep-2640-skillmd-metadata-name\` — the resource \`name\` SHOULD be the frontmatter \`name\` +- \`sep-2640-skillmd-metadata-description\` — the resource \`description\` SHOULD be the frontmatter \`description\` +- \`sep-2640-final-segment-equals-name\` — the final \`\` segment MUST equal the frontmatter \`name\` +- \`sep-2640-meta-prefix\` — un-namespaced skill \`_meta\` keys SHOULD use the \`io.modelcontextprotocol.skills/\` prefix + +**Discovery is dynamic**: the scenario picks the first \`skill-md\` skill it finds. Undeclared extension SKIPs; a declared extension with no discoverable SKILL.md reports the missing prerequisite (not a silent skip).`; + + async run(ctx: RunContext): Promise { + const conn = await ctx.connect(); + try { + const skills = await skillsCapability(conn); + const allIds = [ + MIMETYPE_ID, + METADATA_NAME_ID, + METADATA_DESCRIPTION_ID, + FINAL_SEGMENT_ID, + META_PREFIX_ID + ]; + if (!skills) { + const reason = + 'Server did not declare the io.modelcontextprotocol/skills extension; SKILL.md checks not applicable.'; + return allIds.map((id) => + skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason }) + ); + } + + // === Dynamic discovery: resources/list first (carries Resource + // metadata), then skill://index.json. === + const resources = await listAllResources(conn); + const manifestResource: SkillResource | undefined = resources.find((r) => + isManifestUri(r.uri) + ); + let manifestUri = manifestResource?.uri; + if (!manifestUri) { + const idx = await readSkillIndexText(conn); + if (!('error' in idx) && typeof idx.text === 'string') { + try { + const index = JSON.parse(idx.text) as SkillIndex; + const entry = (index.skills ?? []).find( + (e) => + e.type === 'skill-md' && + typeof e.url === 'string' && + isManifestUri(e.url) + ); + manifestUri = entry?.url; + } catch { + // A malformed index is the index scenario's concern; ignore here. + } + } + } + + if (!manifestUri) { + const reason = + 'no skill:///SKILL.md resource found via resources/list or skill://index.json'; + return [ + untestableCheck( + MIMETYPE_ID, + MIMETYPE_ID, + 'SKILL.md resource mimeType SHOULD be text/markdown.', + reason, + [SEP_2640_REF], + 'WARNING' + ), + untestableCheck( + METADATA_NAME_ID, + METADATA_NAME_ID, + 'SKILL.md resource name SHOULD match the frontmatter name.', + reason, + [SEP_2640_REF], + 'WARNING' + ), + untestableCheck( + METADATA_DESCRIPTION_ID, + METADATA_DESCRIPTION_ID, + 'SKILL.md resource description SHOULD match the frontmatter description.', + reason, + [SEP_2640_REF], + 'WARNING' + ), + untestableCheck( + FINAL_SEGMENT_ID, + FINAL_SEGMENT_ID, + 'The final segment MUST equal the frontmatter name.', + reason, + [SEP_2640_REF], + 'FAILURE' + ), + untestableCheck( + META_PREFIX_ID, + META_PREFIX_ID, + 'Skill _meta keys SHOULD use the io.modelcontextprotocol.skills/ prefix.', + reason, + [SEP_2640_REF], + 'WARNING' + ) + ]; + } + + const checks: ConformanceCheck[] = []; + + // Read the manifest content (for mimeType, frontmatter, and _meta). + let content: + | { text: string; mimeType?: string; meta?: Record } + | undefined; + let readError: string | undefined; + try { + content = await readResourceText(conn, manifestUri); + if (!content) readError = 'resources/read returned no text content'; + } catch (e) { + readError = + e instanceof JsonRpcError + ? `resources/read failed: code ${e.code}: ${e.message}` + : e instanceof Error + ? e.message + : String(e); + } + + // === skillmd-mimetype (SHOULD) === + // Prefer the read content's mimeType; fall back to the resources/list + // Resource metadata mimeType. + const mimeType = content?.mimeType ?? manifestResource?.mimeType; + if (mimeType === undefined) { + checks.push( + untestableCheck( + MIMETYPE_ID, + MIMETYPE_ID, + 'SKILL.md resource mimeType SHOULD be text/markdown.', + `no mimeType observable for ${manifestUri}${readError ? ` (${readError})` : ''}`, + [SEP_2640_REF], + 'WARNING' + ) + ); + } else { + checks.push( + skillsCheck( + MIMETYPE_ID, + 'SKILL.md resource mimeType SHOULD be text/markdown.', + mimeType === MARKDOWN_MIME ? 'SUCCESS' : 'WARNING', + mimeType === MARKDOWN_MIME + ? { details: { uri: manifestUri, mimeType } } + : { + errorMessage: `expected mimeType "${MARKDOWN_MIME}", got ${JSON.stringify(mimeType)}` + } + ) + ); + } + + // Parse the frontmatter once for the name/description/final-segment checks. + const frontmatter = content ? parseFrontmatter(content.text) : undefined; + const fmName = + typeof frontmatter?.name === 'string' ? frontmatter.name : undefined; + const fmDescription = + typeof frontmatter?.description === 'string' + ? frontmatter.description + : undefined; + + // === final-segment-equals-name (MUST) === + const uriName = skillNameFromManifestUri(manifestUri); + if (fmName === undefined || uriName === undefined) { + const missing = + fmName === undefined + ? `SKILL.md frontmatter has no string "name"${readError ? ` (${readError})` : ''}` + : `could not derive the skill name from URI ${manifestUri}`; + checks.push( + untestableCheck( + FINAL_SEGMENT_ID, + FINAL_SEGMENT_ID, + 'The final segment MUST equal the frontmatter name.', + missing, + [SEP_2640_REF], + 'FAILURE' + ) + ); + } else { + checks.push( + skillsCheck( + FINAL_SEGMENT_ID, + 'The final segment of the SKILL.md URI MUST equal the frontmatter name.', + uriName === fmName ? 'SUCCESS' : 'FAILURE', + uriName === fmName + ? { details: { uri: manifestUri, name: fmName } } + : { + errorMessage: `final path segment "${uriName}" != frontmatter name "${fmName}"` + } + ) + ); + } + + // === skillmd-metadata-name (SHOULD) — needs the Resource metadata === + if (!manifestResource) { + checks.push( + untestableCheck( + METADATA_NAME_ID, + METADATA_NAME_ID, + 'SKILL.md resource name SHOULD match the frontmatter name.', + `SKILL.md ${manifestUri} is not listed in resources/list, so its Resource name metadata is not observable`, + [SEP_2640_REF], + 'WARNING' + ) + ); + } else if (fmName === undefined) { + checks.push( + untestableCheck( + METADATA_NAME_ID, + METADATA_NAME_ID, + 'SKILL.md resource name SHOULD match the frontmatter name.', + `SKILL.md frontmatter has no string "name" to compare against${readError ? ` (${readError})` : ''}`, + [SEP_2640_REF], + 'WARNING' + ) + ); + } else { + checks.push( + skillsCheck( + METADATA_NAME_ID, + 'The SKILL.md resource name SHOULD be set from the frontmatter name.', + manifestResource.name === fmName ? 'SUCCESS' : 'WARNING', + manifestResource.name === fmName + ? { details: { name: fmName } } + : { + errorMessage: `resource name ${JSON.stringify(manifestResource.name)} != frontmatter name ${JSON.stringify(fmName)}` + } + ) + ); + } + + // === skillmd-metadata-description (SHOULD) — needs Resource metadata === + if (!manifestResource) { + checks.push( + untestableCheck( + METADATA_DESCRIPTION_ID, + METADATA_DESCRIPTION_ID, + 'SKILL.md resource description SHOULD match the frontmatter description.', + `SKILL.md ${manifestUri} is not listed in resources/list, so its Resource description metadata is not observable`, + [SEP_2640_REF], + 'WARNING' + ) + ); + } else if (fmDescription === undefined) { + checks.push( + untestableCheck( + METADATA_DESCRIPTION_ID, + METADATA_DESCRIPTION_ID, + 'SKILL.md resource description SHOULD match the frontmatter description.', + `SKILL.md frontmatter has no string "description" to compare against${readError ? ` (${readError})` : ''}`, + [SEP_2640_REF], + 'WARNING' + ) + ); + } else { + checks.push( + skillsCheck( + METADATA_DESCRIPTION_ID, + 'The SKILL.md resource description SHOULD be set from the frontmatter description.', + manifestResource.description === fmDescription + ? 'SUCCESS' + : 'WARNING', + manifestResource.description === fmDescription + ? { details: { description: fmDescription } } + : { + errorMessage: `resource description ${JSON.stringify(manifestResource.description)} != frontmatter description ${JSON.stringify(fmDescription)}` + } + ) + ); + } + + // === meta-prefix (SHOULD, conditional on _meta keys being present) === + // Union the _meta of the read content and the resources/list Resource. + const metaKeys = new Set([ + ...Object.keys(content?.meta ?? {}), + ...Object.keys(manifestResource?._meta ?? {}) + ]); + if (metaKeys.size === 0) { + checks.push( + skillsCheck( + META_PREFIX_ID, + 'When _meta keys are used for skill resources, they SHOULD use the io.modelcontextprotocol.skills/ reverse-domain prefix.', + 'SUCCESS', + { details: { note: 'skill resource exposes no _meta keys' } } + ) + ); + } else { + // Only bare (un-namespaced) keys are flagged: a key already carrying a + // reverse-domain namespace is the intended shape, whichever vendor. + const bareKeys = [...metaKeys].filter((k) => !isNamespacedMetaKey(k)); + checks.push( + skillsCheck( + META_PREFIX_ID, + 'When _meta keys are used for skill resources, they SHOULD use the io.modelcontextprotocol.skills/ reverse-domain prefix.', + bareKeys.length === 0 ? 'SUCCESS' : 'WARNING', + bareKeys.length === 0 + ? { details: { metaKeys: [...metaKeys] } } + : { + errorMessage: `un-namespaced skill _meta keys SHOULD use the ${SKILLS_META_PREFIX} prefix: ${bareKeys.join(', ')}` + } + ) + ); + } + + return checks; + } finally { + await conn.close(); + } + } +} diff --git a/src/seps/sep-2640.yaml b/src/seps/sep-2640.yaml new file mode 100644 index 00000000..d93e1487 --- /dev/null +++ b/src/seps/sep-2640.yaml @@ -0,0 +1,111 @@ +# spec_source: modelcontextprotocol/modelcontextprotocol@556154c088371149c120172e95bb634655f00cbe seps/2640-skills-extension.md +# extracted: 2026-06-05 +# forward_reference: rows sep-2640-capability-directory-read-flag through +# sep-2640-directory-read-pagination track SEP commit +# 2e04c48da90224000e750ffd54a3611f2824fbc0 (2026-06-09) — the +# resources/directory/read addition. The file-level provenance above +# stays at 556154c because the PR 97 schema rewrite (360123d0, +# 2026-06-08) made 3 existing rows stale (sep-2640-index-entry-type-enum, +# sep-2640-index-name-required, sep-2640-index-digest-required) and +# drifted ~11 others' verbatim wording. Full re-extraction at SEP HEAD +# is mcpkit#780's lifecycle; this file deliberately holds at 556154c +# until that lands. +# backing_scenarios: three server ClientScenarios under +# src/scenarios/server/skills/ emit the check IDs below (a row is "tested" +# once a scenario emits its check ID; see src/traceability/): +# directory.ts (sep-2640-skills-directory) — sep-2640-capability-directory-read-flag +# and the five sep-2640-directory-read-* rows. +# index.ts (sep-2640-skills-index) — sep-2640-server-expose-index, +# sep-2640-index-entry-type-enum, sep-2640-index-name-required, +# sep-2640-index-digest-required, sep-2640-skill-uri-scheme. +# manifest.ts (sep-2640-skills-manifest) — sep-2640-skillmd-mimetype, +# sep-2640-skillmd-metadata-name, sep-2640-skillmd-metadata-description, +# sep-2640-final-segment-equals-name, sep-2640-meta-prefix. +# The remaining rows are host-internal or off-wire (host load-by-uri, digest +# verification, byte-budget / archive-unpack safety, no-empty-index +# assumption) and stay traceability-only for this server-scenario set. +sep: 2640 +spec_url: https://modelcontextprotocol.io/seps/2640-skills-extension#specification +requirements: + - check: sep-2640-skillmd-required + text: 'Every skill MUST contain a `SKILL.md` file at its root.' + - check: sep-2640-skillmd-frontmatter + text: '`SKILL.md` MUST begin with YAML frontmatter containing at minimum the `name` and `description` fields as defined by the Agent Skills specification.' + - check: sep-2640-skill-uri-scheme + text: 'Each file within a skill directory is exposed as an MCP resource. Servers SHOULD use the `skill://` URI scheme, under which the resource URI has the form: `skill:///`' + - check: sep-2640-final-segment-equals-name + text: "The final segment of `` MUST equal the skill's `name` as declared in its `SKILL.md` frontmatter." + - check: sep-2640-no-nested-skills + text: 'A `SKILL.md` MUST NOT appear in any descendant directory of a skill. The skill directory is the boundary; skills do not nest inside other skills.' + - check: sep-2640-name-naming-rules + text: "The final `` segment, being the skill `name`, MUST satisfy the Agent Skills specification's naming rules." + - check: sep-2640-prefix-rfc3986 + text: 'Prefix segments SHOULD be valid URI path segments per RFC 3986; no further constraints are imposed on them.' + - check: sep-2640-skillmd-mimetype + text: 'For each `skill:///SKILL.md` resource: `mimeType` SHOULD be `text/markdown`.' + - check: sep-2640-skillmd-metadata-name + text: 'For each `skill:///SKILL.md` resource: `name` SHOULD be set from the `name` field of the `SKILL.md` YAML frontmatter. By the path constraint above, this will equal the final segment of ``.' + - check: sep-2640-skillmd-metadata-description + text: 'For each `skill:///SKILL.md` resource: `description` SHOULD be set from the `description` field of the `SKILL.md` YAML frontmatter.' + - check: sep-2640-meta-prefix + text: 'When `_meta` keys are used for skill resources, implementations SHOULD use the `io.modelcontextprotocol.skills/` reverse-domain prefix.' + - check: sep-2640-host-load-by-uri + text: 'hosts MUST support loading a skill given only its URI' + - check: sep-2640-server-expose-index + text: 'A server SHOULD expose a resource at the well-known URI `skill://index.json` whose content is a JSON index of the skills it serves.' + - check: sep-2640-index-entry-type-enum + text: '`skills[].type` MUST be `"skill-md"` or `"archive"`.' + - check: sep-2640-index-name-required + text: '`skills[].name` matches the `SKILL.md` frontmatter `name` and the final segment of the skill path.' + - check: sep-2640-index-digest-required + text: '`skills[].digest` is the SHA-256 content digest of the artifact, formatted as `sha256:{hex}` (64 lowercase hex characters).' + - check: sep-2640-client-ignore-unrecognized + text: 'Clients SHOULD ignore unrecognized fields and SHOULD skip entries with an unrecognized `type`.' + - check: sep-2640-archive-format + text: 'the archive MUST be `.tar.gz` (gzip-compressed tar, `mimeType` `application/gzip`) or `.zip` (`mimeType` `application/zip`)' + - check: sep-2640-host-support-archive-formats + text: 'hosts MUST support both `.tar.gz` and `.zip` archive formats' + - check: sep-2640-archive-skillmd-at-root + text: 'Archive contents represent the skill directory directly — `SKILL.md` MUST be at the archive root, not nested inside a wrapper directory' + - check: sep-2640-archive-no-traversal + text: 'the archive MUST NOT contain path-traversal sequences (`..`) or absolute paths' + - check: sep-2640-host-archive-safety + text: 'Hosts unpacking an archive MUST apply the archive safety requirements of the Agent Skills specification: reject archives containing path-traversal sequences or absolute paths, reject symlinks or hard links that resolve outside the skill directory, and enforce a limit on total unpacked size / Hosts MUST validate archives per the Agent Skills archive safety requirements: reject path traversal and absolute paths, reject links resolving outside the skill directory, and bound total unpacked size to prevent decompression bombs.' + - check: sep-2640-host-verify-digest + text: 'Hosts MUST verify retrieved content against the `digest` in the index / hosts MUST NOT use unverified content.' + - check: sep-2640-host-no-empty-index-assumption + text: 'Hosts MUST NOT treat an absent or empty index as proof that a server has no skills.' + + # resources/directory/read additions (SEP commit 2e04c48d, 2026-06-09) + - check: sep-2640-capability-directory-read-flag + text: 'Clients MUST NOT call `resources/directory/read` against a server that has not declared `directoryRead: true`.' + - check: sep-2640-directory-read-method-registered + text: 'A server that declares `directoryRead` MUST support the method for every directory within the skill namespaces it serves as individual files.' + - check: sep-2640-directory-read-subdir-mimetype + text: 'A _directory resource_ is a resource whose `mimeType` is `inode/directory`.' + - check: sep-2640-directory-read-result-resources-shape + text: 'The result contains every direct child of the directory: files with their ordinary resource metadata, subdirectories listed as directory resources (`mimeType: "inode/directory"`). The listing is not recursive; clients descend by calling the method again on a child directory.' + - check: sep-2640-directory-read-invalid-params + text: 'The method applies only to directory resources. If the URI does not exist, or exists but is not a directory resource, the server MUST return error `-32602` (Invalid params) — the same code `resources/read` uses for unknown resources.' + - check: sep-2640-directory-read-pagination + text: 'Pagination mirrors `resources/list`: when the result includes `nextCursor`, the client passes it back as `cursor` to retrieve the next page.' + + - text: 'Per RFC 3986, the first segment of `` occupies the authority component. This carries no special semantics under this convention and clients MUST NOT attempt DNS or network resolution of it.' + excluded: 'DNS and network resolution sit below the MCP wire layer; the harness cannot observe whether the client performed name lookups on URI authority components.' + - text: "[Hosts] SHOULD determine the format from the resource's `mimeType`, falling back to the URL suffix" + excluded: 'Internal decision logic: when `mimeType` and URL suffix agree, the harness cannot distinguish a host that branched on `mimeType` from one that fell back to the suffix.' + - text: 'Hosts MUST treat MCP-served skill content as untrusted model input, subject to the same prompt-injection defenses applied to any server-provided text. A server being connected does not make its skill content authoritative.' + excluded: 'Internal host policy: "treats as untrusted" is an assertion about how content is reasoned over downstream of the read, not about wire traffic.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - text: 'Hosts MUST NOT honor mechanisms in skill content that would cause local code execution without explicit user opt-in. This includes, non-exhaustively: hook declarations, pre/post-invocation scripts, shell commands embedded in frontmatter, or any field that a filesystem-sourced skill might use to register executable behavior on the host.' + excluded: 'Local code execution and explicit user opt-in are host-side filesystem and UX behaviors; not protocol-observable on the wire.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - text: 'Hosts MUST either ignore such fields entirely when the skill arrives over MCP, or gate them behind an explicit per-skill user approval that states what will execute and where.' + excluded: 'Either branch (silent ignore vs. UI-gated approval) is a host-internal handling choice; not protocol-observable.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - text: 'Hosts MUST NOT treat skill resources as higher-authority than other context. Explicit user policy governs whether a skill is loaded at all.' + excluded: 'Context-authority ordering is an internal prompting decision; not protocol-observable.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - text: "Hosts SHOULD indicate which server a skill originates from when presenting it, SHOULD let users inspect a skill's content before it is loaded into model context" + excluded: 'UI presentation requirements (origin indicator, pre-load inspection); the harness cannot observe what the host displays to users.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications diff --git a/src/types.ts b/src/types.ts index 5960945b..ebe75a27 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,7 +102,8 @@ export const EXTENSION_IDS = [ 'io.modelcontextprotocol/enterprise-managed-authorization', 'io.modelcontextprotocol/auth/dpop', 'io.modelcontextprotocol/auth/wif', - 'io.modelcontextprotocol/tasks' + 'io.modelcontextprotocol/tasks', + 'io.modelcontextprotocol/skills' ] as const; export type ExtensionId = (typeof EXTENSION_IDS)[number];