Skip to content

MCP over HTTP — Limma's tools for agents that run elsewhere

Limma's MCP tools are served by the Limma API process itself, over the MCP streamable HTTP transport, at one URL on the same port as the REST API. An agent platform whose agents run on other machines points its MCP client at that URL and sends its own API key with every request. There is no second server to run, no second port to open and no second set of credentials: the endpoint is an ordinary route of the API, behind the same key check as every /v1/public/* verb, and every tool call is made with the caller's key and judged by the verb it calls.

(An agent host that launches a local process instead can still run python -m epistem.mcp.server over stdio, as in the quickstart. The tools are the same; over stdio the process holds one key from its environment.)

Connect your agent in one line

A client needs two things: the endpoint's URL and your key. No workspace: every tool's workspace_id is optional, and a call that leaves it out is served from your key's default workspace — the same one for every key of your organisation, created by the first such call of a key that may create workspaces (an admin or analyst key). A viewer or service key that finds no default is refused 403 no_default_workspace, and the refusal says how to get one. Every reply names the workspace_id it was served from; pass workspace_id to use another workspace your key reaches.

URL
Hosted https://api.limma.ai/mcp
Inside your network <LIMMA_APP_BASE_URL>/mcp — e.g. https://limma.your-company.internal/mcp (install)

Each setup below reads the key from the LIMMA_API_KEY environment variable, so the key is not written into a file you might commit (export LIMMA_API_KEY=<your key> first). Inside your network, the URL is your own — never the hosted one: an install's agents reach only the API in your network.

Claude Code — one command (hosted, then inside your network):

claude mcp add --transport http limma https://api.limma.ai/mcp --header 'X-API-Key: ${LIMMA_API_KEY}'
claude mcp add --transport http limma https://limma.your-company.internal/mcp --header 'X-API-Key: ${LIMMA_API_KEY}'

The single quotes matter: Claude Code expands ${LIMMA_API_KEY} itself each time it connects, so the key stays out of its configuration. Add --scope project to write the server into the repository's .mcp.json for your team (each person approves it once), or --scope user for every project; claude mcp get limma shows what was saved.

Claude Desktop — its claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/, Windows: %APPDATA%\Claude\) starts local programs, so a remote server is bridged with mcp-remote (it needs Node.js):

{
  "mcpServers": {
    "limma": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://api.limma.ai/mcp",
               "--header", "X-API-Key:${LIMMA_API_KEY}"],
      "env": {"LIMMA_API_KEY": "<your key>"}
    }
  }
}

Inside your network the URL in args is https://limma.your-company.internal/mcp. Write the header with no space after the colon (Claude Desktop on Windows does not escape spaces inside args). The key is then in this file, in plain text: keep it to your machine.

Cursor — .cursor/mcp.json in the project (or ~/.cursor/mcp.json for every project):

{
  "mcpServers": {
    "limma": {
      "url": "https://api.limma.ai/mcp",
      "headers": {"X-API-Key": "${env:LIMMA_API_KEY}"}
    }
  }
}

Inside your network: "url": "https://limma.your-company.internal/mcp".

VS Code (agent mode) — .vscode/mcp.json; VS Code asks for the key once and stores it:

{
  "inputs": [
    {"type": "promptString", "id": "limma-api-key", "description": "Limma API key",
     "password": true}
  ],
  "servers": {
    "limma": {
      "type": "http",
      "url": "https://api.limma.ai/mcp",
      "headers": {"X-API-Key": "${input:limma-api-key}"}
    }
  }
}

Inside your network: "url": "https://limma.your-company.internal/mcp".

These are written as each client documented its configuration in October 2026; a client that changes its format is the one to check. What every one of them then sends — initialize, tools/list, and a tools/call naming no workspace, with the key in X-API-Key — is what the test suite sends to the real app on every change.

The endpoint

URL https://<limma-host>:<port>/mcp — the API's own address (the image serves on port 8000; your operator may put TLS in front)
Method POST only. GET /mcp answers 405: the server sends nothing unprompted, which the protocol allows
Transport MCP streamable HTTP, stateless, JSON responses — every request is a whole exchange; no Mcp-Session-Id is issued, so any process of an install can answer any request
Protocol versions what the install's MCP Python SDK supports — 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05 with the SDK this page was written against; the initialize reply names the version agreed

Headers

Header
X-API-Key: <key> your key. Or Authorization: Bearer <key> for an MCP client that can only send a bearer token — the token is read as the key. When both are sent, X-API-Key is the key.
Content-Type: application/json required by the transport
Accept: application/json, text/event-stream required by the transport (at least application/json); every MCP client sends this
X-Request-ID: <id> optional. Echoed on the response, and recorded on the audit event of every tool call the request makes — the id that joins your log to Limma's
X-Limma-Principals: <p1>,<p2> optional, and only with a platform key: the end user this request is made for (user:alice@x.com,group:finance). Every tool call then reads only the documents those principals may see; any other key sending it is refused 403. See sync and access.

A key in the query string (?api_key=) is refused 401, as at every route: a URL is written to access logs.

The tools

The menu is the install's, set by the operator with LIMMA_MCP_TOOLS — the same setting and the same tools as the stdio server. The default is the six tools that are the product: limma_search (a request to the closed set of matches), limma_facts (every fact at an address, with receipts), limma_region (re-read the bytes a receipt points at, or a whole page or document in bounded pieces), limma_ingest (put a document in — its text, or a file through a one-time upload link: files from an agent; a CSV or a workbook is also read as rows), limma_connect (load a table or an export as addressed facts) and limma_documents (the documents the key may read, a page at a time, with how completely each was read). The operator can add the advanced tier (ask, plan, precheck, derive, verify, attest), the admin tier (state, generations, diff, correct, corrections, resolve, apply_pack, and the connector's two: delete_document and reconcile — sync and access), or single tools by name. Each tool is one /v1/public/* verb and returns exactly that verb's JSON; the verb reference documents every field and who may call each.

Every MCP client sends all the tool schemas the server lists on every request to its model, before the question — that is why the default menu is six.

A tool call is answered when its verb has answered. Most take seconds; limma_ingest waits for its document up to its wait_s (0–120 s; 45 s when it names none — waiting for the read), and limma_connect of a large export can take minutes (the stdio server allows it 600 s). Give your client's read timeout that room.

One complete session

The examples run against the test app: client sends the docs key as X-API-Key on every request (in your code, httpx.Client(base_url="https://<limma-host>:<port>", headers={"X-API-Key": "<key>"})), and WS is a seeded workspace of the key's organisation holding a small annual report. Each example is complete on its own.

Open the session and list the tools. Each message is one POST of one JSON-RPC message:

MCP = {"Accept": "application/json, text/event-stream"}
r = client.post("/mcp", headers=MCP, json={
    "jsonrpc": "2.0", "id": 1, "method": "initialize",
    "params": {"protocolVersion": "2025-06-18", "capabilities": {},
               "clientInfo": {"name": "partner-agent", "version": "1.0"}}})
print(r.status_code, r.headers["content-type"], r.json()["result"]["serverInfo"]["name"])
print("session id:", r.headers.get("mcp-session-id"))
r = client.post("/mcp", headers=MCP, json={
    "jsonrpc": "2.0", "method": "notifications/initialized"})
print(r.status_code)
r = client.post("/mcp", headers=MCP, json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
print(sorted(tool["name"] for tool in r.json()["result"]["tools"]))
200 application/json limma
session id: None
202
['limma_connect', 'limma_documents', 'limma_facts', 'limma_ingest', 'limma_region', 'limma_search']

Call a tool. The result's text is the verb's JSON — here every revenue figure the workspace holds, with the token each page prints:

import json
MCP = {"Accept": "application/json, text/event-stream"}
FACTS = {"workspace_id": WS, "question": "revenue", "mode": "enumerate",
         "coordinate": {"attribute": "revenue"}, "output_mode": "compact"}
r = client.post("/mcp", headers={**MCP, "X-Request-ID": "docs-mcp-facts"}, json={
    "jsonrpc": "2.0", "id": 3, "method": "tools/call",
    "params": {"name": "limma_facts", "arguments": FACTS}})
result = r.json()["result"]
body = json.loads(result["content"][0]["text"])
print("isError:", result["isError"], "| X-Request-ID:", r.headers["x-request-id"])
print(body["enumeration"])
for fact in sorted(body["facts"], key=lambda f: f["period"]):
    print(fact["period"], fact["value_token"], fact["byte_verified"])
isError: False | X-Request-ID: docs-mcp-facts
{'coordinate': {'attribute': 'revenue'}, 'exist': 3, 'delivered': 3, 'truncated': False, 'bound': True}
FY2022 1,100 True
FY2023 1,250 True
Q1 2024 312 True

It is the answer the REST verb gives to the same request, and the call is in the audit trail as that verb's call, made with your key, under the X-Request-ID you sent:

import json
MCP = {"Accept": "application/json, text/event-stream"}
FACTS = {"workspace_id": WS, "question": "revenue", "mode": "enumerate",
         "coordinate": {"attribute": "revenue"}, "output_mode": "compact"}
r = client.post("/mcp", headers={**MCP, "X-Request-ID": "docs-mcp-audited"}, json={
    "jsonrpc": "2.0", "id": 4, "method": "tools/call",
    "params": {"name": "limma_facts", "arguments": FACTS}})
over_mcp = json.loads(r.json()["result"]["content"][0]["text"])
over_rest = client.post("/v1/public/facts", json=FACTS).json()
print("same facts:", over_mcp["facts"] == over_rest["facts"],
      "| same count:", over_mcp["enumeration"] == over_rest["enumeration"])
events = client.get("/v1/audit/events",
                    params={"request_id": "docs-mcp-audited"}).json()["events"]
for event in events:
    print(event["verb"], event["path"], event["status_code"],
          "| workspace:", event["workspace_id"] == WS,
          "| caller_key_id:", event["caller_key_id"][:4] + "…")
same facts: True | same count: True
facts /v1/public/facts 200 | workspace: True | caller_key_id: key_…

With the MCP Python SDK the same session is:

import httpx
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

async def main():
    async with httpx.AsyncClient(headers={"X-API-Key": "<key>"},
                                 timeout=httpx.Timeout(120.0)) as http:
        async with streamable_http_client("https://<limma-host>:<port>/mcp",
                                          http_client=http) as (read, write, _):
            async with ClientSession(read, write) as session:
                await session.initialize()
                tools = await session.list_tools()
                # no workspace_id: the key's default workspace (add one to name another)
                result = await session.call_tool("limma_facts", {"question": "revenue"})

Any MCP client that takes a server URL and request headers is configured the same way as the four above: the URL, transport "streamable HTTP" (sometimes called "HTTP"), and either the X-API-Key header or a bearer token holding the key.

Errors, and what they mean

Two kinds. Refused at the door — the HTTP status of the /mcp request itself, before any tool runs and before the request body is read:

Status detail Meaning
401 API key required. Provide via X-API-Key header. No key: neither header, or an Authorization that is not one Bearer <token>
401 An API key in the query string (?api_key=) is not accepted: … The key was put in the URL
403 Invalid API key. The key names no configured key
413 The request body is larger than this deployment reads (… bytes; LIMMA_MAX_BODY_BYTES). The request is over the install's request limit (4 MB by default). A document that large is not a tool argument: upload it (POST /v1/workspaces/{workspace_id}/documents)
405 GET (or another method): only POST is served
406 / 415 The Accept or Content-Type header above is missing
503 type: mcp_not_running The API process was started without its lifespan (e.g. --lifespan off); the operator starts it as the image does

What the door answers is exactly what every /v1/public/* verb answers for the same key:

MCP = {"Accept": "application/json, text/event-stream"}
LIST = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
for label, key in (("no key", ""), ("a key that names nobody", "not-a-key")):
    over_mcp = client.post("/mcp", headers={**MCP, "X-API-Key": key}, json=LIST)
    at_a_verb = client.post("/v1/public/facts", headers={"X-API-Key": key},
                            json={"workspace_id": WS, "question": "revenue"})
    print(label, "->", over_mcp.status_code, over_mcp.json()["detail"],
          "| the same at a verb:", at_a_verb.json() == over_mcp.json())
no key -> 401 API key required. Provide via X-API-Key header. | the same at a verb: True
a key that names nobody -> 403 Invalid API key. | the same at a verb: True

Refused by the verb — the /mcp request is answered 200, and the tool's result has isError: true with the text Error executing tool <name>: {"status", "path", "detail"}, where status and detail are what the verb at path answered over REST:

import json
MCP = {"Accept": "application/json, text/event-stream"}
r = client.post("/mcp", headers=MCP, json={
    "jsonrpc": "2.0", "id": 5, "method": "tools/call",
    "params": {"name": "limma_facts", "arguments": {
        "workspace_id": "no-such-workspace", "question": "revenue"}}})
result = r.json()["result"]
text = result["content"][0]["text"]
print(r.status_code, "| isError:", result["isError"])
print(text[:text.index("{")])
refused = json.loads(text[text.index("{"):])
print(refused["status"], refused["path"], refused["detail"]["type"])
200 | isError: True
Error executing tool limma_facts:
404 /v1/public/facts not_found

status Typical detail Meaning
403 Insufficient permissions. Required: write, role: viewer The key's role may not call this tool (below)
403 Workspace does not belong to your organization The workspace is another organisation's
403 This API key is scoped to other workspaces; it cannot reach this one The key is scoped to named workspaces
403 type: document_scoped_key_refused The key is confined to documents and this verb is not one that narrows to them
403 type: no_default_workspace The call named no workspace, the organisation has no default yet, and this key may not create it — the detail names the fix
404 type: not_found No such workspace (or document, row, …)
422 type: workspace_id_required The call named no workspace and the key is scoped to several, none of them the default: name one
402 type: quota_exceeded Hosted: a limit of the organization's plan is reached — limit, count, allowed and the route that changes the plan are named (on the Free plan too: add a card in the portal, then move up in place) (your usage); reads keep working when only a loading limit is reached
402 type: account_read_only Hosted: the subscription ended or a payment failed and its grace has passed — questions, reads and deletion keep working
409 / 422 the verb's own The request conflicts with what is stored, or is malformed — the verb reference names each
429 The key is over its rate limit; each tool call counts as one call of its verb
503 the verb's own Something the verb needs is unavailable (a model, the store); nothing was written

A tool name the server does not offer, or arguments that do not match the tool's schema, is a JSON-RPC error or an isError result from the MCP server itself, before any verb is called.

Keys and roles

Your operator issues each key as key:organisation:role[:workspaces[:documents]] (EPISTEM_API_KEYS; see Keys and roles). Over this endpoint:

  • Every tool call is made with the key of the request that carried it — never with a key of the server's. Two callers of two organisations calling at the same moment are each judged by their own key.
  • A key reaches its own organisation's workspaces — and, when it is scoped, only the workspaces (and documents) it names. Anything else is refused by the verb, as above.
  • A call that names no workspace is served from the key's default workspace: for a key scoped to ONE workspace, that one; for a key scoped to several, the organisation's default if it is one of them (otherwise 422 workspace_id_required, naming them); for every other key, the organisation's default — created by the first such call of an admin or analyst key that is confined to no workspace and no document, and made by a platform as itself (a call asserting X-Limma-Principals reads, and creates nothing). Any other caller that finds none is refused 403 no_default_workspace. The default is read with the key's restrictions intact, exactly as a named workspace is.
  • The role decides which tools the key may call, by each verb's own rule:
Role May call Refused (403)
viewer the reads: limma_search, limma_facts, limma_region, limma_ask, limma_plan, limma_precheck, limma_state, limma_generations, limma_diff, limma_corrections every tool that writes or spends: limma_ingest, limma_connect, limma_correct, limma_resolve, limma_apply_pack; a limma_search whose criteria may read documents with a model; and limma_derive / limma_verify / limma_attest (they need verify)
analyst every tool —
service everything an analyst may, except limma_apply_pack limma_apply_pack (it needs manage_catalogue)
admin everything —
  • Every tool call is in the audit trail as one event of its verb — the same event the REST call writes, filed under your key's id (caller_key_id, never the key) and the workspace it named. A call refused at the door is recorded too, under the workspace it named, so that workspace's organisation sees the attempt. Read your own calls with GET /v1/audit/events (an admin key reads its organisation's).
  • Rate limits are per key, counted per tool call as calls of its verb.