Enterprise AI Bootcamp Demo 4

4. A denied approval

planner: model (claude-sonnet-5)manual search: local stubtickets and parts: synthetic training dataMCP 2026-07-28
The interesting half of an approval gate is the denial. An agent that treats 'no' as a temporary obstacle and finds another route has not been governed, it has been inconvenienced.

This is a protocol event, not a user-interface convention

In MCP 2026-07-28 the server does not call the client. Server-initiated requests were replaced by Multi Round-Trip Requests (SEP-2322): the server returns a result whose resultType is "input_required", carrying an inputRequests map and an opaque requestState. The client then re-issues the original request, with a new JSON-RPC id, carrying inputResponses. Both round trips are in the traces below with their two different mcp.request.id values.

The same mid-flight-input pattern is carried by the official Tasks extension (io.modelcontextprotocol/tasks), which moved out of core in this revision: there the pause is against a durable handle that survives a dropped connection, polled with tasks/get and answered with tasks/update. This demonstration implements the in-core MRTR mechanism and not the Tasks extension; the compliance list on the contracts screen says so.

Two host-side rules make the gate mean something. The approval is bound to a fingerprint of the exact arguments, so changing the part number or the quantity after approval invalidates it — possession of a requestState is not authorisation, it is a name. And a denial is returned as a tool execution error, so the planner sees it, cannot mistake it for a transient failure, and has an explicit instruction not to work around it.

Denied

nothing written 0 refusal(s)
step 1: finish0.3 ms
planner failed: TypeError: atlas_shared.llm.LLM.complete() got multiple values for keyword argument 'span_name'
answer0.0 ms

Granted, for comparison

nothing written 0 refusal(s)
step 1: finish0.2 ms
planner failed: TypeError: atlas_shared.llm.LLM.complete() got multiple values for keyword argument 'span_name'
answer0.0 ms
What the agent did after the refusal. It stopped and escalated. It did not substitute a cheaper part, split the order, or re-ask with a different justification. That behaviour is not the planner being well-behaved: the refusal path in pipeline/agent.py injects an explicit host instruction and the authority matrix still refuses the second attempt, which is the only version of this that survives a planner that wants to be helpful.
Both runs executed when you loaded this page. Nothing on this screen is hardcoded; the source is in Demo4/pipeline/.