Model Context Protocol (MCP) integration
Model Context Protocol integration. Two standard `/mcp` servers, both connector-credentials only — the knowledge base, deals and intents on agents (18 tools) and payments reads plus `propose_*` proposals the account holder confirms in the app on the test / live payments hosts (19 tools) — plus the bespoke `/api/mcp/*` REST surface. Connector keys, OAuth, the live opt-in, and wiring for Claude Code, Claude Desktop, claude.ai connectors and curl.
YieldFabric exposes MCP surfaces so AI clients (Claude, Cursor, other MCP-compatible hosts) can use YF as a knowledge base, read wallet state, and propose money movements that the account holder confirms in the app — through stable tools.
This is an optional AI-tooling surface. Regular web apps and backend services use auth REST; JWT-selected payments-direct GraphQL for payments mutations and mode-bound PAY/MQ/RPC reads; the GraphQL gateway for cross-subgraph/shared-core queries; and same-instance message-status polling for settlement.
Three surfaces — pick by client
POST https://agents.yieldfabric.com/mcpfindings) plus deal_list, deal_get, intent_status.POST https://pay.test.yieldfabric.com/mcp (test chain 153) / POST https://pay.live.yieldfabric.com/mcp (live chain 151)whoami, wait_for_settlement, assets, contacts, pipeline tasks, deal agreements), four propose_* proposals and intent_status. Nothing on this server executes a payment — a proposal is confirmed by the account holder in the app.https://pay.test.yieldfabric.com/api/mcp/* (and the pay.live… twin)tools/call, tools/list, …)If you're wiring Claude: you want the two /mcp surfaces — knowledge on
agents, money on payments; a client connects both with one connector
credential.
Connector credentials — what both /mcp servers admit
Every request to either /mcp — initialize included — needs a bearer that
resolves to a connector session (session_kind: connector). There are
two ways to hold one:
-
A connector API key. Mint it once, from a direct personal session:
curl -X POST https://auth.yieldfabric.com/auth/api-key/generate \ -H "Authorization: Bearer $ONE_TIME_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"service_name":"claude-code","kind":"connector","allowed_chains":["153"]}' # → { "api_key": "yf_api_…", "kind": "connector", "allowed_chains": ["153"], … }kindis decided once, on the key row.allowed_chainsis the set of chains its sessions may run on (omitted ⇒ the test chain for a consumer-role caller). Send the rawyf_api_…value as the bearer; each server exchanges it at auth for a short-lived session and caches the result briefly (payments: at most 60 s per key and chain), so the key never expires mid-session and revoking it (POST /auth/api-keys/{id}/revoke) takes effect within minutes, not at the key's expiry. One connector key serves both servers. -
An OAuth grant. Add the server URL with no header: the
401challenge points at/.well-known/oauth-protected-resource, and the consent flow is auth's. The access token carries the server's resource URI inaud(https://agents.yieldfabric.com/mcporhttps://pay.test.yieldfabric.com/mcp/https://pay.live.yieldfabric.com/mcp); each server checks its own audience, so a token minted for the agents server is401on payments and vice versa.
Everything else — a personal yf_api_… key, a login JWT, a delegation JWT,
a service token, a token minted for another resource — answers 401 with
WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource", error="invalid_token"
and a JSON {"error": "…"} carrying one of two texts. Which one depends
only on the credential, not on which server you hit: both servers run the
same two checks in the same order — the audience first, the session kind
last — so one bearer gets one text wherever it is presented.
- "This session is not a connector credential: mint a connector API key
(
POST /auth/api-key/generate {"kind": "connector"}) or connect via OAuth" — a personalyf_api_…key (the defaultkind). The server exchanges the key itself, so the exchange binds it to that server and no audience check runs; the resulting session is then found not to be a connector. - "Token was not issued for this resource (
<this server's /mcp URL>); connect through this server's OAuth flow" — any bearer that is not ayf_api_…key and does not name this server's resource URI inaud: a login JWT, a delegation JWT, a service token, an OAuth token minted for the other server, or a connector JWT you exchanged at auth yourself instead of handing the server the key. Only OAuth grants made for that server carry its audience.
A bearer auth itself rejects — an expired OAuth access token, a revoked
or unknown yf_api_… key, a tampered JWT — is the same 401 with the
same challenge on both servers (the body names the validation failure
rather than one of the two texts above), so an MCP client that follows the
challenge simply re-runs OAuth, or you re-mint the key.
Never 403. When auth cannot be reached while exchanging or validating,
the answer is 503 with Retry-After: 5 and no challenge — retry, do not
re-authorize.
What a connector session may do. Read (both servers) and propose
(payments). It carries no CryptoOperations / PolicyExecution, and it is
refused before any handler runs wherever a write would execute: a payments
GraphQL mutation from a connector session is FORBIDDEN with
extensions.code: connector_read_only (httpStatus: 403); every
write-shaped payments REST route (anything but GET / HEAD / OPTIONS,
a few POST-shaped reads excepted) is 403 {"error": "FORBIDDEN: connector credentials can only propose …", "code": "connector_read_only"}; and the
bespoke /api/mcp/tools/call direct writes answer the same 403 {"error": "FORBIDDEN: …"} before the handler runs. No message id is ever
minted for such a call. Confirming an intent, minting a delegation and
managing keys all require a direct personal session.
Live money is opt-in per person. While users.live_connectors_enabled
is false (the default), no connector credential of yours can be generated
for, minted on or refreshed onto a live chain: a connector key naming 151
is 403 {"error", "code": "live_connector_opt_in_required", "chain_id": "151"} at generation, exchange and refresh, and an OAuth grant for the
pay.live… resource is refused with 400 invalid_target. The flag is read
with GET /auth/users/me/live-connectors (any self-scoped session, a
connector included, so it can learn why a live mint was refused) and written
only with PUT /auth/users/me/live-connectors {"enabled": true} from a
direct personal session at the live verification floor (KycVerified;
otherwise 403 live_verification_required). Disabling never needs the floor
and ends live mints and rotations from the next call. Never enable it from
an agent; the account holder does it in the app.
The payments surface (POST /mcp on pay.test… / pay.live…)
The URL is the chain. A connector key session lands on the chain the
exchange bound it to; a session minted for another chain answers every
tools/call with an isError result whose code is CHAIN_MODE_MISMATCH —
route the credential to the matching host. Every downstream call the tools
make carries an on-behalf-of token minted for that callee — the bearer
never leaves the host.
claude mcp add --transport http yieldfabric-payments https://pay.test.yieldfabric.com/mcp \
--header "Authorization: Bearer yf_api_..." # a CONNECTOR key
Catalog (19 tools)
whoamisession_kind, chain_id + mode (LIVE / TEST), wallet id and address, permissions, gates.wait_for_settlement {message_id, timeout_s ≤ 60}{state: pending | executed | settled | failed, executed_at, post_processed_at, error?, retry_after_s?, timed_out} — the settled predicate of the message-status endpoint, held server-side (default 30 s).list_assets {limit?, cursor?}, get_asset {asset_id}list_contacts {limit?, cursor?}, get_contact {contact_id}, list_connection_requests {status?, limit?, cursor?}pipeline_list_tasks {domain?}, pipeline_describe_task {task_name}, pipeline_propose {steps} (a validator — no side effect), pipeline_find_predecessors {task_name}yieldfabric_dealAgreement {deal_id}, yieldfabric_dealAgreementGraph {deal_id}, agreement_next_scheduled_payment {deal_id}propose_send {asset_id, amount, destination_id? | destination_wallet_id? | contract_id?, obligor?}amount is human-readable (scaled at dispatch).propose_accept {payment_id}propose_swap_credit_for_cash {denomination, amount, obligor, deadline?}deadline RFC 3339, default 30 days).propose_complete_swap {swap_id}intent_status {intent_id, workspace_id?}status, message_id once submitted, result / error, confirm_url and recovery (none / in_progress). An intent the caller may not see is not_found.Every propose_* also takes request_id? (folded into the proposal
identity — pass a distinct value to open a second, intentionally identical
proposal), working_group_id? (the workspace the intent is filed in;
default the caller's personal workspace), title? and rationale? (what
the confirming person reads). Reads are annotated readOnlyHint: true, idempotentHint: true; proposals readOnlyHint: false, destructiveHint: false, idempotentHint: true — nothing here is destructive, the destructive
act is the human's confirmation. The seven direct writes (instant send,
accept, swaps, connection requests) are absent from this catalog, not
present-but-refusing: calling one is JSON-RPC -32602 Unknown tool.
Propose → confirm → settle
propose_send {…}→{intent_id, workspace_id, status: "proposed", expires_at, identity_hash?, confirm_url, created}. The intent pins the chain (this host's), the wallet (the session's default — a walletless session isCHAIN_ACCOUNT_REQUIRED) and the actor from the validated claims, and expires 15 minutes after filing. The same proposal while one is open returns the same intent withcreated: false.- The account holder opens
confirm_url(https://app.yieldfabric.com/private?reader=intent/<workspace_id>/<intent_id>) and confirms from a direct personal session in the app. Nothing an agent holds can confirm. intent_status {intent_id, workspace_id}—proposed → approved → submitting → submitted → completed | failed; onlyproposedcan becomecancelledorexpired. Fromsubmittedon,message_idis set.wait_for_settlement {message_id}untilsettled(orfailed), then read the resulting records.
Results and failures
Every tool advertises an outputSchema derived from its response type, and
a successful result carries the same document as text and as
structuredContent. A failure is a result, not a protocol error: HTTP
200, isError: true, with {"error": {"code", "message"}} in the text
block — and, because these tools advertise an output schema, no
structuredContent on the error (the error document cannot validate
against the success schema, and conforming clients validate whatever
structuredContent they are given). Codes: invalid_params, not_found,
downstream_failed, obo_unavailable, obo_refused, tool_failed,
CHAIN_MODE_MISMATCH, CHAIN_ACCOUNT_REQUIRED, intent_refused (agents
refused the caller), intent_conflict (agents answered 409). The list
tools take limit (≤ 100) and cursor and answer next_cursor while more
remain; the whole serialised result is capped at 256 KiB. The skill's
references/payments.md §Standard MCP carries the same catalog with
examples; the wire reference is
the payments MCP spec.
The knowledge-base surface (POST https://agents.yieldfabric.com/mcp)
Standard MCP over stateless Streamable HTTP: initialize, ping,
tools/list, tools/call; notifications get 202; GET /mcp is 405
(no server-push stream). Admission is the connector rule above — the same
connector key or an OAuth grant whose aud is
https://agents.yieldfabric.com/mcp. Eighteen tools: fifteen read and reason
over the knowledge graphs YieldFabric already built (file → KG; the
connector never re-processes), and three read the deals and financial
intents the caller is party to. Everything is scoped to what the caller can
access.
The knowledge tools come in two halves, and the split is the thing to understand before wiring anything to them:
- Ranked (
search,ask) returns the best matches and never a total, so an empty result means "nothing scored highly" — not that something is absent. - Exhaustive (
count,enumerate,count_in_set) returns a total over a defined scope, with no LLM in the path. These are the tools that can establish absence, and the frozen-set family is what makes such a claim checkable later.
Reading the corpus
graphs {limit?, cursor?}kind / label (deal room / workspace / private workspace / direct message / platform), its graphs' KG:<uuid> ids, names, and entity/document counts. Paged (limit default 50, cap 100; largest rooms and graphs first): count is this page, total the whole count. The entry point for building a picture.fetch {id, offset?}KG:<uuid> → a structural picture of a graph (entity types, schema triples, most-connected entities, relationship vocabulary); DOC:<uuid> → the document's text, windowed at up to 40k chars — shorter when a window would not fit one result — (page the rest with offset/next_offset); FRAME:<kg>:<frame> → one entity; FILE:<room>:<file> → an original upload (text as text, paged with offset / has_more; images viewable — an image too large to inline is downscaled and served as JPEG, with served_as / converted_from / downscaled_to in the companion text block; audio/video/PDF by identity only — read those via their DOC:). DOC/KG/FRAME results carry their source.entities {query?, type?, graph?, limit?, cursor?}query/type/graph → matching entities, each with its source. Paged (limit default 25, cap 100; keyset cursor; next_cursor, also under its older name nextCursor).explore {entity, limit?, cursor?}roles so you can read edge direction ('who are the parties to X', 'what is Y connected to'). Connections are paged (limit default 60, cap 100; next_cursor while more remain); an ambiguous name answers ambiguous: true with candidates.search {query, limit?, cursor?, graph?, set?, as_of?, effort?}source (which knowledge graph + which room) and often structure (the graph nodes that passage grounds). Paged over the ranked window: limit 1–25 (default 10), an offset cursor valid for the same query and scope, next_cursor while deeper ranks remain, at most 100 deep.ask {question, graph?, set?, as_of?}sources roll-up name the graph + room each fact came from (sync, ~5–30 s). Answers from one room at a time: the room holding the most graphs unless you pass graph or set, with scope_note naming what was skipped. Use search when the question spans rooms.whoamifiles {room, limit?, name?, cursor?}FILE:<room>:<file> id fetch accepts — for seeing a photo, reading a short note whose exact words matter, or confirming what a room holds. Paged (limit default 50, cap 100; keyset cursor over upload time — after / next_after / has_more are the older names); name filters by filename substring before paging.Counted answers, and claims that stay checkable
count {term, mode?, graph?, room?, as_of?}graph / one room). documents: 0 here is a defensible absence finding; an empty search is not. capped: true means the scan hit its bound and the number is a floor.enumerate {term, mode?, graph?, room?, as_of?, limit?, cursor?}limit default 100; keyset cursor, next_cursor / the older after / next_after); exhausted: true on the last page is what makes a coverage claim auditable (a page cut short by size is not exhausted). Refuses outright rather than truncating when a term is too broad.freeze_set {terms, mode?, as_of?}{job_id, status: "running"} immediately (see the async pattern below). The one tool here that writes — it files a freeze job — so it is annotated readOnlyHint: false, idempotentHint: false, destructiveHint: false.set_status {job_id}status is running, done or failed (the vocabulary of both the tool description and its outputSchema); done carries the set_id, failed the error.count_in_set {set_id, terms, mode?, as_of?, limit?, cursor?}set_documents as the denominator — a total over known membership rather than over whatever a ranking surfaced. results is paged over the terms you pass (limit default and cap 100 terms per call; pass the same terms with next_cursor).get_set {set_id, limit?, cursor?}members — the frozen DOC: ids, paged (limit default 50, cap 100) — confirm what it covers before relying on a count against it.sets {limit?, cursor?}shared: true). Paged (limit default 50, cap 100): count is this page, total and shared_count are over all. A shared set is often the only way to reach documents in a workspace you otherwise cannot see.count, enumerate, freeze_set and count_in_set all take a mode:
exact (default — no stemming, so an ACN or a clause number matches only
itself), phrase (stemming, OR and -negation), or fuzzy (substring, which
matches inside words — never present a fuzzy count as a finding).
Every result names its source. Hits, entities, and citations carry a
source: {kg, room} object — which knowledge graph, and which room /
workspace / private notebook it came from — so the model can always tell
you where a fact originated, and search spans every room you can access in
one call. Results drawn from a set another workspace shared with you are
the one exception: they carry source: {set: "SET:<uuid>"} and no citation
link, because you were given a set, not the room holding it. Retrieval is
scoped to what the caller can access (rationale:
the agents MCP architecture note).
Deals and intents (reads)
deal_list {workspace_id, thread_id?, include_terminal?, limit?, cursor?}thread_id (matched exactly — omit the key to list every thread; an empty string names no thread and lists nothing) (include_terminal adds completed, cancelled and failed ones; default false), paged with limit (default 20, cap 100) / a keyset cursor (next_cursor while more remain; truncated: true when a page was cut short by size). Each row carries id, group_id (the workspace), thread_id, status (drafting, committed, pipelined, completed, cancelled, failed), label, the snapshot, the selected_* plan fields, created_by and the timestamps. A workspace the caller is not a member of yields only the deals they created there — never an error.deal_get {deal_id}intent_status {intent_id, workspace_id?}intent_status reads, returned whole (the REST intent document: 52 fields, the workspace as group_id): status (the state machine above), tool_name and arguments as filed, the pinned chain_id / wallet_id / actor_entity_id, expires_at, approved_at, message_id, result, error, confirm_url, recovery (none / in_progress). workspace_id is an assertion when given (a mismatch is not_found) and no filter when omitted. Visible to the proposer, the pinned actor, or an active member of the workspace it is filed in.These are store-level authorised reads: anything outside the caller's
view is not_found, and existence is not disclosed. No write tool is on
this surface — a financial proposal is filed through the payments
connector's propose_* tools (that is where chain and wallet context lives),
and the deal commit / cancel and intent-propose tools stay on the internal
/api/mcp/tools/call route (below).
Results, paging and size
Every tool whose result is JSON advertises an outputSchema and returns the
document as text and as structuredContent. A failure is isError: true with no structuredContent (as on payments); its text block is
{"error": {"code", "message"}} on deal_list / deal_get /
intent_status (invalid_arguments, not_found, downstream_failed) and
a plain message on the fifteen knowledge tools. The one exception to the
schema rule is fetch: a FILE: result can be an image content block, so
fetch advertises no output schema and answers content blocks only. Every
list-shaped tool (search, files, graphs, entities, explore,
enumerate, count_in_set, get_set, sets, deal_list) takes limit
(per-tool default, hard cap 100 — 25 on search — an integer past the cap
is clamped, never refused; a limit that is not an integer, such as the
string "12", is refused rather than silently defaulted) and an opaque
cursor, and returns next_cursor only while more exist; a cursor the
tool did not issue is refused rather than read as page one. An
oversized page is cut by the server inside its own paging with truncated: true (plus next_cursor to resume at the first item dropped), and the
transport's 256 KiB cap is only the backstop behind that. fetch DOC: and
fetch FILE: text keep their offset / next_offset window, cut to the
same budget.
Auth: two ways in
1. Connector API key header (backend/dev — Claude Code, Claude Desktop).
Mint a yf_api_… key with "kind": "connector" as shown above (the general
API-key pattern is in docs/building-with-yf.md
§API keys), then pass it as the bearer — the server exchanges it per
request, so it never expires mid-session. A personal key (the default
kind) is 401 here with the hint to mint a connector one. Do not put
a raw JWT in config: access JWTs die in 15 minutes.
claude mcp add --transport http yieldfabric https://agents.yieldfabric.com/mcp \
--header "Authorization: Bearer yf_api_..." # a CONNECTOR key
or in .mcp.json:
{
"mcpServers": {
"yieldfabric": {
"type": "http",
"url": "https://agents.yieldfabric.com/mcp",
"headers": { "Authorization": "Bearer yf_api_..." }
}
}
}
2. OAuth login (claude.ai custom connectors — the "Connect" button).
Add the connector in claude.ai → Settings → Connectors → Add custom
connector → paste https://agents.yieldfabric.com/mcp. The browser
opens YieldFabric's login (any sign-in method — email/password,
MetaMask, passkey, Dynamic), shows a consent card, and the connector
is live; token renewal is silent thereafter. Claude Code/Desktop can
use the same flow — omit the --header and run /mcp to
authenticate. The grant is a connector session bound to the test chain
(a knowledge connector never needs a chain) whose aud is this server's
URL — it is not accepted by the payments server, which issues its own.
Under the hood (all standard, nothing to configure client-side):
anonymous POST /mcp → 401 + WWW-Authenticate →
/.well-known/oauth-protected-resource (agents) → RFC 8414 metadata +
RFC 7591 dynamic registration + PKCE code flow (auth service
/oauth/*) → consent page (app.../oauth/authorize) → 1-hour MCP
access tokens with 30-day silent refresh. The authorization server is
documented in
auth's OAuth spec.
Calling it manually (curl)
# Handshake (YF_CONNECTOR_API_KEY holds a kind: connector key)
curl -s -X POST https://agents.yieldfabric.com/mcp \
-H "Authorization: Bearer $YF_CONNECTOR_API_KEY" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"curl","version":"0"}}}'
# Discover tools
curl -s -X POST https://agents.yieldfabric.com/mcp \
-H "Authorization: Bearer $YF_CONNECTOR_API_KEY" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# Ask the knowledge base (grounded answer + citations)
curl -s -X POST https://agents.yieldfabric.com/mcp \
-H "Authorization: Bearer $YF_CONNECTOR_API_KEY" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ask","arguments":{"question":"What are the key terms of the Acme facility?"}}}'
Async pattern: freeze_set is the one tool here that parks its work. It
returns {job_id, status: "running"} immediately — poll set_status with that
id every few seconds until the status is terminal (done or failed), then
use the returned set_id. Freezing is a single database statement and normally
takes seconds; don't wait synchronously, and don't re-submit freeze_set while
waiting, which just mints a second snapshot. MCP clients abort tools idle for
~5 minutes.
The wallet/payments surface (/api/mcp/*, bespoke REST)
Served by the payments service. This is not the standard MCP protocol — there is no JSON-RPC envelope, so Claude and other standard MCP clients cannot connect to it directly; it exists for YF-internal agent runtimes and custom integrations.
/api/mcp/resources/wallet/{uri}wallet://{wallet_id} URI./api/mcp/tools/call/api/mcp/prompts/getwallet_context_prompt, connections_context_prompt)./api/mcp/tools/list/api/mcp/prompts/list/api/mcp/resources/listwallet://)./api/mcp/initializeEvery endpoint is JWT-gated (Authorization: Bearer <jwt>) and runs
under the caller's permissions. The 15-minute JWT TTL means this
surface suits programmatic callers that mint tokens per session — not
static client config. A connector session can read and propose here, but
the seven direct writes are refused before the handler runs with
403 {"error": "FORBIDDEN: connector credentials can only propose …"} —
it proposes instead.
Wallet-required tools do not deploy an account implicitly. A walletless user
or delegation receives CHAIN_ACCOUNT_REQUIRED with the effective entity and
chain context. Activate that user/group through auth's
POST /entities/{user|group}/{id}/chain-accounts/{chain_id}/activation, wait
for ready, refresh on the same chain, and retry the tool once. Route the JWT
to the matching pay.test... or pay.live... surface.
Tool catalog (26): yieldfabric/list_assets, get_asset,
send_instant_payment, accept_payment, swap_credit_for_cash,
complete_swap, list_contacts, get_contact,
list_connection_requests, create_connection_request,
accept_connection, send_invitation, pipeline/list_tasks,
pipeline/describe_task, pipeline/propose,
pipeline/find_predecessors, agreement/get, agreement/get_graph,
agreement/next_scheduled_payment, whoami, wait_for_settlement, and —
under their public names, no slash alias — propose_send,
propose_accept, propose_swap_credit_for_cash,
propose_complete_swap, intent_status. Read tools/list for the
authoritative input schemas (the same registry drives the standard /mcp
catalog, so the schemas agree).
curl -s -X POST https://pay.test.yieldfabric.com/api/mcp/tools/call \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name": "yieldfabric/list_contacts", "arguments": {}}'
The agents service also keeps a bespoke POST /api/mcp/tools/call
route (knowledge-document, deal and intent tools, yieldfabric/* names)
for the same internal callers, with its routes, status codes and bodies
unchanged. Its yieldfabric/deal/list, yieldfabric/deal/get and
yieldfabric/intent/status are the same implementations as the /mcp
deal_list, deal_get and intent_status above, under their legacy
names and with their legacy bodies (intent/status keeps its compact
eight-key document rather than the /mcp entry's full one) — so reads
there are authorised too, and a deal or intent outside the caller's view
answers the route's existing 404. The legacy deal writes on that route
(yieldfabric/deal/commit, yieldfabric/deal/cancel,
yieldfabric/intent/propose_from_candidate) pass the same check before
they touch anything. The /mcp surface above is the external-client
story.
Use MCP for agent tooling, not app data flow
MCP is useful when a model-facing tool client needs a compact wallet view, typed knowledge access, prompts, and a safe way to propose a payment for a person to confirm. For product flows controlled by your own app, prefer the direct public APIs:
- Auth REST for sign-in, token refresh, profile lookup, and API keys.
- Payments-direct GraphQL for payments mutations and instance-local reads.
- The GraphQL gateway for cross-subgraph and shared-core queries.
- Same-instance payments REST polling for settlement confirmation.
See also
- Agents, knowledge & workspaces — the collaboration surface the knowledge tools sit beside.
- Agents MCP surface architecture — design rationale, auth posture, don't-break rules.
- Authentication & signing — JWT lifecycle and permissions for tool calls.
- API reference — REST and GraphQL operations the MCP tools wrap.