For the complete documentation index, see llms.txt. This page is also available as Markdown.

MCP Authentication

External agents authenticate to RobotMoney's MCP server with a JWT issued by auth.robotmoney.com, verified against that service's published JWKS. Authorization then layers on top: for most agents, the on-chain registration in BrainMCPAgentRegistry supplies a scopeHash attestation that the server cross-checks against the agents.scope_hash DB column before any tool call is allowed. A narrow, read-only class of agents carries no on-chain attestation and is handled separately, under "Attested vs Unattested Agents" below.

The Auth Chain

┌─────────────────────────────────────────────────┐
│  External agent                                 │
│  holds a JWT issued by auth.robotmoney.com             │
└────────────────┬────────────────────────────────┘
                 │  Authorization: Bearer <jwt>
                 ▼
┌─────────────────────────────────────────────────┐
│  RobotMoney edge                                │
│  - Verifies JWT signature via auth.robotmoney.com JWKS │
│  - Resolves principal (tenant + scopes)         │
└────────────────┬────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────┐
│  MCP dispatcher                                 │
│  Always, for principal_type=agent:              │
│  1. Agent record in `agents` is `active`        │
│  2. JWT `tenant_id` equals the DB agent row's   │
│     `tenant_id`                                 │
│  3. The agent row has a `scope_hash` at all     │
│  Then, by the row's `attestation_mode`:         │
│   - attested: DB `scope_hash` matches on-chain  │
│     `scopeHash` in BrainMCPAgentRegistry        │
│     (verified once, cached 60 s per agent)      │
│   - unattested: four-clause check, no chain read│
└────────────────┬────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────┐
│  Per-tool scope enforcement                     │
│  Method dispatcher checks the called tool's     │
│  scope against the agent's granted scopes       │
└─────────────────────────────────────────────────┘

Attested vs Unattested Agents

Every agent row carries an attestation_mode: onchain_custodial (the default), tenant_signed, or none. Only none changes the auth chain above. Three checks always run first and unconditionally, before attestation mode is even read:

  1. Agent record in agents is active.

  2. JWT tenant_id equals the agent's tenant.

  3. The agent row has a scope_hash at all (a row with no scope attestation is rejected outright).

An attested agent (tenant_signed or onchain_custodial, which today means every existing agent) then takes the path this doc describes in full below: the agents row's scope_hash column must match the on-chain hash in BrainMCPAgentRegistry, verified once and cached 60 seconds per agent. That comparison is server-side and does not involve the JWT, which carries no scope_hash claim.

An unattested agent (attestation_mode = "none") skips the on-chain read entirely, but only when ALL FOUR of these hold. Any one failing sends the request down the attested path instead, so a misconfigured unattested agent gets the strict on-chain check, never a silent pass:

  1. The agent row's attestation_mode is "none".

  2. The agent's registered role resolves to a scope set that is fully contained in a small read-only allowlist: ledger:read, wiki:read, raw:read. A role that can propose or execute anything, such as payment, is never eligible no matter how attestation_mode is set.

  3. The agent's stored scope_hash is exactly the canonical hash derived from that role's scopes, not a stale or hand-planted value.

  4. The scopes on the JWT actually presented for this call are also fully contained in that same read-only allowlist. A wider-scoped token, even from an otherwise-eligible agent, is rejected onto the attested path.

This is a real capability, not a formality: an unattested agent can still call payment_intent.list (reads ledger:read) and raw.artifact.get (reads raw:read), so it can list a tenant's pending payment intents and read ingested document bytes without ever touching BrainMCPAgentRegistry. Tenants that grant attestation_mode = "none" to an agent are trusting RobotMoney's own account/scope enforcement for that read access, not an on-chain attestation.

JWT Structure

The JWT is issued and signed by auth.robotmoney.com, RobotMoney's own OAuth 2.0 authorization server, and verified by every service (including MCP) against auth.robotmoney.com's JWKS -- not by any per-agent signing key.

Claim
Purpose

iss

Always https://auth.robotmoney.com

sub

The RobotMoney-internal principal id, agent_<ulid> for an agent JWT

aud

Always brain-api

iat, exp

Issued / expiry, max 1-hour TTL

jti

Token id, checked against the revocation store

tenant_id

Tenant the call is on behalf of; compared against the agents DB row's tenant, not the on-chain registration

principal_type

agent for an MCP-calling agent; user for a human proposal decision

scopes

The granted capability scopes

There is no scope_hash claim on the JWT. The scope-hash comparison described below is entirely server-side: it compares the scope_hash column already stored on the agents DB row against the hash registered on-chain. The JWT plays no part in that comparison; it only supplies tenant_id, principal_type, and scopes.

On-Chain Scope Verification

This is the move that makes RobotMoney's agent surface different from a typical OAuth integration: scope is anchored on-chain.

When the tenant authorized the agent, they signed an EIP-712 message that registered the agent with a scopeHash in BrainMCPAgentRegistry. The scope document itself stays off-chain; only its hash is on-chain.

The MCP auth verifier (services/mcp/src/auth.ts) runs these checks, in order, for every agent-principal request. Steps 3 and 4 are the attested path; an agent that satisfies all four unattested clauses above stops after step 2:

Step
Check

1

Load the agents row for the JWT's sub; reject if missing or not active

2

Reject if the JWT's tenant_id does not equal that row's tenant_id

3

Read BrainMCPAgentRegistry.getAgent(agentId); reject if unregistered or revokedAt != 0

4

Compare the DB row's scope_hash to the on-chain scopeHash; reject on mismatch

The on-chain read is cached for 60 seconds per agent. This balances on-chain verification cost against revocation latency: a revoked agent is rejected within at most 60 seconds.

The Capability Scopes

The canonical scope document enumerates which of these the tenant has granted to the agent.

Scope
Allows

ledger:read

All ledger.* read tools, payment_intent.list, and brain://ledger/... resources

wiki:read

All wiki.* read tools and brain://wiki/pages/... resources

raw:write

The raw.contribute tool

raw:read

The raw.artifact.get tool

payment_intent:propose

The payment_intent.propose and payment_intent.cancel tools, and the brain://payments/action_types resource

payment_intent:approve

Accepted at the proposals.decide call boundary; member approval authority is enforced downstream

execution:read

The proposals.list, proposals.get, and evidence.resolve tools; also accepted by proposals.decide

execution:propose

The agent.action.propose tool

audit:read

The brain://proofs/{action_id} resource

A tenant can grant any subset. Unused scopes do not appear in the canonical document. The scopeHash is the keccak-256 of the canonical, lexicographically-sorted scope set (computeAgentScopeHash in shared/src/agents/capability.ts); it is the same hash the registration tooling writes on-chain, so the seed, the DB row, and the registry agree byte-for-byte.

Per-Call Scope Enforcement

Even after the pre-dispatch checks pass, each tool invocation is scope-checked. Calling wiki.question with a JWT that lacks wiki:read returns:

Per-call scope enforcement runs after authentication, inside JSON-RPC dispatch, so it surfaces as a JSON-RPC error (HTTP 200), unlike the pre-dispatch auth checks below.

Error Codes: two surfaces

RobotMoney's MCP surface fails in two distinct places, and the shape of the error differs:

1. Pre-dispatch auth failures -- HTTP 401/403 RobotMoney error envelope. The route guard (services/mcp/src/transport/http.ts) checks the JWT and principal type; the auth verifier (services/mcp/src/auth.ts, run at the top of server.handle) checks on-chain registration, scope-hash, and tenant. All of these throw a BrainError that propagates out of the handler before any method is dispatched, so the client sees an HTTP 401/403 with a RobotMoney error envelope, not a JSON-RPC response. The envelope shape is { "error": { "code": ..., "message": ..., "details": ..., "request_id": ..., "docs_url": ... } } (RobotMoney Engineering Standards §4.1), not the flatter { "error_code": ..., "message": ... } shape.

error_code

HTTP

Meaning

auth_token_missing/invalid/expired

401

JWT absent, malformed, or expired

auth_scope_insufficient

401

Principal is not principal_type=agent (or user)

auth_tenant_mismatch

401

JWT tenant_id != agent's registered tenant

agent_not_registered

401

Agent row missing in agents, or not active

agent_not_registered_onchain

401

Agent has no record in BrainMCPAgentRegistry

agent_scope_hash_missing

401

Agent row has no on-chain scope attestation

agent_scope_hash_mismatch

401

DB scope_hash differs from the on-chain scopeHash

2. Post-auth JSON-RPC errors -- HTTP 200 with a JSON-RPC error. Once the call has authenticated, failures inside method dispatch (scope, gate, params, not-found) are carried in the JSON-RPC error field with data.brain_code set:

Code
Meaning

-32001

JWT invalid/expired (auth_token_*), in-dispatch only

-32002

Scope insufficient / tenant mismatch (auth_scope_insufficient, auth_tenant_mismatch)

-32003

Agent not registered or inactive (agent_not_registered, agent_not_registered_onchain)

-32004

Pre-execution gate failed (payment_intent_gate_failed, every gate_*)

-32005

On-chain scope_hash mismatch (agent_scope_hash_mismatch)

-32600

Standard JSON-RPC: invalid request

-32601

Standard JSON-RPC: method not found

-32602

Standard JSON-RPC: invalid params, including a valid-looking id that resolves to nothing (*_not_found, payment_intent_invalid_state, payment_intent_approval_invalid)

-32603

Standard JSON-RPC: internal error

The numeric mapping lives in services/mcp/src/dispatcher.ts. In practice the auth-class codes (-32001/-32002/-32003/-32005) are reached via the HTTP envelope above, since auth runs before dispatch; the JSON-RPC codes you will actually observe in a 200 body are -32004 (gate), -32002 (per-tool scope), -32602 (bad params or not-found), and the standard -326xx family.

Token Lifetimes

Token
TTL
Refreshable

Agent JWT

Max 1 hour

Yes; auth.robotmoney.com issues a new one

Cached scope verification

60 seconds

Auto-refreshes on next call

On-chain registration

Until revoked

N/A; on-chain

Revoking an Agent

Two paths:

Path
Effect

Tenant in Console

Generates EIP-712 revocation signature, calls BrainMCPAgentRegistry.revokeAgent

Tenant via API

POST /v1/agents/{agent_id}/revoke with the tenant's signature

After revocation, the on-chain revokedAt becomes non-zero, so the scope read returns null and, within the 60-second cache window, all calls are rejected pre-dispatch with an HTTP 401 agent_not_registered_onchain envelope.

What's Next

Tools

The 21 tools and their per-tool scope requirements.

BrainMCPAgentRegistry

The on-chain contract this all anchors to.

Last updated