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 b589be8e..940550d2 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,14 +59,14 @@ 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, 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 742647c0..286808c1 100644 --- a/integrations/openai-plugin/plugin.json +++ b/integrations/openai-plugin/plugin.json @@ -11,24 +11,36 @@ "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\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", "Open curated collections", "Return run and install commands"], + "capabilities": [ + "Search agent skills", + "Show skill provenance and source", + "Browse tracks and trending skills", + "Return run and install commands", + "Submit a repository for indexing" + ], "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?" + "Which skills help with testing and debugging?", + "What skills are developers talking about this week?" ], "brandColor": "#E11D48", "brandColorDark": "#FB7185", @@ -51,16 +63,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": "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": "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", @@ -75,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." }, { @@ -89,7 +101,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, browse tracks and the trending board, 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 92% rename from layers/marketing/app/pages/developers.vue rename to layers/marketing/app/pages/developers/index.vue index 1805f445..4241d5fa 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..e5a366be 100644 --- a/layers/mcp/app/utils/webmcp-tools.ts +++ b/layers/mcp/app/utils/webmcp-tools.ts @@ -6,7 +6,10 @@ import { mcpTools } from '../../shared/mcp-tools' const UNTRUSTED_OUTPUT_TOOLS = new Set([ 'search_skills', 'get_skill', - 'get_collection', + 'get_track', + 'list_trending', + 'get_repository', + 'submit_repository', ]) export type WebMcpRegistrationResult @@ -32,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/server/mcp/index.ts b/layers/mcp/server/mcp/index.ts index 303fdd4c..b83806f0 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 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 = { fetchApi: (input: string, options: RequestInit) => { diff --git a/layers/mcp/shared/mcp-install-command.ts b/layers/mcp/shared/mcp-install-command.ts index 9ace8e7e..9a812225 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} --all` -} - -export function curatorInstallCommand(login: string): string { - return `${PREFIX} @${login} --all` -} - -export function collectionInstallCommand(login: string, slug: string): string { - return `${PREFIX} @${login}/${slug} --all` + return `${V3_PREFIX} add ${owner}/${repo} --all` } 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..4aac4575 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,18 +193,41 @@ 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'), +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 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, +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, @@ -208,24 +235,118 @@ const getCollection: McpTool = { openWorldHint: true, }, run: async (deps, args, signal) => { - const parsed = GetCollectionArgs.safeParse(args) + const parsed = GetTrackArgs.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}`) + 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 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, 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 +357,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 +387,4 @@ const installCommand: McpTool = { }, } -export const mcpTools: McpTool[] = [searchSkills, getSkill, getCollection, installCommand] +export const mcpTools: McpTool[] = [searchSkills, getSkill, installCommand, listTracks, getTrack, listTrending, getRepository, submitRepository] 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..527c7908 100644 --- a/skills/skilld-registry/SKILL.md +++ b/skills/skilld-registry/SKILL.md @@ -13,16 +13,17 @@ 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. + +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. -`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. +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/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 ebdbd6bd..977989fd 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 { 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', @@ -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() @@ -159,6 +139,114 @@ 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('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 --all'], @@ -166,8 +254,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 --all'], - ['@harlan-zw/nuxt-stack', 'npx skilld add @harlan-zw/nuxt-stack --all'], ])('%s -> %s', async (ref, command) => { const result = await runTool('install_command', { ref }) expect((result.structuredContent as any).command).toBe(command) @@ -191,6 +277,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 +295,8 @@ describe('parseInstallRef', () => { expect(installCommandFor(parseInstallRef('a/b')!)).toBe('npx skilld add a/b --all') 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..90509fc4 100644 --- a/test/unit/webmcp.test.ts +++ b/test/unit/webmcp.test.ts @@ -26,14 +26,18 @@ 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([ 'search_skills', 'get_skill', - 'get_collection', 'install_command', + 'list_tracks', + 'get_track', + 'list_trending', + 'get_repository', + 'submit_repository', ]) expect(tools[0]!.inputSchema).toMatchObject({ type: 'object', @@ -47,10 +51,14 @@ describe('webmcp discovery tools', () => { readOnlyHint: true, untrustedContentHint: true, }) - expect(tools[3]!.annotations).toEqual({ + expect(tools[2]!.annotations).toEqual({ 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 () => { @@ -89,8 +97,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 }) })