Components
main.tf—google_compute_instance.mcp_broker(e2-small, 20 GB, ubuntu-2204-lts,access_configfor egress IP, tagmcp-broker); attachesmcp-broker-sa(data source — the SA and its IAM live outside this state);metadata_startup_scriptbootstraps Tailscale (see Sequences);google_compute_firewall.deny_public_ingress(INGRESS, priority 100, deny all,0.0.0.0/0, target tagmcp-broker). The only variable isgcp_project_id— no secrets in tfvars or state. Editing the startup script forces VM replacement.ansible/install.yml— installs Docker (official apt repo); clonesBobMck/mcp-brokerbranchconnectors; first run only: generates broker secrets (Fernet key viapython3-cryptography, 43-char admin/state keys) and renders.env(mode 600) — the whole block isno_log; renderssettings.yaml; injects GitHub OAuth creds idempotently every run (client secret pulled from Secret Manager via the VM's SA,no_log); installscloudflaredpinned (2026.6.1, sha256-verifiedget_url) and registers the tunnel (no_log— the token grants tunnel attachment); starts the broker withdocker compose up -d --build --force-recreate(--buildrequired: connector code is baked into the image, not mounted).ansible/inventory.ini— hostgcp-mcp-broker(Tailscale MagicDNS), userubuntu.docker-compose.yml(broker fork) — binds127.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 URIsclaude.ai/claude.comonly; access token TTL 3600 s, refresh 30 d, auth code 60 s; DCR rate limit 10/IP/900 s; connectorshubspot, notion, notion_api, slack, workspace_mcp, github, cloudflare; store = SQLite (data/tokens.db, inbound OAuth indata/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 Managergithub-oauth-client-secret| Ansiblegcloud secrets versions access| | Notion client secret | Secret Managernotion-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,.env600 | never leave the VM | | Broker API key (X-Broker-Key) | minted via admin API, hashed inbroker_keys.db| header-auth clients (bot, wiki-publish) | - IAM:
mcp-broker-sahasroles/secretmanager.secretAccessoron the named secrets only; instance scopecloud-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 viassh -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
Related concepts
wiki/projects/gcp-mcp-standalone/LLD.md · git is the source of truth