ShopKart MCP Server
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ShopKart MCP ServerWhat's the status of order 4471?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
ShopKart MCP Server
An MCP server exposing three customer-support tools, and an agent that consumes them as an MCP client. Project 4 of a self-directed GenAI engineering track.
Everything runs locally: Ollama serves qwen2.5:7b, the server and the agent are
two separate Python processes talking JSON-RPC over stdin/stdout.
Why MCP
In project 3 the tool registry lived inside agent.py. That worked for one app.
The moment a second app needed get_order_status, the registry would have to be
copied — and every copy would drift from the function it described.
MCP makes the registry a separate process that any client can discover at
runtime. Tools are described once, in one place, and the client asks for them
with tools/list instead of being told about them in advance.
The comparison that made it click:
Project 3 | Project 4 | |
Tool registry |
|
|
Schemas | Hand-generated from Pydantic models |
|
Adding a tool | Edit the agent | Edit the server, restart it |
Who can use the tools | This one agent | Any MCP client |
Related MCP server: MCP Customer Support Demo
Architecture
┌───────────────────────────────────────────────┐
│ main.py │
│ ↓ │
│ agent_mcp.py ← MCP CLIENT │
│ · input guardrail │
│ · GATE 4 grounding (needs conversation) │
│ · GATE 6 permission (needs a human) │
│ · loop, MAX_STEPS = 4 │
│ · output check on the final reply │
│ ↓ │
│ mcp_tools.py ← bridge │
│ · openai_schemas() │
│ · call(name, args) │
└───────────────────┬───────────────────────────┘
│ JSON-RPC over stdio
│ (a separate OS process)
┌───────────────────▼───────────────────────────┐
│ server.py ← MCP SERVER │
│ · GATE 1 whitelist (built in) │
│ · GATE 2 JSON parse (built in) │
│ · GATE 3 validation Annotated + Field │
│ · GATE 5 precondition business rules │
│ · GATE 7 execute (built in) │
│ ↓ │
│ ORDERS · POLICIES · TICKETS (in-memory) │
└───────────────────────────────────────────────┘llm.py is the transport layer carried over from projects 1–3: it takes
messages plus tools and returns the reply, tool_calls and token usage.
The seven gates, and where each one lives now
Project 3 put all seven validation gates inside execute_tool(). Splitting the
system across a protocol boundary moved five of them and left two behind — and
which two is the whole architectural point.
Gate | Project 3 | Now | Why |
1 · whitelist | agent | server, free | The registry is the whitelist. Unknown name → |
2 · JSON parse | agent | server, free | The JSON-RPC layer does it |
3 · validation | agent | server |
|
4 · grounding | agent | agent | Needs the conversation. The server only sees one call, never the history |
5 · precondition | agent | server | Business truth belongs with the data |
6 · permission | agent | agent | Needs a human. The server only declares risk via annotations |
7 · execute | agent | server, free | Exceptions become |
Gate 4 cannot move to the server. is_grounded() asks "did this order id
appear in the customer's message or in an earlier tool result?" The server has
seen neither. This is the same reason a REST API validates its inputs but cannot
check "the user saw this value on the previous screen" — that is session state,
and it belongs to whoever owns the session.
Gate 6 is split. The server declares intent:
@mcp.tool(annotations=ToolAnnotations(
title="Create support ticket",
read_only_hint=False, # writes data
destructive_hint=False, # but deletes nothing
idempotent_hint=True, # safe to retry
open_world_hint=False, # touches only ShopKart records
))The client decides what to do about it:
def needs_approval(tool) -> bool:
ann = tool.annotations
if ann is None:
return True # no hint → ask (fail closed)
return not bool(ann.read_only_hint)Annotations are a hint, not a lock. A hostile client can ignore them entirely. Anything that must not happen is stopped by gate 5, in the server, where no client can reach around it.
One request, end to end
Real output from python main.py:
>>> what is the status of order 4471?
[tool] get_order_status is_error=False
REPLY: Your order 4471 for Wireless Earbuds has been shipped and is expected
to arrive by September 14, 2026.
meta: {'llm_calls': 2, 'steps': 2, 'tools_used': ['get_order_status']}What happened between those two lines:
looks_malicious()scanned the message — clean, so nothing was spent yet4471was extracted intoknown_idsstraight from the customer's textThe agent sent the conversation plus three tool schemas to
qwen2.5:7bThe model returned
get_order_status({"order_id": "4471"})— it did not run anything; it emitted a requestGate 4:
4471was inknown_ids→ allowedGate 6:
read_only_hint=True→ no approval neededThe call crossed the process boundary; the server ran gates 1, 2, 3, 5, 7
get_order_statusreturned onlyPUBLIC_ORDER_FIELDS—customer_emailnever left the serverThe result was appended as a tool message and its ids joined
known_idsThe model wrote the final reply; the output check confirmed every 3+ digit number in it had actually been observed
Two LLM calls, two steps. The model is two of about eleven stages.
Files
File | Role |
| The MCP server. Three tools, their validated argument types, their annotations, and the business rules |
| Bridge. Connects to a server, converts its schemas to the shape |
| The agent as an MCP client. Guardrails, gates 4 and 6, the step loop |
| Runner. Wires the real |
| Transport, carried over from projects 1–3 |
| Exercises the server directly, without the agent |
| Exercises the bridge, without the LLM |
Tools
Tool | Risk | Validated arguments | Notes |
| read |
| Returns only |
| read |
| Keyword match over five policy sections. Not semantic — see gaps |
| write |
| Precondition on order existence, idempotent on |
Argument types are declared once and reused:
OrderId = Annotated[str, Field(
description="The order id, digits only, for example 4471",
min_length=1, max_length=12, pattern=r"^\d+$",
)]That single definition produces both the runtime validation and the JSON Schema the model sees, so the two can never disagree:
"order_id": {
"description": "The order id, digits only, for example 4471",
"maxLength": 12, "minLength": 1,
"pattern": "^\\d+$", "type": "string"
}Run it
cd mcp-server
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
ollama serve # in another tab, if not already running
ollama pull qwen2.5:7b
python main.py # the agent, with the real modelTwo checks that need no model at all:
python client_test.py # server only — tools, schemas, every gate
python test_bridge.py # bridge only — schema conversion and calls.env carries the configuration:
LLM_PROVIDER=ollama
OLLAMA_BASE_URL=http://localhost:11434/v1
OLLAMA_MODEL=qwen2.5:7bTesting an agent without the model
run() takes the chat function as a parameter rather than importing it:
async def run(question: str, chat_fn, approver=approve_none, ...)That one choice makes every deterministic layer testable with a scripted fake
model — no Ollama, no tokens, no waiting, no flakiness. The same trick as
injecting approver instead of hardcoding the prompt.
Six behaviours verified this way:
Case | Result |
Grounded read | tool ran, reply used the real data |
Customer typed | gate 4 blocked it |
Write tool, approver says no | gate 6 blocked it, nothing was created |
Write tool, approver says yes | ticket created |
| blocked before any model call, |
Reply mentions a number nobody returned | reply replaced with an escalation |
The model-dependent behaviour — does it pick the right tool, does it phrase the answer well — belongs in a separate evaluation, not in these tests.
What I learned the hard way
print() in a stdio server breaks the protocol.
stdout is the JSON-RPC channel. One stray print corrupts the stream and the
client's parse fails. Logs go to stderr. This is the same reason you do not
write to a closed socket — and I met it from the other side too, when
pip show mcp | head -2 printed ERROR: Pipe to stdout was broken.
A docstring is a production string.
My first search_policy docstring said it searched insurance policies and
returned policy IDs. Neither was true. The model reads only that line when
deciding which tool to call, so a customer asking "how many days do I have to
return something?" would have been answered from the model's own memory — the
exact hallucination RAG was built to prevent. Fixing the sentence fixed the
behaviour.
An error code without a message makes the model guess.
When a human declined the ticket, the agent sent back {"error": "not_approved"}
and nothing else. The model told the customer "Could you provide me with a
different order ID?" — the order id had been correct all along. Adding one
sentence explaining what happened and what to say next produced the right reply:
"A human reviewer declined to open a ticket. A specialist will follow up."
Every error that reaches a model needs to say what happened and what to do
now — the same rule as a 403 with an empty body.
Renaming a field means finding every reader.
The bridge originally returned ok, and so did the server's business results —
producing {'ok': True, 'data': {'ok': False}}. Two identical names with
opposite meanings, one nested inside the other. Renaming the protocol layer to
is_error (mirroring MCP's own isError) fixed the readability, and broke every
caller until each was updated. When you write a layer between two systems, do not
borrow the vocabulary of the one below you.
The SDK renamed its main class.
mcp 2.x replaced FastMCP with MCPServer
(from mcp.server.mcpserver import MCPServer). Most tutorials online still show
the old name. The error message points at the migration guide, which is how I
found it.
Known gaps
Listed because they are real, not because they are planned.
search_policyis keyword matching, not retrieval. Asked "do you ship to Nepal?" it matches onship, returns the Shipping passage with high confidence, and Nepal appears nowhere in that text. The same failure as project 2's 0.633 similarity score: a match means the passage is about the topic, not that it contains the answer. Replacing this with the project-2 Chroma index is the obvious next step.No authentication or authorization. Any caller can query any order id. Grounding restricts which ids the model may use, not which ids the customer is entitled to see. This is the largest gap.
Everything is in memory.
TICKETSand the idempotency index are lost when the server restarts. In production the idempotency key would be a unique constraint in the database — an application-level check cannot stop two concurrent requests from both seeing "not found".No retry or timeout on the model call.
The LLM call blocks the event loop via
asyncio.to_thread. Fine for one user at a terminal; a server would need the async client.Model behaviour is not evaluated. The deterministic layers are tested; whether
qwen2.5:7bpicks the right tool across a range of phrasings is not measured.
Versions
Python | 3.14 |
| 2.2.0 |
Model |
|
Transport | stdio |
This server cannot be deployed
Maintenance
Related MCP Connectors
Read-only MCP server for turva.dev's published service catalog, pricing and contact details. Five tools return JSON, including dated agent-readiness and security evidence with verification links. Connect over Streamable HTTP without an API key. The server answers questions about turva.dev and does not scan other websites or run audits.
Search, inspect and invoke every public tool on Invokera through one MCP connection.
Pay-per-use tool marketplace for AI agents. Search, price-check, and call APIs via MCP.
Remote MCP server for managing Muninx tickets, messages, ticket search, and support analytics.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceAn MCP-compatible server providing tools for retrieving order statuses, searching knowledge bases, and managing support tickets. It facilitates automated customer service interactions by exposing internal CRM and database functions through a JSON-RPC interface.-
- FlicenseNot gradedqualityCmaintenanceEnables customer support operations such as order lookup, store credit, refunds, and audit log review through an agent using safe, typed MCP tools.-
- AlicenseNot gradedqualityBmaintenanceExposes order status lookup and knowledge base search tools from the Support Agent AI over MCP, enabling MCP clients to handle customer support queries with grounded, citation-backed answers.MIT
- AlicenseNot gradedqualityCmaintenanceEnables an AI to perform customer support workflows by looking up customers, retrieving orders, and creating support tickets through MCP tools.4 npmX11 no permit persons clause