Tofu External MCP

Tofu External MCP #

Hono + Bun MCP server that exposes BonsAPI’s external HTTP endpoints as LLM tools. Lives at apps/tofu-external-mcp. Part of the Tofu MCP project (see ENG-6758).

Trust model #

Per-user-scoped, no flat access. Unlike tofu-internal-mcp (engineering-only, org-wide DB access), this server forwards each caller’s own Clerk login token to BonsAPI as-is — BonsAPI enforces that user’s real org/entity permissions, so this server never re-implements access control and never touches /internal/*, the dev admin bypass, or any shared superuser credential.

What you can do #

Tool Purpose
whoami Resolve the caller’s own org roles/permissions — smoke-tests the OAuth pass-through chain
get_document Fetch a document by id + entity id (GET /api/v1/documents/{documentId})

Tool surface grows via tools/swagger/filter-tofu-external-mcp.js’s hand-maintained allowlist (only clerkAuth-secured paths from apps/bonsapi/docs/openapi/index.yaml) — add a path there, regenerate with pnpm tofu-external-mcp codegen, then hand-write the tool (see src/tools/get-document.ts as the template: withAudit-wrapped, zod inputSchema, formatError on failure).

Use it from Claude Code (local dev) #

  1. mise tofu-external-mcp-dev (docker compose service on port 3003).
  2. The project-scoped .mcp.json points Claude Code at http://localhost:3003 — no static token; Claude Code’s native MCP OAuth client drives the real Clerk login (/register/authorize → Clerk → /token).
  3. claude mcp list should show tofu-external-mcp - ✓ Connected once authenticated.

If it’s not connected: check docker logs tofu-external-mcp, and that TOFU_EXTERNAL_MCP_URL in your local env is http://localhost:3003 — if it’s ever set to the eventual deployed hostname (dev-mcp.internal.gotofu.com) while working locally, OAuth discovery metadata won’t match what Claude Code connected to and the auth handshake fails outright.

Auth model #

Single path today: Clerk OAuth token pass-through. src/middleware/auth.ts verifies the token (JWKS, issuer, client_id pinned to this server’s dedicated Clerk OAuth app — Clerk puts no aud on OAuth tokens) then, unlike the internal server, keeps it and forwards it to BonsAPI on every downstream call (src/bonsapi/custom-fetch.ts).

Known gap: BonsAPI’s verifier (libs/rust/bonsai-clerk/src/model/jwt.rs) rejects any OAuth token with no org_id claim — Clerk only stamps org_id when the signed-in user has an active organization. A caller with no active org gets a clean 401 (that’s the spec’d behavior, not a bug — see ENG-6794), but it means a real document/permission fetch needs a Clerk session with an active org, not just any valid login.

Service-key path (ENG-6796): a request authenticated with a Tofu-API-Key header instead of a Clerk token is accepted at the middleware level, but each tool must individually opt in to accepting it — a tool declares which bonsapi operation it wraps (withAudit’s bonsapiOperationId option), and src/service-key-policy.ts checks that against the ENG-6768 credential map (only 8 of 168 bonsapi operations accept a service key; everything else is JWT-only). A service-key call to a JWT-only tool (e.g. whoami) is rejected with a clear message before ever reaching bonsapi; get_document is the one tool wired up as compatible today. No key has actually been issued yet — org-wide, no per-user scoping, issued only once a real headless consumer appears. Full policy (issuance, storage, rotation): apps/tofu-external-mcp/docs/service-key-policy.md.

Audit logging + tracing #

Every tool call emits one structured JSON log line (@bonsai/logger, is_audit_log: true, event_type: "tool_call" or "auth_failure") carrying actor_user_id, auth_mode ("oauth" or "api_key"), mcp_session_id, trace_id (32-hex W3C id) and dd.trace_id (its decimal Datadog form). The same call’s trace_id/dd.trace_id show up verbatim in bonsapi’s own log line for the downstream request (bonsapi’s OpenTelemetry layer parses the traceparent header custom-fetch.ts forwards) — the two services’ logs join on that id. Verified live end-to-end for whoami and get_document against a real Clerk login (ENG-6798/ENG-6799); re-verify the same way after any change to src/tools/with-audit.ts or src/logging/.

Known limitations: MCP session id is best-effort (not stateful — single shared stateless transport, see src/server.ts); no real Datadog APM spans yet (log correlation only, dd-trace on Bun is unverified); Datadog Logs forwarding needs DATADOG_API_KEY (not yet wired); no org/entity on the audit line itself (bonsapi already logs it on the same trace, joinable — duplicating it here isn’t worth an extra round-trip); MCP-protocol-dispatch-level failures (unknown tool name, schema validation) bypass withAudit entirely and produce no audit line yet.

Develop on it #

cd apps/tofu-external-mcp
bun test          # or: pnpm tofu-external-mcp test
pnpm tofu-external-mcp check   # format + lint + typecheck

Code is bun-native TS — no build step. bun --watch reloads on file change inside the container. CI-facing mise tasks (mirroring tofu-internal-mcp’s mcp-check/mcp-test) aren’t wired yet — see ENG-6797.

Important env vars #

All under the TOFU_EXTERNAL_MCP_* prefix (Doppler) — never reuse the internal server’s TOFU_MCP_* vars.

Var What
TOFU_EXTERNAL_MCP_URL This server’s public URL — feeds OAuth discovery (resource, authorization_servers) and must exactly match wherever the connecting client actually reaches it (http://localhost:3003 locally; the deployed dev/prod hostname once live)
TOFU_EXTERNAL_MCP_CLERK_ISSUER_URL / _JWKS_URL Clerk issuer + JWKS for verifying forwarded tokens
TOFU_EXTERNAL_MCP_CLERK_CLIENT_ID / _CLIENT_SECRET This server’s dedicated Clerk OAuth app — pinned client_id for verification, secret for the /token proxy
TOFU_EXTERNAL_MCP_BONSAPI_URL / BONSAPI_INTERNAL_HOST Where to reach bonsapi (src/bonsapi/custom-fetch.ts)

Deploy #

Dev: auto-deploys on every merge to main that touches apps/tofu-external-mcp/**, via build-tofu-external-mcp/deploy-tofu-external-mcp in .github/workflows/dev-deploy.yaml (path-filtered through detect-changes.yml’s tofu_external_mcp output). This is deliberately kept out of the shared .github/workflows/deploy.yaml — the tool surface is still changing fast through Phase 6, so it doesn’t get an automatic prod-eligible pipeline yet (see ENG-6913).

Prod: not deployed. Kustomize scaffolding exists (deployment/resources/tofu-external-mcp/ + deployment/overlays/prod/tofu-external-mcp/) ahead of ENG-6761 (Phase 6), which owns wiring prod into the shared pipeline, plus DNS/cert/Cloudflare Access allowlist for claude.ai’s egress IPs, connecting both clients, and metrics.