Project: MCP support desk
The project is a support desk built from the course: an MCP server and an agent over HTTP, with refunds a supervisor must approve.
Last updated: 29 Sep, 2026 · MCP 2.2
The server
from typing import Annotated, Literal
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
mcp = MCPServer("Shop support", log_level="WARNING")
ORDERS = {
"A17": {"item": "blue mug", "status": "shipped", "total": 12.50},
"B42": {"item": "desk lamp", "status": "delivered", "total": 48.00},
}class Order(BaseModel):
id: str
item: str
status: Literal["processing", "shipped", "delivered"]
total: float
class Confirm(BaseModel):
approve: boolThe order record from the structured-output lesson and the approval answer from the Elicitation lesson. B42 is now delivered, and the server logs only warnings (the Streamable HTTP lesson).
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def lookup_order(order_id: str) -> Order:
"""Look up an order by its id."""
if order_id not in ORDERS:
raise ToolError(f"No order with id {order_id!r}. Order ids look like A17.")
return Order(id=order_id, **ORDERS[order_id])The lookup with its model-readable error (the tool-errors lesson), marked read-only (the tool-annotations lesson).
async def approve_refund(order_id: str) -> Confirm | Elicit[Confirm]:
"""Refunds of 20 or less go through; larger ones need a person."""
if order_id in ORDERS and ORDERS[order_id]["total"] <= 20:
return Confirm(approve=True)
return Elicit(f"Approve a refund for order {order_id}?", Confirm)
@mcp.tool(title="Refund an order", annotations=ToolAnnotations(read_only_hint=False, idempotent_hint=False))
async def refund_order(
order_id: str,
reason: str,
confirm: Annotated[ElicitationResult[Confirm], Resolve(approve_refund)],
) -> str:
"""Refund the full total of an order."""
if order_id not in ORDERS:
raise ToolError(f"No order with id {order_id!r}.")
if not (isinstance(confirm, AcceptedElicitation) and confirm.data.approve):
return f"The refund for {order_id} was not approved, so nothing was refunded."
return f"Refunded {ORDERS[order_id]['total']:.2f} for order {order_id}."The refund enforces approval in the server (the Elicitation lesson): 20 or less goes through, anything larger asks a person through the resolver, and an order that does not exist asks nothing and fails with a ToolError.
@mcp.resource("policy://refunds")
def refund_policy() -> str:
"""The shop's refund policy."""
return "Full refund within 30 days of delivery."
if __name__ == "__main__":
mcp.run(transport="streamable-http", port=8200)- written in Agent loop
View the code here
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage, SystemMessage, ToolMessage
model = init_chat_model("groq:openai/gpt-oss-120b", temperature=0)
SYSTEM = (
"You are the support assistant for a small online shop. "
"Answer in one or two short sentences, using only what the tools returned."
)
def to_model_tools(tools):
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema,
},
}
for tool in tools
]
async def run_tool(client, call):
print(f" calling {call['name']} {call['args']}")
result = await client.call_tool(call["name"], call["args"])
status = "error" if result.is_error else "success"
return ToolMessage(result.content[0].text, tool_call_id=call["id"], status=status)
async def run_agent(client, message):
listed = await client.list_tools()
llm = model.bind_tools(to_model_tools(listed.tools))
messages = [SystemMessage(SYSTEM), HumanMessage(message)]
for _ in range(3):
reply = await llm.ainvoke(messages)
messages.append(reply)
if not reply.tool_calls:
return reply.text
for call in reply.tool_calls:
messages.append(await run_tool(client, call))
return "Stopped after three rounds of tool calls."
The support desk
agent.py is the file from the agent-loop lesson, unchanged: the Groq model, to_model_tools, run_tool and run_agent. The desk connects over HTTP (the Streamable HTTP lesson) and plays the supervisor who answers approvals, saying yes or no from the command line:
import asyncio
import sys
from mcp import Client
from mcp.types import ElicitResult
from agent import run_agent
APPROVE = sys.argv[1] == "approve"
async def supervisor(context, params):
print(f" supervisor asked: {params.message} -> {'yes' if APPROVE else 'no'}")
return ElicitResult(action="accept", content={"approve": APPROVE})async def main():
messages = [
"Where is my order A17?",
"Where is my order Z9?",
"Please refund order A17, the handle broke",
"Please refund order B42, it arrived broken",
]
async with Client("http://127.0.0.1:8200/mcp", elicitation_callback=supervisor) as client:
for message in messages:
print(message)
print(" ", await run_agent(client, message))
asyncio.run(main())Run it: the supervisor says no
python shop.py &
sleep 3
python desk.py decline
kill $!Where is my order A17?
calling lookup_order {'order_id': 'A17'}
Your order A17 (blue mug) has been shipped.
Where is my order Z9?
calling lookup_order {'order_id': 'Z9'}
I’m sorry, but I can’t find an order with the ID “Z9.” Could you double‑check the order number and let me know the correct one?
Please refund order A17, the handle broke
calling refund_order {'order_id': 'A17', 'reason': 'the handle broke'}
Order A17 has been refunded $12.50.
Please refund order B42, it arrived broken
calling refund_order {'order_id': 'B42', 'reason': 'arrived broken'}
supervisor asked: Approve a refund for order B42? -> no
The refund for order B42 could not be approved.- A17 refunded with no question, because 12.50 is under the limit the resolver approves on its own.
- B42 asked the supervisor, who said no, so the tool refunded nothing, and the model's reply passed that on.
- The model never saw the question. The approval ran between the server and the desk's callback, so neither the model nor the host could skip it.
- The replies change from run to run, because a model writes them. The tool calls and the supervisor's question are the part the server controls.
And says yes
python shop.py &
sleep 3
python desk.py approve
kill $!Where is my order A17?
calling lookup_order {'order_id': 'A17'}
Your order A17 (blue mug) has been shipped.
Where is my order Z9?
calling lookup_order {'order_id': 'Z9'}
I’m sorry, but I can’t find an order with the ID “Z9.” Could you double‑check the order number and let me know the correct one?
Please refund order A17, the handle broke
calling refund_order {'order_id': 'A17', 'reason': 'the handle broke'}
Order A17 has been refunded $12.50.
Please refund order B42, it arrived broken
calling refund_order {'order_id': 'B42', 'reason': 'arrived broken'}
supervisor asked: Approve a refund for order B42? -> yes
Your order B42 has been refunded in full.
Annotations vs server-side approval
| Annotations (the tool-annotations lesson) | Server-side approval | |
|---|---|---|
| What it is | A hint to the host | Code in the tool |
| Can a host skip it | Yes | No |
| Enforced by | Nothing | The resolver and the elicitation capability |
A refund with no way to approve
A client that passes no elicitation_callback has no way to ask a person. The resolver needs that capability for a refund over the limit, so the call fails instead of refunding. Here a client catches the error:
import asyncio
from mcp import Client, MCPError
from shop import mcp
async def main():
async with Client(mcp) as client:
try:
await client.call_tool("refund_order", {"order_id": "B42", "reason": "broken"})
except MCPError as error:
print("refund blocked:", error)
asyncio.run(main())
refund blocked: Client did not declare the form elicitation capability required by resolver 'shop:approve_refund'
- No refund was made. The request raised
MCPErrorbefore the tool could return a result. - The message names the resolver that asked for the missing capability, so the cause is clear from the error alone.
The tests
Replace test_shop.py from the testing lesson with these three tests, which check the refund rule instead of the lookup.
import pytest
from mcp import Client, MCPError
from mcp.types import ElicitResult
from shop import mcp
@pytest.fixture
def anyio_backend():
return "asyncio"
async def decline(context, params):
return ElicitResult(action="decline")
@pytest.mark.anyio
async def test_small_refund_needs_no_approval():
async with Client(mcp) as client:
result = await client.call_tool("refund_order", {"order_id": "A17", "reason": "broken"})
assert result.content[0].text == "Refunded 12.50 for order A17."
@pytest.mark.anyio
async def test_declined_refund_refunds_nothing():
async with Client(mcp, elicitation_callback=decline) as client:
result = await client.call_tool("refund_order", {"order_id": "B42", "reason": "broken"})
assert "not approved" in result.content[0].text
@pytest.mark.anyio
async def test_large_refund_fails_without_a_way_to_ask():
async with Client(mcp) as client:
with pytest.raises(MCPError):
await client.call_tool("refund_order", {"order_id": "B42", "reason": "broken"})Three tests cover the refund rule: small refunds go through, a declined refund refunds nothing, and a client that cannot ask cannot get a large refund at all.
pytest -q... [100%] 3 passed in 0.35s
When to build it this way
- Any tool that performs an action a person must sign off on.
- A rule you need enforced no matter which host connects.
- A server you will test in memory and run over HTTP.
MCP features left out: sampling, roots and authorization
| Feature | Status | What it is, and what to use instead | Docs |
|---|---|---|---|
| Sampling | Deprecated 2026-07-28 | A server asking the client's model to generate. New code calls an LLM API directly, as the agent lessons do. | spec |
| Roots | Deprecated 2026-07-28 | The client sharing workspace folders. Pass paths as tool arguments or resource URIs instead. | spec |
| Logging over the protocol | Deprecated 2026-07-28 | Server log messages sent as protocol notifications. Log to standard error, or use OpenTelemetry for tracing. | spec |
| Notifications and dynamic tool lists | Skipped | A server telling connected clients its tool or resource list changed while it runs. | docs |
| Elicitation URL mode | Skipped | Sending the user to a URL to finish a sensitive step, instead of the form this course used. | docs |
| Completion | Skipped | Autocomplete for prompt and resource-template arguments as a user types. | docs |
| Authorization | Skipped | OAuth sign-in for a deployed server, so each call carries who is calling. | spec |
Related
- Previous: Integrations
Connecting the desk to more
- Start the server, add it to Claude Code by its URL (the MCP hosts lesson), and ask for a refund on B42. Where does the approval question appear?
- Ask the desk something no tool can answer, such as the shop's opening hours, and read how the model replies.
- Add a
policy://refundsread torun_agentbefore any refund, and include the policy in the reply.
Little by little, you're building something great.