From c965f4589243be996ef7206afcdbcaaf46ab6434 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 17:18:20 +1100 Subject: [PATCH] feat(mcp): answer with Markdown and compact typed data Every MCP tool returned the public API answer twice: as pretty-printed JSON in content and again in structuredContent. A ten-row trending call cost about 8,700 tokens, much of it duplicate fields, empty values, and install commands on every row. Presenters in layers/mcp/shared/mcp-presenters.ts now shape each answer: Markdown a model can relay in content, and a compact copy in structuredContent that each tool declares as an outputSchema, so the MCP SDK checks it. Every Skill keeps its repository and its page link. The public API and the SDK are unchanged. --- docs/runbooks/agent-app-directories.md | 2 +- layers/mcp/server/mcp/index.ts | 1 + layers/mcp/shared/mcp-presenters.ts | 485 ++++++++++++++++++ layers/mcp/shared/mcp-tools.ts | 133 ++--- .../mcp/repository-vercel-agent-skills.json | 1 + test/fixtures/mcp/search-tailwind.json | 1 + .../mcp/skill-web-design-guidelines.json | 1 + test/fixtures/mcp/track-testing.json | 1 + test/fixtures/mcp/tracks.json | 1 + test/fixtures/mcp/trending-week.json | 1 + test/unit/mcp-discovery.test.ts | 82 +-- test/unit/mcp-presenters.test.ts | 274 ++++++++++ test/unit/webmcp.test.ts | 2 +- 13 files changed, 876 insertions(+), 109 deletions(-) create mode 100644 layers/mcp/shared/mcp-presenters.ts create mode 100644 test/fixtures/mcp/repository-vercel-agent-skills.json create mode 100644 test/fixtures/mcp/search-tailwind.json create mode 100644 test/fixtures/mcp/skill-web-design-guidelines.json create mode 100644 test/fixtures/mcp/track-testing.json create mode 100644 test/fixtures/mcp/tracks.json create mode 100644 test/fixtures/mcp/trending-week.json create mode 100644 test/unit/mcp-presenters.test.ts diff --git a/docs/runbooks/agent-app-directories.md b/docs/runbooks/agent-app-directories.md index 0a0b1729..598f48e1 100644 --- a/docs/runbooks/agent-app-directories.md +++ b/docs/runbooks/agent-app-directories.md @@ -16,7 +16,7 @@ Both portals scan the tools from the live server. Every tool must keep these pro - A description that says what the tool does. It must not tell the model how to behave, call other apps, or fetch instructions from a URL. - Output that holds only what the request needs. Do not add request IDs, trace IDs, or debug fields. -`layers/mcp/shared/mcp-tools.ts` holds the tools. The toolkit answers 403 to a request whose `Origin` header is not in `mcp.security.allowedOrigins` in `nuxt.config.ts`. +`layers/mcp/shared/mcp-tools.ts` holds the tools. `layers/mcp/shared/mcp-presenters.ts` shapes each answer: Markdown in `content` for the model, and a compact copy in `structuredContent` that the MCP SDK checks against the tool's `outputSchema`. The public API answers stay complete for the CLI and the SDK. The toolkit answers 403 to a request whose `Origin` header is not in `mcp.security.allowedOrigins` in `nuxt.config.ts`. ## Change a tool diff --git a/layers/mcp/server/mcp/index.ts b/layers/mcp/server/mcp/index.ts index 42a7358c..2e79d838 100644 --- a/layers/mcp/server/mcp/index.ts +++ b/layers/mcp/server/mcp/index.ts @@ -26,6 +26,7 @@ export default defineMcpHandler({ title: tool.title, description: tool.description, inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema.shape, // Claude's directory reads the title from the annotations as well. annotations: { title: tool.title, ...tool.annotations }, handler: (args, extra) => tool.run(deps, args, extra.signal), diff --git a/layers/mcp/shared/mcp-presenters.ts b/layers/mcp/shared/mcp-presenters.ts new file mode 100644 index 00000000..282f7c18 --- /dev/null +++ b/layers/mcp/shared/mcp-presenters.ts @@ -0,0 +1,485 @@ +/** + * MCP answers, shaped for a model instead of a program. + * + * The public API answers stay complete and stable for the CLI and the SDK. + * An MCP client puts every answer into a model's context, so each tool gets + * two compact forms built here from the API answer: + * + * - `text`: Markdown a model can relay as it is. Some clients read only this. + * - `structured`: the same facts as typed data, checked against the tool's + * output schema. Empty, zero, and false fields are left out. + * + * Every Skill names its repository and links its skilld.dev page, so + * provenance is one click away (VISION principle 1). Run commands come + * before install commands, because running is the default. + */ +import type { OperationOutput } from 'skilld-sdk' +import type { indexRequestsV1, repositoriesV1, skillsV1, trackSummarySchema, tracksV1, trendingSkillSchema } from 'skilld-sdk/contract' +import type { InstallRef } from './mcp-install-command' +import { z } from 'zod' +import { installCommandFor, skillRunCommand } from './mcp-install-command' + +type SearchAnswer = OperationOutput +type SkillAnswer = OperationOutput +type TrackAnswer = OperationOutput +/** + * skilld-sdk 0.1.2 types the items of these two answers as unknown, although + * its client parses them with the schemas below. The types come from those schemas. + */ +export interface TrackListAnswer { items: z.output[], total: number } +export interface TrendingAnswer { items: z.output[], total: number } +type RepositoryAnswer = OperationOutput +type IndexRequestAnswer = OperationOutput +type SkillCardAnswer = TrackAnswer['items'][number] +type TrendingSignal = TrendingAnswer['items'][number]['signal'] + +export interface Presented { + text: string + structured: T +} + +/** Skill pages live on the public site, whatever API origin the tools call. */ +const SITE_ORIGIN = 'https://skilld.dev' +const CARD_DESCRIPTION_CHARS = 160 +const TRENDING_DESCRIPTION_CHARS = 100 +const POST_EXCERPT_CHARS = 140 +const SAFETY_NOTE = 'skilld shows where a skill comes from. It does not check whether a skill is safe.' + +// --- schemas --- + +const skillCardSchema = z.object({ + /** owner/repository/name, the ref every skilld command takes. */ + ref: z.string(), + /** The display name, only when it differs from the name in the ref. */ + name: z.string().optional(), + description: z.string().optional(), + stars: z.number().int().optional(), + pageUrl: z.string(), + runCommand: z.string(), +}) +type SkillCard = z.infer + +export const searchOutputSchema = z.object({ + query: z.string(), + total: z.number().int(), + items: z.array(skillCardSchema), +}) + +export const skillOutputSchema = z.object({ + ref: z.string(), + name: z.string().optional(), + author: z.string(), + description: z.string().optional(), + stars: z.number().int().optional(), + pageUrl: z.string(), + sourceUrl: z.string().optional(), + sourceCommit: z.string().optional(), + skillPath: z.string().optional(), + /** YYYY-MM-DD of the last change to the Skill. */ + lastChanged: z.string().optional(), + /** YYYY-MM-DD of the last push to its Repository. */ + lastPushed: z.string().optional(), + license: z.string().optional(), + allowedTools: z.array(z.string()).optional(), + files: z.array(z.object({ path: z.string(), size: z.number().int() })).optional(), + /** Present only when the SKILL.md is gone upstream and this is the last copy. */ + sourceGone: z.literal(true).optional(), + runCommand: z.string(), + installCommand: z.string(), + skillMarkdown: z.string().optional(), +}) + +export const trackListOutputSchema = z.object({ + total: z.number().int(), + items: z.array(z.object({ + slug: z.string(), + label: z.string(), + goal: z.string(), + pageUrl: z.string(), + skillCount: z.number().int(), + })), +}) + +export const trackOutputSchema = z.object({ + slug: z.string(), + label: z.string(), + goal: z.string(), + pageUrl: z.string(), + total: z.number().int(), + offset: z.number().int(), + items: z.array(skillCardSchema), +}) + +/** The post that put a row on the board. Its excerpt is in the text answer only. */ +const postSchema = z.object({ + url: z.string(), + platform: z.enum(['x', 'bsky']), + author: z.string(), + /** YYYY-MM-DD */ + date: z.string(), +}) + +const reasonSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('social'), devs: z.number().int(), posts: z.number().int(), post: postSchema }), + z.object({ kind: z.literal('star-surge'), starGain: z.number().int(), surgedOn: z.string() }), + z.object({ kind: z.literal('social-and-star-surge'), devs: z.number().int(), posts: z.number().int(), post: postSchema, starGain: z.number().int(), surgedOn: z.string() }), + z.object({ kind: z.literal('star-count') }), +]) +type Reason = z.infer + +export const trendingOutputSchema = z.object({ + window: z.enum(['week', 'month']), + total: z.number().int(), + items: z.array(skillCardSchema.extend({ rank: z.number().int(), reason: reasonSchema })), +}) + +export const repositoryOutputSchema = z.object({ + ref: z.string(), + description: z.string().optional(), + stars: z.number().int().optional(), + lastPushed: z.string().optional(), + pageUrl: z.string(), + repositoryUrl: z.string(), + installCommand: z.string(), + total: z.number().int(), + items: z.array(skillCardSchema), +}) + +export const indexRequestOutputSchema = z.object({ + status: z.enum(['indexed', 'queued']), + repository: z.string(), + pageUrl: z.string(), + skillCount: z.number().int().optional(), + id: z.string().optional(), + stage: z.enum(['queued', 'checking', 'indexing']).optional(), + indexed: z.number().int().optional(), + indexTotal: z.number().int().optional(), + statusUrl: z.string().optional(), +}) + +export const installCommandOutputSchema = z.object({ + ref: z.string(), + kind: z.enum(['skill', 'repo']), + runCommand: z.string().optional(), + command: z.string(), +}) + +// --- helpers --- + +/** Collapses whitespace and cuts at the last word boundary before `max`. */ +export function trimText(text: string, max: number): string { + const flat = text.replace(/\s+/g, ' ').trim() + if (flat.length <= max) + return flat + const cut = flat.slice(0, max) + const boundary = cut.lastIndexOf(' ') + // A text with no early space is cut mid-word rather than to almost nothing. + const kept = boundary > max / 2 ? cut.slice(0, boundary) : cut + return `${kept.trimEnd()}…` +} + +function day(iso: string | null | undefined): string | undefined { + return iso ? iso.slice(0, 10) : undefined +} + +function count(n: number, noun: string): string { + return `${n.toLocaleString('en-US')} ${noun}${n === 1 ? '' : 's'}` +} + +/** A fence longer than any backtick run inside, so the Skill's own code blocks stay inside it. */ +function fence(markdown: string): string { + const longest = Math.max(0, ...[...markdown.matchAll(/`+/g)].map(match => match[0].length)) + return '`'.repeat(Math.max(3, longest + 1)) +} + +/** Leaves out `undefined` values so the structured answer carries only real facts. */ +function compact>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T +} + +function skillPageUrl(owner: string, repository: string, name: string): string { + return `${SITE_ORIGIN}/gh/${owner}/${repository}/${name}` +} + +function cardFromAnswer(skill: SkillCardAnswer, options: { descriptionChars?: number, withStars?: boolean } = {}): SkillCard { + const { descriptionChars = CARD_DESCRIPTION_CHARS, withStars = true } = options + return compact({ + ref: `${skill.owner}/${skill.repository}/${skill.name}`, + name: skill.displayName !== skill.name ? skill.displayName : undefined, + description: skill.description ? trimText(skill.description, descriptionChars) : undefined, + stars: withStars && skill.stars > 0 ? skill.stars : undefined, + pageUrl: skill.pageUrl, + runCommand: skill.runCommand, + }) +} + +/** `1. [**name**](page) · owner/repository · 96 stars`, then the description and the run command. */ +function cardLines(card: SkillCard, position: number, options: { showRepository?: boolean } = {}): string[] { + const { showRepository = true } = options + const [owner, repository, name] = card.ref.split('/') as [string, string, string] + const heading = [ + `[**${card.name ?? name}**](${card.pageUrl})`, + showRepository ? `${owner}/${repository}` : undefined, + card.stars ? count(card.stars, 'star') : undefined, + ].filter(Boolean).join(' · ') + return [ + `${position}. ${heading}`, + ...(card.description ? [` ${card.description}`] : []), + ` Run: \`${card.runCommand}\``, + ] +} + +// --- presenters --- + +export function presentSearch(query: string, answer: SearchAnswer): Presented> { + const items = answer.items.map((item) => { + const { owner, repository, selector } = item.source + return compact({ + ref: `${owner}/${repository}/${selector.name}`, + description: item.description ? trimText(item.description, CARD_DESCRIPTION_CHARS) : undefined, + stars: item.stargazerCount > 0 ? item.stargazerCount : undefined, + pageUrl: skillPageUrl(owner, repository, selector.name), + runCommand: skillRunCommand(owner, repository, selector.name), + }) + }) + const heading = items.length + ? `${count(answer.total, 'skill')} on skilld.dev match "${query}". Showing ${items.length}:` + : `No skills on skilld.dev match "${query}".` + return { + text: [heading, ...items.flatMap((card, index) => cardLines(card, index + 1))].join('\n'), + structured: { query, total: answer.total, items }, + } +} + +export function presentSkill(answer: SkillAnswer): Presented> { + const author = answer.authorName ?? answer.owner + const structured = compact({ + ref: `${answer.owner}/${answer.repository}/${answer.name}`, + name: answer.displayName !== answer.name ? answer.displayName : undefined, + author, + description: answer.description ? trimText(answer.description, CARD_DESCRIPTION_CHARS) : undefined, + stars: answer.stars > 0 ? answer.stars : undefined, + pageUrl: answer.pageUrl, + sourceUrl: answer.sourceUrl ?? undefined, + sourceCommit: answer.sourceCommit ?? undefined, + skillPath: answer.skillPath ?? undefined, + lastChanged: day(answer.updatedAt), + lastPushed: day(answer.pushedAt), + license: answer.license ?? undefined, + allowedTools: answer.allowedTools.length ? answer.allowedTools : undefined, + files: answer.files.length ? answer.files.map(file => ({ path: file.path, size: file.size })) : undefined, + sourceGone: answer.sourceGone ? true as const : undefined, + runCommand: answer.runCommand, + installCommand: answer.installCommand, + skillMarkdown: answer.markdown ?? undefined, + }) + + const freshness = [ + structured.lastChanged ? `Last skill change ${structured.lastChanged}` : undefined, + structured.lastPushed ? `last repository push ${structured.lastPushed}` : undefined, + ].filter(Boolean).join(' · ') + const source = structured.sourceUrl + ? `Source: ${structured.sourceUrl}${structured.sourceCommit ? ` (commit ${structured.sourceCommit.slice(0, 7)})` : ''}` + : undefined + const lines = [ + `**${answer.displayName}** by ${author} · ${answer.owner}/${answer.repository}${structured.stars ? ` · ${count(structured.stars, 'star')}` : ''}`, + structured.description, + `Page: ${answer.pageUrl}`, + source, + structured.sourceGone ? 'The SKILL.md is gone upstream. This is the last copy skilld read.' : undefined, + freshness || undefined, + structured.license ? `License: ${structured.license}` : undefined, + structured.allowedTools ? `Allowed tools: ${structured.allowedTools.join(', ')}` : undefined, + structured.files ? `Files beside SKILL.md: ${structured.files.map(file => file.path).join(', ')}` : undefined, + `Run once: \`${answer.runCommand}\` (one session, writes no files)`, + `Install: \`${answer.installCommand}\` (keeps it in the project)`, + SAFETY_NOTE, + ].filter((line): line is string => Boolean(line)) + if (structured.skillMarkdown) { + const marker = fence(structured.skillMarkdown) + lines.push('', 'SKILL.md text:', `${marker}markdown`, structured.skillMarkdown, marker) + } + return { text: lines.join('\n'), structured } +} + +export function presentTrackList(answer: TrackListAnswer): Presented> { + const items = answer.items.map(track => ({ + slug: track.slug, + label: track.label, + goal: track.line, + pageUrl: track.pageUrl, + skillCount: track.skillCount, + })) + const lines = [ + `skilld has ${count(answer.total, 'track')}. Each is a page of skills for one kind of work.`, + ...items.map(track => `- **${track.label}** (\`${track.slug}\`, ${count(track.skillCount, 'skill')}): ${track.goal} ${track.pageUrl}`), + ] + return { text: lines.join('\n'), structured: { total: answer.total, items } } +} + +export function presentTrack(answer: TrackAnswer, offset: number): Presented> { + const items = answer.items.map(skill => cardFromAnswer(skill)) + const range = items.length ? `${offset + 1}–${offset + items.length} of ${answer.total}` : `none of ${answer.total}` + const lines = [ + `**${answer.label}** track (\`${answer.slug}\`): ${answer.line}`, + `Page: ${answer.pageUrl}`, + `Skills ${range}, in page order:`, + ...items.flatMap((card, index) => cardLines(card, offset + index + 1)), + ] + return { + text: lines.join('\n'), + structured: { slug: answer.slug, label: answer.label, goal: answer.line, pageUrl: answer.pageUrl, total: answer.total, offset, items }, + } +} + +type Post = Extract['post'] + +function postFrom(post: Post): z.infer { + return { url: post.url, platform: post.platform, author: post.authorHandle, date: post.postedAt.slice(0, 10) } +} + +function reasonFrom(signal: TrendingSignal): Reason { + switch (signal.kind) { + case 'social': + return { kind: 'social', devs: signal.authorCount, posts: signal.mentionCount, post: postFrom(signal.post) } + case 'star-surge': + return { kind: 'star-surge', starGain: signal.starGain, surgedOn: signal.surgedOn.slice(0, 10) } + case 'social-and-star-surge': + return { + kind: 'social-and-star-surge', + devs: signal.authorCount, + posts: signal.mentionCount, + post: postFrom(signal.post), + starGain: signal.starGain, + surgedOn: signal.surgedOn.slice(0, 10), + } + case 'star-count': + return { kind: 'star-count' } + } +} + +const NETWORK: Record['platform'], string> = { x: 'X', bsky: 'Bluesky' } + +/** The post text a social row quotes, trimmed for the text answer. */ +function postExcerpt(signal: TrendingSignal): string { + return signal.kind === 'social' || signal.kind === 'social-and-star-surge' ? trimText(signal.post.text, POST_EXCERPT_CHARS) : '' +} + +/** ADR-0004: every row says which route put it on the board, in its own words. */ +function reasonLines(reason: Reason, excerpt: string): string[] { + const talked = (devs: number, posts: number) => `${count(devs, 'dev')} talked about it in ${count(posts, 'post')}.` + const surge = (gain: number, on: string) => `Its repository gained ${count(gain, 'GitHub star')} in a surge on ${on}.` + const quote = (post: z.infer) => ` Post: @${post.author} on ${NETWORK[post.platform]}, ${post.date}: "${excerpt}" ${post.url}` + switch (reason.kind) { + case 'social': + return [` Why: ${talked(reason.devs, reason.posts)}`, quote(reason.post)] + case 'star-surge': + return [` Why: ${surge(reason.starGain, reason.surgedOn)}`] + case 'social-and-star-surge': + return [` Why: ${talked(reason.devs, reason.posts)} ${surge(reason.starGain, reason.surgedOn)}`, quote(reason.post)] + case 'star-count': + return [' Why: Nobody posted about it this period. It fills the board by GitHub stars.'] + } +} + +export function presentTrending(window: 'week' | 'month', answer: TrendingAnswer): Presented> { + const items = answer.items.map((row, index) => ({ + rank: index + 1, + ...cardFromAnswer(row, { descriptionChars: TRENDING_DESCRIPTION_CHARS }), + reason: reasonFrom(row.signal), + })) + const lines = [ + `skilld.dev trending board, past ${window}. Showing ${items.length} of ${answer.total} in board order:`, + ...items.flatMap((row, index) => { + const [first, ...rest] = cardLines(row, row.rank) + return [first!, ...reasonLines(row.reason, postExcerpt(answer.items[index]!.signal)), ...rest] + }), + ] + return { text: lines.join('\n'), structured: { window, total: answer.total, items } } +} + +export function presentRepository(answer: RepositoryAnswer, limit: number): Presented> { + const items = answer.skills.slice(0, limit).map(skill => cardFromAnswer(skill, { withStars: false })) + const structured = compact({ + ref: `${answer.owner}/${answer.repository}`, + description: answer.description ? trimText(answer.description, CARD_DESCRIPTION_CHARS) : undefined, + stars: answer.stars > 0 ? answer.stars : undefined, + lastPushed: day(answer.pushedAt), + pageUrl: answer.pageUrl, + repositoryUrl: answer.repositoryUrl, + installCommand: answer.installCommand, + total: answer.skills.length, + items, + }) + const facts = [ + `**${structured.ref}**`, + structured.stars ? count(structured.stars, 'star') : undefined, + structured.lastPushed ? `last push ${structured.lastPushed}` : undefined, + ].filter(Boolean).join(' · ') + const lines = [ + facts, + structured.description, + `Page: ${structured.pageUrl} · GitHub: ${structured.repositoryUrl}`, + `Install every skill: \`${structured.installCommand}\``, + `Skills ${items.length} of ${structured.total}, most recently changed first:`, + ...items.flatMap((card, index) => cardLines(card, index + 1, { showRepository: false })), + ].filter((line): line is string => Boolean(line)) + return { text: lines.join('\n'), structured } +} + +export function presentIndexRequest(answer: IndexRequestAnswer): Presented> { + const repository = `${answer.owner}/${answer.repository}` + const pageUrl = `${SITE_ORIGIN}/gh/${repository}` + if (answer.status === 'indexed') { + return { + text: `${repository} is already in the skilld.dev registry with ${count(answer.skills.length, 'skill')}. Nothing was queued. Page: ${pageUrl}`, + structured: { status: 'indexed', repository, skillCount: answer.skills.length, pageUrl }, + } + } + const statusUrl = `${SITE_ORIGIN}/api/v1/index-requests/${answer.id}` + const { progress } = answer + const stage = progress.stage === 'indexing' + ? `indexing (${progress.indexed} of ${count(progress.total, 'skill')})` + : progress.stage + return { + text: [ + `Queued ${repository} for indexing. Stage: ${stage}.`, + `Status: ${statusUrl}`, + 'A person reviews which skills the registry admits. Nothing in the repository changes.', + `Page after indexing: ${pageUrl}`, + ].join('\n'), + structured: compact({ + status: 'queued' as const, + repository, + pageUrl, + id: answer.id, + stage: progress.stage, + indexed: progress.stage === 'indexing' ? progress.indexed : undefined, + indexTotal: progress.stage === 'indexing' ? progress.total : undefined, + statusUrl, + }), + } +} + +export function presentInstallCommand(ref: InstallRef, raw: string): Presented> { + const command = installCommandFor(ref) + if (ref.kind === 'skill') { + const runCommand = skillRunCommand(ref.owner, ref.repo, ref.name) + return { + text: [ + `Run once, in the project root: \`${runCommand}\``, + 'skilld run gives a coding agent the skill for one session and writes no files.', + `Install it for every session: \`${command}\``, + 'You run these commands. This tool ran nothing.', + ].join('\n'), + structured: { ref: raw, kind: 'skill', runCommand, command }, + } + } + return { + text: [ + `Install every skill from the repository, in the project root: \`${command}\``, + 'You run this command. This tool ran nothing.', + ].join('\n'), + structured: { ref: raw, kind: 'repo', command }, + } +} diff --git a/layers/mcp/shared/mcp-tools.ts b/layers/mcp/shared/mcp-tools.ts index 4aac4575..a7243770 100644 --- a/layers/mcp/shared/mcp-tools.ts +++ b/layers/mcp/shared/mcp-tools.ts @@ -1,16 +1,28 @@ -import type { FetchImplementation, OperationOutput, Result, SkilldFailure } from 'skilld-sdk' -import type { skillsV1 } from 'skilld-sdk/contract' +import type { FetchImplementation, Result, SkilldFailure } from 'skilld-sdk' +import type { Presented, TrackListAnswer, TrendingAnswer } from './mcp-presenters' import { createSkilldClient } from 'skilld-sdk' import { z } from 'zod' +import { parseInstallRef } from './mcp-install-command' import { - installCommandFor, - parseInstallRef, - skillRunCommand, -} from './mcp-install-command' + indexRequestOutputSchema, + installCommandOutputSchema, + presentIndexRequest, + presentInstallCommand, + presentRepository, + presentSearch, + presentSkill, + presentTrack, + presentTrackList, + presentTrending, + repositoryOutputSchema, + searchOutputSchema, + skillOutputSchema, + trackListOutputSchema, + trackOutputSchema, + trendingOutputSchema, +} from './mcp-presenters' const MAX_RESULT_CHARS = 48_000 -/** Skill pages live on the public site, whatever API origin the tools call. */ -const SITE_ORIGIN = 'https://skilld.dev' /** * One spelling for every place an agent reads the accepted refs: the tool * description, the input schema, and the unrecognized-ref failure. @@ -57,6 +69,8 @@ export interface McpTool { title: string description: string inputSchema: Record + /** Shape of structuredContent. The MCP SDK checks every answer against it. */ + outputSchema: z.ZodObject annotations: McpToolAnnotations /** A prompt that calls the tool. /developers/mcp prints it beside the tool. */ examplePrompt: string @@ -65,13 +79,14 @@ export interface McpTool { type OutputPolicy = 'paginated' | 'complete' -function ok(data: Record, policy: OutputPolicy = 'paginated'): McpToolResult { - const text = JSON.stringify(data, null, 2) - if (policy === 'paginated' && text.length > MAX_RESULT_CHARS) +/** The Markdown answer goes to content, the typed copy to structuredContent. */ +function ok(presented: Presented>, policy: OutputPolicy = 'paginated'): McpToolResult { + const size = presented.text.length + JSON.stringify(presented.structured).length + if (policy === 'paginated' && size > MAX_RESULT_CHARS) return fail('Result exceeded the MCP output limit. Request fewer items.') return { - content: [{ type: 'text', text }], - structuredContent: data, + content: [{ type: 'text', text: presented.text }], + structuredContent: presented.structured, } } @@ -79,15 +94,16 @@ function fail(message: string): McpToolResult { return { content: [{ type: 'text', text: message }], isError: true } } -function presentResult( +function presentResult( deps: McpToolDeps, operation: string, - result: Result, SkilldFailure>, + result: Result, + present: (value: T) => Presented>, notFoundMessage?: string, outputPolicy: OutputPolicy = 'paginated', ): McpToolResult { if (result._tag === 'Ok') - return ok(result.value, outputPolicy) + return ok(present(result.value), outputPolicy) const error = result.error if (error._tag === 'ApiFailure') { @@ -108,25 +124,6 @@ function presentResult( return fail(`${operation} failed. Try again later.`) } -/** - * The search answer names each Skill by its source only. Every result also - * links its Skill page, so provenance is one click away (VISION principle 1). - * The long path answers 301 to the canonical page of a one-Skill repository. - */ -function withSkillLinks(answer: OperationOutput): Record { - return { - ...answer, - items: answer.items.map((item) => { - const { owner, repository, selector } = item.source - return { - ...item, - pageUrl: `${SITE_ORIGIN}/gh/${owner}/${repository}/${selector.name}`, - runCommand: skillRunCommand(owner, repository, selector.name), - } - }), - } -} - // --- tools --- const SearchArgs = z.object({ @@ -139,6 +136,7 @@ const searchSkills: McpTool = { title: 'Search Skills', description: 'Search the skilld.dev registry for agent skills that match a topic. A skill is a SKILL.md file that a maintainer publishes in their own GitHub repository, and it works with any coding agent. Returns each match with its source repository, GitHub star count, skilld.dev page, and run command.', inputSchema: SearchArgs.shape, + outputSchema: searchOutputSchema, examplePrompt: 'Find a skill for Tailwind CSS.', annotations: { readOnlyHint: true, @@ -152,7 +150,7 @@ const searchSkills: McpTool = { return fail(`Invalid arguments: ${parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`) const { query, limit } = parsed.data const result = await clientFor(deps).skills.search({ query: { q: query, limit } }, { signal }) - return presentResult(deps, 'Search', result._tag === 'Ok' ? { _tag: 'Ok', value: withSkillLinks(result.value) } : result) + return presentResult(deps, 'Search', result, answer => presentSearch(query, answer)) }, } @@ -165,8 +163,9 @@ const GetSkillArgs = z.object({ const getSkill: McpTool = { name: 'get_skill', title: 'Get Skill details', - description: 'Look up one skill by GitHub owner, repository, and skill name. Returns its provenance: the publisher, the exact SKILL.md file and commit on GitHub, and freshness (the last repository push and the last skill change). Also returns the SKILL.md text in markdown, the skilld.dev page, runCommand, and installCommand. runCommand gives the skill to a coding agent for one session and writes no files. installCommand writes the skill into the project for every session. files lists the files beside SKILL.md without their contents. skilld does not check whether a skill is safe.', + description: 'Look up one skill by GitHub owner, repository, and skill name. Returns its provenance: the publisher, the exact SKILL.md file and commit on GitHub, and freshness (the last repository push and the last skill change). Also returns the full SKILL.md text, the skilld.dev page, runCommand, and installCommand. runCommand gives the skill to a coding agent for one session and writes no files. installCommand writes the skill into the project for every session. files lists the files beside SKILL.md without their contents. skilld does not check whether a skill is safe.', inputSchema: GetSkillArgs.shape, + outputSchema: skillOutputSchema, examplePrompt: 'Who wrote vercel-labs/agent-skills/web-design-guidelines, and when did it last change?', annotations: { readOnlyHint: true, @@ -186,8 +185,9 @@ const getSkill: McpTool = { deps, 'Skill lookup', result, + presentSkill, `Skill not found: ${owner}/${repo}/${name}. Try search_skills to find the right ref.`, - // A single Skill cannot be paginated. Preserve its complete SDK answer. + // A single Skill cannot be paginated. Keep its complete SKILL.md. 'complete', ) }, @@ -203,6 +203,7 @@ const listTracks: McpTool = { title: 'List tracks', description: 'List skilld\'s tracks. A track is a page of Skills for one kind of work a developer wants done, such as testing and debugging, planning and specs, or design and interface work. A person picks its first Skills, and a classifier adds the rest. Returns each track\'s slug, label, the goal it serves, its skilld.dev page, and its Skill count.', inputSchema: ListTracksArgs.shape, + outputSchema: trackListOutputSchema, examplePrompt: 'What kinds of work does skilld have Skills for?', annotations: { readOnlyHint: true, @@ -212,7 +213,8 @@ const listTracks: McpTool = { }, run: async (deps, _args, signal) => { const result = await clientFor(deps).tracks.list(undefined, { signal }) - return presentResult(deps, 'Track list', result) + // The client parsed the items with trackSummarySchema. Only the SDK's types lost them. + return presentResult(deps, 'Track list', result, answer => presentTrackList(answer as TrackListAnswer)) }, } @@ -225,8 +227,9 @@ const GetTrackArgs = z.object({ const getTrack: McpTool = { name: 'get_track', title: 'Get track', - description: 'Get the Skills in one track, the page of Skills for one kind of work, such as testing or design. Takes the track slug. Returns the track\'s label, its goal line, its skilld.dev page, and its Skills in page order: the hand-picked Skills first, then the rest by GitHub stars. Each Skill has its author, its source on GitHub, its skilld.dev page, and its run command. total counts every Skill in the track, and limit and offset page through them.', + description: 'Get the Skills in one track, the page of Skills for one kind of work, such as testing or design. Takes the track slug. Returns the track\'s label, its goal line, its skilld.dev page, and its Skills in page order: the hand-picked Skills first, then the rest by GitHub stars. Each Skill has its GitHub owner and repository, its skilld.dev page, and its run command. total counts every Skill in the track, and limit and offset page through them.', inputSchema: GetTrackArgs.shape, + outputSchema: trackOutputSchema, examplePrompt: 'Which skills help with testing and debugging?', annotations: { readOnlyHint: true, @@ -240,7 +243,7 @@ const getTrack: McpTool = { return fail('Invalid arguments: slug is a lowercase track slug, such as "testing"') const { slug, limit, offset } = parsed.data const result = await clientFor(deps).tracks.get({ params: { slug }, query: { limit, offset } }, { signal }) - return presentResult(deps, 'Track lookup', result, `Track not found: ${slug}`) + return presentResult(deps, 'Track lookup', result, answer => presentTrack(answer, offset), `Track not found: ${slug}`) }, } @@ -252,8 +255,9 @@ const ListTrendingArgs = z.object({ const listTrending: McpTool = { name: 'list_trending', title: 'List trending Skills', - description: 'See which Skills developers talked about recently. Returns the skilld.dev trending board for the past week or month, in rank order. Each row says why it is on the board: a social row counts public X and Bluesky posts that mentioned the Skill and includes one post, a star-surge row counts new GitHub stars, and a star-count row fills the rest of the board. Each row has its author, its source on GitHub, its skilld.dev page, and its run command.', + description: 'See which Skills developers talked about recently. Returns the skilld.dev trending board for the past week or month, in rank order. Each row says why it is on the board: a social row counts the devs and the public X and Bluesky posts that mentioned the Skill and links one post, a star-surge row counts new GitHub stars, and a star-count row fills the rest of the board. Each row has its GitHub owner and repository, its skilld.dev page, and its run command.', inputSchema: ListTrendingArgs.shape, + outputSchema: trendingOutputSchema, examplePrompt: 'What skills are developers talking about this week?', annotations: { readOnlyHint: true, @@ -267,7 +271,8 @@ const listTrending: McpTool = { return fail('Invalid arguments: window is "week" or "month", and limit is 1 to 20') const { window, limit } = parsed.data const result = await clientFor(deps).trending.list({ query: { window, limit } }, { signal }) - return presentResult(deps, 'Trending', result) + // The client parsed the items with trendingSkillSchema. Only the SDK's types lost them. + return presentResult(deps, 'Trending', result, answer => presentTrending(window, answer as TrendingAnswer)) }, } @@ -282,6 +287,7 @@ const getRepository: McpTool = { title: 'Get repository', description: 'See every Skill that one GitHub repository publishes. Takes the owner and the repository name. Returns the repository\'s description, GitHub star count, last push, skilld.dev page, and the command that installs all of its Skills. Its Skills come most recently changed first, each with its skilld.dev page and run command. total counts every Skill in the repository.', inputSchema: GetRepositoryArgs.shape, + outputSchema: repositoryOutputSchema, examplePrompt: 'Which skills does vercel-labs/agent-skills publish?', annotations: { readOnlyHint: true, @@ -295,11 +301,8 @@ const getRepository: McpTool = { return fail('Invalid arguments: owner and repo are required strings, and limit is 1 to 30') const { owner, repo, limit } = parsed.data const result = await clientFor(deps).repositories.get({ params: { owner, repository: repo } }, { signal }) - // The answer has no pages. Bound it here so a large Repository stays under the output limit. - const bounded = result._tag === 'Ok' - ? { _tag: 'Ok' as const, value: { ...result.value, skills: result.value.skills.slice(0, limit), total: result.value.skills.length } } - : result - return presentResult(deps, 'Repository lookup', bounded, `Repository not found: ${owner}/${repo}`) + // The answer has no pages. The presenter bounds it so a large Repository stays under the output limit. + return presentResult(deps, 'Repository lookup', result, answer => presentRepository(answer, limit), `Repository not found: ${owner}/${repo}`) }, } @@ -307,14 +310,12 @@ const SubmitRepositoryArgs = z.object({ repository: z.string().trim().min(3).max(2048).regex(/^(?:[\w.-]+\/[\w.-]+|https?:\/\/\S+)$/).describe('owner/repo, or the URL of a public GitHub repository'), }) -/** The most Skills an `indexed` answer returns, like get_repository. */ -const SUBMIT_SKILL_LIMIT = 30 - const submitRepository: McpTool = { name: 'submit_repository', title: 'Submit a repository', - description: 'Ask skilld.dev to index the Skills in a public GitHub repository. Takes owner/repo or a GitHub URL. If the registry already holds Skills from the repository, returns status indexed with those Skills and the repository\'s skilld.dev page. Otherwise it queues an index request and returns status queued, its id, and a status URL. The status URL reports progress, or the reason the request failed, such as a private or missing repository. A second request for a queued repository returns the same id. A person still reviews which Skills the registry admits. This changes nothing in the repository.', + description: 'Ask skilld.dev to index the Skills in a public GitHub repository. Takes owner/repo or a GitHub URL. If the registry already holds Skills from the repository, returns status indexed with its Skill count and the repository\'s skilld.dev page. Otherwise it queues an index request and returns status queued, its id, and a status URL. The status URL reports progress, or the reason the request failed, such as a private or missing repository. A second request for a queued repository returns the same id. A person still reviews which Skills the registry admits. This changes nothing in the repository.', inputSchema: SubmitRepositoryArgs.shape, + outputSchema: indexRequestOutputSchema, examplePrompt: 'Add github.com/vercel-labs/agent-skills to skilld.', annotations: { readOnlyHint: false, @@ -330,14 +331,7 @@ const submitRepository: McpTool = { const result = await clientFor(deps).indexRequests.create({ body: { repository } }, { signal }) if (result._tag === 'Err' && result.error._tag === 'ApiFailure' && result.error.code === 'INVALID_REQUEST') return fail(`Cannot index ${repository}. Send owner/repo or the URL of a public GitHub repository.`) - if (result._tag !== 'Ok') - return presentResult(deps, 'Repository submission', result) - const answer = result.value - const pageUrl = `${SITE_ORIGIN}/gh/${answer.owner}/${answer.repository}` - const value = answer.status === 'indexed' - ? { ...answer, skills: answer.skills.slice(0, SUBMIT_SKILL_LIMIT), total: answer.skills.length, pageUrl } - : { ...answer, pageUrl, statusUrl: `${SITE_ORIGIN}/api/v1/index-requests/${answer.id}` } - return presentResult(deps, 'Repository submission', { _tag: 'Ok', value }) + return presentResult(deps, 'Repository submission', result, presentIndexRequest) }, } @@ -345,20 +339,12 @@ const InstallCommandArgs = z.object({ ref: z.string().trim().min(1).max(300).describe(`Skill or repository reference: ${ACCEPTED_REFS}`), }) -// Only a single-skill ref has a run command, so the note must not promise one -// for a repository ref. -function noteFor(runCommand: string | null): string { - const install = 'The install command writes skill files locally and works with any coding agent.' - if (!runCommand) - return `Run this in the project root. ${install}` - return `Run these in the project root. skilld run prints the skill for this session and writes nothing. ${install}` -} - const installCommand: McpTool = { name: 'install_command', title: 'Get run and install commands', description: `Return the exact skilld CLI commands for a skill or a repository ref. For a single skill, runCommand gives the skill to a coding agent for one session and writes no files. command installs the skills into the project. Accepted refs: ${ACCEPTED_REFS}. This tool returns text only. It runs and installs nothing.`, inputSchema: InstallCommandArgs.shape, + outputSchema: installCommandOutputSchema, examplePrompt: 'Give me the command to try anthropics/skills/skill-creator without installing it.', annotations: { readOnlyHint: true, @@ -376,14 +362,7 @@ const installCommand: McpTool = { `Unrecognized ref: "${parsed.data.ref}". Accepted forms: ${ACCEPTED_REFS}.`, ) } - const runCommand = ref.kind === 'skill' ? skillRunCommand(ref.owner, ref.repo, ref.name) : null - return ok({ - ref: parsed.data.ref, - kind: ref.kind, - runCommand, - command: installCommandFor(ref), - note: noteFor(runCommand), - }) + return ok(presentInstallCommand(ref, parsed.data.ref)) }, } diff --git a/test/fixtures/mcp/repository-vercel-agent-skills.json b/test/fixtures/mcp/repository-vercel-agent-skills.json new file mode 100644 index 00000000..0698ac6f --- /dev/null +++ b/test/fixtures/mcp/repository-vercel-agent-skills.json @@ -0,0 +1 @@ +{"owner":"vercel-labs","repository":"agent-skills","description":"Vercel's official collection of agent skills","stars":32017,"pushedAt":"2026-08-28T13:36:31.000Z","repositoryUrl":"https://github.com/vercel-labs/agent-skills","pageUrl":"https://skilld.dev/gh/vercel-labs/agent-skills","installCommand":"npx skilld add vercel-labs/agent-skills --all","skills":[{"owner":"vercel-labs","repository":"agent-skills","name":"react-view-transitions","displayName":"vercel-react-view-transitions","description":"Guide for implementing smooth, native-feeling animations using React's View Transition API (`` component, `addTransitionType`, and CSS view transition pseudo-elements). Use this skill whenever the user wants to add page transitions, animate route changes, create shared element animations, animate enter/exit of components, animate list reorder, implement directional (forward/back) navigation animations, or integrate view transitions in Next.js. Also use when the user mentions view transitions, `startViewTransition`, `ViewTransition`, transition types, or asks about animating between UI states in React without third-party animation libraries.","stars":32017,"likes":0,"updatedAt":"2026-08-28T07:42:19.000Z","pageUrl":"https://skilld.dev/gh/vercel-labs/agent-skills/react-view-transitions","sourceUrl":"https://github.com/vercel-labs/agent-skills/blob/main/skills/react-view-transitions/SKILL.md","runCommand":"npx skilld run vercel-labs/agent-skills/react-view-transitions","installCommand":"npx skilld install vercel-labs/agent-skills/react-view-transitions"},{"owner":"vercel-labs","repository":"agent-skills","name":"vercel-optimize","displayName":"vercel-optimize","description":"Use for Vercel cost and performance optimization on deployed projects, especially Next.js, SvelteKit, Nuxt, and limited Astro apps. Collect Vercel metrics, usage, project config, and code scan results first; investigate only metric-backed candidates; produce ranked recommendations grounded in verified files and version-aware Vercel/framework docs. Trigger for Vercel bill reduction, slow or expensive routes, caching opportunities, Function Invocations, Build Minutes, Fast Data Transfer, Core Web Vitals, Bot Management, Fluid compute, or cost breakdown requests.","stars":32017,"likes":0,"updatedAt":"2026-05-26T22:53:17.000Z","pageUrl":"https://skilld.dev/gh/vercel-labs/agent-skills/vercel-optimize","sourceUrl":"https://github.com/vercel-labs/agent-skills/blob/main/skills/vercel-optimize/SKILL.md","runCommand":"npx skilld run vercel-labs/agent-skills/vercel-optimize","installCommand":"npx skilld install vercel-labs/agent-skills/vercel-optimize"},{"owner":"vercel-labs","repository":"agent-skills","name":"writing-guidelines","displayName":"writing-guidelines","description":"Review docs/prose for Writing Guidelines compliance. Use when asked to \"review my docs\", \"check writing style\", \"audit prose\", \"review docs voice and tone\", or \"check this page against the writing handbook\".","stars":32017,"likes":0,"updatedAt":"2026-05-26T00:50:54.000Z","pageUrl":"https://skilld.dev/gh/vercel-labs/agent-skills/writing-guidelines","sourceUrl":"https://github.com/vercel-labs/agent-skills/blob/main/skills/writing-guidelines/SKILL.md","runCommand":"npx skilld run vercel-labs/agent-skills/writing-guidelines","installCommand":"npx skilld install vercel-labs/agent-skills/writing-guidelines"},{"owner":"vercel-labs","repository":"agent-skills","name":"vercel-cli-with-tokens","displayName":"vercel-cli-with-tokens","description":"Deploy and manage projects on Vercel using token-based authentication. Use when working with Vercel CLI using access tokens rather than interactive login — e.g. \"deploy to vercel\", \"set up vercel\", \"add environment variables to vercel\".","stars":32017,"likes":0,"updatedAt":"2026-04-16T23:35:50.000Z","pageUrl":"https://skilld.dev/gh/vercel-labs/agent-skills/vercel-cli-with-tokens","sourceUrl":"https://github.com/vercel-labs/agent-skills/blob/main/skills/vercel-cli-with-tokens/SKILL.md","runCommand":"npx skilld run vercel-labs/agent-skills/vercel-cli-with-tokens","installCommand":"npx skilld install vercel-labs/agent-skills/vercel-cli-with-tokens"},{"owner":"vercel-labs","repository":"agent-skills","name":"react-best-practices","displayName":"vercel-react-best-practices","description":"React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.","stars":32017,"likes":0,"updatedAt":"2026-04-14T11:00:18.000Z","pageUrl":"https://skilld.dev/gh/vercel-labs/agent-skills/react-best-practices","sourceUrl":"https://github.com/vercel-labs/agent-skills/blob/main/skills/react-best-practices/SKILL.md","runCommand":"npx skilld run vercel-labs/agent-skills/react-best-practices","installCommand":"npx skilld install vercel-labs/agent-skills/react-best-practices"},{"owner":"vercel-labs","repository":"agent-skills","name":"deploy-to-vercel","displayName":"deploy-to-vercel","description":"Deploy applications and websites to Vercel. Use when the user requests deployment actions like \"deploy my app\", \"deploy and give me the link\", \"push this live\", or \"create a preview deployment\".","stars":32017,"likes":0,"updatedAt":"2026-03-06T14:32:23.000Z","pageUrl":"https://skilld.dev/gh/vercel-labs/agent-skills/deploy-to-vercel","sourceUrl":"https://github.com/vercel-labs/agent-skills/blob/main/skills/deploy-to-vercel/SKILL.md","runCommand":"npx skilld run vercel-labs/agent-skills/deploy-to-vercel","installCommand":"npx skilld install vercel-labs/agent-skills/deploy-to-vercel"},{"owner":"vercel-labs","repository":"agent-skills","name":"composition-patterns","displayName":"vercel-composition-patterns","description":"React composition patterns that scale. Use when refactoring components with boolean prop proliferation, building flexible component libraries, or designing reusable APIs. Triggers on tasks involving compound components, render props, context providers, or component architecture. Includes React 19 API changes.","stars":32017,"likes":0,"updatedAt":"2026-01-28T16:23:54.000Z","pageUrl":"https://skilld.dev/gh/vercel-labs/agent-skills/composition-patterns","sourceUrl":"https://github.com/vercel-labs/agent-skills/blob/main/skills/composition-patterns/SKILL.md","runCommand":"npx skilld run vercel-labs/agent-skills/composition-patterns","installCommand":"npx skilld install vercel-labs/agent-skills/composition-patterns"},{"owner":"vercel-labs","repository":"agent-skills","name":"react-native-skills","displayName":"vercel-react-native-skills","description":"React Native and Expo best practices for building performant mobile apps. Use when building React Native components, optimizing list performance, implementing animations, or working with native modules. Triggers on tasks involving React Native, Expo, mobile performance, or native platform APIs.","stars":32017,"likes":0,"updatedAt":"2026-01-27T00:48:33.000Z","pageUrl":"https://skilld.dev/gh/vercel-labs/agent-skills/react-native-skills","sourceUrl":"https://github.com/vercel-labs/agent-skills/blob/main/skills/react-native-skills/SKILL.md","runCommand":"npx skilld run vercel-labs/agent-skills/react-native-skills","installCommand":"npx skilld install vercel-labs/agent-skills/react-native-skills"},{"owner":"vercel-labs","repository":"agent-skills","name":"web-design-guidelines","displayName":"web-design-guidelines","description":"Review UI code for Web Interface Guidelines compliance. Use when asked to \"review my UI\", \"check accessibility\", \"audit design\", \"review UX\", or \"check my site against best practices\".","stars":32017,"likes":0,"updatedAt":"2026-01-16T23:47:11.000Z","pageUrl":"https://skilld.dev/gh/vercel-labs/agent-skills/web-design-guidelines","sourceUrl":"https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md","runCommand":"npx skilld run vercel-labs/agent-skills/web-design-guidelines","installCommand":"npx skilld install vercel-labs/agent-skills/web-design-guidelines"}]} \ No newline at end of file diff --git a/test/fixtures/mcp/search-tailwind.json b/test/fixtures/mcp/search-tailwind.json new file mode 100644 index 00000000..db52b6ad --- /dev/null +++ b/test/fixtures/mcp/search-tailwind.json @@ -0,0 +1 @@ +{"items":[{"name":"tailwind","description":"Tailwind CSS v4 styling and configuration.","source":{"provider":"github","owner":"pymodel","repository":"react-frontend-skills","selector":{"type":"named-skill","name":"tailwind"}},"stargazerCount":31},{"name":"tailwind-css","description":"Use for Tailwind v4 styling: add/fix classes, configure or migrate Tailwind, use tailwind-variants, or tw-animate-css.","source":{"provider":"github","owner":"paulrberg","repository":"agent-skills","selector":{"type":"named-skill","name":"tailwind-css"}},"stargazerCount":97},{"name":"frontend-tailwind-best-practices","description":"Tailwind CSS patterns and conventions for frontend apps. Use when writing component styles, layouts, or working with CSS classes.","source":{"provider":"github","owner":"sergiodxa","repository":"agent-skills","selector":{"type":"named-skill","name":"frontend-tailwind-best-practices"}},"stargazerCount":90},{"name":"design-tokens","description":"Generate type scales, color palettes, spacing systems, WCAG contrast checks, and dark mode derivations with math. Use when setting up a design system, creating tokens, or building a Tailwind/CSS theme. Outputs CSS custom properties, Tailwind config, or JSON tokens.","source":{"provider":"github","owner":"dylanfeltus","repository":"skills","selector":{"type":"named-skill","name":"design-tokens"}},"stargazerCount":179},{"name":"shadcn-svelte","description":"Use when working with shadcn-svelte components, TanStack Table in Svelte 5, or Tailwind v4.1. Covers non-obvious reactivity bugs, library selection trade-offs, and migration pitfalls not in the official docs. Keywords: shadcn-svelte, TanStack Table, Tailwind v4.1, Svelte 5 runes, bits-ui, superforms, data table, svelte-check. Triggers on \"use shadcn-svelte\" or \"build Svelte data table\".","source":{"provider":"github","owner":"acedergren","repository":"agentic-tools","selector":{"type":"named-skill","name":"shadcn-svelte"}},"stargazerCount":26}],"total":92} \ No newline at end of file diff --git a/test/fixtures/mcp/skill-web-design-guidelines.json b/test/fixtures/mcp/skill-web-design-guidelines.json new file mode 100644 index 00000000..28d40470 --- /dev/null +++ b/test/fixtures/mcp/skill-web-design-guidelines.json @@ -0,0 +1 @@ +{"owner":"vercel-labs","repository":"agent-skills","name":"web-design-guidelines","displayName":"web-design-guidelines","description":"Review UI code for Web Interface Guidelines compliance. Use when asked to \"review my UI\", \"check accessibility\", \"audit design\", \"review UX\", or \"check my site against best practices\".","stars":32017,"likes":0,"updatedAt":"2026-01-16T23:47:11.000Z","pageUrl":"https://skilld.dev/gh/vercel-labs/agent-skills/web-design-guidelines","sourceUrl":"https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md","runCommand":"npx skilld run vercel-labs/agent-skills/web-design-guidelines","installCommand":"npx skilld install vercel-labs/agent-skills/web-design-guidelines","authorName":"Vercel Labs","license":null,"repositoryUrl":"https://github.com/vercel-labs/agent-skills","skillPath":"skills/web-design-guidelines/SKILL.md","sourceCommit":"ba46938889d4e58635362fb8f618e1178ac3ec46","sourceGone":false,"pushedAt":"2026-08-28T13:36:31.000Z","tags":[],"allowedTools":[],"files":[],"generatedSummary":"Audits UI code against Vercel's Web Interface Guidelines by fetching the latest ruleset and reporting compliance issues in a terse format. Use this to review HTML, CSS, or component files for accessibility, design system adherence, and UX best practices.","markdown":"---\nname: web-design-guidelines\ndescription: Review UI code for Web Interface Guidelines compliance. Use when asked to \"review my UI\", \"check accessibility\", \"audit design\", \"review UX\", or \"check my site against best practices\".\nmetadata:\n author: vercel\n version: \"1.0.0\"\n argument-hint: \n---\n\n# Web Interface Guidelines\n\nReview files for compliance with Web Interface Guidelines.\n\n## How It Works\n\n1. Fetch the latest guidelines from the source URL below\n2. Read the specified files (or prompt user for files/pattern)\n3. Check against all rules in the fetched guidelines\n4. Output findings in the terse `file:line` format\n\n## Guidelines Source\n\nFetch fresh guidelines before each review:\n\n```\nhttps://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md\n```\n\nUse WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.\n\n## Usage\n\nWhen a user provides a file or pattern argument:\n1. Fetch guidelines from the source URL above\n2. Read the specified files\n3. Apply all rules from the fetched guidelines\n4. Output findings using the format specified in the guidelines\n\nIf no files specified, ask the user which files to review.\n"} \ No newline at end of file diff --git a/test/fixtures/mcp/track-testing.json b/test/fixtures/mcp/track-testing.json new file mode 100644 index 00000000..f5ac77c7 --- /dev/null +++ b/test/fixtures/mcp/track-testing.json @@ -0,0 +1 @@ +{"slug":"testing","label":"Testing and debugging","line":"You want tests that prove the change, and a cause when it breaks.","pageUrl":"https://skilld.dev/skills/testing","items":[{"owner":"mattpocock","repository":"skills","name":"tdd","displayName":"tdd","description":"Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions \"red-green-refactor\", or wants integration tests.","stars":278406,"likes":0,"updatedAt":"2026-08-15T20:18:23.000Z","pageUrl":"https://skilld.dev/gh/mattpocock/skills/tdd","sourceUrl":"https://github.com/mattpocock/skills/blob/main/skills/engineering/tdd/SKILL.md","runCommand":"npx skilld run mattpocock/skills/tdd","installCommand":"npx skilld install mattpocock/skills/tdd"},{"owner":"obra","repository":"superpowers","name":"test-driven-development","displayName":"test-driven-development","description":"Use when implementing any feature or bugfix, before writing implementation code","stars":296004,"likes":0,"updatedAt":"2026-09-19T00:31:35.000Z","pageUrl":"https://skilld.dev/gh/obra/superpowers/test-driven-development","sourceUrl":"https://github.com/obra/superpowers/blob/main/skills/test-driven-development/SKILL.md","runCommand":"npx skilld run obra/superpowers/test-driven-development","installCommand":"npx skilld install obra/superpowers/test-driven-development"},{"owner":"bitwarden","repository":"ai-plugins","name":"assessing-test-coverage","displayName":"assessing-test-coverage","description":"Use when determining what test coverage ALREADY exists for a specific change (a PR, Jira key, Tech Breakdown doc, Testmo CSV, changed paths, or named component). Triggers on \"what's already tested\", \"does this PR have tests\", \"what coverage exists for\", \"is this component covered\", or \"which behaviors have no test today\". This is a backward-looking inventory of existing coverage for a concrete change. Do NOT use it to recommend or decide which new tests to add (\"should I add integration tests here\", \"are unit tests enough\"), to design a test strategy or plan, to run or fix existing tests, or to explain testing concepts like the test pyramid or which layers a repo uses — those are all out of scope.","stars":154,"likes":0,"updatedAt":"2026-07-30T12:59:24.000Z","pageUrl":"https://skilld.dev/gh/bitwarden/ai-plugins/assessing-test-coverage","sourceUrl":"https://github.com/bitwarden/ai-plugins/blob/main/plugins/bitwarden-testing-tools/skills/assessing-test-coverage/SKILL.md","runCommand":"npx skilld run bitwarden/ai-plugins/assessing-test-coverage","installCommand":"npx skilld install bitwarden/ai-plugins/assessing-test-coverage"},{"owner":"obra","repository":"superpowers","name":"systematic-debugging","displayName":"systematic-debugging","description":"Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes","stars":296004,"likes":0,"updatedAt":"2026-07-05T19:23:39.000Z","pageUrl":"https://skilld.dev/gh/obra/superpowers/systematic-debugging","sourceUrl":"https://github.com/obra/superpowers/blob/main/skills/systematic-debugging/SKILL.md","runCommand":"npx skilld run obra/superpowers/systematic-debugging","installCommand":"npx skilld install obra/superpowers/systematic-debugging"},{"owner":"wdm0006","repository":"python-skills","name":"verifying-external-behavior","displayName":"verifying-external-behavior","description":"Confirms what a third-party library, remote API, build backend, or scraped document actually does before writing code that depends on it — throwaway probes that run in seconds, permissive clients that forward wrong arguments instead of rejecting them, per-endpoint docs that don't generalize, response shapes that make a \"fast path\" always-false, fakes that encode your assumption rather than the service's behavior, and dry-runs that skip the step that fails. Use when integrating a new dependency or endpoint, writing a tolerated-status or error branch, choosing a client argument name, testing against a fake, or reviewing code that asserts an upstream contract.","stars":99,"likes":0,"updatedAt":"2026-08-01T13:34:56.000Z","pageUrl":"https://skilld.dev/gh/wdm0006/python-skills/verifying-external-behavior","sourceUrl":"https://github.com/wdm0006/python-skills/blob/main/skills/common/verifying-external-behavior/SKILL.md","runCommand":"npx skilld run wdm0006/python-skills/verifying-external-behavior","installCommand":"npx skilld install wdm0006/python-skills/verifying-external-behavior"},{"owner":"anthropics","repository":"skills","name":"webapp-testing","displayName":"webapp-testing","description":"Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.","stars":179950,"likes":0,"updatedAt":"2025-12-01T18:05:36.000Z","pageUrl":"https://skilld.dev/gh/anthropics/skills/webapp-testing","sourceUrl":"https://github.com/anthropics/skills/blob/main/skills/webapp-testing/SKILL.md","runCommand":"npx skilld run anthropics/skills/webapp-testing","installCommand":"npx skilld install anthropics/skills/webapp-testing"},{"owner":"deanpeters","repository":"product-manager-skills","name":"autonomous-investigation","displayName":"autonomous-investigation","description":"The protocol behind every investigation skill. Use when AI research must proceed without you: search-plan gate, Fact/Inference/Assumption labels, confidence stacking, diffable outputs.","stars":7168,"likes":0,"updatedAt":"2026-07-17T13:13:55.000Z","pageUrl":"https://skilld.dev/gh/deanpeters/product-manager-skills/autonomous-investigation","sourceUrl":"https://github.com/deanpeters/Product-Manager-Skills/blob/main/skills/autonomous-investigation/SKILL.md","runCommand":"npx skilld run deanpeters/product-manager-skills/autonomous-investigation","installCommand":"npx skilld install deanpeters/product-manager-skills/autonomous-investigation"},{"owner":"boshu2","repository":"agentops","name":"post-mortem","displayName":"post-mortem","description":"Compatibility pointer for the canonical postmortem skill.","stars":447,"likes":0,"updatedAt":"2026-07-14T06:06:44.000Z","pageUrl":"https://skilld.dev/gh/boshu2/agentops/post-mortem","sourceUrl":"https://github.com/boshu2/agentops/blob/main/skills/post-mortem/SKILL.md","runCommand":"npx skilld run boshu2/agentops/post-mortem","installCommand":"npx skilld install boshu2/agentops/post-mortem"},{"owner":"obra","repository":"superpowers","name":"verification-before-completion","displayName":"verification-before-completion","description":"Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always","stars":296004,"likes":0,"updatedAt":"2026-07-05T19:24:18.000Z","pageUrl":"https://skilld.dev/gh/obra/superpowers/verification-before-completion","sourceUrl":"https://github.com/obra/superpowers/blob/main/skills/verification-before-completion/SKILL.md","runCommand":"npx skilld run obra/superpowers/verification-before-completion","installCommand":"npx skilld install obra/superpowers/verification-before-completion"},{"owner":"mattpocock","repository":"skills","name":"diagnosing-bugs","displayName":"diagnosing-bugs","description":"Diagnosis loop for hard bugs and performance regressions. Use when the user says \"diagnose\"/\"debug this\", or reports something broken/throwing/failing/slow.","stars":278406,"likes":0,"updatedAt":"2026-08-15T20:18:23.000Z","pageUrl":"https://skilld.dev/gh/mattpocock/skills/diagnosing-bugs","sourceUrl":"https://github.com/mattpocock/skills/blob/main/skills/engineering/diagnosing-bugs/SKILL.md","runCommand":"npx skilld run mattpocock/skills/diagnosing-bugs","installCommand":"npx skilld install mattpocock/skills/diagnosing-bugs"}],"total":98} \ No newline at end of file diff --git a/test/fixtures/mcp/tracks.json b/test/fixtures/mcp/tracks.json new file mode 100644 index 00000000..ad5c724f --- /dev/null +++ b/test/fixtures/mcp/tracks.json @@ -0,0 +1 @@ +{"items":[{"slug":"planning","label":"Planning and specs","line":"You turn rough ideas into plans, specs, and scoped work.","pageUrl":"https://skilld.dev/skills/planning","skillCount":317},{"slug":"anti-slop","label":"Anti-slop writing","line":"You want prose that reads like a person wrote it, with the AI tells gone.","pageUrl":"https://skilld.dev/skills/anti-slop","skillCount":160},{"slug":"testing","label":"Testing and debugging","line":"You want tests that prove the change, and a cause when it breaks.","pageUrl":"https://skilld.dev/skills/testing","skillCount":98},{"slug":"devops","label":"Shipping and release","line":"You own the commits, branches, and deploys at the end of the work.","pageUrl":"https://skilld.dev/skills/devops","skillCount":96},{"slug":"backend-data","label":"Backend, data, and security","line":"You wire up databases, auth, security, and realtime systems.","pageUrl":"https://skilld.dev/skills/backend-data","skillCount":71},{"slug":"code-review","label":"Review and refactoring","line":"You read other devs' code and reshape it without breaking it.","pageUrl":"https://skilld.dev/skills/code-review","skillCount":70},{"slug":"design","label":"Design and interface work","line":"You care how the interface looks, moves, and reads.","pageUrl":"https://skilld.dev/skills/design","skillCount":62},{"slug":"coding","label":"Everyday coding","line":"You want the handful of skills that earn their context on every project.","pageUrl":"https://skilld.dev/skills/coding","skillCount":53},{"slug":"context-engineering","label":"Agent workflows","line":"You run agents in parallel, drive a browser, and verify what they hand back.","pageUrl":"https://skilld.dev/skills/context-engineering","skillCount":41},{"slug":"diagrams","label":"Diagrams and codebase maps","line":"You need the system drawn before you can change it.","pageUrl":"https://skilld.dev/skills/diagrams","skillCount":39},{"slug":"performance","label":"Performance and web quality","line":"You profile the slow path before you ship it.","pageUrl":"https://skilld.dev/skills/performance","skillCount":31},{"slug":"research","label":"Research and web content","line":"You send the agent out to read the web and bring back sources.","pageUrl":"https://skilld.dev/skills/research","skillCount":28},{"slug":"seo","label":"SEO","line":"You want the crawler and the model to read the page the same way you do.","pageUrl":"https://skilld.dev/skills/seo","skillCount":10},{"slug":"anti-slop-coding","label":"Anti-slop coding","line":"You want agent-written code cleaned before it reaches review.","pageUrl":"https://skilld.dev/skills/anti-slop-coding","skillCount":6}],"total":14} \ No newline at end of file diff --git a/test/fixtures/mcp/trending-week.json b/test/fixtures/mcp/trending-week.json new file mode 100644 index 00000000..fe370213 --- /dev/null +++ b/test/fixtures/mcp/trending-week.json @@ -0,0 +1 @@ +{"items":[{"owner":"lemomo-ai","repository":"lemo-opuscar","name":"lemo-opuscar","displayName":"lemo-opuscar","description":"Direct and produce a short film made entirely in code, in one of the styles of the Lemo-Opuscar library (e.g. Impasto Oil Painting 油画厚涂, Watercolor Brush 水彩笔刷, Chinese Ink Wash 中国水墨, Ukiyo-e 浮世绘, Whiteboard Explainer 白板讲解). Use when the user asks for a video, film, short, promo, explainer or animation (视频、短片、动画、宣传片) in a named style; when they want a film made about their own topic and haven't chosen a style (help them choose); or when they ask which film styles there are. Not for editing or converting existing video files.","stars":1188,"likes":0,"updatedAt":"2026-09-29T11:42:39.000Z","pageUrl":"https://skilld.dev/gh/lemomo-ai/lemo-opuscar","sourceUrl":"https://github.com/lemomo-ai/lemo-opuscar/blob/main/plugin/skills/lemo-opuscar/SKILL.md","runCommand":"npx skilld run lemomo-ai/lemo-opuscar/lemo-opuscar","installCommand":"npx skilld install lemomo-ai/lemo-opuscar/lemo-opuscar","signal":{"kind":"social","authorCount":6,"mentionCount":6,"post":{"url":"https://x.com/ScaleWthAI/status/2106386087463772495","platform":"x","authorHandle":"ScaleWthAI","authorName":"Saman Ahmed","text":"Claude Opus 5.5 is turning GitHub into a playground for creators.\n\nIn just one week, people shipped everything from coded music videos to full 3D games.\n\n1. PDoomVideo: the Opus 5.5 P(doom) music video, every frame in code (1.5k stars)\nhttps://t.co/D3jokz8BXG\n\n2. awesome-opus5-5-videos: 475 viral Opus 5.5 videos with their prompts (1k stars)\nhttps://t.co/TN6YIciyfj\n\n3. tidewater: a WebGPU fishing game built with Opus 5.5 (988 stars)\nhttps://t.co/z2XcLNsrRU\n\n4. claude-opus-5-…","postedAt":"2026-10-03T14:09:12.000Z"}}},{"owner":"nanaism","repository":"yomiyasu","name":"yomiyasu","displayName":"yomiyasu","description":"AIが生成した日本語の推敲に使うスキル。「この文章を読みやすくして」「AIっぽさをなくして」「自然な日本語にして」「文章を脱臭して」といった依頼や、技術記事、仕様書、PR説明文、社内レポート、エッセイ・noteの文章を見直すときに使用する。意味と条件を保ち、不自然な比喩、曖昧な主述関係、不要な装飾やコピー調を整える。","stars":1541,"likes":0,"updatedAt":"2026-10-05T08:30:12.000Z","pageUrl":"https://skilld.dev/gh/nanaism/yomiyasu","sourceUrl":"https://github.com/nanaism/yomiyasu/blob/main/skills/yomiyasu/SKILL.md","runCommand":"npx skilld run nanaism/yomiyasu/yomiyasu","installCommand":"npx skilld install nanaism/yomiyasu/yomiyasu","signal":{"kind":"social","authorCount":6,"mentionCount":6,"post":{"url":"https://x.com/Marco_Ramilli/status/2106717225407172828","platform":"x","authorHandle":"Marco_Ramilli","authorName":"Marco Ramilli","text":"yomiyasu\n1,355 stars\n\nTired of robotic AI-generated Japanese? Polish your LLM outputs into natural, human-grade prose with this seamless agent skill.\n\nhttps://t.co/RCSINWNrI7\n\n#AI #MachineLearning https://t.co/51ifeCwaiz","postedAt":"2026-10-04T12:05:02.000Z"}}},{"owner":"virgiliojr94","repository":"book-to-skill","name":"book-to-skill","displayName":"book-to-skill","description":"Converts books and documents (PDF, EPUB, DOCX, HTML, Markdown, plain text, RTF, MOBI/AZW with Calibre) into structured agent skills, extracting frameworks, mental models, principles, techniques, and anti-patterns. Use when the user wants to study a document through GitHub Copilot CLI, Amp, Claude Code, Hermes Agent, OpenCode, or OpenClaw, apply an author's frameworks while working, or build a reusable knowledge base from a file.","stars":33925,"likes":0,"updatedAt":"2026-09-29T14:08:10.000Z","pageUrl":"https://skilld.dev/gh/virgiliojr94/book-to-skill","sourceUrl":"https://github.com/virgiliojr94/book-to-skill/blob/master/SKILL.md","runCommand":"npx skilld run virgiliojr94/book-to-skill/book-to-skill","installCommand":"npx skilld install virgiliojr94/book-to-skill/book-to-skill","signal":{"kind":"social-and-star-surge","authorCount":3,"mentionCount":3,"post":{"url":"https://x.com/tom_doerr/status/2106234841888899211","platform":"x","authorHandle":"tom_doerr","authorName":"Tom Dörr","text":"book-to-skill converts technical books into agent skills for tools like Claude Code to reference directly\n\nhttps://t.co/GFnX15PgCP https://t.co/ipieS50aYd","postedAt":"2026-10-03T04:08:12.000Z"},"starGain":327,"surgedOn":"2026-10-04T00:00:00.000Z"}},{"owner":"leonxlnx","repository":"taste-skill","name":"taste-skill","displayName":"design-taste-frontend","description":"Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check.","stars":92983,"likes":0,"updatedAt":"2026-05-26T19:31:36.000Z","pageUrl":"https://skilld.dev/gh/leonxlnx/taste-skill/taste-skill","sourceUrl":"https://github.com/Leonxlnx/taste-skill/blob/main/skills/taste-skill/SKILL.md","runCommand":"npx skilld run leonxlnx/taste-skill/taste-skill","installCommand":"npx skilld install leonxlnx/taste-skill/taste-skill","signal":{"kind":"social","authorCount":3,"mentionCount":3,"post":{"url":"https://x.com/Voxyz_ai/status/2106474370860548341","platform":"x","authorHandle":"Voxyz_ai","authorName":"Vox","text":"Opus 5.5 can make top-tier web design, as long as you show it top-tier design first.\n\nIf your pages still look AI-made at a glance, it probably hasn't seen the kind of design you like.\n\nStep 1: pick 3 to 5 sites you like on Godly, Refero or One Page Love, drag the screenshots straight into Claude Code, and have it tell you what makes each one look good.\n\nStep 2: turn the parts you pick into a DESIGN.md and import it into CLAUDE.md. Every page after that follows it.\n\nStep 3: …","postedAt":"2026-10-03T20:00:01.000Z"}}},{"owner":"cloudflare","repository":"security-audit-skill","name":"security-audit","displayName":"security-audit","description":"Security guidance and vulnerability review for codebases, APIs, services, CLI tools, libraries, and daemons. Use for security questions, focused reviews, vulnerability research, security audits, or pen tests. Run the complete workflow only for explicit codebase audit or pen-test requests, full/comprehensive/end-to-end reviews, or requested report artifacts.","stars":24823,"likes":0,"updatedAt":"2026-09-14T19:28:54.000Z","pageUrl":"https://skilld.dev/gh/cloudflare/security-audit-skill","sourceUrl":"https://github.com/cloudflare/security-audit-skill/blob/main/skills/security-audit/SKILL.md","runCommand":"npx skilld run cloudflare/security-audit-skill/security-audit","installCommand":"npx skilld install cloudflare/security-audit-skill/security-audit","signal":{"kind":"social-and-star-surge","authorCount":5,"mentionCount":5,"post":{"url":"https://x.com/Voxyz_ai/status/2107486036314227068","platform":"x","authorHandle":"Voxyz_ai","authorName":"Vox","text":"Holy shit, tell Claude Code or Codex \"security audit this codebase\" in your project, and it spins up a batch of agents to hunt for vulnerabilities, then hands every suspected issue to a fresh agent that tries to disprove it. Cloudflare's vulnerability-hunting harness grew out of this open-source skill, and the repo picked up 20K stars in September alone.\n\nIt checks:\n- Data isolation: can one user's data leak into another's\n- Login and auth\n- Prompt injection and agent tool c…","postedAt":"2026-10-06T15:00:00.000Z"},"starGain":408,"surgedOn":"2026-10-06T00:00:00.000Z"}},{"owner":"pbakaus","repository":"impeccable","name":"impeccable","displayName":"impeccable","description":"Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.","stars":77848,"likes":0,"updatedAt":"2026-10-04T22:22:22.000Z","pageUrl":"https://skilld.dev/gh/pbakaus/impeccable","sourceUrl":"https://github.com/pbakaus/impeccable/blob/main/cursor-plugin/skills/impeccable/SKILL.md","runCommand":"npx skilld run pbakaus/impeccable/impeccable","installCommand":"npx skilld install pbakaus/impeccable/impeccable","signal":{"kind":"social","authorCount":7,"mentionCount":7,"post":{"url":"https://x.com/0xal0ke/status/2105761017875820695","platform":"x","authorHandle":"0xal0ke","authorName":"Ali","text":"most people are still installing random skill packs\n\nalmost nobody picks the ones that change how the agent thinks\n\n5 repos that actually matter for that:\n\n• tt-a1i/archify — 75.8k★\nagent skill for verifiable architecture diagrams with motion\nhttps://t.co/8JdZFZvnUE\n\n• pbakaus/impeccable — 73.6k★\ndesign language that makes your AI harness stop looking like a template\nhttps://t.co/FQVVZ0daUd\n\n• ayghri/i-have-adhd — 52.7k★\nskill that stops your coding agent from burying the an…","postedAt":"2026-10-01T20:45:24.000Z"}}},{"owner":"ericzakariasson","repository":"skills","name":"game-builder","displayName":"game-builder","description":"Builds playable homage games (original takes on well-known games, with original names and art) whose in-game captures must hold up side by side with real shipped-game screenshots. Orchestrates separate planner, builder, and critic subagents through a brief with locked reference shots, a plan, a playable loop, a staged art pipeline, and live captures, then runs fresh-critic rounds against a per-game Visual Bar until WIN, with a diagnoser for stuck loops and a final orchestrator visual read before handoff. Use when asked to build, polish, or compare homage games or vertical slices where visual quality next to real games matters, when a game plays fine but its screenshots look like a browser toy beside the references, or when a reviewer keeps soft-passing weak visuals. Not for general app scaffolds or gameplay-only prototypes.","stars":20,"likes":0,"updatedAt":"2026-09-30T17:18:16.000Z","pageUrl":"https://skilld.dev/gh/ericzakariasson/skills/game-builder","sourceUrl":"https://github.com/ericzakariasson/skills/blob/main/skills/game-builder/SKILL.md","runCommand":"npx skilld run ericzakariasson/skills/game-builder","installCommand":"npx skilld install ericzakariasson/skills/game-builder","signal":{"kind":"social","authorCount":2,"mentionCount":2,"post":{"url":"https://x.com/ericzakariasson/status/2105359599234855405","platform":"x","authorHandle":"ericzakariasson","authorName":"eric zakariasson","text":"here's the skill ive been using to build games with new models\n\nnpx skills add ericzakariasson/skills --skill game-builder https://t.co/qhmVyL0lyG","postedAt":"2026-09-30T18:10:18.000Z"}}},{"owner":"makevoid","repository":"motion-graphics-music-video-skill","name":"motion-graphics-music-video","displayName":"motion-graphics-music-video","description":"Create and revise animated character music videos from a supplied song and creative prompt, with researched storyboards, Fal character and video generation, p5 motion graphics, audio editing, and Swift VFX. Use for full music videos or revisions to their characters, scenes, animation, sound, and effects.","stars":130,"likes":0,"updatedAt":"2026-09-30T02:35:21.000Z","pageUrl":"https://skilld.dev/gh/makevoid/motion-graphics-music-video-skill","sourceUrl":"https://github.com/makevoid/motion-graphics-music-video-skill/blob/main/.claude/skills/motion-graphics-music-video/SKILL.md","runCommand":"npx skilld run makevoid/motion-graphics-music-video-skill/motion-graphics-music-video","installCommand":"npx skilld install makevoid/motion-graphics-music-video-skill/motion-graphics-music-video","signal":{"kind":"social","authorCount":3,"mentionCount":3,"post":{"url":"https://x.com/socialwithaayan/status/2105271130215064056","platform":"x","authorHandle":"socialwithaayan","authorName":"Muhammad Ayan","text":"10 GitHub repos from Claude Opus 5.5 week, from motion graphics to full games\n\n1. PDoomVideo: the Opus 5.5 P(doom) music video, every frame in code (1.5k stars)\nhttps://t.co/XYmwFwmlJK\n\n2. awesome-opus5-5-videos: 475 viral Opus 5.5 videos with their prompts (1k stars)\nhttps://t.co/GGJUepNjTG\n\n3. tidewater: a WebGPU fishing game built with Opus 5.5 (988 stars)\nhttps://t.co/vkIkczkeSe\n\n4. claude-opus-5-5-demo: three 3D web games, one prompt each (937 stars)\nhttps://t.co/SXBdV1…","postedAt":"2026-09-30T12:18:46.000Z"}}},{"owner":"egonex-ai","repository":"understand-anything","name":"understand","displayName":"understand","description":"Analyze a codebase to produce an interactive knowledge graph for understanding architecture, components, and relationships","stars":85379,"likes":0,"updatedAt":"2026-10-05T16:53:18.000Z","pageUrl":"https://skilld.dev/gh/egonex-ai/understand-anything/understand","sourceUrl":"https://github.com/Egonex-AI/Understand-Anything/blob/main/understand-anything-plugin/skills/understand/SKILL.md","runCommand":"npx skilld run egonex-ai/understand-anything/understand","installCommand":"npx skilld install egonex-ai/understand-anything/understand","signal":{"kind":"social","authorCount":4,"mentionCount":4,"post":{"url":"https://x.com/RoundtableSpace/status/2105196908285227158","platform":"x","authorHandle":"RoundtableSpace","authorName":"0xMarioNawfal","text":"10 OpenCode skill repos worth saving:\n\n1.⁠ ⁠Superpowers\n⁠ https://t.co/bxKZ1aMbbl ⁠\n\n2.⁠ ⁠Ponytail\n⁠ https://t.co/9M6VCTNmGS ⁠\n\n3.⁠ ⁠UI/UX Pro Max\n⁠ https://t.co/Z76SaxFXuJ ⁠\n\n4.⁠ ⁠Graphify\n⁠ https://t.co/qaZQ3CLibj ⁠\n\n5.⁠ ⁠Caveman\n⁠ https://t.co/If0ai4Zy3E ⁠\n\n6.⁠ ⁠Addy Osmani Skills\n⁠ https://t.co/dVIJIyDLv5 ⁠\n\n7.⁠ ⁠Understand Anything\n⁠ https://t.co/Rue3T90NaK ⁠\n\n8.⁠ ⁠Awesome Claude Skills\n⁠ https://t.co/5lbBAQSUuV ⁠\n\n9.⁠ ⁠Archify\n⁠ https://t.co/JwmXyMNDB7 ⁠\n\n10.⁠ ⁠Impecca…","postedAt":"2026-09-30T07:23:50.000Z"}}},{"owner":"typesafe-ai","repository":"skills","name":"typesafe-ai","displayName":"typesafe-ai","description":"Build AI-powered software with TypeSafe: small units of AI intelligence you can use like programming primitives. Its System One models, including Jev, turn natural language and application state into typed judgments and probabilities that code can combine. Use when a feature needs programmable common sense, when brainstorming what AI could make possible in an app, or when an LLM prompt-and-parse step could become a structured decision. Applications include routing, ranking, extraction, verification, and interactive experiences; these are starting points, not the limits. Read live docs and cookbooks to find useful patterns and discover new combinations.","stars":2607,"likes":0,"updatedAt":"2026-09-12T05:42:05.000Z","pageUrl":"https://skilld.dev/gh/typesafe-ai/skills","sourceUrl":"https://github.com/typesafe-ai/skills/blob/main/skills/typesafe-ai/SKILL.md","runCommand":"npx skilld run typesafe-ai/skills/typesafe-ai","installCommand":"npx skilld install typesafe-ai/skills/typesafe-ai","signal":{"kind":"social","authorCount":2,"mentionCount":2,"post":{"url":"https://x.com/polydao/status/2105977846095040587","platform":"x","authorHandle":"polydao","authorName":"Mr. Buzzoni","text":"CLAUDE OPUS 5.5 + FABLE 5.1 + JEV ENGINEERING = THE CHEAPEST CODING LOOP YOU CAN RUN\n\none real multi-file refactor in Claude Code hid 31 checks inside it\n\ndid it pass, is it the right file, merge, retry or flag a person\n\nthe core idea:\n25-40% of model calls in any agent loop with a review step are checks\nClaude writes the code. Jev makes the calls\n\nwho owns which job:\n> read a diff, write a review - Claude\n> does this pass, is this the right file - Jev\n> write the fix - Clau…","postedAt":"2026-10-02T11:07:00.000Z"}}}],"total":30} \ No newline at end of file diff --git a/test/unit/mcp-discovery.test.ts b/test/unit/mcp-discovery.test.ts index 977989fd..3dd43c35 100644 --- a/test/unit/mcp-discovery.test.ts +++ b/test/unit/mcp-discovery.test.ts @@ -8,11 +8,21 @@ function deps(fetchApi: McpToolDeps['fetchApi'] = vi.fn()): McpToolDeps { return { fetchApi, reportError: vi.fn() } } -async function runTool(toolName: string, args: unknown, toolDeps = deps(), signal?: AbortSignal): Promise { +function toolNamed(toolName: string) { const tool = mcpTools.find(candidate => candidate.name === toolName) if (!tool) throw new Error(`Missing tool: ${toolName}`) - return tool.run(toolDeps, args, signal) + return tool +} + +async function runTool(toolName: string, args: unknown, toolDeps = deps(), signal?: AbortSignal): Promise { + return toolNamed(toolName).run(toolDeps, args, signal) +} + +/** The MCP SDK rejects an answer that breaks the tool's output schema, so every success must parse. */ +function structured(toolName: string, result: McpToolResult): any { + expect(result.isError).not.toBe(true) + return toolNamed(toolName).outputSchema.parse(result.structuredContent) } function problem(code: 'NOT_FOUND' | 'RATE_LIMITED' | 'INTERNAL_ERROR' | 'SERVICE_UNAVAILABLE' | 'INVALID_REQUEST', status: number): Response { @@ -34,16 +44,14 @@ describe('mCP public SDK discovery', () => { const result = await runTool('search_skills', { query: 'tailwind', limit: 1 }, deps(fetchApi), signal) - const linked = { - ...response, - items: response.items.map(item => ({ - ...item, - pageUrl: `https://skilld.dev/gh/${item.source.owner}/${item.source.repository}/${item.source.selector.name}`, - runCommand: `npx skilld run ${item.source.owner}/${item.source.repository}/${item.source.selector.name}`, - })), + const answer = structured('search_skills', result) + expect(answer.query).toBe('tailwind') + expect(answer.total).toBe(response.total) + for (const item of response.items) { + const ref = `${item.source.owner}/${item.source.repository}/${item.source.selector.name}` + expect(answer.items).toContainEqual(expect.objectContaining({ ref, pageUrl: `https://skilld.dev/gh/${ref}`, runCommand: `npx skilld run ${ref}` })) + expect(result.content[0]!.text).toContain(`npx skilld run ${ref}`) } - expect(result.structuredContent).toEqual(linked) - expect(JSON.parse(result.content[0]!.text)).toEqual(linked) const [input, options] = fetchApi.mock.calls[0]! const url = new URL(input) expect(url.pathname).toBe('/api/v1/skills') @@ -60,7 +68,13 @@ describe('mCP public SDK discovery', () => { name: 'web-design-guidelines', }, deps(fetchApi)) - expect(result.structuredContent).toEqual(response) + expect(structured('get_skill', result)).toMatchObject({ + ref: `${response.owner}/${response.repository}/${response.name}`, + pageUrl: response.pageUrl, + runCommand: response.runCommand, + installCommand: response.installCommand, + }) + expect(result.content[0]!.text).toContain(response.runCommand) expect(fetchApi).toHaveBeenCalledWith( 'https://skilld.dev/api/v1/skills/vercel-labs/agent-skills/web-design-guidelines', expect.objectContaining({ method: 'GET' }), @@ -74,9 +88,9 @@ describe('mCP public SDK discovery', () => { } const toolDeps = deps(vi.fn().mockResolvedValue(Response.json(response))) const result = await runTool('get_skill', { owner: 'a', repo: 'b', name: 'c' }, toolDeps) - expect(result.isError).not.toBe(true) - expect(result.structuredContent).toEqual(response) - expect(JSON.parse(result.content[0]!.text)).toEqual(response) + expect(structured('get_skill', result).skillMarkdown).toBe(response.markdown) + expect(result.content[0]!.text).toContain(response.markdown) + expect(result.content[0]!.text).toContain(response.runCommand) expect(toolDeps.reportError).not.toHaveBeenCalled() }) @@ -144,15 +158,16 @@ describe('browse tools', () => { const response = tracksV1.operations.list.docs.examples[0]!.response const fetchApi = vi.fn().mockResolvedValue(Response.json(response)) const result = await runTool('list_tracks', {}, deps(fetchApi)) - expect(result.structuredContent).toEqual(response) + const answer = structured('list_tracks', result) + expect(answer.items.map((track: { slug: string }) => track.slug)).toEqual(response.items.map(track => track.slug)) expect(new URL(fetchApi.mock.calls[0]![0]).pathname).toBe('/api/v1/tracks') }) it('reads one track with bounded pagination', async () => { const response = tracksV1.operations.get.docs.examples[0]!.response const fetchApi = vi.fn().mockResolvedValue(Response.json(response)) - const result = await runTool('get_track', { slug: 'design' }, deps(fetchApi)) - expect(result.structuredContent).toEqual(response) + const result = await runTool('get_track', { slug: 'design', offset: 0 }, deps(fetchApi)) + expect(structured('get_track', result)).toMatchObject({ slug: response.slug, total: response.total, offset: 0 }) expect(fetchApi).toHaveBeenCalledWith( 'https://skilld.dev/api/v1/tracks/design?limit=10&offset=0', expect.objectContaining({ method: 'GET' }), @@ -163,7 +178,10 @@ describe('browse tools', () => { const response = trendingV1.operations.list.docs.examples[0]!.response const fetchApi = vi.fn().mockResolvedValue(Response.json(response)) const result = await runTool('list_trending', { window: 'month', limit: 5 }, deps(fetchApi)) - expect(result.structuredContent).toEqual(response) + const answer = structured('list_trending', result) + expect(answer.window).toBe('month') + expect(answer.items.map((row: { rank: number }) => row.rank)).toEqual(response.items.map((_, index) => index + 1)) + expect(result.content[0]!.text).toContain('past month') const url = new URL(fetchApi.mock.calls[0]![0]) expect(url.pathname).toBe('/api/v1/trending') expect(Object.fromEntries(url.searchParams)).toEqual({ window: 'month', limit: '5' }) @@ -174,7 +192,9 @@ describe('browse tools', () => { const skills = Array.from({ length: 3 }, (_, index) => ({ ...example.skills[0]!, name: `skill-${index}` })) const fetchApi = vi.fn().mockResolvedValue(Response.json({ ...example, skills })) const result = await runTool('get_repository', { owner: 'vercel-labs', repo: 'agent-skills', limit: 2 }, deps(fetchApi)) - expect(result.structuredContent).toEqual({ ...example, skills: skills.slice(0, 2), total: 3 }) + const answer = structured('get_repository', result) + expect(answer.items.map((item: { ref: string }) => item.ref)).toEqual(['vercel-labs/agent-skills/skill-0', 'vercel-labs/agent-skills/skill-1']) + expect(answer.total).toBe(3) expect(fetchApi).toHaveBeenCalledWith( 'https://skilld.dev/api/v1/repositories/vercel-labs/agent-skills', expect.objectContaining({ method: 'GET' }), @@ -210,9 +230,10 @@ describe('submit_repository', () => { const response = indexedExample!.response const fetchApi = vi.fn().mockResolvedValue(Response.json(response, { status: 201 })) const result = await runTool('submit_repository', { repository: 'vercel-labs/agent-skills' }, deps(fetchApi)) - expect(result.structuredContent).toEqual({ - ...response, - total: (response as { skills: unknown[] }).skills.length, + expect(structured('submit_repository', result)).toEqual({ + status: 'indexed', + repository: 'vercel-labs/agent-skills', + skillCount: (response as { skills: unknown[] }).skills.length, pageUrl: 'https://skilld.dev/gh/vercel-labs/agent-skills', }) const [input, options] = fetchApi.mock.calls[0]! @@ -225,8 +246,9 @@ describe('submit_repository', () => { const response = queuedExample!.response as { id: string } const fetchApi = vi.fn().mockResolvedValue(Response.json(response, { status: 201 })) const result = await runTool('submit_repository', { repository: 'https://github.com/vercel-labs/agent-skills' }, deps(fetchApi)) - expect(result.structuredContent).toEqual({ - ...response, + expect(structured('submit_repository', result)).toMatchObject({ + status: 'queued', + id: response.id, pageUrl: 'https://skilld.dev/gh/vercel-labs/agent-skills', statusUrl: `https://skilld.dev/api/v1/index-requests/${response.id}`, }) @@ -256,7 +278,7 @@ describe('install_command', () => { ['gh:anthropics/skills/skill-creator', 'npx skilld install anthropics/skills/skill-creator'], ])('%s -> %s', async (ref, command) => { const result = await runTool('install_command', { ref }) - expect((result.structuredContent as any).command).toBe(command) + expect(structured('install_command', result).command).toBe(command) }) it('offers the run command for a skill ref', async () => { @@ -266,15 +288,15 @@ describe('install_command', () => { it('has no run command for a ref that names more than one skill', async () => { const result = await runTool('install_command', { ref: 'gh:nuxt/nuxt' }) - expect((result.structuredContent as any).runCommand).toBeNull() + expect(structured('install_command', result)).not.toHaveProperty('runCommand') }) - it('keeps skilld run out of the note when the ref has no run command', async () => { + it('mentions skilld run only for a ref that has a run command', async () => { const multi = await runTool('install_command', { ref: 'gh:nuxt/nuxt' }) - expect((multi.structuredContent as any).note).not.toContain('skilld run') + expect(multi.content[0]!.text).not.toContain('skilld run') const single = await runTool('install_command', { ref: 'anthropics/skills/skill-creator' }) - expect((single.structuredContent as any).note).toContain('skilld run') + expect(single.content[0]!.text).toContain('skilld run') }) it.each(['@harlan-zw', '@harlan-zw/agent-building-stack'])('rejects the curator or collection ref %s', async (ref) => { diff --git a/test/unit/mcp-presenters.test.ts b/test/unit/mcp-presenters.test.ts new file mode 100644 index 00000000..50026912 --- /dev/null +++ b/test/unit/mcp-presenters.test.ts @@ -0,0 +1,274 @@ +import type { Presented } from '../../layers/mcp/shared/mcp-presenters' +import { readFileSync } from 'node:fs' +import { indexRequestsV1 } from 'skilld-sdk/contract' +import { describe, expect, it } from 'vitest' +import { + indexRequestOutputSchema, + installCommandOutputSchema, + presentIndexRequest, + presentInstallCommand, + presentRepository, + presentSearch, + presentSkill, + presentTrack, + presentTrackList, + presentTrending, + repositoryOutputSchema, + searchOutputSchema, + skillOutputSchema, + trackListOutputSchema, + trackOutputSchema, + trendingOutputSchema, +} from '../../layers/mcp/shared/mcp-presenters' + +// Production answers recorded on 2026-10-07. +function fixture(name: string): T { + return JSON.parse(readFileSync(new URL(`../fixtures/mcp/${name}.json`, import.meta.url), 'utf8')) as T +} + +/** Rough model tokens for one tool answer: the Markdown plus the structured copy. */ +function tokens(presented: Presented): number { + return Math.ceil((presented.text.length + JSON.stringify(presented.structured).length) / 4) +} + +const LONG = `${'Builds accessible interfaces with careful motion and type. '.repeat(10)}Ends here.` + +describe('search_skills answer', () => { + const answer = fixture('search-tailwind') + + it('names each Skill with its repository, stars, page, and run command', () => { + const presented = presentSearch('tailwind', answer) + for (const item of answer.items) { + const { owner, repository, selector } = item.source + expect(presented.text).toContain(`${owner}/${repository}`) + expect(presented.text).toContain(`https://skilld.dev/gh/${owner}/${repository}/${selector.name}`) + expect(presented.text).toContain(`npx skilld run ${owner}/${repository}/${selector.name}`) + } + expect(presented.text).toContain(`${answer.items[0].stargazerCount} stars`) + expect(searchOutputSchema.parse(presented.structured)).toEqual(presented.structured) + }) + + it('leaves install commands to the tools that install', () => { + expect(JSON.stringify(presentSearch('tailwind', answer).structured)).not.toContain('installCommand') + }) + + it('trims a long description at a word boundary', () => { + const item = { ...answer.items[0], description: LONG } + const presented = presentSearch('tailwind', { items: [item], total: 1 }) + const description = presented.structured.items[0]!.description! + expect(description.length).toBeLessThanOrEqual(201) + expect(description.endsWith('…')).toBe(true) + expect(LONG.startsWith(description.slice(0, -1))).toBe(true) + expect(LONG[description.length - 1]).toBe(' ') + }) + + it('fits five results in about 900 tokens, down from 1,500', () => { + expect(tokens(presentSearch('tailwind', answer))).toBeLessThanOrEqual(900) + }) +}) + +describe('get_skill answer', () => { + const answer = fixture('skill-web-design-guidelines') + + it('leads with provenance, freshness, and both commands', () => { + const { text } = presentSkill(answer) + expect(text).toContain('by Vercel Labs') + expect(text).toContain(answer.sourceUrl) + expect(text).toContain(answer.sourceCommit.slice(0, 7)) + expect(text).toContain('Last skill change 2026-01-16') + expect(text).toContain('last repository push 2026-08-28') + expect(text).toContain(answer.runCommand) + expect(text).toContain(answer.installCommand) + expect(text).toContain('does not check whether a skill is safe') + expect(text.indexOf(answer.runCommand)).toBeLessThan(text.indexOf(answer.installCommand)) + }) + + it('drops empty fields', () => { + const { structured } = presentSkill(answer) + expect(structured).not.toHaveProperty('likes') + expect(structured).not.toHaveProperty('tags') + expect(structured).not.toHaveProperty('files') + expect(structured).not.toHaveProperty('allowedTools') + expect(structured).not.toHaveProperty('license') + expect(structured).not.toHaveProperty('sourceGone') + expect(skillOutputSchema.parse(structured)).toEqual(structured) + }) + + it('keeps the facts a reader needs when they exist', () => { + const presented = presentSkill({ + ...answer, + license: 'MIT', + sourceGone: true, + allowedTools: ['Bash'], + files: [{ path: 'references/rules.md', size: 2048 }], + }) + expect(presented.structured).toMatchObject({ license: 'MIT', sourceGone: true, allowedTools: ['Bash'], files: [{ path: 'references/rules.md', size: 2048 }] }) + expect(presented.text).toContain('MIT') + expect(presented.text).toContain('references/rules.md') + expect(presented.text).toContain('gone upstream') + expect(skillOutputSchema.parse(presented.structured)).toEqual(presented.structured) + }) + + it('fences SKILL.md so its own code blocks stay inside', () => { + const markdown = '# Rules\n\n```js\nconst a = 1\n```\n\nAfter the block.' + const { text, structured } = presentSkill({ ...answer, markdown }) + const opening = text.indexOf('````markdown\n') + expect(opening).toBeGreaterThan(-1) + const body = text.slice(opening + '````markdown\n'.length, text.lastIndexOf('\n````')) + expect(body).toBe(markdown) + expect(structured.skillMarkdown).toBe(markdown) + }) + + it('omits the SKILL.md section when the registry holds no copy', () => { + const presented = presentSkill({ ...answer, markdown: null }) + expect(presented.text).not.toContain('SKILL.md text') + expect(presented.structured).not.toHaveProperty('skillMarkdown') + }) +}) + +describe('list_trending answer', () => { + const answer = fixture('trending-week') + const card = answer.items[0] + const post = card.signal.post + + it('ranks rows in board order with a page and run command each', () => { + const presented = presentTrending('week', answer) + presented.structured.items.forEach((row, index) => { + expect(row.rank).toBe(index + 1) + expect(presented.text).toContain(row.pageUrl) + expect(presented.text).toContain(row.runCommand) + }) + expect(trendingOutputSchema.parse(presented.structured)).toEqual(presented.structured) + }) + + it.each([ + [{ kind: 'social', authorCount: 6, mentionCount: 9, post }, '6 devs talked about it in 9 posts'], + [{ kind: 'social', authorCount: 1, mentionCount: 1, post }, '1 dev talked about it in 1 post'], + [{ kind: 'star-surge', starGain: 327, surgedOn: '2026-10-05T00:00:00.000Z' }, '327 GitHub stars'], + [{ kind: 'social-and-star-surge', authorCount: 3, mentionCount: 3, starGain: 120, surgedOn: '2026-10-05T00:00:00.000Z', post }, '3 devs talked about it in 3 posts'], + [{ kind: 'star-count' }, 'Nobody posted about it'], + ])('states why the row is there: %o', (signal, reason) => { + const presented = presentTrending('week', { items: [{ ...card, signal }], total: 1 }) + expect(presented.text).toContain(reason) + expect(trendingOutputSchema.parse(presented.structured)).toEqual(presented.structured) + }) + + it('quotes a short excerpt of the post with its author, network, and link', () => { + const presented = presentTrending('week', { items: [{ ...card, signal: { ...card.signal, post: { ...post, text: LONG } } }], total: 1 }) + const excerpt = presented.text.match(/Post: [^\n]*?: "([^"]*)"/)?.[1] ?? '' + expect(excerpt.length).toBeGreaterThan(100) + expect(excerpt.length).toBeLessThanOrEqual(141) + expect(excerpt.endsWith('…')).toBe(true) + expect(presented.text).toContain(`@${post.authorHandle}`) + expect(presented.text).toContain(post.url) + expect(presented.structured.items[0]!.reason).toMatchObject({ kind: 'social', post: { url: post.url, author: post.authorHandle } }) + }) + + it('makes no popularity or safety claim of its own', () => { + // Descriptions and post excerpts are the authors' words. Check skilld's own lines. + const own = presentTrending('week', answer).text.split('\n').filter(line => !/^ {3}(?:Post:|Page:|Run:)/.test(line) && !/^ {3}[^W]/.test(line)) + expect(own.join('\n')).not.toMatch(/\b(?:popular|hot|top|safe)\b/i) + }) + + it('fits a ten-row board in about 2,800 tokens, down from 8,700', () => { + expect(tokens(presentTrending('week', answer))).toBeLessThanOrEqual(2800) + }) +}) + +describe('list_tracks and get_track answers', () => { + it('lists every track with its slug, goal, size, and page', () => { + const answer = fixture('tracks') + const presented = presentTrackList(answer) + for (const track of answer.items) { + expect(presented.text).toContain(track.label) + expect(presented.text).toContain(`\`${track.slug}\``) + expect(presented.text).toContain(track.pageUrl) + expect(presented.text).toContain(`${track.skillCount} skills`) + } + expect(trackListOutputSchema.parse(presented.structured)).toEqual(presented.structured) + }) + + it('pages one track and fits ten Skills in about 1,850 tokens, down from 4,200', () => { + const answer = fixture('track-testing') + const presented = presentTrack(answer, 0) + expect(presented.text).toContain(`1–${answer.items.length} of ${answer.total}`) + expect(presented.text).toContain(answer.pageUrl) + expect(trackOutputSchema.parse(presented.structured)).toEqual(presented.structured) + expect(tokens(presented)).toBeLessThanOrEqual(1850) + }) +}) + +describe('get_repository answer', () => { + const answer = fixture('repository-vercel-agent-skills') + + it('states the repository facts once and lists each Skill without repeating them', () => { + const presented = presentRepository(answer, 30) + expect(presented.text).toContain(answer.installCommand) + expect(presented.text).toContain('last push 2026-08-28') + expect(presented.structured.items.every(item => item.stars === undefined)).toBe(true) + expect(presented.structured.total).toBe(answer.skills.length) + expect(repositoryOutputSchema.parse(presented.structured)).toEqual(presented.structured) + }) + + it('bounds the Skills it lists', () => { + const presented = presentRepository(answer, 2) + expect(presented.structured.items).toHaveLength(2) + expect(presented.text).toContain(`2 of ${answer.skills.length}`) + }) + + it('fits a nine-Skill repository in about 1,850 tokens, down from 4,400', () => { + expect(tokens(presentRepository(answer, 30))).toBeLessThanOrEqual(1850) + }) +}) + +describe('submit_repository answer', () => { + const [queued, indexed] = indexRequestsV1.operations.create.docs.examples + + it('counts the Skills of an indexed repository instead of echoing them', () => { + const answer = indexed!.response as any + const presented = presentIndexRequest(answer) + expect(presented.structured).toEqual({ + status: 'indexed', + repository: `${answer.owner}/${answer.repository}`, + skillCount: answer.skills.length, + pageUrl: `https://skilld.dev/gh/${answer.owner}/${answer.repository}`, + }) + expect(presented.text).toContain('Nothing was queued') + expect(indexRequestOutputSchema.parse(presented.structured)).toEqual(presented.structured) + }) + + it('returns the status URL and stage of a queued request', () => { + const answer = queued!.response as any + const presented = presentIndexRequest(answer) + expect(presented.structured).toMatchObject({ + status: 'queued', + id: answer.id, + stage: answer.progress.stage, + statusUrl: `https://skilld.dev/api/v1/index-requests/${answer.id}`, + }) + expect(presented.text).toContain(`https://skilld.dev/api/v1/index-requests/${answer.id}`) + expect(presented.text).toContain('A person reviews') + expect(indexRequestOutputSchema.parse(presented.structured)).toEqual(presented.structured) + }) +}) + +describe('install_command answer', () => { + it('leads with the run command for one Skill', () => { + const presented = presentInstallCommand({ kind: 'skill', owner: 'anthropics', repo: 'skills', name: 'skill-creator' }, 'anthropics/skills/skill-creator') + expect(presented.structured).toEqual({ + ref: 'anthropics/skills/skill-creator', + kind: 'skill', + runCommand: 'npx skilld run anthropics/skills/skill-creator', + command: 'npx skilld install anthropics/skills/skill-creator', + }) + expect(presented.text.indexOf('skilld run')).toBeLessThan(presented.text.indexOf('skilld install')) + expect(presented.text).toContain('writes no files') + expect(installCommandOutputSchema.parse(presented.structured)).toEqual(presented.structured) + }) + + it('offers only the install command for a repository', () => { + const presented = presentInstallCommand({ kind: 'repo', owner: 'nuxt', repo: 'nuxt' }, 'nuxt/nuxt') + expect(presented.structured).toEqual({ ref: 'nuxt/nuxt', kind: 'repo', command: 'npx skilld add nuxt/nuxt --all' }) + expect(presented.text).not.toContain('skilld run') + }) +}) diff --git a/test/unit/webmcp.test.ts b/test/unit/webmcp.test.ts index 90509fc4..a47cd45f 100644 --- a/test/unit/webmcp.test.ts +++ b/test/unit/webmcp.test.ts @@ -70,7 +70,7 @@ describe('webmcp discovery tools', () => { expect(result).toEqual({ _tag: 'ok', - data: { total: 0, items: [] }, + data: { query: 'nuxt seo', total: 0, items: [] }, }) const [input, options] = fetchApi.mock.calls[0]! const url = new URL(input)