# MCP API Reference OneHazel's MCP server speaks [JSON-RPC 2.0](https://www.jsonrpc.org/specification) over HTTP `POST`. Every request goes to the same canonical endpoint: ``` POST https://api.onehazel.com/mcp-server Authorization: Bearer oh_live_ Content-Type: application/json ``` Your bearer token identifies the operator — no operator ID in the URL. ::: tip Backward compatibility The legacy form `POST https://api.onehazel.com/mcp-server/` still works. The server enforces that the segment matches the bearer's operator and returns `AUTH_OPERATOR_MISMATCH` if not. ::: For most operators, you won't write these calls by hand — Claude Desktop, Cursor, or any other MCP client handles the protocol. This page documents the wire format for when you're debugging or building a custom integration. ## Protocol overview - **Protocol version:** `2025-06-18` - **Server name:** `onehazel` - **Server version:** see `serverInfo.version` in the `initialize` response - **Transport:** JSON-RPC 2.0 over HTTP (Streamable HTTP). An SSE channel is mounted at `/mcp-server/events` for forward compatibility; current behaviour is a single comment frame then close. The supported methods are listed below. Anything else returns JSON-RPC `-32601 Method not found`. --- ## `initialize` {#initialize} Sent by the client when the connection opens. Returns the server's protocol version, declared capabilities, and identity. ### Request ```json { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "claude-desktop", "version": "0.7.0" } } } ``` ### Response ```json { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": { "listChanged": false } }, "serverInfo": { "name": "onehazel", "version": "0.1.0" } } } ``` ::: tip Notification follow-up Clients send a `notifications/initialized` notification after `initialize` to signal readiness. OneHazel accepts it and returns no response (per JSON-RPC 2.0 §4.1). ::: --- ## `ping` {#ping} Lightweight liveness check. Returns an empty result. ### Request ```json { "jsonrpc": "2.0", "id": 2, "method": "ping" } ``` ### Response ```json { "jsonrpc": "2.0", "id": 2, "result": {} } ``` --- ## `tools/list` {#tools-list} Returns the set of MCP tools the calling API key can invoke. Each tool corresponds to one OneHazel workflow with `mcp_exposed=true`. The list is filtered against the key's `mcp_workflow_allowlist`: - **`null` allowlist** — every MCP-exposed workflow on your operator - **Array allowlist** — only the listed workflows (empty array = none) ### Request ```json { "jsonrpc": "2.0", "id": 3, "method": "tools/list" } ``` ### Response ```json { "jsonrpc": "2.0", "id": 3, "result": { "tools": [ { "name": "refund_lookup", "description": "Look up a refund by customer ID and date range", "inputSchema": { "type": "object", "properties": { "customer_id": { "type": "string", "description": "Customer ID to look up" }, "since": { "type": "string", "format": "date" } }, "required": ["customer_id"] } } ] } } ``` - `name` is the workflow's `mcp_tool_name` — a sluggified, JSON-RPC-safe slug derived from the workflow's display name - `description` comes from the workflow's description field (operator-editable) - `inputSchema` is derived from the **trigger node's effective event schema** — fields the workflow expects on its trigger payload, including operator-defined overrides If you change the trigger schema in the workflow editor, the next `tools/list` reflects it. --- ## `tools/call` {#tools-call} Runs a workflow as if a real trigger event had arrived. The call is synchronous from the client's perspective: OneHazel runs the workflow against your live connections and returns the result inline. ### Request ```json { "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "refund_lookup", "arguments": { "customer_id": "cust_123", "since": "2026-05-01" } } } ``` - `params.name` — must match a tool returned by `tools/list` - `params.arguments` — must satisfy that tool's `inputSchema` (required fields present, types matching) ### Response ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [ { "type": "text", "text": "Workflow completed. 2 refunds found." } ], "isError": false, "_meta": { "execution_id": "exec_abc123", "duration_ms": 412 } } } ``` ### Execution semantics - `tools/call` runs the workflow in `mode='test'` — **real API calls, real side effects**, persisted to `workflow_executions` with `metadata.source='mcp'` - Failures (missing required input, downstream connector error, quota exceeded) come back as JSON-RPC results with `isError: true` and the failure detail in `content` - The trigger payload validation gate (see [Workflows → Trigger schemas](/workflows#trigger-schemas)) runs before any node executes; missing required fields produce a `MISSING_TRIGGER_FIELD` error and zero downstream nodes run - Workflows are billed against the calling operator's quota exactly as any other run — every billable node increments your Actions / AI Actions counters ::: warning Real side effects A `tools/call` invocation is a real workflow run. If your workflow sends emails, writes to a downstream system, or calls a paid third-party API, it'll do so. There is no dry-run mode on MCP. Use a least-privileged API key with a narrow allowlist when exposing workflows that have external side effects. ::: --- ## Error codes JSON-RPC standard codes (protocol-level): | Code | Name | When | |---|---|---| | `-32700` | Parse error | Body was not valid JSON | | `-32600` | Invalid Request | Missing `jsonrpc: "2.0"`, missing `method`, or malformed envelope | | `-32601` | Method not found | Unknown method name | | `-32602` | Invalid params | Params shape doesn't match the method | | `-32603` | Internal error | Unhandled server-side exception | OneHazel-specific codes (returned as JSON-RPC `-32001` with the SCREAMING_SNAKE code embedded in `error.message` as `code: XYZ`): | Code | HTTP status | Meaning | |---|---|---| | `AUTH_MISSING` | 401 | `Authorization` header not present | | `AUTH_INVALID_FORMAT` | 401 | Header isn't `Bearer `, or token isn't an `oh_live_*` key | | `AUTH_INVALID` | 401 | Key not found or doesn't match hash | | `AUTH_REVOKED` | 401 | Key exists but is revoked | | `MCP_NOT_ENABLED` | 403 | Key is valid but `mcp_enabled=false` — flip the toggle in **Configure MCP** | | `AUTH_OPERATOR_MISMATCH` | 403 | URL operator ID doesn't match the key's operator | | `MISSING_TRIGGER_FIELD` | 200* | A `tools/call` argument set is missing a required field from the workflow's trigger schema | | `QUOTA_EXCEEDED` | 200* | Operator has hit their Actions / AI Actions quota for the month | *Workflow-execution errors are returned as JSON-RPC results with `isError: true` and HTTP 200, not as JSON-RPC errors. The SCREAMING_SNAKE code lives inside the result content. The full OneHazel error taxonomy is enforced by the OH-134 drift guard — every `code: '...'` literal in Edge Functions is registered in `_shared/errors.ts:ERROR_CODES`. --- ## CORS `OPTIONS` preflight is supported on every route. The server echoes the request origin if it's in the configured allowlist (default `*` for the MCP endpoint; production may narrow this). Required request headers: `Authorization`, `Content-Type`. Required response headers are returned automatically. --- ## SSE channel ``` GET https://api.onehazel.com/mcp-server/events?session= Authorization: Bearer oh_live_ Accept: text/event-stream ``` Returns `text/event-stream` with a `: ping` heartbeat every 15s to keep proxies from closing the connection. The channel survives across many JSON-RPC requests. Clients that want **progress notifications** during long-running `tools/call`s should: 1. Open the SSE channel above with a chosen `session=` query parameter 2. On the sibling POST to `/mcp-server` include the header `Mcp-Session-Id: ` and `_meta.progressToken: ""` inside `params` 3. Receive `notifications/progress` frames on the SSE channel ::: info Distributed session affinity (OH-461) Progress events now reliably bridge across Edge Function instances. When the `MCP_SSE_REDIS_AFFINITY` server flag is on, the SSE GET handler subscribes to an operator-namespaced Upstash Redis pub/sub channel and the sibling POST publishes every `notifications/progress` frame to the same channel — so clients receive every frame regardless of which instance handled the POST. Heartbeats remain at 15s. The synchronous `tools/call` response is unchanged. With the flag off, the server falls back to per-instance behaviour (channel opens, heartbeats fire, but cross-instance progress events may not arrive). ::: Per-operator concurrent session cap is 5. The 6th `/events` connection returns `MCP_SESSION_LIMIT_EXCEEDED`.