Skip to main content
Glama
jogi6643

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 |