If you want to build MCP server Python tutorial projects that actually work in production, start with the official SDK and a clear understanding of the protocol’s request-response lifecycle. This tutorial walks through creating a functional MCP server that exposes both resources and tools, with runnable code at every step and checkpoints you can verify before moving on.
Prerequisites
You need Python 3.10 or newer and uv for dependency management. The MCP Python SDK requires anyio for async I/O, so make sure your environment supports it.
python --version
# Python 3.11.9
pip install uv
Create a new project directory and initialize it:
mkdir mcp-fileserver && cd mcp-fileserver
uv init --name mcp-fileserver
uv add mcp
The mcp package pulls in the SDK, which includes the server base classes, protocol types, and the stdio transport used by most clients today.
Project structure
Keep it flat for now. You’ll add a single server module and a small test script.
mcp-fileserver/
├── pyproject.toml
├── server.py
└── test_client.py
The server skeleton
Every MCP server implements three core capabilities: resources (read-only data), tools (executable functions), and prompts (templated interactions). Start with a minimal server that declares its capabilities and handles initialization.
# server.py
import asyncio
import json
import os
from pathlib import Path
from typing import Any
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import (
Resource,
Tool,
TextContent,
CallToolResult,
ReadResourceResult,
)
app = Server("fileserver")
ROOT = Path(os.getenv("MCP_ROOT", ".")).resolve()
@app.list_resources()
async def list_resources() -> list[Resource]:
"""List files under the configured root as resources."""
resources = []
for path in ROOT.rglob("*"):
if path.is_file():
rel = path.relative_to(ROOT)
resources.append(
Resource(
uri=f"file://{rel}",
name=str(rel),
description=f"File: {rel}",
mimeType="text/plain",
)
)
return resources
@app.read_resource()
async def read_resource(uri: str) -> ReadResourceResult:
"""Read a file resource by URI."""
if not uri.startswith("file://"):
raise ValueError(f"Unsupported URI scheme: {uri}")
rel_path = uri[7:] # strip "file://"
abs_path = (ROOT / rel_path).resolve()
# Security: prevent directory traversal
if not str(abs_path).startswith(str(ROOT)):
raise ValueError("Path traversal attempt blocked")
if not abs_path.exists() or not abs_path.is_file():
raise FileNotFoundError(f"Resource not found: {uri}")
content = abs_path.read_text(encoding="utf-8")
return ReadResourceResult(
contents=[TextContent(type="text", text=content)]
)
@app.list_tools()
async def list_tools() -> list[Tool]:
"""Declare available tools."""
return [
Tool(
name="write_file",
description="Write text content to a file under the server root",
inputSchema={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Relative path from root"},
"content": {"type": "string", "description": "Text content to write"},
},
"required": ["path", "content"],
},
),
Tool(
name="list_directory",
description="List entries in a directory",
inputSchema={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Relative path from root"},
},
"required": ["path"],
},
),
]
@app.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
"""Execute a tool by name."""
if name == "write_file":
return await _write_file(arguments)
elif name == "list_directory":
return await _list_directory(arguments)
else:
raise ValueError(f"Unknown tool: {name}")
async def _write_file(args: dict[str, Any]) -> CallToolResult:
path = args["path"]
content = args["content"]
abs_path = (ROOT / path).resolve()
if not str(abs_path).startswith(str(ROOT)):
return CallToolResult(
content=[TextContent(type="text", text="Path traversal blocked")],
isError=True,
)
abs_path.parent.mkdir(parents=True, exist_ok=True)
abs_path.write_text(content, encoding="utf-8")
return CallToolResult(
content=[TextContent(type="text", text=f"Wrote {len(content)} bytes to {path}")]
)
async def _list_directory(args: dict[str, Any]) -> CallToolResult:
path = args["path"]
abs_path = (ROOT / path).resolve()
if not str(abs_path).startswith(str(ROOT)):
return CallToolResult(
content=[TextContent(type="text", text="Path traversal blocked")],
isError=True,
)
if not abs_path.exists() or not abs_path.is_dir():
return CallToolResult(
content=[TextContent(type="text", text=f"Not a directory: {path}")],
isError=True,
)
entries = []
for entry in abs_path.iterdir():
entries.append(
{
"name": entry.name,
"type": "directory" if entry.is_dir() else "file",
"size": entry.stat().st_size if entry.is_file() else None,
}
)
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(entries, indent=2))]
)
async def main():
async with stdio_server() as (read_stream, write_stream):
await app.run(read_stream, write_stream, app.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())
Run the server to verify it starts without errors:
uv run python server.py
You should see no output — the server is waiting on stdio for a client connection. Press Ctrl+C to stop it.
Test client
The MCP inspector is the standard way to exercise a server during development, but a tiny script lets you verify the protocol flow programmatically.
# test_client.py
import asyncio
import json
from mcp.client.stdio import stdio_client
from mcp.client.session import ClientSession
from mcp.types import InitializeRequestParams
async def main():
# Launch the server as a subprocess
server_params = {
"command": "uv",
"args": ["run", "python", "server.py"],
}
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
# Initialize
init_result = await session.initialize(
InitializeRequestParams(
protocolVersion="2024-11-05",
capabilities={},
clientInfo={"name": "test-client", "version": "1.0.0"},
)
)
print(f"Initialized: {init_result.serverInfo.name} v{init_result.serverInfo.version}")
# List resources
resources = await session.list_resources()
print(f"\nResources ({len(resources.resources)}):")
for r in resources.resources:
print(f" {r.uri} — {r.name}")
# Read a resource if any exist
if resources.resources:
first_uri = resources.resources[0].uri
content = await session.read_resource(first_uri)
print(f"\nContent of {first_uri}:")
print(content.contents[0].text[:200] + ("..." if len(content.contents[0].text) > 200 else ""))
# List tools
tools = await session.list_tools()
print(f"\nTools ({len(tools.tools)}):")
for t in tools.tools:
print(f" {t.name}: {t.description}")
# Call write_file tool
write_result = await session.call_tool(
"write_file",
{"path": "hello.txt", "content": "Hello from MCP!"},
)
print(f"\nWrite result: {write_result.content[0].text}")
# Call list_directory tool
list_result = await session.call_tool("list_directory", {"path": "."})
print(f"\nDirectory listing:")
print(list_result.content[0].text)
if __name__ == "__main__":
asyncio.run(main())
Run it:
uv run python test_client.py
Expected output (abridged):
Initialized: fileserver v0.1.0
Resources (1):
file://hello.txt — hello.txt
Content of file://hello.txt:
Hello from MCP!
Tools (2):
write_file: Write text content to a file under the server root
list_directory: List entries in a directory
Write result: Wrote 16 bytes to hello.txt
Directory listing:
[
{
"name": "hello.txt",
"type": "file",
"size": 16
},
{
"name": "server.py",
"type": "file",
"size": 3421
},
{
"name": "test_client.py",
"type": "file",
"size": 1892
}
]
If you see this, your server correctly implements the initialization handshake, resource listing and reading, tool declaration, and tool execution.
Using the MCP inspector
The inspector provides a visual interface for exploring resources and invoking tools. Install it globally:
npm install -g @modelcontextprotocol/inspector
Then launch it pointed at your server:
npx @modelcontextprotocol/inspector uv run python server.py
A browser window opens at http://localhost:5173. Click “Connect” and you’ll see the Resources and Tools tabs populated with your server’s capabilities. You can read files, invoke write_file with custom arguments, and inspect the raw JSON-RPC messages in the “Messages” panel.
Adding prompts
Prompts let you expose templated interactions. Add a prompt that generates a file summary:
# Add to server.py, after the imports
from mcp.types import Prompt, PromptArgument, GetPromptResult, PromptMessage
@app.list_prompts()
async def list_prompts() -> list[Prompt]:
return [
Prompt(
name="summarize_file",
description="Generate a concise summary of a text file",
arguments=[
PromptArgument(
name="path",
description="Relative path to the file",
required=True,
)
],
)
]
@app.get_prompt()
async def get_prompt(name: str, arguments: dict[str, str] | None) -> GetPromptResult:
if name != "summarize_file":
raise ValueError(f"Unknown prompt: {name}")
path = arguments["path"]
abs_path = (ROOT / path).resolve()
if not str(abs_path).startswith(str(ROOT)):
raise ValueError("Path traversal blocked")
content = abs_path.read_text(encoding="utf-8")
return GetPromptResult(
description=f"Summary of {path}",
messages=[
PromptMessage(
role="user",
content=TextContent(
type="text",
text=f"Summarize this file in 3 sentences:\n\n{content}",
),
)
],
)
Restart the server and the inspector will show the new prompt under the Prompts tab. Selecting it prompts for the path argument and returns a message ready to send to an LLM.
Configuration via environment
The server uses MCP_ROOT to determine the filesystem root. This lets you run the same binary against different directories without code changes.
MCP_ROOT=/path/to/project uv run python server.py
In production, you’d typically run the server under a process manager (systemd, supervisord, or your container orchestrator) with the environment variable set in the unit file or pod spec.
Error handling patterns
The SDK raises exceptions for protocol errors, but tool implementations should return CallToolResult(isError=True) for expected failures — invalid arguments, missing files, permission errors. This distinction lets clients present actionable feedback to users rather than generic “tool failed” messages.
# Good: expected failure, client can handle gracefully
return CallToolResult(
content=[TextContent(type="text", text="File not found: config.yaml")],
isError=True,
)
# Bad: raising an exception crashes the request
raise FileNotFoundError("config.yaml")
For resource reads, raising FileNotFoundError or ValueError is appropriate — the SDK converts these to proper JSON-RPC error responses.
Running over HTTP
The stdio transport works for local development and subprocess spawning. For networked deployments, use the streamable HTTP transport:
# http_server.py
from mcp.server.streamable_http import streamable_http_server
from server import app # reuse the same app instance
if __name__ == "__main__":
import uvicorn
uvicorn.run(streamable_http_server(app), host="0.0.0.0", port=8000)
uv add uvicorn
uv run python http_server.py
Clients connect via POST http://host:8000/mcp with JSON-RPC 2.0 payloads. The transport handles session management and SSE for server-initiated messages.
Next steps
You now have a working MCP server that exposes filesystem resources and tools. From here, consider:
- Authentication: Add a middleware layer that validates tokens before dispatching to
app.run() - Rate limiting: Wrap tool handlers with a token bucket per client session
- Observability: Emit structured logs for each request/response pair; the SDK’s
LoggingCapabilitylets clients subscribe to server logs - Caching: For expensive resource reads, implement
ETagorLast-Modifiedsemantics and honorIf-None-Matchfrom clients
The protocol is deliberately minimal — resources, tools, and prompts cover most integration patterns. When you need to build MCP server Python tutorial projects that connect LLMs to your internal APIs, databases, or legacy systems, this foundation scales without rewriting the transport layer.