# mcp-server

> An MCP (Model Context Protocol) server for **TiDB Cloud**: management and data
> tools, remote-first, safe by default. Works with Claude Code, Claude Desktop,
> Cursor, and any MCP client.

**Status:** `0.1.0`, tested and CI-green. Ships a local `stdio` server and a secured remote
HTTP endpoint (Node on Fly.io) with per-user API-key auth, permission-group scopes, rate
limiting, and observability. Data path is the MySQL protocol. **Auth is per-user API keys**
(bring-your-own); an OAuth 2.1 resource-server mode is built but dormant. **Not yet on npm**
(deferred to a later phase): install from a packed tarball or from source. Not (yet) an
official PingCAP product.

---

## What this is

A single MCP server that lets an AI agent manage TiDB Cloud clusters and query
data through one credential, with a strong safety posture:

- **Management tools:** list/create/update/delete clusters and branches, regions
  (TiDB Cloud Starter / Essential first; Dedicated later).
- **Data tools:** list databases/tables, describe schema, run read-only queries,
  execute DML/DDL (guarded), all over ephemeral, least-privilege SQL users.
- **Recovery tools:** point-in-time recovery and snapshot restore (Essential / Premium),
  manual snapshot creation ("snapshot before surgery"), recovery-info and backup listing,
  restore-status polling, and recent-DDL lookup to find when a bad change happened. Restores
  always create a new billable cluster (the source is untouched) behind a preflight; deleting a
  backup is a console action by design. See [docs/RECOVERY.md](docs/RECOVERY.md).
- **Telemetry:** structured per-call events (tool, outcome, our-time vs TiDB-time split, redacted
  args) behind an allowlist that drops by default, so SQL, result rows, and credentials are never
  recorded. Local JSONL by default, so a self-hosted install sends nothing anywhere; recording never
  blocks or fails a request; detail is pruned on a retention window while daily counts are kept; and
  it can be turned off entirely with `--no-telemetry`. See [docs/TELEMETRY.md](docs/TELEMETRY.md).
- **Org visibility:** a TiDB Cloud API key belongs to exactly one organization permanently, so every
  connection targets exactly one. The server states which one wherever a decision is made: at the top
  of every preflight (before the action, so you read who before you read what), in
  `tidb_capabilities`, and in not-found errors. If the organization cannot be verified the tool still
  works, but the preflight opens with a warning and the confirm response echoes it. If you manage
  several customer organizations, read [docs/MULTI-ORG.md](docs/MULTI-ORG.md) for the
  one-entry-per-organization setup.
- **Confirmation tokens:** every mutating tool runs preflight then confirm, and the confirm now
  carries a signed token proving this server rendered that preflight, for that call, recently. It
  closes the hole where an agent could send `confirm: true` on its first call and skip the approval
  surface entirely, binds the approval to the plan that was displayed so a changed target is
  refused, and for destructive or billable tools spends a single-use nonce so a replay cannot create
  a second cluster. See [docs/CONFIRM-TOKENS.md](docs/CONFIRM-TOKENS.md).
- **Rate limits and quotas:** the remote endpoint limits each API key to 5 requests per second
  (burst 20) and 5,000 calls per UTC day, shields the upstream TiDB Cloud API with a whole-instance
  ceiling, and holds keyless traffic to a much stricter per-IP budget that is checked before any
  credential is validated. Exceeding a limit returns HTTP 429 with `Retry-After` and a message
  written for an agent to act on, which distinguishes a short pause from a quota that cannot be
  waited out before UTC midnight. Local stdio is deliberately unlimited. See
  [docs/RATE-LIMITS.md](docs/RATE-LIMITS.md).
- **Docs tools:** `tidb_search_docs` searches the TiDB Cloud documentation (live Algolia
  index, with an offline fallback), and `tidb_get_doc` fetches a full page as markdown.
- **`tidb_capabilities`:** a self-description of what the server supports (cluster tiers,
  limits, current mode) so an agent can check the facts instead of guessing.
- **Safe by default:** read-only unless you opt into write/admin scopes; every
  destructive or billable action runs a preflight you must confirm. Least privilege is
  enforced by SQL grant for ephemeral users (a bring-your-own SQL user runs with
  whatever privileges you gave it).
- **Transport:** `stdio` for local use, plus a live remote streamable-HTTP endpoint
  (Node on Fly.io) you connect to with your own key.

## Try it now (hosted, zero install)

The fastest way to try this is the hosted endpoint at `https://tidbcloud-mcp.fly.dev/mcp`.
It is multi-tenant: you bring your own TiDB Cloud management API key (console -> API Keys),
and the server holds none. It is an internal evaluation endpoint, so prefer self-hosting or
local `stdio` for a sensitive key.

**The hosted endpoint is read-only.** It exposes only the read tools (no create, delete, or
execute), because a shared endpoint that people may point a production key at must not expose
mutating tools. To try writes and the preflight/confirm flow, run locally (below) or self-host,
where you control the blast radius.

First base64-encode your key pair:

```bash
printf '%s:%s' "$TIDB_CLOUD_PUBLIC_KEY" "$TIDB_CLOUD_PRIVATE_KEY" | base64
```

**Claude Code:**

```bash
claude mcp add --transport http tidbcloud https://tidbcloud-mcp.fly.dev/mcp \
  --header "Authorization: Basic <paste-base64-here>"
```

**Claude Desktop / Cursor / any config-file client:**

```json
{
  "mcpServers": {
    "tidbcloud": {
      "type": "http",
      "url": "https://tidbcloud-mcp.fly.dev/mcp",
      "headers": { "Authorization": "Basic <paste-base64-here>" }
    }
  }
}
```

See [Remote endpoint](#remote-endpoint) for scopes, auth modes, and self-hosting (where you
can enable writes).

## Run locally (stdio)

Set your TiDB Cloud management API key:

```bash
export TIDB_CLOUD_PUBLIC_KEY=...   # public key
export TIDB_CLOUD_PRIVATE_KEY=...  # private key
```

Build from source and run:

```bash
pnpm install && pnpm build
node packages/stdio/dist/index.js        # read-only by default; see the escalation table below
pnpm inspector                           # explore the tools in the MCP Inspector
```

Point a config-file client at the built entry:

```json
{
  "mcpServers": {
    "tidbcloud": {
      "command": "node",
      "args": ["/absolute/path/to/packages/stdio/dist/index.js", "--read-only"],
      "env": { "TIDB_CLOUD_PUBLIC_KEY": "...", "TIDB_CLOUD_PRIVATE_KEY": "..." }
    }
  }
}
```

**The local server is read-only by default. Destructive tools are off unless you ask for them.**
You escalate once, at startup:

| Command                             | Tools | What you get                                                                    |
| ----------------------------------- | ----- | ------------------------------------------------------------------------------- |
| `node packages/stdio/dist/index.js` | 18    | reads only. No create, execute, backup, restore, or delete.                     |
| `... --allow-write`                 | 19    | adds `tidb_execute` (DML/DDL). Still no cluster admin.                          |
| `... --allow-admin`                 | 29    | adds the cluster, branch, SQL-user, backup, and restore mutators, incl. delete. |

Why a startup flag and not just the confirmation step: the **model**, not you, fills in `confirm`
and `confirmTarget`, so those catch a wrong target but are not a human authorization boundary. A
flag is, because a model cannot add a word to the command line you already typed. `--read-only`
always wins if you pass both, and `--allow-admin` implies `--allow-write`. Env equivalents:
`TIDB_MCP_ALLOW_WRITE`, `TIDB_MCP_ALLOW_ADMIN`. Note: locally the default is already read-only, so
`TIDB_MCP_READ_ONLY=false` on its own does nothing; you must pass `--allow-write`/`--allow-admin`
(or their env equivalents) to enable writes. (The hosted server is the reverse: writes there are
gated by `TIDB_MCP_READ_ONLY`.)

Other flags / env: `--read-only` (or `TIDB_MCP_READ_ONLY=true`), `--disable-data-tools`,
`--cluster-allow a,b` / `--cluster-block`, `--project <id>` (or `TIDB_MCP_PROJECT`) to confine to
one project, `--org-label "Acme Corp"` (or `TIDB_MCP_ORG_LABEL`) to give this connection's
organization a human-readable name on every approval surface, `TIDB_SQL_USER` +
`TIDB_SQL_PASSWORD` (bring your own SQL user).

**Query templates.** `--templates <path>` (or `TIDB_MCP_TEMPLATES`) loads a JSON file of
operator-defined, parameterized queries; each becomes its own tool `tidb_tpl_<name>`, with params
bound positionally (never string-interpolated, so the agent cannot alter the SQL or inject; a colon
inside a string literal is not mistaken for a param). Every param is required (optional params are a
later item). Read templates are validated read-only at load and run read-only; write templates run
through preflight/confirm and still need a write grant. `--disable-data-tools` disables templates
too, since they run SQL. Add `--templates-only` to lock the server down to just the template tools
plus docs, disabling free-form query, execute, and schema tools entirely. See
[examples/templates.example.json](examples/templates.example.json).

**Projects.** `tidb_list_projects` lists the org's projects, `tidb_list_clusters` and
`tidb_get_cluster` show each cluster's `projectId`, and `tidb_create_cluster` takes a
`projectId`. Set `--project <id>` (or `TIDB_MCP_PROJECT`) to confine the server to one
project: it lists only that project's clusters, creates into it, and refuses to target a
cluster in any other project. This is the blast-radius guard for a multi-project org.

> npm publish is deferred to a later phase, so `npx @tidbcloud-mcp/server` and the
> one-click editor deep links are not available yet. Use the hosted endpoint above, build
> from source, or install from a tarball (below).

## Install from tarball

Until npm publish lands, this is how to run the local server on a clean machine. The server
package depends on the `core` package, so pack and install **both** (verified end to end):

```bash
# in a clone of this repo:
pnpm install && pnpm build
pnpm --filter @tidbcloud-mcp/core   pack --pack-destination /tmp/tgz
pnpm --filter @tidbcloud-mcp/server pack --pack-destination /tmp/tgz
# writes /tmp/tgz/tidbcloud-mcp-core-0.1.0.tgz and tidbcloud-mcp-server-0.1.0.tgz

# on the target machine (a clean, empty directory):
npm init -y
npm install /tmp/tgz/tidbcloud-mcp-core-0.1.0.tgz /tmp/tgz/tidbcloud-mcp-server-0.1.0.tgz
TIDB_CLOUD_PUBLIC_KEY=... TIDB_CLOUD_PRIVATE_KEY=... npx tidbcloud-mcp --read-only
```

The installed binary is `tidbcloud-mcp`. In a config-file client, point at it after
installing:

```json
{
  "mcpServers": {
    "tidbcloud": {
      "command": "npx",
      "args": ["tidbcloud-mcp", "--read-only"],
      "env": { "TIDB_CLOUD_PUBLIC_KEY": "...", "TIDB_CLOUD_PRIVATE_KEY": "..." }
    }
  }
}
```

## Remote endpoint

The remote endpoint is a small Node service (Hono) exposing MCP over streamable HTTP at
`POST /mcp`, with `GET /health` and a landing page. A hosted instance runs at
`https://tidbcloud-mcp.fly.dev/mcp` (see [Try it now](#try-it-now-hosted-zero-install)); to
self-host, run it in a container. It uses the MySQL data path over TLS from a Node container;
see [docs/DEPLOY.md](docs/DEPLOY.md). It is **read-only by default**; set
`TIDB_MCP_READ_ONLY=false` to allow writes.

```bash
docker build -t tidbcloud-mcp-remote .
docker run -p 3000:3000 -e TIDB_CLOUD_PUBLIC_KEY=... -e TIDB_CLOUD_PRIVATE_KEY=... tidbcloud-mcp-remote
# connect a client:
claude mcp add --transport http tidbcloud http://localhost:3000/mcp
```

**Protocol revisions.** `POST /mcp` answers both MCP generations on the same URL, and which one a
request gets is decided per request from what it sends:

| Revision                                                             | How a client reaches it                          |
| -------------------------------------------------------------------- | ------------------------------------------------ |
| `2026-07-28`                                                         | No handshake; a `_meta` envelope on each request |
| `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`, `2024-10-07` | The `initialize` handshake                       |

Existing clients need no change. The 2026-07-28 revision removed the `initialize` handshake, but a
client whose only opening move is `initialize` has no way to recover from a server that answers only
the new revision, so the older path stays and keeps its `application/json` responses exactly as
before. Both eras see the same tools and the same permission rules.

What is served is the request/response part of the revision. Server-push is not: responses are
single JSON bodies, never streams, so `subscriptions/listen` (the revision's only route for change
notifications) returns nothing to subscribe to, and mid-call progress or logging messages are
dropped. That is a deliberate fit for a stateless multi-tenant endpoint where each request is served
by a fresh instance that outlives nothing.

**Auth.** Single-tenant when the server has env keys (self-host). Multi-tenant when
`TIDB_MCP_REQUIRE_AUTH=true` or no env keys are set: each request carries a management key
pair via HTTP Basic (`Authorization: Basic base64(public:private)`), a Bearer
(`Bearer public:private`), or `X-TiDBCloud-Public-Key` / `X-TiDBCloud-Private-Key` headers.
New keys are validated by one cheap IAM call; each key gets an isolated credential context.

**Scope narrowing** via URL params (narrow-only, never expand): `?readonly=true`,
`?features=management`, `?cluster=<id>`.

**Hardening.** `TIDB_MCP_ALLOWED_ORIGINS=a,b` enforces an Origin allowlist (403 on
mismatch, never `*` with credentials); rate limiting via `TIDB_MCP_RATE_MAX` (default 120)
per `TIDB_MCP_RATE_WINDOW_MS` (default 60000).

**OAuth 2.1 (built, dormant).** v1 auth is per-user API keys; this OAuth resource-server mode
is fully implemented but off by default and not the recommended path yet (see
[SECURITY.md](SECURITY.md)). Set `TIDB_MCP_OAUTH_ISSUER`,
`TIDB_MCP_OAUTH_RESOURCE` (this server's canonical URL), and `TIDB_MCP_OAUTH_JWKS_URI` to
require OAuth Bearer tokens instead of an API key. The server acts as a resource server: it
serves
`/.well-known/oauth-protected-resource` (RFC 9728), returns `401` with a discovery
`WWW-Authenticate` header, and verifies each JWT against the AS JWKS with issuer and
**audience** validation (RFC 8707). Token scopes narrow the tool set by permission group:
`tidb:read` is read-only, `tidb:write` adds DML/DDL (`data`) but not cluster admin, and
`tidb:admin` adds the cluster/branch/SQL-user mutators. So a `tidb:write` token can run a
migration but cannot delete a cluster. In this mode the server uses its own env TiDB key
(single-tenant, OAuth-gated).

## Repository layout

```
packages/
  core/     @tidbcloud-mcp/core     host-agnostic: API client, tools, auth, safety
  stdio/    @tidbcloud-mcp/server   npx entry, stdio transport
  remote/   @tidbcloud-mcp/remote   Hono app; Node adapter (streamable HTTP)
```

## Credentials & trust

You supply a TiDB Cloud **management API key** (public/private pair). Data operations
use short-lived SQL users the server mints and cleans up automatically, or a SQL user
you bring yourself. Credentials live only in memory and are never logged. See
[SECURITY.md](SECURITY.md) for the full model and [docs/DEPLOY.md](docs/DEPLOY.md) to host it.

**Recommended:** run locally (`stdio`) or self-host the remote endpoint, so keys stay on
your own machine or your own host.

## Related projects

This project builds on and credits prior TiDB MCP work; see [NOTICE](NOTICE) and
[MIGRATING.md](MIGRATING.md). Honest comparison:

|                                      | this project | [pingcap/pytidb](https://github.com/pingcap/pytidb) | [likidu](https://github.com/likidu/mcp-server-tidbcloud) | [c4pt0r](https://github.com/c4pt0r/mcp-server-tidb) |
| ------------------------------------ | ------------ | --------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------- |
| Language                             | TypeScript   | Python                                              | TypeScript                                               | Python                                              |
| Data tools                           | yes          | yes (~7)                                            | yes                                                      | yes                                                 |
| Management tools (clusters/branches) | yes          | no                                                  | Serverless only                                          | no                                                  |
| Ephemeral least-privilege SQL users  | yes          | no                                                  | no                                                       | no                                                  |
| Remote endpoint (streamable HTTP)    | yes          | no                                                  | yes                                                      | no                                                  |
| Auth: API key + OAuth 2.1            | yes          | n/a                                                 | raw key header                                           | n/a                                                 |
| Preflight/confirm on destructive ops | yes          | no                                                  | no                                                       | no                                                  |
| Docs search + page reader            | yes          | no                                                  | no                                                       | no                                                  |
| Tests / CI                           | yes          | some                                                | none                                                     | none                                                |
| License                              | Apache-2.0   | Apache-2.0                                          | MIT (no LICENSE file)                                    | none                                                |
| Maintained                           | yes          | yes                                                 | slowing                                                  | abandoned                                           |

Not an official PingCAP product (yet). See the comparison as a statement of scope, not a
claim that the others are bad; they are the shoulders this stands on.

## License

[Apache-2.0](LICENSE).
