Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Generated from mapbox/mapbox-agent-skills (scripts/fetch-agent-skills.mjs)
src/skills/agentSkills.generated.ts
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,19 @@
- **JSON Schema 2020-12:** v2 publishes tool schemas as 2020-12 itself, so `publishJsonSchema2020(server)` is now a deprecated no-op, kept so existing callers don't break. `toJsonSchema2020` is unchanged.
- **Removed:** `OptimizationV2Tool`, which was never registered and was built on the SDK's experimental tasks API, which v2 removes.
- **Codemod:** most of the change is `npx @modelcontextprotocol/codemod v1-to-v2 .` (import paths, method-string `setRequestHandler`, `RequestHandlerExtra` to `ServerContext`, `extra.authInfo` to `ctx.http?.authInfo`). `@modelcontextprotocol/client` and `@modelcontextprotocol/node` are dev dependencies, used only by tests and scripts.
- **Testing:** the old patch tests called `tool.run()` directly and never went through the SDK; they're replaced by tests that make a real `tools/call` through `McpServer` for a mismatched, a missing and a matching `structuredContent`, and they fail without the wrapper. Tests that built a request context by hand used the v1 `{ authInfo }` shape, so the token never reached the code under test; they now use `{ http: { authInfo } }`, which also makes the map app's secret-token checks test what they say. A stdio smoke test against the built server covers the handshake, all 30 tools with 2020-12 schemas, prompts, completions, resources, the MCP Apps UI resource, logging and a tool call.
- **Testing:** listing, getting and reading every file through the SDK's `SkillsClient` (which checks sizes, digests and frontmatter the way hosts do), the unknown-skill error, one catalog serving several servers, an invalid skill failing to load, tool filtering, that the generated skills match `agent-skills.json`, that every served skill loads under the SDK's strict rules, and that no skill file names a tool this server doesn't have.

### New Features

- **Skills over MCP: workflow instructions served with the tools (#295).** The server now supports the [MCP Skills extension](https://modelcontextprotocol.io/extensions/skills/overview) (SEP-2640, `io.modelcontextprotocol/skills`), so a client can list its skills and load one when a task calls for it, instead of the model working the steps out from tool descriptions.
- **Skills:** `mapbox-map-display` (passing refs to `render_map_tool`, several results on one map, hand-placed markers, when to use a static image), `mapbox-reachability-analysis` (isochrone, then category search in its bounding box, then `points_within_polygon_tool`, then one map), `mapbox-location-grounding` (answering location questions from live data with citations) and `mapbox-search-patterns` (picking the right search tool and parameters).
- **One copy of each skill:** the skills live in [mapbox/mapbox-agent-skills](https://github.com/mapbox/mapbox-agent-skills) (the two new ones added in mapbox-agent-skills#94), not here. `agent-skills.json` pins a commit and maps each served skill to the tools it needs, and `npm run fetch-skills` (run by `prepare`, so by `npm ci` and `npm run build`) generates them into the build. They ship inside the package, so nothing is fetched at runtime, and hosted gets them through the package too.
- **Protocol:** served by the SDK's own Skills support (`@modelcontextprotocol/server/ext/skills`, new in 2.4.0 from #303), which declares the extension under `capabilities.extensions`, answers `skills/list` and `skills/get`, and serves each `SKILL.md` at `skill://<name>/SKILL.md` with its sha256 digest and byte size. Our code only turns each skill into its files. Loading is strict, so a skill that breaks the format's rules fails at startup rather than being skipped.
- **Only usable skills:** a skill is served only when every tool it uses is enabled, so `--disable-tools isochrone_tool` drops `mapbox-reachability-analysis`. With no skills left, the extension isn't declared.
- **Turning skills off:** `--disable-skills` or `DISABLE_SKILLS=true` serves none, for example when a plugin already ships its own copies of the same skills.
- **Clients:** ChatGPT imports skills when an app is submitted (up to 5, which is why the list stays short), and fast-agent and MCP Inspector can read them. Claude doesn't support the extension yet.
- **Import:** `@mapbox/mcp-server/skills` exports `ALL_SKILLS`, `AGENT_SKILLS_SOURCE` (the pinned repo and commit), `installSkillsTo(server, skills)` and `loadSkills(skills)`, so the hosted server can serve the same ones. A server built per request should load the catalog once with `loadSkills` and `attach()` it to each server.
- **Testing:** tests for the frontmatter, listing, getting and reading every file through the SDK's `SkillsClient` (which checks sizes, digests and frontmatter the way hosts do), the unknown-skill error, one catalog serving several servers, an invalid skill failing to load, tool filtering, and that every skill meets the Agent Skills name and size limits and names only real tools.
- **`list_public_tokens_tool`: a public token for pages built outside the chat (#304, part of #281).** An agent building a site or app with a Mapbox map had to ask the user for a token. The tool lists the account's public (`pk.`) tokens with their names, default flag, URL restrictions and scopes, and recommends one.
- **Safer token first:** pass `site_url` and it recommends a token restricted to that URL when one exists. Otherwise it recommends the default public token and says a published site should use a URL-restricted one, with the link to create it.
- **Public tokens only:** it asks the Tokens API for `pk` tokens and drops anything else, so secret and temporary tokens never come back. It can't create tokens (that needs `tokens:write`; DevKit's `create_token_tool` does it).
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ test/ # Mirrors src/ structure
- Resources use URI pattern: `mapbox://resource-name`
- Example: category list at `mapbox://categories` or `mapbox://categories/{language}`

### Skills

- Skills served over the MCP Skills extension (src/skills/) come from mapbox/mapbox-agent-skills, never from this repo
- `agent-skills.json` pins the commit (full SHA) and maps each served skill to the tools it needs
- `npm run fetch-skills` (also run by `npm run prepare`, `npm ci` and `npm run build`) generates the gitignored `src/skills/agentSkills.generated.ts`
- To change a skill: edit it in mapbox-agent-skills, then bump `ref` here; tests fail if a skill names a tool this server doesn't have
- `--disable-skills` or `DISABLE_SKILLS=true` turns skills off (for plugins that ship their own copies)

## Essential Workflows

### Development
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,21 @@ Access the complete list of available category IDs for use with the category sea
- **Clients with native MCP resource support**: Use the `resources/read` MCP protocol method
- **Clients without resource support**: Use the `resource_reader_tool` with the resource URI

## Skills

The server also serves **skills**: workflow instructions a client can load when a task calls for them, over the [MCP Skills extension](https://modelcontextprotocol.io/extensions/skills/overview) (`io.modelcontextprotocol/skills`). Each one is a `SKILL.md` at `skill://<name>/SKILL.md`, listed by `skills/list`.

- **`mapbox-map-display`**: showing tool results with `render_map_tool`, combining several results on one map, hand-placed markers, and when a static image is right instead.
- **`mapbox-reachability-analysis`**: finding places within a travel time or distance (coffee within a 10 minute walk) and mapping them with the reachable area.
- **`mapbox-location-grounding`**: answering location questions from live Mapbox data with citations, instead of from training data.
- **`mapbox-search-patterns`**: choosing the right search tool and parameters for geocoding and POI search.

The skills come from [mapbox/mapbox-agent-skills](https://github.com/mapbox/mapbox-agent-skills), the same skills you can install in Claude Code, Cursor and other assistants. `agent-skills.json` pins the commit and lists which skills this server serves; they're bundled into the package at build time, so nothing is fetched at runtime.

A skill is only served when all the tools it uses are enabled. Client support is still early: ChatGPT imports skills when an app is submitted, and fast-agent and MCP Inspector can read them. Clients without support can still read the `skill://` files as ordinary resources.

To turn skills off, for example when a plugin already ships its own copies of the same skills, start the server with `--disable-skills` or set `DISABLE_SKILLS=true`.

## Rich Map Previews (`render_map_tool`)

Every geospatial tool in this server (directions, isochrone, search, and more) can display its result as a live, interactive Mapbox GL JS map via **`render_map_tool`** — the server's single visualization primitive. It renders through the **MCP Apps** protocol (`@modelcontextprotocol/ext-apps`) as a self-contained HTML panel directly inside the chat, with a Fullscreen toggle, in supported clients:
Expand Down
25 changes: 25 additions & 0 deletions agent-skills.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
5 changes: 1 addition & 4 deletions cspell.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,5 @@
"dataplor",
"tripadvisor"
],
"ignorePaths": [
"node_modules",
"dist"
]
"ignorePaths": ["node_modules", "dist", "src/skills/agentSkills.generated.ts"]
}
18 changes: 18 additions & 0 deletions docs/importing-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -235,6 +236,23 @@ import { GetDirectionsPrompt } from '@mapbox/mcp-server/prompts';
const prompt = new GetDirectionsPrompt();
```

## Skills

Skills are workflow instructions served over the [MCP Skills extension](https://modelcontextprotocol.io/extensions/skills/overview) (SEP-2640), using the SDK's own support for it (`@modelcontextprotocol/server/ext/skills`). To serve the same skills from your own server, install them before connecting:

```typescript
import { McpServer } from '@modelcontextprotocol/server';
import { getSkillsForTools, installSkillsTo } from '@mapbox/mcp-server/skills';

// Only the skills whose tools you've installed.
const skills = getSkillsForTools(['isochrone_tool', 'render_map_tool' /* … */]);

const server = new McpServer({ name: 'my-server', version: '1.0.0' });
await installSkillsTo(server, skills);
```

This declares the extension and serves `skills/list`, `skills/get` and the `skill://<name>/SKILL.md` files. If you create a server per request, build the catalog once with `loadSkills(skills)` and call `catalog.attach(server)` on each one instead. `ALL_SKILLS` has every skill this package serves, taken from [mapbox/mapbox-agent-skills](https://github.com/mapbox/mapbox-agent-skills) at the commit `AGENT_SKILLS_SOURCE` names.

## HTTP Pipeline Utilities

### Default Pipeline
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -114,6 +115,7 @@
"./tools": "./src/tools/index.ts",
"./resources": "./src/resources/index.ts",
"./prompts": "./src/prompts/index.ts",
"./skills": "./src/skills/index.ts",
"./utils": "./src/utils/index.ts"
},
"dialects": [
Expand Down Expand Up @@ -163,6 +165,16 @@
"default": "./dist/commonjs/prompts/index.js"
}
},
"./skills": {
"import": {
"types": "./dist/esm/skills/index.d.ts",
"default": "./dist/esm/skills/index.js"
},
"require": {
"types": "./dist/commonjs/skills/index.d.ts",
"default": "./dist/commonjs/skills/index.js"
}
},
"./utils": {
"import": {
"types": "./dist/esm/utils/index.d.ts",
Expand Down
111 changes: 111 additions & 0 deletions scripts/fetch-agent-skills.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
8 changes: 8 additions & 0 deletions src/config/toolConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

Expand Down
Loading
Loading