Giving AI Agents Real Tools: Building MCP Servers for Commerce Data
Everyone's agents can chat. Almost nobody's agents can act. How I built MCP servers that let Claude query orders, inventory, and customer history on a Medusa store, and the tool-design lessons that took three iterations to learn.
A client I'd built a support chatbot for came back with a new request. Their team had started using Claude internally for everything from drafting emails to analysing spreadsheets, and they wanted it to answer questions about their store. Real questions, against live data. "Why is order #4521 delayed?" "How many units of the blue variant do we have left?" "What did this customer buy last quarter?"
Their store runs on Medusa v2. The data was all there, sitting behind an admin API. The gap was a safe, structured way for an AI to reach it. That's exactly the problem MCP exists to solve, so I built them an MCP server. Three iterations later, it worked well enough that their ops team stopped opening the admin dashboard for most lookups.
What MCP Actually Is
The Model Context Protocol is an open standard for connecting AI assistants to external systems. If you've been around long enough to remember when every integration was a bespoke plugin, MCP is the correction: one protocol, any client, any server. You write a server that exposes tools (functions the model can call), and any MCP-capable client, like Claude or an agent framework, can discover and use them.
The mental model that helped me: an MCP server is an API designed for a language model instead of a frontend developer. Same data, very different design pressures. A frontend dev reads your docs once and writes code that calls the endpoint correctly forever. A model rediscovers your API on every single conversation, from nothing but the tool names and descriptions.
That difference drove almost every design decision that follows.
The Server
The core is small. With the TypeScript SDK, a tool is a name, a description, a schema, and a handler:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { z } from "zod"
const server = new McpServer({ name: "store-ops", version: "1.0.0" })
server.tool(
"get_order",
"Look up a single order by its display ID (e.g. 4521). " +
"Returns status, items, fulfillment state, and shipping events.",
{ orderId: z.string().describe("The order display ID, digits only") },
async ({ orderId }) => {
const order = await medusa.admin.order.retrieve(orderId)
return { content: [{ type: "text", text: formatOrder(order) }] }
}
)I ended up with eight tools: order lookup, order search by customer email, inventory check by SKU or title, customer purchase history, shipment tracking, refund status, low-stock report, and a daily sales summary. Read-only, every one of them. More on that below.
Iteration One: Too Few Tools, Too Generic
My first version had three tools: query_orders, query_products, query_customers, each accepting a flexible filter object. Clean from an engineering point of view. One tool per entity, compose filters as needed.
The model hated it. Given a vague filter schema, Claude would guess at filter shapes, get empty results, and conclude the data didn't exist. The failure wasn't loud. It was a quiet stream of "I couldn't find that order" answers for orders that absolutely existed.
The fix was counterintuitive if you think like an API designer: more tools, each narrower. get_order takes one ID. find_orders_by_customer takes one email. check_inventory takes a SKU or a product name. No clever filter objects anywhere. When a tool can only be called one way, the model calls it the right way.
Iteration Two: Descriptions Are the Documentation
The second round of failures came from descriptions. I'd written them like OpenAPI summaries: "Retrieves order data." Technically accurate, useless in practice.
Tool descriptions are the only documentation the model gets, and they're read in the middle of a conversation with someone impatient on the other end. The descriptions that worked were the ones that answered three questions: when should you reach for this, what exactly goes in, what comes back. The get_order description above includes an example ID format because Claude kept passing #4521 with the hash, and the Medusa lookup wanted digits.
I also learnt to put disambiguation hints directly in the descriptions. Two tools sounded similar (get_order and track_shipment), and the model would pick the wrong one for "where is order 4521?". One sentence in each description ("use track_shipment when the user asks about delivery or location") fixed the routing completely.
Iteration Three: Auth and Rate Limits
The first two iterations ran on my machine over stdio. Production meant the remote transport, and remote means auth.
The server sits behind streamable HTTP with a bearer token per connecting workspace. Tokens map to scopes, and scopes map to tools: the ops team's token exposes all eight tools, while the wider company token only gets inventory and shipping, nothing with customer PII in it. The MCP SDK makes per-session tool registration straightforward, so the same server presents a different tool list depending on who connected.
Rate limiting turned out to matter more than I expected, for a reason specific to agents. A human hammers an API when they're angry. An agent hammers an API when it's confused. One badly phrased question once sent Claude into a loop of eleven find_orders_by_customer calls with slightly different emails, hunting for a customer that didn't exist. A token-bucket limiter (30 calls per minute per session) plus a tool response that says "no results, and similar emails won't help, ask the user to confirm the address" stopped both the load and the loop.
Why Read-Only
The client asked early on whether the agent could also issue refunds. The honest answer was: it could, and we shouldn't let it. Not yet.
A wrong read costs nothing. The model misreads inventory, someone double-checks, life goes on. A wrong write is a refund issued to the wrong customer. The asymmetry is the whole argument. I'd built confirmation-gated writes for a LangGraph agent before, where every destructive action gets staged and a human approves it, and that pattern works. But it deserves its own design pass, its own audit log, and its own failure budget. Bolting writes onto a v1 read server because the demo went well is how incidents happen.
We shipped read-only. Six weeks in, nobody has asked for refunds through the agent again, because it turns out the painful part of their day was always the lookups.
What I'd Tell Someone Building One
Design tools around questions, not entities. The ops team doesn't think in terms of "the orders table." They think "why is this order late?" The closer a tool maps to an actual question, the better the model uses it.
Log every tool call with its arguments from day one. The logs are how you find the iteration-one and iteration-two problems. Mine showed the #4521 hash bug within an hour of launch.
And write the descriptions like you're onboarding a sharp intern who skims. Because functionally, that's exactly what you're doing, on every request, forever.
Related posts
Building a Multi-Step LangGraph Agent That Handles Orders Autonomously
- Published on
Building a Multi-Vendor Marketplace That Grew AOV by 18%
- Published on
Giving a Portfolio Assistant Real Tools (and Five Models So It Never Goes Down)
- Published on
Building a RAG Chatbot That Cut Support Tickets by 40%
- Published on