Project · gcp-mcp-standalone

gcp-mcp-standalone — High-Level Design

Host the [[Firney-MCP-Broker]] so claude.ai can use it as a remote MCP connector to OAuth-protected SaaS (Notion, GitHub, Cloudflare, more to come), while keeping the box maximally private. Admin and day-to-day traffic never traverse the public internet; only the broker's HTTPS endpoint — needed for claude.ai and connector OAuth callbacks — is public, served by an **outbound** Cloudflare Tunnel rather than an open port. Two private doors (Tailscale mesh, outbound tunnel), one locked public door (deny-all firewall).

type hldstatus activegcp · architecture · mcp · security

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

Connection model

Three distinct planes, deliberately separated:

  1. 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 at https://bobsmcp.uk/proxy/<connector>. TLS terminates at the Cloudflare edge; cloudflared on the VM keeps a persistent outbound connection to Cloudflare, so no inbound port ever opens.
  2. 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.
  3. 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

  • Instancee2-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 accountmcp-broker-sa, attached to the VM; IAM grants only secretAccessor on 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-Tunnelcloudflared (version-pinned, checksum-verified) publishes the broker at bobsmcp.uk.
  • Firney-MCP-Broker — Docker (non-root container), bound to 127.0.0.1:8002 only; inbound OAuth 2.1 + header-auth enabled; single app my_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-tokenhttps://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

Compiled from wiki/projects/gcp-mcp-standalone/HLD.md · git is the source of truth