# MCP (Model Context Protocol) Server Integration The orchestrator can call tools hosted on external **MCP servers** (OAuth-secured SaaS like Canva, or self-hosted servers with static API keys). Connected MCP tools are exposed to pieces via `mcp____` names, and can be allowlisted with `mcp____*` wildcards in `piece.allowed_tools`. This document is the **operator runbook** for setting up, troubleshooting, and maintaining MCP integrations. For internal design notes, see ## Prerequisites ### 1. Generate `MCP_ENCRYPTION_KEY` All OAuth client secrets, static API tokens, and user access tokens are encrypted at rest with AES-256-GCM. The key is a 32-byte hex string. ```bash openssl rand -hex 32 ``` Set it in your environment before starting the orchestrator: ```bash export MCP_ENCRYPTION_KEY= scripts/server.sh start ``` If `MCP_ENCRYPTION_KEY` is **not** set, the MCP subsystem boots fail-soft: a warning is logged and all MCP endpoints return 503 / are hidden from the UI. Other features continue normally. > ⚠ **Key rotation invalidates all encrypted tokens.** Plan rotation as a > migration event: ask every user to re-connect. There is no automatic > re-encryption today. ### 2. Optional: `mcp.allow_private_addresses` By default, MCP requests are routed through the SSRF strict-check, which rejects loopback and private-IP addresses. For **self-hosted MCP servers** on `localhost` or LAN, set in `config.yaml`: ```yaml mcp: allow_private_addresses: true ``` This skips the SSRF check entirely (same semantics as `insecureLocalTestMode`). **Only enable in trusted networks.** Better cidr-aware controls are tracked in the Phase 8 follow-ups. ## Authentication modes There are two `auth_kind` values for an MCP server registration: | `auth_kind` | Use case | Setup | |---|---|---| | `oauth` | SaaS providers (Canva, GitHub Apps, etc.) | Register OAuth app in provider's dev portal, capture `client_id` + `client_secret`, plug into Settings UI. User clicks **Connect** to authorize. | | `api_key` | Self-hosted MCP, providers with personal access tokens | Generate a bearer token on the provider side, paste it into the server registration. No per-user dance. | ## Global vs user-owned servers | Owner | Visibility | Who can register | |---|---|---| | **Global** (`owner_id IS NULL`) | All users see it on the Connections panel | Admins via `/api/mcp/servers` | | **User-owned** (`owner_id = userId`) | Only the owner sees / uses it | Any user via `/api/mcp/user-servers` | Admins can also register **user-owned** servers (they're "users too" from the API's perspective). The Settings → User Folder → MCP Servers panel has both sections — global at top (admin only), user's own below. ## Setup walkthroughs ### A. OAuth server (global, admin-managed) 1. **Provider portal**: register a new OAuth client. Configure the callback URL: ``` https:///auth/mcp//callback ``` where `` is the slug you'll use in step 2 (e.g. `canva`). 2. **Admin UI** → Settings → User Folder → **Global Servers** → **+ Add server**: - **ID**: `canva` (matches callback URL) - **Name**: `Canva` (display only) - **URL**: `https://api.canva.com/mcp` (the MCP endpoint, not the OAuth host) - **Auth**: OAuth - **Client ID / Secret / Scopes**: from the provider portal 3. The orchestrator fetches `/.well-known/oauth-authorization-server` and stores `issuer`, `authorization_endpoint`, `token_endpoint`, `discovery_fingerprint`. If discovery fails, see Troubleshooting below. 4. **Each user** clicks **Connect** on the Connections panel → OAuth flow runs → access + refresh tokens persisted (encrypted) under `user_mcp_tokens`. 5. From here on, tools cached for that server are usable by the user as `mcp__canva__`. ### B. api_key server (self-hosted, user-managed) 1. **Provider**: generate a bearer token (e.g. `sk-...`). For most self-hosted MCP, this is a static value in the server's config. 2. **User UI** → Settings → User Folder → **Your Servers** → **+ Add server**: - **ID**: `my-tools` - **Name**: `My self-hosted tools` - **URL**: `http://10.0.0.10:8080/mcp` (or wherever) - **Auth**: API Key - **Static Token**: paste the bearer 3. No OAuth dance — `tools/list` and `tools/call` flow uses the static token directly. Token is encrypted at rest in `mcp_servers.static_token_enc`. 4. If using a private IP, ensure `mcp.allow_private_addresses: true` is set (see Prerequisites). ## How tools flow into pieces The orchestrator caches `tools/list` results in `mcp_server_tools`, refreshed on registration and on explicit admin refresh (no automatic TTL today). Piece authors expose them via `allowed_tools`: ```yaml movements: - name: design allowed_tools: - Read - Write - mcp__canva__* # all tools from server `canva` - mcp__my-tools__lint # a specific tool from `my-tools` ``` The wildcard `mcp____*` expands to all currently-cached tools for that server. ## Job parking and resume When a piece requires an MCP server (via `required_mcp` frontmatter or discovered from `allowed_tools`) and the user has no connection, the worker parks the job: - `jobs.status = 'waiting_human'` - `jobs.wait_reason = 'mcp_auth_required'` - A comment is posted on the local task with a Connect link When the user completes the OAuth flow, `resumeWaitingJobs(userId, serverId)` re-queues every parked job for that pair. api_key servers don't park (server-level credentials, not per-user). ## Troubleshooting ### Discovery fails (`/api/mcp/servers` returns 400 on POST) Symptoms: registration fails with `Discovery fetch failed: ` or `authorization_endpoint origin must match MCP url origin`. Causes: - Provider doesn't expose `/.well-known/oauth-authorization-server` at the origin of the MCP URL. Check with `curl /.well-known/oauth-authorization-server`. - Cross-origin `authorization_endpoint` or `token_endpoint` — orchestrator enforces same-origin to prevent malicious redirects. - SSRF block on a private-IP URL — set `mcp.allow_private_addresses: true`. ### OAuth callback fails with 400 Symptoms: After clicking **Connect**, the browser lands on `/auth/mcp//callback` and gets `400 Bad Request`. Causes: - **State mismatch**: `code` or `state` query param missing, or the `state` was already consumed (single-use, by design). Re-trigger the flow from scratch. - **Token endpoint rejected the code**: check provider portal for misconfigured redirect URI. The orchestrator uses exactly `/auth/mcp//callback`. ### Tool calls return 401 silently The token may have expired and refresh failed. Check the audit log: ```sql SELECT detail FROM audit_log WHERE action LIKE 'mcp.%' AND created_at > datetime('now', '-1 hour'); ``` `mcp.token.refresh` rows with status non-200, or `mcp.token.invalid_grant` rows indicate the user's refresh token is gone. They need to re-connect. ### "MCP_ENCRYPTION_KEY not configured" The env var was not set, OR it's the wrong length (must be exactly 64 hex chars = 32 bytes). Verify with: ```bash echo -n "$MCP_ENCRYPTION_KEY" | wc -c # should be 64 ``` ### Private-IP MCP rejected You're hitting `http://localhost:...` or a RFC1918 address and SSRF is blocking it. Set in `config.yaml`: ```yaml mcp: allow_private_addresses: true ``` then restart (`scripts/server.sh restart`). Note: `loadConfig().mcp` is read at boot; runtime hot-reload doesn't propagate. ## Log prefixes Grep the orchestrator log for: | Prefix | Subsystem | |---|---| | `[mcp:registry]` | Server CRUD, discovery snapshots | | `[mcp:token]` | hasToken, getValidToken, refresh, invalidation | | `[mcp:oauth]` | OAuth start / callback handlers | | `[mcp:client]` | SDK transport connect / close | | `[mcp:executor]` | callTool execution + content[] handling | | `[mcp:aggregator]` | tool list resolution, dispatch | ## Audit log entries | `action` | Trigger | Detail (redacted) | |---|---|---| | `mcp.server.upsert` | Admin or user adds/updates a server | `serverId`, `authKind` | | `mcp.oauth.start` | User clicks Connect on an oauth server | `serverId` | | `mcp.oauth.callback` | User completes OAuth dance | `serverId`, `success` | | `mcp.token.refresh` | getValidToken triggers refresh | `serverId`, `outcome` | | `mcp.token.invalid_grant` | Refresh failed with `invalid_grant` | `serverId` | | `mcp.call_tool` | A tool was invoked | `serverId`, `toolName`, `argsHash` | Token strings, OAuth codes, and Authorization headers are scrubbed by `src/mcp/redact.ts` before being written to detail JSON. ## SSRF and private IPs The strict SSRF check (`src/mcp/ssrf-strict.ts`) is enforced for **all** MCP fetches (discovery, token, /mcp). It: 1. Resolves the URL hostname to an IP. 2. Rejects loopback (`127.0.0.0/8`, `::1`), RFC1918, link-local, multicast, CGNAT (`100.64.0.0/10`), and broadcast. 3. Pins the resolved IP to prevent TOCTOU attacks (`pinnedFetch`). `mcp.allow_private_addresses: true` bypasses **all** of the above. Use only in trusted dev/CI environments. Granular allow/deny (e.g. allow loopback but deny multicast) is a Phase 8 follow-up. ## Future work - Refresh-on-401 retry inside `tool-executor` (currently a 401 fails the call; the user must re-trigger) - Stdio transport for local MCP servers (no HTTP) - Org-scoped shared tokens (schema already has `scope_type` / `scope_id`) - TTL-based opportunistic refresh of `mcp_server_tools` cache - `MCP_ENCRYPTION_KEY` rotation without invalidating tokens - Cidr-aware private-IP allowlist (replace blanket `allow_private_addresses`)