Skip to content

tutorial

Chapter 4 of 5

Chapter 4 — What survives anyway

by Rod Rivera Published

The confirmation constraint still fires against a remote tool, because a constraint is a property of the skill rather than of the transport.

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.