Chapter 3 listed what breaks. This one is about the thing that most obviously could have broken and did not, because the reason it survived is the reason the whole swap works.
The guarantee in question
log_interaction writes to someone else’s system of record. The REST tutorial
put two guarantees around that write, neither of them prose the model can talk
itself out of:
tool_constraints:
- add_timeline_note:
requires_confirmation:
enabled: true
utter_for_confirmation: utter_confirm_note
utter_on_user_denial: utter_note_cancelled
The runtime — not the model — asks before the write lands, and cancels if the caller says no.
The obvious worry about the swap is that this stops working. add_timeline_note
is no longer a Python function in the project; it is a name on a remote server.
A constraint mechanism that looks up local callables would find nothing and
either fail loudly or, far worse, quietly let the write through unconfirmed.
It still fires
It does not break, and the reason is visible in
tool_constraints_executor.py. When a confirmed tool is about to run, the
executor looks for a local function and asks the MCP runtime:
tool_func = tools_for_skill.get(pending_tool_confirmation.tool_name)
is_mcp = bool(
self._mcp_runtime is not None
and self._mcp_runtime.has_mcp_tool(
pending_tool_confirmation.skill_id,
pending_tool_confirmation.tool_name,
)
)
if tool_func is None and not is_mcp:
... # "is no longer registered"
An imported MCP tool satisfies is_mcp, so it is a legitimate target for
confirmation. The ordered block in the same skill resolves the same way: its
execute_tool: add_timeline_note step dispatches through the MCP runtime and is
otherwise unchanged.
So the caller still sees this before anything is written:
bot Save this to your record? "Dana called about the VAT rate on invoice 4471."
And saying no still cancels it.
Why that is the general rule, not a lucky detail
A constraint is declared on the skill, against a tool name. It is not declared on the tool’s implementation, and it does not care where the implementation lives. The runtime resolves the name at dispatch time and applies the constraint either way.
That is the same property that makes the instructions portable. Both the prose and the constraints are written against an interface — a tool’s name, its arguments, its result keys — and the transport swap preserves that interface exactly. Nothing that was written against the interface has to move.
The corollary is worth stating, because it tells you what to check in your own
project: anything written against the implementation rather than the
interface will not survive. Chapter 3’s ToolContext is precisely that. The
tool’s reading of project.contact_id was an implementation fact, not an
interface fact, and it is the one thing that had to be rebuilt.
One thing that did have to change, and it is not in the skills
Building this chapter turned up a genuine problem that the proof script caught and reading would not have.
The first version of the MCP server returned plain dictionaries:
async def find_contact_by_email(email: str) -> dict:
The tools listed correctly, the calls succeeded, and check 6 went red anyway:
✗ find_contact_by_email over MCP None at None
✗ absent contact is not an error error=None
A tool annotated -> dict publishes no output schema, so the result comes back
as unstructured text and structuredContent is None. Mantle notices:
MCPRuntime._invoke_mcp_tool uses structuredContent when it is there and
otherwise dumps the content blocks. The model would have received a JSON string
wrapped in a list of text parts, instead of the flat object the local tool
returned.
The skill instructions branch on ok and error. Those keys have to survive the
wire or the prose stops matching what the model sees — which would have
falsified the whole claim, silently, in a way that still looks like it works.
The fix is an explicit return model, so the tool publishes an output schema:
class CrmResult(BaseModel):
model_config = {"extra": "allow"}
ok: bool
And the proof gained an assertion that keeps it fixed:
✓ result is structured, not text blocks outputSchema published
Two things to take from that. First, “the instructions did not change” is a claim about what the model receives, not only about what the files say — and those can come apart. Second, this is what the template means by writing the assertions before you trust them: the script’s first run disagreed with the implementation, and the script was right.
