…
Skip to content
Topics
On this page

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/list and tools/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.
Messages between an MCP client and the issue tracker serverA sequence diagram with two lifelines, the MCP client inside the host and the issue_server.py process, connected over stdin and stdout. The client sends initialize and the server replies with its protocol version and capabilities. The client sends tools/list and receives the search_issues tool with its input schema. The client sends tools/call for search_issues and receives the matching issue ENG-101. The issue data is illustrative.MCP client (host)issue_server.pystdin / stdoutinitializeprotocolVersion, capabilitiestools/listsearch_issues + inputSchematools/call search_issuesENG-101: Checkout returns 500
Messages between an MCP client and the issue tracker server

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.

Bash
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.0

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

Python
# 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:

Example
ENG-102: Slow query on orders table
No matching issues
ENG-103: Login page typo
  • Filtering: The status argument 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() and err(): Build JSON-RPC 2.0 success and error responses with the request's id.
  • TOOLS: Maps each tool name to its function, description and input schema.
Python
# 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

Example
{"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 tools capability.
  • Notifications: The notifications/initialized message has no id, so the server deliberately returns nothing for it.
  • Results: The search returns a content list with one text block and isError set 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.

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

Bash
npx @modelcontextprotocol/inspector python issue_server.py

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

JSON
{
  "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):

Example
ENG-101: Checkout returns 500 on empty cart

Common Errors

  • ModuleNotFoundError: No module named 'mcp': The virtual environment is inactive or the host uses a different interpreter, so activate it and point command at the environment's Python.
  • spawn python ENOENT or a similar "command not found" message: The host cannot locate the executable, so replace python with 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 with print(..., file=sys.stderr) or the logging module.
  • Unknown tool (code -32602): The requested name does not match a registered tool, so compare it with the tools/list result.
  • 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

Quick Quiz

Pick an answer to check yourself. Nothing is saved.

Question 1 / 3

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