Skip to content
RasaGet a free licence
Menu

tutorial

Chapter 4 of 5

Test customer confirmation before saving a remote CRM note

by Rod Rivera Published

Check that the customer confirms a CRM note before Mantle calls the remote tool that saves it.

Ora’s assistant records notes about customer-service interactions in the CRM. Before saving a note, it asks the customer to confirm what will be recorded. Moving the saving tool to a Model Context Protocol (MCP) server should preserve that confirmation step.

You will inspect the skill’s constraint and the native checks for the remote call. The exercise tests the pending call’s confirmation gate; it does not establish that every remote tool has an identical argument schema.

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.

In this example, the tool name targeted by the confirmation constraint remains available after the move. The native check above establishes that the constraint still applies to that remote call.

That result does not establish an identical interface for all CRM tools. As Chapter 3 shows, list_open_tickets gains an explicit contact_id because the remote tool cannot read Mantle’s local ToolContext. Compare argument schemas and result keys before reusing an instruction or constraint that depends on them.

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.