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"])
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 assertingX-Limma-Principalsreads, and creates nothing). Any other caller that finds none is refused 403no_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 withGET /v1/audit/events(an admin key reads its organisation's). - Rate limits are per key, counted per tool call as calls of its verb.