← Back to Blog

Build an MCP Server in Python: Tools, Resources, and Prompts

You ask Claude Desktop or Cursor to check something that lives on your side: an internal API, a private database, a file on disk. The model can't reach it. No tool, no connector, no way in. That's the gap the Model Context Protocol (MCP) fills: a standard way for AI applications to call your code.

MCP is an open standard introduced by Anthropic in November 2024. Think of it as a USB-C port for AI apps. The host, the app you talk to, runs an MCP client that launches your server as a child process and speaks to it over stdin and stdout. Your server never talks to the model directly. It exposes three kinds of things:

  • Tools, functions the model decides to call to take an action. Roughly a POST.
  • Resources, data the host loads into the model's context, like a file's contents or an API response. Roughly a GET.
  • Prompts, message templates the user invokes by name, like a slash command.

The split exists because each primitive has a different controller: the model, the application, or the user. Write one server, and every host that speaks MCP can use it: Claude Desktop, Claude Code, Cursor, VS Code.

By the end of this tutorial you will have a working server with all three primitives, an automated test, and a connection to the host you actually use.

Prerequisites

  • Python 3.10+ (required by the SDK)
  • uv or pip. The official docs use uv, but pip works the same.
  • An MCP host to test with: the MCP Inspector (nothing to install), Claude Desktop, Claude Code, Cursor, or VS Code 1.99+ with the Copilot extension in Agent mode
  • 20 minutes

Step 1: Install the SDK

The Python SDK is on PyPI as mcp:

pip install "mcp[cli]"
# or: uv add "mcp[cli]"

The [cli] extra adds the mcp command (mcp dev, mcp run, mcp install). The current stable line is v2, which supports the 2026-07-28 revision of the spec along with every earlier revision. If you maintain a package that depends on mcp and is not ready to migrate, pin mcp>=1.28,<2 to stay on the 1.x line.

Step 2: Write the server

Create server.py. The entire API is three decorators on plain Python functions:

from typing import Annotated

from mcp.server import MCPServer
from pydantic import Field

mcp = MCPServer("Bookshop")

CATALOG = {
    "Dune": "Frank Herbert",
    "Neuromancer": "William Gibson",
    "The Left Hand of Darkness": "Ursula K. Le Guin",
}


@mcp.tool()
def search_books(
    query: Annotated[str, Field(description="Title or author to search for.")],
    limit: Annotated[int, Field(ge=1, le=50, description="Maximum number of results.")] = 10,
) -> list[str]:
    """Search the catalog by title or author."""
    needle = query.lower()
    return [
        title
        for title, author in CATALOG.items()
        if needle in title.lower() or needle in author.lower()
    ][:limit]


@mcp.tool()
def get_author(title: str) -> str:
    """Look up the author of a book in the catalog."""
    if title not in CATALOG:
        raise ValueError(f"No book titled {title!r} in the catalog.")
    return CATALOG[title]


@mcp.resource("catalog://titles")
def titles() -> str:
    """Every title in the catalog, one per line."""
    return "\n".join(sorted(CATALOG))


@mcp.prompt()
def recommend(genre: str) -> str:
    """Recommend a book from the catalog."""
    return f"Recommend a {genre} book from this catalog: {', '.join(CATALOG)}"


if __name__ == "__main__":
    mcp.run()

A few things matter here:

  • mcp.run() starts a stdio server. It blocks, reads protocol messages on stdin, writes them on stdout. No port, nothing listens.
  • The server object must be a module-level global named mcp (server and app also work) so the CLI can find it. Call it something else and you name it explicitly: mcp run server.py:bookshop.
  • Type hints are the contract, not documentation. The SDK generates the JSON Schema from them and rejects invalid input before your function runs. If a client sends "limit": "ten", the SDK answers with an error and your function never executes.
  • An exception inside a tool is not a crash. It becomes an error result with is_error=True and the model reads the message. That's how get_author tells the model the title doesn't exist.
  • Constrain arguments with pydantic Field. Annotated[int, Field(ge=1, le=50)] lands in the schema as "minimum": 1, "maximum": 50. Call the tool with limit=999 and the SDK returns "Input should be less than or equal to 50", the model reads it and retries with a valid value. Self-correcting agents for free.

Step 3: Open it in the MCP Inspector

uv run mcp dev server.py
# or, with the SDK installed in your environment: mcp dev server.py

Open the URL it prints. The Inspector has one tab per primitive. The form for search_books was built from your type hints: a required query field and an optional limit field. Call the tool, read the catalog://titles resource, run the prompt. Every other MCP client builds the same UI from the same schema.

Step 4: Test it without a host

The SDK ships a Client with an in-memory transport. Client(mcp) connects to the server object directly: no subprocess, no port. Same idea as FastAPI's TestClient.

import asyncio

from mcp import Client

from server import mcp


async def main() -> None:
    async with Client(mcp) as client:
        result = await client.call_tool("search_books", {"query": "dune"})
        print(result.structured_content)  # {'result': ['Dune']}


asyncio.run(main())

The same pattern drops into pytest:

import pytest

from mcp import Client

from server import mcp


@pytest.fixture
def anyio_backend():
    return "asyncio"


@pytest.fixture
async def client():
    async with Client(mcp, raise_exceptions=True) as c:
        yield c


@pytest.mark.anyio
async def test_search_books(client):
    result = await client.call_tool("search_books", {"query": "dune"})
    assert result.structured_content == {"result": ["Dune"]}

Install pytest (pip install pytest) and run it. raise_exceptions=True matters in tests: without it, a crash outside a tool body is sanitized into a generic "Internal server error" before your test sees it, exactly what you don't want while debugging. In production code the flag has no meaning.

Step 5: Connect it to a real host

Every host gets the same launch command:

uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py

One command everywhere because uv run --with resolves the SDK into a fresh environment on the spot: no project, no virtualenv to activate. Hosts launch your server from their own working directory with a near-empty environment, which is why the path has to be absolute. If a host can't find uv, replace it with the absolute path from which uv.

Claude Desktop. The one host the SDK configures for you:

uv run mcp install server.py

mcp install imports your file to read the server's name, finds Claude Desktop's config, and writes the entry for you. This is what it writes into claude_desktop_config.json (~/Library/Application Support/Claude/ on macOS, %APPDATA%\Claude\ on Windows):

{
  "mcpServers": {
    "Bookshop": {
      "command": "/absolute/path/to/uv",
      "args": ["run", "--frozen", "--with", "mcp[cli]==2.0.0", "mcp", "run", "/absolute/path/to/server.py"]
    }
  }
}

Note the three additions: the absolute path to uv, --frozen so uv never rewrites a lockfile it happens to be near, and an exact pin to the SDK version you have installed. Then fully quit Claude Desktop, not just its window, and reopen it. Env vars your server needs? uv run mcp install server.py -v API_KEY=abc123 or -f .env records them in the entry.

Claude Code. No file to edit:

claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py

Type /mcp inside a session to confirm bookshop is connected and its tools are listed.

Cursor. Create .cursor/mcp.json in your project root with the same mcpServers structure shown above.

VS Code. Create .vscode/mcp.json. Two differences from Cursor's file: the wrapper key is servers, not mcpServers, and each entry declares "type": "stdio". You need VS Code 1.99+ with the GitHub Copilot extension signed in (Copilot Free is enough), and Copilot Chat must be in Agent mode, because no other mode calls tools.

Step 6: When it doesn't show up

Before touching any host config, run the launch command yourself. It prints nothing and doesn't return. That silence is correct: a stdio server is waiting for a host to speak first on stdin (Ctrl-C to stop). A traceback or an immediate exit is the real bug, and now you can read it.

Past that, it's almost always one of three things:

  1. A relative path. The host launches your server from its own working directory, not the one you registered from. server.py where an absolute path is needed is the most common failure.
  2. The host is running its old config. Hosts read their config at launch. Claude Desktop in particular has to be fully quit before an edit takes effect.
  3. Something wrote to stdout. On stdio, stdout is the protocol. A stray print() in a wrapper script or at import time hands the host a corrupt message and it drops the connection. Log with the default logging config, which writes to stderr.

If you need to see what your server is doing, Claude Desktop keeps mcp-server-<NAME>.log next to mcp.log under ~/Library/Logs/Claude on macOS and %APPDATA%\Claude\logs on Windows.

When to build your own vs use an existing server

Ready-made servers exist for common things: filesystem access, GitHub, Postgres, and more live in the official servers repo. Use those when the tool is generic.

Write your own when it's your data: an internal API, a private database, a workflow your company runs. That's where no public server exists and where the payoff is highest.

MCP vs a plain REST API: a REST API serves your application's frontend. An MCP server exists so any LLM host can use your data and actions with schemas the model can read. The same backend can be wrapped in both; they solve different problems. And a stdio MCP server needs no port, no deployment, and no auth server for local use. You give it to a host as one command.

Where to go next

You now have a server that any MCP host can call: two tools, a resource, a prompt, a test, and a real connection. From here:

  • Serve the same mcp object over Streamable HTTP so other people connect to a URL instead of a command. That's the path to a real deployment, and the docs cover auth once you go remote.
  • Read the SDK docs on resources and prompts. Tools are the part the model drives; resources and prompts have their own subtleties worth knowing.
  • Browse the official servers repo for patterns used by production servers before you build something bigger.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation