> This page is for Agent Handler.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.merge.dev/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.merge.dev/_mcp/server.

# MCP integration

> How to connect agents to Agent Handler over MCP: config-file setup for Claude Desktop, Cursor, Windsurf, and VS Code, plus a reference custom client.

Agent Handler exposes its tools over MCP. Most agent runtimes - Claude Desktop, Cursor, Windsurf, VS Code, ChatGPT - speak MCP natively, so connecting them is a matter of pasting a URL into a config file. If you're building your own agent runtime, this page also covers the wire protocol.

## Prerequisites

You need three things, all from your dashboard.

* **Tool Pack ID.** From the [Tool Packs page](https://ah.merge.dev/tool-packs); copy from the URL of the pack you want to expose.
* **Registered User ID.** From the [Registered Users page](https://ah.merge.dev/registered-users); use a test user for development.
* **Access Key.** Production key for production users, test key for test users - they don't mix. See [Access Keys](/merge-agent-handler/administer/access-keys).

## The MCP URL

Every MCP connection points at this URL pattern:

```http
https://ah-api.merge.dev/api/v1/tool-packs/<TOOL_PACK_ID>/registered-users/<REGISTERED_USER_ID>/mcp
```

The URL identifies *what tools* (the Tool Pack) and *whose credentials* (the Registered User). The Access Key in the `Authorization` header authorizes the call. All three values are needed on every connection.

The URL selects; the key authorizes. Editing the Registered User ID to a user your key does not cover fails the call rather than returning that user's data, so a [user-scoped key](/merge-agent-handler/secure/scoping-access-per-user) is what keeps one end user's agent out of another's. An organization-wide production key covers every Registered User you own, which is why it belongs in your backend and not in an agent session.

For the [Agent Handler for Employees](/merge-agent-handler/setup/agent-handler-for-employees) setup, there's a simplified URL that handles Tool Pack and user resolution through SSO instead.

### Scope a connection to specific Connectors

Add a `connectors` query parameter to narrow one connection to part of the Tool Pack. Reach for this when one client should see one Connector, a Slack-only agent for instance, and you don't want a narrower Tool Pack just for it.

```http
https://ah-api.merge.dev/api/v1/tool-packs/<TOOL_PACK_ID>/registered-users/<REGISTERED_USER_ID>/mcp?connectors=slack
```

The repeated form (`?connectors=slack&connectors=jira`) and the comma-separated form (`?connectors=slack,jira`) both parse. What the scope does:

* `tools/list` returns only those Connectors' tools plus the meta-tools, and `tools/call` on anything outside the scope fails with a JSON-RPC invalid-params error
* The scope is a ceiling rather than a default: `search_tools` intersects its own `connector_slugs` argument with it, and `request_tool_access` rejects tool slugs outside it
* A slug that doesn't exist fails the request and names the bad slug
* Omitting the parameter exposes the whole Tool Pack
* The parameter works the same way on the simplified URL used by [Agent Handler for Employees](/merge-agent-handler/setup/agent-handler-for-employees)

### Page a large tool list

A Tool Pack with hundreds of tools answers `tools/list` in one large response, which some clients can't parse. Add `?paginated=true` to the MCP URL to page it instead. Paging is opt-in because a client that ignores `nextCursor` would otherwise see the first 100 tools and nothing else, so turn it on when your client handles cursors and leave it off when it doesn't.

* Each page carries 100 tools and a `nextCursor`, which you pass back as the `cursor` parameter on the next `tools/list` call
* The last page omits `nextCursor`
* Page size is fixed
* A cursor Agent Handler doesn't recognize returns JSON-RPC error `-32602`
* Resumption keys off the sorted tool name, so a Tool Pack edit between two pages can't make you skip or repeat a tool

## Connect an MCP client

#### Claude Desktop

Open **Settings → Developer → Edit Config** and paste:

**`claude_desktop_config.json`**

```json claude_desktop_config.json
{
  "mcpServers": {
    "agent-handler": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://ah-api.merge.dev/api/v1/tool-packs/<TOOL_PACK_ID>/registered-users/<REGISTERED_USER_ID>/mcp",
        "--header",
        "Authorization: Bearer ${AUTH_TOKEN}"
      ],
      "env": {
        "AUTH_TOKEN": "<YOUR_API_KEY>"
      }
    }
  }
}
```

Restart Claude Desktop. The first launch downloads `mcp-remote` (Node 20+ required).

#### Cursor

Edit `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):

```json
{
  "mcpServers": {
    "agent-handler": {
      "url": "https://ah-api.merge.dev/api/v1/tool-packs/<TOOL_PACK_ID>/registered-users/<REGISTERED_USER_ID>/mcp",
      "headers": {
        "Authorization": "Bearer <YOUR_API_KEY>"
      }
    }
  }
}
```

Reload Cursor. Verify in **Cursor Settings → MCP**.

#### Windsurf

Edit `~/.codeium/windsurf/mcp_config.json`:

```json
{
  "mcpServers": {
    "agent-handler": {
      "serverUrl": "https://ah-api.merge.dev/api/v1/tool-packs/<TOOL_PACK_ID>/registered-users/<REGISTERED_USER_ID>/mcp",
      "headers": {
        "Authorization": "Bearer <YOUR_API_KEY>"
      }
    }
  }
}
```

#### VS Code

Edit `.vscode/mcp.json` (workspace) or run **MCP: Open User Configuration** for global:

```json
{
  "servers": {
    "agent-handler": {
      "type": "http",
      "url": "https://ah-api.merge.dev/api/v1/tool-packs/<TOOL_PACK_ID>/registered-users/<REGISTERED_USER_ID>/mcp",
      "headers": {
        "Authorization": "Bearer <YOUR_API_KEY>"
      }
    }
  }
}
```

#### Other clients

Most MCP clients accept a variant of:

```json
{
  "mcpServers": {
    "agent-handler": {
      "url": "https://ah-api.merge.dev/api/v1/tool-packs/<TOOL_PACK_ID>/registered-users/<REGISTERED_USER_ID>/mcp",
      "headers": {
        "Authorization": "Bearer <YOUR_API_KEY>"
      }
    }
  }
}
```

If your client doesn't support custom headers, use the `mcp-remote` wrapper as a proxy (see the Claude Desktop tab - same pattern applies elsewhere).

## Build a custom MCP client

Use the official MCP SDK when your agent is a custom runtime. Anthropic ships an SDK in both Python and TypeScript, and both handle the JSON-RPC framing, session ID generation, and streaming response handling. For custom transport behavior or a runtime that has no SDK, build against the wire protocol instead.

### Use the MCP SDK

#### Python

**`agent_runtime.py`**

```python agent_runtime.py
import asyncio
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamablehttp_client

API_KEY = "<YOUR_API_KEY>"
TOOL_PACK_ID = "<TOOL_PACK_ID>"
REGISTERED_USER_ID = "<REGISTERED_USER_ID>"

async def run():
    url = (
        f"https://ah-api.merge.dev/api/v1/tool-packs/{TOOL_PACK_ID}"
        f"/registered-users/{REGISTERED_USER_ID}/mcp"
    )
    headers = {"Authorization": f"Bearer {API_KEY}"}

    async with streamablehttp_client(url, headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            print(tools)

            result = await session.call_tool(
                "weather__get_forecast",
                {"location": "Stockholm", "days": 3},
            )
            print(result)

asyncio.run(run())
```

#### TypeScript

**`agent-runtime.ts`**

```typescript agent-runtime.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const API_KEY = "<YOUR_API_KEY>";
const TOOL_PACK_ID = "<TOOL_PACK_ID>";
const REGISTERED_USER_ID = "<REGISTERED_USER_ID>";

const url = new URL(
  `https://ah-api.merge.dev/api/v1/tool-packs/${TOOL_PACK_ID}` +
    `/registered-users/${REGISTERED_USER_ID}/mcp`,
);

const transport = new StreamableHTTPClientTransport(url, {
  requestInit: {
    headers: { Authorization: `Bearer ${API_KEY}` },
  },
});

const client = new Client({ name: "agent-runtime", version: "1.0.0" });
await client.connect(transport);

const tools = await client.listTools();
console.log(tools);

const result = await client.callTool({
  name: "weather__get_forecast",
  arguments: { location: "Stockholm", days: 3 },
});
console.log(result);
```

### Build against the raw protocol

MCP is JSON-RPC 2.0 over HTTP. Three core methods cover the agent surface.

#### Python

**`custom_client.py`**

```python custom_client.py
import uuid
import aiohttp

class MCPClient:
    def __init__(self, url: str, api_key: str):
        self.url = url
        self.session_id = str(uuid.uuid4())
        self.request_id = 0
        self.headers = {
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "Accept": "application/json, text/event-stream",
            "Mcp-Session-Id": self.session_id,
        }

    async def _send(self, method: str, params: dict | None = None) -> dict:
        self.request_id += 1
        payload = {"jsonrpc": "2.0", "id": self.request_id, "method": method}
        if params:
            payload["params"] = params

        async with aiohttp.ClientSession() as http:
            async with http.post(self.url, headers=self.headers, json=payload) as resp:
                if "Mcp-Session-Id" in resp.headers:
                    self.session_id = resp.headers["Mcp-Session-Id"]
                    self.headers["Mcp-Session-Id"] = self.session_id
                resp.raise_for_status()
                return await resp.json()

    async def initialize(self):
        return await self._send("initialize", {"protocolVersion": "2024-11-05"})

    async def list_tools(self):
        return await self._send("tools/list")

    async def call_tool(self, name: str, arguments: dict):
        return await self._send("tools/call", {"name": name, "arguments": arguments})
```

#### TypeScript

**`custom-client.ts`**

```typescript custom-client.ts
import { randomUUID } from "node:crypto";

export class MCPClient {
  private sessionId: string;
  private requestId = 0;
  private headers: Record<string, string>;

  constructor(
    private url: string,
    apiKey: string,
  ) {
    this.sessionId = randomUUID();
    this.headers = {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      Accept: "application/json, text/event-stream",
      "Mcp-Session-Id": this.sessionId,
    };
  }

  private async send(method: string, params?: Record<string, unknown>) {
    this.requestId += 1;
    const payload: Record<string, unknown> = {
      jsonrpc: "2.0",
      id: this.requestId,
      method,
    };
    if (params) payload.params = params;

    const resp = await fetch(this.url, {
      method: "POST",
      headers: this.headers,
      body: JSON.stringify(payload),
    });

    const newSessionId = resp.headers.get("Mcp-Session-Id");
    if (newSessionId) {
      this.sessionId = newSessionId;
      this.headers["Mcp-Session-Id"] = newSessionId;
    }

    if (!resp.ok) {
      throw new Error(`MCP request failed: ${resp.status}`);
    }
    return resp.json();
  }

  initialize() {
    return this.send("initialize", { protocolVersion: "2024-11-05" });
  }

  listTools() {
    return this.send("tools/list");
  }

  callTool(name: string, args: Record<string, unknown>) {
    return this.send("tools/call", { name, arguments: args });
  }
}
```

The session ID rotates on Agent Handler's side, so read it back from the response header and use the new value for subsequent requests.

For streaming responses (large tool outputs), set `Accept: text/event-stream` and parse the response as Server-Sent Events. The SDKs handle this automatically; the raw clients above don't.

### Pin a protocol revision

The protocol revision is negotiated on `initialize` and can be pinned per request with the `MCP-Protocol-Version` header. Which revision you land on decides what a tool result contains.

| Revision                                               | A tool result carries                                              |
| ------------------------------------------------------ | ------------------------------------------------------------------ |
| `2024-11-05`, `2025-03-26`                             | `content`, `isError`, and `_meta`                                  |
| `2025-06-18`, `2025-11-25`                             | The same, plus `structuredContent` when the tool returns an object |
| Any other revision, including a draft newer than these | `content`, `isError`, and `_meta`, rather than failing the call    |

`structuredContent` holds the same JSON that is already serialized into `content[0].text`, and that duplication roughly doubles the size of an object-returning result, so pin an older revision when context budget matters more than a typed result. Tools that return a string, the meta-tools, and the re-authentication payload carry `content`, `isError`, and `_meta` on every revision.

### Open a GET stream

A `GET` on the Tool Pack MCP URL opens a Server-Sent Events stream that carries keep-alives only. JSON-RPC responses always come back on the `POST` that made the request, so nothing depends on holding the stream open. The stream closes after roughly ten minutes, which keeps an abandoned connection from living for hours; EventSource clients reconnect on their own. The simplified employee URL answers `GET` with `405`, because it offers no push stream at all.

## Correlate a response with the logs

Every JSON-RPC result carries `_meta["dev.merge/request_id"]`, the same value as the `X-Request-ID` response header. On a protocol error the id rides in `error.data` instead, since the MCP schema puts `_meta` on results and not on the error object.

Filter the [Tool Call Logs](/merge-agent-handler/observe/tool-call-logs) by Request ID and you land on that exact call, with the outbound API requests Agent Handler made nested underneath it. Log the id on your side for every call, and a report of one misbehaving call becomes a single row to open rather than a search.

## Custom headers

Any header you send with an `X-` prefix is captured as metadata on the tool call and shown in the [Tool Call Logs](/merge-agent-handler/observe/tool-call-logs). Useful for tracing - set `X-Chat-Id` to your session ID and you can filter logs to one conversation. See [Custom headers for MCP](/merge-agent-handler/build/connecting-agents/custom-headers-for-mcp).

## Read tool schemas over REST

When an SDK or a code generator needs the catalog without opening an MCP session, read it from the Connectors endpoints.

| Request                                                    | Per-tool fields in the response                                                                                                |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `GET /api/v1/connectors/{slug}/?include_tool_details=true` | `input_schema`, `output_schema`, `credit_type`, `annotations`, OAuth `scopes`, and the third-party `endpoints` behind the tool |
| `GET /api/v1/connectors/{slug}/`                           | Name, description, and credit type                                                                                             |
| `GET /api/v1/connectors/`                                  | Name, description, and credit type, with or without the parameter                                                              |

`output_schema` is left out for tools whose return type doesn't resolve to a schema, so treat it as optional. `GET /api/v1/connectors/?search=` filters the list on Connector name and slug.

## Common issues

* **Tools not appearing in the client.** Restart the client after editing config. Some clients cache aggressively; a hard restart usually fixes it. Check the client's MCP log for connection errors.
* **`401` on every call.** Double-check the `Authorization` header format (`Bearer ` is required and case-sensitive) and that the API key matches the Registered User's environment.
* **Session ID mismatch errors.** Capture and re-use the session ID from response headers. Don't generate a new one per request.

For a full troubleshooting catalog, see [Troubleshooting](/merge-agent-handler/resources/troubleshooting).

## Next

For command-line tool search and execution, use the [Merge CLI](/merge-agent-handler/build/cli/merge-cli).