Build an MCP Server in Python
An MCP server in Python is a program that exposes tools, resources or prompts to AI applications through the Model Context Protocol, receiving JSON-RPC 2.0 requests and returning structured results. The MCP server example below builds an issue tracker server twice: first as a standard-library teaching model of the protocol, then with the official MCP Python SDK.
- Logic: An ordinary Python function, here a search over issue titles, that performs the actual operation.
- Handler: Protocol code that parses each JSON-RPC message and dispatches
initialize,tools/listandtools/call. - Schema: A JSON Schema specification of each tool's arguments, which the model reads before invoking it.
- Transport: The stdio channel, where the host launches the server as a subprocess and exchanges one JSON message per line.
- Registration: A configuration entry that tells an MCP host application how to start the server process.
For example, the finished server lets an AI assistant search the engineering team's issue tracker for open issues that mention "checkout" and receive the matching issue identifiers.
Part 1 (Steps 1 and 2) uses only the standard library and shows every protocol message explicitly; it is a teaching model, not a complete implementation. Part 2 (Steps 3 and 4) builds the production version with the SDK and registers it in a host.
Prerequisites
- Python: Any recent Python 3 release is suitable, although the examples were tested with version 3.11.
- Fundamentals: Familiarity with the concepts in what is MCP and the roles described in MCP architecture.
- Background: A general understanding of how a language model requests actions through tool calling.
- Host: For Part 2, a desktop assistant, code editor or agent framework that supports local MCP servers.
Setup
Part 1 requires no installation, whereas Part 2 requires a virtual environment with the pinned SDK version installed.
python3.11 --version
mkdir issue-mcp && cd issue-mcp
# Part 2 only
python3.11 -m venv .venv
source .venv/bin/activate
pip install mcp==1.12.0Step 1: Write the Tool Logic
The tool is written and tested as an ordinary function before any protocol code exists, which makes the logic straightforward to reuse in both implementations.
# Step 1: the tool logic, written and tested as a plain function first.
ISSUES = [
{"id": "ENG-101", "title": "Checkout returns 500 on empty cart", "status": "open"},
{"id": "ENG-102", "title": "Slow query on orders table", "status": "open"},
{"id": "ENG-103", "title": "Login page typo", "status": "closed"},
]
def search_issues(query, status="open"):
hits = [i for i in ISSUES if query.lower() in i["title"].lower() and i["status"] == status]
return "\n".join(f"{i['id']}: {i['title']}" for i in hits) or "No matching issues"
print(search_issues("orders"))
print(search_issues("typo"))
print(search_issues("typo", status="closed"))Output:
ENG-102: Slow query on orders table
No matching issues
ENG-103: Login page typo- Filtering: The
statusargument defaults to open, so closed issues appear only when requested explicitly. - Readability: The function returns plain text, which is the simplest content type for a language model to interpret.
Step 2: Build the MCP Server in Python with the Standard Library
Save the program as issue_server_stdlib.py. A real stdio server reads each message from sys.stdin; this version reads from a list, so the exchange is visible and repeatable.
handle(): Parses one line, ignores notifications, and routes each method to a result or an error.ok()anderr(): Build JSON-RPC 2.0 success and error responses with the request'sid.TOOLS: Maps each tool name to its function, description and input schema.
# issue_server_stdlib.py: a teaching model of an MCP server, not a complete
# implementation. It reads one JSON-RPC 2.0 message per line and writes one
# response per line, the way a server on the stdio transport does.
import json
ISSUES = [
{"id": "ENG-101", "title": "Checkout returns 500 on empty cart", "status": "open"},
{"id": "ENG-102", "title": "Slow query on orders table", "status": "open"},
{"id": "ENG-103", "title": "Login page typo", "status": "closed"},
]
def search_issues(query, status="open"):
hits = [i for i in ISSUES if query.lower() in i["title"].lower() and i["status"] == status]
return "\n".join(f"{i['id']}: {i['title']}" for i in hits) or "No matching issues"
TOOLS = {
"search_issues": {
"fn": search_issues,
"description": "Search issue titles in the tracker.",
"inputSchema": {"type": "object",
"properties": {"query": {"type": "string"},
"status": {"type": "string", "enum": ["open", "closed"]}},
"required": ["query"]},
},
}
def ok(msg_id, result):
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
def err(msg_id, code, message):
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
def handle(line):
try:
msg = json.loads(line)
except json.JSONDecodeError:
return err(None, -32700, "Parse error")
if "id" not in msg:
return None # notifications get no response
method, params = msg.get("method"), msg.get("params", {})
if method == "initialize":
return ok(msg["id"], {"protocolVersion": "2025-06-18",
"capabilities": {"tools": {}},
"serverInfo": {"name": "issue-tracker", "version": "0.1.0"}})
if method == "tools/list":
return ok(msg["id"], {"tools": [{"name": n, "description": t["description"],
"inputSchema": t["inputSchema"]} for n, t in TOOLS.items()]})
if method == "tools/call":
tool = TOOLS.get(params.get("name"))
if tool is None:
return err(msg["id"], -32602, f"Unknown tool: {params.get('name')}")
text = tool["fn"](**params.get("arguments", {}))
return ok(msg["id"], {"content": [{"type": "text", "text": text}], "isError": False})
return err(msg["id"], -32601, f"Method not found: {method}")
# In a real stdio server these lines arrive on sys.stdin from the client.
incoming = [
'{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test-client", "version": "1.0"}}}',
'{"jsonrpc": "2.0", "method": "notifications/initialized"}',
'{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}',
'{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "search_issues", "arguments": {"query": "checkout"}}}',
'{"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "delete_issue", "arguments": {}}}',
'{"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": ',
]
for line in incoming:
response = handle(line)
if response is not None:
print(json.dumps(response))Output
{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "issue-tracker", "version": "0.1.0"}}}
{"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "search_issues", "description": "Search issue titles in the tracker.", "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}, "status": {"type": "string", "enum": ["open", "closed"]}}, "required": ["query"]}}]}}
{"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "ENG-101: Checkout returns 500 on empty cart"}], "isError": false}}
{"jsonrpc": "2.0", "id": 4, "error": {"code": -32602, "message": "Unknown tool: delete_issue"}}
{"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "Parse error"}}- Handshake: The first response declares the protocol version and a
toolscapability. - Notifications: The
notifications/initializedmessage has noid, so the server deliberately returns nothing for it. - Results: The search returns a
contentlist with one text block andisErrorset to false. - Errors: An unknown tool returns -32602 (invalid params), and a truncated message returns -32700 (parse error) with a null
id.
Step 3: Build the Server with the MCP Python SDK
The SDK's FastMCP class manages the handshake, message parsing, schema generation and transport, so the server reduces to a few decorated functions. Save this as issue_server.py with the virtual environment active.
# issue_server.py: the issue tracker server with the official MCP Python SDK.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("issue-tracker")
ISSUES = [
{"id": "ENG-101", "title": "Checkout returns 500 on empty cart", "status": "open"},
{"id": "ENG-102", "title": "Slow query on orders table", "status": "open"},
{"id": "ENG-103", "title": "Login page typo", "status": "closed"},
]
@mcp.tool()
def search_issues(query: str, status: str = "open") -> str:
"""Search issue titles in the tracker. status is "open" or "closed"."""
hits = [i for i in ISSUES if query.lower() in i["title"].lower() and i["status"] == status]
return "\n".join(f"{i['id']}: {i['title']}" for i in hits) or "No matching issues"
@mcp.resource("issues://{issue_id}")
def get_issue(issue_id: str) -> str:
"""One issue as text, readable by the host as a resource."""
issue = next((i for i in ISSUES if i["id"] == issue_id), None)
return f"{issue['id']} [{issue['status']}] {issue['title']}" if issue else "Not found"
@mcp.prompt()
def triage_issue(issue_id: str) -> str:
"""A reusable prompt template for triaging one issue."""
return f"Read issue {issue_id}, check recent errors for the affected service and suggest a severity."
if __name__ == "__main__":
mcp.run() # stdio transport by default- Schemas: The SDK automatically derives each input schema from the type hints and uses the docstring as the description.
- Primitives: The server exposes one tool, one resource template and one prompt, the three capability types MCP defines.
- Transport:
mcp.run()uses stdio unless another transport, such as streamable HTTP, is configured.
The server can be tested interactively with the MCP Inspector before a host is configured.
npx @modelcontextprotocol/inspector python issue_server.pyStep 4: Register the Server in an MCP Host
A Python MCP server is registered like any other local server: most desktop hosts read a JSON configuration that maps each server name to the command that starts it, and absolute paths are necessary because the host does not start in the project directory.
{
"mcpServers": {
"issue-tracker": {
"command": "/absolute/path/issue-mcp/.venv/bin/python",
"args": ["/absolute/path/issue-mcp/issue_server.py"]
}
}
}After a restart, the host lists search_issues among its available tools, and a request such as "find open checkout issues" then produces a tools/call message like the one in Step 2.
Illustrative result shown to the model (the host's presentation varies):
ENG-101: Checkout returns 500 on empty cartCommon Errors
ModuleNotFoundError: No module named 'mcp': The virtual environment is inactive or the host uses a different interpreter, so activate it and pointcommandat the environment's Python.spawn python ENOENTor a similar "command not found" message: The host cannot locate the executable, so replacepythonwith an absolute interpreter path.Parse error(code -32700) or a host that shows no tools: Something wrote non-protocol text to stdout, so send diagnostic output to stderr withprint(..., file=sys.stderr)or theloggingmodule.Unknown tool(code -32602): The requested name does not match a registered tool, so compare it with thetools/listresult.- Server missing after editing the configuration: The JSON contains a syntax error, such as a trailing comma, or the host has not been restarted.
Next Steps
- Expansion: Wrap the PostgreSQL database, using a read-only account, and the logs service as additional servers, following MCP architecture.
- Hardening: Validate arguments and treat every tool output as untrusted text, as explained in prompt injection.
- Comparison: Decide when a dedicated server is worth building with MCP vs API vs function calling.
- Collaboration: Examine how agents delegate work to each other in A2A protocol vs MCP, or build an agent that uses these tools in the Claude Agent SDK tutorial.
Quick Quiz
Pick an answer to check yourself. Nothing is saved.
Question 1 / 3
1. In the standard-library server, which method name does a client use to run a tool?
Frequently Asked Questions
Which Python package is used to create an MCP server?
The official MCP Python SDK is published on PyPI and includes a decorator-based server API. The standard library alone is enough for a teaching model, but the SDK handles the full protocol and transports.
Why must an MCP server on stdio not print to stdout?
On the stdio transport, standard output carries the protocol messages. Any extra text corrupts the stream, so diagnostic output belongs on standard error or in a log file.
How do I test an MCP server without an AI assistant?
Send JSON-RPC messages to it directly, as the standard-library example does, or connect an interactive inspector that lists tools and calls them with chosen arguments.
Can an MCP server in Python run remotely?
Yes. A server can use the streamable HTTP transport instead of stdio, which allows several users to reach it over a network. Remote servers also need authentication.
Related Articles
- What is MCP (Model Context Protocol)Learn what the Model Context Protocol (MCP) is: hosts, clients and servers, tools and resources, JSON-RPC messages, and a Python teaching model of MCP.
- MCP Architecture: Host, Client, ServerUnderstand MCP architecture: how hosts, clients and servers divide the work, the data and transport layers, and a Python trace of one tool request.
- MCP vs API vs Function CallingMCP vs API vs function calling explained: the layer each one works at, a comparison table, when to use each, and one issue tracker search done three ways.
- Tool Calling (Function Calling) in LLMLearn how tool calling works in LLMs: JSON Schema tool definitions, model tool calls, argument validation and tool results, with a Python DevOps example.
- A2A Protocol vs MCPA2A protocol vs MCP: agent-to-agent delegation versus agent-to-tool calls, a comparison table, when to use each, and an incident example that uses both.
- Claude Agent SDK TutorialClaude Agent SDK tutorial: build an incident triage agent in Python with custom tools, an in-process MCP server and an approval check before rollbacks.