Skip to main content
Draft — ahead of the backend; nothing here is live yet. This surface is written against the design, not against a running server. There is no api.contracts.io to call, no key to issue, and every response below is an illustration rather than a recording. It is published so the shape can be argued with before it is built.It also predates CONTRACT-SCHEMA v0 and still uses the older nouns — ask for Proposal, receipt for Version, relationship for Room. The glossary maps them.
An external agent (someone’s Claude, a Slack bot, an ATS) connects to contracts-mcp as the person who connected it and holds exactly that person’s seat.
This connection can never send or sign.That is the standing line on the consent screen, and it is in the server’s MCP instructions on every connection, so the model is told before it plans rather than after it tries.

Parity, again

MCP is the API’s tool list, not a second product. Every tool is a button somewhere in the app, and every button is a tool. There is no MCP-specific vocabulary: stage_accept writes to the same private reply draft the Accept button writes to, and risk_scan returns the same findings the transcript renders.
Parity binds acting verbs (refined 2026-08-24). Every tool below that acts (stages, sets, imports, confirms, prepares) has an endpoint behind it, and a staging tool without one is a real bug. The explain-and-scan tools are different: they are compositions over public reads, not resources, so they have no endpoints of their own and are not expected to. See the parity principle.
The server is a client of the public API. It does not get a privileged connection, an in-process database handle, or a service role. If an agent needs a datum, the API grows a verb that everyone gets.

Connecting

Authenticate with a per-person token. The token is the seat.
Do not authenticate this server with an org key (sk_live_…). An org key must name the human it acts for on every write, and MCP already has one: the person who connected it.

The scopes a connection asks for

A per-person token carries scopes, and a connection should ask for the narrow set its tools actually use. Here is the map, tool group by tool group. confirm_span is the one row with a seam in it. Before the first send a correction changes the words, so it needs contracts:write. After the first send it can only agree with the parse as it stands, which writes nothing, so contracts:read covers it.
send and sign are never requested by an MCP connection. Both are real, grantable scopes. This connection does not ask for either one, because there is no send verb and no sign verb behind the tool list to use them. A consent screen that offered them would be describing a different product.
Grant ask:read, or the most useful tool an outside agent holds answers nothing. ask_contracts is the corpus read: “have we ever agreed to a six-month cap?” It sits behind its own scope because a question across the corpus crosses every contract the seat can open, and a token granted to read one contract should not quietly read across the whole org. Leave the scope off and the tool is still registered, the call comes back 403 insufficient_scope with the missing scope named in param, and it reads to the person watching like a broken agent rather than a missing grant. See Scopes.

Guests never get MCP

A guest is a person with no org. They act through the signed link they were sent, which grants exactly what the required-party list grants them and nothing else. Seat resolution refuses to build a guest seat and throws with that reason. It is the first thing the server does.

The autonomy ladder

Three dials in series, evaluated on every call: org ceilingpersonal dial (within the ceiling) → per-contract arming (chasing only).
A tool above the effective rung is absent from tools/list, not refused when called. The product’s rule is never render a control you will refuse, and a greyed-out tool in a model’s context is the same mistake as a greyed-out button.
Connect at rung 0 and the list visibly shrinks. The staging and send-class tools are simply not registered for that turn.

The tools

Reads: rung 0

Every read passes through one seat filter that drops: any unsent reply not authored by this seat’s party, any internal note not owned by it, any playbook not this seat’s org, and any agent transcript not this person’s. If a read cannot be filtered, it is not a read tool.
get_receipts is how an agent answers “which exact words did I sign?” Signature state hands it hashes. A receipt hands it the snapshot behind the hash. Without this read an agent holds a code it cannot resolve, so it either declines the question or answers it from the words standing today, which are not necessarily the words anybody signed. A receipt is not a browsable history and there is nothing here to diff. It points at the words one signature bound to, and that is its whole job.

Explain and scan: rung 0

risk_scan reports findings, not edits. Turning a finding into words is a separate stage_counter call, so a human always sees the list before the sheet moves. An agent gets readings; the playbook rows themselves are never returned.

Staging: rung 1

These write to the same private reply draft the buttons write to. There is no parallel agent-proposal object and no “AI suggestions” tray. A staged counter is a staged counter, and it carries a person_id: the person who connected the agent. That is the same field the same decision carries when a person stages it by hand, which is the point. One object, one attribution field, one name for it across the API and this server.
After the first send, confirm_span does not stage anything. Earlier drafts of this page said it staged a counter. It does not, and it never did.Before the first send nobody else holds the words, so a correction applies straight to them, and the call needs contracts:write. After the first send the other side is holding the same words, so this call can only agree that the parse read them correctly. Sending text returns 400 invalid_request, and the scope stays contracts:read, because agreeing with words that are already there writes nothing at all. It stages no decision, opens no question, and needs no decisions:write.To change a word after the first send, stage a decision: POST /contracts/{id}/decisions with a block_id, over MCP stage_counter with a block_id. Every word change is a question the other side gets to answer, and this one is no exception.
Every staging tool must produce a visible mark on the sheet within the same turn: a redline, a staged label, the reply bar counting up. A tool whose only output is chat text is a tool that made a second record.

Send-class: confirmation-required, always

prepare_signature is exposed, and it is pointer-only (reconciled 2026-08-24). The design draft had it withheld, and this server exposed it anyway; the ruling went with the server.The reasoning that withheld it (consent is not a remote act) is right, and pointer-only is exactly what honours it. Supplying no consent material at all, the tool is strictly less capable than get_signature_state, which was always exposed. Withholding it bought nothing and cost an agent the one sentence it most needs to say: “the words are agreed, you and Alex still have to sign, here is the link.” The signer still opens the contract. That never changes.
prepare_reply
landed_if_sent is a genuine dry run of the unanimity fold: the blocks that would move if a human pressed Send right now. confirm_url is where the human goes. The server never follows that URL.
A harness that retries a confirmation_required result is a bug, not a fallback. It is not a rate limit and not a transient failure. It is the end of the road. The next actor is a person.

Why confirmation_required is a lock, not a note

A comment saying “don’t send” is not a lock. The server makes it one three ways, deliberately redundant:
1

There is no send verb to call

The client interface the tool layer can reach has no send, no commitReply, no sign. An agent cannot bypass a method that is not there.
2

The return type has no success variant

Send-class methods return a type whose state is the literal 'confirmation_required'. There is no shape meaning “it went out”: no sent, no letter_id, no signature_id. An edit that tries to return success fails the typecheck rather than shipping.
3

A runtime guard on every send-class result

A seal check is the last thing every send-class handler passes through. It throws if the literal drifted, if unpressed is not true, or if a success-shaped key was smuggled onto the object.
Over HTTP the same wall is 409 confirmation_required, cleared only by a separate POST …/replies or POST …/signatures carrying confirmed_by.

Not exposed over MCP

On that last row: GET /playbooks is a public read-only endpoint over HTTP (reconciled 2026-08-24), and the MCP subset still declines the raw read. Both are deliberate. A tool list is a place a model reaches by reflex, and floors-as-data is the wrong reflex to hand it when a reading answers the question. A person driving their own key can read their own playbook; that was never the concern.
The connection screen is a restricted-key page: one row per tool group, the connecting person’s rung stated in one sentence, and the standing line “This connection can never send or sign.” Revocation is one click and kills in-flight staging.

Prompt-injection containment

An agent here holds the connecting person’s row-level access and no more. A counterparty’s poisoned clause text can make a model say anything; it can never make it read anything the seat cannot, and it can never make it send, because there is no send verb in the process.
Containment is structural rather than instructional. That is the only kind that survives a clever paragraph in clause 7.