Six changed lines is a small enough diff to conclude that MCP is free. It is not. Three things stop working, and all three are enforced by the engine rather than left to your judgement — which is better, because you find out immediately.
A chapter that ended at “look how easy that was” would be the more comfortable one to write and the less useful one to read.
1. An MCP tool has no memory
This is the big one.
A local tool receives a ToolContext and can read and write memory:
@tool(description="List the support tickets on the identified customer's account.")
async def list_open_tickets(context: ToolContext = None) -> ToolResult:
contact_id = context.memory.get("project.contact_id") if context else None
if not contact_id:
return ToolResult(llm_response={"ok": False, "error": "not_identified"})
Read that carefully: the tool is the authority on whether the caller has been identified. It does not trust the model to know, it looks the value up itself.
An MCP tool cannot do that. The engine says so in its own docstring, in
skill_executor.py:
Local tools receive a ToolContext; MCP tools dispatch through the
processor-owned MCPRuntime.
There is no context object on the remote side, because there is no remote side of your process. So the value has to travel as an argument:
@mcp.tool(description="List the support tickets on the identified customer's account.")
async def list_open_tickets(contact_id: str) -> CrmResult:
if not contact_id:
return CrmResult(ok=False, error="not_identified")
Same name, same result keys, same error string — which is why the instructions
still work. But contact_id is now something the model supplies, from what
it has seen in the conversation, rather than something the tool reads from
memory.
Why that is a security property and not a detail
In the REST version, a caller cannot cause list_open_tickets to read someone
else’s tickets, because the tool ignores anything the caller says and uses the
id that find_contact_by_email wrote to memory. In the MCP version, the id is a
model-supplied argument, and the model is influenced by the conversation.
The rule that falls out of this:
If a tool’s correctness depends on a value the user must not be able to influence, that value cannot be an MCP argument. Keep that tool local.
Ora’s ticket lookup is a read of the caller’s own record, and the mock data is fixture data, so the tutorial can afford the looser arrangement. A tool that moves money could not. The transport swap is not free for every tool in the project — it is free for the tools whose inputs were already safe to state out loud.
2. There is no stdio transport
Most desktop MCP clients speak stdio: the server is a subprocess, and messages go over its standard input and output. Mantle does not do that.
MCPServerSpec requires a url: whose scheme is http or https, and rejects
everything else at config-parse time. Underneath, MCPServerConnection only
ever builds a streamablehttp_client — there is no stdio branch to reach.
So an MCP server you already run locally over stdio cannot be plugged into this
engine as-is. You would wrap it in an HTTP server first. That is what the
bundled scripts/mcp_crm_server.py is: FastMCP with
transport="streamable-http", bound to loopback.
Loopback is what keeps the chapter credential-free — nothing leaves the machine — but it is a workaround for a missing transport, not a design preference, and it is worth knowing which is which before you plan an integration around it.
3. A missing remote tool fails late
parse_mcp_imports runs at model load and checks quite a lot: that every
reference is syntactically mcp/<server>:<tool>, that no skill imports two
tools with the same name, that an imported name does not collide with a local
tool or a reserved framework name.
What it explicitly does not check is whether the tool exists. Its own docstring:
Remote existence is not checked here: that requires list_tools at
connection time.
So rasa train succeeds with a typo in an import line. The failure arrives
later, from MCPRuntime.prepare, once there is a live session to ask:
MCP server 'hubspot_crm' does not expose imported tool 'list_tickets'
for skill 'check_tickets'.
The message is good. The timing is not: you trained a model to find out.
This is the specific gap make mcp-prove closes. Its check 5 opens a real MCP
session and calls list_tools — the same call MCPRuntime.prepare makes —
before you spend a training run:
✗ check_tickets imports list_open_tickets reference not found in skill.md
The shape of a limits section
Three limits, and the useful form for each was the same: name the symbol that
enforces it, say what it prevents, and say what you do instead. “MCP has some
caveats” is not that. A reader who hits the tool-context limit at three in the
morning needs the name ToolContext and the file it is missing from.
