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: removed (ENG-7010). The Tofu-API-Key service-key credential path designed in ENG-6796 was retired before any key was ever issued (zero live traffic) — this server is JWT-only (Clerk OAuth) end to end now. src/service-key-policy.ts, the middleware’s API-key branch, and the per-tool bonsapiOperationId opt-in have all been deleted. See External MCP Service-Key Policy for the retired design.

Org gating (ENG-7562): per-org is_external_mcp_enabled feature flag. The old env-var allowlist (TOFU_EXTERNAL_MCP_ALLOWED_ORG_IDS) has been replaced by a per-org flag in config/features.yaml (canary tier, same registry as auto_suggest_multi_snippet) — access is now a Clerk-metadata grant managed the same way as every other flag in that registry, instead of a comma-separated secret only this service read. src/middleware/auth.ts’s clerkOAuthAuth resolves it via two calls to bonsapi, both using the caller’s own already-verified bearer token (src/middleware/org-mcp-access.ts):

  1. GET /api/v1/organization/public-settings — needs only a valid JWT (unlike GET /api/v1/organizations/{id}, which needs org-level access), and derives the org from the caller’s own token rather than a path param. Gives bonsapi’s internal organization_id (distinct from the JWT’s Clerk org_id).
  2. GET /internal/api/v1/organizations/metadata?organization_id=<uuid> — bonsapi-internal only (not in the generated public client, reachable because it’s mounted on the same bind/port as the public API). Returns the org’s raw Clerk public_metadata, Redis-cached bonsapi-side for a day.

A caller whose org doesn’t have the flag, or where either bonsapi call itself fails (unreachable, unexpected response shape, etc.), is rejected 403org_feature_not_enabled or org_feature_lookup_failed respectively (both AuthzFailure), fail-closed either way. A token with no org_id claim at all is rejected 403 no_active_org before either bonsapi call is even attempted. A successful resolution is cached per orgId for 60s (org-mcp-access.ts) so a chatty session doesn’t pay two round-trips per tool call; a failed lookup is never cached, so a transient bonsapi blip retries on the next request.

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 (always "oauth" — JWT-only, ENG-7010), mcp_session_id, operation_id (unique per call/retry, ENG-7010), 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/logging/ or src/tracing.ts. See Audit Logging & Trace Correlation for the field reference and the E2E checklist for a mutation tool (e.g. create_extraction, ENG-7193).

MCP session tracking (ENG-7010): real per-client session state, not just a best-effort logging tag. src/server.ts enables the SDK’s sessionIdGenerator — the first initialize request from a client mints a session id and pins that client’s whole chat session to one McpServer/transport pair, tracked in src/mcp-session-store.ts (idle-evicted after 30 minutes of inactivity). A later request naming a session this process has no record of (evicted, this process restarted, or — since prod runs 2 replicas behind an ALB with no session affinity configured — landed on a different pod) degrades to the old fully-stateless per-request handling rather than erroring, logging a warn-level application_error line each time it happens so that rate is observable. Non-initialize requests with no session header (e.g. a session-less client under a future MCP spec revision) get the same graceful fallback rather than a hard rejection.

Accepted trade-off, not a bug (ENG-7010): prod’s 2 replicas sit behind an ALB with no session affinity — ALB has no header-based stickiness mechanism, and the reference @modelcontextprotocol/sdk client transport doesn’t use cookies (it re-attaches Mcp-Session-Id as a plain header itself), so ALB cookie-based stickiness would be a no-op even if configured. A session’s requests therefore aren’t guaranteed to keep hitting the same pod in prod, and the degradation above can fire routinely there rather than only on process restart. Deliberately left as-is rather than dropping to 1 replica (loses deploy/failure redundancy) or adding a shared external session store (a much larger lift that still can’t relocate a live SSE stream to another pod). Watch the MCP session id not found on this instance warn-log rate in Datadog if this ever needs revisiting.

Known limitations: no real Datadog APM spans yet (log correlation only, dd-trace on Bun is unverified) — logs already ship via OTel, so no DATADOG_API_KEY/direct Datadog Logs push is needed here; 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 the ToolCall audit line 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 (tofu-external-mcp-check/tofu-external-mcp-test, mirroring tofu-internal-mcp’s mcp-check/mcp-test) are wired into ts-check/ts-test (libs/typescript/.tasks.toml), which .github/workflows/ci.yml’s typescript job runs whenever detect-changes.yml’s typescript filter (includes apps/tofu-external-mcp/**) matches — 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).

Prod (ENG-7562): live, via build-tofu-external-mcp/deploy-tofu-external-mcp jobs in the shared .github/workflows/deploy.yaml — the same tag-triggered prod-deploy.yaml pipeline every other prod service runs through (needs: [database-migration, sync-secrets, build-tofu-external-mcp], ./.github/actions/eks-deploy). Kustomize: deployment/resources/tofu-external-mcp/ + deployment/overlays/prod/tofu-external-mcp/, hosted at mcp.gotofu.com — a real cert/security-group-locked ALB. The internal server (tofu-mcp) moved off that hostname to internal-mcp.gotofu.com to free it up for this app (see Tofu Internal MCP). TOFU_EXTERNAL_MCP_URL in prod Doppler must be https://mcp.gotofu.com exactly — it feeds the OAuth WWW-Authenticate header/discovery, which is stricter than the module’s own external-mcp.gotofu.com fallback default (that fallback only ever applies to local dev, where the env var is unset). Anthropic’s egress IPs (160.79.104.0/21) are allowlisted via a Cloudflare Access policy in front of that hostname, configured outside this repo (not expressible in the Kustomize ingress manifest — same as the internal server’s equivalent setup). Per-org access is additionally restricted via the is_external_mcp_enabled feature flag — see Org gating under Auth model above.