diff --git a/.gitignore b/.gitignore index ac4760a1..25a600ee 100644 --- a/.gitignore +++ b/.gitignore @@ -178,3 +178,6 @@ PR_DESCRIPTION.md /test/**/*.d.ts /test/**/*.d.cts /test/**/*.d.mts + +# Generated from mapbox/mapbox-agent-skills by scripts/fetch-agent-skills.mjs +/src/skills/agentSkills.generated.ts diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..5d2eba6e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Generated from mapbox/mapbox-agent-skills (scripts/fetch-agent-skills.mjs) +src/skills/agentSkills.generated.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1805ed..c2a8577a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,19 @@ - **JSON Schema 2020-12:** v2 publishes tool schemas as 2020-12 itself, so `publishJsonSchema2020(server)` is now a deprecated no-op, kept so existing callers don't break. `toJsonSchema2020` is unchanged. - **Removed:** `OptimizationV2Tool`, which was never registered and was built on the SDK's experimental tasks API, which v2 removes. - **Codemod:** most of the change is `npx @modelcontextprotocol/codemod v1-to-v2 .` (import paths, method-string `setRequestHandler`, `RequestHandlerExtra` to `ServerContext`, `extra.authInfo` to `ctx.http?.authInfo`). `@modelcontextprotocol/client` and `@modelcontextprotocol/node` are dev dependencies, used only by tests and scripts. - - **Testing:** the old patch tests called `tool.run()` directly and never went through the SDK; they're replaced by tests that make a real `tools/call` through `McpServer` for a mismatched, a missing and a matching `structuredContent`, and they fail without the wrapper. Tests that built a request context by hand used the v1 `{ authInfo }` shape, so the token never reached the code under test; they now use `{ http: { authInfo } }`, which also makes the map app's secret-token checks test what they say. A stdio smoke test against the built server covers the handshake, all 30 tools with 2020-12 schemas, prompts, completions, resources, the MCP Apps UI resource, logging and a tool call. + - **Testing:** listing, getting and reading every file through the SDK's `SkillsClient` (which checks sizes, digests and frontmatter the way hosts do), the unknown-skill error, one catalog serving several servers, an invalid skill failing to load, tool filtering, that the generated skills match `agent-skills.json`, that every served skill loads under the SDK's strict rules, and that no skill file names a tool this server doesn't have. ### New Features +- **Skills over MCP: workflow instructions served with the tools (#295).** The server now supports the [MCP Skills extension](https://modelcontextprotocol.io/extensions/skills/overview) (SEP-2640, `io.modelcontextprotocol/skills`), so a client can list its skills and load one when a task calls for it, instead of the model working the steps out from tool descriptions. + - **Skills:** `mapbox-map-display` (passing refs to `render_map_tool`, several results on one map, hand-placed markers, when to use a static image), `mapbox-reachability-analysis` (isochrone, then category search in its bounding box, then `points_within_polygon_tool`, then one map), `mapbox-location-grounding` (answering location questions from live data with citations) and `mapbox-search-patterns` (picking the right search tool and parameters). + - **One copy of each skill:** the skills live in [mapbox/mapbox-agent-skills](https://github.com/mapbox/mapbox-agent-skills) (the two new ones added in mapbox-agent-skills#94), not here. `agent-skills.json` pins a commit and maps each served skill to the tools it needs, and `npm run fetch-skills` (run by `prepare`, so by `npm ci` and `npm run build`) generates them into the build. They ship inside the package, so nothing is fetched at runtime, and hosted gets them through the package too. + - **Protocol:** served by the SDK's own Skills support (`@modelcontextprotocol/server/ext/skills`, new in 2.4.0 from #303), which declares the extension under `capabilities.extensions`, answers `skills/list` and `skills/get`, and serves each `SKILL.md` at `skill:///SKILL.md` with its sha256 digest and byte size. Our code only turns each skill into its files. Loading is strict, so a skill that breaks the format's rules fails at startup rather than being skipped. + - **Only usable skills:** a skill is served only when every tool it uses is enabled, so `--disable-tools isochrone_tool` drops `mapbox-reachability-analysis`. With no skills left, the extension isn't declared. + - **Turning skills off:** `--disable-skills` or `DISABLE_SKILLS=true` serves none, for example when a plugin already ships its own copies of the same skills. + - **Clients:** ChatGPT imports skills when an app is submitted (up to 5, which is why the list stays short), and fast-agent and MCP Inspector can read them. Claude doesn't support the extension yet. + - **Import:** `@mapbox/mcp-server/skills` exports `ALL_SKILLS`, `AGENT_SKILLS_SOURCE` (the pinned repo and commit), `installSkillsTo(server, skills)` and `loadSkills(skills)`, so the hosted server can serve the same ones. A server built per request should load the catalog once with `loadSkills` and `attach()` it to each server. + - **Testing:** tests for the frontmatter, listing, getting and reading every file through the SDK's `SkillsClient` (which checks sizes, digests and frontmatter the way hosts do), the unknown-skill error, one catalog serving several servers, an invalid skill failing to load, tool filtering, and that every skill meets the Agent Skills name and size limits and names only real tools. - **`list_public_tokens_tool`: a public token for pages built outside the chat (#304, part of #281).** An agent building a site or app with a Mapbox map had to ask the user for a token. The tool lists the account's public (`pk.`) tokens with their names, default flag, URL restrictions and scopes, and recommends one. - **Safer token first:** pass `site_url` and it recommends a token restricted to that URL when one exists. Otherwise it recommends the default public token and says a published site should use a URL-restricted one, with the link to create it. - **Public tokens only:** it asks the Tokens API for `pk` tokens and drops anything else, so secret and temporary tokens never come back. It can't create tokens (that needs `tokens:write`; DevKit's `create_token_tool` does it). diff --git a/CLAUDE.md b/CLAUDE.md index 0b7d207b..6eb9f31f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,14 @@ test/ # Mirrors src/ structure - Resources use URI pattern: `mapbox://resource-name` - Example: category list at `mapbox://categories` or `mapbox://categories/{language}` +### Skills + +- Skills served over the MCP Skills extension (src/skills/) come from mapbox/mapbox-agent-skills, never from this repo +- `agent-skills.json` pins the commit (full SHA) and maps each served skill to the tools it needs +- `npm run fetch-skills` (also run by `npm run prepare`, `npm ci` and `npm run build`) generates the gitignored `src/skills/agentSkills.generated.ts` +- To change a skill: edit it in mapbox-agent-skills, then bump `ref` here; tests fail if a skill names a tool this server doesn't have +- `--disable-skills` or `DISABLE_SKILLS=true` turns skills off (for plugins that ship their own copies) + ## Essential Workflows ### Development diff --git a/README.md b/README.md index 35ddc810..f576ddd5 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,21 @@ Access the complete list of available category IDs for use with the category sea - **Clients with native MCP resource support**: Use the `resources/read` MCP protocol method - **Clients without resource support**: Use the `resource_reader_tool` with the resource URI +## Skills + +The server also serves **skills**: workflow instructions a client can load when a task calls for them, over the [MCP Skills extension](https://modelcontextprotocol.io/extensions/skills/overview) (`io.modelcontextprotocol/skills`). Each one is a `SKILL.md` at `skill:///SKILL.md`, listed by `skills/list`. + +- **`mapbox-map-display`**: showing tool results with `render_map_tool`, combining several results on one map, hand-placed markers, and when a static image is right instead. +- **`mapbox-reachability-analysis`**: finding places within a travel time or distance (coffee within a 10 minute walk) and mapping them with the reachable area. +- **`mapbox-location-grounding`**: answering location questions from live Mapbox data with citations, instead of from training data. +- **`mapbox-search-patterns`**: choosing the right search tool and parameters for geocoding and POI search. + +The skills come from [mapbox/mapbox-agent-skills](https://github.com/mapbox/mapbox-agent-skills), the same skills you can install in Claude Code, Cursor and other assistants. `agent-skills.json` pins the commit and lists which skills this server serves; they're bundled into the package at build time, so nothing is fetched at runtime. + +A skill is only served when all the tools it uses are enabled. Client support is still early: ChatGPT imports skills when an app is submitted, and fast-agent and MCP Inspector can read them. Clients without support can still read the `skill://` files as ordinary resources. + +To turn skills off, for example when a plugin already ships its own copies of the same skills, start the server with `--disable-skills` or set `DISABLE_SKILLS=true`. + ## Rich Map Previews (`render_map_tool`) Every geospatial tool in this server (directions, isochrone, search, and more) can display its result as a live, interactive Mapbox GL JS map via **`render_map_tool`** — the server's single visualization primitive. It renders through the **MCP Apps** protocol (`@modelcontextprotocol/ext-apps`) as a self-contained HTML panel directly inside the chat, with a Fullscreen toggle, in supported clients: diff --git a/agent-skills.json b/agent-skills.json new file mode 100644 index 00000000..f236b37a --- /dev/null +++ b/agent-skills.json @@ -0,0 +1,25 @@ +{ + "repo": "mapbox/mapbox-agent-skills", + "ref": "b993fef50efedf4cb1ed51d18b412cc7368a5403", + "skills": { + "mapbox-map-display": ["render_map_tool"], + "mapbox-reachability-analysis": [ + "isochrone_tool", + "bbox_tool", + "category_search_tool", + "points_within_polygon_tool", + "render_map_tool" + ], + "mapbox-location-grounding": [ + "reverse_geocode_tool", + "category_search_tool", + "directions_tool", + "isochrone_tool" + ], + "mapbox-search-patterns": [ + "search_and_geocode_tool", + "category_search_tool", + "reverse_geocode_tool" + ] + } +} diff --git a/cspell.config.json b/cspell.config.json index 76d2d3be..9b3e4e42 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -13,8 +13,5 @@ "dataplor", "tripadvisor" ], - "ignorePaths": [ - "node_modules", - "dist" - ] + "ignorePaths": ["node_modules", "dist", "src/skills/agentSkills.generated.ts"] } diff --git a/docs/importing-tools.md b/docs/importing-tools.md index 7e3c253e..0918d4dd 100644 --- a/docs/importing-tools.md +++ b/docs/importing-tools.md @@ -9,6 +9,7 @@ The Mapbox MCP Server exposes several subpath exports for direct integration: - `@mapbox/mcp-server/tools` - Geospatial tools (routing, search, geocoding, etc.) - `@mapbox/mcp-server/resources` - Static resources (category lists, etc.) - `@mapbox/mcp-server/prompts` - Pre-built prompts for common workflows +- `@mapbox/mcp-server/skills` - Skills served over the MCP Skills extension - `@mapbox/mcp-server/utils` - HTTP pipeline utilities and server setup helpers All exports support both **ESM** and **CommonJS** via dual builds powered by [tshy](https://github.com/isaacs/tshy). @@ -235,6 +236,23 @@ import { GetDirectionsPrompt } from '@mapbox/mcp-server/prompts'; const prompt = new GetDirectionsPrompt(); ``` +## Skills + +Skills are workflow instructions served over the [MCP Skills extension](https://modelcontextprotocol.io/extensions/skills/overview) (SEP-2640), using the SDK's own support for it (`@modelcontextprotocol/server/ext/skills`). To serve the same skills from your own server, install them before connecting: + +```typescript +import { McpServer } from '@modelcontextprotocol/server'; +import { getSkillsForTools, installSkillsTo } from '@mapbox/mcp-server/skills'; + +// Only the skills whose tools you've installed. +const skills = getSkillsForTools(['isochrone_tool', 'render_map_tool' /* … */]); + +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); +await installSkillsTo(server, skills); +``` + +This declares the extension and serves `skills/list`, `skills/get` and the `skill:///SKILL.md` files. If you create a server per request, build the catalog once with `loadSkills(skills)` and call `catalog.attach(server)` on each one instead. `ALL_SKILLS` has every skill this package serves, taken from [mapbox/mapbox-agent-skills](https://github.com/mapbox/mapbox-agent-skills) at the commit `AGENT_SKILLS_SOURCE` names. + ## HTTP Pipeline Utilities ### Default Pipeline diff --git a/eslint.config.mjs b/eslint.config.mjs index 16a59664..d72f45a4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,6 +7,8 @@ import prettierPluginRecommended from 'eslint-plugin-prettier/recommended'; import unusedImports from 'eslint-plugin-unused-imports'; export default defineConfig( + // Generated from mapbox/mapbox-agent-skills (scripts/fetch-agent-skills.mjs) + { ignores: ['src/skills/agentSkills.generated.ts'] }, eslint.configs.recommended, tseslint.configs.recommended, prettierPluginRecommended, diff --git a/package.json b/package.json index 660ff9db..b4413f0d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" \"./examples/**/*.{ts,tsx}\"", "lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" \"./examples/**/*.{ts,tsx}\" --fix", "postinstall": "patch-package || (cd ../../.. && node ./node_modules/patch-package/index.js --patch-dir ./node_modules/@mapbox/mcp-server/patches) || true", - "prepare": "node -e \"try { require('fs').accessSync('.husky/setup-hooks.js'); require('child_process').execSync('husky && node .husky/setup-hooks.js', {stdio:'inherit'}) } catch { }\"", + "fetch-skills": "node scripts/fetch-agent-skills.mjs", + "prepare": "node scripts/fetch-agent-skills.mjs && node -e \"try { require('fs').accessSync('.husky/setup-hooks.js'); require('child_process').execSync('husky && node .husky/setup-hooks.js', {stdio:'inherit'}) } catch { }\"", "spellcheck": "cspell \"*.md\" \"src/**/*.ts\" \"test/**/*.ts\" \"examples/**/*.ts\"", "sync-manifest": "node scripts/sync-manifest-version.cjs", "test": "vitest", @@ -114,6 +115,7 @@ "./tools": "./src/tools/index.ts", "./resources": "./src/resources/index.ts", "./prompts": "./src/prompts/index.ts", + "./skills": "./src/skills/index.ts", "./utils": "./src/utils/index.ts" }, "dialects": [ @@ -163,6 +165,16 @@ "default": "./dist/commonjs/prompts/index.js" } }, + "./skills": { + "import": { + "types": "./dist/esm/skills/index.d.ts", + "default": "./dist/esm/skills/index.js" + }, + "require": { + "types": "./dist/commonjs/skills/index.d.ts", + "default": "./dist/commonjs/skills/index.js" + } + }, "./utils": { "import": { "types": "./dist/esm/utils/index.d.ts", diff --git a/scripts/fetch-agent-skills.mjs b/scripts/fetch-agent-skills.mjs new file mode 100644 index 00000000..460c72ba --- /dev/null +++ b/scripts/fetch-agent-skills.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +// Generates src/skills/agentSkills.generated.ts from the skills listed in +// agent-skills.json, taken from mapbox/mapbox-agent-skills at the pinned +// commit. That repo is the only copy of the skills; this file is gitignored +// and rebuilt by `npm run prepare` (which `npm ci` and `npm run build` run), +// so the published package carries the skills and nothing is fetched at +// runtime. It skips the download when the generated file already matches +// agent-skills.json. + +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const configPath = join(root, 'agent-skills.json'); +const outPath = join(root, 'src', 'skills', 'agentSkills.generated.ts'); + +const configText = readFileSync(configPath, 'utf8'); +const config = JSON.parse(configText); +const configHash = createHash('sha256').update(configText).digest('hex'); +const stamp = `// agent-skills.json sha256: ${configHash}`; + +if (!/^[0-9a-f]{40}$/.test(config.ref)) { + throw new Error( + `agent-skills.json "ref" must be a full commit SHA, got "${config.ref}"` + ); +} + +if (existsSync(outPath) && readFileSync(outPath, 'utf8').includes(stamp)) { + process.exit(0); +} + +// Supporting files the model can read. evals/ is for testing the skill and +// AGENTS.md is a condensed copy for other assistants, so neither is served. +const SKIPPED = new Set(['evals', 'AGENTS.md']); + +function readSkillFiles(dir, base = dir, files = {}) { + const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name) + ); + for (const entry of entries) { + if (dir === base && SKIPPED.has(entry.name)) continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) { + readSkillFiles(path, base, files); + } else if (entry.isFile()) { + const bytes = readFileSync(path); + const rel = relative(base, path).split(sep).join('/'); + // Fails on anything that isn't UTF-8 text. + files[rel] = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } else { + throw new Error(`Unsupported file in skill: ${path}`); + } + } + return files; +} + +const url = `https://codeload.github.com/${config.repo}/tar.gz/${config.ref}`; +const response = await fetch(url); +if (!response.ok) { + throw new Error( + `Fetching skills from ${url} failed: ${response.status} ${response.statusText}` + ); +} + +const work = mkdtempSync(join(tmpdir(), 'agent-skills-')); +try { + const archive = join(work, 'skills.tar.gz'); + writeFileSync(archive, Buffer.from(await response.arrayBuffer())); + execFileSync('tar', ['-xzf', archive, '-C', work]); + const [top] = readdirSync(work, { withFileTypes: true }).filter((e) => + e.isDirectory() + ); + + const skills = Object.entries(config.skills).map(([name, tools]) => { + const dir = join(work, top.name, 'skills', name); + if (!existsSync(join(dir, 'SKILL.md'))) { + throw new Error(`${config.repo}@${config.ref} has no skill "${name}"`); + } + return { name, tools, files: readSkillFiles(dir) }; + }); + + const source = `${config.repo}@${config.ref}`; + writeFileSync( + outPath, + `// Generated by scripts/fetch-agent-skills.mjs from ${source}.\n` + + `// Do not edit: change the skills there and the pin in agent-skills.json.\n` + + `${stamp}\n\n` + + `import type { Skill } from './Skill.js';\n\n` + + `export const AGENT_SKILLS_SOURCE = ${JSON.stringify(source)};\n\n` + + `export const AGENT_SKILLS: readonly Skill[] = ${JSON.stringify(skills, null, 2)};\n` + ); + console.log( + `Generated ${relative(root, outPath)}: ${skills.map((s) => s.name).join(', ')} from ${source}` + ); +} finally { + rmSync(work, { recursive: true, force: true }); +} diff --git a/src/config/toolConfig.ts b/src/config/toolConfig.ts index 75881b70..673b24d6 100644 --- a/src/config/toolConfig.ts +++ b/src/config/toolConfig.ts @@ -6,6 +6,8 @@ import type { ToolInstance } from '../tools/toolRegistry.js'; export interface ToolConfig { enabledTools?: string[]; disabledTools?: string[]; + /** Don't serve skills, e.g. when a plugin already ships its own copies. */ + disableSkills?: boolean; } export function parseToolConfigFromArgs(): ToolConfig { @@ -25,9 +27,15 @@ export function parseToolConfigFromArgs(): ToolConfig { if (value) { config.disabledTools = value.split(',').map((t) => t.trim()); } + } else if (arg === '--disable-skills') { + config.disableSkills = true; } } + if (process.env.DISABLE_SKILLS === 'true') { + config.disableSkills = true; + } + return config; } diff --git a/src/index.ts b/src/index.ts index 46c96a66..feb82dc8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,8 @@ import { } from './tools/toolRegistry.js'; import { getAllResources } from './resources/resourceRegistry.js'; import { getAllPrompts, getPromptByName } from './prompts/promptRegistry.js'; +import { getSkillsForTools } from './skills/skillRegistry.js'; +import { installSkillsTo } from './skills/skillsExtension.js'; import { completePromptArgument } from './completions/index.js'; import { getVersionInfo } from './utils/versionUtils.js'; import { buildServerInstructions } from './utils/serverInstructions.js'; @@ -89,6 +91,18 @@ const enabledResourceFallbackTools = filterTools(resourceFallbackTools, config); // Get all resources const allResources = getAllResources(); +const enabledToolNames = [ + ...enabledCoreTools, + ...enabledElicitationTools, + ...enabledResourceFallbackTools +].map((tool) => tool.name); + +// Skills whose tools are all enabled (MCP Skills extension, SEP-2640), +// unless --disable-skills or DISABLE_SKILLS=true turned them off +const enabledSkills = config.disableSkills + ? [] + : getSkillsForTools(enabledToolNames); + // Create an MCP server const server = new McpServer( { @@ -110,13 +124,7 @@ const server = new McpServer( ] }, { - instructions: buildServerInstructions( - [ - ...enabledCoreTools, - ...enabledElicitationTools, - ...enabledResourceFallbackTools - ].map((tool) => tool.name) - ), + instructions: buildServerInstructions(enabledToolNames), capabilities: { tools: { listChanged: true // Advertise support for dynamic tool registration (ready for future capability-dependent tools) @@ -284,6 +292,13 @@ async function main() { }); }; + // Skills (MCP Skills extension, SEP-2640). Loading is async, so it + // happens here rather than with the other registrations, but still + // before connect(), which the extension requires. + if (enabledSkills.length > 0) { + await installSkillsTo(server, enabledSkills); + } + // Start receiving messages on stdin and sending messages on stdout const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/skills/Skill.ts b/src/skills/Skill.ts new file mode 100644 index 00000000..070a926c --- /dev/null +++ b/src/skills/Skill.ts @@ -0,0 +1,19 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * A skill served over the MCP Skills extension (SEP-2640), following the + * Agent Skills format. The skills themselves live in + * mapbox/mapbox-agent-skills; see agent-skills.json. + */ +export interface Skill { + /** Lowercase, hyphenated. Also the skill's directory: skill:///. */ + readonly name: string; + /** + * Tools the instructions rely on. The skill is only served when all of + * them are enabled, so it never points the model at a missing tool. + */ + readonly tools: readonly string[]; + /** The skill's files by path relative to its directory, SKILL.md included. */ + readonly files: Readonly>; +} diff --git a/src/skills/index.ts b/src/skills/index.ts new file mode 100644 index 00000000..cbd2ca77 --- /dev/null +++ b/src/skills/index.ts @@ -0,0 +1,11 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +export type { Skill } from './Skill.js'; +export { + SKILLS_EXTENSION_ID, + installSkillsTo, + loadSkills +} from './skillsExtension.js'; +export { ALL_SKILLS, getSkillsForTools } from './skillRegistry.js'; +export { AGENT_SKILLS_SOURCE } from './agentSkills.generated.js'; diff --git a/src/skills/skillRegistry.ts b/src/skills/skillRegistry.ts new file mode 100644 index 00000000..0d93fe71 --- /dev/null +++ b/src/skills/skillRegistry.ts @@ -0,0 +1,19 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { Skill } from './Skill.js'; +import { AGENT_SKILLS } from './agentSkills.generated.js'; + +// Taken from mapbox/mapbox-agent-skills at the commit agent-skills.json pins. +// ChatGPT imports at most 5 skills from a server, so keep this list short. +export const ALL_SKILLS: readonly Skill[] = AGENT_SKILLS; + +/** Skills whose tools are all enabled. */ +export function getSkillsForTools( + enabledToolNames: readonly string[] +): Skill[] { + const enabled = new Set(enabledToolNames); + return ALL_SKILLS.filter((skill) => + skill.tools.every((tool) => enabled.has(tool)) + ); +} diff --git a/src/skills/skillsExtension.ts b/src/skills/skillsExtension.ts new file mode 100644 index 00000000..cd6dd77e --- /dev/null +++ b/src/skills/skillsExtension.ts @@ -0,0 +1,53 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { McpServer } from '@modelcontextprotocol/server'; +import { + SkillsExtension, + memorySkillFiles, + type SkillCatalog +} from '@modelcontextprotocol/server/ext/skills'; +import type { Skill } from './Skill.js'; + +/** + * The MCP Skills extension (SEP-2640): + * https://modelcontextprotocol.io/extensions/skills/overview + * + * The SDK serves it (`@modelcontextprotocol/server/ext/skills`): skills/list, + * skills/get, the skill:// resources and the capability. This file only + * hands it our `Skill` files. + */ +export { SKILLS_EXTENSION_ID } from '@modelcontextprotocol/server/ext/skills'; + +/** Skills are static per server version, so clients can cache them a while. */ +const SKILL_TTL_MS = 60 * 60 * 1000; + +/** + * Builds the catalog for `skills`, served at skill:///SKILL.md plus + * each skill's supporting files. Build it once and `attach()` it to every + * server (before connecting); one catalog serves any number of servers. + * Throws if a skill breaks the format's rules (name, frontmatter, limits). + */ +export function loadSkills(skills: readonly Skill[]): Promise { + const files: Record = {}; + for (const skill of skills) { + for (const [path, text] of Object.entries(skill.files)) { + files[`${skill.name}/${path}`] = text; + } + } + return SkillsExtension.load(memorySkillFiles(files), { + strict: true, + cacheHint: { ttlMs: SKILL_TTL_MS, cacheScope: 'public' } + }); +} + +/** + * Serves `skills` on `server`: loads them and attaches the catalog, which + * also declares the extension capability. Must run before connecting. + */ +export async function installSkillsTo( + server: McpServer, + skills: readonly Skill[] +): Promise { + (await loadSkills(skills)).attach(server); +} diff --git a/test/config/toolConfig.test.ts b/test/config/toolConfig.test.ts index 3d9c1670..7b68b8e2 100644 --- a/test/config/toolConfig.test.ts +++ b/test/config/toolConfig.test.ts @@ -136,6 +136,32 @@ describe('Tool Configuration', () => { const config = parseToolConfigFromArgs(); expect(config).toEqual({ enabledTools: ['version_tool'] }); }); + + it('should parse --disable-skills', () => { + process.argv = ['node', 'index.js', '--disable-skills']; + const config = parseToolConfigFromArgs(); + expect(config).toEqual({ disableSkills: true }); + }); + + it('should disable skills when DISABLE_SKILLS=true', () => { + vi.stubEnv('DISABLE_SKILLS', 'true'); + process.argv = ['node', 'index.js']; + try { + expect(parseToolConfigFromArgs()).toEqual({ disableSkills: true }); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('should leave skills on for any other DISABLE_SKILLS value', () => { + vi.stubEnv('DISABLE_SKILLS', '1'); + process.argv = ['node', 'index.js']; + try { + expect(parseToolConfigFromArgs()).toEqual({}); + } finally { + vi.unstubAllEnvs(); + } + }); }); describe('filterTools', () => { diff --git a/test/exports.test.ts b/test/exports.test.ts index c177c0ae..c46b43ad 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -45,6 +45,28 @@ describe('Package exports', () => { }); }); + describe('skills subpath', () => { + it('should export the skills and the extension helpers', async () => { + const { + ALL_SKILLS, + AGENT_SKILLS_SOURCE, + SKILLS_EXTENSION_ID, + getSkillsForTools, + installSkillsTo, + loadSkills + } = await import('../src/skills/index.js'); + + expect(SKILLS_EXTENSION_ID).toBe('io.modelcontextprotocol/skills'); + expect(ALL_SKILLS.map((s) => s.name)).toContain('mapbox-map-display'); + expect(AGENT_SKILLS_SOURCE).toMatch( + /^mapbox\/mapbox-agent-skills@[0-9a-f]{40}$/ + ); + expect(typeof getSkillsForTools).toBe('function'); + expect(typeof installSkillsTo).toBe('function'); + expect(typeof loadSkills).toBe('function'); + }); + }); + describe('resources subpath', () => { it('should export pre-configured resource instances', async () => { const { categoryList } = await import('../src/resources/index.js'); diff --git a/test/skills/skillsExtension.test.ts b/test/skills/skillsExtension.test.ts new file mode 100644 index 00000000..c2de1d1b --- /dev/null +++ b/test/skills/skillsExtension.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { McpServer, InMemoryTransport } from '@modelcontextprotocol/server'; +import { Client } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; +import { + SKILLS_EXTENSION_ID, + installSkillsTo, + loadSkills +} from '../../src/skills/skillsExtension.js'; +import { + ALL_SKILLS, + getSkillsForTools +} from '../../src/skills/skillRegistry.js'; +import { AGENT_SKILLS_SOURCE } from '../../src/skills/agentSkills.generated.js'; +import type { Skill } from '../../src/skills/Skill.js'; +import { getAllTools } from '../../src/tools/toolRegistry.js'; + +const testSkill: Skill = { + name: 'test-skill', + tools: ['render_map_tool'], + files: { + 'SKILL.md': + '---\nname: test-skill\ndescription: "Has a colon: and \\"quotes\\" in it"\n---\n\n# Test\n\nRead `references/notes.md`.\n', + 'references/notes.md': 'Notes with a café\n' + } +}; + +async function connect(skills: readonly Skill[]) { + const server = new McpServer( + { name: 'test', version: '0.0.0' }, + { capabilities: { resources: {} } } + ); + await installSkillsTo(server, skills); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '0.0.0' }); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport) + ]); + return client; +} + +describe('skills extension', () => { + // SkillsClient is the SDK's own client side: it checks every file's size + // and digest against the listing and SKILL.md's frontmatter against the + // entry, the same checks hosts make before trusting a skill. + it('serves skills that the SDK client lists, gets and verifies', async () => { + const client = await connect([testSkill]); + expect( + client.getServerCapabilities()?.extensions?.[SKILLS_EXTENSION_ID] + ).toBeDefined(); + + const skills = new SkillsClient(client); + expect(skills.supported).toBe(true); + + const list = await skills.list(); + expect(list.invalid).toEqual([]); + expect(list.skills).toHaveLength(1); + const [entry] = list.skills; + expect(entry.uri).toBe('skill://test-skill/SKILL.md'); + + expect((await skills.get(entry.uri)).skill).toEqual(entry); + + const skillMd = await skills.read(entry); + expect(skillMd.verified).toBe(true); + expect(skillMd.frontmatter).toEqual({ + name: 'test-skill', + // The colon and quotes survive the YAML round trip. + description: 'Has a colon: and "quotes" in it' + }); + expect(skillMd.text).toBe(testSkill.files['SKILL.md']); + + const notes = await skills.read(entry, 'references/notes.md'); + expect(notes.verified).toBe(true); + expect(notes.text).toBe('Notes with a café\n'); + + await client.close(); + }); + + it('answers an unknown skill with an error', async () => { + const client = await connect([testSkill]); + await expect( + new SkillsClient(client).get('skill://nope/SKILL.md') + ).rejects.toThrow(); + await client.close(); + }); + + it('builds one catalog that serves several servers', async () => { + const catalog = await loadSkills(ALL_SKILLS); + expect(catalog.entries.map((e) => e.uri)).toEqual( + ALL_SKILLS.map((s) => `skill://${s.name}/SKILL.md`).sort() + ); + for (let i = 0; i < 2; i++) { + const server = new McpServer({ name: 'test', version: '0.0.0' }); + catalog.attach(server); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '0.0.0' }); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport) + ]); + const list = await new SkillsClient(client).list(); + expect(list.skills).toHaveLength(ALL_SKILLS.length); + await client.close(); + } + }); + + it('rejects a skill that breaks the format instead of serving it', async () => { + await expect( + loadSkills([{ ...testSkill, name: 'Not A Valid Name' }]) + ).rejects.toThrow(); + }); +}); + +describe('skill registry', () => { + const toolNames = getAllTools().map((tool) => tool.name); + + it('was generated from the commit agent-skills.json pins', () => { + // A stale generated file means `npm run fetch-skills` wasn't run. + const config = JSON.parse( + readFileSync(new URL('../../agent-skills.json', import.meta.url), 'utf8') + ); + expect(AGENT_SKILLS_SOURCE).toBe(`${config.repo}@${config.ref}`); + expect(ALL_SKILLS.map((s) => s.name)).toEqual(Object.keys(config.skills)); + }); + + it('stays within ChatGPT import limit of 5 skills', () => { + expect(ALL_SKILLS.length).toBeGreaterThan(0); + expect(ALL_SKILLS.length).toBeLessThanOrEqual(5); + }); + + it('only serves skills whose tools are all enabled', () => { + expect(getSkillsForTools(toolNames)).toEqual(ALL_SKILLS); + const withoutIsochrone = getSkillsForTools( + toolNames.filter((name) => name !== 'isochrone_tool') + ); + expect(withoutIsochrone.map((s) => s.name)).toEqual( + ALL_SKILLS.filter((s) => !s.tools.includes('isochrone_tool')).map( + (s) => s.name + ) + ); + expect(withoutIsochrone.length).toBeLessThan(ALL_SKILLS.length); + expect(getSkillsForTools([])).toEqual([]); + }); + + it('loads every skill under the SDK strict rules', async () => { + const catalog = await loadSkills(ALL_SKILLS); + expect(catalog.entries).toHaveLength(ALL_SKILLS.length); + }); + + it.each(ALL_SKILLS.map((skill) => [skill.name, skill] as const))( + '%s names only tools this server has', + (_name, skill) => { + // ChatGPT's import limit for SKILL.md. + expect( + Buffer.byteLength(skill.files['SKILL.md'], 'utf8') + ).toBeLessThanOrEqual(256 * 1024); + + for (const tool of skill.tools) expect(toolNames).toContain(tool); + // Every file the model can read, so a skill never sends it to a tool + // that doesn't exist here. + for (const [path, text] of Object.entries(skill.files)) { + for (const tool of text.match(/\b[a-z_]+_tool\b/g) ?? []) { + expect(toolNames, `${skill.name}/${path}`).toContain(tool); + } + } + } + ); +});