From 93f9490902b914cd07abdbffbbae8404a1632142 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 22:33:42 +0000 Subject: [PATCH 1/9] Add `skills-list` and `skills-get` for MCP skills extension (SEP-2640) Implements the experimental `io.modelcontextprotocol/skills` extension as sugar over the existing Resources primitive. Skills are markdown documents (SKILL.md + YAML frontmatter) served under `skill://` URIs, with optional discovery via `skill://index.json`. - `skills-list` reads the well-known index, falling back to scanning resources for `skill://*/SKILL.md` URIs (per spec, an absent index does not imply zero skills) - `skills-get ` reads the SKILL.md; `--raw` prints just the markdown for piping to LLMs or files - Session overview surfaces `skills` under capabilities when the server advertises `capabilities.extensions["io.modelcontextprotocol/skills"]` - JSON output is documented for both commands so AI agents can consume it --- CHANGELOG.md | 1 + README.md | 2 + src/cli/commands/skills.ts | 277 +++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 66 +++++++++ src/cli/output.ts | 114 +++++++++++++++ src/cli/parser.ts | 3 + 6 files changed, 463 insertions(+) create mode 100644 src/cli/commands/skills.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 86ba64a1..2ff42882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- New `skills-list` and `skills-get` commands implementing the experimental MCP skills extension (`io.modelcontextprotocol/skills`, [SEP-2640](https://github.com/modelcontextprotocol/experimental-ext-skills)). Skills are discovered via the well-known `skill://index.json` resource, falling back to scanning `skill://*/SKILL.md` URIs. `skills-get ` reads a skill's `SKILL.md`; pass `--raw` to print just the markdown for piping to LLMs or files. The session overview now lists `skills` under capabilities when a server advertises the extension. JSON shape: `[{ name, description, type, url }]` for `skills-list`, full `ReadResourceResult` for `skills-get`. - New `npm run test:conformance` script (and on-demand `Conformance` GitHub Actions workflow) that runs the `@modelcontextprotocol/conformance` framework against mcpc to verify adherence to the MCP specification. The conformance adapter now covers the `initialize`, `tools_call`, and `sse-retry` client scenarios and exercises a broader set of mcpc sub-commands (`tools-list`, `tools-get`, `tools-call` with and without `--task`, `ping`, `logging-set-level`, `resources-list`, `resources-templates-list`, `prompts-list`) against the conformance test server. - `mcpc connect` (with no arguments) now auto-discovers standard MCP config files (`.mcp.json`, `mcp.json`, `mcp_config.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `.kiro/settings/mcp.json`, `~/.claude.json`, `~/.codeium/windsurf/mcp_config.json`, VS Code app config, Claude Desktop config, etc.) in the current directory and home directory, and connects every server defined across them. Entries with duplicate session names across files are deduplicated (project-scoped files win over global ones). Config files using VS Code's `"servers"` key (instead of `"mcpServers"`) are also supported. - `mcpc connect` auto-connects to `mcp.apify.com` as `@apify` when the `APIFY_API_TOKEN` environment variable is set, using it as a Bearer token. Existing live sessions are reused without restart. diff --git a/README.md b/README.md index 3010f159..853696ed 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,8 @@ MCP session commands (after connecting): <@session> resources-subscribe <@session> resources-unsubscribe <@session> resources-templates-list + <@session> skills-list + <@session> skills-get [--raw] <@session> tasks-list <@session> tasks-get <@session> tasks-result diff --git a/src/cli/commands/skills.ts b/src/cli/commands/skills.ts new file mode 100644 index 00000000..8aa303fa --- /dev/null +++ b/src/cli/commands/skills.ts @@ -0,0 +1,277 @@ +/** + * Skills command handlers — implements the experimental MCP skills extension + * (SEP-2640: io.modelcontextprotocol/skills). + * + * Skills are not a new MCP primitive — they are a URI convention layered on + * top of the existing Resources primitive: + * + * - Each skill lives at `skill:///SKILL.md` (markdown + YAML + * frontmatter), optionally with supporting files under the same prefix. + * - Servers MAY expose a discovery index at `skill://index.json` listing + * `{ name, description, type, url }` entries. + * - Servers MAY advertise the extension via + * `capabilities.extensions["io.modelcontextprotocol/skills"]`. + * + * These commands are sugar on top of `resources-read`, so they work against + * any compliant server without requiring server-side awareness of mcpc. + * + * Spec: https://github.com/modelcontextprotocol/experimental-ext-skills + */ + +import type { CommandOptions, IMcpClient } from '../../lib/types.js'; +import type { ReadResourceResult, Resource } from '@modelcontextprotocol/sdk/types.js'; +import { ServerError, ClientError } from '../../lib/errors.js'; +import { withMcpClient } from '../helpers.js'; +import { formatOutput, formatSkills, formatSkillDetail } from '../output.js'; + +/** + * URI of the well-known skills discovery index. + */ +export const SKILLS_INDEX_URI = 'skill://index.json'; + +/** + * Capability key under `capabilities.extensions` advertising skills support. + */ +export const SKILLS_EXTENSION_KEY = 'io.modelcontextprotocol/skills'; + +/** + * Single entry in the skills discovery index. Mirrors the Agent Skills + * discovery schema with mcpc-relevant fields kept. + */ +export interface Skill { + /** Skill name (matches the final segment of the skill path). */ + name: string; + /** Human-readable description. */ + description: string; + /** + * Entry type, either `"skill-md"` (concrete skill) or + * `"mcp-resource-template"` (parameterized namespace). + */ + type?: string; + /** MCP resource URI of the skill's `SKILL.md` (or template). */ + url: string; +} + +interface RawIndexEntry { + name?: unknown; + description?: unknown; + type?: unknown; + url?: unknown; +} + +interface RawIndex { + skills?: unknown; +} + +/** + * Extract the readable text from a `ReadResourceResult`. Skills resources are + * always text (`text/markdown` or `application/json`), so we ignore blobs. + */ +function extractTextContent(result: ReadResourceResult): string | undefined { + for (const item of result.contents) { + if ('text' in item && typeof item.text === 'string') { + return item.text; + } + } + return undefined; +} + +/** + * Parse and validate a discovery index into a list of `Skill` objects. Drops + * malformed entries silently rather than failing — the spec instructs hosts + * to be permissive about what they accept. + */ +function parseIndex(text: string): Skill[] { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + throw new ServerError( + `Skills index at ${SKILLS_INDEX_URI} is not valid JSON: ${(err as Error).message}` + ); + } + + if (!parsed || typeof parsed !== 'object') { + throw new ServerError( + `Skills index at ${SKILLS_INDEX_URI} is not a JSON object (got ${typeof parsed})` + ); + } + + const raw = (parsed as RawIndex).skills; + if (!Array.isArray(raw)) { + return []; + } + + const skills: Skill[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as RawIndexEntry; + if (typeof e.name !== 'string' || typeof e.url !== 'string') continue; + + skills.push({ + name: e.name, + description: typeof e.description === 'string' ? e.description : '', + ...(typeof e.type === 'string' && { type: e.type }), + url: e.url, + }); + } + + return skills; +} + +/** + * Fallback discovery: scan the server's resource list for SKILL.md files + * under any `skill://...` prefix. Used when the well-known index is absent. + */ +function skillsFromResources(resources: Resource[]): Skill[] { + // Match `skill:///SKILL.md` + const pattern = /^skill:\/\/((?:[^/]+\/)*[^/]+)\/SKILL\.md$/; + + const skills: Skill[] = []; + for (const resource of resources) { + const m = pattern.exec(resource.uri); + if (!m || !m[1]) continue; + // The skill name is the *final* path segment per SEP-2640. + const path = m[1]; + const lastSlash = path.lastIndexOf('/'); + const name = lastSlash >= 0 ? path.slice(lastSlash + 1) : path; + + skills.push({ + name: resource.name || name, + description: resource.description || '', + type: 'skill-md', + url: resource.uri, + }); + } + return skills; +} + +/** + * Discover skills exposed by the server. + * + * Strategy: + * 1. Try to read `skill://index.json` and parse its `skills` array. + * 2. If the index is missing (404-style errors), fall back to listing + * resources and matching `skill://*​/SKILL.md` URIs. + * + * The spec requires that hosts MUST NOT treat an absent index as proof a + * server has no skills, hence the fallback. + */ +async function discoverSkills(client: IMcpClient): Promise { + try { + const indexResult = await client.readResource(SKILLS_INDEX_URI); + const text = extractTextContent(indexResult); + if (text !== undefined) { + return parseIndex(text); + } + } catch { + // Index not present — fall through to resource scan. + } + + // Fallback: scan all resources, matching `skill://*​/SKILL.md`. + const all: Resource[] = []; + let cursor: string | undefined; + do { + const page = await client.listResources(cursor); + all.push(...page.resources); + cursor = page.nextCursor; + } while (cursor); + + return skillsFromResources(all); +} + +/** + * Resolve a user-provided identifier into a `SKILL.md` URI. + * + * Accepts: + * - A bare name (`git-workflow`) → `skill://git-workflow/SKILL.md` + * - A multi-segment path (`acme/billing/refunds`) → `skill://acme/billing/refunds/SKILL.md` + * - A full `skill://...` URI → returned as-is, with `/SKILL.md` appended + * when the URI does not already point at a file. + */ +export function resolveSkillUri(input: string): string { + const trimmed = input.trim(); + if (!trimmed) { + throw new ClientError('Skill name is required'); + } + + if (trimmed.startsWith('skill://')) { + // Already a URI. If it points at a directory, append SKILL.md. + const rest = trimmed.slice('skill://'.length); + const lastSegment = rest.slice(rest.lastIndexOf('/') + 1); + if (lastSegment.includes('.')) { + return trimmed; + } + return trimmed.endsWith('/') ? `${trimmed}SKILL.md` : `${trimmed}/SKILL.md`; + } + + // Strip surrounding slashes; allow nested paths. + const path = trimmed.replace(/^\/+/, '').replace(/\/+$/, ''); + if (!path) { + throw new ClientError(`Invalid skill name: ${input}`); + } + return `skill://${path}/SKILL.md`; +} + +/** + * `skills-list` — discover and list skills exposed by the server. + * + * Tries the well-known `skill://index.json` index first; falls back to + * scanning resources for `skill://*​/SKILL.md` URIs. + */ +export async function listSkills(target: string, options: CommandOptions): Promise { + await withMcpClient(target, options, async (client) => { + const skills = await discoverSkills(client); + + if (options.outputMode === 'json') { + console.log(formatOutput(skills, 'json')); + return; + } + + console.log( + formatSkills(skills, target, { + ...(options.maxChars && { maxChars: options.maxChars }), + }) + ); + }); +} + +/** + * `skills-get ` — read a skill's SKILL.md. + * + * Resolves `` to `skill:///SKILL.md` (or accepts a full URI), + * reads it via `resources/read`, and renders the markdown. + * + * With `--raw`, prints just the SKILL.md text (suitable for piping). + */ +export async function getSkill( + target: string, + name: string, + options: CommandOptions & { raw?: boolean } +): Promise { + const uri = resolveSkillUri(name); + + await withMcpClient(target, options, async (client) => { + const result = await client.readResource(uri); + + if (options.outputMode === 'json') { + console.log(formatOutput(result, 'json')); + return; + } + + if (options.raw) { + const text = extractTextContent(result); + if (text !== undefined) { + console.log(text); + return; + } + // No text content — fall through to formatted view. + } + + console.log( + formatSkillDetail(uri, result, { + ...(options.maxChars && { maxChars: options.maxChars }), + }) + ); + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 8a51bb27..9a6fec91 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -18,6 +18,7 @@ import chalk from 'chalk'; import { formatJson, formatJsonError, rainbow } from './output.js'; import * as tools from './commands/tools.js'; import * as resources from './commands/resources.js'; +import * as skills from './commands/skills.js'; import * as prompts from './commands/prompts.js'; import * as sessions from './commands/sessions.js'; import * as logging from './commands/logging.js'; @@ -419,6 +420,8 @@ ${chalk.bold('MCP session commands (after connecting):')} <@session> ${chalk.cyan('resources-subscribe')} <@session> ${chalk.cyan('resources-unsubscribe')} <@session> ${chalk.cyan('resources-templates-list')} + <@session> ${chalk.cyan('skills-list')} + <@session> ${chalk.cyan('skills-get')} [--raw] <@session> ${chalk.cyan('tasks-list')} <@session> ${chalk.cyan('tasks-get')} <@session> ${chalk.cyan('tasks-result')} @@ -1188,6 +1191,69 @@ ${toolsCallCombinedJsonHelp}` await resources.listResourceTemplates(session, getOptionsFromCommand(command)); }); + // Skills commands (experimental MCP extension: io.modelcontextprotocol/skills) + // Sugar over resources-read using the `skill://` URI convention. + // Spec: https://github.com/modelcontextprotocol/experimental-ext-skills + program + .command('skills-list') + .description('List Agent Skills exposed by the server (experimental MCP extension).') + .addHelpText( + 'after', + ` +${chalk.bold('How discovery works:')} + Skills are not a new MCP primitive — they are markdown documents (SKILL.md + with YAML frontmatter) served as resources under \`skill://\` URIs. mcpc + first tries to read \`skill://index.json\`; if absent, it scans the server's + resources for \`skill://*/SKILL.md\` entries. + +${chalk.bold('Examples:')} + mcpc ${session} skills-list + mcpc ${session} skills-list --json | jq '.[].name' +${jsonHelp( + 'Array of `Skill` objects', + '`[{ name: string, description: string, type?: "skill-md" | "mcp-resource-template", url: string }, ...]`', + 'https://github.com/modelcontextprotocol/experimental-ext-skills/blob/main/docs/sep-draft-skills-extension.md' +)}` + ) + .action(async (_options, command) => { + await skills.listSkills(session, getOptionsFromCommand(command)); + }); + + program + .command('skills-get ') + .description('Read an Agent Skill (SKILL.md) by name (experimental MCP extension).') + .option('--raw', 'Print only the SKILL.md text (markdown), suitable for piping') + .addHelpText( + 'after', + ` +${chalk.bold('Name forms:')} + bare name mcpc ${session} skills-get git-workflow + → reads \`skill://git-workflow/SKILL.md\` + nested path mcpc ${session} skills-get acme/billing/refunds + → reads \`skill://acme/billing/refunds/SKILL.md\` + full URI mcpc ${session} skills-get skill://git-workflow/SKILL.md + +${chalk.bold('--raw mode:')} + Prints just the SKILL.md text content with no header or fences. Useful for + loading skill content into an LLM context (e.g. cat-style piping): + mcpc ${session} skills-get git-workflow --raw > /tmp/skill.md + +${jsonHelp( + '`ReadResourceResult` object', + '`{ contents: [{ uri, mimeType?, text? | blob? }] }`', + `${SCHEMA_BASE}#readresourceresult` +)} In --json mode, --raw is ignored; the full \`ReadResourceResult\` is emitted + so callers can inspect mimeType and metadata. Pull the markdown via: + mcpc ${session} skills-get --json | jq -r '.contents[0].text' +` + ) + .action(async (name, options, command) => { + await skills.getSkill(session, name, { + ...(options.raw && { raw: true }), + ...getOptionsFromCommand(command), + }); + }); + // Prompts commands program .command('prompts-list') diff --git a/src/cli/output.ts b/src/cli/output.ts index 087b92e4..dd2cdbe1 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -10,6 +10,7 @@ import type { GetPromptResult, PromptMessage, ContentBlock, + ReadResourceResult, } from '@modelcontextprotocol/sdk/types.js'; import type { OutputMode } from '../lib/index.js'; import type { @@ -809,6 +810,100 @@ export function formatResourceTemplateDetail(template: ResourceTemplate): string return lines.join('\n'); } +/** + * Skill entry as exposed by the MCP skills extension. + * Imported indirectly to avoid coupling output.ts to commands/skills.ts. + */ +interface SkillSummary { + name: string; + description: string; + type?: string; + url: string; +} + +/** + * Format a list of skills with Markdown-like display. + * Used by `skills-list` in human mode. + */ +export function formatSkills( + skills: SkillSummary[], + sessionName?: string, + options?: FormatOptions +): string { + if (skills.length === 0) { + return chalk.gray( + '(no skills found — server does not expose `skill://index.json` and no `skill://*/SKILL.md` resources are listed)' + ); + } + + const lines: string[] = []; + const bullet = chalk.dim('*'); + + lines.push(chalk.bold(`Skills (${skills.length}):`)); + for (const skill of skills) { + const typeSuffix = + skill.type && skill.type !== 'skill-md' ? ` ${chalk.gray(`[${skill.type}]`)}` : ''; + const desc = skill.description ? ` ${chalk.dim('-')} ${skill.description}` : ''; + lines.push(`${bullet} ${inBackticks(skill.name)}${typeSuffix}${desc}`); + } + + if (sessionName) { + lines.push(''); + lines.push( + `For full skill content, run \`mcpc ${sessionName} skills-get \` (use --raw for the markdown only).` + ); + } + + let output = lines.join('\n'); + if (options?.maxChars) { + output = truncateOutput(output, options.maxChars); + } + return output; +} + +/** + * Format a single skill (`skills-get` output) with the SKILL.md text inlined + * in a code block, prefixed with the resolved URI. + */ +export function formatSkillDetail( + uri: string, + result: ReadResourceResult, + options?: { maxChars?: number } +): string { + const lines: string[] = []; + lines.push(`${chalk.bold('Skill:')} ${inBackticks(uri)}`); + + let body: string | undefined; + let mimeType: string | undefined; + for (const item of result.contents) { + if ('text' in item && typeof item.text === 'string') { + body = item.text; + mimeType = item.mimeType; + break; + } + } + + if (mimeType) { + lines.push(`${chalk.bold('MIME type:')} ${chalk.yellow(mimeType)}`); + } + + if (body !== undefined) { + lines.push(''); + lines.push(chalk.gray('````')); + lines.push(body); + lines.push(chalk.gray('````')); + } else { + lines.push(''); + lines.push(chalk.gray('(skill returned non-text content)')); + } + + let output = lines.join('\n'); + if (options?.maxChars) { + output = truncateOutput(output, options.maxChars); + } + return output; +} + /** * Format a list of prompts with Markdown-like display */ @@ -1405,6 +1500,17 @@ export function formatServerDetails( capabilityList.push(`${bullet} tasks${featureStr}`); } + // Experimental extension: io.modelcontextprotocol/skills + // Reported under capabilities.extensions; SDK types it as a passthrough object. + const extensions = (capabilities as { extensions?: Record } | undefined) + ?.extensions; + const hasSkillsExtension = + !!extensions && + Object.prototype.hasOwnProperty.call(extensions, 'io.modelcontextprotocol/skills'); + if (hasSkillsExtension) { + capabilityList.push(`${bullet} skills ${chalk.gray('(experimental extension)')}`); + } + if (capabilityList.length > 0) { lines.push(capabilityList.join('\n')); } else { @@ -1445,6 +1551,14 @@ export function formatServerDetails( commands.push(`${bullet} ${bt}mcpc ${target} resources-read ${bt}`); } + // Surface skills commands when the server advertises the extension, OR + // unconditionally as a hint when resources are supported (the spec lets a + // server expose `skill://*` resources without advertising the extension). + if (hasSkillsExtension) { + commands.push(`${bullet} ${bt}mcpc ${target} skills-list${bt}`); + commands.push(`${bullet} ${bt}mcpc ${target} skills-get [--raw]${bt}`); + } + if (capabilities?.prompts) { commands.push(`${bullet} ${bt}mcpc ${target} prompts-list${bt}`); commands.push( diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 09c334ed..18483887 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -104,6 +104,8 @@ export const KNOWN_SESSION_COMMANDS = [ 'resources-subscribe', 'resources-unsubscribe', 'resources-templates-list', + 'skills-list', + 'skills-get', 'prompts-list', 'prompts-get', 'logging-set-level', @@ -167,6 +169,7 @@ export function suggestCommand( tools: 'tools-list', resources: 'resources-list', prompts: 'prompts-list', + skills: 'skills-list', }; const prefixSuggestion = prefixSuggestions[normalized]; if (prefixSuggestion && commands.includes(prefixSuggestion)) { From 56fd24c8171102067b156a5c6890e12d907a7009 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 23:14:10 +0000 Subject: [PATCH 2/9] Add unit and e2e tests for the skills extension Coverage: - Unit: parseIndex (well-formed, malformed, drops bad entries, JSON errors), skillsFromResources (URI matching, nested paths, ignores non-skill-md files), resolveSkillUri (bare names, nested paths, full URIs, trailing slashes), extractTextContent, discoverSkills (index path, fallback path, paginated resource scan) - Unit: formatSkills (count, descriptions, types, empty state), formatSkillDetail (markdown body, missing MIME, blob fallback, truncation), formatServerDetails capability surfacing under both `capabilities.extensions` (per spec) and `capabilities.experimental` (SDK-preserved escape hatch) - Unit: parser suggests `skills-list` for `skills`/`list-skills` - E2E: opt-in `WITH_SKILLS=true` server flag exposes skill:// resources and advertises the extension capability. Three scenarios covered: index-path discovery, no-index fallback (`SKILLS_NO_INDEX=true`), and no-extension server. Verifies --json shapes, --raw mode, nested skill paths, full-URI input, and that --json+--raw still emits the full ReadResourceResult so `jq -r '.contents[0].text'` works. Implementation tweak: capability detection now also accepts `capabilities.experimental[skills]` since current MCP SDKs strip unknown capability fields like `extensions`. Test server advertises under both keys for forward compatibility. Internal helpers (parseIndex, skillsFromResources, extractTextContent, discoverSkills) are exported with `@internal` markers for testability. --- CHANGELOG.md | 2 +- src/cli/commands/skills.ts | 16 +- src/cli/output.ts | 20 +- test/e2e/server/index.ts | 154 +++++++++- test/e2e/suites/basic/skills.test.sh | 255 ++++++++++++++++ test/unit/cli/output.test.ts | 200 +++++++++++++ test/unit/cli/parser.test.ts | 7 +- test/unit/cli/skills.test.ts | 420 +++++++++++++++++++++++++++ 8 files changed, 1061 insertions(+), 13 deletions(-) create mode 100755 test/e2e/suites/basic/skills.test.sh create mode 100644 test/unit/cli/skills.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ff42882..96d7130f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- New `skills-list` and `skills-get` commands implementing the experimental MCP skills extension (`io.modelcontextprotocol/skills`, [SEP-2640](https://github.com/modelcontextprotocol/experimental-ext-skills)). Skills are discovered via the well-known `skill://index.json` resource, falling back to scanning `skill://*/SKILL.md` URIs. `skills-get ` reads a skill's `SKILL.md`; pass `--raw` to print just the markdown for piping to LLMs or files. The session overview now lists `skills` under capabilities when a server advertises the extension. JSON shape: `[{ name, description, type, url }]` for `skills-list`, full `ReadResourceResult` for `skills-get`. +- New `skills-list` and `skills-get` commands implementing the experimental MCP skills extension (`io.modelcontextprotocol/skills`, [SEP-2640](https://github.com/modelcontextprotocol/experimental-ext-skills)). Skills are discovered via the well-known `skill://index.json` resource, falling back to scanning `skill://*/SKILL.md` URIs. `skills-get ` reads a skill's `SKILL.md`; pass `--raw` to print just the markdown for piping to LLMs or files. The session overview now lists `skills` under capabilities when a server advertises the extension under either `capabilities.extensions` (per the spec) or `capabilities.experimental` (the SDK-preserved escape hatch). JSON shape: `[{ name, description, type, url }]` for `skills-list`, full `ReadResourceResult` for `skills-get`. - New `npm run test:conformance` script (and on-demand `Conformance` GitHub Actions workflow) that runs the `@modelcontextprotocol/conformance` framework against mcpc to verify adherence to the MCP specification. The conformance adapter now covers the `initialize`, `tools_call`, and `sse-retry` client scenarios and exercises a broader set of mcpc sub-commands (`tools-list`, `tools-get`, `tools-call` with and without `--task`, `ping`, `logging-set-level`, `resources-list`, `resources-templates-list`, `prompts-list`) against the conformance test server. - `mcpc connect` (with no arguments) now auto-discovers standard MCP config files (`.mcp.json`, `mcp.json`, `mcp_config.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `.kiro/settings/mcp.json`, `~/.claude.json`, `~/.codeium/windsurf/mcp_config.json`, VS Code app config, Claude Desktop config, etc.) in the current directory and home directory, and connects every server defined across them. Entries with duplicate session names across files are deduplicated (project-scoped files win over global ones). Config files using VS Code's `"servers"` key (instead of `"mcpServers"`) are also supported. - `mcpc connect` auto-connects to `mcp.apify.com` as `@apify` when the `APIFY_API_TOKEN` environment variable is set, using it as a Bearer token. Existing live sessions are reused without restart. diff --git a/src/cli/commands/skills.ts b/src/cli/commands/skills.ts index 8aa303fa..20cb9c87 100644 --- a/src/cli/commands/skills.ts +++ b/src/cli/commands/skills.ts @@ -66,8 +66,10 @@ interface RawIndex { /** * Extract the readable text from a `ReadResourceResult`. Skills resources are * always text (`text/markdown` or `application/json`), so we ignore blobs. + * + * @internal exported for tests */ -function extractTextContent(result: ReadResourceResult): string | undefined { +export function extractTextContent(result: ReadResourceResult): string | undefined { for (const item of result.contents) { if ('text' in item && typeof item.text === 'string') { return item.text; @@ -80,8 +82,10 @@ function extractTextContent(result: ReadResourceResult): string | undefined { * Parse and validate a discovery index into a list of `Skill` objects. Drops * malformed entries silently rather than failing — the spec instructs hosts * to be permissive about what they accept. + * + * @internal exported for tests */ -function parseIndex(text: string): Skill[] { +export function parseIndex(text: string): Skill[] { let parsed: unknown; try { parsed = JSON.parse(text); @@ -122,8 +126,10 @@ function parseIndex(text: string): Skill[] { /** * Fallback discovery: scan the server's resource list for SKILL.md files * under any `skill://...` prefix. Used when the well-known index is absent. + * + * @internal exported for tests */ -function skillsFromResources(resources: Resource[]): Skill[] { +export function skillsFromResources(resources: Resource[]): Skill[] { // Match `skill:///SKILL.md` const pattern = /^skill:\/\/((?:[^/]+\/)*[^/]+)\/SKILL\.md$/; @@ -156,8 +162,10 @@ function skillsFromResources(resources: Resource[]): Skill[] { * * The spec requires that hosts MUST NOT treat an absent index as proof a * server has no skills, hence the fallback. + * + * @internal exported for tests */ -async function discoverSkills(client: IMcpClient): Promise { +export async function discoverSkills(client: IMcpClient): Promise { try { const indexResult = await client.readResource(SKILLS_INDEX_URI); const text = extractTextContent(indexResult); diff --git a/src/cli/output.ts b/src/cli/output.ts index dd2cdbe1..2991c6b1 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1500,13 +1500,21 @@ export function formatServerDetails( capabilityList.push(`${bullet} tasks${featureStr}`); } - // Experimental extension: io.modelcontextprotocol/skills - // Reported under capabilities.extensions; SDK types it as a passthrough object. - const extensions = (capabilities as { extensions?: Record } | undefined) - ?.extensions; + // Experimental extension: io.modelcontextprotocol/skills (SEP-2640). + // The spec advertises under `capabilities.extensions`, but the current MCP + // SDK strips unknown capability fields. The SDK does preserve + // `capabilities.experimental` — the long-standing escape hatch for + // non-standard capabilities — so we check both locations to support + // today's servers and forward-compatible SDKs. + const capsAny = capabilities as + | { extensions?: Record; experimental?: Record } + | undefined; + const SKILLS_KEY = 'io.modelcontextprotocol/skills'; const hasSkillsExtension = - !!extensions && - Object.prototype.hasOwnProperty.call(extensions, 'io.modelcontextprotocol/skills'); + (!!capsAny?.extensions && + Object.prototype.hasOwnProperty.call(capsAny.extensions, SKILLS_KEY)) || + (!!capsAny?.experimental && + Object.prototype.hasOwnProperty.call(capsAny.experimental, SKILLS_KEY)); if (hasSkillsExtension) { capabilityList.push(`${bullet} skills ${chalk.gray('(experimental extension)')}`); } diff --git a/test/e2e/server/index.ts b/test/e2e/server/index.ts index 8e6f2ef8..8851ab80 100644 --- a/test/e2e/server/index.ts +++ b/test/e2e/server/index.ts @@ -10,6 +10,12 @@ * NO_TOOLS - disable tools capability (default: false) * NO_RESOURCES - disable resources capability (default: false) * NO_PROMPTS - disable prompts capability (default: false) + * WITH_SKILLS - enable the io.modelcontextprotocol/skills extension and + * expose skill:// resources (default: false; opt-in to avoid skewing + * resource counts in non-skills tests) + * SKILLS_NO_INDEX - serve skill files but no skill://index.json (default: false, + * used to exercise the resource-scan fallback path; only meaningful when + * WITH_SKILLS=true) * * Control endpoints (for test manipulation): * GET /health - health check @@ -48,6 +54,8 @@ const REQUIRE_AUTH = process.env.REQUIRE_AUTH === 'true'; const NO_TOOLS = process.env.NO_TOOLS === 'true'; const NO_RESOURCES = process.env.NO_RESOURCES === 'true'; const NO_PROMPTS = process.env.NO_PROMPTS === 'true'; +const WITH_SKILLS = process.env.WITH_SKILLS === 'true'; +const SKILLS_NO_INDEX = process.env.SKILLS_NO_INDEX === 'true'; // Control state (manipulated via /control/* endpoints) let failNextCount = 0; @@ -170,6 +178,119 @@ const RESOURCE_TEMPLATES = [ }, ]; +// Skills (experimental MCP extension: io.modelcontextprotocol/skills, SEP-2640) +// Each skill is served as one or more `skill://...` resources. The resource +// list always includes the skill file entries; the well-known +// `skill://index.json` is included only when SKILLS_NO_INDEX is unset, so +// tests can exercise both the index path and the resource-scan fallback. + +const SKILL_GIT_BODY = `--- +name: git-workflow +description: Helpers for everyday Git workflows +--- + +# Git workflow + +Stash, commit, push. The usual. +`; + +const SKILL_REFUNDS_BODY = `--- +name: refunds +description: How acme processes refund requests +--- + +# Refunds + +Acme's refund flow lives at \`acme/billing/refunds\`. +`; + +// Extra non-SKILL.md file under a skill prefix — used to verify that the +// resource-scan fallback only picks up SKILL.md entries. +const SKILL_GIT_NOTES_BODY = `# Notes + +Reference notes for the git-workflow skill. +`; + +const SKILL_INDEX_BODY = JSON.stringify( + { + $schema: 'https://schemas.agentskills.io/discovery/0.2.0/schema.json', + skills: [ + { + name: 'git-workflow', + type: 'skill-md', + description: 'Helpers for everyday Git workflows', + url: 'skill://git-workflow/SKILL.md', + }, + { + name: 'refunds', + type: 'skill-md', + description: 'How acme processes refund requests', + url: 'skill://acme/billing/refunds/SKILL.md', + }, + ], + }, + null, + 2 +); + +// Skill file resources always exposed (when NO_SKILLS is unset) +const SKILL_FILE_RESOURCES = [ + { + uri: 'skill://git-workflow/SKILL.md', + name: 'git-workflow', + description: 'Helpers for everyday Git workflows', + mimeType: 'text/markdown', + }, + { + uri: 'skill://acme/billing/refunds/SKILL.md', + name: 'refunds', + description: 'How acme processes refund requests', + mimeType: 'text/markdown', + }, + { + uri: 'skill://git-workflow/references/notes.md', + name: 'git-workflow notes', + description: 'Supporting notes for git-workflow', + mimeType: 'text/markdown', + }, +]; + +const SKILL_INDEX_RESOURCE = { + uri: 'skill://index.json', + name: 'Skills index', + description: 'Skills discovery index (SEP-2640)', + mimeType: 'application/json', +}; + +// Compute the effective skills resource list and content map at startup. +const SKILLS_RESOURCES: Array<{ + uri: string; + name?: string; + description?: string; + mimeType?: string; +}> = !WITH_SKILLS + ? [] + : SKILLS_NO_INDEX + ? [...SKILL_FILE_RESOURCES] + : [SKILL_INDEX_RESOURCE, ...SKILL_FILE_RESOURCES]; + +const SKILL_CONTENTS: Record = !WITH_SKILLS + ? {} + : { + 'skill://git-workflow/SKILL.md': { mimeType: 'text/markdown', text: SKILL_GIT_BODY }, + 'skill://acme/billing/refunds/SKILL.md': { + mimeType: 'text/markdown', + text: SKILL_REFUNDS_BODY, + }, + 'skill://git-workflow/references/notes.md': { + mimeType: 'text/markdown', + text: SKILL_GIT_NOTES_BODY, + }, + ...(SKILLS_NO_INDEX + ? {} + : { 'skill://index.json': { mimeType: 'application/json', text: SKILL_INDEX_BODY } }), + }; + const PROMPTS = [ { name: 'greeting', @@ -253,6 +374,22 @@ function createMcpServer(): Server { if (!NO_PROMPTS) { capabilities.prompts = { listChanged: true }; } + // Advertise the experimental skills extension when skill resources are exposed. + // SEP-2640 specifies `capabilities.extensions`, but current MCP SDKs strip + // unknown capability fields. We also publish under `capabilities.experimental` + // (the standard SDK-preserved escape hatch) so clients can detect the + // extension today regardless of SDK version. + if (WITH_SKILLS && !NO_RESOURCES) { + const SKILLS_KEY = 'io.modelcontextprotocol/skills'; + capabilities.extensions = { + ...((capabilities.extensions as Record) || {}), + [SKILLS_KEY]: {}, + }; + capabilities.experimental = { + ...((capabilities.experimental as Record) || {}), + [SKILLS_KEY]: {}, + }; + } const server = new Server( { @@ -440,7 +577,10 @@ function createMcpServer(): Server { throw new Error('Simulated failure'); } - const { items, nextCursor } = paginate(RESOURCES, request.params?.cursor); + // Combine standard test resources with skill resources (when enabled) + // so listResources can drive the skills resource-scan fallback path. + const all = [...RESOURCES, ...SKILLS_RESOURCES]; + const { items, nextCursor } = paginate(all, request.params?.cursor); return { resources: items, nextCursor }; }); @@ -486,6 +626,15 @@ function createMcpServer(): Server { }; } + // Skill resources (SEP-2640). May include the well-known + // skill://index.json plus per-skill SKILL.md files. + const skillContent = SKILL_CONTENTS[uri]; + if (skillContent) { + return { + contents: [{ uri, mimeType: skillContent.mimeType, text: skillContent.text }], + }; + } + throw new Error(`Resource not found: ${uri}`); }); } // end if (!NO_RESOURCES) @@ -725,6 +874,9 @@ async function main() { if (NO_TOOLS) console.log(` Tools: DISABLED`); if (NO_RESOURCES) console.log(` Resources: DISABLED`); if (NO_PROMPTS) console.log(` Prompts: DISABLED`); + if (WITH_SKILLS) { + console.log(` Skills: ENABLED${SKILLS_NO_INDEX ? ' (index OFF, fallback only)' : ''}`); + } }); // Graceful shutdown diff --git a/test/e2e/suites/basic/skills.test.sh b/test/e2e/suites/basic/skills.test.sh new file mode 100755 index 00000000..30530f4a --- /dev/null +++ b/test/e2e/suites/basic/skills.test.sh @@ -0,0 +1,255 @@ +#!/bin/bash +# Test: Skills extension (SEP-2640, io.modelcontextprotocol/skills) +# Tests skills-list, skills-get, --raw mode, --json shapes, and the +# resource-scan fallback when skill://index.json is absent. + +source "$(dirname "$0")/../../lib/framework.sh" +test_init "basic/skills" + +# ============================================================================= +# Scenario 1: server with skill://index.json (default) +# ============================================================================= + +start_test_server WITH_SKILLS=true + +SESSION=$(session_name "skills") + +test_case "setup: connect to server with skills extension" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" +assert_success +_SESSIONS_CREATED+=("$SESSION") +test_pass + +# ----------------------------------------------------------------------------- +# Capability surfacing in session overview +# ----------------------------------------------------------------------------- + +test_case "session overview lists skills under capabilities" +run_mcpc "$SESSION" +assert_success +assert_contains "$STDOUT" "skills (experimental extension)" +test_pass + +test_case "session overview lists skills-list/skills-get commands" +run_mcpc "$SESSION" +assert_success +assert_contains "$STDOUT" "skills-list" +assert_contains "$STDOUT" "skills-get" +test_pass + +# ----------------------------------------------------------------------------- +# skills-list (index path) +# ----------------------------------------------------------------------------- + +test_case "skills-list returns skills from index" +run_xmcpc "$SESSION" skills-list +assert_success +assert_not_empty "$STDOUT" +assert_contains "$STDOUT" "git-workflow" +assert_contains "$STDOUT" "refunds" +test_pass + +test_case "skills-list human output shows count and descriptions" +run_mcpc "$SESSION" skills-list +assert_success +assert_contains "$STDOUT" "Skills (2):" +assert_contains "$STDOUT" "Helpers for everyday Git workflows" +assert_contains "$STDOUT" "How acme processes refund requests" +test_pass + +test_case "skills-list human output includes a hint to skills-get" +run_mcpc "$SESSION" skills-list +assert_success +assert_contains "$STDOUT" "skills-get" +assert_contains "$STDOUT" "--raw" +test_pass + +test_case "skills-list --json returns valid array of Skill objects" +run_mcpc --json "$SESSION" skills-list +assert_success +assert_json_valid "$STDOUT" +assert_json "$STDOUT" '. | type == "array"' +assert_json "$STDOUT" '. | length == 2' +# Each entry has the SEP-2640 fields +assert_json "$STDOUT" '.[0].name' +assert_json "$STDOUT" '.[0].description' +assert_json "$STDOUT" '.[0].url' +assert_json "$STDOUT" '.[0].type == "skill-md"' +test_pass + +test_case "skills-list --json contains expected URIs" +run_mcpc --json "$SESSION" skills-list +assert_success +assert_json "$STDOUT" '[.[] | .url] | any(. == "skill://git-workflow/SKILL.md")' +assert_json "$STDOUT" '[.[] | .url] | any(. == "skill://acme/billing/refunds/SKILL.md")' +test_pass + +# ----------------------------------------------------------------------------- +# skills-get (bare name, nested path, full URI, --raw, --json) +# ----------------------------------------------------------------------------- + +test_case "skills-get by bare name reads SKILL.md" +run_xmcpc "$SESSION" skills-get git-workflow +assert_success +assert_contains "$STDOUT" "skill://git-workflow/SKILL.md" +assert_contains "$STDOUT" "name: git-workflow" +assert_contains "$STDOUT" "Git workflow" +test_pass + +test_case "skills-get by nested path resolves to skill:///SKILL.md" +run_xmcpc "$SESSION" skills-get acme/billing/refunds +assert_success +assert_contains "$STDOUT" "skill://acme/billing/refunds/SKILL.md" +assert_contains "$STDOUT" "Acme's refund flow" +test_pass + +test_case "skills-get by full skill:// URI works" +run_mcpc "$SESSION" skills-get "skill://git-workflow/SKILL.md" +assert_success +assert_contains "$STDOUT" "name: git-workflow" +test_pass + +test_case "skills-get --raw prints just the markdown body" +run_mcpc "$SESSION" skills-get git-workflow --raw +assert_success +# No mcpc-added headers / fences in --raw mode +assert_not_contains "$STDOUT" "Skill:" +assert_not_contains "$STDOUT" "MIME type:" +assert_not_contains "$STDOUT" '````' +# Markdown body is present +assert_contains "$STDOUT" "name: git-workflow" +assert_contains "$STDOUT" "# Git workflow" +test_pass + +test_case "skills-get --json returns full ReadResourceResult" +run_mcpc --json "$SESSION" skills-get git-workflow +assert_success +assert_json_valid "$STDOUT" +assert_json "$STDOUT" '.contents | type == "array"' +assert_json "$STDOUT" '.contents[0].uri == "skill://git-workflow/SKILL.md"' +assert_json "$STDOUT" '.contents[0].mimeType == "text/markdown"' +assert_json "$STDOUT" '.contents[0].text | type == "string"' +test_pass + +test_case "skills-get --json with --raw still emits full ReadResourceResult" +# --raw is a human-mode convenience; in --json mode the structured payload +# is what callers want, so --raw is ignored (documented in --help). +run_mcpc --json "$SESSION" skills-get git-workflow --raw +assert_success +assert_json_valid "$STDOUT" +assert_json "$STDOUT" '.contents[0].uri == "skill://git-workflow/SKILL.md"' +test_pass + +test_case "skills-get unknown skill fails" +run_mcpc "$SESSION" skills-get does-not-exist +assert_failure +test_pass + +# ----------------------------------------------------------------------------- +# Cleanup scenario 1 +# ----------------------------------------------------------------------------- + +test_case "cleanup: close session" +run_mcpc "$SESSION" close +assert_success +_SESSIONS_CREATED=("${_SESSIONS_CREATED[@]/$SESSION}") +test_pass + +stop_test_server + +# ============================================================================= +# Scenario 2: server WITHOUT skill://index.json — exercises the fallback path +# of scanning the resource list for skill://*/SKILL.md URIs. +# ============================================================================= + +# Reset server state and start with a different config +TEST_SERVER_PORT=0 +start_test_server WITH_SKILLS=true SKILLS_NO_INDEX=true + +SESSION_FB=$(session_name "skills-fb") + +test_case "setup: connect to server with skills (no index, fallback only)" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION_FB" --header "X-Test: true" +assert_success +_SESSIONS_CREATED+=("$SESSION_FB") +test_pass + +test_case "fallback: skills-list still finds skills via resource scan" +run_mcpc "$SESSION_FB" skills-list +assert_success +assert_contains "$STDOUT" "Skills" +assert_contains "$STDOUT" "git-workflow" +# The nested skill is named by its final path segment per SEP-2640 +assert_contains "$STDOUT" "refunds" +test_pass + +test_case "fallback: skills-list --json shape matches index path" +run_mcpc --json "$SESSION_FB" skills-list +assert_success +assert_json_valid "$STDOUT" +assert_json "$STDOUT" '. | type == "array"' +assert_json "$STDOUT" '. | length == 2' +# Fallback path always tags entries as "skill-md" +assert_json "$STDOUT" 'all(.[]; .type == "skill-md")' +# Non-SKILL.md files under skill:// are NOT promoted to skills +assert_json "$STDOUT" '[.[] | .url] | any(. | test("notes\\.md")) | not' +test_pass + +test_case "fallback: skills-get still works when there is no index" +run_mcpc "$SESSION_FB" skills-get git-workflow --raw +assert_success +assert_contains "$STDOUT" "# Git workflow" +test_pass + +test_case "cleanup: close fallback session" +run_mcpc "$SESSION_FB" close +assert_success +_SESSIONS_CREATED=("${_SESSIONS_CREATED[@]/$SESSION_FB}") +test_pass + +stop_test_server + +# ============================================================================= +# Scenario 3: server with skills disabled entirely +# ============================================================================= + +TEST_SERVER_PORT=0 +# WITH_SKILLS is false by default, so this is a server with no skills extension +start_test_server + +SESSION_NO=$(session_name "skills-no") + +test_case "setup: connect to server without skills" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION_NO" --header "X-Test: true" +assert_success +_SESSIONS_CREATED+=("$SESSION_NO") +test_pass + +test_case "session overview does NOT advertise skills" +run_mcpc "$SESSION_NO" +assert_success +assert_not_contains "$STDOUT" "skills (experimental extension)" +assert_not_contains "$STDOUT" "skills-list" +test_pass + +test_case "skills-list returns helpful empty message" +run_mcpc "$SESSION_NO" skills-list +assert_success +# Human-readable hint about absent index + fallback +assert_contains "$STDOUT" "no skills" +test_pass + +test_case "skills-list --json returns empty array" +run_mcpc --json "$SESSION_NO" skills-list +assert_success +assert_json_valid "$STDOUT" +assert_json "$STDOUT" '. == []' +test_pass + +test_case "cleanup: close session" +run_mcpc "$SESSION_NO" close +assert_success +_SESSIONS_CREATED=("${_SESSIONS_CREATED[@]/$SESSION_NO}") +test_pass + +test_done diff --git a/test/unit/cli/output.test.ts b/test/unit/cli/output.test.ts index 49802bc1..dc5c1820 100644 --- a/test/unit/cli/output.test.ts +++ b/test/unit/cli/output.test.ts @@ -52,6 +52,8 @@ import { formatResourceTemplateDetail, formatPrompts, formatPromptDetail, + formatSkills, + formatSkillDetail, formatSessionLine, formatHuman, logTarget, @@ -1177,6 +1179,74 @@ describe('formatServerDetails', () => { expect(output).toContain('prompts-list'); expect(output).toContain('prompts-get'); }); + + it('surfaces the skills extension when capabilities.extensions advertises it', () => { + const details: ServerDetails = { + capabilities: { + resources: {}, + // The skills extension is reported as a non-standard capability + // under `extensions["io.modelcontextprotocol/skills"]`. The mcpc + // overview should detect it and list both the capability line and + // the corresponding session commands. + extensions: { 'io.modelcontextprotocol/skills': {} }, + } as ServerDetails['capabilities'], + serverInfo: { name: 'Skills Server', version: '1.0.0' }, + }; + + const output = formatServerDetails(details, '@skills'); + + expect(output).toContain('skills (experimental extension)'); + expect(output).toContain('mcpc @skills skills-list'); + expect(output).toContain('mcpc @skills skills-get'); + }); + + it('does not surface skills when the extension is absent', () => { + const details: ServerDetails = { + capabilities: { + resources: {}, + }, + serverInfo: { name: 'Plain Server', version: '1.0.0' }, + }; + + const output = formatServerDetails(details, '@plain'); + + expect(output).not.toContain('skills (experimental extension)'); + expect(output).not.toContain('skills-list'); + expect(output).not.toContain('skills-get'); + }); + + it('does not surface skills when extensions is present but empty', () => { + const details: ServerDetails = { + capabilities: { + resources: {}, + extensions: {}, + } as ServerDetails['capabilities'], + serverInfo: { name: 'Empty Ext', version: '1.0.0' }, + }; + + const output = formatServerDetails(details, '@e'); + + expect(output).not.toContain('skills'); + }); + + it('also surfaces skills when advertised under capabilities.experimental', () => { + // The current MCP SDK strips unknown fields like `extensions` but + // preserves `experimental` — the long-standing escape hatch for + // non-standard capabilities. mcpc accepts the skills extension under + // either key for forward compatibility. + const details: ServerDetails = { + capabilities: { + resources: {}, + experimental: { 'io.modelcontextprotocol/skills': {} }, + } as ServerDetails['capabilities'], + serverInfo: { name: 'Experimental Skills', version: '1.0.0' }, + }; + + const output = formatServerDetails(details, '@exp'); + + expect(output).toContain('skills (experimental extension)'); + expect(output).toContain('mcpc @exp skills-list'); + }); }); describe('formatResources', () => { @@ -1435,6 +1505,136 @@ describe('formatPromptDetail', () => { }); }); +describe('formatSkills', () => { + it('formats a list of skills with name and description', () => { + const skills = [ + { + name: 'git-workflow', + description: 'Helpers for Git workflows', + type: 'skill-md', + url: 'skill://git-workflow/SKILL.md', + }, + { + name: 'pdf', + description: 'PDF processing skill', + type: 'skill-md', + url: 'skill://pdf/SKILL.md', + }, + ]; + + const output = formatSkills(skills, '@test'); + + expect(output).toContain('Skills (2):'); + expect(output).toContain('git-workflow'); + expect(output).toContain('Helpers for Git workflows'); + expect(output).toContain('pdf'); + expect(output).toContain('PDF processing skill'); + // skill-md is the default; not surfaced + expect(output).not.toContain('[skill-md]'); + // Hint references the session + expect(output).toContain('mcpc @test skills-get'); + expect(output).toContain('--raw'); + }); + + it('flags non-default types like mcp-resource-template', () => { + const skills = [ + { + name: 'paramd', + description: 'Parameterized', + type: 'mcp-resource-template', + url: 'skill://paramd/{id}/SKILL.md', + }, + ]; + const output = formatSkills(skills, '@test'); + expect(output).toContain('[mcp-resource-template]'); + }); + + it('returns a helpful empty message when no skills are found', () => { + const output = formatSkills([], '@test'); + expect(output).toContain('no skills found'); + // Mentions both discovery paths so users know what to expect + expect(output).toContain('skill://index.json'); + expect(output).toContain('SKILL.md'); + }); + + it('omits the get hint when no session is provided', () => { + const skills = [ + { + name: 'x', + description: 'y', + type: 'skill-md', + url: 'skill://x/SKILL.md', + }, + ]; + const output = formatSkills(skills); + expect(output).toContain('Skills (1):'); + expect(output).not.toContain('skills-get'); + }); + + it('handles skills without descriptions', () => { + const skills = [ + { + name: 'minimal', + description: '', + type: 'skill-md', + url: 'skill://minimal/SKILL.md', + }, + ]; + const output = formatSkills(skills); + expect(output).toContain('minimal'); + // Should not produce a stray dash when description is empty + expect(output).not.toMatch(/`minimal`.*-\s*$/m); + }); +}); + +describe('formatSkillDetail', () => { + it('renders a skill with markdown body in a code fence', () => { + const result = { + contents: [ + { + uri: 'skill://git-workflow/SKILL.md', + mimeType: 'text/markdown', + text: '---\nname: git-workflow\ndescription: Helpers\n---\n\n# Body', + }, + ], + }; + + const output = formatSkillDetail('skill://git-workflow/SKILL.md', result); + expect(output).toContain('Skill:'); + expect(output).toContain('skill://git-workflow/SKILL.md'); + expect(output).toContain('text/markdown'); + expect(output).toContain('````'); + expect(output).toContain('# Body'); + expect(output).toContain('name: git-workflow'); + }); + + it('omits MIME type when not provided', () => { + const result = { + contents: [{ uri: 'skill://x/SKILL.md', text: 'body' }], + }; + const output = formatSkillDetail('skill://x/SKILL.md', result); + expect(output).not.toContain('MIME type:'); + expect(output).toContain('body'); + }); + + it('shows a placeholder when there is no text content', () => { + const result = { + contents: [{ uri: 'skill://x/SKILL.md', blob: 'aGk=', mimeType: 'application/octet-stream' }], + }; + const output = formatSkillDetail('skill://x/SKILL.md', result); + expect(output).toContain('non-text content'); + }); + + it('truncates the body when maxChars is provided', () => { + const result = { + contents: [{ uri: 'skill://x/SKILL.md', text: 'A'.repeat(10000) }], + }; + const output = formatSkillDetail('skill://x/SKILL.md', result, { maxChars: 200 }); + expect(output.length).toBeLessThan(500); + expect(output).toContain('output truncated'); + }); +}); + describe('formatHuman with GetPromptResult', () => { it('should format single text message with backticks', () => { const result = { diff --git a/test/unit/cli/parser.test.ts b/test/unit/cli/parser.test.ts index 709f4ad8..9517deb7 100644 --- a/test/unit/cli/parser.test.ts +++ b/test/unit/cli/parser.test.ts @@ -514,6 +514,8 @@ describe('suggestCommand', () => { 'tools-call', 'resources-list', 'resources-read', + 'skills-list', + 'skills-get', 'prompts-list', 'prompts-get', 'connect', @@ -525,15 +527,18 @@ describe('suggestCommand', () => { expect(suggestCommand('list-tools', commands)).toBe('tools-list'); expect(suggestCommand('list-resources', commands)).toBe('resources-list'); expect(suggestCommand('list-prompts', commands)).toBe('prompts-list'); + expect(suggestCommand('list-skills', commands)).toBe('skills-list'); }); - it('suggests list command for bare prefix (tools, resources, prompts)', () => { + it('suggests list command for bare prefix (tools, resources, prompts, skills)', () => { expect(suggestCommand('tools', commands)).toBe('tools-list'); expect(suggestCommand('resources', commands)).toBe('resources-list'); expect(suggestCommand('prompts', commands)).toBe('prompts-list'); + expect(suggestCommand('skills', commands)).toBe('skills-list'); // Case-insensitive expect(suggestCommand('TOOLS', commands)).toBe('tools-list'); expect(suggestCommand('Resources', commands)).toBe('resources-list'); + expect(suggestCommand('Skills', commands)).toBe('skills-list'); }); it('suggests the closest match for typos', () => { diff --git a/test/unit/cli/skills.test.ts b/test/unit/cli/skills.test.ts new file mode 100644 index 00000000..e07d2a68 --- /dev/null +++ b/test/unit/cli/skills.test.ts @@ -0,0 +1,420 @@ +/** + * Tests for the skills command module — implementation of the experimental + * MCP skills extension (SEP-2640). + */ + +// Mock chalk to return plain strings (Jest can't handle chalk's ESM imports) +jest.mock('chalk', () => ({ + default: { + cyan: (s: string) => s, + yellow: (s: string) => s, + red: (s: string) => s, + dim: (s: string) => s, + gray: (s: string) => s, + bold: (s: string) => s, + green: (s: string) => s, + greenBright: (s: string) => s, + blue: (s: string) => s, + magenta: (s: string) => s, + white: (s: string) => s, + }, + cyan: (s: string) => s, + yellow: (s: string) => s, + red: (s: string) => s, + dim: (s: string) => s, + gray: (s: string) => s, + bold: (s: string) => s, + green: (s: string) => s, + greenBright: (s: string) => s, + blue: (s: string) => s, + magenta: (s: string) => s, + white: (s: string) => s, +})); + +// Mock sessions module to avoid loading session state during import +jest.mock('../../../src/lib/sessions.js', () => ({ + getSession: jest.fn().mockResolvedValue(null), +})); + +import type { ReadResourceResult, Resource } from '@modelcontextprotocol/sdk/types.js'; + +import { + SKILLS_INDEX_URI, + SKILLS_EXTENSION_KEY, + resolveSkillUri, + parseIndex, + skillsFromResources, + extractTextContent, + discoverSkills, +} from '../../../src/cli/commands/skills.js'; +import { ServerError } from '../../../src/lib/errors.js'; + +describe('skills constants', () => { + it('matches the spec', () => { + expect(SKILLS_INDEX_URI).toBe('skill://index.json'); + expect(SKILLS_EXTENSION_KEY).toBe('io.modelcontextprotocol/skills'); + }); +}); + +describe('resolveSkillUri', () => { + it('resolves a bare name to skill:///SKILL.md', () => { + expect(resolveSkillUri('git-workflow')).toBe('skill://git-workflow/SKILL.md'); + }); + + it('resolves a nested path', () => { + expect(resolveSkillUri('acme/billing/refunds')).toBe('skill://acme/billing/refunds/SKILL.md'); + }); + + it('passes through a full skill:// URI ending in a filename', () => { + expect(resolveSkillUri('skill://git-workflow/SKILL.md')).toBe('skill://git-workflow/SKILL.md'); + }); + + it('passes through a non-SKILL.md file URI unchanged', () => { + expect(resolveSkillUri('skill://pdf/references/FORMS.md')).toBe( + 'skill://pdf/references/FORMS.md' + ); + }); + + it('appends SKILL.md when given a skill:// directory URI', () => { + expect(resolveSkillUri('skill://git-workflow')).toBe('skill://git-workflow/SKILL.md'); + expect(resolveSkillUri('skill://acme/billing')).toBe('skill://acme/billing/SKILL.md'); + }); + + it('appends SKILL.md when given a trailing-slash skill:// URI', () => { + expect(resolveSkillUri('skill://git-workflow/')).toBe('skill://git-workflow/SKILL.md'); + }); + + it('strips surrounding slashes from bare paths', () => { + expect(resolveSkillUri('/git-workflow/')).toBe('skill://git-workflow/SKILL.md'); + }); + + it('trims surrounding whitespace', () => { + expect(resolveSkillUri(' git-workflow ')).toBe('skill://git-workflow/SKILL.md'); + }); + + it('throws on empty input', () => { + expect(() => resolveSkillUri('')).toThrow(); + expect(() => resolveSkillUri(' ')).toThrow(); + }); + + it('throws when bare name resolves to nothing after stripping slashes', () => { + expect(() => resolveSkillUri('//')).toThrow(); + }); +}); + +describe('parseIndex', () => { + it('parses a well-formed index', () => { + const text = JSON.stringify({ + $schema: 'https://schemas.agentskills.io/discovery/0.2.0/schema.json', + skills: [ + { + name: 'git-workflow', + type: 'skill-md', + description: 'Git workflow helpers', + url: 'skill://git-workflow/SKILL.md', + }, + { + name: 'pdf', + type: 'skill-md', + description: 'Read PDFs', + url: 'skill://pdf/SKILL.md', + }, + ], + }); + + const skills = parseIndex(text); + expect(skills).toHaveLength(2); + expect(skills[0]).toEqual({ + name: 'git-workflow', + type: 'skill-md', + description: 'Git workflow helpers', + url: 'skill://git-workflow/SKILL.md', + }); + expect(skills[1]?.name).toBe('pdf'); + }); + + it('preserves the type field including mcp-resource-template', () => { + const text = JSON.stringify({ + skills: [ + { + name: 'paramd', + type: 'mcp-resource-template', + description: 'Templates', + url: 'skill://paramd/{id}/SKILL.md', + }, + ], + }); + const skills = parseIndex(text); + expect(skills[0]?.type).toBe('mcp-resource-template'); + }); + + it('omits the type field when absent', () => { + const text = JSON.stringify({ + skills: [{ name: 'x', description: 'y', url: 'skill://x/SKILL.md' }], + }); + const skills = parseIndex(text); + expect(skills[0]).not.toHaveProperty('type'); + }); + + it('treats missing description as empty string', () => { + const text = JSON.stringify({ + skills: [{ name: 'x', url: 'skill://x/SKILL.md' }], + }); + const skills = parseIndex(text); + expect(skills[0]?.description).toBe(''); + }); + + it('drops entries missing required name or url', () => { + const text = JSON.stringify({ + skills: [ + { name: 'good', description: 'ok', url: 'skill://good/SKILL.md' }, + { description: 'no name', url: 'skill://x/SKILL.md' }, + { name: 'no-url', description: 'no url' }, + null, + 'not-an-object', + { name: 123, url: 'skill://x/SKILL.md' }, // wrong type for name + ], + }); + const skills = parseIndex(text); + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe('good'); + }); + + it('returns empty list when skills field is absent or non-array', () => { + expect(parseIndex(JSON.stringify({}))).toEqual([]); + expect(parseIndex(JSON.stringify({ skills: null }))).toEqual([]); + expect(parseIndex(JSON.stringify({ skills: 'not-an-array' }))).toEqual([]); + }); + + it('throws ServerError on invalid JSON', () => { + expect(() => parseIndex('{not json')).toThrow(ServerError); + expect(() => parseIndex('{not json')).toThrow(/not valid JSON/); + }); + + it('throws ServerError when JSON is null or a primitive', () => { + expect(() => parseIndex('"hello"')).toThrow(ServerError); + expect(() => parseIndex('42')).toThrow(ServerError); + expect(() => parseIndex('null')).toThrow(ServerError); + }); + + it('treats a top-level array as an object with no skills field', () => { + // typeof [] === 'object' so the index-shape check passes, but the + // `skills` field is absent — return empty rather than throwing, since + // the spec asks hosts to be permissive about index shape. + expect(parseIndex('[]')).toEqual([]); + }); +}); + +describe('skillsFromResources', () => { + it('extracts skills from SKILL.md resource URIs', () => { + const resources: Resource[] = [ + { + uri: 'skill://git-workflow/SKILL.md', + name: 'Git Workflow', + description: 'Git helpers', + mimeType: 'text/markdown', + }, + { + uri: 'skill://pdf/SKILL.md', + name: 'PDF', + description: 'PDFs', + mimeType: 'text/markdown', + }, + ]; + const skills = skillsFromResources(resources); + expect(skills).toHaveLength(2); + expect(skills[0]).toEqual({ + name: 'Git Workflow', + description: 'Git helpers', + type: 'skill-md', + url: 'skill://git-workflow/SKILL.md', + }); + }); + + it('uses the final path segment as name when resource name is missing', () => { + const resources: Resource[] = [ + { uri: 'skill://git-workflow/SKILL.md', name: '' }, + ] as Resource[]; + const skills = skillsFromResources(resources); + expect(skills[0]?.name).toBe('git-workflow'); + }); + + it('uses the final path segment for nested skill paths', () => { + const resources: Resource[] = [{ uri: 'skill://acme/billing/refunds/SKILL.md' } as Resource]; + const skills = skillsFromResources(resources); + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe('refunds'); + expect(skills[0]?.url).toBe('skill://acme/billing/refunds/SKILL.md'); + }); + + it('ignores non-skill URIs', () => { + const resources: Resource[] = [ + { uri: 'file:///etc/hosts', name: 'hosts' } as Resource, + { uri: 'skill://git-workflow/SKILL.md', name: 'gw' } as Resource, + { uri: 'http://example.com', name: 'http' } as Resource, + ]; + const skills = skillsFromResources(resources); + expect(skills).toHaveLength(1); + expect(skills[0]?.url).toBe('skill://git-workflow/SKILL.md'); + }); + + it('ignores non-SKILL.md files under skill:// prefix', () => { + const resources: Resource[] = [ + { uri: 'skill://pdf/SKILL.md', name: 'pdf' } as Resource, + { uri: 'skill://pdf/references/FORMS.md', name: 'forms' } as Resource, + { uri: 'skill://index.json', name: 'index' } as Resource, + ]; + const skills = skillsFromResources(resources); + expect(skills).toHaveLength(1); + expect(skills[0]?.url).toBe('skill://pdf/SKILL.md'); + }); +}); + +describe('extractTextContent', () => { + it('returns the text of the first text content block', () => { + const result: ReadResourceResult = { + contents: [{ uri: 'skill://x/SKILL.md', mimeType: 'text/markdown', text: 'hello' }], + }; + expect(extractTextContent(result)).toBe('hello'); + }); + + it('returns undefined when there is no text content', () => { + const result: ReadResourceResult = { + contents: [{ uri: 'skill://x/SKILL.md', mimeType: 'application/octet-stream', blob: 'aGk=' }], + }; + expect(extractTextContent(result)).toBeUndefined(); + }); + + it('skips blob entries to find a later text entry', () => { + const result: ReadResourceResult = { + contents: [ + { uri: 'skill://x/SKILL.md', mimeType: 'application/octet-stream', blob: 'aGk=' }, + { uri: 'skill://x/extra.md', mimeType: 'text/markdown', text: 'second' }, + ], + }; + expect(extractTextContent(result)).toBe('second'); + }); +}); + +/** + * Build a minimal mock IMcpClient covering only the methods discoverSkills + * touches. Returned object is cast to IMcpClient via `unknown`. + */ +function makeMockClient(opts: { + /** Body returned from readResource(skill://index.json), or null to throw. */ + index?: string | null; + /** Resources returned by listResources (single page). */ + resources?: Resource[]; + /** Multiple pages of resources, simulating pagination. */ + resourcePages?: Array<{ resources: Resource[]; nextCursor?: string }>; +}): { + client: import('../../../src/lib/types.js').IMcpClient; + readResourceCalls: string[]; + listResourcesCalls: Array; +} { + const readResourceCalls: string[] = []; + const listResourcesCalls: Array = []; + + const readResource = jest.fn(async (uri: string): Promise => { + readResourceCalls.push(uri); + if (uri === 'skill://index.json') { + if (opts.index === null) { + throw new Error('not found'); + } + if (typeof opts.index === 'string') { + return { + contents: [{ uri, mimeType: 'application/json', text: opts.index }], + }; + } + } + throw new Error(`unexpected uri: ${uri}`); + }); + + const listResources = jest.fn(async (cursor?: string) => { + listResourcesCalls.push(cursor); + if (opts.resourcePages) { + const page = opts.resourcePages.shift(); + if (!page) return { resources: [] }; + return page; + } + return { resources: opts.resources ?? [] }; + }); + + const client = { + readResource, + listResources, + } as unknown as import('../../../src/lib/types.js').IMcpClient; + + return { client, readResourceCalls, listResourcesCalls }; +} + +describe('discoverSkills', () => { + it('returns parsed index when skill://index.json is available', async () => { + const indexBody = JSON.stringify({ + skills: [ + { + name: 'git-workflow', + type: 'skill-md', + description: 'Git helpers', + url: 'skill://git-workflow/SKILL.md', + }, + ], + }); + const { client, readResourceCalls, listResourcesCalls } = makeMockClient({ + index: indexBody, + }); + + const skills = await discoverSkills(client); + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe('git-workflow'); + + // Only the index was read; no resource fallback when index succeeds + expect(readResourceCalls).toEqual(['skill://index.json']); + expect(listResourcesCalls).toHaveLength(0); + }); + + it('falls back to scanning resources when index read throws', async () => { + const { client, readResourceCalls, listResourcesCalls } = makeMockClient({ + index: null, // throw + resources: [ + { uri: 'skill://git-workflow/SKILL.md', name: 'GW' } as Resource, + { uri: 'file:///other', name: 'other' } as Resource, + ], + }); + + const skills = await discoverSkills(client); + expect(skills).toHaveLength(1); + expect(skills[0]?.url).toBe('skill://git-workflow/SKILL.md'); + expect(readResourceCalls).toEqual(['skill://index.json']); + expect(listResourcesCalls.length).toBeGreaterThanOrEqual(1); + }); + + it('drains all pages of resources during fallback', async () => { + const { client, listResourcesCalls } = makeMockClient({ + index: null, + resourcePages: [ + { + resources: [{ uri: 'skill://a/SKILL.md', name: 'a' } as Resource], + nextCursor: 'cursor1', + }, + { + resources: [{ uri: 'skill://b/SKILL.md', name: 'b' } as Resource], + }, + ], + }); + + const skills = await discoverSkills(client); + expect(skills.map((s) => s.name).sort()).toEqual(['a', 'b']); + // Two pages consumed, with the second call passing the cursor + expect(listResourcesCalls).toEqual([undefined, 'cursor1']); + }); + + it('returns empty list when neither index nor matching resources exist', async () => { + const { client } = makeMockClient({ + index: null, + resources: [{ uri: 'file:///nope', name: 'nope' } as Resource], + }); + const skills = await discoverSkills(client); + expect(skills).toEqual([]); + }); +}); From 4a5ea6ad02ae26c82c3000f31c2fd9ca4110dd73 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 09:27:52 +0000 Subject: [PATCH 3/9] Mark skills feature EXPERIMENTAL; align with latest SEP-2640 draft CI fix: - Add `import { jest } from '@jest/globals'` to skills.test.ts. In Jest ESM mode (`useESM: true` via ts-jest), the `jest` global isn't guaranteed at module-evaluation time across Node 20/22/24; the explicit import works everywhere. (Fixes "jest is not defined" in CI.) Spec alignment with current draft of modelcontextprotocol/modelcontextprotocol#2640: - Add support for the third index entry type, `archive` (.tar.gz/.zip bundles). Treated like `skill-md` for naming (name required); type preserved in JSON output. For archive entries, users should fetch via `resources-read ` since the URL points to a binary bundle. - Skip entries with an unrecognized `type` value (spec MUST). Default missing `type` to `skill-md` for backwards compat with older drafts. Experimental labeling: - `[EXPERIMENTAL]` tag in command descriptions and in the session command listing shown by `mcpc --help`. - Dedicated "Status:" block in `skills-list --help` / `skills-get --help` explaining the spec is in draft and the shape may change. - Spec link updated to the canonical PR (#2640) instead of the experimental-ext-skills mirror. Tests: - Unit: new cases for `archive` type, unknown-type skipping, default type behavior. - E2E: server's index now includes an archive entry and an unrecognized-type entry; assertions verify archive is kept (length == 3) and the unknown type is skipped. --- CHANGELOG.md | 2 +- README.md | 4 +- src/cli/commands/skills.ts | 15 ++++++-- src/cli/index.ts | 36 ++++++++++++++---- test/e2e/server/index.ts | 14 +++++++ test/e2e/suites/basic/skills.test.sh | 11 +++++- test/unit/cli/skills.test.ts | 56 +++++++++++++++++++++++++++- 7 files changed, 120 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ae94fa..3ae2f418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- New `skills-list` and `skills-get` commands implementing the experimental MCP skills extension (`io.modelcontextprotocol/skills`, [SEP-2640](https://github.com/modelcontextprotocol/experimental-ext-skills)). Skills are discovered via the well-known `skill://index.json` resource, falling back to scanning `skill://*/SKILL.md` URIs. `skills-get ` reads a skill's `SKILL.md`; pass `--raw` to print just the markdown for piping to LLMs or files. The session overview now lists `skills` under capabilities when a server advertises the extension under either `capabilities.extensions` (per the spec) or `capabilities.experimental` (the SDK-preserved escape hatch). JSON shape: `[{ name, description, type, url }]` for `skills-list`, full `ReadResourceResult` for `skills-get`. +- New **[EXPERIMENTAL]** `skills-list` and `skills-get` commands implementing the draft MCP skills extension (`io.modelcontextprotocol/skills`, [SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640)). Marked experimental because the spec is still in draft and the index shape, recognized types, and capability key may change. Skills are discovered via the well-known `skill://index.json` resource, falling back to scanning `skill://*/SKILL.md` URIs. Supports all three SEP-2640 index entry types: `skill-md`, `archive` (`.tar.gz`/`.zip` bundles), and `mcp-resource-template`. Entries with unrecognized `type` values are skipped per spec. `skills-get ` reads a skill's `SKILL.md`; pass `--raw` to print just the markdown for piping to LLMs or files. The session overview lists `skills` under capabilities when a server advertises the extension under either `capabilities.extensions` (per spec) or `capabilities.experimental` (the SDK-preserved escape hatch). JSON shape: `[{ name, description, type, url }]` for `skills-list`, full `ReadResourceResult` for `skills-get`. - `mcpc connect` (with no arguments) now auto-discovers standard MCP config files (`.mcp.json`, `mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `~/.claude.json`, Claude Desktop, Windsurf, Kiro, etc.) in the current directory and home directory, and connects every server defined across them. Entries with duplicate session names are deduplicated (project-scoped files win over global ones). VS Code's `"servers"` key is also supported. - `mcpc connect` auto-connects to `mcp.apify.com` as `@apify` when the `APIFY_API_TOKEN` environment variable is set, using it as a Bearer token. Existing live sessions are reused without restart. diff --git a/README.md b/README.md index 768bcd4d..19115e53 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,8 @@ MCP session commands (after connecting): <@session> resources-subscribe <@session> resources-unsubscribe <@session> resources-templates-list - <@session> skills-list - <@session> skills-get [--raw] + <@session> skills-list [EXPERIMENTAL] List Agent Skills (SEP-2640) + <@session> skills-get [--raw] [EXPERIMENTAL] Read a skill's SKILL.md <@session> tasks-list <@session> tasks-get <@session> tasks-result diff --git a/src/cli/commands/skills.ts b/src/cli/commands/skills.ts index 9c108975..076e1f8d 100644 --- a/src/cli/commands/skills.ts +++ b/src/cli/commands/skills.ts @@ -117,9 +117,16 @@ export function parseIndex(text: string): Skill[] { const e = entry as RawIndexEntry; if (typeof e.url !== 'string') continue; - // SEP-2640: `name` is required for `skill-md` but optional for - // `mcp-resource-template` (parameterized namespaces). - const type = typeof e.type === 'string' ? e.type : undefined; + // SEP-2640 defines three valid `type` values. The spec says clients + // SHOULD skip entries with an unrecognized `type`. Treat an absent + // `type` as `skill-md` for backwards compatibility with older drafts. + const type = typeof e.type === 'string' ? e.type : 'skill-md'; + if (type !== 'skill-md' && type !== 'archive' && type !== 'mcp-resource-template') { + continue; + } + + // `name` is required for `skill-md` and `archive` entries, and optional + // for `mcp-resource-template` (parameterized namespaces). const isTemplate = type === 'mcp-resource-template'; let name: string; if (typeof e.name === 'string' && e.name.length > 0) { @@ -128,7 +135,7 @@ export function parseIndex(text: string): Skill[] { // Derive a display name from the template URL for nameless templates. name = displayNameFromUrl(e.url); } else { - // skill-md without a name violates the spec — drop silently. + // skill-md / archive without a name violates the spec — drop silently. continue; } diff --git a/src/cli/index.ts b/src/cli/index.ts index 75480c3f..e843a691 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -417,8 +417,8 @@ ${chalk.bold('MCP session commands (after connecting):')} <@session> ${theme.cyan('resources-subscribe')} <@session> ${theme.cyan('resources-unsubscribe')} <@session> ${theme.cyan('resources-templates-list')} - <@session> ${theme.cyan('skills-list')} - <@session> ${theme.cyan('skills-get')} [--raw] + <@session> ${theme.cyan('skills-list')} ${chalk.yellow('[EXPERIMENTAL]')} List Agent Skills (SEP-2640) + <@session> ${theme.cyan('skills-get')} [--raw] ${chalk.yellow('[EXPERIMENTAL]')} Read a skill's SKILL.md <@session> ${theme.cyan('tasks-list')} <@session> ${theme.cyan('tasks-get')} <@session> ${theme.cyan('tasks-result')} @@ -1183,26 +1183,40 @@ ${toolsCallCombinedJsonHelp}` // Skills commands (experimental MCP extension: io.modelcontextprotocol/skills) // Sugar over resources-read using the `skill://` URI convention. - // Spec: https://github.com/modelcontextprotocol/experimental-ext-skills + // Spec: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640 + // NOTE: This extension is still in draft (SEP-2640) and may change. The + // command surface here is marked EXPERIMENTAL accordingly. program .command('skills-list') - .description('List Agent Skills exposed by the server (experimental MCP extension).') + .description( + '[EXPERIMENTAL] List Agent Skills exposed by the server (MCP extension SEP-2640, may change).' + ) .addHelpText( 'after', ` +${chalk.bold('Status:')} ${chalk.yellow('EXPERIMENTAL')} — implements the draft MCP skills + extension (SEP-2640). The spec is not yet finalized; the index shape, + recognized types, and capability key may change. Use at your own risk + and pin mcpc versions if you depend on the JSON shape. + ${chalk.bold('How discovery works:')} Skills are not a new MCP primitive — they are markdown documents (SKILL.md with YAML frontmatter) served as resources under \`skill://\` URIs. mcpc first tries to read \`skill://index.json\`; if absent, it scans the server's resources for \`skill://*/SKILL.md\` entries. +${chalk.bold('Entry types (per SEP-2640):')} + skill-md concrete skill — \`skills-get \` reads SKILL.md + archive .tar.gz / .zip bundle — fetch with \`resources-read \` + mcp-resource-template parameterized namespace (RFC 6570 URI template) + ${chalk.bold('Examples:')} mcpc ${session} skills-list mcpc ${session} skills-list --json | jq '.[].name' ${jsonHelp( 'Array of `Skill` objects', - '`[{ name: string, description: string, type?: "skill-md" | "mcp-resource-template", url: string }, ...]`', - 'https://github.com/modelcontextprotocol/experimental-ext-skills/blob/main/docs/sep-draft-skills-extension.md' + '`[{ name: string, description: string, type?: "skill-md" | "archive" | "mcp-resource-template", url: string }, ...]`', + 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640' )}` ) .action(async (_options, command) => { @@ -1211,11 +1225,16 @@ ${jsonHelp( program .command('skills-get ') - .description('Read an Agent Skill (SKILL.md) by name (experimental MCP extension).') + .description( + '[EXPERIMENTAL] Read an Agent Skill (SKILL.md) by name (MCP extension SEP-2640, may change).' + ) .option('--raw', 'Print only the SKILL.md text (markdown), suitable for piping') .addHelpText( 'after', ` +${chalk.bold('Status:')} ${chalk.yellow('EXPERIMENTAL')} — implements the draft MCP skills + extension (SEP-2640). Behavior and output shape may change. + ${chalk.bold('Name forms:')} bare name mcpc ${session} skills-get git-workflow → reads \`skill://git-workflow/SKILL.md\` @@ -1223,6 +1242,9 @@ ${chalk.bold('Name forms:')} → reads \`skill://acme/billing/refunds/SKILL.md\` full URI mcpc ${session} skills-get skill://git-workflow/SKILL.md + For \`archive\` skills, pass the URL from \`skills-list\` to + \`resources-read\` instead — \`skills-get\` expects a SKILL.md target. + ${chalk.bold('--raw mode:')} Prints just the SKILL.md text content with no header or fences. Useful for loading skill content into an LLM context (e.g. cat-style piping): diff --git a/test/e2e/server/index.ts b/test/e2e/server/index.ts index 8851ab80..acc2e44e 100644 --- a/test/e2e/server/index.ts +++ b/test/e2e/server/index.ts @@ -227,6 +227,20 @@ const SKILL_INDEX_BODY = JSON.stringify( description: 'How acme processes refund requests', url: 'skill://acme/billing/refunds/SKILL.md', }, + // SEP-2640 type: bundled skill delivered as a single archive resource. + { + name: 'big-skill', + type: 'archive', + description: 'A bundled skill delivered as .tar.gz', + url: 'skill://big-skill/big-skill.tar.gz', + }, + // Entry with unrecognized type — clients SHOULD skip it. + { + name: 'future-thing', + type: 'something-not-in-spec', + description: 'Reserved for a future spec version', + url: 'skill://future-thing/SKILL.md', + }, ], }, null, diff --git a/test/e2e/suites/basic/skills.test.sh b/test/e2e/suites/basic/skills.test.sh index 30530f4a..a6c5abe0 100755 --- a/test/e2e/suites/basic/skills.test.sh +++ b/test/e2e/suites/basic/skills.test.sh @@ -52,7 +52,7 @@ test_pass test_case "skills-list human output shows count and descriptions" run_mcpc "$SESSION" skills-list assert_success -assert_contains "$STDOUT" "Skills (2):" +assert_contains "$STDOUT" "Skills (3):" assert_contains "$STDOUT" "Helpers for everyday Git workflows" assert_contains "$STDOUT" "How acme processes refund requests" test_pass @@ -69,12 +69,18 @@ run_mcpc --json "$SESSION" skills-list assert_success assert_json_valid "$STDOUT" assert_json "$STDOUT" '. | type == "array"' -assert_json "$STDOUT" '. | length == 2' +# 4 entries in the index, but the one with an unrecognized `type` is +# skipped per SEP-2640, leaving 3: 2 skill-md + 1 archive. +assert_json "$STDOUT" '. | length == 3' # Each entry has the SEP-2640 fields assert_json "$STDOUT" '.[0].name' assert_json "$STDOUT" '.[0].description' assert_json "$STDOUT" '.[0].url' assert_json "$STDOUT" '.[0].type == "skill-md"' +# `archive` entries from the index are included (with type preserved) +assert_json "$STDOUT" '[.[] | .type] | any(. == "archive")' +# Unrecognized types are skipped — `future-thing` MUST NOT appear +assert_json "$STDOUT" '[.[] | .name] | any(. == "future-thing") | not' test_pass test_case "skills-list --json contains expected URIs" @@ -82,6 +88,7 @@ run_mcpc --json "$SESSION" skills-list assert_success assert_json "$STDOUT" '[.[] | .url] | any(. == "skill://git-workflow/SKILL.md")' assert_json "$STDOUT" '[.[] | .url] | any(. == "skill://acme/billing/refunds/SKILL.md")' +assert_json "$STDOUT" '[.[] | .url] | any(. == "skill://big-skill/big-skill.tar.gz")' test_pass # ----------------------------------------------------------------------------- diff --git a/test/unit/cli/skills.test.ts b/test/unit/cli/skills.test.ts index cd302d50..ee4c0ade 100644 --- a/test/unit/cli/skills.test.ts +++ b/test/unit/cli/skills.test.ts @@ -3,6 +3,11 @@ * MCP skills extension (SEP-2640). */ +// In Jest ESM mode (`useESM: true` via ts-jest), `jest` is not always +// available as a global at module-evaluation time. Import it explicitly so +// the file works under every CI Node version we support. +import { jest } from '@jest/globals'; + // Mock chalk to return plain strings (Jest can't handle chalk's ESM imports). // Matches the mock shape used in output.test.ts — the `theme` object in // src/cli/output.ts calls chalk.hex(...) at module load, so hex must return @@ -144,12 +149,14 @@ describe('parseIndex', () => { expect(skills[0]?.type).toBe('mcp-resource-template'); }); - it('omits the type field when absent', () => { + it('defaults missing type to skill-md', () => { + // SEP-2640 says `type` is required, but for forwards-compat with older + // drafts that omitted it, mcpc treats a missing `type` as `skill-md`. const text = JSON.stringify({ skills: [{ name: 'x', description: 'y', url: 'skill://x/SKILL.md' }], }); const skills = parseIndex(text); - expect(skills[0]).not.toHaveProperty('type'); + expect(skills[0]?.type).toBe('skill-md'); }); it('treats missing description as empty string', () => { @@ -185,6 +192,51 @@ describe('parseIndex', () => { expect(skills[0]?.name).toBe('good'); }); + it('keeps `archive` entries with name (requires same as skill-md)', () => { + const text = JSON.stringify({ + skills: [ + { + name: 'big-skill', + type: 'archive', + description: 'Bundled as .tar.gz', + url: 'skill://big-skill/big-skill.tar.gz', + }, + // archive without name → dropped per spec + { + type: 'archive', + description: 'no name', + url: 'skill://x/x.tar.gz', + }, + ], + }); + const skills = parseIndex(text); + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe('big-skill'); + expect(skills[0]?.type).toBe('archive'); + }); + + it('skips entries with an unrecognized `type` (per SEP-2640)', () => { + const text = JSON.stringify({ + skills: [ + { + name: 'ok', + type: 'skill-md', + description: 'kept', + url: 'skill://ok/SKILL.md', + }, + { + name: 'bad', + type: 'something-new', + description: 'dropped', + url: 'skill://bad/SKILL.md', + }, + ], + }); + const skills = parseIndex(text); + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe('ok'); + }); + it('keeps mcp-resource-template entries without a name (spec allows it)', () => { // Per SEP-2640, `name` is required for `skill-md` entries but optional // for `mcp-resource-template` namespaces. mcpc derives a display name From 9923b06c99fed66774294a805b3094221ffe7037 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 09:30:27 +0000 Subject: [PATCH 4/9] =?UTF-8?q?Port=20skills.test.ts=20to=20Vitest=20(`jes?= =?UTF-8?q?t`=20=E2=86=92=20`vi`)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main migrated the test runner from Jest to Vitest (#236) after my last commit. Update skills.test.ts to use `vi.mock` / `vi.fn` and drop the no-longer-needed `import { jest } from '@jest/globals'`. All 632 unit tests pass under Vitest. --- test/unit/cli/skills.test.ts | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/test/unit/cli/skills.test.ts b/test/unit/cli/skills.test.ts index ee4c0ade..73bf1866 100644 --- a/test/unit/cli/skills.test.ts +++ b/test/unit/cli/skills.test.ts @@ -3,16 +3,11 @@ * MCP skills extension (SEP-2640). */ -// In Jest ESM mode (`useESM: true` via ts-jest), `jest` is not always -// available as a global at module-evaluation time. Import it explicitly so -// the file works under every CI Node version we support. -import { jest } from '@jest/globals'; - -// Mock chalk to return plain strings (Jest can't handle chalk's ESM imports). -// Matches the mock shape used in output.test.ts — the `theme` object in -// src/cli/output.ts calls chalk.hex(...) at module load, so hex must return -// a function that yields a string-passthrough callable. -jest.mock('chalk', () => { +// Mock chalk to return plain strings (the runner can't import chalk's ESM +// at module-load). Matches the mock shape used in output.test.ts — the +// `theme` object in src/cli/output.ts calls chalk.hex(...) at module load, +// so hex must return a function that yields a string-passthrough callable. +vi.mock('chalk', () => { const identity = (s: string): string => s; const hex = (): ((s: string) => string) => identity; const palette = { @@ -33,8 +28,8 @@ jest.mock('chalk', () => { }); // Mock sessions module to avoid loading session state during import -jest.mock('../../../src/lib/sessions.js', () => ({ - getSession: jest.fn().mockResolvedValue(null), +vi.mock('../../../src/lib/sessions.js', () => ({ + getSession: vi.fn().mockResolvedValue(null), })); import type { ReadResourceResult, Resource } from '@modelcontextprotocol/sdk/types.js'; @@ -406,7 +401,7 @@ function makeMockClient(opts: { const readResourceCalls: string[] = []; const listResourcesCalls: Array = []; - const readResource = jest.fn(async (uri: string): Promise => { + const readResource = vi.fn(async (uri: string): Promise => { readResourceCalls.push(uri); if (uri === 'skill://index.json') { if (opts.index === null) { @@ -421,7 +416,7 @@ function makeMockClient(opts: { throw new Error(`unexpected uri: ${uri}`); }); - const listResources = jest.fn(async (cursor?: string) => { + const listResources = vi.fn(async (cursor?: string) => { listResourcesCalls.push(cursor); if (opts.resourcePages) { const page = opts.resourcePages.shift(); From 5ce12448a94dac0a6c82f50c7a70b987dfd281b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 11:38:26 +0000 Subject: [PATCH 5/9] Tighten skills help output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpc help skills-list` / `mcpc help skills-get` were too verbose. Drop the multi-paragraph "Status:" blocks, the entry-types table, the name- forms table, and the standalone Examples section — collapse the remaining context into one short paragraph per command. Also drop the trailing `[EXPERIMENTAL] List ...` / `[EXPERIMENTAL] Read ...` descriptions from the session-commands listing in `mcpc --help` (and the auto-generated README block). The `[EXPERIMENTAL]` tag is still on the per-command descriptions (`mcpc help skills-list`). --- README.md | 4 +-- src/cli/index.ts | 69 +++++++++++------------------------------------- 2 files changed, 18 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 6f5f8d7a..2f646dfd 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,8 @@ MCP session commands (after connecting): <@session> resources-subscribe <@session> resources-unsubscribe <@session> resources-templates-list - <@session> skills-list [EXPERIMENTAL] List Agent Skills (SEP-2640) - <@session> skills-get [--raw] [EXPERIMENTAL] Read a skill's SKILL.md + <@session> skills-list + <@session> skills-get [--raw] <@session> tasks-list <@session> tasks-get <@session> tasks-result diff --git a/src/cli/index.ts b/src/cli/index.ts index e7a234ac..28d43e9f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -417,8 +417,8 @@ ${chalk.bold('MCP session commands (after connecting):')} <@session> ${theme.cyan('resources-subscribe')} <@session> ${theme.cyan('resources-unsubscribe')} <@session> ${theme.cyan('resources-templates-list')} - <@session> ${theme.cyan('skills-list')} ${chalk.yellow('[EXPERIMENTAL]')} List Agent Skills (SEP-2640) - <@session> ${theme.cyan('skills-get')} [--raw] ${chalk.yellow('[EXPERIMENTAL]')} Read a skill's SKILL.md + <@session> ${theme.cyan('skills-list')} + <@session> ${theme.cyan('skills-get')} [--raw] <@session> ${theme.cyan('tasks-list')} <@session> ${theme.cyan('tasks-get')} <@session> ${theme.cyan('tasks-result')} @@ -1188,34 +1188,16 @@ ${toolsCallCombinedJsonHelp}` // command surface here is marked EXPERIMENTAL accordingly. program .command('skills-list') - .description( - '[EXPERIMENTAL] List Agent Skills exposed by the server (MCP extension SEP-2640, may change).' - ) + .description('[EXPERIMENTAL] List Agent Skills from the server (SEP-2640).') .addHelpText( 'after', ` -${chalk.bold('Status:')} ${chalk.yellow('EXPERIMENTAL')} — implements the draft MCP skills - extension (SEP-2640). The spec is not yet finalized; the index shape, - recognized types, and capability key may change. Use at your own risk - and pin mcpc versions if you depend on the JSON shape. - -${chalk.bold('How discovery works:')} - Skills are not a new MCP primitive — they are markdown documents (SKILL.md - with YAML frontmatter) served as resources under \`skill://\` URIs. mcpc - first tries to read \`skill://index.json\`; if absent, it scans the server's - resources for \`skill://*/SKILL.md\` entries. - -${chalk.bold('Entry types (per SEP-2640):')} - skill-md concrete skill — \`skills-get \` reads SKILL.md - archive .tar.gz / .zip bundle — fetch with \`resources-read \` - mcp-resource-template parameterized namespace (RFC 6570 URI template) - -${chalk.bold('Examples:')} - mcpc ${session} skills-list - mcpc ${session} skills-list --json | jq '.[].name' + Tries \`skill://index.json\`; falls back to scanning \`skill://*/SKILL.md\` + resources. Index entry types: \`skill-md\`, \`archive\` (.tar.gz/.zip — fetch + with \`resources-read \`), \`mcp-resource-template\`. ${jsonHelp( - 'Array of `Skill` objects', - '`[{ name: string, description: string, type?: "skill-md" | "archive" | "mcp-resource-template", url: string }, ...]`', + '`[{ name, description, type, url }, ...]`', + undefined, 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640' )}` ) @@ -1225,39 +1207,20 @@ ${jsonHelp( program .command('skills-get ') - .description( - '[EXPERIMENTAL] Read an Agent Skill (SKILL.md) by name (MCP extension SEP-2640, may change).' - ) + .description("[EXPERIMENTAL] Read a skill's SKILL.md by name (SEP-2640).") .option('--raw', 'Print only the SKILL.md text (markdown), suitable for piping') .addHelpText( 'after', ` -${chalk.bold('Status:')} ${chalk.yellow('EXPERIMENTAL')} — implements the draft MCP skills - extension (SEP-2640). Behavior and output shape may change. - -${chalk.bold('Name forms:')} - bare name mcpc ${session} skills-get git-workflow - → reads \`skill://git-workflow/SKILL.md\` - nested path mcpc ${session} skills-get acme/billing/refunds - → reads \`skill://acme/billing/refunds/SKILL.md\` - full URI mcpc ${session} skills-get skill://git-workflow/SKILL.md - - For \`archive\` skills, pass the URL from \`skills-list\` to - \`resources-read\` instead — \`skills-get\` expects a SKILL.md target. - -${chalk.bold('--raw mode:')} - Prints just the SKILL.md text content with no header or fences. Useful for - loading skill content into an LLM context (e.g. cat-style piping): - mcpc ${session} skills-get git-workflow --raw > /tmp/skill.md - + Accepts a bare name (\`git-workflow\`), a nested path + (\`acme/billing/refunds\`), or a full \`skill://...\` URI. For \`archive\` + skills, use \`resources-read \` instead. With --json, --raw is + ignored; pull markdown via \`jq -r '.contents[0].text'\`. ${jsonHelp( - '`ReadResourceResult` object', - '`{ contents: [{ uri, mimeType?, text? | blob? }] }`', + '`ReadResourceResult`: `{ contents: [{ uri, mimeType?, text? | blob? }] }`', + undefined, `${SCHEMA_BASE}#readresourceresult` -)} In --json mode, --raw is ignored; the full \`ReadResourceResult\` is emitted - so callers can inspect mimeType and metadata. Pull the markdown via: - mcpc ${session} skills-get --json | jq -r '.contents[0].text' -` +)}` ) .action(async (name, options, command) => { await skills.getSkill(session, name, { From 65e84b8f8cf958c56a7fc6759d75372aea5ad0ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 10:54:28 +0000 Subject: [PATCH 6/9] Document Skills support in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: - Row in the "MCP feature support" compatibility table marking Skills as 🧪 Experimental (SEP-2640), linking to the new section. - New "Skills" subsection under "MCP feature support" covering: what Skills are, how to use `skills-list` and `skills-get` (bare name, nested path, full URI, `--raw`, `--json`), the three recognized index entry types, capability advertisement under both `extensions` and `experimental`, and the untrusted-content security stance. - Cross-reference from the existing "Agent skills" section in the AI agents block so readers find the new MCP-client feature. Refreshed the auto-generated `mcpc --help` block and TOC via `pnpm run build:readme`. --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index d4d1f729..484786cb 100644 --- a/README.md +++ b/README.md @@ -672,6 +672,9 @@ To help Claude Code use `mcpc`, you can install this [Claude skill](./docs/claud +`mcpc` also acts as a **client for skills served by MCP servers** (experimental, SEP-2640) — see +[Skills](#skills) for the `skills-list` / `skills-get` commands. + ## Agentic payments (x402) > ⚠️ **Experimental.** This feature is under active development and may change. @@ -811,6 +814,7 @@ The bridge process manages the full MCP session lifecycle: | 🔧 [**Tools**](#tools) | ✅ Supported | | 💬 [**Prompts**](#prompts) | ✅ Supported | | 📦 [**Resources**](#resources) | ✅ Supported | +| 🧠 [**Skills**](#skills) | 🧪 Experimental (SEP-2640) | | 📝 [**Logging**](#server-logs) | ✅ Supported | | 🔔 [**Notifications**](#list-change-notifications) | ✅ Supported | | 📄 [**Pagination**](#pagination) | ✅ Supported | @@ -921,6 +925,41 @@ mcpc @apify resources-subscribe "https://api.example.com/data" mcpc @apify resources-templates-list ``` +#### Skills + +> 🧪 **Experimental.** Implements the draft [MCP skills extension (SEP-2640)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640). +> The spec is in active iteration; the index shape, recognized entry types, and capability key may change. + +[Agent Skills](https://agentskills.io/) are reusable markdown workflow instructions (`SKILL.md` with YAML frontmatter) +that AI agents load on demand. `mcpc` lets you discover and pull skills served by any MCP server that exposes +them under the `skill://` URI convention — no SDK changes required, since skills are just resources: + +```bash +# List skills exposed by the server (tries skill://index.json, falls back to scanning skill://*/SKILL.md) +mcpc @apify skills-list + +# Read a skill's SKILL.md by bare name, nested path, or full URI +mcpc @apify skills-get git-workflow +mcpc @apify skills-get acme/billing/refunds +mcpc @apify skills-get skill://git-workflow/SKILL.md + +# Print just the markdown (no header/fences) — pipe straight to an LLM or a file +mcpc @apify skills-get git-workflow --raw > /tmp/skill.md + +# JSON for scripts: [{ name, description, type, url }] +mcpc --json @apify skills-list | jq '.[].name' +``` + +Recognized index entry types (per SEP-2640): `skill-md` (concrete skill), `mcp-resource-template` +(parameterized namespace), and `archive` (`.tar.gz`/`.zip` bundle — fetch the URL via `resources-read`). +Entries with an unrecognized `type` are silently skipped. + +Skills appear under capabilities in `mcpc @session` output when a server advertises the extension +under either `capabilities.extensions["io.modelcontextprotocol/skills"]` (per spec) or +`capabilities.experimental["io.modelcontextprotocol/skills"]` (the SDK-preserved escape hatch some +SDKs still use). Skill content is treated as untrusted input — `mcpc` only reads and prints it; it +never executes hooks, scripts, or other frontmatter-declared behavior. + #### List change notifications When connected via a [session](#sessions), `mcpc` automatically handles `list_changed` From cbe8f05f03cdf44004578052f0baaf7b136e619a Mon Sep 17 00:00:00 2001 From: Jan Curn Date: Sat, 30 May 2026 22:12:05 +0200 Subject: [PATCH 7/9] Copy --- src/cli/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index ce27cdaa..25e4b181 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1229,7 +1229,7 @@ ${toolsCallCombinedJsonHelp}` // command surface here is marked EXPERIMENTAL accordingly. program .command('skills-list') - .description('[EXPERIMENTAL] List Agent Skills from the server (SEP-2640).') + .description('[EXPERIMENTAL] List agent skills from the server (SEP-2640).') .addHelpText( 'after', ` From 65f4d1d418314846f208eef35e0faaa2fd0d6dc5 Mon Sep 17 00:00:00 2001 From: Jan Curn Date: Sat, 30 May 2026 22:25:16 +0200 Subject: [PATCH 8/9] Copy --- src/cli/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 25e4b181..327d4786 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1249,7 +1249,7 @@ ${jsonHelp( program .command('skills-get ') .description("[EXPERIMENTAL] Read a skill's SKILL.md by name (SEP-2640).") - .option('--raw', 'Print only the SKILL.md text (markdown), suitable for piping') + .option('--raw', 'Print only the SKILL.md text (Markdown), suitable for piping') .addHelpText( 'after', ` From e15f4acb2f77894e50633fb72e7e9dac50c905f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 20:27:57 +0000 Subject: [PATCH 9/9] Add Discovery caption to skills-list --help https://claude.ai/code/session_01PyzCAbMiMXTbLrFXUP3nQJ --- src/cli/index.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 327d4786..765212f6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1233,9 +1233,9 @@ ${toolsCallCombinedJsonHelp}` .addHelpText( 'after', ` - Tries \`skill://index.json\`; falls back to scanning \`skill://*/SKILL.md\` - resources. Index entry types: \`skill-md\`, \`archive\` (.tar.gz/.zip — fetch - with \`resources-read \`), \`mcp-resource-template\`. +${chalk.bold('Discovery:')} + Tries \`skill://index.json\`, else scans \`skill://*/SKILL.md\`. Types: + \`skill-md\`, \`mcp-resource-template\`, \`archive\` (use \`resources-read \`). ${jsonHelp( '`[{ name, description, type, url }, ...]`', undefined, @@ -1253,10 +1253,9 @@ ${jsonHelp( .addHelpText( 'after', ` - Accepts a bare name (\`git-workflow\`), a nested path - (\`acme/billing/refunds\`), or a full \`skill://...\` URI. For \`archive\` - skills, use \`resources-read \` instead. With --json, --raw is - ignored; pull markdown via \`jq -r '.contents[0].text'\`. +${chalk.bold('Names:')} + \`name\`, \`nested/path\`, or \`skill://...\` URI. For \`archive\` skills, use + \`resources-read \`. With --json, --raw is ignored. ${jsonHelp( '`ReadResourceResult`: `{ contents: [{ uri, mimeType?, text? | blob? }] }`', undefined,