diff --git a/CHANGELOG.md b/CHANGELOG.md index af959d3e..39ae94fa 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 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`. - `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 15bfb601..768bcd4d 100644 --- a/README.md +++ b/README.md @@ -166,6 +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> 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..9c108975 --- /dev/null +++ b/src/cli/commands/skills.ts @@ -0,0 +1,323 @@ +/** + * 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. + * + * Per SEP-2640, `name` is required for `type: "skill-md"` entries but + * optional for `type: "mcp-resource-template"` (parameterized namespaces + * may not have a single concrete name). When `name` is absent, mcpc + * derives a display name from the URL. + */ +export interface Skill { + /** Skill name. Derived from URL for nameless `mcp-resource-template` entries. */ + 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 an RFC 6570 URI 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. + * + * @internal exported for tests + */ +export 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. + * + * @internal exported for tests + */ +export 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.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; + const isTemplate = type === 'mcp-resource-template'; + let name: string; + if (typeof e.name === 'string' && e.name.length > 0) { + name = e.name; + } else if (isTemplate) { + // 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. + continue; + } + + skills.push({ + name, + description: typeof e.description === 'string' ? e.description : '', + ...(type !== undefined && { type }), + url: e.url, + }); + } + + return skills; +} + +/** + * Derive a display name from an index URL when the entry has no `name` field + * (e.g. an `mcp-resource-template` entry). Picks the last meaningful segment + * before `SKILL.md` or the last segment of the path. + */ +function displayNameFromUrl(url: string): string { + // Strip scheme + authority; we only care about the path. + const schemeEnd = url.indexOf('://'); + const path = schemeEnd >= 0 ? url.slice(schemeEnd + 3) : url; + const parts = path.split('/').filter(Boolean); + if (parts.length === 0) return url; + // If the URL ends with SKILL.md, the segment before it is the skill name. + if (parts[parts.length - 1] === 'SKILL.md' && parts.length >= 2) { + return parts[parts.length - 2] as string; + } + return parts[parts.length - 1] as string; +} + +/** + * 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 + */ +export 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. + * + * @internal exported for tests + */ +export 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 a32c99d9..75480c3f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -18,6 +18,7 @@ import chalk from 'chalk'; import { formatJson, formatJsonError, rainbow, theme } 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'; @@ -416,6 +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('tasks-list')} <@session> ${theme.cyan('tasks-get')} <@session> ${theme.cyan('tasks-result')} @@ -1178,6 +1181,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 8cac5b7a..7b734fbf 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 { @@ -825,6 +826,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 */ @@ -1421,6 +1516,25 @@ export function formatServerDetails( capabilityList.push(`${bullet} tasks${featureStr}`); } + // 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 = + (!!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)')}`); + } + if (capabilityList.length > 0) { lines.push(capabilityList.join('\n')); } else { @@ -1461,6 +1575,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)) { 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 d8d1863d..6500a92d 100644 --- a/test/unit/cli/output.test.ts +++ b/test/unit/cli/output.test.ts @@ -45,6 +45,8 @@ import { formatResourceTemplateDetail, formatPrompts, formatPromptDetail, + formatSkills, + formatSkillDetail, formatSessionLine, formatHuman, logTarget, @@ -1170,6 +1172,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', () => { @@ -1428,6 +1498,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..cd302d50 --- /dev/null +++ b/test/unit/cli/skills.test.ts @@ -0,0 +1,459 @@ +/** + * 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). +// 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', () => { + const identity = (s: string): string => s; + const hex = (): ((s: string) => string) => identity; + const palette = { + cyan: identity, + yellow: identity, + red: identity, + dim: identity, + gray: identity, + bold: identity, + green: identity, + greenBright: identity, + blue: identity, + magenta: identity, + white: identity, + hex, + }; + return { default: palette, ...palette }; +}); + +// 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 url, or skill-md entries missing name', () => { + const text = JSON.stringify({ + skills: [ + // valid skill-md + { + name: 'good', + type: 'skill-md', + description: 'ok', + url: 'skill://good/SKILL.md', + }, + // skill-md without name → dropped per spec + { type: 'skill-md', description: 'no name', url: 'skill://x/SKILL.md' }, + // entry without url → dropped regardless of type + { name: 'no-url', description: 'no url' }, + null, + 'not-an-object', + // wrong type for name → treated as missing (and no type means skill-md) + { name: 123, url: 'skill://x/SKILL.md' }, + ], + }); + const skills = parseIndex(text); + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe('good'); + }); + + 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 the URL for nameless templates. + const text = JSON.stringify({ + skills: [ + { + type: 'mcp-resource-template', + description: 'Per-product docs', + url: 'skill://docs/{product}/SKILL.md', + }, + { + type: 'mcp-resource-template', + description: 'No SKILL.md suffix', + url: 'skill://templates/{kind}', + }, + ], + }); + const skills = parseIndex(text); + expect(skills).toHaveLength(2); + // For URLs ending in SKILL.md, name = segment before SKILL.md + expect(skills[0]?.name).toBe('{product}'); + expect(skills[0]?.type).toBe('mcp-resource-template'); + // For URLs not ending in SKILL.md, name = last path segment + expect(skills[1]?.name).toBe('{kind}'); + }); + + it('treats an empty `name` on skill-md as missing', () => { + const text = JSON.stringify({ + skills: [{ name: '', type: 'skill-md', description: 'x', url: 'skill://x/SKILL.md' }], + }); + expect(parseIndex(text)).toEqual([]); + }); + + 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([]); + }); +});