Architecture

Architecture #

Classification: Restricted (describes source code, infrastructure, and access control).

Hono + Bun MCP server that exposes BonsAPI’s external HTTP endpoints as LLM tools. Code is bun-native TypeScript, no build step; bun --watch reloads on file change inside the container.

MCP client ──HTTPS──▶ tofu-external-mcp ──HTTP + same Clerk token──▶ bonsapi ──▶ database
 (claude.ai,          (auth, tool dispatch,                          (enforces the user's
  Claude Code)         audit log, rate limit)                         org/entity permissions)
  • Per-user scope, no flat access. Unlike tofu-internal-mcp (engineering-only, org-wide DB access), this server forwards each caller’s own token and never touches /internal/*, the dev admin bypass, or any shared superuser credential.
  • Observability. Every tool call emits one structured audit log line that joins with bonsapi’s own log on the trace id; see Audit logging.
  • Limits. 60 tool calls per rolling minute per user; see Rate limiting.

Audit logging and 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 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).

Tool surface #

Which tools exist and how customers use them is end-user documentation and belongs in the external docs (docs/external/content/docs/; there is no MCP page there yet). This page covers only how the server works and how to run it.

The 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).

The full list of tools is in the generated Tool catalog; curated input/output notes are in the Tools guide.

MCP session tracking #

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.