AI engineering

Build Your First MCP Server in Python: Tools, Resources and Testing

Build a Model Context Protocol server in Python with the official SDK, expose a tool and a resource, test it with MCP Inspector and a Python client, and connect it to VS Code.

By EpiFive Editorial TeamReviewed/updated 9 min readHow we check articles
On this page

A Model Context Protocol (MCP) server is a program that exposes tools, resources and prompts to an AI client in a standard way. To build one in Python, install the official mcp package, create an MCPServer, decorate typed functions with @mcp.tool() and @mcp.resource(), and run it with mcp.run(transport="stdio"). You can test it with MCP Inspector before you connect it to VS Code or Claude for Desktop.

Who this is for and what you will have

This guide is for developers who want an AI client to reach internal data or actions, such as looking up a support ticket, without writing a custom integration for every client. By the end you will have a small server with one tool and one resource, a way to test it in a browser and from Python code, and a configuration that lets VS Code start it.

The example uses an in-memory ticket list so that you can focus on the protocol. Replace the dictionary with your own data source when you adapt it.

What an MCP server can offer

MCP servers can provide three kinds of capability, according to the Model Context Protocol documentation:

CapabilityWhat it isExample
ResourcesFile-like data that clients can readA ticket record, a file, an API response
ToolsFunctions that the model can call, with user approvalSearch tickets, create a note
PromptsPre-written templates that help users complete a taskA "summarise this ticket" template

This tutorial covers tools and resources. A client such as VS Code or Claude for Desktop connects to the server, lists what it offers, and decides when to call it.

Prerequisites

  • Python 3.10 or higher.
  • The Python MCP SDK. The current server quickstart states that you must use SDK version 2.0.0 or higher, and its examples import MCPServer from mcp.server. If you have older code that uses a different class name, check the SDK migration guide linked in the references.
  • uv, the Python package manager that the quickstart uses.
  • Node.js 22.19.0 or newer if you want to run MCP Inspector, which the Inspector documentation states as its requirement.
  • VS Code if you want to connect the server to an editor client.

Install uv on macOS or Linux:

curl -LsSf https://astral.sh/uv/install.sh | sh

On Windows, use the PowerShell installer from the same documentation:

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Restart your terminal so that the uv command is found.

Create the project

uv init tickets
cd tickets
uv venv
source .venv/bin/activate
uv add "mcp[cli]"
touch server.py

On Windows, activate the environment with .venv\Scripts\activate and create the file with new-item server.py. The cli extra installs the mcp command-line tool, which provides mcp dev and mcp run.

Write the server

Create server.py. The server uses a typed function for each tool, and the SDK builds the tool definition from the type hints and the docstring, so write the docstring for the model as well as for people.

import logging
 
from mcp.server import MCPServer
 
logger = logging.getLogger(__name__)
 
mcp = MCPServer("tickets")
 
TICKETS = {
    "T-1001": {"title": "VPN drops every hour", "status": "open", "owner": "alex"},
    "T-1002": {"title": "Shared mailbox not visible", "status": "closed", "owner": "sam"},
    "T-1003": {"title": "Printer queue stuck", "status": "open", "owner": "alex"},
}
 
 
@mcp.tool()
def search_tickets(status: str) -> list[dict]:
    """Return tickets with the given status.
 
    Args:
        status: Ticket status to filter by, either "open" or "closed"
    """
    logger.info("search_tickets called with status=%s", status)
    return [
        {"id": ticket_id, **ticket}
        for ticket_id, ticket in TICKETS.items()
        if ticket["status"] == status
    ]
 
 
@mcp.resource("ticket://{ticket_id}")
def ticket_record(ticket_id: str) -> str:
    """Return one ticket as text."""
    ticket = TICKETS.get(ticket_id)
    if ticket is None:
        return f"No ticket found with id {ticket_id}."
    return f"{ticket_id}: {ticket['title']} ({ticket['status']}, owner {ticket['owner']})"
 
 
if __name__ == "__main__":
    mcp.run(transport="stdio")

The tool and resource decorators match the SDK's own README examples, which register a tool with @mcp.tool() and a resource with a URI template such as greeting://{name}. Here the template is ticket://{ticket_id}, and the ticket_id argument receives the part of the URI that matches.

Do not write to stdout

For a stdio server, the client reads JSON-RPC messages from the server's standard output. The documentation is explicit: never write to stdout, because it corrupts the messages and breaks the server. print() writes to stdout by default, so keep it out of the server. Use the standard library logging module, which writes to stderr, and create one logger per module with logging.getLogger(__name__), as the example does.

For servers that use an HTTP transport, standard output logging does not interfere with responses, but keeping one logging approach is simpler.

Test with MCP Inspector

MCP Inspector is the reference developer tool for testing and debugging MCP servers. It runs through npx with no installation. Pass the command that starts your server as the arguments:

npx @modelcontextprotocol/inspector uv run server.py

The command prints a URL that carries a per-launch API token and opens it in your browser. Connect, open the tools list, and you should see search_tickets with its status parameter. Run it with open and confirm that two tickets come back.

The SDK also has its own shortcut. The README lists mcp dev to launch the Inspector with a server file:

uv run mcp dev server.py

For automation, the Inspector has a CLI mode that lists tools and exits:

npx @modelcontextprotocol/inspector --cli uv run server.py --method tools/list

That form follows the Inspector documentation, which lists tools with --cli ... --method tools/list. It is useful in a CI job that fails when a tool disappears or changes its schema.

Be aware that the Inspector stores secrets, such as OAuth tokens and environment values for stdio servers, in the operating system keychain when one exists. Where there is none, it writes them to ~/.mcp-inspector/secrets.json unencrypted unless you supply a key. Do not put real credentials in a test configuration on a shared machine.

Test from Python code

You can also exercise a server from a script with the SDK's Client class. The README shows this against a server running over Streamable HTTP. Start the server with the HTTP transport:

uv run mcp run server.py --transport streamable-http

The README's client example connects to http://localhost:8000/mcp after the server is started this way; check the address that the server prints on startup if yours differs. Then call the tool from a second script:

import asyncio
 
from mcp import Client
 
 
async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        result = await client.call_tool("search_tickets", {"status": "open"})
        print(result.structured_content)
 
 
asyncio.run(main())

The README example prints result.structured_content for a tool that returns an integer. Inspect result in your own run to see how a list of dictionaries is represented, because the documentation does not show that shape.

Connect the server to VS Code

VS Code reads MCP server definitions from .vscode/mcp.json in a workspace, or from an mcp.json in your user profile, which you can open with the MCP: Open User Configuration command. The VS Code format uses a top-level servers object, not mcpServers. A local server needs type set to stdio and a command, and may have args, cwd and env.

{
  "servers": {
    "tickets": {
      "type": "stdio",
      "command": "uv",
      "args": ["--directory", "/absolute/path/to/tickets", "run", "server.py"]
    }
  }
}

Use an absolute path for the project directory. VS Code documents that the command must be on your system path or given as a full path, so if uv is not found, put its full path in command. Local MCP servers can run code on your machine, so review the command before you start one, and keep credentials out of the file; VS Code supports input variables for secrets.

Connect the server to Claude for Desktop

Claude for Desktop uses a different file and key. Edit claude_desktop_config.json and add the server under mcpServers. The documented locations are ~/Library/Application Support/Claude/claude_desktop_config.json on macOS, ~/.config/Claude/claude_desktop_config.json on Linux as shown in the quickstart, and $env:AppData\Claude\claude_desktop_config.json on Windows.

{
  "mcpServers": {
    "tickets": {
      "command": "uv",
      "args": ["--directory", "/ABSOLUTE/PATH/TO/tickets", "run", "server.py"]
    }
  }
}

Save the file and restart Claude for Desktop. The quickstart notes that you may need the full path to uv in command, which you can find with which uv on macOS and Linux or where uv on Windows. On Windows, use double backslashes or forward slashes in JSON paths.

Troubleshooting

SymptomLikely causeFix
Client shows the server as failed or disconnectedOutput written to stdout, often a stray print()Remove it and log with logging
uv or command not found in the clientThe client does not use your shell pathUse the full path to uv
Server starts in a terminal but not in the clientRelative path in the configUse an absolute project path
Tool does not appear in InspectorDecorator missing or the file is not the one launchedConfirm @mcp.tool() and the command you passed
ImportError for MCPServerOlder SDK installedUpgrade the package and check the SDK migration guide
Inspector will not startNode older than 22.19.0Upgrade Node

Design tips for tools that models call

Keep each tool narrow and give parameters clear types, because the SDK derives the schema from them. Describe what the tool returns and what values are valid in the docstring, as with the status argument above. Return data that is already safe to show: the client may pass anything a tool returns into a model's context, so filter out fields that the user should not expose. If a tool changes data, make that obvious in its name and description, since the protocol relies on user approval for tool calls.

Checklist

  • Python 3.10 or higher, the mcp package at the version the quickstart requires, and uv installed.
  • Tools registered with @mcp.tool() and resources with @mcp.resource() and a URI template.
  • No print() in a stdio server; log through logging.
  • Tested with MCP Inspector or the SDK Client before connecting an AI client.
  • VS Code config under servers with type: stdio; Claude for Desktop config under mcpServers.
  • Absolute paths and, where needed, the full path to uv.

For related reading on tool calling from the model side, see Claude Tool Use in Python: Build an Agent Loop.

References

Questions people ask

Why does my stdio MCP server break when I add a print statement?

A stdio server talks to the client over standard output using JSON-RPC messages. print() also writes to standard output, which corrupts those messages. Use the logging module, which writes to stderr.

What is the difference between an MCP tool and a resource?

A tool is a function the model can call, with user approval, to do something or fetch computed data. A resource is file-like data that a client can read, such as file contents or an API response, addressed by a URI.

How do I test an MCP server without an AI client?

Run it through MCP Inspector with npx @modelcontextprotocol/inspector followed by the command that starts your server, or write a small script with the SDK's Client class. Inspector needs Node 22.19.0 or newer.

Which transport should I use, stdio or Streamable HTTP?

Use stdio for a server that the client launches on the same machine, such as in VS Code or Claude for Desktop. Use Streamable HTTP when the server runs as a separate service that clients reach over the network.

MCPPythonClaudeVS CodeMCP Inspector
  1. LLM API Timeouts, Retries and Rate Limits: A Python Reliability Guide

    Make Claude and OpenAI API calls in Python resilient: set timeouts, retry only the right errors, honor retry-after on 429, and tell spend limits from rate limits.

    AI engineering10 min read
  2. LLM Model Routing: Send Easy Requests to a Cheaper Model

    Build a simple Python router that sends each request to a small or large Claude model, then measure cost and quality against an eval set before you ship it.

    AI engineering12 min read
  3. Migrate from OpenAI Chat Completions to the Responses API: What Changes

    Port Chat Completions code to the OpenAI Responses API: new request shape, typed output items, multi-turn state, function calling, structured outputs and streaming.

    AI engineering9 min read
  4. Getting Started with LLM Evals: A Small-Team Setup with Code

    Build your first LLM eval in an afternoon: write 30 test cases in a JSONL file, grade them with simple code, add an LLM grader for tone, and track the score between prompt changes.

    AI engineering9 min read
  5. Semantic Search With Azure OpenAI Embeddings in Python: A Minimal Example

    Build a small semantic search in Python with Azure OpenAI embeddings: split text into chunks, embed them in batches, and rank results by cosine similarity.

    AI engineering8 min read
  6. Claude Message Batches API: Cut Bulk Job Costs in Python

    Run large asynchronous Claude jobs at 50 percent of standard API prices with the Message Batches API: Python create, poll and results code, limits and fixes.

    AI engineering9 min read