Skip to content

MCP tools

POST /mcp — stateless Streamable HTTP, one bearer token per call.

app/tools/, registered in app/tools/tool.rb.

Grant#tools filters Tool.all by the token’s scopes, per request:

def tools
Tool.all.select { |tool| permits?(tool.scope) }
end

A tool outside the grant is absent from tools/list. Calling it anyway answers Tool not found. Tool::Base.respond re-checks with grant.permit!(scope) before running anything.

app/models/grant.rb:

uris:read Search your catalog and read what is in it
uris:write Add to your catalog, and run analysis over it
resources:read List the places your items live
resources:command Act on those places — sync, fetch, and export

Scopes not in Grant::SCOPES are dropped from the token’s claims.

Tool Scope Starts a run Does
search_items uris:read search the catalog
get_item uris:read one item in full, plus an 8,000-character excerpt
analyze_item uris:write queue one item for analysis
list_resources resources:read the resources, and their sync state
describe_resource resources:read one resource’s capabilities and command schema
check_resource resources:read does it answer, and are its credentials accepted
command_resource resources:command run one command against one resource
sync_resource resources:command walk it and record what it holds
export_items resources:command copy matching bytes into a storage resource
list_runs resources:read what the work you started is doing
cancel_run resources:command stop a run that is still open

search_items takes query, kind and limit (1–200, default 50). See Search.

get_item takes id.

export_items takes destination_id plus the selector below. list_runs filters on kind and status; cancel_run takes a run id.

Tool::Base::SELECTOR_SCHEMA, shared by export_items and resolved by Item.matching:

Field Restricts to
query words to match
kind one kind
resource_id items referenced by one resource
folder a prefix of the locator key: 2024/invoices
since / before created_at bounds

Every argument left off widens it. No arguments at all is the whole catalog.

Run::KINDS is sync, export, analyze, reindex, dedupe. Run::STATUSES is queued, running, done, failed, cancelled, gated.

cancel_run writes the status. The iteration reads it on its next halted? check — a bulk job holds no token that can be revoked. See Jobs.

Set by Default Applies to
rate limit URIS_MCP_LIMIT, per minute 120 every call, keyed on tenant + token digest
run budget URIS_RUN_BUDGET, per hour 20 tools declaring starts_runs

The rate limit is McpController’s rate_limit, keyed by caller_key — the tenant id plus a SHA-256 of the bearer token, or the remote IP when there is none. Over it, the response is a JSON-RPC error with code -32000 and status 429.

The run budget is Tool::Base.within_budget!, a Rails.cache counter keyed on tenant, subject and the current hour. Setting it to 0 disables the check.

Every call is written to AuditEvent on the mcp channel with its tool name, status, scope, grant, arguments, duration in milliseconds, and the request’s remote IP and request id. Denied calls are recorded too.

SweepAuditEventsJob deletes events older than URIS_AUDIT_RETENTION_DAYS, default 90.

Tool::Base::EXPECTED are returned as tool errors rather than crashing the transport:

Recorded as
Grant::Denied denied
Tool::OverBudget denied
ArgumentError error
ActiveRecord::RecordNotFound error
Resource::Failed error

Anything else propagates.