[Python MCP Servers: A Practical Guide to Connecting Claude and Cursor to Real Systems]
Analyze with AI
Get AI-powered insights from this Mad Devs tech article:
The Model Context Protocol (MCP) is the standard way AI agents connect to external tools. Claude Code, Cursor, Codex CLI, and a growing number of agentic development tools support MCP. When an agent needs to check a Jira ticket, query a database, or call an internal API, MCP is how the request reaches your code and how the response comes back.
For a backend developer, this is useful for one specific reason: you do not need to build a custom integration layer for every agent that wants to communicate with your systems. You build one MCP server, expose it over stdio or HTTP, and every MCP-compatible client can discover and call your tools with typed inputs and structured responses. MCP handles discovery and message exchange; the Python SDK generates tool schemas from your type hints and docstrings automatically.
This guide walks through building a model context protocol MCP server Python project that connects AI agents to real backend services: from a minimal working example to production concerns like auth, timeouts, error handling, and observability. The code uses FastMCP (part of the official MCP Python SDK), which reduces the protocol layer to decorators on top of regular Python functions.
Why MCP is a useful layer for your Python backend
If you're unsure whether MCP is the right layer for your use case, see "When MCP is the right tool" at the end of this guide.
Before MCP, connecting an AI agent to your internal API meant one of two approaches: either you gave the agent raw HTTP access and a text description of your endpoints (fragile, no schema enforcement, no discoverability), or you built a custom tool-calling layer specific to the agent you were integrating with (works but vendor-locked and hard to maintain across agents).
MCP replaces both with a protocol that separates concerns cleanly.
- The agent (client) handles prompt construction, tool selection, and conversation flow. It does not need to know how your API authenticates or what your database schema looks like.
- Your code (server) exposes tools with typed inputs, descriptions, and structured outputs. It does not decide what to do next — the agent makes that decision. The server exposes capabilities; the agent chooses when to call them.
- The protocol handles discovery (the agent asks "what tools are available?"), schema (each tool declares its input parameters as JSON Schema, generated automatically from Python type hints), transport (stdio for local tools, streamable HTTP for remote), and error codes (JSON-RPC 2.0 standard error categories).
The practical result for a backend developer: the same Python MCP server you build for Claude Code also works with Cursor, with VS Code Copilot, with ChatGPT desktop, and with any custom agent using the MCP client SDK. You maintain one integration point, not one per agent.
MCP's three primitives. Every MCP server exposes some combination of:
- Tools — functions the agent can call. They have side effects: creating a ticket, running a query, and sending a notification. The agent chooses when to call a tool based on the user's request and the tool's description.
- Resources — read-only data the agent can pull into context. A resource is addressed by URI (
config://database/prod,docs://api/users). Think of resources as things the agent reads, not things it executes. - Prompts — reusable templates that structure how the agent interacts with a specific domain. Less commonly used in backend integrations, but useful for complex workflows like incident response or code review.
For most backend integrations, tools are the primary primitive. The examples in this guide focus on tools, with resources where they add value.
How an MCP server fits between the agent and your APIs
The request lifecycle looks like this:
User: "What's the status of PROJ-1234?"
Agent (Claude / Cursor / etc.)
│
├─ 1. Discovers available tools via MCP initialize + tools/list
├─ 2. Selects "get_ticket" based on description and user intent
├─ 3. Sends tools/call with { "ticket_id": "PROJ-1234" }
│
▼
MCP Server (your Python code)
│
├─ 4. Validates input (Pydantic / type hints)
├─ 5. Calls Jira REST API with your service account credentials
├─ 6. Formats response as structured content
│
▼
Agent receives structured result
│
└─ 7. Incorporates into response: "PROJ-1234 is In Review, assigned to..."Two transport modes. MCP servers connect to agents over one of two transports:
stdio — the agent launches your server as a subprocess and communicates via stdin/stdout. This is how Claude Code, Claude Desktop, and Cursor connect to local MCP servers. No network exposure, but the server runs with the local user's privileges and environment variables. Treat it as local code execution, not as a security boundary.
Streamable HTTP — the server runs as a standalone HTTP service. The agent connects over HTTP with JSON-RPC messages. This is for remote or shared MCP servers — a team-wide Jira integration, a production monitoring tool, and a shared database query layer. Streamable HTTP supports OAuth 2.1 for authentication.
Most backend integrations start with stdio (fastest to build and test), then move to streamable HTTP when the server needs to be shared across a team or deployed as a service.
Minimal MCP server in Python: a useful example, not a toy
This Python MCP server example connects an agent to a REST API, a common integration pattern in backend systems. The server exposes two tools: one to fetch a resource, one to create a resource.
Tested environment
Python 3.12
mcp 1.9.x (pip install "mcp[cli]")
httpx 0.28.x
uv 0.5.xNote on SDK versions. The MCP Python SDK and FastMCP evolve quickly. The code below is tested against these versions. If @mcp.tool() or lifespan APIs change in a future release, consult the Python SDK changelog.
Setup
Here is how to build an MCP server Python project from scratch using the official SDK:
# Create a project with uv (recommended by the MCP Python SDK)
uv init mcp-backend
cd mcp-backend
# Add the MCP SDK and httpx for HTTP calls
uv add "mcp[cli]" httpx
# Or with pip:
# pip install "mcp[cli]" httpxThe server
# server.py
"""
MCP server that connects an AI agent to a project management API.
Exposes two tools:
- get_ticket: fetch a ticket by ID
- create_ticket: create a new ticket
And one resource:
- project://config: returns the current project configuration
"""
import os
import httpx
from mcp.server.fastmcp import FastMCP
# Initialize the server.
# The name appears in the agent's tool list — make it descriptive.
mcp = FastMCP("project-tracker")
# Base URL and auth for your internal API
API_BASE = os.environ.get("TRACKER_API_URL", "https://api.tracker.internal")
API_TOKEN = os.environ.get("TRACKER_API_TOKEN", "")
def _api_headers() -> dict[str, str]:
return {
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json",
}
@mcp.tool()
async def get_ticket(ticket_id: str) -> dict:
"""
Fetch a ticket by ID from the project tracker.
Returns the ticket's title, status, assignee, and description.
Use this when the user asks about a specific ticket like PROJ-1234.
"""
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{API_BASE}/tickets/{ticket_id}",
headers=_api_headers(),
)
response.raise_for_status()
ticket = response.json()
# Return only what the agent needs — not the full API response.
# Less data in the response means less token usage in the agent's context.
return {
"id": ticket["id"],
"title": ticket["title"],
"status": ticket["status"],
"assignee": ticket.get("assignee", {}).get("name", "Unassigned"),
"description": ticket.get("description", ""),
}
@mcp.tool()
async def create_ticket(
title: str,
description: str,
project: str = "PROJ",
priority: str = "medium",
) -> dict:
"""
Create a new ticket in the project tracker.
Use this when the user asks to file a bug, create a task, or log an issue.
The project defaults to PROJ. Priority can be: low, medium, high, critical.
"""
payload = {
"title": title,
"description": description,
"project": project,
"priority": priority,
}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{API_BASE}/tickets",
headers=_api_headers(),
json=payload,
)
response.raise_for_status()
created = response.json()
return {
"id": created["id"],
"url": f"{API_BASE.replace('/api', '')}/tickets/{created['id']}",
"status": "created",
}
@mcp.resource("project://config")
async def get_project_config() -> str:
"""
Current project configuration and available projects.
The agent can read this to understand which projects exist
and what statuses/priorities are valid.
"""
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{API_BASE}/config",
headers=_api_headers(),
)
response.raise_for_status()
return response.text
if __name__ == "__main__":
# Default: stdio transport (for local use with Claude Code, Cursor, etc.)
# For Streamable HTTP (remote/shared deployment):
# mcp.run(transport="streamable-http")
# For production HTTP, mount the MCP app into an ASGI server (uvicorn/gunicorn)
# and configure host/port there. See: Python SDK Streamable HTTP docs.
mcp.run(transport="stdio")What FastMCP does here. The @mcp.tool() decorator registers the function as an MCP tool, generates a JSON Schema from the type hints and docstring, and handles JSON-RPC serialization. The docstring is critical: the agent reads it to decide when to call the tool. A vague docstring leads to the agent calling the wrong tool or not calling it at all. Write the docstring as if you are explaining to a colleague when to use this function.
Connecting to Claude Code
Here is how to add an MCP server to Claude Code. The Claude Code MCP server configuration lives in your project's .claude/settings.json or can be set via the CLI:
# Add the server to Claude Code (project-scoped)
claude mcp add project-tracker -- uv run server.py
# Or with environment variables:
claude mcp add project-tracker \
-e TRACKER_API_URL=https://api.tracker.internal \
-e TRACKER_API_TOKEN=your-token-here \
-- uv run server.py
# Verify it's registered:
claude mcp list
# CLI syntax evolves; run `claude mcp add --help` if any command above fails.Claude Code launches the server as a subprocess when a session starts. The server appears in the tool list, and the agent can call get_ticket and create_ticket based on user requests.
Connecting to Cursor
Cursor uses a .cursor/mcp.json file in the project root. Configuration paths and field names may change between Cursor versions; verify against your installed version.
{
"mcpServers": {
"project-tracker": {
"command": "uv",
"args": ["run", "server.py"],
"env": {
"TRACKER_API_URL": "https://api.tracker.internal",
"TRACKER_API_TOKEN": "your-token-here"
}
}
}
}Connecting to other agents
The same server works with any MCP-compatible client.
Codex CLI uses a TOML configuration. Add to your Codex config:
# Codex CLI — stdio transport
[mcp_servers.project_tracker]
command = "uv"
args = ["run", "server.py"]
env = { TRACKER_API_URL = "https://api.tracker.internal", TRACKER_API_TOKEN = "your-token" }
enabled_tools = ["get_ticket", "create_ticket"]
tool_timeout_sec = 20
startup_timeout_sec = 10# Codex CLI — HTTP transport (for shared/remote servers)
[mcp_servers.project_tracker]
url = "https://mcp.example.com/mcp"
bearer_token_env_var = "MCP_TRACKER_TOKEN"The enabled_tools field is a useful safety lever: restrict which tools a given client can call, even if the server exposes more. Codex CLI MCP server configuration paths and field names may vary by version; check codex --help or the Codex MCP server setup documentation for the current reference.
Testing with the MCP Inspector
# The MCP Inspector is a standalone debugging tool.
# Launch syntax depends on Inspector version. Common patterns:
npx -y @modelcontextprotocol/inspector uv run server.py
# If the above fails, run the Inspector standalone and connect manually:
# npx -y @modelcontextprotocol/inspectorThe Inspector opens a browser UI where you can list tools, call them with test inputs, and inspect the JSON-RPC messages. This catches docstring issues, schema problems, and runtime errors before the agent ever sees the server.
Adapting the pattern for other backends
The project tracker example generalizes to most backend integrations. The tool structure stays the same; the API client and response shape change.
| INTEGRATION | TOOL EXAMPLES |
|---|---|
| Jira | get_issue, create_issue, list_sprint_tickets |
| GitLab | get_merge_request, list_pipeline_jobs |
| PostgreSQL | run_readonly_query |
| Internal REST | get_customer_status, trigger_deploy |
For database integrations, never give the MCP server write access through the same tool that runs read queries. Use a separate tool with a separate database role for mutations, and gate it behind explicit user confirmation in the agent flow.
Talking to real services: auth, timeouts, and error responses
The minimal example works for a demo. Production backend integrations need four things that the demo lacks: centralized HTTP client management, structured error handling, timeouts that match the agent's expectations, and input validation.
Lifespan management for HTTP clients
Opening a new httpx.AsyncClient on every tool call works, but wastes connections. Use FastMCP's lifespan hook to create a shared client that lives for the server's lifetime:
from contextlib import asynccontextmanager
from mcp.server.fastmcp import FastMCP, Context
import httpx
@asynccontextmanager
async def lifespan(server: FastMCP):
"""Create a shared HTTP client that persists across tool calls."""
async with httpx.AsyncClient(
base_url=os.environ["TRACKER_API_URL"],
headers=_api_headers(),
timeout=httpx.Timeout(10.0, connect=5.0),
) as client:
yield {"api_client": client}
mcp = FastMCP("project-tracker", lifespan=lifespan)
@mcp.tool()
async def get_ticket(ticket_id: str, ctx: Context) -> dict:
"""Fetch a ticket by ID from the project tracker."""
# Access lifespan state via Context.
# The exact access pattern (ctx.request_context, ctx.lifespan_context, etc.)
# depends on your SDK version. Check your installed version's docs.
api_client: httpx.AsyncClient = ctx.request_context["api_client"]
response = await api_client.get(f"/tickets/{ticket_id}")
response.raise_for_status()
return response.json()Error handling that the agent can act on
When a tool call fails, the agent needs a clear, structured error — not a Python traceback. MCP tools signal errors by raising exceptions or returning error content. FastMCP catches exceptions and returns them as tool errors in the protocol.
from mcp.server.fastmcp import Context
@mcp.tool()
async def get_ticket(ticket_id: str, ctx: Context) -> dict:
"""Fetch a ticket by ID from the project tracker."""
client: httpx.AsyncClient = ctx.request_context["api_client"]
try:
response = await client.get(f"/tickets/{ticket_id}")
response.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
# Return a clear message the agent can relay to the user
raise ValueError(f"Ticket {ticket_id} not found")
if e.response.status_code == 403:
raise PermissionError(
f"Access denied for ticket {ticket_id}. "
"The service account may lack permissions for this project."
)
raise RuntimeError(
f"Tracker API returned {e.response.status_code}. "
"Check server logs for details."
)
except httpx.TimeoutException:
raise TimeoutError(
"Tracker API did not respond within 10 seconds. "
"The service may be experiencing high load."
)
return response.json()When a tool call fails, the agent needs a clear, structured error — not a Python traceback. MCP tools signal errors by raising exceptions or returning error content. FastMCP catches exceptions and returns them as tool errors in the protocol. Do not include raw response bodies in error messages returned to the agent — they may contain internal service names, PII, or error details that should stay in your logs. Log the full error server-side; return a summary to the agent.
The principle: the error message should be something a human would understand if the agent passes it through to the user. "ConnectionError: [Errno 111] Connection refused" is not useful. "The tracker API is not reachable — check if the service is running."
Input validation
FastMCP generates JSON Schema from type hints, but the agent can still send values that pass the schema but are semantically wrong. Validate early:
import re
@mcp.tool()
async def get_ticket(ticket_id: str, ctx: Context) -> dict:
"""
Fetch a ticket by ID (format: PROJ-1234).
"""
# Validate format before making an API call
if not re.match(r"^[A-Z]+-\d+$", ticket_id):
raise ValueError(
f"Invalid ticket ID format: '{ticket_id}'. "
"Expected format: PROJ-1234 (uppercase project key, dash, number)."
)
# ... rest of implementationBasic safety and observability so your server survives day 2
Logging
MCP servers running over stdio cannot write to stdout (it is reserved for JSON-RPC messages). Log to stderr or a file:
import logging
import sys
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stderr, # stdout is the MCP transport — never log there
)
logger = logging.getLogger("project-tracker")
@mcp.tool()
async def get_ticket(ticket_id: str, ctx: Context) -> dict:
"""Fetch a ticket by ID from the project tracker."""
logger.info("get_ticket called: %s", ticket_id)
# ...Rate limiting
If the agent calls a tool in a loop (which it can — imagine "get all tickets for this sprint" triggering 30 individual calls), your backend API may throttle or block you. Add client-side rate limiting:
import asyncio
# Simple semaphore-based concurrency limit
_api_semaphore = asyncio.Semaphore(5) # max 5 concurrent API calls
@mcp.tool()
async def get_ticket(ticket_id: str, ctx: Context) -> dict:
"""Fetch a ticket by ID from the project tracker."""
async with _api_semaphore:
client: httpx.AsyncClient = ctx.request_context["api_client"]
response = await client.get(f"/tickets/{ticket_id}")
response.raise_for_status()
return response.json()Principle of least privilege
Your MCP server's service account should have the minimum permissions the tools actually need. If the server only reads tickets, the API token should not have write access. If it creates tickets but never deletes them, the token should not have delete access.
For database-backed MCP servers, use a read-only database user for query tools. Create a separate connection with write access only for mutation tools, and log every mutation.
Start with read-only tools. Add mutation tools only after you have audit logging, scoped credentials, and a clear understanding of how the agent calls them. A read-only MCP server is immediately useful and carries minimal risk. A write-capable MCP server without audit logs is a security gap.
Mutation tools need extra controls
Tools that create, update, or delete resources are fundamentally different from read-only tools. For production:
- Separate read-only tools from mutation tools in your code — different functions, different credentials, different logging levels.
- Log every mutation tool call: tool name, input parameters (redacted as needed), caller identity if available, timestamp, result status.
- Consider idempotency keys for create operations: if the agent retries a
create_ticket call, you should not create two tickets.
Tool output redaction
Treat tool outputs as an untrusted context entering the agent's reasoning. Raw HTML, internal error messages, user-generated content from Jira/GitLab, and anything resembling instructions can influence the agent's behavior in unexpected ways.
Before returning data to the agent, redact secrets, PII, and raw markup. Return structured summaries, not full API responses. The less raw data in the agent's context window, the lower the risk of prompt injection through tool outputs — and the lower the token cost. For example, instead of returning a full Jira issue with comments, HTML descriptions, user emails, and metadata, the MCP server can return a compact summary: issue status, blocker, owner, and recommended next action. The agent still gets enough context to continue the workflow, but sensitive data and untrusted markup stay outside the context window.
Secrets management
Never hardcode tokens in server code. Environment variables are the minimum. For production deployments, pull from your secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager) during the server's lifespan initialization:
@asynccontextmanager
async def lifespan(server: FastMCP):
# Pull secrets at startup, not at import time
api_token = await get_secret("tracker-api-token")
async with httpx.AsyncClient(
base_url=os.environ["TRACKER_API_URL"],
headers={"Authorization": f"Bearer {api_token}"},
timeout=10.0,
) as client:
yield {"api_client": client}When MCP is the right tool, and when a simple webhook is enough
MCP is not the right tool for everything. Understanding where it fits saves you from over-engineering integrations that do not need it.
MCP fits when:
- The agent needs to discover and select from a set of tools at runtime. This is the core MCP value: the agent reads the tool list, reads the descriptions, and decides which tool to call based on the user's request. If tool selection is part of the flow, MCP is the right layer.
- The integration is interactive — the user asks a question, the agent calls a tool, and the response informs the next turn. Ticket lookup, database queries, API exploration, and deployment status checks. These are MCP-shaped problems.
- Multiple agents or clients need the same integration. Build the MCP server once, connect it to Claude Code, Cursor, and your custom internal agent without maintaining separate integrations.
A webhook or direct API call is better when:
- The action is fire-and-forget — a CI pipeline trigger, a Slack notification, a deployment webhook. There is no tool discovery, no runtime selection, no conversational context. A webhook is simpler and more reliable.
- The data flow is server-to-agent, not agent-to-server. MCP is a request/response protocol: the agent calls a tool and gets a result. If you need to push events to the agent (a new alert fired, a build completed), MCP is not the transport — use webhooks, WebSockets, or polling on the agent side.
- The integration is a single, well-known API call that does not benefit from discovery or schema. If the agent always calls the same function with the same parameters, the MCP layer adds protocol overhead without adding value. Call the API directly.
The decision in practice: if the user's prompt determines which function to call, the MCP. If the code path determines which function to call, direct API. If nothing calls anything, and events push to the agent, webhook.
What to take from this
A Python MCP server is a thin layer between the agent and your existing backend services. The core of the work is not the protocol — FastMCP handles that. The core is writing clear tool descriptions so the agent can choose the right tool, and returning error messages that a human can understand if the agent passes them through. After that, an MCP server needs the same backend discipline you already apply elsewhere: timeouts, rate limiting, input validation, and least-privilege credentials.
The setup that covers most backend integrations: FastMCP with @mcp.tool() decorators, httpx.AsyncClient in a lifespan for connection management, stdio transport for local development with Claude Code or Cursor, and streamable HTTP when the server needs to be deployed as a shared service.
The fastest path to a working MCP server:
uv inita project,uv add "mcp[cli]".- Write one tool function with a clear docstring.
- Test with the MCP Inspector (
npx @modelcontextprotocol/inspector uv run server.py). - Connect to Claude Code (
claude mcp add my-server -- uv run server.py). - Iterate on descriptions and error messages based on how the agent actually uses the tools.
MCP is still a young protocol, but its ecosystem is developing quickly. SDKs, clients, and reference implementations are already available, which makes it a practical integration layer for teams that need to connect agents to internal tools, APIs, and data sources without tying the architecture to a single agent interface.
