From 93f9490902b914cd07abdbffbbae8404a1632142 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 22:33:42 +0000 Subject: [PATCH 1/2] 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 86ba64a..2ff4288 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 3010f15..853696e 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 0000000..8aa303f --- /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 8a51bb2..9a6fec9 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 087b92e..dd2cdbe 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 09c334e..1848388 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/2] 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 2ff4288..96d7130 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 8aa303f..20cb9c8 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 dd2cdbe..2991c6b 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 8e6f2ef..8851ab8 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 0000000..30530f4 --- /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 49802bc..dc5c182 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 709f4ad..9517deb 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 0000000..e07d2a6 --- /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([]); + }); +});