Model Context ProtocolMCP Python SDK 2.2 · LangChain 1.4 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
27 small wins to finish your path

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

Exampleshop.py, part 1
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},
}
Exampleshop.py, part 2
class Order(BaseModel):
    id: str
    item: str
    status: Literal["processing", "shipped", "delivered"]
    total: float


class Confirm(BaseModel):
    approve: bool

The 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).

Exampleshop.py, part 3
@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).

Exampleshop.py, part 4
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.

Exampleshop.py, part 5
@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)
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
agent.py
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:

Exampledesk.py, part 1
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})
Exampledesk.py, part 2
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

ExampleAPI key
python shop.py &
sleep 3
python desk.py decline
kill $!
  • 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

ExampleAPI key
python shop.py &
sleep 3
python desk.py approve
kill $!
desk.py sends the tools and the message to the Groq model, gets back a tool call, and calls lookup_order or refund_order on shop.py over Streamable HTTP; before refund_order runs, approve_refund asks the supervisor in desk.py through elicitation for refunds over 20.
The support desk

Annotations vs server-side approval

Annotations (the tool-annotations lesson)Server-side approval
What it isA hint to the hostCode in the tool
Can a host skip itYesNo
Enforced byNothingThe 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:

Exampleno_approver.py
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())
  • No refund was made. The request raised MCPError before 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.

Exampletest_shop.py
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.

Example
pytest -q

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

FeatureStatusWhat it is, and what to use insteadDocs
SamplingDeprecated 2026-07-28A server asking the client's model to generate. New code calls an LLM API directly, as the agent lessons do.spec
RootsDeprecated 2026-07-28The client sharing workspace folders. Pass paths as tool arguments or resource URIs instead.spec
Logging over the protocolDeprecated 2026-07-28Server log messages sent as protocol notifications. Log to standard error, or use OpenTelemetry for tracing.spec
Notifications and dynamic tool listsSkippedA server telling connected clients its tool or resource list changed while it runs.docs
Elicitation URL modeSkippedSending the user to a URL to finish a sensitive step, instead of the form this course used.docs
CompletionSkippedAutocomplete for prompt and resource-template arguments as a user types.docs
AuthorizationSkippedOAuth sign-in for a deployed server, so each call carries who is calling.spec
Watch out
Annotations and prompts are requests a host may ignore. Only the server's own code, the resolver plus the elicitation capability, guarantees a refund was approved.

Connecting the desk to more

Try it yourself
  • 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://refunds read to run_agent before any refund, and include the policy in the reply.
PreviousIntegrations

Little by little, you're building something great.