Project · gcp-mcp-standalone

gcp-mcp-standalone — Low-Level Design

type lldstatus activegcp · terraform · ansible · config

Components

  • main.tfgoogle_compute_instance.mcp_broker (e2-small, 20 GB, ubuntu-2204-lts, access_config for egress IP, tag mcp-broker); attaches mcp-broker-sa (data source — the SA and its IAM live outside this state); metadata_startup_script bootstraps Tailscale (see Sequences); google_compute_firewall.deny_public_ingress (INGRESS, priority 100, deny all, 0.0.0.0/0, target tag mcp-broker). The only variable is gcp_project_idno secrets in tfvars or state. Editing the startup script forces VM replacement.
  • ansible/install.yml — installs Docker (official apt repo); clones BobMck/mcp-broker branch connectors; first run only: generates broker secrets (Fernet key via python3-cryptography, 43-char admin/state keys) and renders .env (mode 600) — the whole block is no_log; renders settings.yaml; injects GitHub OAuth creds idempotently every run (client secret pulled from Secret Manager via the VM's SA, no_log); installs cloudflared pinned (2026.6.1, sha256-verified get_url) and registers the tunnel (no_log — the token grants tunnel attachment); starts the broker with docker compose up -d --build --force-recreate (--build required: connector code is baked into the image, not mounted).
  • ansible/inventory.ini — host gcp-mcp-broker (Tailscale MagicDNS), user ubuntu.
  • docker-compose.yml (broker fork) — binds 127.0.0.1:8002:8002; volumes ./data (token DBs) and ./settings.yaml:ro; restart: unless-stopped.

Configuration

  • Broker settings.yaml: oauth.enabled: true, app_key: my_company:app1, public_url: https://bobsmcp.uk/; allowed redirect URIs claude.ai/claude.com only; access token TTL 3600 s, refresh 30 d, auth code 60 s; DCR rate limit 10/IP/900 s; connectors hubspot, notion, notion_api, slack, workspace_mcp, github, cloudflare; store = SQLite (data/tokens.db, inbound OAuth in data/inbound_oauth.db). All credentials are ${ENV} references resolved from .env — the YAML itself is secret-free.
  • Secrets, by home: | Secret | Lives in | Reaches the box via | |---|---|---| | Tailscale auth key | Secret Manager tailscale-auth-key | startup script, SA token, REST | | GitHub client secret | Secret Manager github-oauth-client-secret | Ansible gcloud secrets versions access | | Notion client secret | Secret Manager notion-oauth-client-secret | Ansible fetch (mirrors GitHub) | | Cloudflare tunnel token | operator's Mac (file, mode 600) | ansible-playbook -e, no_log | | Broker admin/encryption/state keys | generated on-box, .env 600 | never leave the VM | | Broker API key (X-Broker-Key) | minted via admin API, hashed in broker_keys.db | header-auth clients (bot, wiki-publish) |
  • IAM: mcp-broker-sa has roles/secretmanager.secretAccessor on the named secrets only; instance scope cloud-platform (effective access = IAM ∩ scope).

Interfaces

  • Public (via tunnel): https://bobsmcp.uk/proxy/<connector> (MCP endpoints, OAuth- or header-auth-gated); /.well-known/oauth-* discovery; /oauth/* (DCR, authorize, token); /oauth/<connector>/connect?connect_token=… (browser connect flow); /admin/* (admin-key-gated). Root / answers 401 when healthy.
  • Private (tailnet): ssh ubuntu@gcp-mcp-broker. The broker itself is localhost-only — admin API from the Mac via ssh -L 8002:localhost:8002.
  • Local (on-box): broker at 127.0.0.1:8002; cloudflared and the Telegram bot connect to it there.

Admin API & connector auth model

All /admin/* require the X-Admin-Key header (bootstrap secret from .env). - POST /admin/keys — provision the my_company:app1 API key (X-Broker-Key); returned once. …/rotate, DELETE … cascade-drop stored tokens. - POST /admin/connect-token — single-use, short-TTL token for the browser connect flow. - DELETE /admin/connections/<app>/<connector> — revoke one upstream authorization.

Two client auth styles: claude.ai uses inbound OAuth 2.1 + PKCE (interactive); programmatic clients (the Telegram bot, /wiki-publish) use header-auth (X-Broker-Key + X-App-Id: my_company:app1), scoped to the app's allowed_connectors. Either way the upstream token stays in the broker — clients never see it. Connector authorizations and API keys live in data/ (SQLite) and are lost on a VM rebuild; recovery in gcp-mcp-standalone/runbooks/reconnect-connectors.

Sequences

First boot — Tailscale bootstrap (the only moment the auth key is in memory):

sequenceDiagram
  participant VM as VM startup script
  participant MD as metadata server
  participant SM as Secret Manager
  participant TS as Tailscale control
  VM->>VM: install tailscale (signed apt repo)
  VM->>MD: GET /token (Metadata-Flavor: Google)
  MD-->>VM: SA access token
  VM->>SM: GET …/tailscale-auth-key/versions/latest:access (Bearer)
  SM-->>VM: base64 payload (parsed with python3, not grep)
  VM->>TS: tailscale up --authkey=… --hostname=gcp-mcp-broker
  TS-->>VM: joined tailnet — admin plane live

Inbound OAuth + proxied MCP call:

sequenceDiagram
  participant C as claude.ai
  participant B as Broker (bobsmcp.uk)
  participant U as Upstream (Notion/GitHub/Cloudflare)
  C->>B: GET /.well-known/oauth-* (discovery)
  C->>B: POST /oauth/register (DCR, rate-limited)
  C->>B: GET /oauth/authorize (PKCE S256)
  B-->>C: 302 code (60 s TTL)
  C->>B: POST /oauth/token
  B-->>C: access token (1 h) + refresh (30 d)
  C->>B: POST /proxy/<connector>/ (MCP, Bearer)
  B->>U: forward with stored upstream token (Fernet-decrypted)
  U-->>B: result
  B-->>C: result
Compiled from wiki/projects/gcp-mcp-standalone/LLD.md · git is the source of truth