← Back to Blog

Build a Local MCP Server in Python and Wire It to Claude Code

Most agents hit a wall the moment they need to touch real data. They can write code, but reading the file you actually have open is a different problem. Until recently every IDE and every coding agent solved this with its own private plugin format. Switching tools meant rewriting the same integration.

The Model Context Protocol (MCP) is the attempt to fix that. Anthropic open-sourced it in late 2024, the spec is now maintained at modelcontextprotocol.io, and Python, TypeScript, Go, Rust, and Java SDKs all speak the same wire format. The mental model is a JSON-RPC server (the MCP server) that a host application (Claude Code, Cursor, Zed, Continue) launches and talks to over stdio or HTTP. You write a tool, the agent can call it.

This guide builds a real working server. By the end you will have a Python process that exposes a filesystem reader and a git status tool, and you will have wired it into Claude Code so the agent can call it. The whole thing is around 60 lines of Python.

Prerequisites

  • Python 3.10+ (the SDK requires it; check with python3 --version)
  • uv for dependency management (pip install uv if you don't have it)
  • Claude Code (npm install -g @anthropic-ai/claude-code) or Cursor 0.42+ with MCP support
  • Optional: Node 20+ if you want to use the MCP Inspector for debugging

Step 1: Set up the project

uv init mcp-fs-server
cd mcp-fs-server
uv add "mcp[cli]"

The [cli] extra pulls in the mcp command, which is what the Inspector and Claude Code call under the hood. The install lands in a project-local virtualenv; nothing touches your system Python.

Step 2: Write a minimal server

Create server.py:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("fs-tools")

@mcp.tool()
def read_file(path: str, max_lines: int = 200) -> str:
    """Read a text file and return its contents.

    Args:
        path: Absolute path to the file.
        max_lines: Cap on lines returned (default 200).
    """
    with open(path) as f:
        lines = f.readlines()
    return "".join(lines[:max_lines])

@mcp.tool()
def list_dir(path: str) -> list[str]:
    """List immediate children of a directory.

    Args:
        path: Absolute path to the directory.
    """
    import os
    return sorted(os.listdir(path))

if __name__ == "__main__":
    mcp.run()  # stdio by default

That is the whole server. FastMCP reads the type hints and the docstring and turns them into a JSON Schema the agent sees. The function name becomes the tool name. path: str becomes {type: "string"}. The Args block in the docstring becomes the parameter description. You do not write a single line of schema by hand.

The default transport is stdio, which is what Claude Code and Cursor expect when they spawn the server as a subprocess. The SDK also ships streamable-http and sse transports if you want to expose the server over the network.

Step 3: Test it with the MCP Inspector

Before you bolt it onto a real host, make sure the server actually speaks MCP correctly. The Inspector is the official debug tool.

In one terminal:

uv run server.py

In another:

npx -y @modelcontextprotocol/inspector

The Inspector UI will ask how to connect. Pick "stdio" and point it at the same uv run server.py command. You should see two tools appear: read_file and list_dir. Click list_dir, pass it /tmp, and confirm it returns real directory entries. If you see the JSON response come back correctly, your server is healthy.

If the Inspector cannot connect, the most common culprit is the uv PATH. The Inspector spawns the command in a fresh shell. Run which uv first and copy that full path into the command field if uv is not on the default PATH.

Step 4: Add a git tool

A filesystem reader by itself is not very interesting. The reason you want a real agent talking to a real project is so it can answer questions like "what is in this branch right now." Add a git status tool:

import subprocess

@mcp.tool()
def git_status(repo: str) -> str:
    """Get a short git status for a local repository.

    Args:
        repo: Absolute path to the repo working directory.
    """
    result = subprocess.run(
        ["git", "-C", repo, "status", "--short", "--branch"],
        capture_output=True,
        text=True,
        check=True,
    )
    return result.stdout

Two things to notice. The tool runs git as a subprocess and pipes the output back. There is no LLM in the loop inside the tool itself. MCP tools are just functions, and that is a feature, not a limitation. Heavy lifting should stay on the deterministic side.

The second thing is path handling. MCP tools receive JSON, which means everything is a string. os.path.expanduser, pathlib.Path.resolve, and explicit validation all matter more than they would in a normal Python function. The Inspector will happily send you "~/" and the OS will happily expand it for you. If you do not want that, you need to refuse the input.

Step 5: Wire it into Claude Code

Now the fun part. Add the server to Claude Code:

claude mcp add fs-tools -- uv --directory $(pwd) run server.py

That writes a block to ~/.claude.json (or the project-scoped .mcp.json) describing how to launch the server. Restart the Claude Code session. The tools should now appear in the tool picker, with the names read_file, list_dir, and git_status.

Try it:

> Use the fs-tools to check the status of my project at /home/me/code/foo.

Claude should call git_status, get back a porcelain status, and summarise it for you. If it does not, type /mcp inside the session to see whether the server connected. A red dot means the spawn failed. The first thing to check is the launch command: claude mcp get fs-tools shows what Claude Code is actually running.

Step 6: Lock down which paths the server can touch

A server that can read any file is fine for local development and terrifying for anything shared. The fix is a path allowlist applied inside the tool, not in the host:

from pathlib import Path

ALLOWED_ROOTS = [Path.home() / "code"]

def _safe(path: str) -> Path:
    resolved = Path(path).expanduser().resolve()
    if not any(resolved.is_relative_to(root) for root in ALLOWED_ROOTS):
        raise ValueError(f"path {resolved} is outside allowed roots")
    return resolved

@mcp.tool()
def read_file(path: str, max_lines: int = 200) -> str:
    """Read a text file under an allowed root.

    Args:
        path: Absolute path; must live under $HOME/code.
        max_lines: Cap on lines returned.
    """
    p = _safe(path)
    with p.open() as f:
        lines = f.readlines()
    return "".join(lines[:max_lines])

Path.is_relative_to landed in Python 3.9, and resolve() collapses symlinks, which is what you want for a security check. Once you have the allowlist pattern, copy it for every new tool. The lesson generalises: a tool that is "just a function" still needs the same input validation as a web endpoint, because the caller is an LLM that will absolutely try /etc/passwd.

When to use MCP vs alternatives

Use MCP when:

  • The same capability (database access, file system, internal API) needs to be reachable from multiple hosts (Claude Code, Cursor, Zed, custom agent) without rewriting.
  • You want the agent loop and the tool implementation to live in separate processes. MCP's stdio transport gives you that for free.
  • You are building for a team. One MCP server, every developer hooks it up once.

Skip MCP when:

  • The tool only needs to exist inside one agent framework. If you are already deep in LangGraph and the tool is never going to leave that graph, a plain Python function is simpler.
  • The data is already exposed over HTTP with a clean OpenAPI spec. The agent framework can usually consume that directly. Adding MCP in front of a REST API is mostly adding another moving part.
  • Latency is critical. Each tool call is at least one JSON-RPC round trip plus a model turn. For a tight inner loop you want the tool in the same process.

Common gotchas

  1. The default transport is stdio. If you start the server manually and see it print to stdout, that output is breaking the JSON-RPC stream. print debugging from inside a tool goes to the host's log, not yours. Use logging with a file handler, or use the Inspector.
  2. Tool names that contain dots get renamed. The SDK uses the function name as the tool name and replaces . with _. Pick plain function names.
  3. Docstrings matter. The first sentence becomes the tool description the agent sees. A vague description gets vague usage. A specific one ("Read a text file and return its contents") gets called for the right thing.
  4. The server process is owned by the host. When Claude Code exits, your server dies. That is fine for dev, but if you want the server to live longer than one host session, switch to the streamable-http transport and run it as a long-lived service.
  5. @mcp.resource and @mcp.prompt exist alongside @mcp.tool. Resources are read-only data the host can pull into context (think config files). Prompts are reusable message templates the user can trigger. You do not need them to ship a working server, but they are the right primitives when you outgrow plain tools.

Where to go next

  • Add structured logging. The SDK routes logging calls to the host's MCP logger, so a single logging.info call inside a tool becomes a span in Claude Code's trace view.
  • Swap stdio for Streamable HTTP once you have multiple hosts on different machines. The change is one argument on mcp.run(transport="streamable-http", host="0.0.0.0", port=8000).
  • Write a pytest suite. The SDK ships an in-memory transport, so you can test tools without spawning a process.
  • Look at the official example servers at github.com/modelcontextprotocol/servers. Filesystem, GitHub, Postgres, and Puppeteer are all reference implementations in the same shape as what you just built.

Need Help Implementing This?

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

Book a Free Consultation