From 4667ada58278df13f2fb13e1c68559a52c3ea56a Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 21 Oct 2026 09:08:37 -0400 Subject: [PATCH 1/5] feat: serve skills over the MCP Skills extension (SEP-2640) Adds two skills, mapbox-map-display and mapbox-reachability-analysis, served through skills/list, skills/get and skill:// resources, with manifest digests computed from the served bytes. A skill is only served when all its tools are enabled. Also exported from @mapbox/mcp-server/skills for the hosted server. Part of #259 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + README.md | 9 + docs/importing-tools.md | 30 +++ package.json | 11 ++ src/index.ts | 31 ++- src/skills/Skill.ts | 21 ++ src/skills/index.ts | 14 ++ src/skills/map-display/mapDisplaySkill.ts | 71 +++++++ .../reachabilityAnalysisSkill.ts | 74 +++++++ src/skills/skillRegistry.ts | 22 +++ src/skills/skillsExtension.ts | 149 ++++++++++++++ test/exports.test.ts | 17 ++ test/skills/skillsExtension.test.ts | 186 ++++++++++++++++++ 13 files changed, 634 insertions(+), 8 deletions(-) create mode 100644 src/skills/Skill.ts create mode 100644 src/skills/index.ts create mode 100644 src/skills/map-display/mapDisplaySkill.ts create mode 100644 src/skills/reachability-analysis/reachabilityAnalysisSkill.ts create mode 100644 src/skills/skillRegistry.ts create mode 100644 src/skills/skillsExtension.ts create mode 100644 test/skills/skillsExtension.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1541150..e3b7535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ### New Features +- **Skills over MCP: workflow instructions served with the tools (#259).** 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) and `mapbox-reachability-analysis` (isochrone, then category search inside its bounding box, then dropping places outside the polygon, then one map with the area and the places). The second goes further than the `show-reachable-areas` prompt, which maps the area only. + - **Protocol:** the server declares the extension under `capabilities.extensions` and answers `skills/list` and `skills/get`. Each `SKILL.md` is a resource at `skill:///SKILL.md`, and each listing carries the file's sha256 digest and byte size, computed from the bytes served. An unknown skill gets `-32602`. The TypeScript SDK doesn't support the extension yet (typescript-sdk#2818), so these are custom request handlers, to be swapped for the SDK's once it ships. + - **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. + - **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 the skills and `installSkillsTo`, so the hosted server can serve the same ones. + - **Testing:** tests for the frontmatter, manifest digests and byte sizes against what `resources/read` returns, `skills/list` and `skills/get` over an in-memory client, the unknown-skill error, tool filtering, and that every skill meets the Agent Skills name and size limits and names only real tools. - **`feedback_tool`: report wrong map, place or routing data to Mapbox (#291).** When someone says a place has closed, a road is closed, or a route took an illegal turn, the agent can send it to the [Feedback API](https://docs.mapbox.com/api/feedback/) (`POST /user-feedback/v1/feedback`, Public Preview) with the text, a category and the location, usually taken from an earlier search or directions result. It returns the report's id and status (`received`). - **Confirm first:** the report goes to Mapbox under the user's account, so the description tells the model to show the user what it will send and get a yes. Clients that support elicitation also get a confirmation prompt from the tool itself. Declining sends nothing, and so does a failed prompt. - **Categories:** an enum of the Feedback Agent's default categories that make sense from chat (`poi_closed`, `poi_details`, `road_closure_issue`, `illegal_turn` and so on), leaving out the navigation-app ones such as voice guidance and rerouting. diff --git a/README.md b/README.md index 4494629..1e75973 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,15 @@ 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. + +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. + ## 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/docs/importing-tools.md b/docs/importing-tools.md index 8b982b9..65f0846 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,35 @@ 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). To serve the same skills from your own server, declare the extension when you create it, then install them: + +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + SKILLS_EXTENSION_ID, + 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' }, + { + capabilities: { + resources: {}, + extensions: { [SKILLS_EXTENSION_ID]: {} } + } + } +); +installSkillsTo(server, skills); +``` + +This registers `skills/list`, `skills/get` and a `skill:///SKILL.md` resource per skill. Available skills: `mapDisplaySkill` (`mapbox-map-display`) and `reachabilityAnalysisSkill` (`mapbox-reachability-analysis`). + ## HTTP Pipeline Utilities ### Default Pipeline diff --git a/package.json b/package.json index 6b53b40..a8e27e6 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,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": [ @@ -159,6 +160,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/src/index.ts b/src/index.ts index 5afb5e2..468d0c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,11 @@ 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 { + SKILLS_EXTENSION_ID, + installSkillsTo +} from './skills/skillsExtension.js'; import { completePromptArgument } from './completions/index.js'; import { getVersionInfo } from './utils/versionUtils.js'; import { publishJsonSchema2020 } from './utils/jsonSchema2020.js'; @@ -96,6 +101,15 @@ 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) +const enabledSkills = getSkillsForTools(enabledToolNames); + // Create an MCP server const server = new McpServer( { @@ -117,13 +131,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) @@ -131,7 +139,10 @@ const server = new McpServer( completions: {}, resources: {}, prompts: {}, - logging: {} + logging: {}, + ...(enabledSkills.length > 0 && { + extensions: { [SKILLS_EXTENSION_ID]: {} } + }) } } ); @@ -172,6 +183,10 @@ regularResources.forEach((resource) => { resource.installTo(server); }); +if (enabledSkills.length > 0) { + installSkillsTo(server, enabledSkills); +} + // Register prompt handlers server.server.setRequestHandler(ListPromptsRequestSchema, async () => { const allPrompts = getAllPrompts(); diff --git a/src/skills/Skill.ts b/src/skills/Skill.ts new file mode 100644 index 0000000..f5a2a30 --- /dev/null +++ b/src/skills/Skill.ts @@ -0,0 +1,21 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * A skill served over the MCP Skills extension (SEP-2640): a `SKILL.md` + * plus optional supporting files, following the Agent Skills format. + */ +export interface Skill { + /** Lowercase, hyphenated. Also the skill's directory: skill:///. */ + readonly name: string; + readonly description: 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[]; + /** Markdown body of SKILL.md, after the frontmatter. */ + readonly instructions: string; + /** Supporting files by path relative to the skill directory. */ + readonly files?: Readonly>; +} diff --git a/src/skills/index.ts b/src/skills/index.ts new file mode 100644 index 0000000..1caa763 --- /dev/null +++ b/src/skills/index.ts @@ -0,0 +1,14 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +export type { Skill } from './Skill.js'; +export { + SKILLS_EXTENSION_ID, + installSkillsTo, + serveSkill, + skillMarkdown +} from './skillsExtension.js'; +export type { SkillEntry, SkillFileEntry } from './skillsExtension.js'; +export { ALL_SKILLS, getSkillsForTools } from './skillRegistry.js'; +export { mapDisplaySkill } from './map-display/mapDisplaySkill.js'; +export { reachabilityAnalysisSkill } from './reachability-analysis/reachabilityAnalysisSkill.js'; diff --git a/src/skills/map-display/mapDisplaySkill.ts b/src/skills/map-display/mapDisplaySkill.ts new file mode 100644 index 0000000..b62db7f --- /dev/null +++ b/src/skills/map-display/mapDisplaySkill.ts @@ -0,0 +1,71 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { Skill } from '../Skill.js'; + +export const mapDisplaySkill: Skill = { + name: 'mapbox-map-display', + description: + 'Show Mapbox tool results on an interactive map with render_map_tool. Covers passing refs, combining several results on one map, hand-placed markers, and when a static image is the right call instead.', + tools: ['render_map_tool'], + instructions: `# Showing results on a Mapbox map + +Use \`render_map_tool\` whenever the user wants to see something on a map. It +renders a live, interactive Mapbox GL JS map inline in the conversation. + +## Pass refs, not geometry + +Every geo tool (search, directions, isochrone, elevation and so on) returns +\`structuredContent.mapboxRender.ref\`. Pass it to \`render_map_tool\` unchanged: + +\`\`\`json +{ "payload_refs": [""] } +\`\`\` + +- Never write, edit or shorten a ref. Copy it exactly. +- Don't copy coordinates out of a tool result into \`layers\` or \`markers\` + when a ref exists. The ref keeps thousands of coordinates out of the + conversation and the map fetches the data itself. +- The camera fits all the data on its own. Only set \`camera\` when the user + asks for a particular view. + +## Several results on one map + +Put every ref in the same call to answer one question on one map, for example +a route plus places along it, or a drive-time area plus the places inside it: + +\`\`\`json +{ "payload_refs": ["", ""], "summary": "Coffee within a 10 min walk" } +\`\`\` + +\`summary\` is the short chip in the map's top-left corner. Keep it under about +40 characters. + +## Hand-placed markers + +Use \`markers\` only for points no tool returned a ref for, or for a filtered +subset of a result (see the mapbox-reachability-analysis skill). + +- For a place from a search tool, set \`id\` to its \`mapbox_id\` and \`name\` to + its name (\`category\` and \`distanceMeters\` are optional). The map then lists + it in its results panel with a photo and popularity when Mapbox has them, + the same as a search ref would. +- \`style\` is \`pin\` (default), \`numbered\` (with \`label\`, e.g. visit order), + \`start\` or \`end\`. +- Coordinates are \`[longitude, latitude]\`. + +## Restyling the base map + +- \`baseMapConfig: { "lightPreset": "night" }\` (also \`dawn\`, \`dusk\`) for + lighting. +- \`baseStyle: "standard-satellite"\` for imagery. +- Only restyle when the user asks, or when it clearly helps (night lighting for + a question about the evening). + +## Static images + +Use \`static_map_image_tool\` only when the user explicitly asks for an image +file (PNG or JPEG), for example to paste into a document or email. It is never +the default way to show a map. +` +}; diff --git a/src/skills/reachability-analysis/reachabilityAnalysisSkill.ts b/src/skills/reachability-analysis/reachabilityAnalysisSkill.ts new file mode 100644 index 0000000..21add42 --- /dev/null +++ b/src/skills/reachability-analysis/reachabilityAnalysisSkill.ts @@ -0,0 +1,74 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { Skill } from '../Skill.js'; + +export const reachabilityAnalysisSkill: Skill = { + name: 'mapbox-reachability-analysis', + description: + 'Find places of a kind within a travel time or distance of a location (coffee within a 10 minute walk, hospitals within 20 minutes by car) and map them with the reachable area. Also fits site selection and coverage questions.', + tools: [ + 'isochrone_tool', + 'bbox_tool', + 'category_search_tool', + 'points_within_polygon_tool', + 'render_map_tool' + ], + instructions: `# Places within reach + +Answers questions like "what's within a 10 minute walk of my hotel?" with the +area a person can actually reach, not a straight-line radius. Rivers, highways +and one-way streets make the two very different. + +## Steps + +1. **Get the origin.** If you only have an address or a name, look it up with + \`search_and_geocode_tool\` first. + +2. **Compute the reachable area** with \`isochrone_tool\`: + - \`profile\`: \`mapbox/walking\`, \`mapbox/cycling\`, \`mapbox/driving\` or + \`mapbox/driving-traffic\`. + - \`contours_minutes\` (1 to 60) or \`contours_meters\`. One contour is + usually enough. + - \`polygons: true\`, so the result is an area you can test points against. + - \`generalize\` keeps the polygon small: about 100 meters for a short walk, + 2000 for a drive. + + Keep the result's \`mapboxRender.ref\` for the map. The polygon is the + feature's \`geometry.coordinates\`. If the result is too large to include + the polygon, call it again with a larger \`generalize\`. + +3. **Bound the search.** Pass the polygon to \`bbox_tool\`. It returns + \`[minLon, minLat, maxLon, maxLat]\`. + +4. **Search inside the box** with \`category_search_tool\`: + - \`category\`: a canonical category such as \`cafe\`, \`restaurant\`, + \`pharmacy\` or \`hospital\` (the \`mapbox://categories\` resource has the + full list). + - \`bbox\`: \`{ minLongitude, minLatitude, maxLongitude, maxLatitude }\` + from step 3. + - \`proximity\`: the origin, so the closest places come first. + - \`limit: 25\`, the most it returns. + +5. **Drop places outside the area.** The box is a rectangle and the area isn't, + so some results fall in its corners. Pass every result's coordinates with + the polygon to \`points_within_polygon_tool\` and keep only the points it + returns. + +6. **Show it.** Call \`render_map_tool\` with the isochrone ref in + \`payload_refs\` and one marker per place you kept, with \`id\` set to its + Mapbox ID and \`name\` to its name. Don't pass the search ref: it would also + show the places you dropped. + +## Answering + +- Say how many places are within reach, then list them nearest first. +- Name the mode and time ("a 10 minute walk"), since the area depends on both. +- If all 25 results are inside the area, there are probably more. Say so, and + offer a narrower category or a shorter time. +- If nothing is inside, say that plainly and offer a longer time or another + mode of travel. +- For exact travel times to each place, call \`matrix_tool\` with the origin + first in \`coordinates\` and \`sources: "0"\`. One call covers every place. +` +}; diff --git a/src/skills/skillRegistry.ts b/src/skills/skillRegistry.ts new file mode 100644 index 0000000..2c93920 --- /dev/null +++ b/src/skills/skillRegistry.ts @@ -0,0 +1,22 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { Skill } from './Skill.js'; +import { mapDisplaySkill } from './map-display/mapDisplaySkill.js'; +import { reachabilityAnalysisSkill } from './reachability-analysis/reachabilityAnalysisSkill.js'; + +// ChatGPT imports at most 5 skills from a server, so keep this list short. +export const ALL_SKILLS: readonly Skill[] = [ + mapDisplaySkill, + reachabilityAnalysisSkill +]; + +/** 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 0000000..0c480d1 --- /dev/null +++ b/src/skills/skillsExtension.ts @@ -0,0 +1,149 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { createHash } from 'node:crypto'; +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import type { Skill } from './Skill.js'; + +/** + * The MCP Skills extension (SEP-2640): + * https://modelcontextprotocol.io/extensions/skills/overview + * + * The SDK has no support for it yet (typescript-sdk#2818), so the two + * methods are registered as custom request handlers and skill files are + * ordinary resources under skill://. Swap these for the SDK's handlers once + * that ships. + */ +export const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; + +/** Skills are static per server version, so clients can cache them a while. */ +const SKILL_TTL_MS = 60 * 60 * 1000; + +export interface SkillFileEntry { + uri: string; + digest: string; + size: number; +} + +export interface SkillEntry { + uri: string; + frontmatter: { name: string; description: string }; + resources: SkillFileEntry[]; +} + +interface ServedSkill { + entry: SkillEntry; + /** Served text by URI; the manifest's digests are computed from it. */ + files: Map; +} + +/** + * SKILL.md text: YAML frontmatter, then the instructions. Values are written + * as JSON strings, which YAML reads as double-quoted scalars, so a colon or + * quote in a description can't break the frontmatter. + */ +export function skillMarkdown(skill: Skill): string { + const body = skill.instructions.endsWith('\n') + ? skill.instructions + : `${skill.instructions}\n`; + return ( + '---\n' + + `name: ${JSON.stringify(skill.name)}\n` + + `description: ${JSON.stringify(skill.description)}\n` + + '---\n\n' + + body + ); +} + +function fileEntry(uri: string, text: string): SkillFileEntry { + const bytes = Buffer.from(text, 'utf8'); + return { + uri, + digest: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + size: bytes.byteLength + }; +} + +export function serveSkill(skill: Skill): ServedSkill { + const root = `skill://${skill.name}`; + const skillUri = `${root}/SKILL.md`; + const files = new Map([[skillUri, skillMarkdown(skill)]]); + for (const [path, text] of Object.entries(skill.files ?? {})) { + files.set(`${root}/${path}`, text); + } + return { + entry: { + uri: skillUri, + frontmatter: { name: skill.name, description: skill.description }, + resources: [...files].map(([uri, text]) => fileEntry(uri, text)) + }, + files + }; +} + +const SkillsListRequestSchema = z.object({ + method: z.literal('skills/list'), + params: z.object({ cursor: z.string().optional() }).passthrough().optional() +}); + +const SkillsGetRequestSchema = z.object({ + method: z.literal('skills/get'), + params: z.object({ uri: z.string() }).passthrough() +}); + +const cacheFields = { + resultType: 'complete', + ttlMs: SKILL_TTL_MS, + cacheScope: 'public' +} as const; + +/** + * Serves `skills` on `server`: each file as a skill:// resource, plus the + * skills/list and skills/get methods. The server must also declare + * SKILLS_EXTENSION_ID under `capabilities.extensions`, which has to happen + * when it's constructed. + */ +export function installSkillsTo( + server: McpServer, + skills: readonly Skill[] +): void { + const served = skills.map(serveSkill); + + for (const { entry, files } of served) { + for (const [uri, text] of files) { + const path = uri.slice(`skill://${entry.frontmatter.name}/`.length); + server.registerResource( + `${entry.frontmatter.name}/${path}`, + uri, + { + title: `${entry.frontmatter.name}: ${path}`, + description: + path === 'SKILL.md' ? entry.frontmatter.description : undefined, + mimeType: 'text/markdown' + }, + async () => ({ + contents: [{ uri, mimeType: 'text/markdown', text }] + }) + ); + } + } + + // Few enough skills that one page holds them all; cursor is ignored. + server.server.setRequestHandler(SkillsListRequestSchema, async () => ({ + skills: served.map((s) => s.entry), + ...cacheFields + })); + + server.server.setRequestHandler(SkillsGetRequestSchema, async (request) => { + const skill = served.find((s) => s.entry.uri === request.params.uri); + if (!skill) { + throw new McpError( + ErrorCode.InvalidParams, + `Unknown skill: ${request.params.uri}` + ); + } + return { skill: skill.entry, ...cacheFields }; + }); +} diff --git a/test/exports.test.ts b/test/exports.test.ts index 6095c63..62aa347 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -45,6 +45,23 @@ describe('Package exports', () => { }); }); + describe('skills subpath', () => { + it('should export the skills and the extension helpers', async () => { + const { + ALL_SKILLS, + SKILLS_EXTENSION_ID, + getSkillsForTools, + installSkillsTo, + mapDisplaySkill + } = await import('../src/skills/index.js'); + + expect(SKILLS_EXTENSION_ID).toBe('io.modelcontextprotocol/skills'); + expect(ALL_SKILLS).toContain(mapDisplaySkill); + expect(typeof getSkillsForTools).toBe('function'); + expect(typeof installSkillsTo).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 0000000..446779d --- /dev/null +++ b/test/skills/skillsExtension.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { createHash } from 'node:crypto'; +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpError } from '@modelcontextprotocol/sdk/types.js'; +import { + SKILLS_EXTENSION_ID, + installSkillsTo, + serveSkill, + skillMarkdown, + type SkillEntry +} from '../../src/skills/skillsExtension.js'; +import { + ALL_SKILLS, + getSkillsForTools +} from '../../src/skills/skillRegistry.js'; +import type { Skill } from '../../src/skills/Skill.js'; +import { getAllTools } from '../../src/tools/toolRegistry.js'; + +const testSkill: Skill = { + name: 'test-skill', + description: 'Has a colon: and "quotes" in it', + tools: ['render_map_tool'], + instructions: '# Test\n\nRead `references/notes.md`.', + files: { 'references/notes.md': 'Notes with a café\n' } +}; + +/** Frontmatter values are JSON strings, so JSON.parse reads them back. */ +function parseFrontmatter(markdown: string): Record { + const match = /^---\n([\s\S]*?)\n---\n/.exec(markdown); + if (!match) throw new Error('No frontmatter'); + return Object.fromEntries( + match[1].split('\n').map((line) => { + const i = line.indexOf(': '); + return [line.slice(0, i), JSON.parse(line.slice(i + 2))]; + }) + ); +} + +const listResult = z + .object({ skills: z.array(z.custom()) }) + .passthrough(); +const getResult = z.object({ skill: z.custom() }).passthrough(); + +async function connect(skills: readonly Skill[]) { + const server = new McpServer( + { name: 'test', version: '0.0.0' }, + { + capabilities: { + resources: {}, + extensions: { [SKILLS_EXTENSION_ID]: {} } + } + } + ); + 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', () => { + it('writes frontmatter that reads back as the skill name and description', () => { + const markdown = skillMarkdown(testSkill); + expect(parseFrontmatter(markdown)).toEqual({ + name: 'test-skill', + description: 'Has a colon: and "quotes" in it' + }); + expect(markdown.endsWith('`references/notes.md`.\n')).toBe(true); + }); + + it('lists every file with the digest and byte size of what is served', () => { + const { entry, files } = serveSkill(testSkill); + expect(entry.uri).toBe('skill://test-skill/SKILL.md'); + expect(entry.resources.map((r) => r.uri)).toEqual([ + 'skill://test-skill/SKILL.md', + 'skill://test-skill/references/notes.md' + ]); + for (const resource of entry.resources) { + const bytes = Buffer.from(files.get(resource.uri) as string, 'utf8'); + expect(resource.size).toBe(bytes.byteLength); + expect(resource.digest).toBe( + `sha256:${createHash('sha256').update(bytes).digest('hex')}` + ); + } + // The é is two bytes, so size counts bytes, not characters. + const notes = entry.resources[1]; + expect(notes.size).toBe('Notes with a café\n'.length + 1); + }); + + it('serves skills/list, skills/get and the files over MCP', async () => { + const client = await connect([testSkill]); + + expect( + client.getServerCapabilities()?.extensions?.[SKILLS_EXTENSION_ID] + ).toEqual({}); + + const list = await client.request({ method: 'skills/list' }, listResult); + expect(list).toMatchObject({ + resultType: 'complete', + cacheScope: 'public' + }); + expect(list.skills).toHaveLength(1); + const [entry] = list.skills; + expect(entry.frontmatter).toEqual({ + name: 'test-skill', + description: testSkill.description + }); + + const got = await client.request( + { method: 'skills/get', params: { uri: entry.uri } }, + getResult + ); + expect(got.skill).toEqual(entry); + + // What resources/read returns matches the manifest, as hosts verify. + for (const resource of entry.resources) { + const read = await client.readResource({ uri: resource.uri }); + const text = (read.contents[0] as { text: string }).text; + const bytes = Buffer.from(text, 'utf8'); + expect(bytes.byteLength).toBe(resource.size); + expect(`sha256:${createHash('sha256').update(bytes).digest('hex')}`).toBe( + resource.digest + ); + } + const skillMd = await client.readResource({ uri: entry.uri }); + expect( + parseFrontmatter((skillMd.contents[0] as { text: string }).text) + ).toEqual(entry.frontmatter); + + await client.close(); + }); + + it('answers an unknown skill with invalid params', async () => { + const client = await connect([testSkill]); + const error = await client + .request( + { method: 'skills/get', params: { uri: 'skill://nope/SKILL.md' } }, + getResult + ) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(McpError); + expect((error as McpError).code).toBe(-32602); + await client.close(); + }); +}); + +describe('skill registry', () => { + const toolNames = getAllTools().map((tool) => tool.name); + + 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(['mapbox-map-display']); + expect(getSkillsForTools([])).toEqual([]); + }); + + it.each(ALL_SKILLS.map((skill) => [skill.name, skill] as const))( + '%s is a valid Agent Skill that names only real tools', + (_name, skill) => { + // Agent Skills spec limits. + expect(skill.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); + expect(skill.name.length).toBeLessThanOrEqual(64); + expect(skill.description.length).toBeLessThanOrEqual(1024); + // ChatGPT's import limit for SKILL.md. + expect( + Buffer.byteLength(skillMarkdown(skill), 'utf8') + ).toBeLessThanOrEqual(256 * 1024); + + for (const tool of skill.tools) expect(toolNames).toContain(tool); + const mentioned = skill.instructions.match(/\b[a-z_]+_tool\b/g) ?? []; + for (const tool of mentioned) expect(toolNames).toContain(tool); + } + ); +}); From 15f170f2b27d1c37f2a766f80d04c2fa2ec0700d Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 21 Oct 2026 09:09:07 -0400 Subject: [PATCH 2/5] docs: CHANGELOG PR number for skills extension Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b7535..3a926b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### New Features -- **Skills over MCP: workflow instructions served with the tools (#259).** 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 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) and `mapbox-reachability-analysis` (isochrone, then category search inside its bounding box, then dropping places outside the polygon, then one map with the area and the places). The second goes further than the `show-reachable-areas` prompt, which maps the area only. - **Protocol:** the server declares the extension under `capabilities.extensions` and answers `skills/list` and `skills/get`. Each `SKILL.md` is a resource at `skill:///SKILL.md`, and each listing carries the file's sha256 digest and byte size, computed from the bytes served. An unknown skill gets `-32602`. The TypeScript SDK doesn't support the extension yet (typescript-sdk#2818), so these are custom request handlers, to be swapped for the SDK's once it ships. - **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. From 24f908fad250d0f6042655635910ee5836952200 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 26 Oct 2026 11:52:06 -0400 Subject: [PATCH 3/5] feat: serve skills with the SDK's Skills extension SDK 2.4.0 ships SEP-2640 support, so the hand-rolled skills/list and skills/get handlers go away. loadSkills() builds a SkillCatalog from our Skill objects and installSkillsTo() attaches it, which also declares the capability. Tests read every skill through the SDK's SkillsClient, which checks sizes, digests and frontmatter. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- docs/importing-tools.md | 24 ++--- src/index.ts | 21 ++--- src/skills/index.ts | 3 +- src/skills/skillsExtension.ts | 141 +++++++--------------------- test/exports.test.ts | 2 + test/skills/skillsExtension.test.ts | 137 ++++++++++++--------------- 7 files changed, 112 insertions(+), 222 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f78f24..d0b65a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,11 @@ - **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) and `mapbox-reachability-analysis` (isochrone, then category search inside its bounding box, then dropping places outside the polygon, then one map with the area and the places). The second goes further than the `show-reachable-areas` prompt, which maps the area only. - - **Protocol:** the server declares the extension under `capabilities.extensions` and answers `skills/list` and `skills/get`. Each `SKILL.md` is a resource at `skill:///SKILL.md`, and each listing carries the file's sha256 digest and byte size, computed from the bytes served. An unknown skill gets `-32602`. The TypeScript SDK doesn't support the extension yet (typescript-sdk#2818), so these are custom request handlers, to be swapped for the SDK's once it ships. + - **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. - **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 the skills and `installSkillsTo`, so the hosted server can serve the same ones. - - **Testing:** tests for the frontmatter, manifest digests and byte sizes against what `resources/read` returns, `skills/list` and `skills/get` over an in-memory client, the unknown-skill error, tool filtering, and that every skill meets the Agent Skills name and size limits and names only real tools. + - **Import:** `@mapbox/mcp-server/skills` exports the skills, `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. - **`feedback_tool`: report wrong map, place or routing data to Mapbox (#291).** When someone says a place has closed, a road is closed, or a route took an illegal turn, the agent can send it to the [Feedback API](https://docs.mapbox.com/api/feedback/) (`POST /user-feedback/v1/feedback`, Public Preview) with the text, a category and the location, usually taken from an earlier search or directions result. It returns the report's id and status (`received`). - **Confirm first:** the report goes to Mapbox under the user's account, so the description tells the model to show the user what it will send and get a yes. Clients that support elicitation also get a confirmation prompt from the tool itself. Declining sends nothing, and so does a failed prompt. - **Categories:** an enum of the Feedback Agent's default categories that make sense from chat (`poi_closed`, `poi_details`, `road_closure_issue`, `illegal_turn` and so on), leaving out the navigation-app ones such as voice guidance and rerouting. diff --git a/docs/importing-tools.md b/docs/importing-tools.md index 2b573c7..1429aff 100644 --- a/docs/importing-tools.md +++ b/docs/importing-tools.md @@ -238,32 +238,20 @@ const prompt = new GetDirectionsPrompt(); ## Skills -Skills are workflow instructions served over the [MCP Skills extension](https://modelcontextprotocol.io/extensions/skills/overview) (SEP-2640). To serve the same skills from your own server, declare the extension when you create it, then install them: +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/sdk/server/mcp.js'; -import { - SKILLS_EXTENSION_ID, - getSkillsForTools, - installSkillsTo -} from '@mapbox/mcp-server/skills'; +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' }, - { - capabilities: { - resources: {}, - extensions: { [SKILLS_EXTENSION_ID]: {} } - } - } -); -installSkillsTo(server, skills); +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); +await installSkillsTo(server, skills); ``` -This registers `skills/list`, `skills/get` and a `skill:///SKILL.md` resource per skill. Available skills: `mapDisplaySkill` (`mapbox-map-display`) and `reachabilityAnalysisSkill` (`mapbox-reachability-analysis`). +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. Available skills: `mapDisplaySkill` (`mapbox-map-display`) and `reachabilityAnalysisSkill` (`mapbox-reachability-analysis`). ## HTTP Pipeline Utilities diff --git a/src/index.ts b/src/index.ts index 720bb58..980d0e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,10 +23,7 @@ import { import { getAllResources } from './resources/resourceRegistry.js'; import { getAllPrompts, getPromptByName } from './prompts/promptRegistry.js'; import { getSkillsForTools } from './skills/skillRegistry.js'; -import { - SKILLS_EXTENSION_ID, - installSkillsTo -} from './skills/skillsExtension.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'; @@ -132,10 +129,7 @@ const server = new McpServer( completions: {}, resources: {}, prompts: {}, - logging: {}, - ...(enabledSkills.length > 0 && { - extensions: { [SKILLS_EXTENSION_ID]: {} } - }) + logging: {} } } ); @@ -177,10 +171,6 @@ regularResources.forEach((resource) => { resource.installTo(server); }); -if (enabledSkills.length > 0) { - installSkillsTo(server, enabledSkills); -} - // Register prompt handlers server.server.setRequestHandler('prompts/list', async () => { const allPrompts = getAllPrompts(); @@ -299,6 +289,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/index.ts b/src/skills/index.ts index 1caa763..c1cf6b7 100644 --- a/src/skills/index.ts +++ b/src/skills/index.ts @@ -5,10 +5,9 @@ export type { Skill } from './Skill.js'; export { SKILLS_EXTENSION_ID, installSkillsTo, - serveSkill, + loadSkills, skillMarkdown } from './skillsExtension.js'; -export type { SkillEntry, SkillFileEntry } from './skillsExtension.js'; export { ALL_SKILLS, getSkillsForTools } from './skillRegistry.js'; export { mapDisplaySkill } from './map-display/mapDisplaySkill.js'; export { reachabilityAnalysisSkill } from './reachability-analysis/reachabilityAnalysisSkill.js'; diff --git a/src/skills/skillsExtension.ts b/src/skills/skillsExtension.ts index 0c480d1..c46c3aa 100644 --- a/src/skills/skillsExtension.ts +++ b/src/skills/skillsExtension.ts @@ -1,44 +1,27 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. -import { createHash } from 'node:crypto'; -import { z } from 'zod'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +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 has no support for it yet (typescript-sdk#2818), so the two - * methods are registered as custom request handlers and skill files are - * ordinary resources under skill://. Swap these for the SDK's handlers once - * that ships. + * The SDK serves it (`@modelcontextprotocol/server/ext/skills`): skills/list, + * skills/get, the skill:// resources and the capability. This file only + * turns our `Skill` objects into the files it reads. */ -export const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; +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; -export interface SkillFileEntry { - uri: string; - digest: string; - size: number; -} - -export interface SkillEntry { - uri: string; - frontmatter: { name: string; description: string }; - resources: SkillFileEntry[]; -} - -interface ServedSkill { - entry: SkillEntry; - /** Served text by URI; the manifest's digests are computed from it. */ - files: Map; -} - /** * SKILL.md text: YAML frontmatter, then the instructions. Values are written * as JSON strings, which YAML reads as double-quoted scalars, so a colon or @@ -57,93 +40,33 @@ export function skillMarkdown(skill: Skill): string { ); } -function fileEntry(uri: string, text: string): SkillFileEntry { - const bytes = Buffer.from(text, 'utf8'); - return { - uri, - digest: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, - size: bytes.byteLength - }; -} - -export function serveSkill(skill: Skill): ServedSkill { - const root = `skill://${skill.name}`; - const skillUri = `${root}/SKILL.md`; - const files = new Map([[skillUri, skillMarkdown(skill)]]); - for (const [path, text] of Object.entries(skill.files ?? {})) { - files.set(`${root}/${path}`, text); +/** + * 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) { + files[`${skill.name}/SKILL.md`] = skillMarkdown(skill); + for (const [path, text] of Object.entries(skill.files ?? {})) { + files[`${skill.name}/${path}`] = text; + } } - return { - entry: { - uri: skillUri, - frontmatter: { name: skill.name, description: skill.description }, - resources: [...files].map(([uri, text]) => fileEntry(uri, text)) - }, - files - }; + return SkillsExtension.load(memorySkillFiles(files), { + strict: true, + cacheHint: { ttlMs: SKILL_TTL_MS, cacheScope: 'public' } + }); } -const SkillsListRequestSchema = z.object({ - method: z.literal('skills/list'), - params: z.object({ cursor: z.string().optional() }).passthrough().optional() -}); - -const SkillsGetRequestSchema = z.object({ - method: z.literal('skills/get'), - params: z.object({ uri: z.string() }).passthrough() -}); - -const cacheFields = { - resultType: 'complete', - ttlMs: SKILL_TTL_MS, - cacheScope: 'public' -} as const; - /** - * Serves `skills` on `server`: each file as a skill:// resource, plus the - * skills/list and skills/get methods. The server must also declare - * SKILLS_EXTENSION_ID under `capabilities.extensions`, which has to happen - * when it's constructed. + * Serves `skills` on `server`: loads them and attaches the catalog, which + * also declares the extension capability. Must run before connecting. */ -export function installSkillsTo( +export async function installSkillsTo( server: McpServer, skills: readonly Skill[] -): void { - const served = skills.map(serveSkill); - - for (const { entry, files } of served) { - for (const [uri, text] of files) { - const path = uri.slice(`skill://${entry.frontmatter.name}/`.length); - server.registerResource( - `${entry.frontmatter.name}/${path}`, - uri, - { - title: `${entry.frontmatter.name}: ${path}`, - description: - path === 'SKILL.md' ? entry.frontmatter.description : undefined, - mimeType: 'text/markdown' - }, - async () => ({ - contents: [{ uri, mimeType: 'text/markdown', text }] - }) - ); - } - } - - // Few enough skills that one page holds them all; cursor is ignored. - server.server.setRequestHandler(SkillsListRequestSchema, async () => ({ - skills: served.map((s) => s.entry), - ...cacheFields - })); - - server.server.setRequestHandler(SkillsGetRequestSchema, async (request) => { - const skill = served.find((s) => s.entry.uri === request.params.uri); - if (!skill) { - throw new McpError( - ErrorCode.InvalidParams, - `Unknown skill: ${request.params.uri}` - ); - } - return { skill: skill.entry, ...cacheFields }; - }); +): Promise { + (await loadSkills(skills)).attach(server); } diff --git a/test/exports.test.ts b/test/exports.test.ts index c46f0c9..9cc6c1b 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -52,6 +52,7 @@ describe('Package exports', () => { SKILLS_EXTENSION_ID, getSkillsForTools, installSkillsTo, + loadSkills, mapDisplaySkill } = await import('../src/skills/index.js'); @@ -59,6 +60,7 @@ describe('Package exports', () => { expect(ALL_SKILLS).toContain(mapDisplaySkill); expect(typeof getSkillsForTools).toBe('function'); expect(typeof installSkillsTo).toBe('function'); + expect(typeof loadSkills).toBe('function'); }); }); diff --git a/test/skills/skillsExtension.test.ts b/test/skills/skillsExtension.test.ts index 446779d..596d2c6 100644 --- a/test/skills/skillsExtension.test.ts +++ b/test/skills/skillsExtension.test.ts @@ -1,19 +1,15 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. -import { createHash } from 'node:crypto'; import { describe, it, expect } from 'vitest'; -import { z } from 'zod'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import { McpError } from '@modelcontextprotocol/sdk/types.js'; +import { McpServer, InMemoryTransport } from '@modelcontextprotocol/server'; +import { Client } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; import { SKILLS_EXTENSION_ID, installSkillsTo, - serveSkill, - skillMarkdown, - type SkillEntry + loadSkills, + skillMarkdown } from '../../src/skills/skillsExtension.js'; import { ALL_SKILLS, @@ -42,22 +38,12 @@ function parseFrontmatter(markdown: string): Record { ); } -const listResult = z - .object({ skills: z.array(z.custom()) }) - .passthrough(); -const getResult = z.object({ skill: z.custom() }).passthrough(); - async function connect(skills: readonly Skill[]) { const server = new McpServer( { name: 'test', version: '0.0.0' }, - { - capabilities: { - resources: {}, - extensions: { [SKILLS_EXTENSION_ID]: {} } - } - } + { capabilities: { resources: {} } } ); - installSkillsTo(server, skills); + await installSkillsTo(server, skills); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: 'test-client', version: '0.0.0' }); @@ -78,80 +64,75 @@ describe('skills extension', () => { expect(markdown.endsWith('`references/notes.md`.\n')).toBe(true); }); - it('lists every file with the digest and byte size of what is served', () => { - const { entry, files } = serveSkill(testSkill); - expect(entry.uri).toBe('skill://test-skill/SKILL.md'); - expect(entry.resources.map((r) => r.uri)).toEqual([ - 'skill://test-skill/SKILL.md', - 'skill://test-skill/references/notes.md' - ]); - for (const resource of entry.resources) { - const bytes = Buffer.from(files.get(resource.uri) as string, 'utf8'); - expect(resource.size).toBe(bytes.byteLength); - expect(resource.digest).toBe( - `sha256:${createHash('sha256').update(bytes).digest('hex')}` - ); - } - // The é is two bytes, so size counts bytes, not characters. - const notes = entry.resources[1]; - expect(notes.size).toBe('Notes with a café\n'.length + 1); - }); - - it('serves skills/list, skills/get and the files over MCP', async () => { + // 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] - ).toEqual({}); + ).toBeDefined(); - const list = await client.request({ method: 'skills/list' }, listResult); - expect(list).toMatchObject({ - resultType: 'complete', - cacheScope: 'public' - }); + 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.frontmatter).toEqual({ + 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', description: testSkill.description }); + expect(skillMd.text).toBe(skillMarkdown(testSkill)); - const got = await client.request( - { method: 'skills/get', params: { uri: entry.uri } }, - getResult - ); - expect(got.skill).toEqual(entry); - - // What resources/read returns matches the manifest, as hosts verify. - for (const resource of entry.resources) { - const read = await client.readResource({ uri: resource.uri }); - const text = (read.contents[0] as { text: string }).text; - const bytes = Buffer.from(text, 'utf8'); - expect(bytes.byteLength).toBe(resource.size); - expect(`sha256:${createHash('sha256').update(bytes).digest('hex')}`).toBe( - resource.digest - ); - } - const skillMd = await client.readResource({ uri: entry.uri }); - expect( - parseFrontmatter((skillMd.contents[0] as { text: string }).text) - ).toEqual(entry.frontmatter); + 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 invalid params', async () => { + it('answers an unknown skill with an error', async () => { const client = await connect([testSkill]); - const error = await client - .request( - { method: 'skills/get', params: { uri: 'skill://nope/SKILL.md' } }, - getResult - ) - .catch((e: unknown) => e); - expect(error).toBeInstanceOf(McpError); - expect((error as McpError).code).toBe(-32602); + 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', () => { From b2e0c1c6877630dd38217283d5ce2d0e96d90ce4 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 26 Oct 2026 12:21:26 -0400 Subject: [PATCH 4/5] feat: serve skills from mapbox/mapbox-agent-skills The skills now live only in mapbox-agent-skills (mapbox-agent-skills#94 adds the two this PR introduced). agent-skills.json pins a commit and maps each served skill to the tools it needs; scripts/fetch-agent-skills.mjs, run by prepare (npm ci, npm run build), generates the gitignored src/skills/agentSkills.generated.ts, so the skills ship in the package and nothing is fetched at runtime. Also serves mapbox-location-grounding and mapbox-search-patterns. Tests check the generated file matches the pin, every skill loads strictly, and no skill names a tool this server lacks. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + .prettierignore | 2 + CHANGELOG.md | 7 +- CLAUDE.md | 7 ++ README.md | 4 + agent-skills.json | 25 ++++ cspell.config.json | 5 +- docs/importing-tools.md | 2 +- eslint.config.mjs | 2 + package.json | 3 +- scripts/fetch-agent-skills.mjs | 111 ++++++++++++++++++ src/skills/Skill.ts | 12 +- src/skills/index.ts | 6 +- src/skills/map-display/mapDisplaySkill.ts | 71 ----------- .../reachabilityAnalysisSkill.ts | 74 ------------ src/skills/skillRegistry.ts | 9 +- src/skills/skillsExtension.ts | 23 +--- test/exports.test.ts | 9 +- test/skills/skillsExtension.test.ts | 82 +++++++------ 19 files changed, 225 insertions(+), 232 deletions(-) create mode 100644 .prettierignore create mode 100644 agent-skills.json create mode 100644 scripts/fetch-agent-skills.mjs delete mode 100644 src/skills/map-display/mapDisplaySkill.ts delete mode 100644 src/skills/reachability-analysis/reachabilityAnalysisSkill.ts diff --git a/.gitignore b/.gitignore index ac4760a..25a600e 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 0000000..5d2eba6 --- /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 d0b65a4..779a708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,17 @@ - **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) and `mapbox-reachability-analysis` (isochrone, then category search inside its bounding box, then dropping places outside the polygon, then one map with the area and the places). The second goes further than the `show-reachable-areas` prompt, which maps the area only. + - **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. - **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 the skills, `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. + - **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. - **`feedback_tool`: report wrong map, place or routing data to Mapbox (#291).** When someone says a place has closed, a road is closed, or a route took an illegal turn, the agent can send it to the [Feedback API](https://docs.mapbox.com/api/feedback/) (`POST /user-feedback/v1/feedback`, Public Preview) with the text, a category and the location, usually taken from an earlier search or directions result. It returns the report's id and status (`received`). - **Confirm first:** the report goes to Mapbox under the user's account, so the description tells the model to show the user what it will send and get a yes. Clients that support elicitation also get a confirmation prompt from the tool itself. Declining sends nothing, and so does a failed prompt. diff --git a/CLAUDE.md b/CLAUDE.md index 0b7d207..c453f1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,13 @@ 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 + ## Essential Workflows ### Development diff --git a/README.md b/README.md index 1e75973..0393774 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,10 @@ The server also serves **skills**: workflow instructions a client can load when - **`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. diff --git a/agent-skills.json b/agent-skills.json new file mode 100644 index 0000000..f236b37 --- /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 76d2d3b..9b3e4e4 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 1429aff..0918d4d 100644 --- a/docs/importing-tools.md +++ b/docs/importing-tools.md @@ -251,7 +251,7 @@ 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. Available skills: `mapDisplaySkill` (`mapbox-map-display`) and `reachabilityAnalysisSkill` (`mapbox-reachability-analysis`). +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 diff --git a/eslint.config.mjs b/eslint.config.mjs index 16a5966..d72f45a 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 7253be8..b4413f0 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", diff --git a/scripts/fetch-agent-skills.mjs b/scripts/fetch-agent-skills.mjs new file mode 100644 index 0000000..460c72b --- /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/skills/Skill.ts b/src/skills/Skill.ts index f5a2a30..070a926 100644 --- a/src/skills/Skill.ts +++ b/src/skills/Skill.ts @@ -2,20 +2,18 @@ // Licensed under the MIT License. /** - * A skill served over the MCP Skills extension (SEP-2640): a `SKILL.md` - * plus optional supporting files, following the Agent Skills format. + * 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; - readonly description: 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[]; - /** Markdown body of SKILL.md, after the frontmatter. */ - readonly instructions: string; - /** Supporting files by path relative to the skill directory. */ - readonly files?: Readonly>; + /** 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 index c1cf6b7..cbd2ca7 100644 --- a/src/skills/index.ts +++ b/src/skills/index.ts @@ -5,9 +5,7 @@ export type { Skill } from './Skill.js'; export { SKILLS_EXTENSION_ID, installSkillsTo, - loadSkills, - skillMarkdown + loadSkills } from './skillsExtension.js'; export { ALL_SKILLS, getSkillsForTools } from './skillRegistry.js'; -export { mapDisplaySkill } from './map-display/mapDisplaySkill.js'; -export { reachabilityAnalysisSkill } from './reachability-analysis/reachabilityAnalysisSkill.js'; +export { AGENT_SKILLS_SOURCE } from './agentSkills.generated.js'; diff --git a/src/skills/map-display/mapDisplaySkill.ts b/src/skills/map-display/mapDisplaySkill.ts deleted file mode 100644 index b62db7f..0000000 --- a/src/skills/map-display/mapDisplaySkill.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import type { Skill } from '../Skill.js'; - -export const mapDisplaySkill: Skill = { - name: 'mapbox-map-display', - description: - 'Show Mapbox tool results on an interactive map with render_map_tool. Covers passing refs, combining several results on one map, hand-placed markers, and when a static image is the right call instead.', - tools: ['render_map_tool'], - instructions: `# Showing results on a Mapbox map - -Use \`render_map_tool\` whenever the user wants to see something on a map. It -renders a live, interactive Mapbox GL JS map inline in the conversation. - -## Pass refs, not geometry - -Every geo tool (search, directions, isochrone, elevation and so on) returns -\`structuredContent.mapboxRender.ref\`. Pass it to \`render_map_tool\` unchanged: - -\`\`\`json -{ "payload_refs": [""] } -\`\`\` - -- Never write, edit or shorten a ref. Copy it exactly. -- Don't copy coordinates out of a tool result into \`layers\` or \`markers\` - when a ref exists. The ref keeps thousands of coordinates out of the - conversation and the map fetches the data itself. -- The camera fits all the data on its own. Only set \`camera\` when the user - asks for a particular view. - -## Several results on one map - -Put every ref in the same call to answer one question on one map, for example -a route plus places along it, or a drive-time area plus the places inside it: - -\`\`\`json -{ "payload_refs": ["", ""], "summary": "Coffee within a 10 min walk" } -\`\`\` - -\`summary\` is the short chip in the map's top-left corner. Keep it under about -40 characters. - -## Hand-placed markers - -Use \`markers\` only for points no tool returned a ref for, or for a filtered -subset of a result (see the mapbox-reachability-analysis skill). - -- For a place from a search tool, set \`id\` to its \`mapbox_id\` and \`name\` to - its name (\`category\` and \`distanceMeters\` are optional). The map then lists - it in its results panel with a photo and popularity when Mapbox has them, - the same as a search ref would. -- \`style\` is \`pin\` (default), \`numbered\` (with \`label\`, e.g. visit order), - \`start\` or \`end\`. -- Coordinates are \`[longitude, latitude]\`. - -## Restyling the base map - -- \`baseMapConfig: { "lightPreset": "night" }\` (also \`dawn\`, \`dusk\`) for - lighting. -- \`baseStyle: "standard-satellite"\` for imagery. -- Only restyle when the user asks, or when it clearly helps (night lighting for - a question about the evening). - -## Static images - -Use \`static_map_image_tool\` only when the user explicitly asks for an image -file (PNG or JPEG), for example to paste into a document or email. It is never -the default way to show a map. -` -}; diff --git a/src/skills/reachability-analysis/reachabilityAnalysisSkill.ts b/src/skills/reachability-analysis/reachabilityAnalysisSkill.ts deleted file mode 100644 index 21add42..0000000 --- a/src/skills/reachability-analysis/reachabilityAnalysisSkill.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import type { Skill } from '../Skill.js'; - -export const reachabilityAnalysisSkill: Skill = { - name: 'mapbox-reachability-analysis', - description: - 'Find places of a kind within a travel time or distance of a location (coffee within a 10 minute walk, hospitals within 20 minutes by car) and map them with the reachable area. Also fits site selection and coverage questions.', - tools: [ - 'isochrone_tool', - 'bbox_tool', - 'category_search_tool', - 'points_within_polygon_tool', - 'render_map_tool' - ], - instructions: `# Places within reach - -Answers questions like "what's within a 10 minute walk of my hotel?" with the -area a person can actually reach, not a straight-line radius. Rivers, highways -and one-way streets make the two very different. - -## Steps - -1. **Get the origin.** If you only have an address or a name, look it up with - \`search_and_geocode_tool\` first. - -2. **Compute the reachable area** with \`isochrone_tool\`: - - \`profile\`: \`mapbox/walking\`, \`mapbox/cycling\`, \`mapbox/driving\` or - \`mapbox/driving-traffic\`. - - \`contours_minutes\` (1 to 60) or \`contours_meters\`. One contour is - usually enough. - - \`polygons: true\`, so the result is an area you can test points against. - - \`generalize\` keeps the polygon small: about 100 meters for a short walk, - 2000 for a drive. - - Keep the result's \`mapboxRender.ref\` for the map. The polygon is the - feature's \`geometry.coordinates\`. If the result is too large to include - the polygon, call it again with a larger \`generalize\`. - -3. **Bound the search.** Pass the polygon to \`bbox_tool\`. It returns - \`[minLon, minLat, maxLon, maxLat]\`. - -4. **Search inside the box** with \`category_search_tool\`: - - \`category\`: a canonical category such as \`cafe\`, \`restaurant\`, - \`pharmacy\` or \`hospital\` (the \`mapbox://categories\` resource has the - full list). - - \`bbox\`: \`{ minLongitude, minLatitude, maxLongitude, maxLatitude }\` - from step 3. - - \`proximity\`: the origin, so the closest places come first. - - \`limit: 25\`, the most it returns. - -5. **Drop places outside the area.** The box is a rectangle and the area isn't, - so some results fall in its corners. Pass every result's coordinates with - the polygon to \`points_within_polygon_tool\` and keep only the points it - returns. - -6. **Show it.** Call \`render_map_tool\` with the isochrone ref in - \`payload_refs\` and one marker per place you kept, with \`id\` set to its - Mapbox ID and \`name\` to its name. Don't pass the search ref: it would also - show the places you dropped. - -## Answering - -- Say how many places are within reach, then list them nearest first. -- Name the mode and time ("a 10 minute walk"), since the area depends on both. -- If all 25 results are inside the area, there are probably more. Say so, and - offer a narrower category or a shorter time. -- If nothing is inside, say that plainly and offer a longer time or another - mode of travel. -- For exact travel times to each place, call \`matrix_tool\` with the origin - first in \`coordinates\` and \`sources: "0"\`. One call covers every place. -` -}; diff --git a/src/skills/skillRegistry.ts b/src/skills/skillRegistry.ts index 2c93920..0d93fe7 100644 --- a/src/skills/skillRegistry.ts +++ b/src/skills/skillRegistry.ts @@ -2,14 +2,11 @@ // Licensed under the MIT License. import type { Skill } from './Skill.js'; -import { mapDisplaySkill } from './map-display/mapDisplaySkill.js'; -import { reachabilityAnalysisSkill } from './reachability-analysis/reachabilityAnalysisSkill.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[] = [ - mapDisplaySkill, - reachabilityAnalysisSkill -]; +export const ALL_SKILLS: readonly Skill[] = AGENT_SKILLS; /** Skills whose tools are all enabled. */ export function getSkillsForTools( diff --git a/src/skills/skillsExtension.ts b/src/skills/skillsExtension.ts index c46c3aa..cd6dd77 100644 --- a/src/skills/skillsExtension.ts +++ b/src/skills/skillsExtension.ts @@ -15,31 +15,13 @@ import type { Skill } from './Skill.js'; * * The SDK serves it (`@modelcontextprotocol/server/ext/skills`): skills/list, * skills/get, the skill:// resources and the capability. This file only - * turns our `Skill` objects into the files it reads. + * 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; -/** - * SKILL.md text: YAML frontmatter, then the instructions. Values are written - * as JSON strings, which YAML reads as double-quoted scalars, so a colon or - * quote in a description can't break the frontmatter. - */ -export function skillMarkdown(skill: Skill): string { - const body = skill.instructions.endsWith('\n') - ? skill.instructions - : `${skill.instructions}\n`; - return ( - '---\n' + - `name: ${JSON.stringify(skill.name)}\n` + - `description: ${JSON.stringify(skill.description)}\n` + - '---\n\n' + - body - ); -} - /** * Builds the catalog for `skills`, served at skill:///SKILL.md plus * each skill's supporting files. Build it once and `attach()` it to every @@ -49,8 +31,7 @@ export function skillMarkdown(skill: Skill): string { export function loadSkills(skills: readonly Skill[]): Promise { const files: Record = {}; for (const skill of skills) { - files[`${skill.name}/SKILL.md`] = skillMarkdown(skill); - for (const [path, text] of Object.entries(skill.files ?? {})) { + for (const [path, text] of Object.entries(skill.files)) { files[`${skill.name}/${path}`] = text; } } diff --git a/test/exports.test.ts b/test/exports.test.ts index 9cc6c1b..c46b43a 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -49,15 +49,18 @@ describe('Package exports', () => { it('should export the skills and the extension helpers', async () => { const { ALL_SKILLS, + AGENT_SKILLS_SOURCE, SKILLS_EXTENSION_ID, getSkillsForTools, installSkillsTo, - loadSkills, - mapDisplaySkill + loadSkills } = await import('../src/skills/index.js'); expect(SKILLS_EXTENSION_ID).toBe('io.modelcontextprotocol/skills'); - expect(ALL_SKILLS).toContain(mapDisplaySkill); + 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'); diff --git a/test/skills/skillsExtension.test.ts b/test/skills/skillsExtension.test.ts index 596d2c6..c2de1d1 100644 --- a/test/skills/skillsExtension.test.ts +++ b/test/skills/skillsExtension.test.ts @@ -1,6 +1,7 @@ // 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'; @@ -8,36 +9,26 @@ import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; import { SKILLS_EXTENSION_ID, installSkillsTo, - loadSkills, - skillMarkdown + 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', - description: 'Has a colon: and "quotes" in it', tools: ['render_map_tool'], - instructions: '# Test\n\nRead `references/notes.md`.', - files: { 'references/notes.md': 'Notes with a café\n' } + 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' + } }; -/** Frontmatter values are JSON strings, so JSON.parse reads them back. */ -function parseFrontmatter(markdown: string): Record { - const match = /^---\n([\s\S]*?)\n---\n/.exec(markdown); - if (!match) throw new Error('No frontmatter'); - return Object.fromEntries( - match[1].split('\n').map((line) => { - const i = line.indexOf(': '); - return [line.slice(0, i), JSON.parse(line.slice(i + 2))]; - }) - ); -} - async function connect(skills: readonly Skill[]) { const server = new McpServer( { name: 'test', version: '0.0.0' }, @@ -55,15 +46,6 @@ async function connect(skills: readonly Skill[]) { } describe('skills extension', () => { - it('writes frontmatter that reads back as the skill name and description', () => { - const markdown = skillMarkdown(testSkill); - expect(parseFrontmatter(markdown)).toEqual({ - name: 'test-skill', - description: 'Has a colon: and "quotes" in it' - }); - expect(markdown.endsWith('`references/notes.md`.\n')).toBe(true); - }); - // 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. @@ -88,9 +70,10 @@ describe('skills extension', () => { expect(skillMd.verified).toBe(true); expect(skillMd.frontmatter).toEqual({ name: 'test-skill', - description: testSkill.description + // The colon and quotes survive the YAML round trip. + description: 'Has a colon: and "quotes" in it' }); - expect(skillMd.text).toBe(skillMarkdown(testSkill)); + expect(skillMd.text).toBe(testSkill.files['SKILL.md']); const notes = await skills.read(entry, 'references/notes.md'); expect(notes.verified).toBe(true); @@ -138,30 +121,55 @@ describe('skills extension', () => { 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(['mapbox-map-display']); + 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 is a valid Agent Skill that names only real tools', + '%s names only tools this server has', (_name, skill) => { - // Agent Skills spec limits. - expect(skill.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); - expect(skill.name.length).toBeLessThanOrEqual(64); - expect(skill.description.length).toBeLessThanOrEqual(1024); // ChatGPT's import limit for SKILL.md. expect( - Buffer.byteLength(skillMarkdown(skill), 'utf8') + Buffer.byteLength(skill.files['SKILL.md'], 'utf8') ).toBeLessThanOrEqual(256 * 1024); for (const tool of skill.tools) expect(toolNames).toContain(tool); - const mentioned = skill.instructions.match(/\b[a-z_]+_tool\b/g) ?? []; - for (const tool of mentioned) 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); + } + } } ); }); From 851cc15d85d0a6318c9f0c2e1f21289705c9929c Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 27 Oct 2026 12:41:23 -0400 Subject: [PATCH 5/5] feat: --disable-skills / DISABLE_SKILLS=true to turn skills off For plugins that already ship their own copies of the same skills. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + CLAUDE.md | 1 + README.md | 2 ++ src/config/toolConfig.ts | 8 ++++++++ src/index.ts | 7 +++++-- test/config/toolConfig.test.ts | 26 ++++++++++++++++++++++++++ 6 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 779a708..f37aeb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - **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. diff --git a/CLAUDE.md b/CLAUDE.md index c453f1c..6eb9f31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ test/ # Mirrors src/ structure - `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 diff --git a/README.md b/README.md index 0393774..aeb69ad 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,8 @@ The skills come from [mapbox/mapbox-agent-skills](https://github.com/mapbox/mapb 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/src/config/toolConfig.ts b/src/config/toolConfig.ts index 75881b7..673b24d 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 980d0e0..feb82dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,8 +97,11 @@ const enabledToolNames = [ ...enabledResourceFallbackTools ].map((tool) => tool.name); -// Skills whose tools are all enabled (MCP Skills extension, SEP-2640) -const enabledSkills = getSkillsForTools(enabledToolNames); +// 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( diff --git a/test/config/toolConfig.test.ts b/test/config/toolConfig.test.ts index 3d9c167..7b68b8e 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', () => {