# MCP server reference

> Connect an agent to a project's MCP endpoint at mcp.spatibase.com, see which credential unlocks which tools and learn about dry runs, errors and limits.

Every Spatibase project has an MCP (Model Context Protocol) server, so AI assistants and your own agents can read the project and, when allowed, change it. This reference is for developers: the URL and transport, what each credential unlocks, how to connect, every tool, dry runs, limits and security notes. For a step-by-step setup in Claude, ChatGPT or Cursor, see [Connect an AI assistant](https://spatibase.com/docs/ai/connect-an-assistant/).

On this page, `$PROJECT_ID` is the project's UUID (the id in the project's web address), and `$EDIT_KEY` is an AI assistant key (`sb_edit_…`) created by a project owner.

## URL and transport

Each project has its own MCP endpoint:

```http
https://mcp.spatibase.com/projects/$PROJECT_ID
```

Copy the exact address from the project's **AI agents** page, in **Connect your assistant** (**This project's assistant address**). That value is the source of truth.

The server uses the Streamable HTTP transport and is stateless:

- Every request is a `POST` with one JSON-RPC message (or a batch) and its own `Authorization` header.
- Responses are plain JSON. There is no SSE stream and no `Mcp-Session-Id`. A `GET` answers `405`.
- The server does not remember `initialize` between requests.

## Which credential gives which access?

What a client can do follows only from its credential, sent as `Authorization: Bearer <credential>`. Keys in the query string or in an `X-API-Key` header are ignored on this endpoint. The tool list a client receives contains only the tools its credential allows; calling any other tool fails with `MCP error -32602: Tool … not found`.

| Credential | Tiers |
|---|---|
| None, public project | **Read**: published content only, exactly what the Content Delivery API shows |
| None, private project | Nothing: `401` with `WWW-Authenticate: Bearer resource_metadata="…"` for [OAuth discovery](https://spatibase.com/docs/ai/oauth/) |
| Read key `sb_live_…` | **Read** |
| Edit key `sb_edit_…` | **Read** + **Edit**: read and change drafts, never live content |
| Edit key with **Allow publishing** | + **Publish**: mark drafts ready, publish the project, stage unpublishes, go back to an earlier version, change group membership and order (groups ship with the next publish) |
| Edit key with **Allow deleting** | + **Delete**: delete entries and files into the trash (30 days) |
| OAuth access token `sb_oat_…` | What the person allowed on the consent screen, capped by their current project role, including **Model** for owners |
| Temporary project token `sb_temp_…` | Read, Edit, Publish, Delete and Model on that one [temporary project](https://spatibase.com/docs/build/start-from-your-code/#temporary-projects), for 24 hours from when it was issued. It is made by the [`spatibase` command](https://spatibase.com/docs/build/cli/) without an account |

What a person's role allows over OAuth:

| Role | Maximum tiers |
|---|---|
| Owner | Read, Edit, Publish, Delete, Model |
| Editor | Read, Edit, Publish, Delete |
| Developer | Read |

Writing always needs a credential: a public project is only public to read. Only owners create edit keys, in the web app (**API**, **Access & keys**, **Create key**, **For an AI assistant**). Editors and developers connect their assistants through [OAuth](https://spatibase.com/docs/ai/oauth/) instead.

## Connect a client

### Claude Code

```bash
claude mcp add --transport http spatibase "https://mcp.spatibase.com/projects/$PROJECT_ID" \
  --header "Authorization: Bearer $EDIT_KEY"
```

Leave out `--header` to read a public project, or to sign in with OAuth: run `/mcp` in Claude Code, choose **spatibase** and sign in.

### Cursor

In `.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "spatibase": {
      "url": "https://mcp.spatibase.com/projects/PROJECT_ID",
      "headers": { "Authorization": "Bearer EDIT_KEY" }
    }
  }
}
```

### Claude Desktop with a key

Claude Desktop's configuration file starts local servers, so bridge to the remote server with `mcp-remote` (needs Node.js):

```json
{
  "mcpServers": {
    "spatibase": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://mcp.spatibase.com/projects/PROJECT_ID",
               "--header", "Authorization: Bearer ${SPATIBASE_KEY}"],
      "env": { "SPATIBASE_KEY": "EDIT_KEY" }
    }
  }
}
```

Claude.ai, ChatGPT and Claude Desktop custom connectors use the URL and [OAuth](https://spatibase.com/docs/ai/oauth/); no key is pasted.

### Your own agent

With the MCP TypeScript SDK:

```js
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'

const url = new URL(`https://mcp.spatibase.com/projects/${process.env.PROJECT_ID}`)
const transport = new StreamableHTTPClientTransport(url, {
  requestInit: { headers: { Authorization: `Bearer ${process.env.SPATIBASE_KEY}` } }
})
const client = new Client({ name: 'my-agent', version: '1.0.0' })
await client.connect(transport)

const result = await client.callTool({ name: 'search_entries', arguments: { type: 'store', limit: 5 } })
console.log(result.structuredContent)
```

### curl

One request carries one message. The `Accept` header may be just `application/json`.

```bash
curl -s -X POST "https://mcp.spatibase.com/projects/$PROJECT_ID" \
  -H 'content-type: application/json' \
  -H "Authorization: Bearer $EDIT_KEY" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"count_entries","arguments":{"type":"store"}}}'
```

## Tools

Results are compact JSON, returned both as text and as `structuredContent`. Ids and apiKeys are always included so calls can be chained. Geometry is summarised as `{type, centroid, bbox, vertices}` (a point keeps its coordinates) unless you pass `includeGeometry: true`.

| Tier | Tool | What it does |
|---|---|---|
| Read | `describe_project` | Types (tree, location rules, elements with kind, required flag and options), map feature types, groups, the project's `locales` and `defaultLocale` (with a hint on how to translate), and the caller's access. A connection that can edit also sees the switched-off Types, marked `enabled: false`: the assistant can write drafts of them, but the live API leaves them out until someone switches the Type on. Also available as the resource `spatibase://projects/{id}/schema`. |
| Read | `search_entries` | Published entries, filtered by `type`, `subtypes`, `group`, `q`, `filters` (for example `{"rating": {"gte": 4}}` or `{"open": true}`, with the [Content Delivery API](https://spatibase.com/docs/reference/delivery-api/) operators), `bbox`, `near` + `radius`, `nearest` (closest first, no radius), `within` (a GeoJSON polygon), `withinEntry` (+ `withinField`) and `contains` (+ `containsField`: which zones a point is in), as `[lng, lat]` pairs; with `sort`, `page`, `limit` (up to 100, default 20), `fields` and `includeGeometry`. Text over 300 characters is cut. |
| Read | `count_entries` | The number of matches for the same filters. |
| Read | `get_entry` | One published entry in full; `includeReferences` expands reference fields to the full referenced entries. |
| Read | `list_groups`, `get_group` | Groups, and a group's entries in their order. |
| Read | `get_entries_geojson` | A GeoJSON FeatureCollection of up to 200 features; when more match, the Content Delivery API URL for all of them. |
| Read | `get_api_spec` | The project's typed OpenAPI document of the Content Delivery API (an `Entry<Type>` schema per type, a parameter per field filter) for writing or generating client code, plus its public URL. A private project needs a read key at that URL; the tool never returns a key. A document over 200 KB comes back as the URL only. |
| Edit | `search_drafts`, `get_draft` | Entries as editors see them, with status `draft`, `changed` or `published`, and their `translations`. `changedByAgent: true` returns the editors' review list; `missingLocale: "de"` returns the entries that still have no German translation, each with `missingTranslations` counts. |
| Edit | `create_entry` | Creates a draft entry (`type` is an apiKey or id). Takes `translations` too. |
| Edit | `update_entry` | Changes a draft. Only the fields you send change; `null` clears one. `expectedUpdatedAt` refuses the write when someone saved in between. `translations` is merged per language and key, see [Translations](https://spatibase.com/docs/ai/mcp-server/#translations). |
| Edit | `bulk_update_entries` | Up to 50 updates (also with `translations`). The dry run shows each change from old to new value. |
| Edit | `import_features` | Up to 1,000 GeoJSON features or CSV rows (text or row objects) as drafts, with `mapping` and `matchBy` (see [Import API](https://spatibase.com/docs/reference/import-api/)). With `publish: true` (this also needs the Publish tier) the imported and updated entries are marked ready and the project is published in the same call, so anything a person had already marked ready ships too. The answer lists `ids` in row order (`null` for a skipped row) and, with `publish`, `published` and `publishFailed`. With `locale` (and `matchBy`) the rows are translations of existing entries. A longitude outside -180 to 180 is imported wrapped and reported as a warning. |
| Edit | `list_datasets` | Spatibase's free [public datasets](https://spatibase.com/datasets/): slug, name, licence, the fields each can be filtered by (such as `country_code`), entry count, snapshot date, and what this project already took from each (`sources`, `updateAvailable` when a newer snapshot exists). Writes nothing. |
| Edit | `import_dataset` | Imports a public dataset into **this** project (it never creates a project), whole or with `filter: {field, value}` and/or `bbox`, up to 10,000 matching entries. Missing types and fields are created; existing ones are never changed (a mismatch is an error naming them). Entries arrive as drafts; ones whose identifier is already in the project are skipped, so running it again adds only what's missing. The new types' descriptions carry the dataset's source and licence. |
| Edit | `upload_asset` | Uploads one file: `filename`, `contentBase64`, optional `mimeType` and `alt`. Up to 3.5 MB after decoding; the same type rules as the media library. Returns `{id, filename, url, …}`; put the `id` in an Images or Files value as `["<id>"]`. Counts as one write. |
| Edit | `list_assets` | Uploaded images and files. |
| Edit | `propose_group_changes` | Checks a group change and returns it for an editor to apply. Changes nothing. |
| Edit | `list_trash` | The trash. |
| Edit | `list_versions`, `get_version` | The project's numbered versions (one per publish), newest first, and what one of them changed. Read-only. |
| Publish | `mark_ready` | Marks drafts ready to publish, by `ids` or a `filter` (default: drafts and unpublished changes), up to 500 entries. Nothing goes live. The dry run lists what would be marked and which entries fail validation and stay drafts. |
| Publish | `publish_project` | Publishes the project as one new version. The dry run shows everything that would ship, including entries other people marked ready, what stays out and the changes made by assistants; the real call needs the preview's `token` and says if the preview went stale. Returns the version number. |
| Publish | `unpublish_entries` | By `ids` or a `filter`, up to 500 entries. **Stages** the unpublish: the entries leave the API at the next `publish_project`. |
| Publish | `restore_version` | Goes back to an earlier version, live at once, as a new version. The dry run lists what would be added, changed and removed and what can't come back. |
| Publish | `set_entry_groups`, `add_to_group`, `remove_from_group`, `reorder_group` | Group membership and order. They change the groups' drafts and go live with the next publish. |
| Delete | `delete_entries`, `delete_assets` | Up to 100 ids. A live entry is only staged for removal and goes to the trash at the next publish; an entry that was never live and a file go to the trash at once. The dry run lists what links to each item. |
| Delete | `restore_from_trash` | Brings an entry back from the trash as a draft, or a file back. |
| Model | `create_type`, `create_element`, `assign_elements`, `create_group`, `update_group`, `update_project_settings` (including `locales`, the project's languages) | Need `confirm: true`; without it they return a preview of the change. |

Prompts: `summarize_project`, and `find_missing_field` (arguments `type` and `field`).

No credential can manage API keys, switch a project between public and private, manage members or the webhook, duplicate or delete the project or empty the trash.

## Translations

In a project with [languages](https://spatibase.com/docs/content/translations/) the tools know about them:

- The read tools (`search_entries`, `get_entry`, `get_group`, `get_entries_geojson`) take `locale`, like the [Content Delivery API](https://spatibase.com/docs/reference/delivery-api/#languages): `locale: "de"` returns German with a fallback to the default language and `missingTranslations`, and `locale: "*"` returns all stored translations.
- `create_entry`, `update_entry` and `bulk_update_entries` take `translations`, for example `{"de": {"title": "Wald", "values": {"description": "…"}}}`. It is **merged**: only the languages and fields you send change, `null` removes one. Only the title and text and rich text fields can be translated, and only in the project's non-default languages; other requests are refused with the reason.
- `search_drafts` takes `missingLocale`.
- `import_features` takes `locale` with `matchBy`, and reads language columns such as `title_de`.
- `update_project_settings` takes `locales` and needs the model tier (and `confirm: true`).

The workflow the tool descriptions teach: find the entries with `missingLocale`, read each draft, write `translations` as drafts, and leave publishing to a person. Translated drafts are marked as changed by an assistant, like every other draft change.

## Dry runs and errors

Every tool that takes `dryRun` defaults to `dryRun: true`: `bulk_update_entries`, `import_features`, `import_dataset`, `mark_ready`, `publish_project`, `unpublish_entries`, `restore_version`, `delete_entries` and `delete_assets`. The first call shows what would happen; nothing is written until the client calls again with `dryRun: false`. Model tools work the same way with `confirm`.

Tool errors come back as tool results with `isError: true` and the API's plain message:

```json
{ "error": "This entry changed since you loaded it…", "status": 409, "code": "entry_changed" }
```

## What editors see

Every change appears in the project's **Activity**, as "AI assistant (key name)" for an edit key or "person via app" for an OAuth connection. Drafts an assistant changed are flagged in **Entries** (the **Changed by AI** switch in the **Status** filter) until someone edits them or publishes them. The `publish_project` preview counts them too. **Activity** has an **AI changes** checkbox for the change log.

The client name comes from `clientInfo.name` in an `initialize` request, otherwise from the first word of the `User-Agent` header, or from the OAuth client's registered name.

## Limits

| Limit | Value | When exceeded |
|---|---|---|
| Calls (`tools/call`, `resources/read`, `prompts/get`) per credential, or per IP without one | 120 a minute | HTTP `429` with `Retry-After`, JSON-RPC error `-32000` |
| Write calls per credential | 30 a minute | Tool error with `code: "rate_limited"` and `retryAfterSeconds` |
| Entries changed per credential (created, updated, marked ready, published, unpublished, deleted, restored, group changes) | 20,000 a UTC day | Tool error before anything is written. The count is reserved up front, so parallel calls cannot exceed it. |
| Request body | 5 MB (a file of up to 3.5 MB fits in an `upload_asset` call) | `413` |
| Tool result | 200 KB | The largest list is shortened and the result says `truncated` |
| Failed credentials per IP | 60 in 10 minutes | `429`, shared with the Content Delivery API |

A wrong, revoked, expired or other project's key gets `401` with `error="invalid_token"`. An expired temporary project token gets `401` with the code `token_expired`, one the owner removed in **Members** gets `401` with `token_revoked` (the agent should stop and ask, not start a new project), and a temporary project that expired gets `410` with `project_expired` (for 30 days after it was deleted). Expired or revoked credentials do not count towards the failed-credential limit. See [Limits](https://spatibase.com/docs/reference/limits/) for the limits of the other APIs.

## Security notes

- **Prompt injection.** Tool results are the project's data. An entry's text can contain instructions aimed at an assistant; every tool description warns the model about this. Give an assistant the smallest credential that does the job, keep publishing and deleting off unless needed, and review the entries flagged **Changed by AI**.
- **Keys are secrets.** An edit key can change drafts. Keep it out of code repositories and web pages, and revoke it when the assistant no longer needs access.
- **Browser requests.** A request that carries an `Origin` header from a website other than the Spatibase web app is refused with `403`.
- **Logging.** Spatibase logs the tool, the key prefix or connection id, the duration and the size of each call, never a credential or content.

## Related

- [OAuth for MCP clients](https://spatibase.com/docs/ai/oauth/)
- [Connect an AI assistant](https://spatibase.com/docs/ai/connect-an-assistant/)
- [What assistants can do](https://spatibase.com/docs/ai/what-assistants-can-do/)
- [Authentication](https://spatibase.com/docs/reference/authentication/)
- [Content Delivery API reference](https://spatibase.com/docs/reference/delivery-api/)
