# MCP Server

Coda exposes its AI tools to external MCP clients (Claude Code, Claude Desktop, Cursor, etc.) via a stdio-based MCP server. The server runs as a child process launched by the MCP client and proxies tool calls to the running Coda Express API over HTTP.

## Architecture

```
┌──────────────┐  stdio   ┌──────────────────┐  HTTP POST  ┌───────────────────────┐
│ Claude Code  │ ←──────→ │ MCP Server       │ ──────────→ │ Coda Express Server   │
│ (MCP client) │  JSON-RPC│ (apps/server/    │  /api/v1/   │ (apps/server)         │
│              │          │  src/mcp/)        │  tools/     │  ↓ auth middleware    │
│              │          │                  │  execute    │  ↓ tool registry      │
│              │          │                  │             │  ↓ handler execution  │
└──────────────┘          └──────────────────┘             └───────────────────────┘
```

The MCP server does **not** execute tools directly. It:

1. Reads `TOOL_DEFINITIONS` at startup and registers them with the MCP SDK
2. On `tools/call`, POSTs to `/api/v1/tools/execute` on the running Express server
3. Forwards the user's Bearer token in the `Authorization` header
4. Returns the tool result (or error) to the MCP client

This means the existing auth middleware, rate limiting, and Snowflake identity resolution all apply — no duplication. Tool-level permission checks are pending (COD-109).

## Prerequisites

1. The Coda Express server must be running (`pnpm dev` or Docker)
2. A valid Bearer token (copy from browser devtools → Network tab → any API request → `Authorization` header)

## Quick start

### 1. Configure environment

Copy the MCP env vars to your `.env` file:

```bash
# In apps/server/.env — add these:
CODA_MCP_TOKEN=Bearer eyJ...your-token-here
CODA_MCP_SERVER_URL=http://localhost:8080
# CODA_MCP_DOMAINS=           # comma-separated to filter, empty = all domains
# CODA_MCP_TIMEOUT_MS=30000   # per-tool timeout (default 30s)
```

### 2. Start the Coda server

```bash
pnpm dev
```

### 3. Configure your MCP client

**Automatic setup** (recommended):

```bash
pnpm mcp:setup              # Claude Code (writes .mcp.json in project root)
pnpm mcp:setup --desktop    # Claude Desktop
pnpm mcp:setup --cursor     # Cursor
```

**Manual setup** — if you prefer to edit config files directly:

**Claude Code** — add to `.mcp.json` in the project root:

```json
{
  "mcpServers": {
    "coda": {
      "command": "pnpm",
      "args": ["--filter", "@coda/server-app", "mcp"],
      "cwd": "/path/to/ows-coda"
    }
  }
}
```

**Claude Desktop** — add to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "coda": {
      "command": "pnpm",
      "args": ["--filter", "@coda/server-app", "mcp"],
      "cwd": "/path/to/ows-coda"
    }
  }
}
```

### 4. Verify

In Claude Code, ask "What tools do you have?" — you should see the full Coda tool list. Try a simple call like "Search for account Sony Music".

## Environment variables

| Variable                 | Required | Default       | Description                                                                                                         |
| ------------------------ | -------- | ------------- | ------------------------------------------------------------------------------------------------------------------- |
| `CODA_MCP_TOKEN`         | Yes      | —             | Bearer token forwarded to the Express server. Copy from browser devtools.                                           |
| `CODA_MCP_SERVER_URL`    | Yes      | —             | Base URL of the running Coda server (e.g. `http://localhost:8080`). Trailing slash is stripped.                     |
| `CODA_MCP_DOMAINS`       | No       | all supported | Comma-separated list of tool domains to expose. Empty = all supported domains (excludes snowflake, notion, runner). |
| `CODA_MCP_TIMEOUT_MS`    | No       | `30000`       | Per-tool HTTP call timeout in milliseconds.                                                                         |
| `CODA_MCP_CONCURRENCY`   | No       | `5`           | Max concurrent in-flight tool calls (Semaphore limit).                                                              |
| `CODA_MCP_OTEL_ENDPOINT` | No       | —             | OTLP/HTTP endpoint for OTel trace + metric export (e.g. `http://localhost:4318`). Disabled when unset.              |

### Domain filtering

Use `CODA_MCP_DOMAINS` to expose only a subset of tools:

```bash
# Only search and account tools
CODA_MCP_DOMAINS=search,account

# Only account and royalties tools
CODA_MCP_DOMAINS=account,royalties
```

Available domains: `search`, `skill`, `account`, `royalties`, `moneyhub`, `product`, `ledger`, `file`, `graphql`, `adjustments`.

> **Note:** `snowflake`, `notion`, and `runner` domains are excluded from MCP — they require per-request deps (identity-scoped Snowflake pool, Notion OAuth, Runner client) that the MCP tool-execute endpoint does not inject.

## How it works

### Tool registration

At startup, the MCP server:

1. Loads `TOOL_DEFINITIONS` from `apps/server/src/ai/tools/definitions.ts`
2. Filters by `CODA_MCP_DOMAINS` (if set)
3. Registers each tool with the MCP SDK via `server.registerTool()`, passing:
   - Zod input schema (converted to JSON Schema by the SDK for the client)
   - Tool annotations (`readOnlyHint` for query tools, `destructiveHint` for mutation tools)

The server also embeds instructions (passed to the `McpServer` constructor) with tool classification, usage rules, and domain context.

### Tool execution flow

```
1. MCP client sends tools/call { name: "search_accounts", arguments: { search_term: "Sony" } }
2. MCP server receives the call → handleToolCall()
3. HTTP POST to http://localhost:8080/api/v1/tools/execute
   Body: { name: "search_accounts", input: { search_term: "Sony" } }
   Headers: { Authorization: "Bearer eyJ..." }
4. Express server authenticates and resolves tenant (tool-level permission checks pending — see COD-109)
5. Tool handler executes (search_accounts → hits ows-abacus-account API)
6. Express returns { data: [...], error: null }
7. MCP server truncates if > 100K chars, returns as text content
```

### Error handling

Tool errors are wrapped in structured envelopes with error codes and recovery hints:

```
[TIMEOUT] Tool execution timed out
Hint: The tool took too long. Try a simpler query or increase CODA_MCP_TIMEOUT_MS.

[HTTP_ERROR] HTTP 401: Unauthorized
Hint: Authentication failed. The Bearer token may have expired — copy a fresh one from browser devtools.

[NETWORK_ERROR] Connection refused — is the Coda server running?
Hint: Cannot reach the Coda server. Is it running?

[TOOL_ERROR] Unknown tool: bad_tool
```

Error codes: `TIMEOUT`, `HTTP_ERROR`, `NETWORK_ERROR`, `PARSE_ERROR`, `TOOL_ERROR`. The `Hint:` line is included when a recovery action is available.

Large responses are truncated at 100K chars. Arrays are truncated at item boundaries (preserving valid JSON); non-arrays fall back to character-level truncation with a notice asking the agent to refine the query.

### Server instructions

The MCP server includes embedded instructions (visible to the MCP client) with:

- Tool tier classification (search → lookup → query → explore → mutation)
- Critical rules (search before querying, never fabricate data, etc.)
- Domain context (accounts, contracts, statement periods, revenue, adjustments, products)

See `apps/server/src/mcp/instructions.ts` for the full text.

### Prompts

The MCP server registers 4 prompt templates for common workflows:

| Prompt              | Description                               | Arguments      |
| ------------------- | ----------------------------------------- | -------------- |
| `search-account`    | Search for an account and get an overview | `account_name` |
| `revenue-overview`  | Get revenue data for an account           | `account_name` |
| `explore-graphql`   | Explore the GraphQL schema for a topic    | `topic`        |
| `create-adjustment` | Step-by-step adjustment batch creation    | (none)         |

In Claude Desktop, these appear as quick-action buttons. In Claude Code, use them via the MCP prompt interface.

### Resources

The MCP server exposes 3 resources for introspection:

| Resource         | URI                     | Description                                                                 |
| ---------------- | ----------------------- | --------------------------------------------------------------------------- |
| `tool-catalog`   | `coda://tools/catalog`  | Complete list of available tools with domains, parameters, and descriptions |
| `domain-guide`   | `coda://tools/domains`  | Guide to each tool domain with descriptions and tool lists                  |
| `server-metrics` | `coda://server/metrics` | In-process tool call metrics (counts, errors, truncations, latencies)       |

Resources are read-only metadata. Clients can fetch them to understand what tools are available before making tool calls.

### Request correlation

Every tool call generates a UUID request ID and a W3C `traceparent` header:

- **`X-Request-ID`** — UUID forwarded to the Express server. Appears in the MCP stderr log and the Express request log, enabling cross-process debugging.
- **`traceparent`** — W3C Trace Context header (`00-{traceId}-{parentId}-01`). The trace ID is derived from the request UUID for deterministic correlation with Langfuse/Datadog APM traces.

Both values are included in every structured log entry written to stderr:

```json
{
  "tool": "search_accounts",
  "durationMs": 142,
  "ok": true,
  "truncated": false,
  "requestId": "a1b2c3d4-...",
  "traceparent": "00-a1b2c3d4....-5f6e7d8c...-01"
}
```

### Startup health probe

Before connecting the MCP transport, the server probes the Express backend's `/health` endpoint. If the backend is unreachable, a warning is logged to stderr but startup continues — the backend may come up later. This prevents a silent failure mode where the MCP server starts but every tool call fails with ECONNREFUSED.

### Graceful shutdown

The MCP server handles `SIGTERM` and `SIGINT` signals by calling `server.close()` to drain in-flight tool calls, then exits. A 5-second force-exit timeout ensures the process terminates even if `close()` hangs.

## File map

```
apps/server/src/mcp/
├── index.ts              # Entry point — registration, health probe, graceful shutdown
├── config.ts             # Env var parsing (token, URL, domains, timeout)
├── http-client.ts        # Fetch wrapper with auth, timeout, health check, request ID, traceparent
├── tool-annotations.ts   # MCP protocol annotations (readOnlyHint, destructiveHint)
├── tool-filter.ts        # Composable domain/exclusion-set filtering predicates
├── tool-serializer.ts    # Response serialization + structural array truncation
├── tool-caller.ts        # Tool call dispatch with structured logging, semaphore, progress
├── tool-result.ts        # Result assembly, logging, structured content decisions
├── tool-error.ts         # Error classification with [CODE] envelopes and hints
├── excluded-tools.ts     # Centralized MCP exclusion set (Snowflake, Notion, Runner)
├── instructions.ts       # Server instructions (tool classification, rules, context)
├── mcp-logger.ts         # MCP protocol logging via sendLoggingMessage
├── metrics.ts            # In-process per-tool call metrics (counts, errors, latencies)
├── prompts.ts            # MCP prompt templates (search-account, revenue-overview, etc.)
├── resources.ts          # MCP resources (tool-catalog, domain-guide, metrics)
├── telemetry.ts          # OTel tracing + metrics (OTLP export, graceful no-op when disabled)
├── setup.ts              # CLI entry point for pnpm mcp:setup
├── setup-helpers.ts      # Pure helpers for setup (parseTarget, getConfigPath)
└── __tests__/
    ├── fixtures.ts
    ├── config.test.ts
    ├── excluded-tools.test.ts
    ├── http-client.test.ts
    ├── integration.test.ts
    ├── mcp-logger.test.ts
    ├── metrics.test.ts
    ├── prompts.test.ts
    ├── resources.test.ts
    ├── setup-helpers.test.ts
    ├── tool-annotations.test.ts
    ├── tool-caller.test.ts
    ├── tool-definitions.test.ts
    ├── tool-error.test.ts
    ├── tool-filter.test.ts
    └── tool-serializer.test.ts

apps/server/src/routes/
├── tool-execute-handler.ts          # POST /api/v1/tools/execute
└── __tests__/
    └── tool-execute-handler.test.ts
```

## Troubleshooting

**"Missing required environment variable: CODA_MCP_TOKEN"**
Set `CODA_MCP_TOKEN` in `apps/server/.env`. Copy a Bearer token from browser devtools.

**Tools appear but calls fail with "HTTP 401: Unauthorized"**
The token has expired. Copy a fresh one from browser devtools.

**"Tool execution timed out"**
The default timeout is 30s. Increase with `CODA_MCP_TIMEOUT_MS=60000`. Some tools calling downstream APIs may take longer under load.

**No tools appear**
Check that the Coda server is running (`pnpm dev`) and `CODA_MCP_SERVER_URL` points to it. Check stderr output — the MCP server logs "Coda MCP server started — N tools registered" on startup.

**Only some tools appear**
Check `CODA_MCP_DOMAINS` — it may be filtering to a subset. Clear it (or remove the env var) to expose all tools.

## Connecting to QA

To use the MCP server against the deployed QA environment instead of local:

```bash
CODA_MCP_TOKEN=Bearer eyJ...qa-token
CODA_MCP_SERVER_URL=https://qa-coda-api.theorchard.io
```

The QA gateway routes through API Gateway → ALB → Fargate. The same auth token format works.

## Examples

### Simple: search for an account

```
You: Search for account Sony Music

Claude Code calls: search_accounts({ search_term: "Sony Music" })
→ Returns matching accounts with IDs, names, payment terms
```

### Intermediate: revenue overview

```
You: What's the revenue breakdown for account 12345?

Claude Code calls:
  1. revenue_overview_skill({ account_id: 12345 })
     → Net/gross revenue by store and artist
```

The `revenue_overview_skill` combines multiple internal queries into a single call. Use skills for complex lookups rather than chaining individual tools. Note: `account_id` is a numeric integer, not a string.

### Advanced: multi-step GraphQL exploration

```
You: Find all contracts for Sony Music that are about to be terminated

Claude Code calls:
  1. search_accounts({ search_term: "Sony Music" })
     → Finds account ID 12345
  2. search_graphql({ search_term: "contracts terminated" })
     → Discovers field names: contractStatus, terminationDate
  3. query_graphql({
       query: "{ contracts(accountId: 12345, status: TO_BE_TERMINATED) { id name terminationDate } }"
     })
     → Returns matching contracts
```

The key pattern: **always search before querying**. Use `search_graphql` to find field names, then `query_graphql` with the discovered schema.

### Advanced: adjustment workflow

```
You: Create an adjustment for account 12345, contract 67890

Claude Code calls:
  1. get_current_statement_period()
     → Returns active period (e.g. "2026-Q1")
  2. get_adjustment_types()
     → Lists valid adjustment types
  3. validate_adjustment_file({ ... })
     → Validates the adjustment data
  4. [Shows preview and asks for confirmation]
  5. submit_adjustment_batch({ ... })
     → Submits only after user confirms
```

Adjustment tools are **destructive** — the MCP server marks them with `destructiveHint: true` so Claude Code asks for confirmation before executing.

## Scope and limitations

### What MCP can do

- **Query data**: accounts, contracts, revenue, products, ledger, statement periods
- **Search schemas**: GraphQL field discovery, tool catalog browsing
- **Generate files**: Excel reports, PDF exports
- **Execute GraphQL**: arbitrary queries against the Abacus data warehouse
- **Create adjustments**: with step-by-step validation and user confirmation
- **Explore**: deep-dive into account/contract/revenue details via skills

### What MCP cannot do

| Capability              | Reason                                                    | Alternative                               |
| ----------------------- | --------------------------------------------------------- | ----------------------------------------- |
| **Snowflake queries**   | Requires identity-scoped connection pool (per-user OAuth) | Use the web UI or direct Snowflake client |
| **Notion integration**  | Requires per-user Notion OAuth token                      | Use the web UI's Notion panel             |
| **Code execution**      | Requires RPC client to the runner service                 | Use the web UI's sandbox                  |
| **Real-time streaming** | stdio transport is request/response only                  | Use the web UI for streaming responses    |
| **Multi-user sessions** | Single Bearer token per MCP process                       | Each user runs their own MCP server       |
| **File uploads**        | MCP spec doesn't support file input (SEP-2356 pending)    | Upload via the web UI, then query         |

### Where tools break

- **Large result sets**: Responses over 100K characters are truncated at array item boundaries. Refine queries with filters or pagination to stay under the limit.
- **Long-running queries**: The default 30-second timeout applies per tool call. Complex GraphQL queries or large exports may time out — increase `CODA_MCP_TIMEOUT_MS` or simplify the query.
- **Expired tokens**: Bearer tokens expire (typically after a few hours). When calls start failing with HTTP 401, copy a fresh token from browser devtools.
- **Rate limits**: The Express server has rate limiting. Rapid-fire tool calls from an aggressive MCP client may hit 429 responses.
- **Concurrent calls**: Limited to 5 in-flight tool calls (Semaphore). Additional calls queue until a slot opens.

## Appendix: End-to-end traces

These traces show every hop and transformation for debugging. Most users won't need this — see [Troubleshooting](#troubleshooting) for common issues.

### Tool call trace (`search_accounts`)

This trace shows every hop and transformation for a single `search_accounts` call:

```
1. Claude Code (MCP client)
   → JSON-RPC over stdio:
     {"jsonrpc":"2.0","method":"tools/call","id":1,
      "params":{"name":"search_accounts","arguments":{"search_term":"Sony"}}}

2. MCP SDK (in-process)
   → Dispatches to registered tool handler: createToolHandler("search_accounts")
   → Handler calls: handleToolCall(ctx, "search_accounts", {search_term:"Sony"}, extra)

3. tool-caller.ts: handleToolCall()
   → semaphore.acquire()  (blocks if 5 calls already in flight)
   → sendProgress(extra, token, 0, "Executing search_accounts...")
   → client.executeTool("search_accounts", {search_term:"Sony"})

4. http-client.ts: executeTool()
   → Generate requestId: "a1b2c3d4-e5f6-..."
   → Generate traceparent: "00-a1b2c3d4e5f6...-7890abcdef...-01"
   → POST http://localhost:8080/api/v1/tools/execute
     Headers: {
       "Content-Type": "application/json",
       "Authorization": "Bearer eyJ...",
       "X-Request-ID": "a1b2c3d4-e5f6-...",
       "traceparent": "00-a1b2c3d4e5f6...-7890abcdef...-01"
     }
     Body: {"name":"search_accounts","input":{"search_term":"Sony"}}

5. Express server: tool-execute-handler.ts
   → Zod validates request body
   → Creates ToolUse: {toolUseId: uuid, name: "search_accounts", input: {...}}
   → Calls executeTool(toolUse, req) from the tool registry
   → Auth middleware verifies Bearer token, resolves tenant

6. Tool handler: search_accounts handler
   → Calls ows-abacus-account API with forwarded auth headers
   → Returns: {data: [{id:1, name:"Sony Music Entertainment"}], error: null}

7. Express → MCP (response)
   → HTTP 200: {"data":[{"id":1,"name":"Sony Music Entertainment"}],"error":null}

8. http-client.ts: parse response
   → Content-Length check (< 50MB)
   → JSON parse → validate {data, error} shape
   → Return: {requestId, traceparent, outcome: {kind:"ok", data:[...]}}

9. tool-caller.ts: build result
   → serializeResponse(data) → JSON.stringify with 2-space indent
   → Truncate if > 100K chars (structural array truncation)
   → Log to stderr: {"tool":"search_accounts","durationMs":142,"ok":true,...}
   → Log to MCP: sendLoggingMessage({level:"info", ...})
   → Record metrics: metrics.record("search_accounts", 142, true, false)
   → sendProgress(extra, token, 1, "search_accounts completed")
   → semaphore.release()

10. MCP SDK → Claude Code (response)
    → JSON-RPC over stdio:
      {"jsonrpc":"2.0","id":1,"result":{
        "content":[{"type":"text","text":"[\n  {\n    \"id\": 1,\n    ...}]"}]
      }}
    Note: structuredContent is only set when the tool has an outputSchema
    AND data is a non-array object. Array results and tools without
    outputSchema omit it — the text field contains the pretty-printed JSON.
```

### Error trace (HTTP 401)

```
Steps 1-4: Same as above
5. Express returns: HTTP 401 {"error":"Token expired"}
6. http-client.ts: outcome = {kind:"http", status:401, detail:"Token expired"}
7. tool-error.ts: outcomeToError() →
   {code:"HTTP_ERROR", message:"HTTP 401: Token expired",
    hint:"Authentication failed. The Bearer token may have expired..."}
8. tool-error.ts: formatError() →
   "[HTTP_ERROR] HTTP 401: Token expired\nHint: Authentication failed..."
9. MCP SDK → Claude Code:
   {"result":{"content":[{"type":"text","text":"[HTTP_ERROR]..."}],"isError":true}}
```
