Skip to main content
Glama
jogi6643

ShopKart MCP Server

by jogi6643

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


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 → 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:

@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:

  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_FIELDScustomer_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:

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 model

Two 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:7b

Testing 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 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

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    An 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.
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    Exposes 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