Architecture
flowchart LR
subgraph Internet
CA[claude.ai cloud]
end
subgraph Cloudflare
EDGE[Cloudflare edge<br/>bobsmcp.uk]
end
subgraph GCP[GCP e2-small · europe-west2 london]
CFD[cloudflared] -->|127.0.0.1:8002| BROKER[Firney broker<br/>Docker, non-root]
TS[tailscaled]
FW{{deny-all-ingress<br/>priority 100}}
SA[service account<br/>mcp-broker-sa]
end
SM[(Secret Manager<br/>tailscale-auth-key<br/>github-oauth-client-secret<br/>notion-oauth-client-secret)]
MAC[Bob's Mac]
BOT[Claude Telegram bot<br/>claudebot, on-VM]
SAAS[(Notion · GitHub<br/>Cloudflare APIs)]
CA -- HTTPS + OAuth2.1 --> EDGE
EDGE -- outbound tunnel --> CFD
MAC -- Tailscale mesh --> TS
MAC -. wiki-publish header-auth .-> EDGE
BOT -. localhost header-auth .-> BROKER
TS -. SSH / Ansible .-> BROKER
SA -- secretAccessor --> SM
BROKER -- per-connector OAuth --> SAASConnection model
Three distinct planes, deliberately separated:
- Public plane (claude.ai → broker). claude.ai discovers the broker via
/.well-known/oauth-*, registers as an OAuth 2.1 client (DCR, PKCE S256 enforced), and calls MCP tools athttps://bobsmcp.uk/proxy/<connector>. TLS terminates at the Cloudflare edge;cloudflaredon the VM keeps a persistent outbound connection to Cloudflare, so no inbound port ever opens. - Admin plane (Mac → VM). SSH, Ansible and broker admin ride the Tailscale mesh (WireGuard). The VM joins the tailnet at first boot using a one-time auth key fetched from GCP-Secret-Manager — see gcp-mcp-standalone/adr/0004-tailscale-key-in-secret-manager.
- Egress plane (broker → SaaS). The broker performs upstream OAuth against each connector's provider and proxies MCP calls with stored (Fernet-encrypted) tokens. The VM keeps a public IP for egress only (~$3/mo — far cheaper than Cloud NAT, equally private given the deny-all ingress rule).
Components
- Instance —
e2-small, Ubuntu 22.04 LTS, 20 GB disk,europe-west2-a. - Firewall — Terraform-managed deny-all ingress (priority 100) overriding the default network's permissive rules. See gcp-mcp-standalone/adr/0002-deny-all-public-ingress.
- Service account —
mcp-broker-sa, attached to the VM; IAM grants onlysecretAccessoron the named secrets. No keys are ever exported. - Tailscale — installed at first boot from Tailscale's signed apt repo (not
curl | sh); carries SSH/admin/Ansible. - Cloudflare-Tunnel —
cloudflared(version-pinned, checksum-verified) publishes the broker atbobsmcp.uk. - Firney-MCP-Broker — Docker (non-root container), bound to
127.0.0.1:8002only; inbound OAuth 2.1 + header-auth enabled; single appmy_company:app1.
Connectors & integrations
The broker's job: hold each SaaS's OAuth token centrally and proxy MCP calls, so clients never see raw upstream tokens. Two moving parts — the connectors (broker → SaaS) and the integrations (clients → broker).
Connectors (broker → SaaS)
Enabled in settings.yaml under my_company:app1. "Authorized" = an upstream token is
stored (data/tokens.db), established via a one-time browser connect flow
(POST /admin/connect-token → https://bobsmcp.uk/oauth/<connector>/connect). State as of
2026-07-03:
| Connector | Flavour | Credentials | Authorized? |
|---|---|---|---|
notion_api |
Static/public-OAuth (full Notion REST) | client id + secret (GCP-Secret-Manager notion-oauth-client-secret) |
✅ yes |
github |
Static OAuth | client id + secret (Secret Manager github-oauth-client-secret) |
✅ yes |
cloudflare |
Discovery (RFC 8414/7591 DCR) | none — dynamic registration | ✅ yes |
notion |
Discovery (mcp.notion.com) | none | ⚪ enabled, unused |
hubspot · slack · workspace_mcp |
Static OAuth | creds blank | ⚪ enabled, no creds |
Upstream tokens are Fernet-encrypted at rest. A VM rebuild wipes data/ (tokens + keys +
inbound registrations), so every connector must be re-authorized after one — see
gcp-mcp-standalone/runbooks/reconnect-connectors.
Integrations (clients → broker)
| Client | Auth to broker | Uses |
|---|---|---|
| claude.ai / desktop | Inbound OAuth 2.1 + PKCE (the mcp-broker custom connector) |
Notion, GitHub, Cloudflare |
| Claude Telegram bot (on this VM) | Header-auth over localhost (X-Broker-Key + X-App-Id) |
Notion — read-only tools only |
/wiki-publish (Mac) |
Header-auth (notion-broker MCP server) |
mirrors the wiki to Notion |
Programmatic clients authenticate with a broker API key (X-Broker-Key, minted via
POST /admin/keys) rather than OAuth; the key scopes them to the app's
allowed_connectors. The Telegram bot's read-only boundary is enforced client-side by its
Claude Code allow-list (only search/fetch/query-type Notion tools), so it can read
Notion but never create/edit/delete.
Security model
Defence in depth, outermost first:
| Layer | Control |
|---|---|
| Network ingress | Deny-all firewall (priority 100, all protocols, 0.0.0.0/0) — verified live 2026-07-02; ports 22/8002 unreachable from the internet |
| Public exposure | Only via Cloudflare Tunnel (outbound); TLS + --proxy-headers so OAuth redirect URIs build as https:// |
| Host binding | Broker listens on 127.0.0.1 only — invisible to the public IP and the tailnet (gcp-mcp-standalone/adr/0006-localhost-only-broker-bind) |
| Inbound auth | OAuth 2.1 + PKCE S256; redirect URIs restricted to claude.ai/claude.com; DCR rate-limited (10/IP/15 min); access tokens 1 h, refresh 30 d |
| Secrets at rest | Tailscale key + GitHub + Notion client secrets in GCP-Secret-Manager; broker .env mode 600, generated once on-box; connector OAuth tokens Fernet-encrypted in SQLite; broker API keys hashed in broker_keys.db |
| Secrets in transit | Fetched at boot/deploy by the attached SA (secretAccessor on named secrets only); Ansible tasks handling secrets are no_log |
| Supply chain | Tailscale from signed apt repo; cloudflared pinned + sha256-verified (gcp-mcp-standalone/adr/0005-pinned-supply-chain) |
| Container | Non-root (appuser), settings.yaml mounted read-only, config carries only ${ENV} references |
Known accepted risks (single-admin personal box): broker /admin/* is key-gated but
internet-reachable through the tunnel (mitigation planned: Cloudflare Access policy);
GitHub connector requests the broad repo scope; token DBs rely on host file permissions.
No data/ durability — the broker DBs (upstream tokens, API keys, inbound
registrations) live only on the boot disk, so a VM rebuild nukes every authorization
(happened 2026-07-02; recovery in gcp-mcp-standalone/runbooks/reconnect-connectors).
Fix later: persistent disk or a GCS backup of data/.
History: the original Tailscale auth key and a GitHub client secret were exposed during
early development — both rotated (2026-07-02 and 2026-06-30); state scrubbed.
Running cost
About $17/month: e2-small (~$13) + 20 GB disk (~$1) + static-free egress public IP (~$3). Cloudflare Tunnel and Tailscale (personal) are free tiers; Secret Manager pennies. A deliberately light box — it only proxies API calls.
Decisions
- gcp-mcp-standalone/adr/0001-cloudflare-tunnel-over-tailscale-funnel
- gcp-mcp-standalone/adr/0002-deny-all-public-ingress
- gcp-mcp-standalone/adr/0003-dedicated-domain-for-the-tunnel
- gcp-mcp-standalone/adr/0004-tailscale-key-in-secret-manager
- gcp-mcp-standalone/adr/0005-pinned-supply-chain
- gcp-mcp-standalone/adr/0006-localhost-only-broker-bind
Related concepts
wiki/projects/gcp-mcp-standalone/HLD.md · git is the source of truth