From 1a453c1fd2d4ebd81983562975444fce880f1ed8 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 15:00:30 +1100 Subject: [PATCH 1/3] feat(mcp): add the MCP server reference page and drop collections The Claude listing needs public docs that an IT admin can review. /developers/mcp lists every tool from the definitions the directories scan, with limits, data, troubleshooting, and support by email or Discord. It renders noindex until a measured query admits it. The MCP server stays on Skill discovery and provenance; curator features are under review. get_collection leaves the server, and install_command accepts only Skill and Repository refs. Evals showed Agents calling a Skill safe or verified. get_skill now says skilld does not check safety, and the server instructions forbid the claim. The chat-app wording from #520 moves from the get_skill description into the server instructions, because the Claude directory rejects behaviour instructions in a tool description. --- docs/runbooks/agent-app-directories.md | 16 +- integrations/openai-plugin/plugin.json | 30 ++-- layers/identity/app/layouts/account.vue | 2 +- .../{developers.vue => developers/index.vue} | 20 ++- layers/marketing/app/utils/developer-setup.ts | 9 - layers/marketing/app/utils/page-admissions.ts | 1 + layers/marketing/content/pages/privacy.md | 3 +- layers/marketing/content/pages/terms.md | 4 +- layers/mcp/app/pages/developers/mcp.vue | 165 ++++++++++++++++++ layers/mcp/app/utils/webmcp-tools.ts | 1 - layers/mcp/server/mcp/index.ts | 4 +- layers/mcp/shared/mcp-install-command.ts | 36 +--- layers/mcp/shared/mcp-tools.ts | 48 ++--- nuxt.config.ts | 4 +- shared/agent-setup.ts | 9 + skills/skilld-registry/SKILL.md | 5 +- test/unit/developer-setup.test.ts | 3 +- test/unit/developers-page.nuxt.test.ts | 2 +- test/unit/mcp-discovery.test.ts | 38 ++-- test/unit/webmcp.test.ts | 7 +- 20 files changed, 260 insertions(+), 147 deletions(-) rename layers/marketing/app/pages/{developers.vue => developers/index.vue} (97%) create mode 100644 layers/mcp/app/pages/developers/mcp.vue diff --git a/docs/runbooks/agent-app-directories.md b/docs/runbooks/agent-app-directories.md index b589be8e..2771301c 100644 --- a/docs/runbooks/agent-app-directories.md +++ b/docs/runbooks/agent-app-directories.md @@ -39,13 +39,9 @@ A project with EU data residency cannot submit a plugin with an MCP server. 2. Upload the ZIP. Select **Upload new or existing plugin** and choose the verified developer identity. 3. Connect the `skilld` MCP server and choose **No authentication**. -4. The portal shows a domain-verification token. Set it as a Worker secret: - - ```sh - pnpm exec wrangler secret put NUXT_OPENAI_APPS_CHALLENGE --config wrangler.jsonc - ``` - - Then check that `https://skilld.dev/.well-known/openai-apps-challenge` prints the token, and complete the challenge. +4. The portal shows a domain-verification token. Add it to `vars` in `wrangler.jsonc` as `NUXT_OPENAI_APPS_CHALLENGE` through a pull request. + The deploy workflow is the only path to production, and the token is public, so it belongs in the repository. + After the deploy, check that `https://skilld.dev/.well-known/openai-apps-challenge` prints the token, and complete the challenge. 5. Scan the tools and resolve every finding. 6. In **Review details**, add the demo video URL. The review needs a video of the five positive test cases in ChatGPT. 7. Submit for review, accept the policy attestations, and publish after approval. @@ -63,12 +59,12 @@ Any paid Claude plan can submit. Use these fields in the portal. | One-liner | Search curated agent skills, see who wrote each one, and get the command that runs it in your coding agent. | | Description | `longDescription` in `integrations/openai-plugin/plugin.json` | | Categories | Developer tools | -| Documentation URL | `https://skilld.dev/developers?setup=mcp&app=claude` | +| Documentation URL | `https://skilld.dev/developers/mcp` | | Privacy policy URL | `https://skilld.dev/privacy` | -| Support contact | `https://github.com/skilld-dev/skilld.dev/issues` | +| Support contact | `harlan@harlanzw.com` | | Icon | `public/logo-icon.png` | | Slug | `skilld`. The slug is permanent after publication | -| Use cases | Find agent skills by topic, check provenance before a run, open curated collections, get run and install commands | +| Use cases | Find agent skills by topic, check provenance and freshness before a run, get the command that installs every Skill in a Repository | | Requirements | None. Every tool works without an account | | Data access | Reads data only | | Company | Harlan Wilton, `https://skilld.dev`, `harlan@harlanzw.com` | diff --git a/integrations/openai-plugin/plugin.json b/integrations/openai-plugin/plugin.json index 742647c0..98d23c89 100644 --- a/integrations/openai-plugin/plugin.json +++ b/integrations/openai-plugin/plugin.json @@ -11,24 +11,34 @@ "homepage": "https://skilld.dev/developers?setup=mcp", "repository": "https://github.com/skilld-dev/skilld.dev", "license": "MIT", - "keywords": ["agent skills", "SKILL.md", "coding agents", "codex", "developer tools"], + "keywords": [ + "agent skills", + "SKILL.md", + "coding agents", + "codex", + "developer tools" + ], "extensions": { "com.openai": { "interface": { "displayName": "skilld", "shortDescription": "Curated agent skills by humans", - "longDescription": "skilld searches skilld.dev, a curated registry of agent skills. A skill is a SKILL.md file that a maintainer writes in their own GitHub repository. It teaches a coding agent how to use a library, a framework, or a workflow.\n\nAsk for a skill by topic, such as Tailwind CSS, Nuxt, or database migrations. skilld returns the matching skills with their author, source repository, GitHub star count, and skilld.dev page. For one skill, it shows the exact SKILL.md and commit on GitHub, when the skill last changed, and the full skill text, so you can read a skill before you run it. It also opens curated collections, where a curator explains why each skill is in the set.\n\nEvery skill comes with its commands. npx skilld run gives the skill to a coding agent for one session and writes no files. npx skilld install keeps the skill in a project. skilld runs and installs nothing itself. You run the command in your own terminal or coding agent.\n\nskilld needs no account and reads public registry data only.", + "longDescription": "skilld searches skilld.dev, a curated registry of agent skills. A skill is a SKILL.md file that a maintainer writes in their own GitHub repository. It teaches a coding agent how to use a library, a framework, or a workflow.\n\nAsk for a skill by topic, such as Tailwind CSS, Nuxt, or database migrations. skilld returns the matching skills with their author, source repository, GitHub star count, and skilld.dev page. For one skill, it shows the exact SKILL.md and commit on GitHub, when the skill last changed, and the full skill text, so you can read a skill before you run it.\n\nEvery skill comes with its commands. npx skilld run gives the skill to a coding agent for one session and writes no files. npx skilld install keeps the skill in a project. skilld runs and installs nothing itself. You run the command in your own terminal or coding agent.\n\nskilld needs no account and reads public registry data only.", "developerName": "Harlan Wilton", "category": "Developer Tools", - "capabilities": ["Search agent skills", "Show skill provenance and source", "Open curated collections", "Return run and install commands"], + "capabilities": [ + "Search agent skills", + "Show skill provenance and source", + "Return run and install commands" + ], "websiteURL": "https://skilld.dev", - "supportURL": "https://github.com/skilld-dev/skilld.dev/issues", + "supportURL": "https://skilld.dev/developers/mcp#support", "privacyPolicyURL": "https://skilld.dev/privacy", "termsOfServiceURL": "https://skilld.dev/terms", "defaultPrompt": [ "Find a skill for Tailwind CSS and give me the run command.", "Who wrote the web-design-guidelines skill, and when did it last change?", - "What is in the Agent builder essentials collection on skilld?" + "Give me the command to install every skill in anthropics/skills." ], "brandColor": "#E11D48", "brandColorDark": "#FB7185", @@ -57,10 +67,10 @@ "expected_behavior": "Returns the SKILL.md text with a link to its source file on GitHub, and the npx skilld run command for the skill." }, { - "description": "Open a curated collection", - "prompt": "What is in the agent-building-stack collection by harlan-zw on skilld?", - "tools_triggered": "get_collection", - "expected_behavior": "Returns the Agent builder essentials collection: its skills, the curator's reason for each skill, and the npx skilld add @harlan-zw/agent-building-stack command that installs the whole collection." + "description": "Install every skill in a repository", + "prompt": "Give me the command to install every skill in anthropics/skills.", + "tools_triggered": "install_command", + "expected_behavior": "Returns npx skilld add anthropics/skills, which installs every skill in the repository. States that the user runs the command, and does not claim that anything ran." }, { "description": "Get the run command for one skill", @@ -89,7 +99,7 @@ }, "publication": { "countries": [], - "release_notes": "First release. Search skilld.dev for agent skills, show who wrote each skill and where its source lives, open curated collections, and return run and install commands." + "release_notes": "First release. Search skilld.dev for agent skills, show who wrote each skill and where its source lives, and return run and install commands." } } } diff --git a/layers/identity/app/layouts/account.vue b/layers/identity/app/layouts/account.vue index 82e55406..cf507b8b 100644 --- a/layers/identity/app/layouts/account.vue +++ b/layers/identity/app/layouts/account.vue @@ -15,7 +15,7 @@ const navigation = computed(() => [ { label: 'Your skills', icon: 'i-lucide-heart', to: '/me', active: route.path === '/me' && !['email', 'repositories', 'skillgen', 'account'].includes(String(route.query.view)) }, { label: 'Repository coverage', icon: 'i-lucide-git-fork', to: '/me?view=repositories', active: route.path === '/me' && route.query.view === 'repositories' }, { label: 'Email updates', icon: 'i-lucide-mail', to: '/me?view=email', active: route.path === '/me' && route.query.view === 'email' }, - { label: 'Set up your agent', icon: 'i-lucide-plug', to: '/developers', active: route.path === '/developers' }, + { label: 'Set up your agent', icon: 'i-lucide-plug', to: '/developers', active: route.path === '/developers' || route.path.startsWith('/developers/') }, { label: 'Skillgen', icon: 'i-lucide-git-pull-request-draft', to: '/me?view=skillgen', active: route.path === '/me' && route.query.view === 'skillgen' }, { label: 'Devices and tokens', icon: 'i-lucide-terminal', to: '/me/devices', active: route.path.startsWith('/me/devices') || route.path.startsWith('/me/cli-tokens') }, { label: 'Account', icon: 'i-lucide-settings', to: '/me?view=account', active: route.path === '/me' && route.query.view === 'account' }, diff --git a/layers/marketing/app/pages/developers.vue b/layers/marketing/app/pages/developers/index.vue similarity index 97% rename from layers/marketing/app/pages/developers.vue rename to layers/marketing/app/pages/developers/index.vue index 1805f445..40355e52 100644 --- a/layers/marketing/app/pages/developers.vue +++ b/layers/marketing/app/pages/developers/index.vue @@ -1,21 +1,20 @@ + + diff --git a/layers/mcp/app/utils/webmcp-tools.ts b/layers/mcp/app/utils/webmcp-tools.ts index a8774497..49a81d08 100644 --- a/layers/mcp/app/utils/webmcp-tools.ts +++ b/layers/mcp/app/utils/webmcp-tools.ts @@ -6,7 +6,6 @@ import { mcpTools } from '../../shared/mcp-tools' const UNTRUSTED_OUTPUT_TOOLS = new Set([ 'search_skills', 'get_skill', - 'get_collection', ]) export type WebMcpRegistrationResult diff --git a/layers/mcp/server/mcp/index.ts b/layers/mcp/server/mcp/index.ts index 303fdd4c..8ca82e6e 100644 --- a/layers/mcp/server/mcp/index.ts +++ b/layers/mcp/server/mcp/index.ts @@ -6,8 +6,8 @@ import { mcpTools } from '../../shared/mcp-tools' const MAX_MCP_BODY_BYTES = 64 * 1024 export default defineMcpHandler({ - description: 'Search skilld.dev for agent skills and curated collections.', - instructions: 'Search first, inspect provenance before recommending a skill, then return the run command for the user to approve and run. Offer the install command only when the user wants the skill in every session. This server never runs or installs anything.', + description: 'Search skilld.dev for agent skills and read who wrote each one.', + instructions: 'skilld.dev is a curated registry of agent skills. Search first, inspect provenance before recommending a skill, then return the run command for the user to approve and run. Offer the install command only when the user wants the skill in every session. Without a shell, such as in a chat app, follow the markdown from get_skill for this session, and tell the user the skill name and source repository first. skilld.dev shows who wrote a skill and where its source lives. It does not review skills for safety, so never call a skill safe or verified. This server never runs or installs anything.', tools: (event) => { const deps: McpToolDeps = { fetchApi: (input: string, options: RequestInit) => { diff --git a/layers/mcp/shared/mcp-install-command.ts b/layers/mcp/shared/mcp-install-command.ts index 777eaeb2..98b4d73e 100644 --- a/layers/mcp/shared/mcp-install-command.ts +++ b/layers/mcp/shared/mcp-install-command.ts @@ -7,10 +7,12 @@ * truth). Duplicated rather than imported: ADR-0001 forbids reaching into * another layer's code, and the command shape is a product-wide convention, * not layer domain data. + * + * The MCP server covers Skill discovery and provenance only, so it names + * Skills and Repositories. Curator and collection refs stay with the CLI. */ const V3_PREFIX = 'npx skilld' -const PREFIX = `${V3_PREFIX} add` /** * The default handoff. `skilld run` gives the calling agent the skill now and @@ -30,22 +32,12 @@ export function skillInstallCommand(owner: string, repo: string, skill: string): /** Whole-repository install. `add` takes every ref that names several Skills. */ export function repoInstallCommand(owner: string, repo: string): string { - return `${PREFIX} ${owner}/${repo}` -} - -export function curatorInstallCommand(login: string): string { - return `${PREFIX} @${login}` -} - -export function collectionInstallCommand(login: string, slug: string): string { - return `${PREFIX} @${login}/${slug}` + return `${V3_PREFIX} add ${owner}/${repo}` } export type InstallRef = | { kind: 'skill', owner: string, repo: string, name: string } | { kind: 'repo', owner: string, repo: string } - | { kind: 'collection', login: string, slug: string } - | { kind: 'curator', login: string } const SEGMENT_RE = /^[\w.-]+$/ @@ -58,24 +50,14 @@ function validSegments(...segments: string[]): boolean { * - `owner/repo` (all skills in a repo) * - `owner/repo/name` (one skill) * - the same refs with the `gh:` or `skilld:` prefix that older CLI output printed - * - `@login` (everything a curator publishes) - * - `@login/slug` (one collection) * - * An `npm:` ref has no installable target: the v3 CLI rejects it. + * A curator (`@login`), collection (`@login/slug`), or `npm:` ref has no + * target here. */ export function parseInstallRef(raw: string): InstallRef | null { const ref = raw.trim() - if (!ref) - return null - - if (ref.startsWith('@')) { - const parts = ref.slice(1).split('/') - if (parts.length === 1 && validSegments(parts[0]!)) - return { kind: 'curator', login: parts[0]! } - if (parts.length === 2 && validSegments(parts[0]!, parts[1]!)) - return { kind: 'collection', login: parts[0]!, slug: parts[1]! } + if (!ref || ref.startsWith('@')) return null - } const path = ref.replace(/^(?:gh|skilld):/, '') const parts = path.split('/') @@ -92,9 +74,5 @@ export function installCommandFor(ref: InstallRef): string { return skillInstallCommand(ref.owner, ref.repo, ref.name) case 'repo': return repoInstallCommand(ref.owner, ref.repo) - case 'collection': - return collectionInstallCommand(ref.login, ref.slug) - case 'curator': - return curatorInstallCommand(ref.login) } } diff --git a/layers/mcp/shared/mcp-tools.ts b/layers/mcp/shared/mcp-tools.ts index afaf0a6a..c6f42239 100644 --- a/layers/mcp/shared/mcp-tools.ts +++ b/layers/mcp/shared/mcp-tools.ts @@ -15,7 +15,7 @@ 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. */ -const ACCEPTED_REFS = ['"owner/repo"', '"owner/repo/skill-name"', '"@login"', '"@login/collection-slug"'].join(', ') +const ACCEPTED_REFS = ['"owner/repo"', '"owner/repo/skill-name"'].join(', ') /** Raw HTTP transport is injected. The SDK owns request and response parsing. */ export interface McpToolDeps { @@ -58,6 +58,8 @@ export interface McpTool { description: string inputSchema: Record annotations: McpToolAnnotations + /** A prompt that calls the tool. /developers/mcp prints it beside the tool. */ + examplePrompt: string run: (deps: McpToolDeps, args: unknown, signal?: AbortSignal) => Promise } @@ -137,6 +139,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, + examplePrompt: 'Find a skill for Tailwind CSS.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -162,8 +165,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, 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. Without a shell, such as in a chat app, follow the SKILL.md text in markdown for this session. Tell the user the skill name and source repository first. Files beside SKILL.md are listed in files, not included.', + 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.', inputSchema: GetSkillArgs.shape, + examplePrompt: 'Who wrote vercel-labs/agent-skills/web-design-guidelines, and when did it last change?', annotations: { readOnlyHint: true, destructiveHint: false, @@ -189,43 +193,12 @@ const getSkill: McpTool = { }, } -const GetCollectionArgs = z.object({ - login: z.string().trim().min(1).max(100).regex(/^[\w.-]+$/).describe('Curator GitHub login'), - slug: z.string().trim().min(1).max(100).regex(/^[\w.-]+$/).describe('Collection slug'), - limit: z.number().int().min(1).max(50).default(25).describe('Maximum skills to return'), - offset: z.number().int().min(0).max(10_000).default(0).describe('Skills to skip for pagination'), -}) - -const getCollection: McpTool = { - name: 'get_collection', - title: 'Get collection', - description: 'Look up a curated collection by curator GitHub login and collection slug, as in skilld.dev/@login/slug. Returns the collection, its skills with the reason the curator gives for each, and one command that installs the whole collection.', - inputSchema: GetCollectionArgs.shape, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, - run: async (deps, args, signal) => { - const parsed = GetCollectionArgs.safeParse(args) - if (!parsed.success) - return fail('Invalid arguments: login and slug are required strings') - const { login, slug, limit, offset } = parsed.data - const result = await clientFor(deps).collections.get({ - params: { login, slug }, - query: { limit, offset }, - }, { signal }) - return presentResult(deps, 'Collection lookup', result, `Collection not found: @${login}/${slug}`) - }, -} - const InstallCommandArgs = z.object({ - ref: z.string().trim().min(1).max(300).describe(`Skill, repo, collection, or curator reference: ${ACCEPTED_REFS}`), + 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 repo, collection, or curator ref. +// 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) @@ -236,8 +209,9 @@ function noteFor(runCommand: string | null): string { const installCommand: McpTool = { name: 'install_command', title: 'Get run and install commands', - description: `Return the exact skilld CLI commands for a skill, repository, collection, or curator 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.`, + 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, + examplePrompt: 'Give me the command to try anthropics/skills/skill-creator without installing it.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -265,4 +239,4 @@ const installCommand: McpTool = { }, } -export const mcpTools: McpTool[] = [searchSkills, getSkill, getCollection, installCommand] +export const mcpTools: McpTool[] = [searchSkills, getSkill, installCommand] diff --git a/nuxt.config.ts b/nuxt.config.ts index 78f6a8f7..96f2008b 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -362,8 +362,8 @@ export default defineNuxtConfig({ route: '/api/mcp', name: 'skilld.dev discovery', version: '1.0.0', - description: 'Discover curated agent skills with provenance and a safe run or install command handoff.', - instructions: 'Search first, inspect provenance before recommending a skill, then return the run command for the user to approve and run. Offer the install command only when the user wants the skill in every session. This server never runs or installs anything.', + description: 'Discover curated agent skills with provenance and a run or install command handoff.', + instructions: 'skilld.dev is a curated registry of agent skills. Search first, inspect provenance before recommending a skill, then return the run command for the user to approve and run. Offer the install command only when the user wants the skill in every session. Without a shell, such as in a chat app, follow the markdown from get_skill for this session, and tell the user the skill name and source repository first. skilld.dev shows who wrote a skill and where its source lives. It does not review skills for safety, so never call a skill safe or verified. This server never runs or installs anything.', sessions: false, browserRedirect: '/', // The toolkit rejects a request whose Origin header is another site. Server-side diff --git a/shared/agent-setup.ts b/shared/agent-setup.ts index f0f20b7b..7369aa16 100644 --- a/shared/agent-setup.ts +++ b/shared/agent-setup.ts @@ -12,6 +12,15 @@ const SITE_ORIGIN = 'https://skilld.dev' /** The discovery MCP server. `nuxt.config.ts` mounts it at `mcp.route`. */ export const REGISTRY_MCP_URL = `${SITE_ORIGIN}/api/mcp` +/** + * Claude's link opens its Add custom connector dialog with the name and URL + * filled in. The user still confirms before Claude adds anything. + */ +export function claudeConnectorUrl(url: string = REGISTRY_MCP_URL): string { + const query = new URLSearchParams({ modal: 'add-custom-connector', connectorName: 'skilld', connectorUrl: url }) + return `https://claude.ai/customize/connectors?${query}` +} + /** Where `server/middleware/agent-md.ts` serves {@link agentSetupMarkdown}. */ export const AGENT_SETUP_PATH = '/agent.md' export const AGENT_SETUP_URL = `${SITE_ORIGIN}${AGENT_SETUP_PATH}` diff --git a/skills/skilld-registry/SKILL.md b/skills/skilld-registry/SKILL.md index 695f33ee..ce0acc00 100644 --- a/skills/skilld-registry/SKILL.md +++ b/skills/skilld-registry/SKILL.md @@ -13,14 +13,13 @@ Running is the default. `skilld run` hands you the skill for this session and wr 1. Search before choosing a skill. Prefer `search_skills` through the skilld MCP server at `https://skilld.dev/api/mcp`. 2. Inspect the chosen result with `get_skill`. Check its source repository, commit, trust signals, and freshness. -3. Use `get_collection` when the user asks for a curated set of skills. -4. Call `install_command` for the exact commands. Return them to the user; do not claim either one ran. +3. Call `install_command` for the exact commands. Return them to the user; do not claim either one ran. `search_skills` and `get_skill` return `runCommand` beside `installCommand`. Lead with `runCommand`. Offer `installCommand` only when the user asks to keep the skill. Without a shell, such as in a chat app, follow the `markdown` field from `get_skill` for this session. It is the skill's SKILL.md at `sourceCommit`. Tell the user the skill name and source repository first. The `files` beside it are listed, not included. -`install_command` returns `runCommand` for a single-skill ref and `command` for the install. A collection, curator, repository, or npm ref has no run command yet, so `runCommand` is `null` there. +`install_command` returns `runCommand` for a single-skill ref and `command` for the install. A repository ref has no run command, so `runCommand` is `null` there. The MCP server accepts only skill and repository refs. Every tool is read only. Running and installing both stay separate actions in the user's own environment. diff --git a/test/unit/developer-setup.test.ts b/test/unit/developer-setup.test.ts index 2181f27c..0d9317a6 100644 --- a/test/unit/developer-setup.test.ts +++ b/test/unit/developer-setup.test.ts @@ -3,7 +3,8 @@ import type { ApiSampleCall } from '../../layers/marketing/app/utils/developer-s import { createSkilldClient } from 'skilld-sdk' import { skilldV1Protocol } from 'skilld-sdk/contract' import { describe, expect, it } from 'vitest' -import { apiSampleCalls, claudeConnectorUrl, curlCall, cursorInstallUrl, vscodeInstallUrl } from '../../layers/marketing/app/utils/developer-setup' +import { apiSampleCalls, curlCall, cursorInstallUrl, vscodeInstallUrl } from '../../layers/marketing/app/utils/developer-setup' +import { claudeConnectorUrl } from '../../shared/agent-setup' describe('mCP install links', () => { it('opens Claude\'s custom connector dialog with the skilld name and the server URL', () => { diff --git a/test/unit/developers-page.nuxt.test.ts b/test/unit/developers-page.nuxt.test.ts index 8a88f2b7..b24aba3a 100644 --- a/test/unit/developers-page.nuxt.test.ts +++ b/test/unit/developers-page.nuxt.test.ts @@ -7,7 +7,7 @@ vi.stubGlobal('defineOgImage', () => {}) async function mountAt(route: string) { return await mountSuspended( - await import('../../layers/marketing/app/pages/developers.vue').then(module => module.default), + await import('../../layers/marketing/app/pages/developers/index.vue').then(module => module.default), { route }, ) } diff --git a/test/unit/mcp-discovery.test.ts b/test/unit/mcp-discovery.test.ts index 3f294bc3..1adf2d4a 100644 --- a/test/unit/mcp-discovery.test.ts +++ b/test/unit/mcp-discovery.test.ts @@ -1,5 +1,5 @@ import type { McpToolDeps, McpToolResult } from '../../layers/mcp/shared/mcp-tools' -import { collectionsV1, problemType, skillsV1 } from 'skilld-sdk/contract' +import { problemType, skillsV1 } from 'skilld-sdk/contract' import { describe, expect, it, vi } from 'vitest' import { installCommandFor, parseInstallRef } from '../../layers/mcp/shared/mcp-install-command' import { mcpTools } from '../../layers/mcp/shared/mcp-tools' @@ -92,37 +92,17 @@ describe('mCP public SDK discovery', () => { expect(toolDeps.fetchApi).toHaveBeenCalledOnce() }) - it('uses server collection pagination and returns the collection contract', async () => { - const response = collectionsV1.operations.get.docs.examples[0]!.response - const fetchApi = vi.fn().mockResolvedValue(Response.json(response)) - const result = await runTool('get_collection', { - login: 'harlan-zw', - slug: 'design-engineering-essentials', - limit: 10, - offset: 20, - }, deps(fetchApi)) - - expect(result.structuredContent).toEqual(response) - expect(fetchApi).toHaveBeenCalledWith( - 'https://skilld.dev/api/v1/collections/harlan-zw/design-engineering-essentials?limit=10&offset=20', - expect.objectContaining({ method: 'GET' }), - ) - }) - it('bounds inputs before fetching', async () => { const toolDeps = deps() expect((await runTool('search_skills', { query: 'x'.repeat(201) }, toolDeps)).isError).toBe(true) expect((await runTool('search_skills', { query: 'seo', limit: 21 }, toolDeps)).isError).toBe(true) - expect((await runTool('get_collection', { login: 'harlan-zw', slug: 'x', limit: 51 }, toolDeps)).isError).toBe(true) expect(toolDeps.fetchApi).not.toHaveBeenCalled() }) - it.each([ - ['get_skill', { owner: 'ghost', repo: 'nothing', name: 'missing' }, 'Skill not found: ghost/nothing/missing'], - ['get_collection', { login: 'ghost', slug: 'nothing' }, 'Collection not found: @ghost/nothing'], - ])('maps %s NOT_FOUND without logging infrastructure errors', async (name, args, message) => { + it('maps get_skill NOT_FOUND without logging infrastructure errors', async () => { const toolDeps = deps(vi.fn().mockResolvedValue(problem('NOT_FOUND', 404))) - const result = await runTool(name, args, toolDeps) + const result = await runTool('get_skill', { owner: 'ghost', repo: 'nothing', name: 'missing' }, toolDeps) + const message = 'Skill not found: ghost/nothing/missing' expect(result.isError).toBe(true) expect(result.content[0]!.text).toContain(message) expect(toolDeps.reportError).not.toHaveBeenCalled() @@ -166,8 +146,6 @@ describe('install_command', () => { ['anthropics/skills/skill-creator', 'npx skilld install anthropics/skills/skill-creator'], ['skilld:anthropics/skills/skill-creator', 'npx skilld install anthropics/skills/skill-creator'], ['gh:anthropics/skills/skill-creator', 'npx skilld install anthropics/skills/skill-creator'], - ['@harlan-zw', 'npx skilld add @harlan-zw'], - ['@harlan-zw/nuxt-stack', 'npx skilld add @harlan-zw/nuxt-stack'], ])('%s -> %s', async (ref, command) => { const result = await runTool('install_command', { ref }) expect((result.structuredContent as any).command).toBe(command) @@ -191,6 +169,12 @@ describe('install_command', () => { expect((single.structuredContent as any).note).toContain('skilld run') }) + it.each(['@harlan-zw', '@harlan-zw/agent-building-stack'])('rejects the curator or collection ref %s', async (ref) => { + const result = await runTool('install_command', { ref }) + expect(result.isError).toBe(true) + expect(result.content[0]!.text).toBe(`Unrecognized ref: "${ref}". Accepted forms: "owner/repo", "owner/repo/skill-name".`) + }) + it('rejects unrecognized refs', async () => { const result = await runTool('install_command', { ref: 'not a ref!!' }) expect(result).toMatchObject({ isError: true }) @@ -203,6 +187,8 @@ describe('parseInstallRef', () => { expect(installCommandFor(parseInstallRef('a/b')!)).toBe('npx skilld add a/b') expect(parseInstallRef('a/b/c/d')).toBeNull() expect(parseInstallRef('@')).toBeNull() + expect(parseInstallRef('@harlan-zw')).toBeNull() + expect(parseInstallRef('@harlan-zw/agent-building-stack')).toBeNull() expect(parseInstallRef('')).toBeNull() }) diff --git a/test/unit/webmcp.test.ts b/test/unit/webmcp.test.ts index 2f7edc96..9792eb64 100644 --- a/test/unit/webmcp.test.ts +++ b/test/unit/webmcp.test.ts @@ -32,7 +32,6 @@ describe('webmcp discovery tools', () => { expect(tools.map(tool => tool.name)).toEqual([ 'search_skills', 'get_skill', - 'get_collection', 'install_command', ]) expect(tools[0]!.inputSchema).toMatchObject({ @@ -47,7 +46,7 @@ describe('webmcp discovery tools', () => { readOnlyHint: true, untrustedContentHint: true, }) - expect(tools[3]!.annotations).toEqual({ + expect(tools[2]!.annotations).toEqual({ readOnlyHint: true, untrustedContentHint: false, }) @@ -89,8 +88,8 @@ describe('webmcp discovery tools', () => { const result = await registerWebMcpTools(modelContext, tools, controller) - expect(result).toEqual({ _tag: 'registered', count: 4 }) - expect(registerTool).toHaveBeenCalledTimes(4) + expect(result).toEqual({ _tag: 'registered', count: tools.length }) + expect(registerTool).toHaveBeenCalledTimes(tools.length) for (const call of registerTool.mock.calls) expect(call[1]).toEqual({ signal: controller.signal }) }) From b2b9b0bf52a362881adcaf92dce9b17c52d44c48 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 15:12:24 +1100 Subject: [PATCH 2/3] feat(mcp): browse tracks, trending, and repositories over MCP Search alone left an Agent without a way to browse. Four read-only tools now read the same answers as the site: list_tracks and get_track for one kind of work, list_trending for the Skills developers talked about or starred, and get_repository for every Skill in one Repository. Each description leads with the user's goal and bounds its output under the MCP limit. The ChatGPT setup steps described a Developer mode toggle that no longer exists. They now follow the Plugins flow verified in ChatGPT on the web, and link OpenAI's current guide instead of naming plans. --- GLOSSARY.md | 6 +- docs/runbooks/agent-app-directories.md | 2 +- integrations/openai-plugin/plugin.json | 25 ++-- .../marketing/app/pages/developers/index.vue | 12 +- layers/marketing/content/pages/privacy.md | 4 + layers/mcp/app/pages/developers/mcp.vue | 2 +- layers/mcp/app/utils/webmcp-tools.ts | 3 + layers/mcp/server/mcp/index.ts | 2 +- layers/mcp/shared/mcp-tools.ts | 112 +++++++++++++++++- skills/skilld-registry/SKILL.md | 2 + test/unit/mcp-discovery.test.ts | 66 ++++++++++- test/unit/webmcp.test.ts | 4 + 12 files changed, 216 insertions(+), 24 deletions(-) diff --git a/GLOSSARY.md b/GLOSSARY.md index 80768175..1e5e58b4 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -25,7 +25,7 @@ Shared terms match the skilld CLI glossary (`skilld-dev/skilld/GLOSSARY.md`). Th | collection | `layers/collections`, `/@login/slug` | collection N—N Skill | "collection" | | watch | `layers/identity` | account N—N Repository or collection | "watch" | | digest | `layers/identity` email | account 1—1 digest schedule | "digest" | -| track | `/skills/`, `layers/registry` | track 1—N Skill | "track" | +| track | `/skills/`, `layers/registry`, MCP `list_tracks` and `get_track` | track 1—N Skill | "track" | | comparison | `/compare/`, `layers/marketing` | comparison N—N Skill | "comparison" | | trending | `/skills/trending`, ADR-0004 | Repository 1—N social mention | "trending" | | trending award | `skill_trending_awards`, ADR-0011 | Skill 1—N trending award | "#3 trending" | @@ -145,12 +145,14 @@ Collisions ### track -**Is:** a page of Skills for one kind of work, at `/skills/`. A person writes its label, its second-person line and its pinned Skills; a classifier category fills in the depth beneath them. +**Is:** a page of Skills for one kind of work a developer wants done, at `/skills/`. A person writes its label, its second-person line and its pinned Skills; a classifier category fills in the depth beneath them. **Use for:** "hand-picked" names a track's pinned Skills in UI copy. "Pinned" stays in the code. **Never:** cluster, category, outcome, topic, use case (in UI). `CLUSTERS` and `abstractness_category` are the internal names and stay in the code. +**Frozen identifiers:** the MCP tools `list_tracks` and `get_track`. Agents and directory listings call them by name, so a rename breaks every connected app. + **Collides with:** collection. A track is ours, permanent, and one per kind of work. A collection belongs to a curator, at `/@login/slug`, and any number can exist. ### collection diff --git a/docs/runbooks/agent-app-directories.md b/docs/runbooks/agent-app-directories.md index 2771301c..76676d6e 100644 --- a/docs/runbooks/agent-app-directories.md +++ b/docs/runbooks/agent-app-directories.md @@ -64,7 +64,7 @@ Any paid Claude plan can submit. Use these fields in the portal. | Support contact | `harlan@harlanzw.com` | | Icon | `public/logo-icon.png` | | Slug | `skilld`. The slug is permanent after publication | -| Use cases | Find agent skills by topic, check provenance and freshness before a run, get the command that installs every Skill in a Repository | +| Use cases | Find agent skills by topic, check provenance and freshness before a run, browse a track or the trending board | | Requirements | None. Every tool works without an account | | Data access | Reads data only | | Company | Harlan Wilton, `https://skilld.dev`, `harlan@harlanzw.com` | diff --git a/integrations/openai-plugin/plugin.json b/integrations/openai-plugin/plugin.json index 98d23c89..cc8296d1 100644 --- a/integrations/openai-plugin/plugin.json +++ b/integrations/openai-plugin/plugin.json @@ -23,12 +23,13 @@ "interface": { "displayName": "skilld", "shortDescription": "Curated agent skills by humans", - "longDescription": "skilld searches skilld.dev, a curated registry of agent skills. A skill is a SKILL.md file that a maintainer writes in their own GitHub repository. It teaches a coding agent how to use a library, a framework, or a workflow.\n\nAsk for a skill by topic, such as Tailwind CSS, Nuxt, or database migrations. skilld returns the matching skills with their author, source repository, GitHub star count, and skilld.dev page. For one skill, it shows the exact SKILL.md and commit on GitHub, when the skill last changed, and the full skill text, so you can read a skill before you run it.\n\nEvery skill comes with its commands. npx skilld run gives the skill to a coding agent for one session and writes no files. npx skilld install keeps the skill in a project. skilld runs and installs nothing itself. You run the command in your own terminal or coding agent.\n\nskilld needs no account and reads public registry data only.", + "longDescription": "skilld searches skilld.dev, a curated registry of agent skills. A skill is a SKILL.md file that a maintainer writes in their own GitHub repository. It teaches a coding agent how to use a library, a framework, or a workflow.\n\nAsk for a skill by topic, such as Tailwind CSS, Nuxt, or database migrations. skilld returns the matching skills with their author, source repository, GitHub star count, and skilld.dev page. For one skill, it shows the exact SKILL.md and commit on GitHub, when the skill last changed, and the full skill text, so you can read a skill before you run it.\n\nYou can also browse. A track is a page of skills for one kind of work, such as testing and debugging or design and interface work. The trending board shows which skills developers talked about on X and Bluesky, with a post that mentioned each one, and which skills gained GitHub stars fast. You can also list every skill that one GitHub repository publishes.\n\nEvery skill comes with its commands. npx skilld run gives the skill to a coding agent for one session and writes no files. npx skilld install keeps the skill in a project. skilld runs and installs nothing itself. You run the command in your own terminal or coding agent.\n\nskilld needs no account and reads public registry data only.", "developerName": "Harlan Wilton", "category": "Developer Tools", "capabilities": [ "Search agent skills", "Show skill provenance and source", + "Browse tracks and trending skills", "Return run and install commands" ], "websiteURL": "https://skilld.dev", @@ -37,8 +38,8 @@ "termsOfServiceURL": "https://skilld.dev/terms", "defaultPrompt": [ "Find a skill for Tailwind CSS and give me the run command.", - "Who wrote the web-design-guidelines skill, and when did it last change?", - "Give me the command to install every skill in anthropics/skills." + "Which skills help with testing and debugging?", + "What skills are developers talking about this week?" ], "brandColor": "#E11D48", "brandColorDark": "#FB7185", @@ -61,16 +62,16 @@ "expected_behavior": "Names Vercel Labs as the publisher. Links the SKILL.md file on GitHub and gives its commit, the last repository push date, and the last skill change date." }, { - "description": "Read a skill before running it", - "prompt": "Show me the SKILL.md of vercel-labs/agent-skills/web-design-guidelines before I run it.", - "tools_triggered": "get_skill", - "expected_behavior": "Returns the SKILL.md text with a link to its source file on GitHub, and the npx skilld run command for the skill." + "description": "Browse a track", + "prompt": "Which skills help with testing and debugging?", + "tools_triggered": "list_tracks, get_track", + "expected_behavior": "Finds the Testing and debugging track and lists its skills, each with its author, skilld.dev page, and npx skilld run command." }, { - "description": "Install every skill in a repository", - "prompt": "Give me the command to install every skill in anthropics/skills.", - "tools_triggered": "install_command", - "expected_behavior": "Returns npx skilld add anthropics/skills, which installs every skill in the repository. States that the user runs the command, and does not claim that anything ran." + "description": "See what developers talk about", + "prompt": "What skills are developers talking about this week?", + "tools_triggered": "list_trending", + "expected_behavior": "Lists this week's trending skills in rank order. Each row says why it is there: developers mentioned it on X or Bluesky, with one post, or its GitHub stars rose. Each row gives an npx skilld run command. It does not call any skill popular or safe." }, { "description": "Get the run command for one skill", @@ -99,7 +100,7 @@ }, "publication": { "countries": [], - "release_notes": "First release. Search skilld.dev for agent skills, show who wrote each skill and where its source lives, and return run and install commands." + "release_notes": "First release. Search skilld.dev for agent skills, show who wrote each skill and where its source lives, browse tracks and the trending board, and return run and install commands." } } } diff --git a/layers/marketing/app/pages/developers/index.vue b/layers/marketing/app/pages/developers/index.vue index 40355e52..d71c2b59 100644 --- a/layers/marketing/app/pages/developers/index.vue +++ b/layers/marketing/app/pages/developers/index.vue @@ -209,13 +209,15 @@ const uiClass = 'font-medium text-default'
diff --git a/layers/marketing/content/pages/privacy.md b/layers/marketing/content/pages/privacy.md index 13f906f7..d5e4f384 100644 --- a/layers/marketing/content/pages/privacy.md +++ b/layers/marketing/content/pages/privacy.md @@ -135,6 +135,10 @@ When your chat app calls a tool, skilld receives what that tool needs: - `search_skills` receives your search text and a result count. - `get_skill` receives the owner, Repository, and name of one Skill. - `install_command` receives one Skill or Repository reference. +- `list_tracks` receives nothing. +- `get_track` receives a track slug, a result count, and an offset. +- `list_trending` receives a period, a week or a month, and a result count. +- `get_repository` receives the owner and name of one Repository and a result count. skilld receives no chat history, no files from the chat, and no account details from the chat app. ChatGPT and Claude call the server from OpenAI or Anthropic servers, so skilld sees their IP address, not yours. Apps on your computer, such as Claude Code or Cursor, call it from your network. Rate limits count requests per IP address, and skilld stores no IP address. diff --git a/layers/mcp/app/pages/developers/mcp.vue b/layers/mcp/app/pages/developers/mcp.vue index 05e4cedb..65eb0ef5 100644 --- a/layers/mcp/app/pages/developers/mcp.vue +++ b/layers/mcp/app/pages/developers/mcp.vue @@ -39,7 +39,7 @@ const linkClass = 'underline underline-offset-2 hover:text-default' MCP server

- The skilld MCP server lets ChatGPT, Claude, and other MCP apps search the registry. It needs no account, and every tool is read only. + The skilld MCP server lets ChatGPT, Claude, and other MCP apps search and browse the registry. It needs no account, and every tool is read only.

    diff --git a/layers/mcp/app/utils/webmcp-tools.ts b/layers/mcp/app/utils/webmcp-tools.ts index 49a81d08..8479974c 100644 --- a/layers/mcp/app/utils/webmcp-tools.ts +++ b/layers/mcp/app/utils/webmcp-tools.ts @@ -6,6 +6,9 @@ import { mcpTools } from '../../shared/mcp-tools' const UNTRUSTED_OUTPUT_TOOLS = new Set([ 'search_skills', 'get_skill', + 'get_track', + 'list_trending', + 'get_repository', ]) export type WebMcpRegistrationResult diff --git a/layers/mcp/server/mcp/index.ts b/layers/mcp/server/mcp/index.ts index 8ca82e6e..b83806f0 100644 --- a/layers/mcp/server/mcp/index.ts +++ b/layers/mcp/server/mcp/index.ts @@ -6,7 +6,7 @@ import { mcpTools } from '../../shared/mcp-tools' const MAX_MCP_BODY_BYTES = 64 * 1024 export default defineMcpHandler({ - description: 'Search skilld.dev for agent skills and read who wrote each one.', + description: 'Search and browse skilld.dev for agent skills, and read who wrote each one.', instructions: 'skilld.dev is a curated registry of agent skills. Search first, inspect provenance before recommending a skill, then return the run command for the user to approve and run. Offer the install command only when the user wants the skill in every session. Without a shell, such as in a chat app, follow the markdown from get_skill for this session, and tell the user the skill name and source repository first. skilld.dev shows who wrote a skill and where its source lives. It does not review skills for safety, so never call a skill safe or verified. This server never runs or installs anything.', tools: (event) => { const deps: McpToolDeps = { diff --git a/layers/mcp/shared/mcp-tools.ts b/layers/mcp/shared/mcp-tools.ts index c6f42239..60fad6d2 100644 --- a/layers/mcp/shared/mcp-tools.ts +++ b/layers/mcp/shared/mcp-tools.ts @@ -193,6 +193,116 @@ const getSkill: McpTool = { }, } +const SEGMENT = z.string().trim().min(1).max(100).regex(/^[\w.-]+$/) +const TRACK_SLUG = z.string().trim().min(1).max(64).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/) + +const ListTracksArgs = z.object({}) + +const listTracks: McpTool = { + name: 'list_tracks', + 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, + examplePrompt: 'What kinds of work does skilld have Skills for?', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + run: async (deps, _args, signal) => { + const result = await clientFor(deps).tracks.list(undefined, { signal }) + return presentResult(deps, 'Track list', result) + }, +} + +const GetTrackArgs = z.object({ + slug: TRACK_SLUG.describe('Track slug, such as "testing" or "design"'), + limit: z.number().int().min(1).max(25).default(10).describe('Maximum Skills to return'), + offset: z.number().int().min(0).max(10_000).default(0).describe('Skills to skip for pagination'), +}) + +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.', + inputSchema: GetTrackArgs.shape, + examplePrompt: 'Which skills help with testing and debugging?', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + run: async (deps, args, signal) => { + const parsed = GetTrackArgs.safeParse(args) + if (!parsed.success) + 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}`) + }, +} + +const ListTrendingArgs = z.object({ + window: z.enum(['week', 'month']).default('week').describe('Board period: the past week or the past month'), + limit: z.number().int().min(1).max(20).default(10).describe('Maximum rows to return'), +}) + +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.', + inputSchema: ListTrendingArgs.shape, + examplePrompt: 'What skills are developers talking about this week?', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + run: async (deps, args, signal) => { + const parsed = ListTrendingArgs.safeParse(args) + if (!parsed.success) + 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) + }, +} + +const GetRepositoryArgs = z.object({ + owner: SEGMENT.describe('GitHub owner'), + repo: SEGMENT.describe('GitHub repository name'), + limit: z.number().int().min(1).max(30).default(20).describe('Maximum Skills to return'), +}) + +const getRepository: McpTool = { + name: 'get_repository', + 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, + examplePrompt: 'Which skills does vercel-labs/agent-skills publish?', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + run: async (deps, args, signal) => { + const parsed = GetRepositoryArgs.safeParse(args) + if (!parsed.success) + 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}`) + }, +} + const InstallCommandArgs = z.object({ ref: z.string().trim().min(1).max(300).describe(`Skill or repository reference: ${ACCEPTED_REFS}`), }) @@ -239,4 +349,4 @@ const installCommand: McpTool = { }, } -export const mcpTools: McpTool[] = [searchSkills, getSkill, installCommand] +export const mcpTools: McpTool[] = [searchSkills, getSkill, installCommand, listTracks, getTrack, listTrending, getRepository] diff --git a/skills/skilld-registry/SKILL.md b/skills/skilld-registry/SKILL.md index ce0acc00..a70db283 100644 --- a/skills/skilld-registry/SKILL.md +++ b/skills/skilld-registry/SKILL.md @@ -15,6 +15,8 @@ Running is the default. `skilld run` hands you the skill for this session and wr 2. Inspect the chosen result with `get_skill`. Check its source repository, commit, trust signals, and freshness. 3. Call `install_command` for the exact commands. Return them to the user; do not claim either one ran. +To browse instead of search, use `list_tracks` and `get_track` for one kind of work, `list_trending` for the Skills developers talk about, or `get_repository` for every Skill in one Repository. Their rows carry the same `runCommand`. + `search_skills` and `get_skill` return `runCommand` beside `installCommand`. Lead with `runCommand`. Offer `installCommand` only when the user asks to keep the skill. Without a shell, such as in a chat app, follow the `markdown` field from `get_skill` for this session. It is the skill's SKILL.md at `sourceCommit`. Tell the user the skill name and source repository first. The `files` beside it are listed, not included. diff --git a/test/unit/mcp-discovery.test.ts b/test/unit/mcp-discovery.test.ts index 1adf2d4a..e1f84191 100644 --- a/test/unit/mcp-discovery.test.ts +++ b/test/unit/mcp-discovery.test.ts @@ -1,5 +1,5 @@ import type { McpToolDeps, McpToolResult } from '../../layers/mcp/shared/mcp-tools' -import { problemType, skillsV1 } from 'skilld-sdk/contract' +import { problemType, repositoriesV1, skillsV1, tracksV1, trendingV1 } from 'skilld-sdk/contract' import { describe, expect, it, vi } from 'vitest' import { installCommandFor, parseInstallRef } from '../../layers/mcp/shared/mcp-install-command' import { mcpTools } from '../../layers/mcp/shared/mcp-tools' @@ -139,6 +139,70 @@ describe('mCP public SDK discovery', () => { }) }) +describe('browse tools', () => { + it('lists tracks with the track contract', async () => { + 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) + 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) + expect(fetchApi).toHaveBeenCalledWith( + 'https://skilld.dev/api/v1/tracks/design?limit=10&offset=0', + expect.objectContaining({ method: 'GET' }), + ) + }) + + it('reads the trending board for a window', async () => { + 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 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' }) + }) + + it('reads a repository and bounds its Skills', async () => { + const example = repositoriesV1.operations.get.docs.examples[0]!.response + 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 }) + expect(fetchApi).toHaveBeenCalledWith( + 'https://skilld.dev/api/v1/repositories/vercel-labs/agent-skills', + expect.objectContaining({ method: 'GET' }), + ) + }) + + it('bounds browse inputs before fetching', async () => { + const toolDeps = deps() + expect((await runTool('list_trending', { window: 'year' }, toolDeps)).isError).toBe(true) + expect((await runTool('list_trending', { limit: 21 }, toolDeps)).isError).toBe(true) + expect((await runTool('get_track', { slug: 'Not A Slug' }, toolDeps)).isError).toBe(true) + expect((await runTool('get_track', { slug: 'design', limit: 26 }, toolDeps)).isError).toBe(true) + expect((await runTool('get_repository', { owner: 'a', repo: 'b', limit: 31 }, toolDeps)).isError).toBe(true) + expect(toolDeps.fetchApi).not.toHaveBeenCalled() + }) + + it.each([ + ['get_track', { slug: 'nothing' }, 'Track not found: nothing'], + ['get_repository', { owner: 'ghost', repo: 'nothing' }, 'Repository not found: ghost/nothing'], + ])('maps %s NOT_FOUND without logging infrastructure errors', async (name, args, message) => { + const toolDeps = deps(vi.fn().mockResolvedValue(problem('NOT_FOUND', 404))) + const result = await runTool(name, args, toolDeps) + expect(result.isError).toBe(true) + expect(result.content[0]!.text).toBe(message) + expect(toolDeps.reportError).not.toHaveBeenCalled() + }) +}) + describe('install_command', () => { it.each([ ['gh:nuxt/nuxt', 'npx skilld add nuxt/nuxt'], diff --git a/test/unit/webmcp.test.ts b/test/unit/webmcp.test.ts index 9792eb64..6067b73b 100644 --- a/test/unit/webmcp.test.ts +++ b/test/unit/webmcp.test.ts @@ -33,6 +33,10 @@ describe('webmcp discovery tools', () => { 'search_skills', 'get_skill', 'install_command', + 'list_tracks', + 'get_track', + 'list_trending', + 'get_repository', ]) expect(tools[0]!.inputSchema).toMatchObject({ type: 'object', From fed3532455946914b4a6b70e8c5095f5f92d7e96 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 15:20:25 +1100 Subject: [PATCH 3/3] feat(mcp): submit a repository for indexing over MCP An Agent that finds a repository the registry lacks had no way to add it. submit_repository calls the public index request and returns the indexed Skills, or a queued id with a status URL. The API dedupes a repeat request, so the tool is idempotent, and it is the only tool that is not read only. Every page and listing that called all tools read only now names the exception. WebMCP passes each tool's own read-only hint instead of marking every tool read only. --- docs/runbooks/agent-app-directories.md | 2 +- integrations/openai-plugin/plugin.json | 7 +-- .../marketing/app/pages/developers/index.vue | 2 +- layers/marketing/content/pages/privacy.md | 3 +- layers/marketing/content/pages/terms.md | 2 +- layers/mcp/app/pages/developers/mcp.vue | 6 +-- layers/mcp/app/utils/webmcp-tools.ts | 3 +- layers/mcp/shared/mcp-tools.ts | 40 +++++++++++++++- skills/skilld-registry/SKILL.md | 2 +- test/unit/mcp-discovery.test.ts | 48 ++++++++++++++++++- test/unit/webmcp.test.ts | 7 ++- 11 files changed, 106 insertions(+), 16 deletions(-) diff --git a/docs/runbooks/agent-app-directories.md b/docs/runbooks/agent-app-directories.md index 76676d6e..940550d2 100644 --- a/docs/runbooks/agent-app-directories.md +++ b/docs/runbooks/agent-app-directories.md @@ -66,7 +66,7 @@ Any paid Claude plan can submit. Use these fields in the portal. | Slug | `skilld`. The slug is permanent after publication | | Use cases | Find agent skills by topic, check provenance and freshness before a run, browse a track or the trending board | | Requirements | None. Every tool works without an account | -| Data access | Reads data only | +| Data access | Reads data, and `submit_repository` queues an index request | | Company | Harlan Wilton, `https://skilld.dev`, `harlan@harlanzw.com` | | Authentication | No authentication | | Data handling | First-party API. No health data. No sponsored content | diff --git a/integrations/openai-plugin/plugin.json b/integrations/openai-plugin/plugin.json index cc8296d1..286808c1 100644 --- a/integrations/openai-plugin/plugin.json +++ b/integrations/openai-plugin/plugin.json @@ -23,14 +23,15 @@ "interface": { "displayName": "skilld", "shortDescription": "Curated agent skills by humans", - "longDescription": "skilld searches skilld.dev, a curated registry of agent skills. A skill is a SKILL.md file that a maintainer writes in their own GitHub repository. It teaches a coding agent how to use a library, a framework, or a workflow.\n\nAsk for a skill by topic, such as Tailwind CSS, Nuxt, or database migrations. skilld returns the matching skills with their author, source repository, GitHub star count, and skilld.dev page. For one skill, it shows the exact SKILL.md and commit on GitHub, when the skill last changed, and the full skill text, so you can read a skill before you run it.\n\nYou can also browse. A track is a page of skills for one kind of work, such as testing and debugging or design and interface work. The trending board shows which skills developers talked about on X and Bluesky, with a post that mentioned each one, and which skills gained GitHub stars fast. You can also list every skill that one GitHub repository publishes.\n\nEvery skill comes with its commands. npx skilld run gives the skill to a coding agent for one session and writes no files. npx skilld install keeps the skill in a project. skilld runs and installs nothing itself. You run the command in your own terminal or coding agent.\n\nskilld needs no account and reads public registry data only.", + "longDescription": "skilld searches skilld.dev, a curated registry of agent skills. A skill is a SKILL.md file that a maintainer writes in their own GitHub repository. It teaches a coding agent how to use a library, a framework, or a workflow.\n\nAsk for a skill by topic, such as Tailwind CSS, Nuxt, or database migrations. skilld returns the matching skills with their author, source repository, GitHub star count, and skilld.dev page. For one skill, it shows the exact SKILL.md and commit on GitHub, when the skill last changed, and the full skill text, so you can read a skill before you run it.\n\nYou can also browse. A track is a page of skills for one kind of work, such as testing and debugging or design and interface work. The trending board shows which skills developers talked about on X and Bluesky, with a post that mentioned each one, and which skills gained GitHub stars fast. You can also list every skill that one GitHub repository publishes.\n\nEvery skill comes with its commands. npx skilld run gives the skill to a coding agent for one session and writes no files. npx skilld install keeps the skill in a project. skilld runs and installs nothing itself. You run the command in your own terminal or coding agent.\n\nskilld needs no account. It reads public registry data, and it can ask skilld.dev to index the skills in a public GitHub repository. A person still reviews which skills the registry admits.", "developerName": "Harlan Wilton", "category": "Developer Tools", "capabilities": [ "Search agent skills", "Show skill provenance and source", "Browse tracks and trending skills", - "Return run and install commands" + "Return run and install commands", + "Submit a repository for indexing" ], "websiteURL": "https://skilld.dev", "supportURL": "https://skilld.dev/developers/mcp#support", @@ -86,7 +87,7 @@ "prompt": "Write a new SKILL.md for my React project and publish it on skilld." }, { - "description": "Change registry data. Every skilld tool is read only.", + "description": "Change registry data. skilld has no tool that edits or deletes a skill.", "prompt": "Delete the tailwind skill from skilld.dev." }, { diff --git a/layers/marketing/app/pages/developers/index.vue b/layers/marketing/app/pages/developers/index.vue index d71c2b59..4241d5fa 100644 --- a/layers/marketing/app/pages/developers/index.vue +++ b/layers/marketing/app/pages/developers/index.vue @@ -177,7 +177,7 @@ const uiClass = 'font-medium text-default' Copy the server URL

    - No account and no API key. Every tool is read only. + No account and no API key. Every tool is read only, except the one that submits a repository for indexing.

diff --git a/layers/marketing/content/pages/privacy.md b/layers/marketing/content/pages/privacy.md index d5e4f384..bede13e4 100644 --- a/layers/marketing/content/pages/privacy.md +++ b/layers/marketing/content/pages/privacy.md @@ -128,7 +128,7 @@ If the client sends your operating system and processor type, such as `darwin-ar ## The MCP server -ChatGPT, Claude, and other MCP apps can search skilld.dev through its MCP server at `https://skilld.dev/api/mcp`. The server needs no account and sets no cookie. Every tool is read only. +ChatGPT, Claude, and other MCP apps can search skilld.dev through its MCP server at `https://skilld.dev/api/mcp`. The server needs no account and sets no cookie. Every tool is read only, except `submit_repository`. When your chat app calls a tool, skilld receives what that tool needs: @@ -139,6 +139,7 @@ When your chat app calls a tool, skilld receives what that tool needs: - `get_track` receives a track slug, a result count, and an offset. - `list_trending` receives a period, a week or a month, and a result count. - `get_repository` receives the owner and name of one Repository and a result count. +- `submit_repository` receives one Repository name or URL. skilld stores the index request with the Repository name and its status. It records nothing about who sent it. skilld receives no chat history, no files from the chat, and no account details from the chat app. ChatGPT and Claude call the server from OpenAI or Anthropic servers, so skilld sees their IP address, not yours. Apps on your computer, such as Claude Code or Cursor, call it from your network. Rate limits count requests per IP address, and skilld stores no IP address. diff --git a/layers/marketing/content/pages/terms.md b/layers/marketing/content/pages/terms.md index 727269b2..074ae0aa 100644 --- a/layers/marketing/content/pages/terms.md +++ b/layers/marketing/content/pages/terms.md @@ -35,7 +35,7 @@ A Skill gives instructions to your Agent. Your Agent can then run commands, chan ## The MCP server and the API -The MCP server lets [ChatGPT](https://chatgpt.com), Claude, and other MCP apps search the registry. It returns search results and commands as text. It runs nothing and installs nothing. +The MCP server lets [ChatGPT](https://chatgpt.com), Claude, and other MCP apps search the registry. It returns search results and commands as text, and it can ask skilld.dev to index a public GitHub repository. It runs nothing and installs nothing. The skilld API serves the same registry to scripts and to the skilld CLI. Account operations need a skilld token. diff --git a/layers/mcp/app/pages/developers/mcp.vue b/layers/mcp/app/pages/developers/mcp.vue index 65eb0ef5..42198d4e 100644 --- a/layers/mcp/app/pages/developers/mcp.vue +++ b/layers/mcp/app/pages/developers/mcp.vue @@ -39,7 +39,7 @@ const linkClass = 'underline underline-offset-2 hover:text-default' MCP server

- The skilld MCP server lets ChatGPT, Claude, and other MCP apps search and browse the registry. It needs no account, and every tool is read only. + The skilld MCP server lets ChatGPT, Claude, and other MCP apps search and browse the registry. It needs no account. Every tool reads the registry, except Submit a repository, which asks skilld.dev to index a public GitHub repository.

    @@ -79,7 +79,7 @@ const linkClass = 'underline underline-offset-2 hover:text-default' {{ tool.title }} {{ tool.name }} - Read only + {{ tool.annotations.readOnlyHint ? 'Read only' : 'Queues a request' }}

    {{ tool.description }} @@ -108,7 +108,7 @@ const linkClass = 'underline underline-offset-2 hover:text-default' Limits and data

      -
    • Every tool is read only. No tool writes data, and the server sets no cookie.
    • +
    • Every tool is read only, except Submit a repository. It queues an index request and changes nothing in the repository. The server sets no cookie.
    • Each search or lookup counts against a limit of {{ RATE_LIMIT_PER_MINUTE }} requests a minute per IP address. ChatGPT and Claude call from their own servers, so their users share that allowance.
    • Each tool receives only its inputs, such as your search text or a Skill name. It receives no chat history and no files.
    diff --git a/layers/mcp/app/utils/webmcp-tools.ts b/layers/mcp/app/utils/webmcp-tools.ts index 8479974c..e5a366be 100644 --- a/layers/mcp/app/utils/webmcp-tools.ts +++ b/layers/mcp/app/utils/webmcp-tools.ts @@ -9,6 +9,7 @@ const UNTRUSTED_OUTPUT_TOOLS = new Set([ 'get_track', 'list_trending', 'get_repository', + 'submit_repository', ]) export type WebMcpRegistrationResult @@ -34,7 +35,7 @@ export function createWebMcpTools( return { _tag: 'ok', data: result.structuredContent ?? {} } }, annotations: { - readOnlyHint: true, + readOnlyHint: tool.annotations.readOnlyHint ?? false, untrustedContentHint: UNTRUSTED_OUTPUT_TOOLS.has(tool.name), }, })) diff --git a/layers/mcp/shared/mcp-tools.ts b/layers/mcp/shared/mcp-tools.ts index 60fad6d2..4aac4575 100644 --- a/layers/mcp/shared/mcp-tools.ts +++ b/layers/mcp/shared/mcp-tools.ts @@ -303,6 +303,44 @@ const getRepository: McpTool = { }, } +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.', + inputSchema: SubmitRepositoryArgs.shape, + examplePrompt: 'Add github.com/vercel-labs/agent-skills to skilld.', + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + run: async (deps, args, signal) => { + const parsed = SubmitRepositoryArgs.safeParse(args) + if (!parsed.success) + return fail('Invalid arguments: repository is owner/repo or the URL of a public GitHub repository') + const { repository } = parsed.data + 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 }) + }, +} + const InstallCommandArgs = z.object({ ref: z.string().trim().min(1).max(300).describe(`Skill or repository reference: ${ACCEPTED_REFS}`), }) @@ -349,4 +387,4 @@ const installCommand: McpTool = { }, } -export const mcpTools: McpTool[] = [searchSkills, getSkill, installCommand, listTracks, getTrack, listTrending, getRepository] +export const mcpTools: McpTool[] = [searchSkills, getSkill, installCommand, listTracks, getTrack, listTrending, getRepository, submitRepository] diff --git a/skills/skilld-registry/SKILL.md b/skills/skilld-registry/SKILL.md index a70db283..527c7908 100644 --- a/skills/skilld-registry/SKILL.md +++ b/skills/skilld-registry/SKILL.md @@ -23,7 +23,7 @@ Without a shell, such as in a chat app, follow the `markdown` field from `get_sk `install_command` returns `runCommand` for a single-skill ref and `command` for the install. A repository ref has no run command, so `runCommand` is `null` there. The MCP server accepts only skill and repository refs. -Every tool is read only. Running and installing both stay separate actions in the user's own environment. +Every tool is read only, except `submit_repository`, which asks skilld.dev to index a public GitHub repository. Running and installing both stay separate actions in the user's own environment. ## Fork a Skill diff --git a/test/unit/mcp-discovery.test.ts b/test/unit/mcp-discovery.test.ts index e1f84191..846f5514 100644 --- a/test/unit/mcp-discovery.test.ts +++ b/test/unit/mcp-discovery.test.ts @@ -1,5 +1,5 @@ import type { McpToolDeps, McpToolResult } from '../../layers/mcp/shared/mcp-tools' -import { problemType, repositoriesV1, skillsV1, tracksV1, trendingV1 } from 'skilld-sdk/contract' +import { indexRequestsV1, problemType, repositoriesV1, skillsV1, tracksV1, trendingV1 } from 'skilld-sdk/contract' import { describe, expect, it, vi } from 'vitest' import { installCommandFor, parseInstallRef } from '../../layers/mcp/shared/mcp-install-command' import { mcpTools } from '../../layers/mcp/shared/mcp-tools' @@ -15,7 +15,7 @@ async function runTool(toolName: string, args: unknown, toolDeps = deps(), signa return tool.run(toolDeps, args, signal) } -function problem(code: 'NOT_FOUND' | 'RATE_LIMITED' | 'INTERNAL_ERROR' | 'SERVICE_UNAVAILABLE', status: number): Response { +function problem(code: 'NOT_FOUND' | 'RATE_LIMITED' | 'INTERNAL_ERROR' | 'SERVICE_UNAVAILABLE' | 'INVALID_REQUEST', status: number): Response { return Response.json({ type: problemType(code), title: code === 'NOT_FOUND' ? 'Not found' : 'Rate limited', @@ -203,6 +203,50 @@ describe('browse tools', () => { }) }) +describe('submit_repository', () => { + const [queuedExample, indexedExample] = indexRequestsV1.operations.create.docs.examples + + it('returns the indexed Skills with the Repository page', async () => { + 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, + pageUrl: 'https://skilld.dev/gh/vercel-labs/agent-skills', + }) + const [input, options] = fetchApi.mock.calls[0]! + expect(new URL(input).pathname).toBe('/api/v1/index-requests') + expect(options.method).toBe('POST') + expect(JSON.parse(options.body)).toEqual({ repository: 'vercel-labs/agent-skills' }) + }) + + it('returns a status URL for a queued request', async () => { + 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, + pageUrl: 'https://skilld.dev/gh/vercel-labs/agent-skills', + statusUrl: `https://skilld.dev/api/v1/index-requests/${response.id}`, + }) + }) + + it('explains a Repository the API cannot index', async () => { + const toolDeps = deps(vi.fn().mockResolvedValue(problem('INVALID_REQUEST', 400))) + const result = await runTool('submit_repository', { repository: 'https://gitlab.com/a/b' }, toolDeps) + expect(result.isError).toBe(true) + expect(result.content[0]!.text).toBe('Cannot index https://gitlab.com/a/b. Send owner/repo or the URL of a public GitHub repository.') + expect(toolDeps.reportError).not.toHaveBeenCalled() + }) + + it('rejects text that names no Repository before fetching', async () => { + const toolDeps = deps() + expect((await runTool('submit_repository', { repository: 'not a repository' }, toolDeps)).isError).toBe(true) + expect(toolDeps.fetchApi).not.toHaveBeenCalled() + }) +}) + describe('install_command', () => { it.each([ ['gh:nuxt/nuxt', 'npx skilld add nuxt/nuxt'], diff --git a/test/unit/webmcp.test.ts b/test/unit/webmcp.test.ts index 6067b73b..90509fc4 100644 --- a/test/unit/webmcp.test.ts +++ b/test/unit/webmcp.test.ts @@ -26,7 +26,7 @@ describe('webmcp support', () => { }) describe('webmcp discovery tools', () => { - it('exposes the MCP tools as read-only JSON Schema tools', () => { + it('exposes the MCP tools as JSON Schema tools with their read-only hints', () => { const tools = createWebMcpTools(deps(), new AbortController().signal) expect(tools.map(tool => tool.name)).toEqual([ @@ -37,6 +37,7 @@ describe('webmcp discovery tools', () => { 'get_track', 'list_trending', 'get_repository', + 'submit_repository', ]) expect(tools[0]!.inputSchema).toMatchObject({ type: 'object', @@ -54,6 +55,10 @@ describe('webmcp discovery tools', () => { readOnlyHint: true, untrustedContentHint: false, }) + expect(tools.find(tool => tool.name === 'submit_repository')!.annotations).toEqual({ + readOnlyHint: false, + untrustedContentHint: true, + }) }) it('returns tagged data and preserves cancellation', async () => {