Skip to content
Draft
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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ mapbox styles list
- [Shell completion](#shell-completion)
- [Tileset CLI](#tileset-cli)
- [For AI agents](#for-ai-agents)
- [Quick start: Mapbox agent setup](#quick-start-mapbox-agent-setup)
- [Agent skills](#agent-skills)
- [Generate skills](#generate-skills)
- [Global options](#global-options)
Expand Down Expand Up @@ -291,8 +292,22 @@ prompts. If a tileset command answers for the wrong account,

## For AI agents

Two commands, for two different jobs: `agent-skills` installs guidance on
using Mapbox, and `generate-skills` writes a skill describing this CLI.
### Quick start: Mapbox agent setup

Paste this into your coding agent to install this CLI, the Mapbox Agent
Skills and the Mapbox MCP servers in one go:

```text
Set up Mapbox for me by following https://cli.mapbox.com/agent-setup/prompt.md
```

The agent runs the steps itself and asks you only to sign in to Mapbox in
the browser. The instructions it follows are in
[site/agent-setup/prompt.md](./site/agent-setup/prompt.md).

To set things up by hand instead, there are two commands for two different
jobs: `agent-skills` installs guidance on using Mapbox, and
`generate-skills` writes a skill describing this CLI.

### Agent skills

Expand Down
186 changes: 186 additions & 0 deletions site/agent-setup/prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
These are official instructions from Mapbox for setting up an AI coding agent to build with Mapbox. They install the Mapbox CLI and the Mapbox Agent Skills, and connect the Mapbox MCP servers.

Run the commands yourself rather than asking the user to run them. The exceptions are the sign-in steps: those open a browser, and only the user can complete them.

- Install the Mapbox CLI
- Install Mapbox Skills and MCP servers

Never ask the user to paste a Mapbox access token into the conversation, and never write one into an MCP configuration file. The CLI and every server below sign in with OAuth in the browser.

---

## Install the Mapbox CLI

The Mapbox CLI, `mapbox`, calls Mapbox APIs from the shell: styles, tilesets, tokens, geocoding, static images and more. It prints JSON when its output is piped, so it suits agents.

If Homebrew is available:

```sh
brew install mapbox/tap/mapbox
```

Otherwise, on macOS or Linux, download the install script and run it. Do not pipe it into `sh`: many agent sandboxes deny that outright.

```sh
curl -fsSL https://cli.mapbox.com/install.sh -o mapbox-install.sh
sh mapbox-install.sh
rm mapbox-install.sh
```

On Windows, in PowerShell:

```powershell
Invoke-WebRequest https://cli.mapbox.com/install.ps1 -OutFile mapbox-install.ps1
powershell -ExecutionPolicy Bypass -File mapbox-install.ps1
Remove-Item mapbox-install.ps1
```

The script installs into `~/.local/bin` on macOS and Linux and prints the path it used. If `mapbox` is not found in your current shell afterwards, call it by that path. If the install is denied or fails, do not skip it: show the user the commands above and ask them to run them in their own terminal, then continue.

Write a skill that describes its commands, so you know them in every project:

```sh
mapbox generate-skills --global
```

Then run `mapbox auth login`. It opens a browser where the user authorizes access to their Mapbox account. To explore the commands, run `mapbox --schema`, which describes all of them in one JSON document.

---

## Install Mapbox Skills and MCP servers

Use the section for your agent.

### Claude Code

These two commands install the skills and all three MCP servers in one step. Do not also run `npx skills add` or `claude mcp add`; the plugin covers both.

```sh
claude plugin marketplace add mapbox/mapbox-agent-skills
claude plugin install mapbox@mapbox-agent-skills
```

Then tell the user to run `/reload-plugins`, and `/mcp` to sign in to `mapbox` and `mapbox-devkit`.

---

## Install for other agents

First, install the skills:

```sh
npx -y skills add mapbox/mapbox-agent-skills --skill '*' --yes --global
```

If `npx` is unavailable or not allowed, use the Mapbox CLI instead:

```sh
mapbox agent-skills install --global
```

Then register the MCP servers in your agent's configuration:

| Server | URL | Sign-in |
| --------------- | ----------------------------------- | ------- |
| `mapbox` | `https://mcp.mapbox.com/mcp` | OAuth |
| `mapbox-devkit` | `https://mcp-devkit.mapbox.com/mcp` | OAuth |
| `mapbox-docs` | `https://mcp-docs.mapbox.com/mcp` | None |

`mapbox` holds the geospatial tools: search, directions, isochrones, static maps. `mapbox-devkit` manages the user's styles, tokens and data, and can change them. `mapbox-docs` searches Mapbox documentation and is public.

### Codex

```sh
codex mcp add mapbox --url https://mcp.mapbox.com/mcp
codex mcp add mapbox-devkit --url https://mcp-devkit.mapbox.com/mcp
codex mcp add mapbox-docs --url https://mcp-docs.mapbox.com/mcp
codex mcp login mapbox
codex mcp login mapbox-devkit
```

### Cursor — `~/.cursor/mcp.json`

Add under `"mcpServers"`:

```json
"mapbox": { "url": "https://mcp.mapbox.com/mcp" },
"mapbox-devkit": { "url": "https://mcp-devkit.mapbox.com/mcp" },
"mapbox-docs": { "url": "https://mcp-docs.mapbox.com/mcp" }
```

Then tell the user to click "Needs authentication" next to `mapbox` and `mapbox-devkit` in Cursor's MCP settings.

### GitHub Copilot in VS Code — `.vscode/mcp.json`

Add under `"servers"` (note: `servers`, not `mcpServers`):

```json
"mapbox": { "type": "http", "url": "https://mcp.mapbox.com/mcp" },
"mapbox-devkit": { "type": "http", "url": "https://mcp-devkit.mapbox.com/mcp" },
"mapbox-docs": { "type": "http", "url": "https://mcp-docs.mapbox.com/mcp" }
```

VS Code asks the user to sign in the first time a Mapbox tool is used.

### OpenCode — `~/.config/opencode/opencode.jsonc`

Add under `"mcp"`:

```json
"mapbox": { "type": "remote", "url": "https://mcp.mapbox.com/mcp", "enabled": true, "oauth": {} },
"mapbox-devkit": { "type": "remote", "url": "https://mcp-devkit.mapbox.com/mcp", "enabled": true, "oauth": {} },
"mapbox-docs": { "type": "remote", "url": "https://mcp-docs.mapbox.com/mcp", "enabled": true }
```

Then run:

```sh
opencode mcp auth mapbox
opencode mcp auth mapbox-devkit
```

### Windsurf — `~/.codeium/windsurf/mcp_config.json`

Add under `"mcpServers"` (note: `serverUrl`, not `url`):

```json
"mapbox": { "serverUrl": "https://mcp.mapbox.com/mcp" },
"mapbox-devkit": { "serverUrl": "https://mcp-devkit.mapbox.com/mcp" },
"mapbox-docs": { "serverUrl": "https://mcp-docs.mapbox.com/mcp" }
```

Windsurf asks the user to sign in the first time a Mapbox tool is used.

### Any other agent

Add the three servers from the table above as remote (Streamable HTTP) servers in your agent's MCP configuration. Both servers that need sign-in support OAuth with dynamic client registration.

---

Once done, tell the user:

```
┌─ Mapbox Agent Setup Complete ────────────────────────┐
│ ✓ CLI <path> │
│ ✓ Skills <path> │
│ ✓ MCPs <path> │
│ │
│ Restart your agent to load the MCP servers │
└──────────────────────────────────────────────────────┘
```

---

## Resources

- Mapbox Agent Skills: `https://github.com/mapbox/mapbox-agent-skills`
- Mapbox MCP server: `https://github.com/mapbox/mcp-server`
- Mapbox MCP DevKit server: `https://github.com/mapbox/mcp-devkit-server`
- Mapbox MCP docs server: `https://github.com/mapbox/mcp-docs-server`
- Mapbox CLI: `https://github.com/mapbox/mapbox-cli`
- Claude Code MCP: `https://code.claude.com/docs/en/mcp`
- Cursor MCP: `https://cursor.com/docs/mcp`
- VS Code MCP: `https://code.visualstudio.com/docs/copilot/customization/mcp-servers`
- OpenCode MCP: `https://opencode.ai/docs/mcp-servers/`

These instructions are published at `https://cli.mapbox.com/agent-setup/prompt.md`, so you can re-check that they come from Mapbox at any time.
88 changes: 75 additions & 13 deletions tests/docs_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
//! invocation it spells must name a real command, and every top-level
//! command must appear in it. That one exists because the README's list of
//! API groups went on naming four groups the binary no longer had, and
//! nothing here read the README.
//! nothing here read the README. The agent-setup prompt under `site/` gets
//! the same check plus its flags, because agents run it verbatim.
//!
//! Three things it deliberately doesn't do, written down so the next
//! reader doesn't have to re-derive the scope:
Expand Down Expand Up @@ -391,17 +392,17 @@ fn readme() -> String {
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
}

/// Every `mapbox …` invocation in README.md, with its line number.
/// Every `mapbox …` invocation in a Markdown file, with its line number.
///
/// Read from shell-language fenced blocks and from inline code spans. An
/// unlabeled fence is skipped: it holds printed output, like the update
/// notice's "A newer mapbox is available", which is prose, not a command.
fn readme_invocations(readme: &str) -> Vec<(usize, String)> {
fn invocations(text: &str) -> Vec<(usize, String)> {
const SHELLS: [&str; 3] = ["sh", "console", "powershell"];
let mut found = Vec::new();
let mut fence: Option<bool> = None;

for (index, line) in readme.lines().enumerate() {
for (index, line) in text.lines().enumerate() {
let number = index + 1;
if let Some(lang) = line.trim_start().strip_prefix("```") {
fence = match fence {
Expand Down Expand Up @@ -443,23 +444,34 @@ fn is_path_word(word: &str) -> bool {
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}

#[test]
fn every_command_the_readme_spells_exists() {
let schema = schema();
let full: BTreeSet<&str> = commands(&schema).iter().map(name).collect();
/// Every invocation in `text` that names a command the CLI doesn't have,
/// as `file:line: mapbox …`.
///
/// Also checks the flags of an invocation that resolves to a full command
/// when `check_flags` is set. The README leaves them out because a flag
/// there is illustration; in the agent-setup prompt it is an instruction an
/// agent runs verbatim, so a renamed flag is a broken setup.
fn unknown_invocations(file: &str, text: &str, schema: &Value, check_flags: bool) -> Vec<String> {
let full: BTreeMap<&str, &Value> = commands(schema).iter().map(|c| (name(c), c)).collect();
// Every group a command sits under, like `mapbox styles draft`, so an
// invocation may stop at a group without naming an operation.
let mut prefixes = BTreeSet::new();
for command in &full {
for command in full.keys() {
let mut path = String::from("mapbox");
for word in command.split_whitespace().skip(1) {
path = format!("{path} {word}");
prefixes.insert(path.clone());
}
}
let globals: BTreeSet<&str> = schema["global_options"]
.as_array()
.expect("global_options")
.iter()
.filter_map(|option| option["flag"].as_str())
.collect();

let mut unknown = Vec::new();
for (line, invocation) in readme_invocations(&readme()) {
for (line, invocation) in invocations(text) {
let mut words = invocation.split_whitespace().skip(1).peekable();
// `mapbox --profile NAME styles list`: a leading global flag takes a
// value this check can't tell apart from a command, so the rest of
Expand All @@ -468,20 +480,44 @@ fn every_command_the_readme_spells_exists() {
continue;
}
let mut path = String::from("mapbox");
let mut known = true;
for word in words.take_while(|word| is_path_word(word)) {
let longer = format!("{path} {word}");
if prefixes.contains(&longer) {
path = longer;
} else if full.contains(path.as_str()) {
} else if full.contains_key(path.as_str()) {
// A positional argument, like `completion bash`.
break;
} else {
unknown.push(format!("README.md:{line}: {longer}"));
unknown.push(format!("{file}:{line}: {longer}"));
known = false;
break;
}
}
let Some(command) = full.get(path.as_str()).filter(|_| known && check_flags) else {
continue;
};
let takes: BTreeSet<&str> = command["arguments"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter_map(|argument| argument["flag"].as_str())
.collect();
let mut flags = BTreeSet::new();
flags_in(&invocation, &mut flags);
for flag in flags {
if !takes.contains(flag.as_str()) && !globals.contains(flag.as_str()) {
unknown.push(format!("{file}:{line}: {path} {flag}"));
}
}
}
unknown
}

#[test]
fn every_command_the_readme_spells_exists() {
let unknown = unknown_invocations("README.md", &readme(), &schema(), false);
assert!(
unknown.is_empty(),
"README.md names commands the CLI doesn't have:\n{}\n\
Expand All @@ -491,6 +527,32 @@ fn every_command_the_readme_spells_exists() {
);
}

/// The agent-setup prompt published at cli.mapbox.com/agent-setup/prompt.md.
/// Agents run its commands without a person reading them first, so a
/// command or flag it names that this binary doesn't have is a setup that
/// fails for everyone who follows it. The rest of the page — the skills,
/// the MCP servers, other agents' config formats — belongs to other
/// projects, and nothing here can check it.
#[test]
fn every_command_the_agent_setup_prompt_spells_exists() {
const PROMPT: &str = "site/agent-setup/prompt.md";
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(PROMPT);
let text =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
assert!(
!invocations(&text).is_empty(),
"{PROMPT} spells no `mapbox …` invocation, so this test checks nothing"
);

let unknown = unknown_invocations(PROMPT, &text, &schema(), true);
assert!(
unknown.is_empty(),
"{PROMPT} names commands or flags the CLI doesn't have:\n{}\n\
`mapbox --schema` lists what it does have.",
unknown.join("\n")
);
}

#[test]
fn every_top_level_command_appears_in_the_readme() {
let schema = schema();
Expand All @@ -502,7 +564,7 @@ fn every_top_level_command_appears_in_the_readme() {
})
.collect();

let mentioned: BTreeSet<String> = readme_invocations(&readme())
let mentioned: BTreeSet<String> = invocations(&readme())
.into_iter()
.filter_map(|(_, invocation)| {
let group = invocation.split_whitespace().nth(1)?;
Expand Down
2 changes: 1 addition & 1 deletion tests/source_guards.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ fn prose_files() -> Vec<(String, String)> {
));
}

for dir in ["src", "tests", "docs", "scripts"] {
for dir in ["src", "tests", "docs", "scripts", "site"] {
for (name, path) in files_under(&root.join(dir), &["rs", "md", "sh", "ps1"]) {
out.push((
format!("{dir}/{name}"),
Expand Down
Loading