# merge-api/merge-mcp

> Small Python stdio MCP server that turns the Merge unified-API OpenAPI schema into scope-filtered tools at startup.

- Category: [API layer & connectors](https://llms-technical-reviews.com/connectors/)
- Repository: https://github.com/merge-api/merge-mcp (reviewed at commit `171177a7d966c3dda65a990e9f7c449bd9c4067c`, 2025-04-30)
- Stars: 20 · Language: Python · License: n/a
- Canonical page: https://llms-technical-reviews.com/p/merge-mcp/

## Overview

merge-mcp is Merge's MCP server for its unified API (HRIS, ATS, accounting, ticketing and the other Merge categories). It is a small Python package, about 1,200 lines across ten modules, that runs as a local stdio process next to an MCP client such as Claude Desktop. You give it one Merge API key and one Linked Account token. It then exposes that one linked account's Merge endpoints as MCP tools.

There is no hand-written tool list. At startup the server asks Merge which common models the linked account may read or write, downloads Merge's OpenAPI schema, keeps only the operations those permissions allow, and turns each remaining `operationId` into an MCP tool. A tool call is a thin HTTP proxy back to the Merge REST API. Authentication, OAuth with the underlying HR or ATS vendor, data sync and storage all happen inside Merge's cloud. This repository only translates between MCP and Merge's REST surface.

The code is a 0.1.x release whose last commit is from April 2025. It works for the main path, but it has rough edges that a larger project would have caught.

## Architecture

```mermaid
flowchart LR
  C["MCP client"] -->|stdio| S["serve(): mcp Server"]
  S --> TM["ToolManager"]
  TM --> SM["ScopeManager"]
  TM --> SI["SchemaIndex"]
  TM --> SP["SchemaParser"]
  TM --> MC["MergeAPIClient (singleton)"]
  SI --> MC
  MC -->|"httpx, Bearer + X-Account-Token"| M["Merge API (US / EU / APAC)"]
```

| Component | Path | Role |
|---|---|---|
| Entry point | `src/merge_mcp/main.py` | `merge-mcp` console script; parses `--scopes` and runs `serve` |
| MCP server | `src/merge_mcp/server.py` | Builds the `mcp.server.Server`, registers `list_tools` / `call_tool`, runs over stdio |
| API client | `src/merge_mcp/client.py` | Singleton httpx wrapper: credentials, tenant URL, category routing, retries |
| Scope filter | `src/merge_mcp/scope_manager.py`, `services.py` | Intersects `--scopes` with the account's enabled read/write permissions |
| Schema index | `src/merge_mcp/schema_index.py` | Indexes OpenAPI paths by tag and method, keeps only permitted operations |
| Schema parser | `src/merge_mcp/schema_parser.py` | Turns parameters and request-body components into a JSON Schema `inputSchema` |
| Tool manager | `src/merge_mcp/tool_manager.py` | Builds the tool list once, handles `meta` endpoints, executes calls |
| Constants | `src/merge_mcp/constants.py` | Scope names, method-to-scope map, excluded params, tag naming exceptions |

## How a request flows

Startup does almost all of the work:

1. **Launch.** `main()` parses an optional `--scopes` list and calls `asyncio.run(serve(scopes))` ([main.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/main.py#L3-L22)).
2. **Account discovery.** `serve` calls `MergeAPIClient.get_initialized_instance()`, then `ToolManager.create` ([server.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/server.py#L11-L14)). The client reads `MERGE_API_KEY`, `MERGE_ACCOUNT_TOKEN` and `MERGE_TENANT`, picks `api.merge.dev`, `api-eu.merge.dev` or `api-ap.merge.dev` ([client.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L60-L84)), and learns the account's category from `/api/hris/v1/account-details` ([client.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L121-L150)). After that, relative paths are rewritten to `/api/{category}/v1/...` ([client.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L261-L284)).
3. **Permissions.** `fetch_enabled_scopes` reads `linked-account-scopes` into `CommonModelScope(model_name, is_read_enabled, is_write_enabled)` records ([client.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L162-L187)). `ScopeManager.get_available_scopes` intersects them with the requested scopes. With no `--scopes`, everything the account allows is kept ([scope_manager.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/scope_manager.py#L39-L74)).
4. **Schema index.** The OpenAPI document is fetched from `schema` without auth headers ([client.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L189-L206)). `SchemaIndex` maps each model name to an OpenAPI tag (`TimeOffRequest` becomes `time-off-requests`) and drops any operation whose tags are not all permitted. GET needs read, POST and PATCH need write ([schema_index.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/schema_index.py#L106-L171)).
5. **Tool build.** `fetch_tools` converts every remaining operation concurrently with `asyncio.gather` ([tool_manager.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/tool_manager.py#L74-L95)). `meta` and `download` operations are skipped. The `operationId` becomes the tool name and the OpenAPI description becomes the tool description ([tool_manager.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/tool_manager.py#L97-L139)).
6. **Call.** On `call_tool`, `ToolManager.call_tool` looks the operation up by name and `_build_request_from_schema_and_arguments` sorts the arguments into path substitutions, query parameters and JSON body fields ([tool_manager.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/tool_manager.py#L208-L246)).
7. **Proxy and return.** `_make_request` sends it with `with_retry=True`. The response, or the error, is formatted into one `TextContent` string ([tool_manager.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/tool_manager.py#L248-L268)).

## Key components

### Scope model

Scopes have the form `<category>.<CommonModel>:<read|write>`. The parser throws away the category prefix and lowercases the model name. A scope without a permission grants both read and write ([services.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/services.py#L6-L44)). Because the category is discarded, `ats.Job` against an HRIS account simply matches nothing. That behaviour is safe, but it is silent. Your `--scopes` can only narrow what the linked account already allows in Merge. They never widen it.

### Write tools and `meta` endpoints

Merge wraps POST and PATCH bodies in a `model` object whose real shape depends on the integration, so Merge publishes `/meta/post` and `/meta/patch` endpoints. When a write operation has a `model` property and no other required fields, the tool manager calls the meta endpoint during startup and merges its `request_schema` properties into the tool's input schema ([tool_manager.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/tool_manager.py#L141-L180)). Otherwise it publishes the matching `*_meta_*_retrieve` operation as a companion tool and adds "should be called before" to both descriptions ([tool_manager.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/tool_manager.py#L182-L206)). This is the only clever part of the codebase, and it is a sensible answer to per-integration schemas.

### Schema parsing

`SchemaParser` drops header parameters and the three `include_*_data` flags. It copies the parameters it keeps into JSON Schema, and it resolves a request body only through a direct `$ref` to a component ([schema_parser.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/schema_parser.py#L20-L103)). Body fields are appended to the operation's `parameters` with `in: "body"` so that the call path can route them later.

### HTTP client

`MergeAPIClient` is a process-wide singleton. Every request carries `Authorization: Bearer`, `X-Account-Token` and a per-process `MCP-Session-ID` ([client.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L237-L259)). `_make_request` opens a fresh `httpx.AsyncClient` per call, has a 10-second default timeout, and retries any `httpx.HTTPError` with doubling backoff. There is no status-code filter, so a 400 or 404 is retried like a 503 ([client.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L289-L387)).

## Extending it

There are no plugin hooks. The supported way to shape the tool surface is `--scopes`, plus the permissions you configure on the linked account in Merge. New Merge endpoints show up automatically because the schema is fetched at every start. Anything else means editing the code: `EXCLUDED_PARAMETERS` and the tag-naming exceptions live in `constants.py`, and transport is fixed to `stdio_server()` in `server.py`.

## Running it

- `uvx merge-mcp`, configured as an MCP server with `MERGE_API_KEY` and `MERGE_ACCOUNT_TOKEN` in its environment, plus optional `MERGE_TENANT` (`US`, `EU`, `APAC`) and optional `--scopes ...` arguments.
- Python 3.10 or newer. The only declared runtime dependencies are `mcp` and, oddly, `isort`. `httpx` and `pydantic` arrive through `mcp`.
- One process serves one linked account. To serve several end customers you run several processes with different tokens.
- Startup makes several network calls (account details, scopes, the full schema, and one meta call per qualifying write operation). Tools can take a while to appear, as the README warns.

## Strengths and caveats

- **Strength: zero maintenance surface.** Tools come from Merge's live OpenAPI schema, so the server tracks Merge's API without releases.
- **Strength: least privilege by construction.** Tools are filtered by the account's real read/write permissions before the client ever sees them, and `--scopes` narrows them further.
- **Strength: tiny and readable.** The whole request path is a few hundred lines, and there is a reasonable unit-test suite with mocked httpx.
- **Caveat: malformed parameter schemas.** `convert_parameter_to_property` returns `{name: schema}`, and the caller stores that whole dict under `name` again. Every path and query property is therefore nested one level too deep (`properties.id.id.type`). The client sees no type or description for those arguments ([schema_parser.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/schema_parser.py#L78-L85), [L135-L155](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/schema_parser.py#L135-L155)). The unit tests only check the inner function, so they do not catch it.
- **Caveat: stdout noise.** `main()` and `serve()` `print` status lines to stdout, which is also the MCP stdio channel ([server.py](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/server.py#L28-L31)).
- **Caveat: blunt retries and verbose errors.** All HTTP errors are retried, including 4xx. A failed call echoes the full operation schema and arguments back to the model. A success returns the Python `repr` of the response, not JSON.
- **Caveat: permission mapping is GET vs POST/PATCH only.** Any other method passes both permission checks in `_build_index_for_available_scopes`. One linked account per process, no pagination helpers, and no tool-list refresh without a restart.
- **Caveat: unlicensed and quiet.** There is no license file and no license field, and there are no commits since April 2025.

*Sources: code at 171177a, deepwiki-open wiki (12 pages), OpenDeepWiki wiki (8 pages), verified Q&A.*

## How merge-api/merge-mcp answers the API layer & connectors questions

### How is third-party authentication implemented? (answered)

There is no OAuth2 flow, credential encryption, or token refresh mechanism in the repo. Authentication is a simple API-key-based bearer-token scheme implemented in `MergeAPIClient.__init__` (`client.py:60-66`). The client reads `MERGE_API_KEY` and `MERGE_ACCOUNT_TOKEN` from environment variables (or constructor parameters) and stores them as instance fields. Every outgoing request to the Merge API includes an `Authorization: Bearer {api_key}` header and an `X-Account-Token` header, built in `_get_headers` (`client.py:237-258`). The optional `MERGE_TENANT` env var selects the regional base URL (`US` → `api.merge.dev`, `EU` → `api-eu.merge.dev`, `APAC` → `api-ap.merge.dev`) (`client.py:77-84`). A generated `MCP-Session-ID` (UUID4) is also sent on every request (`client.py:254`). The singleton pattern on the client prevents multiple instances. The Merge API handles the actual account linking and multi-tenant credential management server-side — this MCP server is just a thin proxy that passes the credentials through. The OpenAPI schema endpoint can be called "unauthorized" (no auth headers), but all other endpoints require authentication (`client.py:250-253`).


Citations: [src/merge_mcp/client.py:60-84](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L60-L84) · [src/merge_mcp/client.py:237-258](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L237-L258) · [src/merge_mcp/client.py:289-306](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L289-L306) · [src/merge_mcp/client.py:34-46](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L34-L46)

### How is an integration / connector defined? (answered)

Integrations are defined entirely dynamically at runtime — there is no static manifest, config file, or codegen step. On startup, `ToolManager.create` (`tool_manager.py:43-66`) fetches the full OpenAPI 3.0 schema from the Merge API via `MergeAPIClient.get_openapi_schema()` (`client.py:189-206`), which calls `GET /schema` (without auth headers since the schema endpoint is public). This schema is passed to `SchemaIndex.__init__` (`schema_index.py:29-35`), which indexes all path/operation entries by tag (model name), method (GET/POST/PATCH), and operationId. The `SchemaParser` (`schema_parser.py`) extracts path parameters, query parameters, and request-body component schemas from each operation and converts them into JSON Schema `input_schema` objects suitable for MCP tool definitions. The `SchemaIndex._build_index_for_available_scopes` method (`schema_index.py:132-171`) filters the index to only include operations whose tags match the account's enabled scopes (read/write permissions per model). Versioning is entirely server-driven — the schema version comes from the Merge API response; there is no local schema versioning or cache. The number of available integrations thus depends on the Merge account's linked-account configuration: each "common model" (e.g. Employee, Candidate, Account) that has at least read or write scope enabled generates one or more MCP tools.


Citations: [src/merge_mcp/tool_manager.py:43-66](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/tool_manager.py#L43-L66) · [src/merge_mcp/client.py:189-206](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L189-L206) · [src/merge_mcp/schema_index.py:29-35](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/schema_index.py#L29-L35) · [src/merge_mcp/schema_parser.py:20-58](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/schema_parser.py#L20-L58) · [src/merge_mcp/schema_index.py:132-171](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/schema_index.py#L132-L171)

### How are integrations exposed to LLM agents? (answered)

Integrations are exposed through the MCP (Model Context Protocol) stdio interface. The `serve` function in `server.py:11-31` creates an `mcp.server.Server` named `"merge-mcp"` and registers two handlers: `list_tools` (which returns the current tool list) and `call_tool` (which dispatches execution). Tools are dynamically generated once during initialization: `ToolManager.async_init` (`tool_manager.py:68-69`) calls `fetch_tools` which uses `asyncio.gather` (`tool_manager.py:74-95`) to concurrently convert every operation schema from the SchemaIndex into an MCP `Tool` object. Each tool's `inputSchema` is a JSON Schema object produced by `SchemaParser.extract_input_schema_and_update_parameters` (`schema_parser.py:20-58`), which unpacks path/query/body parameters. For POST/PATCH operations that need a model schema, the tool manager either populates the schema via a meta endpoint call (`tool_manager.py:166-180`) or prepends a companion meta tool that the agent should call first (`tool_manager.py:182-206`). Tool availability is gated by scopes: `ScopeManager` (`scope_manager.py:17-74`) intersects the user's `--scopes` CLI argument with the account's API-enabled scopes, so only permitted operations are built into tools. There is no dynamic tool loading after startup — the tool list is fixed for the process lifetime. The server communicates over stdio (`stdio_server`), meaning any MCP-compatible client (Claude Desktop, custom Python clients via `mcp` SDK) can connect.


Citations: [src/merge_mcp/server.py:11-31](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/server.py#L11-L31) · [src/merge_mcp/tool_manager.py:74-95](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/tool_manager.py#L74-L95) · [src/merge_mcp/schema_parser.py:20-58](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/schema_parser.py#L20-L58) · [src/merge_mcp/tool_manager.py:97-139](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/tool_manager.py#L97-L139) · [src/merge_mcp/scope_manager.py:17-74](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/scope_manager.py#L17-L74)

### How is a tool call executed? (answered)

Tool call execution is a synchronous request-reply proxy to the Merge API with no sandboxing. When the MCP server receives a tool call, `ToolManager.call_tool` (`tool_manager.py:248-269`) looks up the operation schema by `operationId` from the SchemaIndex, then calls `_build_request_from_schema_and_arguments` (`tool_manager.py:208-246`) which maps the MCP-provided arguments to the correct HTTP parameter locations: path parameters are interpolated into the URL (replacing `{param}` placeholders), query parameters are collected into `query_params`, and body parameters into `body_params`. Header parameters and parameters in the `EXCLUDED_PARAMETERS` list (include_deleted_data, include_remote_data, include_shell_data) are silently dropped (`tool_manager.py:229-231`). The actual HTTP request is made via `MergeAPIClient._make_request` (`client.py:289-387`) using `httpx.AsyncClient` with a configurable timeout (default 10s). The optional retry mechanism uses exponential backoff (`delay *= 2`) and is configurable per call (default 3 attempts, gated by `with_retry` flag) — tool calls use `with_retry=True` (`tool_manager.py:265`). HTTP errors (4xx/5xx) trigger retries on eligible status codes. Non-JSON responses are returned as raw text. There is no sandboxing, no rate limiting, no dedicated pagination support, and no request queueing — every tool call directly proxies to the Merge API and blocks until a response is received. Error responses are caught and returned as `TextContent` with descriptive messages including the request, schema, and arguments.

> **Editor's note.** Correction: retries are not limited to eligible status codes. `_make_request` retries every `httpx.HTTPError`, including 4xx responses, with doubling backoff. Successful tool results are the Python repr of the response, not JSON.

Citations: [src/merge_mcp/tool_manager.py:208-246](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/tool_manager.py#L208-L246) · [src/merge_mcp/client.py:289-387](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L289-L387) · [src/merge_mcp/constants.py:19-19](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/constants.py#L19-L19)

### How are data sync, webhooks and triggers implemented? (not applicable)

This repository does not implement any data sync, webhook ingestion, scheduled synchronization, incremental cursors, or event triggers for agents. It is a purely synchronous request-response MCP proxy: an LLM agent makes a tool call, the server proxies it to the Merge REST API, and returns the result. There are no background workers, no webhook listeners, no cron jobs, and no subscription/polling mechanisms anywhere in the codebase. The Merge API itself may support webhooks and sync (server-side), but this MCP server does not consume or interact with those features.



### How is it self-hosted and what is open vs proprietary? (answered)

The project has no license file and no `license` field in `pyproject.toml`, so its licensing is unspecified. The complete source code is open-source in this repository — all 11 Python modules (~700 lines) that make up the MCP server are in `src/merge_mcp/`. The package is published to PyPI as `merge-mcp` (version 0.1.4) and run via `uvx` or `pip install`. It depends entirely on open-source libraries: `mcp` (Python MCP SDK), `httpx`, and `pydantic` (`pyproject.toml:10-13`). Self-hosting is the only deployment model: users run the server locally on their own machine, configured with their own Merge API credentials (`README.md:40-53`). The critical proprietary dependency is the Merge API cloud service (`api.merge.dev`, `api-eu.merge.dev`, `api-ap.merge.dev`) — there is no local emulation or mock; the server is useless without a valid Merge API key and account token. The three regional endpoints are hardcoded in `client.py:77-84`. What is open: the entire MCP server proxy, tool generation, schema parsing, scope filtering, and request building. What is proprietary/cloud-only: the Merge API itself (account management, data storage, business logic, webhooks, sync).

> **Editor's note.** Correction: pyproject.toml declares only `isort` and `mcp` as runtime dependencies; `httpx` and `pydantic` arrive transitively through `mcp`.

Citations: [src/merge_mcp/client.py:60-84](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/src/merge_mcp/client.py#L60-L84) · [pyproject.toml:1-20](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/pyproject.toml#L1-L20) · [README.md:40-53](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/README.md#L40-L53) · [README.md:293-318](https://github.com/merge-api/merge-mcp/blob/171177a7d966c3dda65a990e9f7c449bd9c4067c/README.md#L293-L318)
