Skip to content

tutorial

Chapter 3 of 5

Chapter 3 — What MCP takes away

by Rod Rivera Published

Three limits the engine enforces rather than suggests: no tool context, no stdio, and a missing tool that fails after training rather than during it.

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.