The complete example is in python/ of the examples repository.

Files in the repository

Every snippet on this page comes from these files; open them to see the whole thing.

Install

python -m venv .venv
.venv/bin/pip install "mcp==2.3.0" httpx

Python 3.10 or later. Version 2 of the SDK renamed the high-level server class to MCPServer (it was FastMCP in version 1; the separate fastmcp package on PyPI is a different project).

The upstream client

shop.py wraps the Sample Shop API with httpx, reads UPSTREAM_URL and UPSTREAM_API_KEY from the environment, and raises one UpstreamError with a kind of invalid_input, not_found, conflict or unavailable. Tools never see headers or status codes.

Tools are typed functions

MCPServer turns a function's signature into the tool's input schema: parameter types become JSON Schema types, defaults make parameters optional, and Annotated[..., Field(...)] adds descriptions and constraints.

from typing import Annotated, Any, Literal

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
from pydantic import Field

mcp = MCPServer("sample-shop", version="1.0.0", instructions="...")

@mcp.tool(
    title="Search products",
    description="Search the shop catalogue by phrase and optional category. Returns one page of up to `limit` products and a `nextCursor`; when nextCursor is not null, more results exist and you must say so rather than claiming the list is complete. Use get_product for full details of one item. This tool does not place orders.",
    annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False),
)
def search_products(
    query: Annotated[str, Field(max_length=100, description="Words to match against product names and descriptions.")] = "",
    category: Annotated[Literal["lighting", "furniture", "kitchen"] | None, Field(description="Restrict results to one category.")] = None,
    limit: Annotated[int, Field(ge=1, le=20, description="Page size.")] = 5,
    cursor: Annotated[str | None, Field(description="Opaque token from a previous page's nextCursor. Never invent one.")] = None,
) -> dict[str, Any]:
    try:
        page = shop.search_products(q=query, category=category, limit=limit, cursor=cursor)
    except UpstreamError as error:
        raise _explain(error) from error
    return {"items": [_summary(p) for p in page["items"]], "nextCursor": page["nextCursor"]}

Returning a dict gives the client both a text rendering and structuredContent. Parameter names are camelCase (productId, idempotencyKey, orderId) in the other tools to match the shared interface; that is deliberate, not a style slip.

Errors the assistant can act on

Raising ToolError produces an isError result with your message as its text. _explain turns the four upstream kinds into the four instructions (adjust input, do not retry this id, tell the user about the conflict, wait):

def _explain(error: UpstreamError) -> ToolError:
    if error.kind == "invalid_input":
        return ToolError(f"Invalid input: {error} Adjust the arguments and call again.")
    if error.kind == "not_found":
        return ToolError(f"Not found: {error} Do not retry with the same identifier; ask the user to check it.")
    if error.kind == "conflict":
        return ToolError(f"Conflict: {error} Do not retry automatically; tell the user what happened.")
    return ToolError(f"The shop service is unavailable right now: {error} Wait before retrying, and tell the user.")

The guarded cancel_order uses the same mechanism: without confirm=True it fetches the order and raises a ToolError describing what would be cancelled and asking for confirmation.

Serve it stateless, with a health route

streamable_http_app() returns a Starlette application. The example adds a /health route and runs it with uvicorn, which the SDK already depends on:

if __name__ == "__main__":
    import uvicorn
    from starlette.responses import JSONResponse

    app = mcp.streamable_http_app(stateless_http=True, host=HOST)
    app.add_route("/health", lambda request: JSONResponse({"ok": True}))
    uvicorn.run(app, host=HOST, port=PORT, log_level="warning")

mcp.run("streamable-http", host=..., port=...) does the same without the extra route.

Run and test

node sample-api/server.mjs &
cd python && .venv/bin/python server.py
# in another shell
cd tests && npm ci && MCP_URL=http://127.0.0.1:3002/mcp npm test

Next: connect it to Claude or ChatGPT, or see what production needs.