# 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.

Source: https://www.epifive.com/blog/model-context-protocol-mcp-server-python-tutorial · Published 2026-10-25 · EpiFive

## Key takeaways

- An MCP server exposes tools (callable functions), resources (readable data) and prompts to any MCP client over stdio or HTTP.
- With the official Python SDK you register a tool by decorating a typed function with @mcp.tool and a resource with @mcp.resource and a URI template.
- A stdio server must never write to standard output, so use the logging module instead of print.
- Test with MCP Inspector or the SDK Client before you connect an AI client, so you can see the exact tool schema and results.
- In VS Code a local server is declared under the servers key in .vscode/mcp.json with type stdio, a command and args.

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:

| Capability | What it is | Example |
| --- | --- | --- |
| Resources | File-like data that clients can read | A ticket record, a file, an API response |
| Tools | Functions that the model can call, with user approval | Search tickets, create a note |
| Prompts | Pre-written templates that help users complete a task | A "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:

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

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

```powershell
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

```bash
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.

```python
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:

```bash
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:

```bash
uv run mcp dev server.py
```

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

```bash
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:

```bash
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:

```python
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`.

```json
{
  "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.

```json
{
  "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

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Client shows the server as failed or disconnected | Output written to stdout, often a stray `print()` | Remove it and log with `logging` |
| `uv` or `command not found` in the client | The client does not use your shell path | Use the full path to `uv` |
| Server starts in a terminal but not in the client | Relative path in the config | Use an absolute project path |
| Tool does not appear in Inspector | Decorator missing or the file is not the one launched | Confirm `@mcp.tool()` and the command you passed |
| `ImportError` for `MCPServer` | Older SDK installed | Upgrade the package and check the SDK migration guide |
| Inspector will not start | Node older than 22.19.0 | Upgrade 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](https://www.epifive.com/blog/claude-tool-use-python-agent-loop).

## References

- Build an MCP server, Model Context Protocol documentation: https://modelcontextprotocol.io/docs/develop/build-server
- MCP Inspector: https://modelcontextprotocol.io/docs/tools/inspector
- Python SDK repository and README: https://github.com/modelcontextprotocol/python-sdk
- Add and manage MCP servers in VS Code: https://code.visualstudio.com/docs/copilot/customization/mcp-servers
- MCP configuration reference for VS Code: https://code.visualstudio.com/docs/agents/reference/mcp-configuration

## Frequently asked questions

### 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.

