How to Build an MCP Server in Python

Expose your own tools and data to AI assistants through the Model Context Protocol: a complete Python server, tests, the Inspector, and a Claude Desktop setup.

Executive Summary: An MCP server exposes tools, data, and prompt templates to AI apps through the Model Context Protocol. This post builds one in Python with the official SDK’s FastMCP class in about fifty lines, explains why one integration can serve any MCP client, and covers the new trust boundary it creates and why tools should stay few and narrow.

You built a function that lets one AI assistant search your team’s task tracker. Then a colleague wanted it in their code editor’s assistant, and another wanted it in a chat application. Each one had a different plugin format, so you wrote the same integration three times. A shared protocol makes that work disappear: one server, many clients.

The Model Context Protocol (MCP) is that shared protocol. This guide builds an MCP server in Python that manages a small task list. An MCP server offers capabilities. An MCP host, such as Claude Desktop or an editor, connects to servers and makes their capabilities available to a language model.

You need Python 3.13 and the mcp package, version 1.2 or later. The project uses uv to manage dependencies, and the commands for plain pip are shown too. One background idea helps: with tool calling, a model replies with a structured request to run a function, and the application runs it. A separate guide walks through that tool calling loop. Building and testing the server costs nothing and needs no API key.

My position: MCP is plumbing, and good plumbing is valuable. It does not make a model smarter. It makes your tools portable. The engineering effort belongs in the design of the tools themselves, since a model will read their descriptions and decide what to do.

How MCP works

+----------------------------- your computer ------------------------------+
|                                                                          |
|  HOST application (Claude Desktop, an editor, your own app)              |
|     |                                                                    |
|     |  talks to a language model, and decides which tools it may use     |
|     |                                                                    |
|     +-- MCP client --- stdio (JSON-RPC) ---> MCP SERVER: taskapi         |
|     |                                           tools, resources, prompts|
|     +-- MCP client --- stdio (JSON-RPC) ---> MCP SERVER: another one     |
|                                                                          |
+--------------------------------------------------------------------------+

The host starts your server as a subprocess and exchanges JSON-RPC 2.0 messages with it. On connection, the host asks what the server offers. Later, when the model requests a tool, the host forwards the call to your server and returns the result to the model.

A server can offer three kinds of things, and the difference is who decides to use them.

Primitive What it is Who triggers it Example
Tool A function with side effects or computation The model, usually with the user’s approval add_task(title)
Resource Read-only data identified by a URI The application or the user tasks://open
Prompt A reusable prompt template The user, for example from a menu “Weekly review”

Tools get most of the attention, because they let the model act. Resources and prompts matter when your users want to attach context or start a common task themselves.

The myth: MCP replaces tool calling

A common reading is that MCP is a new capability that supersedes function calling. It is not. The model still uses ordinary tool calling to request a function. MCP standardizes the layer underneath: how a host discovers tools, how it invokes them, and how results come back.

Think of tool calling as the model’s ability to ask, and MCP as the socket that tools plug into. If you are calling a model API directly in your own backend, with three functions defined in the same codebase, you do not need MCP. You need it when tools and applications are built by different people.

Step 1: create the project

python --version
uv init taskapi-mcp
cd taskapi-mcp
uv add "mcp[cli]"
uv add --dev pytest

The cli extra installs the mcp command, which runs the development tools. If you prefer pip, the equivalent is:

python -m venv .venv
source .venv/bin/activate          # macOS and Linux
.venv\Scripts\Activate.ps1         # Windows PowerShell
python -m pip install "mcp[cli]" pytest

Step 2: write the MCP server in Python

Save this complete file as server.py in the project folder.

import json
import logging
import os
import sys
from pathlib import Path

from mcp.server.fastmcp import FastMCP

DATA_FILE = Path(os.environ.get('TASKAPI_DATA', Path.home() / '.taskapi-mcp.json'))

# A stdio server must never write to stdout. Logs go to stderr.
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
logger = logging.getLogger('taskapi-mcp')

mcp = FastMCP('taskapi')


def load_tasks() -> list[dict]:
    if not DATA_FILE.exists():
        return []
    return json.loads(DATA_FILE.read_text(encoding='utf-8'))


def save_tasks(tasks: list[dict]) -> None:
    DATA_FILE.write_text(json.dumps(tasks, indent=2), encoding='utf-8')


@mcp.tool()
def add_task(title: str) -> dict:
    """Create a new task. Use this when the user asks to add or remember something to do."""
    title = title.strip()
    if not 3 <= len(title) <= 200:
        raise ValueError('title must be between 3 and 200 characters')
    tasks = load_tasks()
    task = {'id': max((item['id'] for item in tasks), default=0) + 1, 'title': title, 'done': False}
    tasks.append(task)
    save_tasks(tasks)
    logger.info('added task %s', task['id'])
    return task


@mcp.tool()
def list_tasks(only_open: bool = False) -> list[dict]:
    """List tasks with their IDs. Set only_open to true to hide completed tasks."""
    return [task for task in load_tasks() if not (only_open and task['done'])]


@mcp.tool()
def complete_task(task_id: int) -> dict:
    """Mark one task as done. Call list_tasks first if you do not know the ID."""
    tasks = load_tasks()
    for task in tasks:
        if task['id'] == task_id:
            task['done'] = True
            save_tasks(tasks)
            logger.info('completed task %s', task_id)
            return task
    raise ValueError(f'task {task_id} does not exist. Call list_tasks to see valid IDs.')


@mcp.resource('tasks://open')
def open_tasks() -> str:
    """The current open tasks, as JSON."""
    return json.dumps([task for task in load_tasks() if not task['done']], indent=2)


@mcp.prompt()
def weekly_review() -> str:
    """Review the open tasks and suggest priorities."""
    return (
        'Review my open tasks. Group them by theme, point out anything that looks stale, '
        'and suggest the three I should do first.'
    )


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

There is no protocol code in that file. FastMCP reads each decorated function and does the rest.

  • The function name becomes the tool name.
  • The type hints become the JSON Schema for the arguments. title: str is a required string, and only_open: bool = False is an optional boolean.
  • The docstring becomes the description the model reads. It is the most important line in each tool.
  • The return value is serialized and sent back as the tool result.
  • A raised exception becomes an error result with your message. The server keeps running.

mcp.run() starts the server on the stdio transport, which is what desktop hosts expect.

The rule that breaks first servers: stdout belongs to the protocol

With the stdio transport, the host and the server talk through the server’s standard input and output. Every byte on stdout must be a protocol message. A single print('starting up') injects text into that stream, the host fails to parse it, and the connection drops with an unhelpful error.

That is why the file configures logging to sys.stderr before anything else. Use the logger for all diagnostics, and check that libraries you import do not print on their own.

Step 3: test the functions as plain Python

The decorators register each function and return it unchanged, so you can call the tools directly in a test. No host, no model, and no network are involved. Save this as test_server.py.

import json

import pytest

import server


@pytest.fixture(autouse=True)
def data_file(tmp_path, monkeypatch):
    monkeypatch.setattr(server, 'DATA_FILE', tmp_path / 'tasks.json')


def test_add_then_list():
    server.add_task('Renew domain')
    assert server.list_tasks() == [{'id': 1, 'title': 'Renew domain', 'done': False}]


def test_completed_tasks_leave_the_open_list():
    server.add_task('Renew domain')
    server.add_task('Write docs')
    server.complete_task(1)
    assert [task['id'] for task in server.list_tasks(only_open=True)] == [2]
    assert [task['id'] for task in json.loads(server.open_tasks())] == [2]


def test_short_title_is_rejected():
    with pytest.raises(ValueError):
        server.add_task('x')


def test_unknown_task_gives_a_helpful_error():
    with pytest.raises(ValueError, match='list_tasks'):
        server.complete_task(99)
uv run pytest -q
....                                                                     [100%]
4 passed in 0.42s

The fixture points the data file at a temporary directory, so tests never touch your real tasks. These tests cover your logic. The next step checks the protocol side.

Step 4: inspect the server with MCP Inspector

The MCP Inspector is a browser tool that acts as a host. It connects to your server, lists what it offers, and lets you call tools by hand. It needs Node.js installed, because the SDK launches it with npx.

uv run mcp dev server.py

The command prints a local URL. Open it, connect, and check three things.

  1. The Tools tab lists three tools with the descriptions and argument schemas you expect. Read them as a model would. Is it clear when to use each one?
  2. Calling add_task with a title returns the new task, and calling it with x returns an error result with your message.
  3. The Resources and Prompts tabs show tasks://open and weekly_review.

Use the Inspector before any real host. It shows the raw messages, so a schema or serialization problem is visible immediately, without a model in the way.

Step 5: connect Claude Desktop

Claude Desktop reads its server list from a JSON file. The SDK can add your server for you:

uv run mcp install server.py

You can also edit the file yourself. It lives at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS and %APPDATA%\Claude\claude_desktop_config.json on Windows.

{
  "mcpServers": {
    "taskapi": {
      "command": "uv",
      "args": ["--directory", "/ABSOLUTE/PATH/TO/taskapi-mcp", "run", "server.py"],
      "env": {
        "TASKAPI_DATA": "/ABSOLUTE/PATH/TO/tasks.json"
      }
    }
  }
}

Replace both placeholders with real absolute paths. The host starts the command from its own working directory, so relative paths fail. Restart Claude Desktop completely, and the server’s tools appear in the tools menu of a conversation. Ask it to add a task and then list what is open. The host shows which tool it wants to call and asks for your approval first.

The env block is the right place for configuration and secrets. If a server needs an API token, pass it there and read it with os.environ. Never write a secret into server.py.

Troubleshooting

  • The server does not appear. The JSON file has a syntax error, or the application was not fully restarted. Validate the file with python -m json.tool.
  • The server appears and then disconnects. Something wrote to stdout, or the command failed. Run the exact command from the config in a terminal and read the error.
  • spawn uv ENOENT or a similar message. The host cannot find uv on its PATH. Use the absolute path to the executable, which which uv or where uv prints.
  • Tools exist, but the model never calls them. The descriptions do not tell the model when the tool applies. Rewrite the docstrings.

Transports: stdio and HTTP with SSE

The specification dated 2024-11-05 defines two standard transports.

Transport How it connects Use it for Watch out for
stdio The host starts the server as a local subprocess Personal tools on your own machine Nothing may be printed to stdout
HTTP with SSE The client connects to a URL. The server streams messages with Server-Sent Events A shared server reached over a network You must add authentication and transport security yourself

Switching is one argument: mcp.run(transport='sse'). The decision behind it is larger. A stdio server is reachable only by programs on your machine. A network server is reachable by anyone who can reach the port, and the SDK does not authenticate callers for you. Do not expose one beyond localhost without an authenticating proxy in front of it.

Design tools for a model, not for a developer

Here is the insight that decides whether an MCP server is useful in practice. Your reader is a language model with limited context and no patience for ambiguity. Tools that mirror your internal API one-to-one usually work poorly.

  • Say when, not only what. “Create a new task. Use this when the user asks to add or remember something to do” guides selection. “Creates a task” does not.
  • Prefer a few task-shaped tools. One complete_task beats a generic update_task with eight optional fields. Fewer choices mean fewer wrong ones.
  • Write errors as instructions. “Task 99 does not exist. Call list_tasks to see valid IDs” tells the model its next move. A bare “not found” leaves it guessing.
  • Return compact results. Everything you return is added to the model’s context. Send the fields that matter, not a database row with forty columns.
  • Type every argument. The hints become the schema. The same discipline that produces good schemas and validated output applies here.

There is also arithmetic to respect. Every connected server’s full tool list, with names, descriptions, and schemas, is sent to the model in each conversation. Five servers with twenty tools each put a hundred tool definitions into the context before the user types a word. That costs tokens on every request, and it makes tool selection worse. My heuristic: keep a server under about ten tools, and open the Inspector to read the tool list as one block of text. If it is long enough to bore you, it is too long for the model.

Security: a server runs as you

  • Local servers have your permissions. A stdio server can read your files and reach your network, exactly like any script you run. Install only servers whose code you trust.
  • Tool results are untrusted input to the model. If a tool returns the text of a web page or an email, that text can contain instructions aimed at the model. This is prompt injection. Limit what your tools can do, so that a hijacked conversation cannot do much harm.
  • Validate arguments. The model writes them, and they can be wrong or malicious. The length check in add_task is the minimum. Never pass an argument to a shell or a SQL string.
  • Keep destructive actions narrow. There is no delete_all_tasks tool in this server, on purpose. Hosts ask the user to approve tool calls, and users approve quickly.
  • Keep secrets in the environment. Tokens go in the host’s env block, not in code and not in tool results or logs.

How real teams use MCP servers

  • One server per system. A team wraps its issue tracker, its documentation, or its database behind a server, and everyone connects their own client.
  • Read-only first. The first version exposes search and fetch tools. Write tools arrive later, once the team trusts the behavior.
  • The server calls the real API. Tools are thin wrappers over an existing service, with its authentication and rate limits. The server holds no business logic of its own.
  • Tested like any service. Tool functions have unit tests, and a scripted Inspector session checks the protocol surface before a release.
  • Version pinned. The protocol and the SDK are new and moving. Teams pin the mcp package and upgrade deliberately.

We once hit a bug when a server that passed every unit test would connect to the desktop host and drop within a second. The cause was a single print() left in a helper module for debugging. On the stdio transport, that line landed in the protocol stream, and the host rejected the malformed message. The Inspector showed the stray text at once. Since then, the first lines of every server I write configure logging to standard error.

Deciding whether to build an MCP server: a decision framework

  1. Will more than one application use these tools? If yes, MCP saves you from writing the integration several times.
  2. Are the tools used only inside your own backend? Define them directly with your model provider’s tool calling. MCP adds a process and a protocol you do not need.
  3. Do users want to bring their own assistant? An MCP server lets them connect the client they already use.
  4. Is the data local to the user’s machine? Use the stdio transport.
  5. Must many users share one deployment? Use HTTP with SSE, behind authentication you control.
  6. Can any tool cause irreversible harm? Leave it out, or make it narrow enough that approving it by mistake is survivable.

When NOT to build an MCP server

  • A single application with a handful of functions. Direct tool definitions are simpler, faster, and easier to debug.
  • Latency-critical paths. Each call crosses a process or network boundary and passes through a host. For tight loops, call the function in-process.
  • You need a stable, long-lived interface today. The protocol is a few months old, and both the specification and the clients are changing. If you cannot absorb updates, wait.

Common mistakes

  • Printing to stdout. On stdio, any stray output corrupts the message stream, and the host disconnects.
  • Relative paths in the host configuration. The host runs the command from its own directory, so the server or its data file is not found.
  • Vague docstrings. The model cannot tell when to use the tool, so it never calls it, or calls the wrong one.
  • Exposing a whole API as tools. Dozens of tools fill the context window and confuse selection.
  • Returning huge payloads. A tool that returns a megabyte of JSON exhausts the context, and the conversation fails.
  • Running a network server without authentication. Anyone who can reach the port can call your tools with your server’s permissions.

Key takeaways

  • MCP is an open protocol that connects AI applications to tools, data, and prompts.
  • FastMCP turns typed, documented Python functions into tools, resources, and prompts.
  • On the stdio transport, write logs to stderr and nothing to stdout.
  • Test tool functions as plain Python, then check the protocol with MCP Inspector.
  • Configure hosts with absolute paths, and pass secrets through the env block.
  • Design tools for a model: few, task-shaped, clearly described, with instructive errors.
  • A server runs with your permissions, so keep tools narrow and validate every argument.

FAQ

What is an MCP server?

An MCP server is a program that exposes tools, resources, and prompts to AI applications through the Model Context Protocol. A host application connects to it and lets a language model use what it offers.

How do I build an MCP server in Python?

Install the mcp package, create a FastMCP instance, decorate functions with @mcp.tool(), and call mcp.run(). Type hints define the argument schema, and docstrings become the descriptions.

What is the difference between MCP tools, resources, and prompts?

Tools are functions the model can ask to run. Resources are read-only data that the application or user attaches as context. Prompts are reusable templates that a user selects.

How do I test an MCP server?

Call the tool functions directly in unit tests, since they are ordinary Python functions. Then run mcp dev server.py to open MCP Inspector and call the tools through the protocol.

Is MCP the same as function calling?

No. Function calling is how a model requests a tool. MCP is a standard for how applications discover and invoke tools provided by separate servers. MCP hosts use function calling underneath.

Small server, sharp tools

The protocol work in an MCP server is nearly free, because the SDK does it. What remains is the part that determines quality: which tools exist, how they are described, what they return, and what they are allowed to touch. Spend your time there, test the functions like any other code, and read your tool list the way a model will.

Rule of thumb: an MCP server is an API whose only user is a language model, so write its documentation first and its code second.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *