MCP tools
POST /mcp — stateless Streamable HTTP, one bearer token per call.
app/tools/, registered in app/tools/tool.rb.
The grant decides which tools exist
Section titled “The grant decides which tools exist”Grant#tools filters Tool.all by the token’s scopes, per request:
def tools Tool.all.select { |tool| permits?(tool.scope) }endA 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.
Scopes
Section titled “Scopes”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.
The tools
Section titled “The tools”| 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 |
Arguments
Section titled “Arguments”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.
The selector
Section titled “The selector”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.
Budgets
Section titled “Budgets”| 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.
Auditing
Section titled “Auditing”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.
Errors
Section titled “Errors”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.