Skip to main content
Glama
ivantacca

anytype-mcp-remote-gateway

by ivantacca
README.md
# anytype-mcp-remote-gateway

An HTTP gateway that exposes the official [Anytype MCP server](https://github.com/anyproto/anytype-mcp)
— which only speaks **stdio**, so it only works with local clients like Claude Desktop — as a remote
**Streamable HTTP** endpoint, so it can be used as a custom connector from Claude mobile/web.

This is **not** a reimplementation of the Anytype MCP server. It runs the official
`@anyproto/anytype-mcp` package unmodified and bridges it to HTTP.

## What this is / isn't (current status)

This repo implements **Phases 0-4** of the project plan: local setup, proof of concept, a working
stdio→HTTP bridge with bearer-token auth, public exposure via Tailscale Funnel (no open inbound port,
automatic TLS), per-IP rate limiting, and a Docker/docker-compose deployment. **Phase 5** (Claude
connector registration) is automated/verified as far as this codebase can take it — see "Registering as a
connector in Claude" below and `npm run verify:remote` — but actually adding the connector is a manual
step only you can do inside your own Claude account. See [ROADMAP.md](./ROADMAP.md) for what's still ahead
(token rotation, monitoring).

## Architecture

```
Claude (mobile/web)
  │  HTTPS (Tailscale-issued TLS cert) + Authorization: Bearer <GATEWAY_AUTH_TOKEN>
  ▼
https://<device>.<tailnet>.ts.net  (Tailscale Funnel — public internet, no VPN/tailnet
  │                                 membership required on Claude's side, see below)
  ▼
gateway.ts (public Express app, this repo)
  │  trust proxy, request logging, per-IP rate limiting, bearer-token auth, reverse proxy
  │  http://127.0.0.1:<MCP_INTERNAL_PORT>
  ▼
mcp-proxy (spawned child process, loopback only, no auth of its own)
  │  spawns and bridges stdio ⇄ Streamable HTTP (/mcp) + legacy SSE (/sse)
  ▼
npx @anyproto/anytype-mcp (stdio, spawned by mcp-proxy)
  │  OPENAPI_MCP_HEADERS (Authorization + Anytype-Version)
  │  ANYTYPE_API_BASE_URL=http://127.0.0.1:31012 (or http://anytype-headless:31012 in Docker)
  ▼
anytype-cli headless (`anytype serve`), bot account
```

Tailscale has two related features — **Serve** (reachable only by devices on your own
tailnet, i.e. requires the VPN) and **Funnel** (punches a Serve config through to the
public internet at a plain HTTPS URL, no Tailscale client needed on the caller's side).
This project uses **Funnel**: only the *host* running the gateway needs to be on Tailscale
— Claude itself just calls a normal HTTPS URL, protected by `GATEWAY_AUTH_TOKEN` exactly as
it would be behind any other reverse proxy.

Two HTTP servers are involved on purpose:

- The **public** one (`gateway.ts`) is the only thing meant to ever be reachable from outside the host.
  It owns the bearer-token check and structured logging.
- The **internal** one (spawned `mcp-proxy`) is bound to `127.0.0.1` only and has no auth of its own —
  it trusts anything that can reach it, which by design is only the gateway process on the same host.
  **Never bind `MCP_INTERNAL_PORT` to a public interface.**

### Implementation note: why mcp-proxy is spawned as a CLI, not wired as a library

The original plan considered importing `mcp-proxy`'s `startHTTPServer`/`proxyServer` helpers directly and
hand-wiring an MCP SDK `Client`/`Server` pair around the spawned `anytype-mcp` process. During
implementation this turned out to be unsafe: `mcp-proxy@6.7.1` depends on the newer split
`@modelcontextprotocol/client`/`@modelcontextprotocol/server@^2.0.0` packages, while
`@anyproto/anytype-mcp@1.2.10` depends on the older unified `@modelcontextprotocol/sdk@1.30.0`. Mixing
SDK instances from two different package families is fragile. Instead, [`src/mcpProxyServer.ts`](./src/mcpProxyServer.ts)
spawns mcp-proxy's own CLI binary (`node_modules/mcp-proxy/dist/bin/mcp-proxy.mjs`), which internally
spawns `npx -y @anyproto/anytype-mcp` and bridges it using its own internally-consistent SDK pairing.

## Prerequisites

- Node.js ≥ 20 (tested with v23.10.0)
- [`anytype-cli`](https://github.com/anyproto/anytype-cli) (installed via its official install script)
- An Anytype **bot account** (separate from any personal/Desktop Anytype account — see below)

## Setup: Anytype backend (anytype-cli headless)

This gateway talks to a **headless** Anytype instance via a dedicated **bot account**, not to Anytype
Desktop and not to any personal account. See "Data isolation" below for why this matters.

```bash
# 1. Install anytype-cli (no sudo required, installs to ~/.local/bin)
/usr/bin/env bash -c "$(curl -fsSL https://raw.githubusercontent.com/anyproto/anytype-cli/HEAD/install.sh)"
export PATH="$HOME/.local/bin:$PATH"   # add to your shell rc if not already present

# 2. Start the headless server (foreground; keep this running in its own terminal/tmux pane)
anytype serve   # binds 127.0.0.1:31010-31012

# 3. In a second terminal: create a bot account (one-time)
anytype auth create anytype-mcp-gateway-bot
# ⚠ save the printed account key somewhere safe — it's also saved to your OS keychain

# 4. Generate an API key for this gateway to use
anytype auth apikey create "mcp-gateway"
# copy the printed key into .env as ANYTYPE_API_KEY (next section)
```

Verify the API is reachable and see what's in the bot account's space (should be new/empty):

```bash
node -e "
require('dotenv/config');
const http = require('http');
http.get({
  host: '127.0.0.1', port: 31012, path: '/v1/spaces',
  headers: { Authorization: 'Bearer ' + process.env.ANYTYPE_API_KEY, 'Anytype-Version': process.env.ANYTYPE_API_VERSION },
}, res => { let d=''; res.on('data', c => d+=c); res.on('end', () => console.log(res.statusCode, d)); });
"
```

Expect `200` and a single space with no name — the bot account's own auto-created default space.

## Setup: the gateway

```bash
npm install
cp .env.example .env
# edit .env: generate GATEWAY_AUTH_TOKEN with `openssl rand -hex 32`,
# fill in ANYTYPE_API_KEY from the step above.
npm run dev
```

You should see, in order: `internal mcp-proxy listening on 127.0.0.1:8788`, then
`gateway listening` on `127.0.0.1:8787` (or whatever `GATEWAY_PORT`/`GATEWAY_HOST` you set).

## Testing locally with MCP Inspector

```bash
curl -i http://localhost:8787/healthz    # 200, no token needed
curl -i http://localhost:8787/mcp        # 401, no token supplied

npm run inspect
```

In the Inspector UI: connect to `http://localhost:8787/mcp` with transport **Streamable HTTP**, and add
header `Authorization: Bearer <your GATEWAY_AUTH_TOKEN>`. Confirm `tools/list` returns the Anytype tool
set and a read-only call (e.g. listing spaces) succeeds.

## Public exposure (Tailscale Funnel) — bare metal

Prerequisites, one-time, in the [Tailscale admin console](https://login.tailscale.com/admin):

1. [Enable HTTPS certificates](https://tailscale.com/kb/1153/enabling-https) for your tailnet
   (DNS → HTTPS Certificates → Enable).
2. Make sure this node is allowed to run Funnel — either it's covered by the default ACL, or add a
   `nodeAttrs` grant for it under Access Controls (see [Tailscale's Funnel docs](https://tailscale.com/kb/1223/funnel)).
3. Install and authenticate Tailscale on the host running the gateway: `tailscale up`.

Then, with the gateway already running (`npm run dev` / `npm start`):

```bash
tailscale funnel --bg 8787
```

Verify from any device **not** on your tailnet (e.g. your phone on cellular data):

```bash
curl -i https://<device>.<tailnet>.ts.net/healthz    # 200, no token, no VPN needed
```

`tailscale funnel status` shows the current mapping; `tailscale funnel --https=443 off` tears it down.
The gateway's own `GATEWAY_AUTH_TOKEN` check is what protects everything past `/healthz` — Funnel only
gets you a public HTTPS URL, it does not add auth of its own.

## Docker deployment

An alternative to the bare-metal setup above: `docker-compose.yml` runs four services — `netns` (a
trivial, inert container that exists only to own a stable network namespace, see the comment at the top
of the compose file for why it's not just `tailscale`), `tailscale` (the Funnel sidecar, the only public
ingress — see [`deploy/tailscale/README.md`](./deploy/tailscale/README.md) for how the ingress wiring
works), `anytype-headless` (the official
[`anyproto/anytype-cli`](https://hub.docker.com/r/anyproto/anytype-cli) image), and `mcp-gateway` (built
from [`Dockerfile`](./Dockerfile)). All four share `netns`'s network namespace and talk to each other over
`127.0.0.1`, same as the bare-metal setup — no bridge-network container-DNS lookups involved (this is
deliberate, not incidental — see the compose file's comment).

`docker-compose.yml` uses `${TS_AUTHKEY}`/`${TS_HOSTNAME}` substitution, which Compose only reads from a
file literally named `.env` by default — pass `--env-file .env.docker` explicitly on every `docker
compose` invocation below (or alias `dc='docker compose --env-file .env.docker'`), otherwise those
variables resolve empty.

```bash
cp .env.docker.example .env.docker
# edit .env.docker: GATEWAY_AUTH_TOKEN, TS_AUTHKEY (Tailscale admin console → Settings → Keys) —
# same HTTPS-certs/Funnel prerequisites as the bare-metal section above apply to this node too.

docker compose --env-file .env.docker up -d anytype-headless
# one-time: create the bot account and an API key for this gateway (see "Data isolation" below
# for why this is a separate bot account, not your personal Anytype account). Note: anytype-cli's
# REST API (port 31012) only starts listening once a bot account is logged in -- so this step
# isn't just bookkeeping, mcp-gateway genuinely can't reach it before this runs.
docker compose exec anytype-headless anytype auth create anytype-mcp-gateway-bot
docker compose exec anytype-headless anytype auth apikey create "mcp-gateway"
# copy the printed key into .env.docker as ANYTYPE_API_KEY

docker compose --env-file .env.docker up -d --build
docker compose logs -f mcp-gateway   # confirm "gateway listening"
curl -i https://<device>.<tailnet>.ts.net/healthz
```

No port is published to the host — `tailscale` is the only ingress, reaching the rest of the stack over
the namespace they all share.

This whole flow (bot account creation, API key generation, gateway startup, an authenticated `initialize`
call reaching the real Anytype API through the containerized gateway) was verified end-to-end against a
disposable test bot account while writing this — including deliberately restarting the `tailscale`
container mid-run to confirm it doesn't strand the other services.

## Bare-metal supervision (no Docker)

For an always-on non-Docker host (Mac mini, Raspberry Pi, VPS), the gateway itself has no built-in
restart-on-crash logic (Phases 0-2 intentionally fail loud and exit — see
[`src/mcpProxyServer.ts`](./src/mcpProxyServer.ts)), so it needs a process supervisor. A sample systemd
unit is at [`deploy/systemd/anytype-mcp-gateway.service.example`](./deploy/systemd/anytype-mcp-gateway.service.example)
— copy it to `/etc/systemd/system/`, edit the `User`/`WorkingDirectory` paths, then
`systemctl enable --now anytype-mcp-gateway`. It only supervises the gateway process; `anytype serve`
needs its own supervision (`anytype service install && anytype service start`, see
[anytype-cli](https://github.com/anyproto/anytype-cli)).

## Registering as a connector in Claude

Once the gateway is reachable at its public Funnel URL (see above), point Claude at it as a
**custom connector**. Before touching Claude's UI, run the pre-flight check:

```bash
npm run verify:remote -- https://<device>.<tailnet>.ts.net
```

This exercises the exact path Claude will use — HTTPS, Funnel, rate limiting, the full OAuth shim
(discovery → dynamic client registration → consent → PKCE token exchange → a real MCP call authenticated
with the OAuth-issued token, not the static one), the MCP `initialize`/`tools/list` handshake, a read call
(list spaces), and a full create-then-delete round trip on a clearly-tagged test object
(`[gateway-verify] <timestamp>`) in the bot's own space — and prints a pass/fail line per step. Fix
anything it reports before wiring the connector into Claude; a broken gateway is much easier to debug from
this script's output than from Claude's connector UI.

**Claude web (claude.ai):** Settings → Connectors → Add custom connector. Enter:

- **Name**: anything (e.g. `Anytype Gateway`)
- **URL**: `https://<device>.<tailnet>.ts.net/mcp`

Leave the "Advanced settings" OAuth Client ID/Secret fields **empty** and click Add. On most accounts this
is currently the only auth option claude.ai's custom-connector dialog exposes — there's a separate,
beta-gated "Request headers" section for a static `Authorization: Bearer <token>` header, but it's not
rolled out to every account yet. That's exactly why this gateway also speaks OAuth (see
[`src/oauth.ts`](./src/oauth.ts)): when Claude tries to connect with just the URL, it discovers the
gateway's own `/authorize` and `/token` endpoints automatically and walks you through a one-time consent
page, hosted by the gateway itself, asking for `GATEWAY_AUTH_TOKEN` — that's the actual credential check;
OAuth here is just the transport claude.ai understands, not a second, separate secret to manage. Claude
then holds an OAuth access/refresh token pair and silently refreshes it going forward, no repeat prompts.

If your account *does* have the "Request headers" beta, you can use that instead: header name
`authorization`, value `Bearer <GATEWAY_AUTH_TOKEN>` — functionally equivalent, skips the consent-page
step. Either path is accepted by the gateway; `src/auth.ts`'s `bearerAuth` checks the static token first,
then falls back to validating an OAuth-issued token.

claude.ai's exact field labels/beta availability can change over time — if what you see in the UI doesn't
match this description, follow what's actually on screen.

**Claude mobile:** connectors are tied to your claude.ai account, so a connector added on web should also
be usable from the mobile app — but whether mobile currently lets you *add* a custom connector directly
(vs. only use ones added on web) is worth checking directly in the app, since this is exactly the kind of
detail that changes between app versions. Don't take this README's word for it — verify in-app.

**First real test, once connected:** ask Claude to list your Anytype spaces, then to create a small test
note. This exercises the same read/write path `verify:remote` already proved automatically, but through
Claude itself end-to-end.

**Scope note:** the bot account currently only has access to its own empty space (see "Data isolation"
above) — Claude won't be able to see or touch your real Anytype notes until you deliberately invite the
bot into a real space. That's intentionally a separate, manual step, not something this gateway or its
tooling does on its own.

**Recommended follow-up — lock down the redirect host:** by default `/register` accepts any
`redirect_uri` host (see "Security notes"). After connecting once, check the gateway logs for a line like
`oauth: registered dynamic client ... hosts: [...]` — that's the real host Claude just registered. Set
`OAUTH_ALLOWED_REDIRECT_HOSTS` to that value in `.env`/`.env.docker` and restart; you'll see one more
consent prompt afterward (expected — the restart clears the previous registration), and from then on
`/register` rejects anything else outright.

## Environment variables

| Variable | Purpose |
|---|---|
| `GATEWAY_PORT` | Port the public Express app listens on (default `8787`) |
| `GATEWAY_HOST` | Host/interface the public app binds to (default `127.0.0.1` — loopback is all Tailscale Funnel needs, bare metal or via the docker-compose sidecar's shared netns) |
| `GATEWAY_AUTH_TOKEN` | Bearer token required on every request except `/healthz`. Generate with `openssl rand -hex 32`. |
| `RATE_LIMIT_WINDOW_MS` | Rate-limit window in milliseconds, per IP (default `300000`, 5 min) |
| `RATE_LIMIT_MAX` | Max requests per IP per window before a `429` (default `300`) |
| `MCP_INTERNAL_PORT` | Port the internal mcp-proxy process listens on, loopback-only (default `8788`) |
| `ANYTYPE_API_BASE_URL` | Local Anytype API base URL — `http://127.0.0.1:31012` in both bare metal and Docker (all four docker-compose services share one network namespace, see "Docker deployment") |
| `ANYTYPE_API_KEY` | API key for the bot account, from `anytype auth apikey create` |
| `ANYTYPE_API_VERSION` | `Anytype-Version` header value expected by the installed `@anyproto/anytype-mcp` |
| `ANYTYPE_TEST_SPACE_ID` | Not read by the gateway — only by `verify-remote.mjs`, to pin its write+cleanup test to the bot's disposable sandbox space. See "Data isolation". |
| `LOG_LEVEL` | pino log level (default `info`) |
| `OAUTH_REFRESH_TOKEN_TTL_HOURS` | How long an OAuth refresh token stays usable (default `168` = 7 days). Always on — see "Security notes". |
| `GATEWAY_RESTART_INTERVAL_HOURS` | Optional scheduled self-restart, `0` disables (default). See "Security notes" — only enable with a verified process supervisor in place. |
| `OAUTH_ALLOWED_REDIRECT_HOSTS` | Comma-separated hostnames `/register` may accept, e.g. `claude.ai,claude.com`. Unset (default) accepts any host. See "Security notes". |

Docker-only, set in `.env.docker` (see [`.env.docker.example`](./.env.docker.example)):

| Variable | Purpose |
|---|---|
| `TS_AUTHKEY` | Tailscale pre-auth key for the `tailscale` sidecar service (admin console → Settings → Keys) |
| `TS_HOSTNAME` | This node's name on the tailnet, and thus part of its public Funnel URL (default `anytype-mcp-gateway`) |

## Data isolation (why this doesn't touch your real Anytype notes by default)

`anytype-cli`'s `auth create` command creates a brand-new **bot account**, with its own identity, entirely
separate from any personal Anytype Desktop account and its cloud-synced data. A fresh bot account starts
with a single empty space (0 objects) and has no access to any other space unless explicitly invited as
a collaborator — which nothing in this project does automatically. This was verified empirically against
a real bot account during setup (`GET /v1/spaces` → one space, `GET /v1/spaces/{id}/objects` → 0 objects).

**`anytype-cli` has no concept of "log in as your personal account"** — `auth create`/`auth login` only
create/authenticate bot identities; there's no command that imports a personal recovery phrase. This is
unlike the official local MCP server, which runs on top of an already-logged-in Anytype Desktop app and
so uses your real identity directly — that model doesn't translate to a headless, remotely-reachable
deployment. The only supported way to have this gateway operate on your real notes is to explicitly
**invite the bot account into a real space**, the same way you'd invite any other collaborator:

1. In Anytype Desktop, open the real space → Settings → Members → Invite, and generate an invite link
   (Writer role recommended — no reason to grant the bot Owner).
2. `docker compose --env-file .env.docker exec anytype-headless anytype space join "<invite-link>"`
   (bare metal: drop the `docker compose ... exec anytype-headless` prefix).

The bot account keeps its own auto-created sandbox space either way — joining a real space just adds a
second one. **Once the bot is a member of more than one space, `scripts/verify-remote.mjs`'s destructive
write+cleanup test will no longer guess which space is safe to write test objects into** — it requires
`ANYTYPE_TEST_SPACE_ID` (see the environment variables table) pinned to the sandbox space's id, and
otherwise skips that check rather than risk running it against a real space. Capture that id with
`anytype space list` right after `auth create`, before doing any of the above.

## Security notes

- `GATEWAY_AUTH_TOKEN` and `ANYTYPE_API_KEY` are read from `.env`/`.env.docker` (both gitignored) and are
  never logged — see the redaction list in [`src/logger.ts`](./src/logger.ts).
- The internal mcp-proxy server has no authentication of its own; its only protection is that it's bound
  to `127.0.0.1`. Don't change `MCP_INTERNAL_PORT`'s bind address.
- No compression/buffering middleware sits in front of `/mcp` or `/sse` — it would break streaming
  responses.
- `GATEWAY_HOST` defaults to `127.0.0.1`: nothing should ever need it on a public interface — Tailscale
  Funnel (bare metal) or the sidecar's shared network namespace (Docker) both reach the gateway over
  loopback. `GATEWAY_AUTH_TOKEN` is the only thing standing between the internet and this gateway once
  Funnel is on, so treat it like a credential (rotate it if it ever leaks, don't paste it into logs/issues).
- Per-IP rate limiting (`RATE_LIMIT_WINDOW_MS`/`RATE_LIMIT_MAX`, default 300 req/5 min) runs before the
  bearer-token check, so it also throttles brute-force attempts against the token itself; throttled
  requests are logged at `warn` with the client IP.
- Before relying on this for real, ongoing use: re-check Anytype's terms of service / acceptable-use
  policy for always-on programmatic API access — not something this repo can verify on your behalf.
- **The OAuth shim ([`src/oauth.ts`](./src/oauth.ts)) is single-user by design, not a general-purpose
  authorization server.** There's exactly one credential behind it — `GATEWAY_AUTH_TOKEN`, required once
  as the consent-page secret — and no user database or per-client scoping. Registered clients, issued
  authorization codes, and access/refresh tokens all live in memory and are lost on restart; Claude just
  silently redoes the flow (and prompts you for the token again) the next time it needs to. Anyone who can
  reach the `/authorize` consent page still can't get in without the token, so the security boundary is
  unchanged from the plain-bearer-token design — OAuth here is a transport Claude's UI understands, not an
  additional party being granted access. `/authorize` and `/token` sit behind the same per-IP rate limiter
  as everything else, throttling brute-force attempts against the consent form the same way it already
  throttled the bearer-token check.
- **`POST /register` (dynamic client registration) is unauthenticated by spec — RFC 7591 requires it to be
  callable before a client has any credentials — but registering a client grants no access.** It only
  stores a `client_id` and a list of `redirect_uris`; getting anywhere past that still requires
  `GATEWAY_AUTH_TOKEN` at the `/authorize` step. Two distinct things an unauthenticated caller can do here,
  both mitigated:
  - **Memory growth**: registering many clients costs the gateway a little memory each time. Capped
    (`MAX_REGISTERED_CLIENTS`, 100) and TTL'd (`CLIENT_TTL_MS`, 30 days) in `src/oauth.ts` — hitting either
    just makes Claude transparently re-register, no user-visible prompt.
  - **Consent-hijacking via an attacker-chosen `redirect_uri`**: nothing about RFC 7591 requires
    `redirect_uris` to point anywhere legitimate. Without a check, someone who can reach this gateway (the
    Funnel URL isn't public, but isn't a secret either) could register a client with *their own* server as
    the `redirect_uri`, then send you a crafted `/authorize` link. The consent page is real (correct
    domain, valid TLS), so if you didn't notice the destination and typed `GATEWAY_AUTH_TOKEN` anyway, the
    resulting authorization code — and the access token it becomes — would go to them, not Claude. Two
    mitigations, both in `src/oauth.ts`: the consent page always shows the exact `redirect_uri` (and
    `client_id`) before asking for the token, so there's a chance to notice something's wrong; and
    `OAUTH_ALLOWED_REDIRECT_HOSTS` (unset/permissive by default — see `.env.example` for how to determine
    and set it) makes `/register` reject any host that isn't explicitly allowed, closing the door before a
    malicious client can even be registered. Worth noting: the blast radius of a successful hijack is
    whatever spaces the bot account is a member of at the time — its own isolated sandbox space only by
    default, but real Anytype data too once you've invited it into a real space (see "Data isolation").
    That's a reason to take `OAUTH_ALLOWED_REDIRECT_HOSTS` more seriously after doing so, not a reason not
    to invite it.
- **OAuth session lifetime is bounded two ways, deliberately redundant (defense in depth):**
  1. **Always on:** `OAUTH_REFRESH_TOKEN_TTL_HOURS` (default `168` = 7 days) caps how long a refresh token
     is honored at all — rotating `GATEWAY_AUTH_TOKEN` does *not* revoke an already-issued refresh token
     (it's an independent secret with no link back to the master token's current value), so this TTL is
     what actually bounds a leaked token's exposure window by default, with zero configuration required.
  2. **Optional, off by default:** `GATEWAY_RESTART_INTERVAL_HOURS` — the gateway exits cleanly on a timer
     and relies on the process supervisor (`Restart=always` in the systemd unit,
     `restart: unless-stopped` already in `docker-compose.yml`) to bring it back up, wiping *all* OAuth
     state at once (not just expired tokens — also clears the client registry above). This is a coarser,
     additional layer on top of (1), not a replacement for it: it costs a brief availability gap on every
     restart, and **only works if a supervisor is actually restarting the process** — with none configured,
     turning this on quietly converts a security feature into an outage that never recovers. Verify that
     before enabling it. Trade-off either way: Claude has to redo the consent step (re-enter
     `GATEWAY_AUTH_TOKEN`) whenever a session ends, whether by TTL or by restart.

## Current limitations / what's not done yet

See [ROADMAP.md](./ROADMAP.md): the connector still needs to be added inside your own Claude account (see
"Registering as a connector in Claude" above — this repo can verify and document the path but can't click
the button for you), the bot account hasn't been invited into a real Anytype space, and there's no token
rotation procedure or uptime monitoring/alerting yet.

## Troubleshooting

- **`anytype-cli` not running** — `anytype-mcp` fails fast with `Can't connect to API. Please ensure
  Anytype is running and reachable.` Start `anytype serve` first and confirm `curl`/Node request to
  `127.0.0.1:31012/v1/spaces` returns `200` before starting the gateway.
- **Wrong `Anytype-Version` header** — check the installed `@anyproto/anytype-mcp` version's expected
  value; a mismatch can cause API calls to fail even though the connection succeeds.
- **`npx` cache issues (bare metal)** — `npx -y @anyproto/anytype-mcp` fetches from the npm registry on
  first run; if it hangs, check network access or pre-warm the npx cache with a manual
  `npx -y @anyproto/anytype-mcp` run (Ctrl-C once it prints "running on stdio"). The Docker image avoids
  this entirely by installing `@anyproto/anytype-mcp` globally at build time (see [`Dockerfile`](./Dockerfile)).
- **`docker compose exec anytype-headless anytype auth create ...` hangs or fails** — confirm the
  `anytype-headless` container is healthy first (`docker compose ps`); it needs a few seconds after
  `docker compose up -d` to start listening.
- **Funnel URL doesn't resolve / connection refused** — confirm HTTPS certs and the Funnel node attribute
  are enabled for this node in the Tailscale admin console (see "Public exposure" above); `tailscale
  funnel status` (bare metal) or `docker compose exec tailscale tailscale funnel status` (Docker) shows
  the current mapping.