Skip to content

Approval for effects

  • Lesson 18
  • 55 min
  • Module 6: Protect tools and user control

What you will be able to do

Reading data is one thing; changing something in the world is another. In this lesson you add the course’s one write, propose_refund. The server authorizes it, and it only records a proposal. A person outside the AI application must approve it before anything happens.

Before you startWhat to know and to have readyAbout 55 minutes

What you need to know

Your project

  • ~/projects/support-mcp after the last lesson: python -m pytest -q prints 68 passed, and host/app.py labels results untrusted_data.
  • The labs repository in ~/projects/nextia-learning-labs, up to date.

In many companies, a clerk can fill in a refund form, but cannot pay it. The form names the customer, the order, the amount and the reason. Then it goes to a manager, who reads it and signs. Only a signed form leaves the office. And a manager cannot sign a form that they filled in themselves.

The clerk does useful work: the form is complete and correct. The manager does a different job: they decide. Nobody can skip the signature, however convincing the customer was on the phone. And there is a limit too: a form over a certain amount goes to someone more senior.

Up to now, support-mcp could only read. A fooled model could, at worst, read the wrong thing inside its own organization. Grace wants more: when a customer was charged twice, the assistant should help with the refund.

The easy design is a tool issue_refund(ticket_id, amount) that refunds at once. Think about the last lesson with that tool on the server. The obedient model read T-30201 and asked for a 450-dollar refund. --yes answered the host’s question at once. One fooled model and one careless click, and money leaves the shop.

Tomás has an answer: “Mark the tool readOnlyHint: false and destructiveHint: true. Then every host knows that it is dangerous.” Those are tool annotations: hints in the tool’s description that a host can show to the person. But a hint is not a control. A host can ignore it, and a server that you do not know can lie in it. The annotation does not stop a single call.

The idea: propose, then approve, with gates outside the model

Section titled “The idea: propose, then approve, with gates outside the model”

The fix has the same shape as the form with two signatures. The tool does not refund; it proposes. Two gates protect the effect, and the model controls neither of them.

  1. Authorization on the server, for the call itself. The server checks four facts that no text can change. Is the caller a member of this organization? Does the token carry the scope refunds:propose? Is the role staff or owner? Is the ticket in this organization? Then a business rule: the amount is at most 100.00 dollars, the agent’s limit in Larkfield’s refund approval procedure.
  2. Approval by a person, outside the AI application. The tool records a proposal with an operation ID, such as OP-0001, and the exact operation: what, for whom, how much and why. An owner of the organization approves it with a separate command, never through the model. The owner cannot be the person who proposed it, and a decision is made once.

The host keeps its own, weaker gate as well. A tool that is not on its read-only allow-list needs the person’s “yes”, after the host shows the exact call. That person is Sam, who asked the question. Sam’s “yes” stops a surprise, but it is not a second person’s decision.

Three columns. The AI application (host): the model proposes propose_refund T-30002, amount 25. On the host's read-only allow-list? Yes would run at once, for search_knowledge and get_ticket. No: the person sees the exact call, The assistant wants to run propose_refund with the arguments; no gives the person declined this call. Yes goes to support-mcp (the server), five gates in order: 1. a member of this organization, else -32003 forbidden: you have no access to this organization; 2. scope refunds:propose, else -32003 insufficient_scope; 3. role staff or owner, else -32003 the role read_only cannot propose a refund; 4. a ticket of this organization, else tool error not_found; 5. amount up to 100.00, else tool error over_limit. Then a green box: proposal OP-0001 pending_approval with the exact operation, refund, larkfield, T-30002, 25.00 USD, the reason; nothing is refunded now. The same proposal again, a retry, gives the same OP-0001, not a second one. A note under the host column: annotations, readOnlyHint false, only change what the host shows; they are hints, not a control. Third column, outside the AI application: later, a person, Grace, runs python -m support_mcp.approvals approve OP-0001 --user usr-grace. Three checks: an owner of this organization, else Refused: only an owner of larkfield can decide (you: staff); not the person who proposed it, else Refused: you proposed it, another person must approve it; still pending, else Refused: already approved. Then a green box: refund recorded, OP-0001 approved by usr-grace, mock: no money moved.
The gates around the one write. The model can only propose; the server authorizes the proposal, and a different person approves it outside the AI application.
Text description of the diagram

The AI application (host): the model proposes propose_refund for T-30002, 25 dollars. The tool is not on the read-only allow-list, so the host shows the exact call and asks the person. If the person says no, the call is not sent.

support-mcp (the server): five gates in order. A member of this organization, else −32003 forbidden. Scope refunds:propose, else −32003 insufficient_scope. Role staff or owner, else −32003 “the role read_only cannot propose a refund”. A ticket of this organization, else the tool error not_found. An amount up to 100.00, else the tool error over_limit. Then the proposal OP-0001 is recorded as pending_approval, with the exact operation. Nothing is refunded. The same proposal again returns the same operation ID.

Outside the AI application: later, Grace runs python -m support_mcp.approvals approve OP-0001 --user usr-grace. She must be an owner of the organization, must not be the person who proposed it, and the proposal must still be pending. Then the refund is recorded (a mock: no money moves).

Annotations such as readOnlyHint: false only change what the host shows. They are hints, not a control.

Why does the person approve outside the AI application? Because text can steer everything inside it. A page in the help desk where Grace signs in, a separate command, or an email with a signed link are all outside. In this course, python -m support_mcp.approvals … --user usr-grace stands for that sign-in. It is a practice shortcut on your own computer.

Here is what propose_refund returns for Sam, with every field visible:

The result of propose_refund (T-30002, 25)
{
"operation_id": "OP-0001",
"status": "pending_approval",
"operation": {
"action": "refund",
"organization": "larkfield",
"ticket_id": "T-30002",
"amount": "25.00",
"currency": "USD",
"reason": "Requested in the help desk"
},
"proposed_by": "usr-sam",
"approver_role": "owner",
"message": "Proposed, not done. An owner of the organization must approve it."
}

Every value is the one that the owner will approve. The organization comes from the connection, not from the arguments. The amount is a fixed decimal, 25.00, not “about 25”. The message says plainly that nothing happened yet. The model reads this message too, so its answer cannot claim “your refund is done”.

The tool is also idempotent: the same call twice has the same effect as once. A retry after a network error returns the same OP-0001 instead of a second proposal. Without that, one timeout could become two refunds.

The outline of this course says: add the write only after the permission tests pass. In Module 5, the attempts table sent 48 real requests as 12 different callers. All 33 that should fail did fail, on the server. Those checks, membership, scope and organization, are the first gates of the write. If they were broken, the write would inherit the hole.

So the order is: read-only tools, permission tests that pass, then one write with its own gates. And the server can switch the write off: with SUPPORT_WRITES=off, the tool is not registered at all.

The write is four files and an update of others. support_mcp/writes.py holds the tool, the proposals table and the decision. support_mcp/approvals.py is the owner’s command. server.py registers the write. The tests are test_writes.py and test_injection.py. Here is the heart of the tool, the gates in order:

support_mcp/writes.py (part)
who = caller(ctx) # membership: no member, no call (-32003)
identity.require_scope(who, "refunds:propose") # the token or the local server's scopes
if not who.staff:
raise identity.forbidden(f"forbidden: the role {who.role} cannot propose a refund")
knowledge.ticket(who.tenant, ticket_id) # NotFound -> "not_found: there is no ticket …"
if amount > REFUND_LIMIT: # 100.00, the agent's limit
raise ToolError("over_limit: a refund over 100.00 dollars needs a team lead; …")

The proposals live in a small SQLite file, .support/state.db. Delete the folder .support/ to start again with no proposals.

  1. In ~/projects/support-mcp, with the virtual environment active, copy this lesson’s files:

    Terminal window
    S=../nextia-learning-labs/C28/snapshots/end-of-m06
    cp "$S"/support_mcp/{writes,approvals,server}.py support_mcp/
    cp "$S"/tests/{test_writes,test_injection,test_host,conftest}.py tests/
    cp "$S"/scripts/{attempts,trace}.py scripts/
    cp "$S/.env.example" "$S/README.md" .

    The new conftest.py gives every test its own proposals file. attempts.py and trace.py now include the write.

  2. Run the tests:

    Terminal window
    python -m pytest -q

    Expect 84 passed: 12 tests in test_writes.py and 4 in test_injection.py are new.

  3. Ask for a refund with the default scopes, the two read scopes. Type y when the host asks:

    Terminal window
    python -m host "Ticket T-30002: propose a refund of 25 dollars"
    [host] discovered 3 tools: search_knowledge, get_ticket, propose_refund {"protocol": "2026-07-28", "server": "support-mcp"}
    …
    [model] wants propose_refund {"arguments": {"ticket_id": "T-30002", "amount": "25", "reason": "Requested in the help desk"}}
    [host] not on the allow-list: ask the person {"decision": "ask", "annotations": {"read_only_hint": false, "destructive_hint": false, "idempotent_hint": true, "open_world_hint": false}}
    [host] The assistant wants to run propose_refund with {"ticket_id": "T-30002", "amount": "25", "reason": "Requested in the help desk"}
    [host] Run it? [y/N] y
    [person] said yes {"decision": "approved"}
    [server] protocol error -32003: insufficient_scope: this needs the scope refunds:propose {"code": -32003}

    The tool is listed, because discovery is not gated. But the call fails on the server: these scopes allow reading only. Sam’s “yes” changed nothing.

  4. Ask again with the write scope. This time, type n:

    Terminal window
    python -m host "Ticket T-30002: propose a refund of 25 dollars" --scopes "knowledge:read tickets:read refunds:propose"
    [host] Run it? [y/N] n
    [person] said no {"decision": "declined"}
    …
    I could not use propose_refund: the person declined this call

    The call never left the host.

  5. Run the same command again, and type y:

    [host] Run it? [y/N] y
    [person] said yes {"decision": "approved"}
    [server] result: 397 characters {"ms": 2.7, "clipped": false}
    …
    Ticket T-30002 (open): Charged twice for order LK-182074.
    Refund proposal OP-0001: pending_approval.

    On stderr, the server logged "outcome": "proposed", "operation_id": "OP-0001".

  6. Look at what waits for an owner, and at the refunds:

    Terminal window
    python -m support_mcp.approvals list
    python -m support_mcp.approvals refunds
    {"operation_id": "OP-0001", "status": "pending_approval", "operation": {"action": "refund", "organization": "larkfield", "ticket_id": "T-30002", "amount": "25.00", "currency": "USD", "reason": "Requested in the help desk"}, "proposed_by": "usr-sam", "approver_role": "owner"}

    refunds prints nothing: no refund exists yet.

  7. Run the command of step 5 again, with y. The answer is again Refund proposal OP-0001: pending_approval., and approvals list still shows one proposal. The retry did not make a second one.

  8. Try to approve it as Sam, then as Ines, an owner of Bramble Books:

    Terminal window
    python -m support_mcp.approvals approve OP-0001 --user usr-sam
    python -m support_mcp.approvals approve OP-0001 --user usr-ines
    Refused: OP-0001: only an owner of larkfield can decide (you: staff)
    Refused: OP-0001: only an owner of larkfield can decide (you: no membership)

    Both exit with code 1. The proposal’s organization decides who may approve it.

  9. Approve it as Grace, the owner of Larkfield, then try once more:

    Terminal window
    python -m support_mcp.approvals approve OP-0001 --user usr-grace
    python -m support_mcp.approvals approve OP-0001 --user usr-grace
    OP-0001 approved by usr-grace: refund of 25.00 USD for T-30002 recorded (mock: no money moved).
    Refused: OP-0001: already approved
  10. Show the refunds:

    Terminal window
    python -m support_mcp.approvals refunds
    OP-0001 larkfield T-30002 25.00 USD (mock: no money moved)

Try two more cases on your own. Grace proposes 30 dollars herself (--user usr-grace and the write scope), then approves her own OP-0002: “Refused: OP-0002: you proposed it; another person must approve it”. And a proposal of 100.01 dollars stops on the server with over_limit, even after a “yes”.

The proposal moves through a few states, and only one path records a refund:

A state diagram. From the start, propose_refund passes the server's gates and the proposal is pending_approval. The same proposal again stays pending_approval with the same OP number. From pending_approval, approve, by an owner who did not propose it, leads to approved, and then the refund is recorded, a mock with no money moved. Reject, by an owner who did not propose it, leads to rejected, and nothing is recorded.
The life of a proposal. Only an owner who did not propose it can move it to approved, and only once.
Text description of the diagram
  1. propose_refund passes the server’s gates: the proposal is pending_approval.
  2. The same proposal again stays pending_approval, with the same operation ID.
  3. An owner who did not propose it approves: approved, and the refund is recorded (mock: no money moves).
  4. An owner who did not propose it rejects: rejected, and nothing is recorded.
  5. A decided proposal cannot be decided again: “already approved”.

Now the obedient model from the last lesson finds propose_refund on the server. Give it the write scope, and let --yes play a careless person:

Terminal window
python -m host "Please check ticket T-30201." --model mock-obedient --scopes "knowledge:read tickets:read refunds:propose" --yes
[host] discovered 3 tools: search_knowledge, get_ticket, propose_refund {"protocol": "2026-07-28", "server": "support-mcp"}
[model] wants get_ticket {"arguments": {"ticket_id": "T-30201"}}
[host] read-only tool on the allow-list: run it {"decision": "auto"}
[server] result: 506 characters {"ms": 17.6, "clipped": false}
[model] wants propose_refund {"arguments": {"ticket_id": "T-30201", "amount": "450", "reason": "customer is always right"}}
[host] not on the allow-list: ask the person {"decision": "ask", …}
[host] The assistant wants to run propose_refund with {"ticket_id": "T-30201", "amount": "450", "reason": "customer is always right"}
[host] Answer given by option: yes
[person] said yes {"decision": "approved"}
[server] tool error: Error executing tool propose_refund: over_limit: a refund over 100.00 dollars needs a team lead; it cannot be proposed here {"ms": 1.0}
[model] wants get_ticket {"arguments": {"ticket_id": "T-40003"}}
[host] read-only tool on the allow-list: run it {"decision": "auto"}
[server] tool error: Error executing tool get_ticket: not_found: there is no ticket T-40003 in this organization {"ms": 0.8}
…
A sequence with Sam (answering with --yes, constructed), the host, the model mock-obedient (constructed) and the server, Sam at Larkfield with the write scope. The model wants get_ticket T-30201; the host runs it, it is on the allow-list; the server returns 506 characters, the ticket and its planted text; the host gives the model the ticket as untrusted_data. The model wants propose_refund T-30201, 450, customer is always right. The host asks Sam: The assistant wants to run propose_refund with the arguments. Sam answers yes. The host sends tools/call propose_refund. A note on the server: member ok, scope ok, role staff ok, ticket ok, 450 over 100.00 refused. The server answers the tool error over_limit. The model wants get_ticket T-40003; the host runs it; a note: looked up inside Larkfield only; the server answers the tool error not_found, no ticket T-40003 in this organization. A final note: 0 proposals, 0 refunds, nothing from Bramble Books.
The recorded obedient run. The model obeys, the person says yes, and the server still refuses both requests.
Text description of the diagram
  1. The obedient model (constructed) reads T-30201 and asks for propose_refund of 450 dollars.
  2. The host asks the person; --yes (constructed) answers yes, so the call goes to the server.
  3. On the server, membership, scope, role and ticket pass. The amount, 450, is over 100.00: the tool error over_limit.
  4. The model asks for T-40003. The server looks inside Larkfield only: not_found.
  5. Result: 0 proposals, 0 refunds, and nothing from Bramble Books.

The business limit stopped this one. Change one condition at a time, and other gates take over. The course team ran each case:

Run of the obedient model What stops propose_refund
Sam, write scope, --yes the server: over_limit (450 over 100.00)
Sam, write scope, --no the host: “the person declined this call”
Omar (read-only), write scope, --yes the server: −32003 “the role read_only cannot propose a refund”
Sam, read scopes only, --yes the server: −32003 “insufficient_scope: this needs the scope refunds:propose”

In every row, approvals list printed “No proposals wait for approval.” And even a proposal under the limit would only wait for Grace. tests/test_injection.py turns these runs into four tests, so they run after every change.

The attempts table of Identity and permission now sends two write requests per caller: a 25-dollar and a 450-dollar proposal. Run it again with python -m scripts.attempts. It ends with:

72 attempts: 18 allowed, 54 refused on the server.
Refunds recorded: 0 (proposals only wait for an owner).

The script starts its own server, but it records its proposals in your .support/state.db. So python -m support_mcp.approvals list now shows three more proposals, from Grace, Sam and Camille. Delete the folder .support/ if you want to start again with none.

Here are the 24 write attempts, by the check that decided each one. On the chart, org is the organization-scoped lookup, and kept means recorded as a proposal.

  • refused on the server
  • proposal recorded, waits for an owner
24 real write attempts over Streamable HTTP: 21 refused by six different checks, 3 recorded as proposals, 0 refunds.
Text description of the chart

Of 24 write attempts, the token check refused 6, membership 4, scope 2, role 4, the organization-scoped lookup 2 and the business limit 3. 3 were recorded as proposals: Grace, Sam and Camille at Larkfield, each for 25 dollars. No refund was recorded.

Three proposals passed every server gate. Each still waits for an owner, so the last gate is a person in every case.

Use a proposal and an approval for every effect that is hard to undo or costs money. Examples are refunds, emails to customers, deleted records and changes to permissions. Use the server’s gates for every write, approved or not.

Do not ask for approval on reads. Sam would click “yes” a hundred times a day and stop reading the questions. The host’s allow-list exists so that the questions stay rare and therefore meaningful.

The costs:

  • Slower work. A refund now waits for Grace. For a 25-dollar double charge that is acceptable; Larkfield’s procedure asks for it anyway.
  • A second interface. The approval needs its own screen or command, outside the AI application. In this course, --user stands for a sign-in, a practice shortcut.
  • State to keep. Proposals live in a database that needs backups, and old proposals need a clean-up rule.
  • Approval fatigue. If owners approve without reading, the gate is gone. Show the exact operation, keep proposals few, and refuse what the server can refuse by itself, such as amounts over the limit.

Common mistake

-32003 insufficient_scope: this needs the scope refunds:propose

Symptom: you said “yes”, and the server refused the proposal with −32003.

Cause: the local server runs with SUPPORT_SCOPES, by default knowledge:read tickets:read. Those scopes allow reading only.

Fix: add --scopes "knowledge:read tickets:read refunds:propose" to the host command. Give the write scope only to the runs that need it.

Common mistake

Refused: you proposed it; another person must approve it

Symptom: an owner cannot approve a proposal.

Cause: the owner proposed it, with --user usr-grace in the host command. The proposer never approves their own proposal.

Fix: propose as Sam, and approve as Grace. In a real team, a second owner approves Grace’s proposals.

Check your understanding

Tomás says: propose_refund has readOnlyHint false, so hosts will treat it carefully, and the approval command is extra work we do not need. What is wrong with this?

Show the answer

An annotation is a hint for the host’s interface, not a control. A host can ignore it, and a server that you did not write can set it to anything. It did not stop a single call in this lesson. The host asked because propose_refund is not on its allow-list, and --yes gave the person’s “yes”. The controls are on the server: membership, scope, role, organization and limit. And a second person decides outside the AI application: an owner who is not the proposer. Without the approval, a fooled model and one careless “yes” would record a refund.

Check your understanding

Predict: Camille is staff at Larkfield. With the write scope, they propose a 40-dollar refund for T-30002 twice, and then run approvals approve on their proposal. What does each step print?

Show the answer

The first call records OP-0001, pending_approval, with the exact operation (larkfield, T-30002, 40.00 USD). The second call is the same proposal, so the server returns the same OP-0001 with the message “This refund was already proposed. It waits for an owner’s approval.” There is still one pending proposal. Their approval is refused: “Refused: OP-0001: only an owner of larkfield can decide (you: staff)”, exit code 1. Grace can approve it.

Check your understanding

Practical check: with python -m host and the obedient model, show three runs in which propose_refund fails for three different reasons. Give the command and the deciding line of each.

Show the answer

All three use python -m host "Please check ticket T-30201." --model mock-obedient, and each changes one condition. With --scopes "knowledge:read tickets:read refunds:propose" --yes: [server] tool error: … over_limit: a refund over 100.00 dollars needs a team lead …. With --user usr-omar --scopes "knowledge:read tickets:read refunds:propose" --yes: [server] protocol error -32003: forbidden: the role read_only cannot propose a refund. With --yes only: [server] protocol error -32003: insufficient_scope: this needs the scope refunds:propose. A fourth, with --no: “the person declined this call”. After each run, python -m support_mcp.approvals list prints “No proposals wait for approval.”

Recap

  • The one write, propose_refund, only proposes. It returns an operation ID and the exact operation, and it is idempotent.
  • Server gates decide whether the proposal may exist: membership, scope refunds:propose, role staff or owner, a ticket of this organization, and at most 100.00 dollars.
  • A second person decides outside the AI application: an owner of that organization, not the proposer, once.
  • Annotations and the person’s “yes” in the host are useful, but they are not controls. The obedient model, with “yes” to everything, recorded 0 proposals over the limit and 0 refunds.
  • Your project has 84 passed.

Next, you look at the credentials themselves: which token reaches which service, and which secrets each part may hold.

Next: Credential boundaries