# Deploying the remote endpoint

The remote server is a small Node/Hono service (a container, not Cloudflare Workers) that
speaks MCP over streamable HTTP at `POST /mcp`, with
`GET /health`, `GET /metrics`, and a landing page. **It is read-only by default.**

## AWS (the hosted endpoints)

`.github/workflows/deploy.yml` builds one image on every push to `main`. That push deploys
**staging only**. Reaching the public official endpoint or the partner POC takes a deliberate act:
run the workflow by hand (`Actions` → `Deploy` → `Run workflow`) and tick the box for that endpoint.
There are no AWS keys in this repository; the workflow assumes a role over OIDC. The infrastructure
itself lives in a separate Terraform repository.

**Why a checkbox rather than an approval.** The first design used GitHub Environments with required
reviewers, which is the better mechanism and is not available here: environment protection rules
need a paid plan on a private repository, so the `environment:` key was accepted, displayed, and
enforced nothing. Call the checkbox what it is. It does not involve a second person and it does not
catch a mistake by whoever ticked it. What it does catch is the failure that actually threatens a
public endpoint and a named partner, which is a merge to `main` quietly reaching them. If the
repository moves to a plan with environment rules, or goes public, switch back to required
reviewers and delete the inputs.

**Repository variables** (Settings, Variables, not Secrets). A role ARN and a hostname are
identifiers, not credentials, and storing them as secrets only makes the logs harder to read.

| Variable              | Value                                                       |
| --------------------- | ----------------------------------------------------------- |
| `AWS_DEPLOY_ROLE_ARN` | `terraform output deploy_role_arn`                          |
| `OFFICIAL_URL`        | `terraform output official_url`. Unset skips the smoke test |
| `POC_URL`             | `terraform output poc_url`. Unset skips the smoke test      |

**Environments**: none are used. See the note above on why.

**If the OIDC assume fails with "Not authorized to perform sts:AssumeRoleWithWebIdentity"**, the
trust policy is pinned to a subject the token does not carry. This organisation emits IMMUTABLE
subjects, which use numeric ids rather than names, so the claim is not the documented
`repo:owner/name:ref:refs/heads/main`. Do not guess it. Read it from a workflow run, since any job
with `id-token: write` can fetch its own token, and decode only the `sub` claim. Then set
`oidc_subject` in the infrastructure repository and apply. The error message names nothing useful,
which is why this paragraph exists.

**Service configuration is not here.** `TIDB_MCP_READ_ONLY`, secret ARNs, task size, and the port
are set by the Terraform stack, and the deploy script carries the live values forward rather than
rendering them from a file in this repository. That is deliberate: a task-definition file here
would silently win over a Terraform change. Adding an application setting is a change in the infra
repository, not this one.

**To roll back**, run the workflow (`workflow_dispatch`) against the earlier commit. Its image is
still in ECR under that commit's SHA and the tag is immutable, so it is exactly the artifact that
was tested. The push step notices the image already exists and skips straight to deploying it.

You will usually not need to: the ECS deployment circuit breaker rolls the two real services back
on its own when new tasks fail to start.

## Fly.io (~$2/month, scales to zero)

Currently hosts the partner POC. Being replaced by the AWS deployment above; the cutover swaps the
URL and the bearer token in the Workato connector in one change.

```bash
fly launch --no-deploy                                    # first time; uses fly.toml
fly secrets set TIDB_CLOUD_PUBLIC_KEY=... TIDB_CLOUD_PRIVATE_KEY=...
fly deploy
# your endpoint: https://<app>.fly.dev/mcp
```

Connect a client:

```bash
claude mcp add --transport http tidbcloud https://<app>.fly.dev/mcp
```

## Any container host

```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
```

## Local, exposed for ChatGPT / remote clients

ChatGPT connectors need a public HTTPS URL. For a quick test, tunnel the local server:

```bash
node packages/remote/dist/main.js            # listens on :3000
ngrok http 3000                              # gives https://<id>.ngrok.app
# add https://<id>.ngrok.app/mcp as an MCP connector
```

## Configuration (env vars)

| Variable                                            | Purpose                                                     |
| --------------------------------------------------- | ----------------------------------------------------------- |
| `TIDB_CLOUD_PUBLIC_KEY` / `TIDB_CLOUD_PRIVATE_KEY`  | Management key for single-tenant mode.                      |
| `TIDB_MCP_READ_ONLY`                                | `true` (default here) blocks writes. Set `false` to allow.  |
| `TIDB_MCP_REQUIRE_AUTH`                             | `true` = multi-tenant; each request brings its own key.     |
| `TIDB_MCP_ALLOWED_ORIGINS`                          | Comma list; enforces an Origin allowlist (403 on mismatch). |
| `TIDB_MCP_RATE_MAX` / `TIDB_MCP_RATE_WINDOW_MS`     | Legacy fixed window, used only when no limiter is wired.    |
| `TIDB_MCP_CLIENT_IP_HEADER`                         | **Set this.** Header the proxy SETS, e.g. `fly-client-ip`.  |
| `TIDB_MCP_TRUSTED_PROXY_DEPTH`                      | For `X-Forwarded-For`; use `1` behind a single ALB.         |
| `TIDB_MCP_GATEWAY_IPS`                              | Addresses fronting many tenants; larger proving budget.     |
| `CONFIRM_SECRET`                                    | **Required** for a writable remote; 32+ bytes. See below.   |
| `CONTROL_DSN`                                       | Control DB: confirm nonce ledger and quota counters.        |
| `TIDB_MCP_CONFIRM_TTL_MS`                           | Confirm token lifetime (default 600000, ten minutes).       |
| `TIDB_MCP_TENANT_RPS` / `_BURST` / `_DAILY_QUOTA`   | Per-tenant limits (default 5 / 20 / 5000).                  |
| `TIDB_MCP_OAUTH_ISSUER` / `_RESOURCE` / `_JWKS_URI` | Enable OAuth 2.1 (Bearer tokens).                           |
| `TIDB_MCP_BANNER`                                   | Landing-page banner (use for eval/demo endpoints).          |
| `TIDB_MCP_DISABLE_DATA_TOOLS`                       | Management-only mode.                                       |
| `TIDB_MCP_FS_REGION`                                | Default region for TiDB Filesystem hosts, e.g. `us-west-2`. |
| `TIDB_MCP_FS_PROVIDER`                              | Cloud provider half of the FS host. Defaults to `aws`.      |
| `PORT`                                              | Listen port (default 3000).                                 |

## TiDB Filesystem tools

The `fs_*` tools read files out of a caller's TiDB Filesystem workspace, so they count as data
access and `TIDB_MCP_DISABLE_DATA_TOOLS` drops them along with the SQL tools. They run no SQL, but
management-only mode is a promise about what an endpoint can read, not about which protocol it uses
to read it.

Everywhere else they are listed even when the request carries no FS token, and they do nothing until
a caller sends their own token in `x-tidbcloud-fs-token`. That is deliberate. Hiding them until a
token arrives is a deadlock, because an agent cannot learn it should send one from a tool surface
that omits the tools; without a token each answers with the header name and the scopes it needs.

The token is read per request and never stored, the same custody rule the management key follows. It
never becomes a tool argument, so it cannot land in tool-call telemetry. It joins the request's
redaction list, which covers tool results and telemetry, and the request's logger is wrapped with it
as well, because the shared logger is built once at startup and cannot know a per-request value.

A read-only FS token must carry `read,list,search`. `search` is its own op upstream, so a `read,list`
token gets a 403 on `fs_grep` specifically, and the error says so.

The read tools are on by default. `fs_write`, `fs_mkdir`, and `fs_remove` are mutating, so they need
a write grant like any other mutator here and are absent from a read-only endpoint entirely. Each
goes through the same preflight and confirmation flow as the SQL and cluster mutators: a bare call
returns a summary and does nothing. `fs_remove` is destructive, so it also requires `confirmTarget`
to name the exact path.

`fs_write` and `fs_remove` each spend a nonce, so one approval authorises exactly one write or one
deletion. FS offers no undo, which makes a replaced file as unrecoverable as a deleted one, and a
replayed write would silently revert an edit made inside the ten-minute window. `fs_mkdir` does not,
because repeating it changes nothing. A `fs_write` confirmation is bound to a digest of the exact
bytes it showed you, so a token issued for one body will not write a different body of the same size.

Writing needs the `write` scope and `fs_remove` needs `delete`, which upstream keeps separate from
`write`. Note that undoing a file this server created also needs `delete`, so a write-only token
cannot reverse its own work.

FS hosts are per region. `TIDB_MCP_FS_REGION` sets the default, and a tool may name its own region;
that argument is the only caller input that shapes the host, and it is validated to a bare host part
before it becomes one.

## Auth modes

- **Single-tenant** (env keys, no `TIDB_MCP_REQUIRE_AUTH`): the server uses its own key.
  Simplest for a self-hosted, single-team endpoint.
- **Multi-tenant** (`TIDB_MCP_REQUIRE_AUTH=true` or no env keys): each request supplies a
  key via HTTP Basic, `Bearer public:private`, or `X-TiDBCloud-*-Key` headers. Each key gets
  an isolated credential context; a new key is validated by one cheap IAM call.
- **OAuth 2.1** (`TIDB_MCP_OAUTH_*`): clients present a Bearer JWT from your authorization
  server. The server validates issuer + audience + scopes and narrows the tool set.

## Notes

- **Set `TIDB_MCP_ALLOWED_ORIGINS` on any hosted/internet-facing endpoint that browser
  clients will reach.** Without it, the server does not enforce an Origin allowlist and does
  not assert CORS credentials mode (safe for non-browser MCP clients, which send no Origin,
  but a hosted endpoint should still pin the allowlist). With it set, a mismatched Origin
  gets 403 and a matched one is echoed with credentials mode. Auth here is header-based, so
  this is defense-in-depth, not the primary gate.
- A public demo/eval endpoint should carry a banner telling evaluators to self-host for
  real keys (`TIDB_MCP_BANNER`).

## Set the client address source, or the rate limit measures the wrong thing

The pre-auth rate limit is keyed by client address, so the server has to know where the real address
comes from. It never guesses: with nothing configured it ignores `X-Forwarded-For` entirely and uses
the connection address, and it logs a warning at startup saying so.

Behind a proxy the connection address is the proxy, which puts every unproven caller in one bucket.
Nothing is forgeable in that state, but nothing works properly either: after a deploy your tenants
reprove at roughly one per second and are refused until they do, which scales linearly with tenant
count.

Pick the one that matches the deployment:

- **Fly:** `TIDB_MCP_CLIENT_IP_HEADER = "fly-client-ip"`. Already set in `fly.toml`.
- **AWS behind one ALB:** `TIDB_MCP_TRUSTED_PROXY_DEPTH = "1"`. An ALB appends to
  `X-Forwarded-For`, so the trustworthy entry is the last one, not the first.
- **Anything else:** count the proxies between the internet and the container and use that depth,
  or find a header that layer sets rather than appends.

Getting the depth wrong reads an entry the caller wrote, which is the failure this setting exists to
prevent, so prefer a set-not-appended header when one is available.

## A writable remote will not start without CONFIRM_SECRET

Confirmation tokens are signed with `CONFIRM_SECRET`. If it is unset, the server would sign with a
key invented at startup, and every outstanding approval would become invalid on the next restart
with no visible symptom. So a remote server with write or admin enabled refuses to start rather than
degrade quietly.

A read-only remote starts without it, because it exposes no mutating tools and therefore has no
confirm flow to protect. That is the public hosted deployment, which is why it is not in `fly.toml`.

Local `stdio` generates an ephemeral secret, since a restart there ends the session anyway.

Generate one with:

```bash
openssl rand -hex 32
```
