ShopKart MCP Server
by jogi6643
README.md
# 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 | `TOOLS` dict inside the agent | `server.py`, a separate process |
| Schemas | Hand-generated from Pydantic models | `session.list_tools()` at runtime |
| Adding a tool | Edit the agent | Edit the server, restart it |
| Who can use the tools | This one agent | Any MCP client |
---
## 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 → `Unknown tool: x` |
| 2 · JSON parse | agent | **server**, free | The JSON-RPC layer does it |
| 3 · validation | agent | **server** | `Annotated[str, Field(pattern=…)]` → enforced and published in the schema |
| 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 `isError`, tracebacks stay on server stderr |
**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:
```python
@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:
```python
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:
1. `looks_malicious()` scanned the message — clean, so nothing was spent yet
2. `4471` was extracted into `known_ids` straight from the customer's text
3. The agent sent the conversation plus three tool schemas to `qwen2.5:7b`
4. The model returned `get_order_status({"order_id": "4471"})` — it did **not**
run anything; it emitted a request
5. Gate 4: `4471` was in `known_ids` → allowed
6. Gate 6: `read_only_hint=True` → no approval needed
7. The call crossed the process boundary; the server ran gates 1, 2, 3, 5, 7
8. `get_order_status` returned only `PUBLIC_ORDER_FIELDS` — `customer_email`
never left the server
9. The result was appended as a tool message and its ids joined `known_ids`
10. The 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 |
|---|---|
| `server.py` | The MCP server. Three tools, their validated argument types, their annotations, and the business rules |
| `mcp_tools.py` | Bridge. Connects to a server, converts its schemas to the shape `llm.chat(tools=…)` wants, and calls tools |
| `agent_mcp.py` | The agent as an MCP client. Guardrails, gates 4 and 6, the step loop |
| `main.py` | Runner. Wires the real `llm.chat` into the agent |
| `llm.py` | Transport, carried over from projects 1–3 |
| `client_test.py` | Exercises the server directly, without the agent |
| `test_bridge.py` | Exercises the bridge, without the LLM |
---
## Tools
| Tool | Risk | Validated arguments | Notes |
|---|---|---|---|
| `get_order_status` | read | `order_id` — `^\d+$`, 1–12 chars | Returns only `PUBLIC_ORDER_FIELDS`; `customer_email` is withheld |
| `search_policy` | read | `query` — 3–200 chars | Keyword match over five policy sections. **Not semantic** — see gaps |
| `create_support_ticket` | write | `order_id` + `issue` (10–300 chars) | Precondition on order existence, idempotent on `(order_id, issue.lower())` |
Argument types are declared once and reused:
```python
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:
```json
"order_id": {
"description": "The order id, digits only, for example 4471",
"maxLength": 12, "minLength": 1,
"pattern": "^\\d+$", "type": "string"
}
```
---
## Run it
```bash
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 model
```
Two checks that need no model at all:
```bash
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:7b
```
---
## Testing an agent without the model
`run()` takes the chat function as a parameter rather than importing it:
```python
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 `44712`, model sent `4471` | **gate 4 blocked it** |
| Write tool, approver says no | **gate 6 blocked it**, nothing was created |
| Write tool, approver says yes | ticket created |
| `order 4471'; DROP TABLE orders--` | blocked before any model call, `llm_calls=0` |
| 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_policy` is keyword matching, not retrieval.** Asked "do you ship to
Nepal?" it matches on `ship`, 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.** `TICKETS` and 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:7b` picks the right tool across a range of phrasings is not
measured.
---
## Versions
| | |
|---|---|
| Python | 3.14 |
| `mcp` | 2.2.0 |
| Model | `qwen2.5:7b` via Ollama |
| Transport | stdio |
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues