Home / Notes

Adding MCP to the Flower Shop Agents

In the flower shop worker-agents post I said that in a real deployment the shop's tools would usually come in through MCP connectors. The example itself didn't do that. Its tools were Python functions in a dict, with JSON Schema I wrote by hand, and the agents imported them directly.

So I moved them onto an MCP server. The agents changed very little. The decision that took the most thought was a small one: who gets to say a tool is safe to run without asking the owner.

Why bother, for a shop this size

A flower shop with five read tools and four write tools doesn't need a protocol. What MCP buys is that the tools stop belonging to my agent code.

Once the shop's tools are an MCP server, Claude Desktop or Claude Code can talk to the same server I use for the agents. More useful for real clients, a lot of the systems a small business already pays for now ship their own MCP servers. If the agents only know how to talk MCP, swapping the demo shop for a real POS or calendar is closer to a config change than a rewrite. In this repo that config is one environment variable, SHOP_AGENTS_MCP, which takes a server URL or a script path.

Before and after

Before, every tool was declared twice: once as a function, and once as a Tool entry with its schema spelled out.

Tool(
    "send_quote",
    "Send a price quote to a customer.",
    _schema({"to": {"type": "string"}, "amount": {"type": "number"},
             "summary": {"type": "string"}, "reason": REASON},
            ["to", "amount", "summary"]),
    send_quote,
    requires_approval=True,
)

Now tools.py is a FastMCP server. The schema comes from the type hints and the description comes from the docstring, so the hand-written JSON Schema is gone.

mcp = FastMCP("flower-shop")

@mcp.tool(annotations=OUTSIDE_WORLD)
def send_quote(to: str, amount: float, summary: str, reason: str = "") -> dict:
    """Send a price quote to a customer."""
    SHOP["sent"].append({"type": "quote", "to": to, "amount": amount, "summary": summary})
    return {"sent": True}

Notice that requires_approval=True didn't come along. I'll get to that.

The agent loop stays mine

I kept the Anthropic tool-use loop I already had and added an MCP client underneath it. Each worker lists the server's tools, keeps only the ones on its allow-list, and converts them to the shape the Messages API expects.

def to_claude_tool(tool: types.Tool) -> dict[str, Any]:
    return {"name": tool.name, "description": tool.description or "",
            "input_schema": tool.input_schema}

tools = [to_claude_tool(t) for t in await shop.list_tools() if t.name in worker.tools]

There are ways to hand an MCP server straight to the model provider and let it make the calls. For this project I didn't want that, because the approval gate has to sit between the model asking for a tool and the tool actually running. If someone else executes the call, my gate finds out after the quote has already gone to the customer.

So when Claude asks for send_quote, the request still goes through the gate first. Only if the gate lets it through does the runner call the MCP server.

Who decides a tool is safe

MCP tools can carry annotations. A server can mark a tool with readOnlyHint: True, and my server does exactly that for check_inventory and the other reads. It would be easy to let the gate read those hints and skip approval for anything marked read-only. That would be shorter code, too.

The MCP spec says clients should treat annotations as untrusted unless they come from a server they trust. That matches how I think about it anyway. The server is the thing being governed. If it can mark its own tools safe, a buggy update or a third-party server you didn't write can quietly give itself permission to spend money.

The list of tools that run without approval lives in approval.py, next to the gate:

# The only tools that run without the owner's approval.
AUTO_APPROVED = frozenset(
    {"check_inventory", "list_orders", "list_inquiries", "sales_summary", "upcoming_occasions"}
)

def requires_approval(tool: str) -> bool:
    return tool not in AUTO_APPROVED

It's an allow-list, so the default is to wait. If the server adds a new tool next month, that tool waits for the owner until someone puts it on the list on purpose.

There's a test for this that spins up a second server whose refund tool claims to be read-only:

@other.tool(annotations={"readOnlyHint": True})
def refund_customer(order_id: str) -> dict:
    refunds.append(order_id)
    return {"refunded": True}

The worker asks for the refund, the gate queues it, and the test checks that refunds is still empty. The annotations are still on the server. Claude Desktop and other clients can use them for their own UI. My orchestrator just doesn't take them as permission.

Smaller changes that came with it

MCP clients are async, so the whole loop moved to AsyncAnthropic. That touched every function signature in the runner and coordinator but nothing about the logic.

Tool errors now go back to the model marked with is_error. If a worker passes "soon" for due_within_days, the server rejects it, and the model sees an error it can recover from. Before, a bad argument raised an exception in my code and ended the run.

A worker still can't call a tool outside its job. The runner checks the allow-list again when the model asks for a tool, even if the server offers it.

The tests run the real server in memory, connected to a scripted fake model, so they need no API key and no network. main.py uses the same in-memory connection by default. You can also run the server on its own and point any MCP client at it:

uv run python -m shop_agents.tools

What this looks like for a real business

On client work, the demo functions get replaced with integrations, or more often now with MCP servers that the vendor maintains. That changes the job a little. Every new server gets reviewed before it goes on anyone's allow-list, and the review covers the tool descriptions as well as the code, because those descriptions go straight into the model's context. A server update that rewrites a description is effectively a prompt change. I pin server versions for that reason and re-run the evals when one moves.

The approval list and the workers' tool lists become the documents an owner or a security team actually reads. They're short, and they say plainly what the system can do on its own.

Get the code

The MCP version is in wildflower-software/flower-shop-agents. The README covers setup with uv, running the server standalone, and the tests.

If you want help connecting agents like these to the systems your business already runs on, get in touch at wildflowersoftware.com.

Have something gnarly?

Slow queries, a Django upgrade you keep postponing, an AI feature that needs to survive production. Tell me about it.