MCP reference
Loom serves its operations as MCP tools at <loom>/mcp. Any MCP client that
speaks streamable HTTP connects with a URL and a bearer token. The tool list
is generated from the same operation registry as the HTTP API,
and a tool call runs the same authorization, rate limit, audit and idempotency
path as POST /v1/<op>. The transport is server/transport/mcp.py, with a
tool call in mcp_call.py and mcp_outcome.py, bearer checks in mcp_auth.py, its
listing in mcp_tools.py, schema shaping in mcp_schemas.py, protocol and
session constants in mcp_config.py and the curated descriptions in
mcp_descriptions.py.
Wiring a specific client (Claude Code, Codex, Cursor, VS Code, Claude Desktop
and others) with loom install or by hand is in
MCP clients.
Endpoint
| Request | Does |
|---|---|
POST /mcp |
One JSON-RPC request or a batch of 1 to 64. The answer is a JSON body, not an event stream. A notification answers 202 with no body. |
DELETE /mcp + Mcp-Session-Id |
Ends the session and closes its implicit run. Rate-limited as run.complete, per caller within the org's budget: past the cap it answers 429 with Retry-After. |
Query parameters on the URL select what tools/list shows for that connection:
?toolset=picks the roster that is listed (Toolsets). Default:LOOM_MCP_TOOLSET(core).?schema_profile=picks the listing's shape:standard(the curated description and the input schema; the default,LOOM_MCP_SCHEMA_PROFILE),full(addsoutputSchema) orcompact(the first paragraph and the argument tree).
An unknown value for either falls back to the deployment default, so a typo
never widens or narrows a listing silently. The ?toolset= value also shapes the
session primer (initialize instructions, server/discover, the loom prompt,
loom://primer and loom_activate): the primer names an op's own tool when the
toolset lists it, else the op name marked "(via loom_op)", and leaves out an op
loom_op does not route there.
Methods served: initialize, notifications/initialized,
notifications/cancelled, ping, tools/list, tools/call, prompts/list,
prompts/get (the loom prompt: the session primer), resources/list,
resources/read (loom://primer) and server/discover. List results carry
ttlMs and cacheScope hints so a client can cache them.
Protocol versions. 2025-11-25, 2025-06-18, 2025-03-26 and
2024-11-05 (MCP_SUPPORTED_PROTOCOL_VERSIONS). initialize answers with the
client's version when it is one of these, else 2025-11-25. An
MCP-Protocol-Version header naming another revision is HTTP 400 with JSON-RPC
error -32022, whose data.supported lists the served ones. server/discover
answers with the same list, the capabilities, the server identity and the
instructions.
Authentication
LOOM_MCP_AUTH_MODE decides which methods need a bearer:
| Mode | Bearer required on |
|---|---|
required (default) |
every method except notifications |
discovery_only |
tools/call only; every other method is open |
open |
nothing; an invalid bearer is ignored (trusted networks only) |
A server on a non-loopback LOOM_BIND_HOST refuses to start with open unless
LOOM_ALLOW_INSECURE=1. A deployment with auth off (LOOM_AUTH_REQUIRED=false)
behaves as open. A missing or invalid bearer on a gated method is HTTP 401 with
WWW-Authenticate: Bearer realm="loom" (plus error="invalid_token" when a
token was sent). A token that cannot be checked yet, because the revocation
list has not loaded, is HTTP 503 with Retry-After instead, so the client
keeps the token and retries. Why a 401 and not a JSON-RPC error: clients key their
"needs authentication" flow, and their OAuth discovery, on the HTTP status; a
JSON-RPC error body would surface as a parse or tool error instead. The token
is an ordinary Loom JWT; see Tokens and grants.
The bearer is read from the Authorization header only. A token passed as ?token= on
the /mcp URL is ignored and the request is unauthenticated: request lines land in proxy
and access logs, where a URL token would be written in the clear.
OAuth
Served only with LOOM_OAUTH_ENABLED=true. Loom is an OAuth 2.1 authorization server for clients that
connect by browser sign-in instead of a pasted token (server/auth/oauth.py):
- Discovery at
/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resource. - Dynamic client registration at
/oauth/register: public clients, PKCES256required. /oauth/authorizeand/oauth/tokenissue an access token plus a rotating refresh token. A refresh succeeds only when the request names theclient_idthe token was issued to.- The access token is a Loom JWT for the signed-in user, so namespaces the user joins later are reachable on the same connection.
- Sign-in uses Loom's password login unless
LOOM_OAUTH_LOGIN_PROVIDER=oidcdelegates it to an external identity provider (LOOM_OIDC_ISSUER,LOOM_OIDC_CLIENT_ID,LOOM_OIDC_CLIENT_SECRET). Delegating sign-in also closes password self-signup. - A remote deployment must serve HTTPS and set
LOOM_OAUTH_ISSUERto its publichttps://base URL. - A registered redirect URI must be absolute and fragment-free:
https,httpto a loopback host, or an app scheme (cursor://…). The sign-in page names the host the authorization goes to. - Each refresh re-checks that the account is still active and re-derives its
roles, so a released account, or a subject removed from
LOOM_PLATFORM_ADMINS, does not keep its access by refreshing.
Security covers the token model behind this.
Sessions and identity
- Session id.
initializeassigns anMcp-Session-Idand every response echoes it. The client'sclientInfo.nameis cached against it and becomes theagentof later calls, slugified (Cursor→cursor; absent →claude-desktop). The cache is in-process and bounded. - Identity defaults. Tool calls usually carry only the action's arguments. The
server fills
orgfrom the token andnamespaceandagentfrom the caller's default workspace; explicit arguments win.code.*calls resolve to the code plane instead, and on theassistanttoolset a read that names nonamespaceor noagentfollows that toolset's rules (see The assistant toolset). Anorgargument the token may not act in is refused (ORG_MISMATCH): a token acts in its own org, an org-wide (*) token or a platform admin in any org, the same rule as over HTTP (authz.may_act_in_org). A restricted namespace still refuses every other org, platform admins included.loom_searchtakes nonamespaceargument: it searches the caller's home namespace and its other memory-kind namespaces, up to the fan-out cap. - Code scope. A client may send
X-Loom-Code-Scope: <org>/<namespace>(the Claude Code plugin sends the repository its checkout resolved to). It applies to everycode.*call that names no namespace, and authorization then runs on that namespace as if it had been named.loom_searchunder the header also reads the repository's documents anddecisionmemories from its trunk code namespace, when the caller may read it, and runs the tracker duplicate check for a defect-shaped query. - Implicit run. One run per (org, token subject, session), begun in the
run ledger by the session's first run-scoped tool call and filed in the
caller's default namespace; writes carry its
run_id(exceptrun.*, which names its own run,code.*, which acts as the deployment's code identity, andworking_memory.*andconversation.update, whererun_idscopes or binds the row), and reads are labelled with it but not filtered. The run closes onDELETE /mcp, or when themcp_session_run_sweepworker finds no call of the token under it forLOOM_MCP_SESSION_RUN_IDLE_SECONDS(default 1800); a deployment that runs no background workers anywhere keeps idle runs open. The run stores no call count: its calls are theagent_eventsrows with itsrun_id. See Runs.
Tool list
Every registry operation not in _MCP_HIDDEN_OPS is a tool:
- Name.
loom_plus the op name with dots as underscores (memory.set→loom_memory_set). - Description. The curated text in
TOOL_DESCRIPTIONS(server/transport/mcp_descriptions.py) when there is one, else a one-line fallback with the op's latency budget, idempotency and minimum role. The curated texts say when to use a tool and when not to; the choice is left to the model. A text names other ops by op name, and each listing renders it for the client that reads it (name_ops_for_listinginserver/transport/mcp_op.py): an op the listing carries is named by its tool (loom_memory_update), and an op it does not carry keeps its op name, the oneloom_optakes, with "(via loom_op)" after its first mention whenloom_oproutes it on that connection; it does not route a hidden op, a write onreadonlyor anything onassistant. Input field descriptions, and the output schema thefullprofile keeps, are rendered the same way, and so is a hidden op'sinsteadtext;loom_op's own text, which is about op names, is built per listing instead. Mentor text is rendered for the caller's listing over MCP too (a tool result'shint,context.pack'smentorsection, andmentor.brief's hints and planewhen); over HTTP it reads as stored. - Input schema. The op's Pydantic input model without the fields the
server fills itself (
org,agent,user_id,session_id,consumer), the dashboard-only fields (_HIDDEN_OP_PROPSinserver/transport/mcp_schemas.py) and every field markedMCP_HIDDEN;namespacestays as an optional property. Hidden fields stay accepted by name. - Annotations.
readOnlyHint,destructiveHintandidempotentHintare derived from the registry (role, idempotency, audit, destructive verbs in the name).readOnlyHintis true only for an idempotent viewer op that is neither audited nor markedwrites, so a client that auto-approves read-only tools never runs a state change unprompted. They are hints; the role check at call time is the boundary.
Three synthetic tools are listed on every toolset but assistant:
| Tool | Does |
|---|---|
loom_search |
One call searches memory, profile, knowledge and conversations in parallel and returns all four sections. explain: true adds why each memory hit ranked. |
loom_activate |
Returns the session primer (profile, preferences, default workspace), the same text as the loom prompt. |
loom_op |
{op, arguments} runs any op the roster does not list, through the same path as a listed tool; {op: "help", query} lists up to 15 matching ops (_CATALOG_LIMIT), each with its required and optional arguments. |
loom_search is the registry's search.query operation (POST /v1/search.query
over HTTP, the same sections without the text summary). Over MCP it runs the same
path as every registry tool (caller defaults, session run, authorization, restricted
access audit, traffic class, history policy, rate limit, idempotency) and keeps its
own listed schema, its lenient options (an out-of-range top_k or an unknown
recipe / bodies is clamped, not refused) and a text summary beside the lean
result. Operation.mcp_tool names it, so no second loom_search_query tool is
listed or callable. See
unified search.
Hidden ops (_MCP_HIDDEN_OPS in server/operations/toolsets.py) are neither
listed nor callable over MCP: the per-store searches loom_search replaces
(memory.search, knowledge.search, conversation.search), review queues,
snapshot and restore, policy administration, audit and cost dashboards, code-index
ingest. A call to one answers isError with code OP_NOT_ON_MCP and what to use
instead. They stay reachable over HTTP and the SDKs. On the assistant toolset
memory.search and knowledge.search are listed and callable, and a call to any
other hidden op answers UNKNOWN_OP. The rule for hiding an op: would a typical end
user, not an operator, reach for it in chat? A namespace-wide view can be hidden
while a variant bounded to the caller stays on MCP: namespace.list is hidden and
namespace.list_mine callable (listed on all and readonly, through loom_op on
every other toolset except assistant); memory.extraction_status (every batch queued
in the namespace) is hidden and memory.extraction_status_mine (the batches the caller
queued, by queue_id, at most 20 a call) is callable by name or through loom_op
on every toolset except assistant (readonly included). Its write twin,
memory.extraction_retry_mine, replays the caller's failed batches once more;
loom_op routes it on every toolset but readonly and assistant.
Toolsets
A toolset decides what is listed; every non-hidden op stays callable by
tool name, or through loom_op, whatever the toolset. Two exceptions: on
assistant only its own reads are callable, and on readonly, named by
?toolset= or by LOOM_MCP_TOOLSET when the request names none, loom_op
routes read ops only.
| Toolset | Lists | For |
|---|---|---|
core |
The memory loop: memory.set/update/delete/pin/get/history/apply/lock, blacklist.add, context.pack. Every other op runs through loom_op; no tool listed on core logs conversation turns. The code tools join with LOOM_MCP_CODE_TOOLS=true. |
chat clients (the default) |
coding |
memory.set/update/get/delete, working_memory.set, context.pack, the code tools (code.search, grep, enclosing, ask, symbol, outline, callers, context_pack), tracker.duplicate_check, code.bisect_candidates, code.pr_check, code.impact. |
coding agents |
tracker |
tracker.answer, binding, component, duplicate_check, flaky, item, links, release, similar, test_signal, plus code.bisect_candidates, code.pr_check, code.impact. |
sessions working a failing test, crash or PR |
tasks |
task.pull/create/complete/fail/wont_do/defer/heartbeat/updates/list/get, inbox.list. |
a session acting as an agent with a task inbox |
atomic |
memory.set/get/delete/ask, working_memory.set/append, run.begin/complete. |
minimal, policy-shaped agents |
readonly |
The all listing minus every tool whose readOnlyHint is false; loom_op routes read ops only. |
read-only registrations |
assistant |
A fixed set of reads for investigating Loom on a person's behalf: namespace.get/summary/inventory/tune/taxonomy/audit/validate/guide_get/guide_history/list_categories, mentor.brief/guide, help.search/get, grants.list, memory.inspect/search/ask/get/list/history/provenance/aggregate, facts.traverse/path, knowledge.search/get/list/inspect, code.search/symbol, insights.catalog/query. No synthetic tools. |
a chat client acting for a signed-in person |
all |
Every non-hidden op whose maturity is stable, plus the experimental ops the core or atomic roster lists. An experimental op that neither roster lists is left out; it is still callable by name or through loom_op. Code tools only with LOOM_MCP_CODE_TOOLS=true. |
operators |
Curated rosters combine: ?toolset=coding+tracker, coding,tracker, or an
unescaped + that arrives as a space. Only curated rosters join (all,
readonly and assistant are whole surfaces), and an unknown part makes the whole value
unknown, so the listing falls back to the deployment default. The rosters are
the frozensets in server/operations/toolsets.py; ops.capabilities reports
each op's toolsets. A second MCP registration on another toolset
(?toolset=tracker, ?toolset=tasks) adds a roster without changing the first.
Why the listing is small. Every listed tool's name, description and schema
is paid for in tokens on every turn of every session, and tool-selection
accuracy falls as the list grows. tests/unit/test_mcp_listing_budget.py
holds each roster under a token ceiling. Prefer leaving an op callable by name
over listing it; Adding an operation covers
when and how to list one.
Results
structuredContent (and the JSON in content[0].text) is the op's output,
trimmed to the answer (server/transport/lean_results.py): hits with their
key, content, namespace, dates, similarity, source and status, plus counts,
paging and abstain fields. Score components, explanations, retrieval plans and
costs, hashes and empty fields are dropped. Why: some clients hand
structuredContent to the model as well as the text block, and the full
diagnostic shape costs several thousand tokens for a short answer.
explain: true on the call returns the full shape. HTTP, the SDKs and the
assistant toolset always get the full result (on assistant, a memory.search without
its per-hit score breakdown).
The assistant toolset
?toolset=assistant is the connection a chat client uses to investigate Loom on behalf
of the signed-in person: what a namespace holds, how it is used, and cited answers over
memory, documents and code. The value names assistant alone (in any case): a misspelling or a
union such as assistant+core does not resolve and the connection gets the deployment's
default listing, with no error. The URL the operator configures and a viewer token
carry the guarantee, not the client. A replica one release older does not know the value
either and serves its default listing, so during a rolling upgrade only the token limits
the connection: point a client at ?toolset=assistant once every replica runs a build
that serves it, and give it a viewer token in any case. On it:
- Only its reads are callable. A
tools/callfor any other tool,loom_opandloom_searchincluded, answersisErrorbefore the handler runs, whatever the token allows: with codeUNKNOWN_OP, or with the access refusal when the call names an org or namespace the token cannot read, which is checked first. The token is still checked on every call, so a read the person may not make is refused as on any connection. Mint the client's token with theviewerrole to make the same limit hold on every other surface. - Its calls are ledgered as consumer
loom-assistant, whatever the call sends, a refused call to a registry tool included (an unknown tool name writes no row).namespace.audit,namespace.summary,mentor.briefandknowledge.readsleave that consumer's and the dashboard's (loom-ui) reads out of their usage counts, so looking at a namespace does not change its grade (a change to a memory from the dashboard is a person using the namespace and still counts innamespace.auditandnamespace.summary);namespace.auditreports how many it left out insummary.inspector_calls. A token whoseconsumerclaim names another consumer keeps its claim, and its calls are counted. - A read that names no namespace reads the token's. The default is the first namespace the token's claim names and may read, not the first it may write: an assistant answer's token is minted for one namespace, while its subject's personal namespace is the one it may write. A token whose claim names none keeps the caller's default workspace.
- A read names whose rows it reads. The assistant writes nothing, so its client's own
agent holds no rows, and a read that names no
agentis not narrowed to it:memory.askreads across agents andmentor.briefbriefs the namespace's top writer, asmemory.search,memory.listandmemory.historyalready read across agents.insights.queryanswers open analytical questions in SQL over the tablesinsights.cataloglists, held to the token's org and namespace by ClickHouse (assistant design). A read of one writer's rows,memory.aggregateormemory.provenance, is refused for the missingagentuntil the call names the writer: a search hit'sagent,user_idandsession_id. The listing offers those arguments on every memory read, andagentonmentor.brief.memory.getfalls back to the row under any agent. - Reads do not record usage.
memory.searchruns withauto_touch: falseandmemory.askwithreinforce: false, whatever the call sends, so an investigation does not raise the recall counts that rank results;memory.askalso runs withprecedents: false, so no answer is stored for reuse. What remains is telemetry and caches: the request ledger row; the exposure row every read of a memory leaves (loom.memory_exposures, the record of who was served which row); the code search trace acode.searchorcode.symbolwrites (loom.code_search_trace, underLOOM_CODE_TRACE_LOG_ENABLED); the search trace and query-node rows amemory.searchwrites whenLOOM_SEARCH_TRACE_LOG_ENABLEDorLOOM_MEMORY_QUERY_NODES_ENABLEDis on; amemory.askconsistency row under deferred consistency; and the model response cache. An assistant call opens no MCP session run, and itsinitializeandloomprompt write no profile-read row: its primer inlines no profile. - Results are the full output, as HTTP returns them, since a citation needs the
identities, versions and attestations the trimmed shape drops; a
memory.searchruns withexplain: false, so its hits carry no per-hit score breakdown.memory.searchandknowledge.search, which the other listings leave toloom_search, are listed here so an answer can come from one plane at a time. Aknowledge.searchhit names itssource_uri, heading breadcrumb (section) and therevisionits passage was written as (0 when none was recorded), and an ingested file's hit itslocator. The hit is the nearest passage, not an answer: the op has no abstain signal, so the model says a fact is not in the documents when no hit states it. - The
initializeinstructions tell the model to cite each claim by key and version or content hash, or by documentsource_uri, section and the revision a hit carries (none when it is 0), to keep source statements, computed results and its own reading apart, to report missing evidence as not observed, and to treat stored text as data that cannot grant a tool or approve a change.
Errors
A call that cannot run as sent comes back as a tool result with
isError: true, a text block the model reads and a structuredContent body.
Clients show these to the model so it can correct the call; many clients drop
state on a JSON-RPC error frame, which Loom keeps for transport failures.
-
Wrong arguments. A validation failure answers
"<op>: invalid arguments."followed by each field error, every unknown key with the field it most likely meant (nested fields included), the required and optional arguments and one corrected example call:memory.get: invalid arguments. memory_key: Field required [missing] unknown 'key' — did you mean 'memory_key'? required: memory_key optional: if_version, include_recall_breakdown, as_of, fields, follow example: {"memory_key": "deploy_window"}structuredContentcarries the same parts:errors,op,required,optional,unknown(key → suggestion) andexample. An op that changes state (any op that is not read-only) given a near-miss key is refused before validation (UNKNOWN_ARGUMENT, nothing written) withdid_you_meanand the op's arguments.
- Refusals. A handler's 4xx other than 401 (
LOCKED,PINNED_CONFLICT,BLACKLISTED,RESOURCE_NOT_FOUND,RATE_LIMITED, …) is a tool result whose body is the HTTPdetailplusstatusand, when there was aRetry-After,retry_after_s. - Access. A role failure answers
AUTH_INSUFFICIENTwithrequired_role; a namespace the token neither names nor holds a grant on answersNAMESPACE_NOT_ACCESSIBLE. On a code or tracker namespace, or a shared collection (not someone else's personal one), the refusal says how to get access. - Transport errors stay JSON-RPC errors: parse error
-32700, invalid request-32600(an auth refusal carries its HTTP status; a malformed request or a batch outside 1 to 64 answers 200), method not found-32601, unsupported protocol version-32022, and a server fault-32000 "tool execution failed". A fault Loom names (OVERLOADED,BACKEND_TIMEOUT,SCHEMA_PENDING,EMBED_TIMEOUT) states its code and message in that error, anderror.datais the HTTPdetailplusstatus,toolandrequest_id(withretry_after_swhen the error names a wait); any other fault says onlyinternal errorand the request id.
Every refusal body with a code and every fault's error.data also carries category
and retryable, as the HTTP envelope does. A tool's refusal text ends with the same class
in words ((auth: do not retry unchanged)), for a host that shows the model only the text;
loom_op's own refusals carry the fields in their JSON text. A NAMESPACE_NOT_ACCESSIBLE
refusal of a namespace that is not a collection, from a tool that is not a code or tracker
tool, on a toolset that reaches loom_op, also carries a next step
(errors reference). Every code, with its
cause and fix, is in the errors reference.
Security
tools/call runs the HTTP transport's gates (server/transport/gates.py):
- Authorization per call: the op's
required_roleand the namespace's access rules. - Rate limit: the same buckets as HTTP. Because MCP clients do not retry, a
limited call whose wait hint is at most
LOOM_MCP_RATELIMIT_RETRY_MAX_WAIT_S(default 2 s) sleeps that long once and checks the same buckets again; a longer hint or a second refusal answersRATE_LIMITEDwithretry_after_s(LOOM_MCP_RATELIMIT_RETRY_MAX_WAIT_S=0never waits). - Audit: ops declared
audit=Truewrite the sameloom.audit_logrows. - Idempotency: MCP has no idempotency header, so an idempotent op takes
_idempotency_key(oridempotency_key) in its arguments. It is removed before validation and replays exactly as the HTTPIdempotency-Keydoes: only after authorization, and only to the principal that stored it.
Exporting the action schema
A policy gateway can govern Loom's tools from a schema generated from the
registry (server/transport/action_schema.py):
loom export-actions --format json -o loom-tools.json # tools/list manifest + a `loom` block per tool
loom export-actions --format cedar -o loom.cedarschema # Cedar: one action per operation
The JSON manifest has the MCP tools/list shape (name, description,
inputSchema, outputSchema) for every op, plus a loom block with what a
policy engine needs and MCP has no field for: the op name, the HTTP path, the
required role, the effect class (read, write or destructive, from the same
classifier the annotations use), idempotency, the audit flag, MCP visibility and
the input and output model names. The Cedar schema declares, in namespace
Loom, an Agent principal and a Namespace resource, one record type per
input and output model, the action groups Read, Write and Destructive
(Destructive in Write), and one action per operation in its group. -o -
writes to stdout.
Related
- MCP clients: connecting Claude Code, Codex, Cursor and other clients.
- Claude Code and Codex: the plugins that mount the server themselves.
- Operations reference: every op with its role, kind and whether
alllists it. - Errors reference.