Building an MCP server in Python for Claude Code is mostly a matter of defining a few safe tools, running them over stdio, and registering the server command correctly in Claude Code.
This walkthrough shows how to build a minimal MCP server step by step: project setup, tool definitions, transport, local testing, a concrete inspection flow, and Claude Code connection. If you want the big-picture background first, start with this overview of the Model Context Protocol (MCP).
An MCP server is a small process that exposes tools and context to Claude Code through a standard protocol.
An MCP server gives an AI client structured access to capabilities like file reading, test execution, API lookup, or internal automation. Instead of pasting context into chat manually, you let the client discover tools and call them with typed arguments.
In this tutorial, you will build two minimal tools:
ping— confirms the server is aliveread_file_summary— reads a text file from an allowed directory and returns a short structured summary
That is enough to learn the important MCP patterns without jumping straight into authentication, hosted deployments, or broad agent-style tools.
The core MCP building blocks
Most servers revolve around the same pieces:
- Server runtime: receives MCP requests and returns MCP responses
- Tools: functions the client can call
- Resources: readable content the client can inspect
- Transport: communication layer, commonly stdio or HTTP
- Schemas: typed inputs and outputs that make tool calls predictable
The local architecture in one glance
Claude Code
|
| MCP over stdio
v
Your MCP Server
|
+--> local files
+--> scripts
+--> APIs
If you are thinking in product terms, an MCP server is often the thin execution layer behind a reusable AI skill for developer workflows: a narrowly scoped set of tools that turns a model into something operationally useful.
The fastest way to start is to use Python, a current MCP SDK, and stdio transport.
For a first server, Python + stdio is usually the shortest path because it minimizes infrastructure and works well for local files, scripts, and development tooling.
The exact SDK helpers may evolve, but the workflow stays the same: install the SDK, define tools, run the server, and connect a client.
Python vs TypeScript and stdio vs HTTP
| Choice | Best for | Advantages | Tradeoffs |
|---|---|---|---|
| Python SDK | Scripts, file tools, data workflows | Fast prototyping, concise code | Environment setup can trip up teams |
| TypeScript SDK | Node tooling, editor extensions, JS stacks | Familiar npm ecosystem, strong typing | Slightly more setup for simple local tools |
| stdio transport | Local development, Claude Code | No web server, simple child-process model | Not meant for shared remote access |
| HTTP transport | Hosted internal services | Standard networking and deployment | Requires auth, routing, and more ops |
Recommended stack for this walkthrough
Use:
- Python
- a maintained MCP Python SDK
- stdio transport
- a local virtual environment
That gives you a minimal but realistic setup that mirrors how many developers first connect custom tools into Claude Code.
A minimal MCP server project only needs a virtualenv, a server file, and two tool definitions.
The exact import paths can change across SDK iterations, so treat the code below as the core pattern and adapt imports to the current docs if needed.
1) Create the project
mkdir my-mcp-server
cd my-mcp-server
python -m venv .venv
source .venv/bin/activate
Install the SDK and a common validation dependency:
pip install mcp pydantic
If the current SDK recommends a different package or extra, follow its install instructions instead.
2) Create a safe test directory and sample file
This gives your file-reading tool a fixed root and a known test target:
mkdir -p docs
cat > docs/example.txt <<'EOF'
Hello from MCP.
This is a sample file for local tool testing.
EOF
3) Add server.py
Create server.py:
from pathlib import Path
from typing import Dict, Any
import sys
# Adjust imports if the current SDK exposes these differently.
from mcp.server.fastmcp import FastMCP
app = FastMCP("my-mcp-server")
ALLOWED_ROOT = Path("./docs").resolve()
@app.tool()
def ping() -> Dict[str, Any]:
"""Return a simple health response."""
return {
"ok": True,
"message": "pong"
}
@app.tool()
def read_file_summary(path: str) -> Dict[str, Any]:
"""
Read a text file from the docs directory and return a preview.
"""
requested = (ALLOWED_ROOT / path).resolve()
if not str(requested).startswith(str(ALLOWED_ROOT)):
return {
"ok": False,
"error": "Path escapes allowed root"
}
if not requested.exists():
return {
"ok": False,
"error": f"File not found: {requested}"
}
if not requested.is_file():
return {
"ok": False,
"error": f"Not a file: {requested}"
}
text = requested.read_text(encoding="utf-8", errors="replace")
return {
"ok": True,
"path": str(requested),
"chars": len(text),
"preview": text[:500]
}
if __name__ == "__main__":
# Never write debugging logs to stdout under stdio transport.
print("Starting MCP server...", file=sys.stderr)
app.run()
4) Why this is a good minimal server
This example covers the core mechanics you will reuse in larger projects:
- tool registration
- structured return payloads
- input and path validation
- directory scoping
- stdio startup behavior
It is minimal, but not toy-like: the same design patterns apply when you later add test runners, API calls, repo inspection, or internal documentation tools.
Good MCP tools should be narrow, typed, and safe by default.
The best MCP tools do one thing clearly, accept limited inputs, and return structured outputs that the client can reason about reliably.
Tool design rules that improve model behavior
Prefer tools that are:
- single-purpose
- read-only when possible
- clearly named
- strictly validated
- small in scope
- predictable in output shape
Good examples:
list_repo_filesget_ticket_by_idrun_unit_testsread_file_summary
Risky examples:
do_taskfilesystem_agentsystem_toolrun_anything
Better vs worse tool definitions
| Better | Worse |
|---|---|
read_file_summary(path) |
filesystem_agent(action, payload) |
run_pytest(test_path) |
run_any_command(command) |
get_customer_by_id(id) |
query_database(sql) |
Why path restriction is non-negotiable
A file tool without a root restriction can expose sensitive files by accident. Since AI clients can chain and retry tool calls, you should assume unexpected inputs will happen.
The key guard is:
requested = (ALLOWED_ROOT / path).resolve()
if not str(requested).startswith(str(ALLOWED_ROOT)):
return {"ok": False, "error": "Path escapes allowed root"}
That blocks common traversal attempts like ../secret.txt.
Tool descriptions also matter
Models often choose tools based on name and description, so descriptions should explain:
- what the tool does
- when to use it
- what input it expects
- what limits apply
- what it returns
Good description:
Read a text file from the docs directory and return a preview and character count.
Weak description:
Read file.
Local testing should prove tool discovery, invocation, and failure handling before Claude Code enters the loop.
Before you wire up Claude Code, verify that your server starts cleanly, exposes tools, and returns useful errors when inputs are wrong.
1) Start the server manually
python server.py
With stdio, an apparently idle process is normal. It is waiting for an MCP client to connect.
2) Validate the file and environment first
These checks catch a lot of setup issues early:
python -m py_compile server.py
python -c "import mcp; print('mcp import ok')"
which python
If which python does not point into your .venv, activate the environment again.
3) Use an MCP inspector or test client for a real invocation flow
The most useful next step is to attach an MCP inspector or SDK-provided test client if your current SDK includes one. The exact command can vary by SDK release, but the flow is usually the same:
- Launch the inspector or test client.
- Point it at your server command.
- Confirm the tool list includes
pingandread_file_summary. - Invoke a valid tool call.
- Invoke an invalid call and verify the error is structured.
A typical command pattern looks like this:
/path/to/python server.py
Then inside the inspector or client UI/flow:
List tools
- Expected result:
ping,read_file_summary
Invoke ping
{}
Expected response:
{
"ok": true,
"message": "pong"
}
Invoke read_file_summary
{
"path": "example.txt"
}
Expected response shape:
{
"ok": true,
"path": "/absolute/path/to/docs/example.txt",
"chars": 59,
"preview": "Hello from MCP.\nThis is a sample file for local tool testing.\n"
}
Invoke an invalid path
{
"path": "../secret.txt"
}
Expected response:
{
"ok": false,
"error": "Path escapes allowed root"
}
The exact UI labels differ between inspector tools, but that sequence proves the three things that matter most: discovery, successful invocation, and safe rejection.
4) Avoid the most common stdio bug
Do not print logs to stdout. MCP messages usually travel over stdin/stdout, so stray output can break parsing.
Bad:
print("debugging")
Good:
import sys
print("debugging", file=sys.stderr)
5) What should pass before you continue
Verify all of the following:
- the server starts with no traceback
- the process stays alive
- the client can discover both tools
pingreturns structured JSONread_file_summary("example.txt")succeedsread_file_summary("../oops.txt")fails cleanly- logs go to stderr, not stdout
- no interactive prompt blocks startup
Claude Code connection works best when you register absolute paths for the virtualenv Python and server file.
Claude Code usually launches your MCP server as a subprocess, so the most reliable configuration uses an absolute interpreter path and absolute script path.
The exact config location may change over time, but the shape is typically similar to this.
Minimal Claude Code MCP config
{
"mcpServers": {
"my-mcp-server": {
"command": "/absolute/path/to/my-mcp-server/.venv/bin/python",
"args": ["/absolute/path/to/my-mcp-server/server.py"]
}
}
}
This is safer than using python from PATH, because it guarantees Claude Code runs the interpreter where you installed the MCP SDK.
Config with environment variables
{
"mcpServers": {
"my-mcp-server": {
"command": "/absolute/path/to/my-mcp-server/.venv/bin/python",
"args": ["/absolute/path/to/my-mcp-server/server.py"],
"env": {
"MY_APP_MODE": "development",
"LOG_LEVEL": "debug"
}
}
}
}
A practical Claude Code smoke test
After saving the config, restart or reload Claude Code and try prompts like:
- “What MCP tools are available?”
- “Call the
pingtool.” - “Use
read_file_summarywithexample.txt.” - “Try
read_file_summarywith../oops.txtand show the returned error.”
If Claude Code cannot see the tools, the issue is usually startup-related, not tool-definition-related.
The most common Claude Code setup failures
These are the usual culprits:
- using relative paths in
commandorargs - pointing to the wrong Python interpreter
- relying on shell aliases or shell startup files
- missing SDK installation in the selected
.venv - printing to stdout during startup
- assuming the working directory is your project root
- requiring secrets only available in your interactive shell
Resolve your paths explicitly first:
pwd
realpath .venv/bin/python
realpath server.py
Then paste those exact values into config.
A useful MCP server becomes production-ready when you add stronger guardrails, logging, and deployment choices.
A local toy server is easy; a dependable server for daily work needs tighter boundaries, better observability, and a cleaner rollout model.
Security upgrades to make early
Add these before exposing sensitive systems:
- keep tools narrow
- restrict filesystem roots
- avoid raw shell execution
- validate every parameter
- redact sensitive errors
- prefer read-only operations first
- add authentication if you move to networked transports
If you are considering a tool like run_command(command: str), stop and replace it with explicit tools such as git_status(), run_pytest(test_path), or read_ci_log(job_id).
Reliability upgrades that pay off fast
Add these next:
- request timeouts
- retries for flaky upstream APIs
- structured stderr logging
- correlation IDs
- unit tests for tool functions
- integration tests for server startup
- predictable error contracts
A good MCP server should fail in a way the client can reason about, not with silent exits or free-form stack traces.
When to keep stdio and when to move to HTTP
Keep stdio if you need:
- personal local workflows
- fast Claude Code integration
- minimal infrastructure
- tight edit-test loops
Move toward HTTP if you need:
- shared access across machines
- centralized deployment
- auth and policy layers
- standard service monitoring
For many developer use cases, stdio is not just the beginner option; it remains the right option.
Frequently Asked Questions
How do I debug tool discovery if Claude Code says no MCP tools are available?
First, confirm the server process actually starts by running the same command and args manually in your terminal. Then check for three common issues: wrong .venv interpreter, import failures, and accidental stdout logging that corrupts the MCP session.
Why does my server work in the terminal but fail inside Claude Code?
The usual reason is environment mismatch: Claude Code may launch a different Python binary, working directory, or environment than your shell. Fix this by using absolute paths for both .venv/bin/python and server.py, and by passing required environment variables explicitly in the MCP config.
What should I do if file paths behave inconsistently?
Assume the current working directory is not reliable and resolve paths from a known base. In practice, use Path(...).resolve(), log Path.cwd() to stderr during debugging, and prefer absolute config paths so your server starts from a predictable context.
Can I test my MCP tools before connecting Claude Code?
Yes: use an SDK-provided MCP inspector or test client to list tools and invoke them directly before Claude Code is involved. A good preflight test is to call ping, then read_file_summary with example.txt, then retry with ../secret.txt to confirm your success and error paths are both working.