anytype-mcp-remote-gateway
An HTTP gateway that exposes the official Anytype MCP server— which only speaks stdio, so it only works with local clients like Claude Desktop — as a remoteStreamable 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 workingstdio→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 (Claudeconnector registration) is automated/verified as far as this codebase can take it — see "Registering as aconnector in Claude" below and npm run verify:remote — but actually adding the connector is a manualstep only you can do inside your own Claude account. See 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 owntailnet, i.e. requires the VPN) and Funnel (punches a Serve config through to thepublic 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 asit 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 to127.0.0.1only 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 bindMCP_INTERNAL_PORTto 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 andhand-wiring an MCP SDK Client/Server pair around the spawned anytype-mcp process. Duringimplementation this turned out to be unsafe: [email protected] depends on the newer split@modelcontextprotocol/client/@modelcontextprotocol/server@^2.0.0 packages, while@anyproto/[email protected] depends on the older unified @modelcontextprotocol/[email protected]. MixingSDK instances from two different package families is fragile. Instead, src/mcpProxyServer.tsspawns mcp-proxy's own CLI binary (node_modules/mcp-proxy/dist/bin/mcp-proxy.mjs), which internallyspawns 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(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 AnytypeDesktop and not to any personal account. See "Data isolation" below for why this matters.
# 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):
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
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, thengateway listening on 127.0.0.1:8787 (or whatever GATEWAY_PORT/GATEWAY_HOST you set).
Testing locally with MCP Inspector
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 addheader Authorization: Bearer <your GATEWAY_AUTH_TOKEN>. Confirm tools/list returns the Anytype toolset and a read-only call (e.g. listing spaces) succeeds.
Public exposure (Tailscale Funnel) — bare metal
Prerequisites, one-time, in the Tailscale admin console:
- Enable HTTPS certificates for your tailnet(DNS → HTTPS Certificates → Enable).
- Make sure this node is allowed to run Funnel — either it's covered by the default ACL, or add a
nodeAttrsgrant for it under Access Controls (see Tailscale's Funnel docs). - Install and authenticate Tailscale on the host running the gateway:
tailscale up.
Then, with the gateway already running (npm run dev / npm start):
tailscale funnel --bg 8787
Verify from any device not on your tailnet (e.g. your phone on cellular data):
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 onlygets 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 (atrivial, inert container that exists only to own a stable network namespace, see the comment at the topof the compose file for why it's not just tailscale), tailscale (the Funnel sidecar, the only publicingress — see deploy/tailscale/README.md for how the ingress wiringworks), anytype-headless (the officialanyproto/anytype-cli image), and mcp-gateway (builtfrom Dockerfile). All four share netns's network namespace and talk to each other over127.0.0.1, same as the bare-metal setup — no bridge-network container-DNS lookups involved (this isdeliberate, not incidental — see the compose file's comment).
docker-compose.yml uses ${TS_AUTHKEY}/${TS_HOSTNAME} substitution, which Compose only reads from afile 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 thosevariables resolve empty.
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 overthe namespace they all share.
This whole flow (bot account creation, API key generation, gateway startup, an authenticated initializecall reaching the real Anytype API through the containerized gateway) was verified end-to-end against adisposable test bot account while writing this — including deliberately restarting the tailscalecontainer 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-inrestart-on-crash logic (Phases 0-2 intentionally fail loud and exit — seesrc/mcpProxyServer.ts), so it needs a process supervisor. A sample systemdunit is at deploy/systemd/anytype-mcp-gateway.service.example— copy it to /etc/systemd/system/, edit the User/WorkingDirectory paths, thensystemctl enable --now anytype-mcp-gateway. It only supervises the gateway process; anytype serveneeds its own supervision (anytype service install && anytype service start, seeanytype-cli).
Registering as a connector in Claude
Once the gateway is reachable at its public Funnel URL (see above), point Claude at it as acustom connector. Before touching Claude's UI, run the pre-flight check:
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 authenticatedwith 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. Fixanything it reports before wiring the connector into Claude; a broken gateway is much easier to debug fromthis 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 thisis 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 notrolled out to every account yet. That's exactly why this gateway also speaks OAuth (seesrc/oauth.ts): when Claude tries to connect with just the URL, it discovers thegateway's own /authorize and /token endpoints automatically and walks you through a one-time consentpage, 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. Claudethen 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 nameauthorization, value Bearer <GATEWAY_AUTH_TOKEN> — functionally equivalent, skips the consent-pagestep. 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'tmatch 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 alsobe 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 ofdetail 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 testnote. This exercises the same read/write path verify:remote already proved automatically, but throughClaude 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 thebot into a real space. That's intentionally a separate, manual step, not something this gateway or itstooling does on its own.
Recommended follow-up — lock down the redirect host: by default /register accepts anyredirect_uri host (see "Security notes"). After connecting once, check the gateway logs for a line likeoauth: registered dynamic client ... hosts: [...] — that's the real host Claude just registered. SetOAUTH_ALLOWED_REDIRECT_HOSTS to that value in .env/.env.docker and restart; you'll see one moreconsent 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):
| 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, entirelyseparate from any personal Anytype Desktop account and its cloud-synced data. A fresh bot account startswith a single empty space (0 objects) and has no access to any other space unless explicitly invited asa collaborator — which nothing in this project does automatically. This was verified empirically againsta 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 onlycreate/authenticate bot identities; there's no command that imports a personal recovery phrase. This isunlike the official local MCP server, which runs on top of an already-logged-in Anytype Desktop app andso uses your real identity directly — that model doesn't translate to a headless, remotely-reachabledeployment. The only supported way to have this gateway operate on your real notes is to explicitlyinvite the bot account into a real space, the same way you'd invite any other collaborator:
- 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).
docker compose --env-file .env.docker exec anytype-headless anytype space join "<invite-link>"(bare metal: drop thedocker compose ... exec anytype-headlessprefix).
The bot account keeps its own auto-created sandbox space either way — joining a real space just adds asecond one. Once the bot is a member of more than one space, scripts/verify-remote.mjs's destructivewrite+cleanup test will no longer guess which space is safe to write test objects into — it requiresANYTYPE_TEST_SPACE_ID (see the environment variables table) pinned to the sandbox space's id, andotherwise skips that check rather than risk running it against a real space. Capture that id withanytype space list right after auth create, before doing any of the above.
Security notes
GATEWAY_AUTH_TOKENandANYTYPE_API_KEYare read from.env/.env.docker(both gitignored) and arenever logged — see the redaction list insrc/logger.ts.- The internal mcp-proxy server has no authentication of its own; its only protection is that it's boundto
127.0.0.1. Don't changeMCP_INTERNAL_PORT's bind address. - No compression/buffering middleware sits in front of
/mcpor/sse— it would break streamingresponses. GATEWAY_HOSTdefaults to127.0.0.1: nothing should ever need it on a public interface — TailscaleFunnel (bare metal) or the sidecar's shared network namespace (Docker) both reach the gateway overloopback.GATEWAY_AUTH_TOKENis the only thing standing between the internet and this gateway onceFunnel 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 thebearer-token check, so it also throttles brute-force attempts against the token itself; throttledrequests are logged atwarnwith the client IP. - Before relying on this for real, ongoing use: re-check Anytype's terms of service / acceptable-usepolicy for always-on programmatic API access — not something this repo can verify on your behalf.
- The OAuth shim (
src/oauth.ts) is single-user by design, not a general-purposeauthorization server. There's exactly one credential behind it —GATEWAY_AUTH_TOKEN, required onceas the consent-page secret — and no user database or per-client scoping. Registered clients, issuedauthorization codes, and access/refresh tokens all live in memory and are lost on restart; Claude justsilently redoes the flow (and prompts you for the token again) the next time it needs to. Anyone who canreach the/authorizeconsent page still can't get in without the token, so the security boundary isunchanged from the plain-bearer-token design — OAuth here is a transport Claude's UI understands, not anadditional party being granted access./authorizeand/tokensit behind the same per-IP rate limiteras everything else, throttling brute-force attempts against the consent form the same way it alreadythrottled the bearer-token check. POST /register(dynamic client registration) is unauthenticated by spec — RFC 7591 requires it to becallable before a client has any credentials — but registering a client grants no access. It onlystores aclient_idand a list ofredirect_uris; getting anywhere past that still requiresGATEWAY_AUTH_TOKENat the/authorizestep. 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) insrc/oauth.ts— hitting eitherjust makes Claude transparently re-register, no user-visible prompt. - Consent-hijacking via an attacker-chosen
redirect_uri: nothing about RFC 7591 requiresredirect_uristo point anywhere legitimate. Without a check, someone who can reach this gateway (theFunnel URL isn't public, but isn't a secret either) could register a client with their own server astheredirect_uri, then send you a crafted/authorizelink. The consent page is real (correctdomain, valid TLS), so if you didn't notice the destination and typedGATEWAY_AUTH_TOKENanyway, theresulting authorization code — and the access token it becomes — would go to them, not Claude. Twomitigations, both insrc/oauth.ts: the consent page always shows the exactredirect_uri(andclient_id) before asking for the token, so there's a chance to notice something's wrong; andOAUTH_ALLOWED_REDIRECT_HOSTS(unset/permissive by default — see.env.examplefor how to determineand set it) makes/registerreject any host that isn't explicitly allowed, closing the door before amalicious client can even be registered. Worth noting: the blast radius of a successful hijack iswhatever spaces the bot account is a member of at the time — its own isolated sandbox space only bydefault, but real Anytype data too once you've invited it into a real space (see "Data isolation").That's a reason to takeOAUTH_ALLOWED_REDIRECT_HOSTSmore seriously after doing so, not a reason notto invite it.
- Memory growth: registering many clients costs the gateway a little memory each time. Capped(
- OAuth session lifetime is bounded two ways, deliberately redundant (defense in depth):
- Always on:
OAUTH_REFRESH_TOKEN_TTL_HOURS(default168= 7 days) caps how long a refresh tokenis honored at all — rotatingGATEWAY_AUTH_TOKENdoes 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 iswhat actually bounds a leaked token's exposure window by default, with zero configuration required. - Optional, off by default:
GATEWAY_RESTART_INTERVAL_HOURS— the gateway exits cleanly on a timerand relies on the process supervisor (Restart=alwaysin the systemd unit,restart: unless-stoppedalready indocker-compose.yml) to bring it back up, wiping all OAuthstate 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 everyrestart, 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 thatbefore enabling it. Trade-off either way: Claude has to redo the consent step (re-enterGATEWAY_AUTH_TOKEN) whenever a session ends, whether by TTL or by restart.
- Always on:
Current limitations / what's not done yet
See 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 clickthe button for you), the bot account hasn't been invited into a real Anytype space, and there's no tokenrotation procedure or uptime monitoring/alerting yet.
Troubleshooting
anytype-clinot running —anytype-mcpfails fast withCan't connect to API. Please ensure Anytype is running and reachable.Startanytype servefirst and confirmcurl/Node request to127.0.0.1:31012/v1/spacesreturns200before starting the gateway.- Wrong
Anytype-Versionheader — check the installed@anyproto/anytype-mcpversion's expectedvalue; a mismatch can cause API calls to fail even though the connection succeeds. npxcache issues (bare metal) —npx -y @anyproto/anytype-mcpfetches from the npm registry onfirst run; if it hangs, check network access or pre-warm the npx cache with a manualnpx -y @anyproto/anytype-mcprun (Ctrl-C once it prints "running on stdio"). The Docker image avoidsthis entirely by installing@anyproto/anytype-mcpglobally at build time (seeDockerfile).docker compose exec anytype-headless anytype auth create ...hangs or fails — confirm theanytype-headlesscontainer is healthy first (docker compose ps); it needs a few seconds afterdocker compose up -dto start listening.- Funnel URL doesn't resolve / connection refused — confirm HTTPS certs and the Funnel node attributeare enabled for this node in the Tailscale admin console (see "Public exposure" above);
tailscale funnel status(bare metal) ordocker compose exec tailscale tailscale funnel status(Docker) showsthe current mapping.