MCPMCP Python SDK 2.2 · Python 3.10+
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
24 small wins to finish your path

Project: a support desk agent over MCP

Lesson 0 promised an MCP server for a support team and an agent that uses its tools. Here both run over HTTP, with approved refunds and tests.

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 lesson 5 and the approval answer from lesson 14. B42 is now delivered, and the server logs only warnings (lesson 16).

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 (lesson 6), marked read-only (lesson 7).

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 (lesson 14): 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)

The support desk

agent.py is lessons 19 to 21 unchanged. The desk connects over HTTP (lesson 16) 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

Example
python shop.py &
sleep 3
python desk.py decline
kill %1

A17 costs 12.50, so its refund went through without a question. B42's did not: the supervisor was asked, said no, and the tool refunded nothing. The model-side code never saw the approval and could not have skipped it.

And says yes

Example
python shop.py &
sleep 3
python desk.py approve
kill %1

The tests

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 for the rule that matters most: 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

Where each piece came from

The support desk, by lesson
Servertools, lessons 2 and 4structured output, lesson 5errors, lesson 6resources, lesson 8Safetyannotations, lesson 7approval, lesson 14TransportStreamable HTTP, lesson 16Agentmodel tools, lesson 19choosing, lesson 20the loop, lesson 21Proofpytest, lesson 22MCP support desk

Things to add

Try it yourself
  • Connect the server to Claude Code (lesson 18) and ask for a refund on B42. Where does the approval question appear?
  • Replace choose_tool with a real model call from LLM Fundamentals or APIs for AI, using to_model_tools for its tools.
  • Add a policy://refunds read to run_agent before any refund, and include the policy in the reply.

What this course left out

TopicWhat it is for
AuthorizationOAuth sign-in for deployed servers, so each user's calls carry who they are.
CompletionsAutocomplete for prompt and resource template arguments as a user types.
Sampling and rootsA server asking the client's model for a completion, or for the user's workspace folders.
SubscriptionsClients notified when a resource or a list of tools changes.
DependenciesResolvers that compute a value without asking anyone, and dependencies of dependencies.
DeploymentAllowed hostnames, several workers and TLS for a server on the internet.
Session groupsOne client application connected to several servers at once.
The low-level ServerFull control over every protocol message when the decorators are not enough.

Slow is fine. Stopping is the only problem.