anytype-mcp-remote-gateway
Provides tools for interacting with Anytype, enabling management of spaces and other Anytype data through the official Anytype MCP server.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@anytype-mcp-remote-gatewayWhat are my recent notes from Anytype?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 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 for what's still ahead
(token rotation, monitoring).
Related MCP server: Levitate
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 accountTailscale 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 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 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
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(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.
# 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 devYou 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
curl -i http://localhost:8787/healthz # 200, no token needed
curl -i http://localhost:8787/mcp # 401, no token supplied
npm run inspectIn 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:
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 8787Verify 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 neededtailscale 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 for how the ingress wiring
works), anytype-headless (the official
anyproto/anytype-cli image), and mcp-gateway (built
from 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.
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/healthzNo 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), so it needs a process supervisor. A sample systemd
unit is at 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).
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:
npm run verify:remote -- https://<device>.<tailnet>.ts.netThis 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): 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 |
| Port the public Express app listens on (default |
| Host/interface the public app binds to (default |
| Bearer token required on every request except |
| Rate-limit window in milliseconds, per IP (default |
| Max requests per IP per window before a |
| Port the internal mcp-proxy process listens on, loopback-only (default |
| Local Anytype API base URL — |
| API key for the bot account, from |
|
|
| Not read by the gateway — only by |
| pino log level (default |
| How long an OAuth refresh token stays usable (default |
| Optional scheduled self-restart, |
| Comma-separated hostnames |
Docker-only, set in .env.docker (see .env.docker.example):
Variable | Purpose |
| Tailscale pre-auth key for the |
| This node's name on the tailnet, and thus part of its public Funnel URL (default |
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:
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 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_TOKENandANYTYPE_API_KEYare read from.env/.env.docker(both gitignored) and are never 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 bound to
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 streaming responses.GATEWAY_HOSTdefaults to127.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_TOKENis 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 atwarnwith 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) 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/authorizeconsent 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./authorizeand/tokensit 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 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 either just 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 (the Funnel URL isn't public, but isn't a secret either) could register a client with their own server as theredirect_uri, then send you a crafted/authorizelink. The consent page is real (correct domain, valid TLS), so if you didn't notice the destination and typedGATEWAY_AUTH_TOKENanyway, the resulting authorization code — and the access token it becomes — would go to them, not Claude. Two mitigations, 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 determine and set it) makes/registerreject 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 takeOAUTH_ALLOWED_REDIRECT_HOSTSmore seriously after doing so, not a reason not to invite it.
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 token is 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 is what 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 timer and relies on the process supervisor (Restart=alwaysin the systemd unit,restart: unless-stoppedalready indocker-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-enterGATEWAY_AUTH_TOKEN) whenever a session ends, whether by TTL or by restart.
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 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-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 expected value; 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 on first 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 avoids this 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 attribute are 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) shows the current mapping.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- Flicense-qualityDmaintenanceExposes local OpenCode instances as remote MCP servers for Claude and ChatGPT, enabling terminal access, session management, and interactive human-in-the-loop workflows. It simplifies deployment for local machines using Cloudflare Tunnels to provide secure public connectivity and OAuth support.
- Alicense-qualityAmaintenanceLifts local stdio MCP servers into remote Streamable HTTP endpoints for cloud-hosted AI clients, with bearer-token auth and tool policy filtering.4MIT
- Flicense-qualityCmaintenanceExposes file, shell, git, and memory tools from a host Mac to remote Claude clients (e.g., iPhone) via HTTPS and Tailscale, enabling full coding capabilities on the go.
- Flicense-qualityBmaintenanceAn authenticated Streamable HTTP bridge that wraps the local OmniFocus MCP server, enabling remote MCP access with bearer token authentication and optional Tailscale Serve.
Related MCP Connectors
Hosted MCP server connecting claude.ai, ChatGPT and other AI apps to your own computer
Multi-tenant Telegram gateway for AI agents — HTTP+stdio, 8 tools, MTProto User API
MCP server bridging holepunchto/keet-identity-key to the Hive agentic identity network
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/ivantacca/anytype-mcp-remote-gateway'
If you have feedback or need assistance with the MCP directory API, please join our Discord server