Comprehensive guide for developing Model Context Protocol (MCP) servers...
Complete guide for developing Model Context Protocol (MCP) servers with Python, covering server setup, tool implementation, error handling, and testing.
Model Context Protocol (MCP) enables AI agents to securely access tools and data sources via JSON-RPC 2.0 over stdio.
Key Components:
import asyncio
import sys
from typing import Any, Optional
from pathlib import Path
try:
from mcp.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
import mcp.server.stdio
import mcp.types as types
except ImportError:
print("Error: mcp package is not installed. Please install it with: pip install mcp", file=sys.stderr)
sys.exit(1)
# Initialize MCP server
server = Server("your-server-name")
Critical: Always handle import errors gracefully. MCP servers run in isolated environments where dependencies might be missing.
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
"""List available tools"""
return [
types.Tool(
name="tool_name",
description="Clear description of what the tool does",
inputSchema={
"type": "object",
"properties": {
"param1": {
"type": "string",
"description": "Parameter description"
},
"param2": {
"type": "string",
"description": "Optional parameter",
"default": "default_value"
}
},
"required": ["param1"] # List required parameters
}
)
]
Best Practices:
@server.call_tool()
async def handle_call_tool(name: str, arguments: Optional[dict[str, Any]]) -> list[types.TextContent]:
"""Handle tool calls"""
# Validate tool name
if name != "tool_name":
raise ValueError(f"Unknown tool: {name}")
# Validate required arguments
if not arguments or "param1" not in arguments:
raise ValueError("Missing required argument: param1")
param1 = arguments["param1"]
if not isinstance(param1, str):
raise ValueError("param1 must be a string")
# Get optional arguments with defaults
param2 = arguments.get("param2", "default_value")
try:
# Your tool logic here
result = perform_operation(param1, param2)
# Return result as TextContent
return [types.TextContent(type="text", text=str(result))]
except Exception as e:
# Wrap errors in RuntimeError for clear error messages
error_msg = f"Error processing {param1}: {str(e)}"
print(error_msg, file=sys.stderr)
raise RuntimeError(error_msg) from e
Error Handling:
RuntimeError for user-facing errorsFor stdio mode (most common):
async def main_async():
"""Async main entry point for the MCP server"""
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="your-server-name",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)
def main():
"""Main entry point (synchronous wrapper)"""
asyncio.run(main_async())
if __name__ == "__main__":
main()
For SSE (Server-Sent Events) mode:
See SSE Mode Implementation section below.
Create __main__.py to enable python -m package.mcp_server:
"""Enable running as python -m package.mcp_server"""
from .mcp_server import main
if __name__ == "__main__":
main()
# Global cache
resource_cache: dict[str, Resource] = {}
def get_resource(key: str = "default") -> Resource:
"""Get or create resource with caching"""
global resource_cache
if key not in resource_cache:
resource_cache[key] = Resource(key)
return resource_cache[key]
from pathlib import Path
# Validate file exists
file_path = Path(arguments["file_path"])
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
if not file_path.is_file():
raise ValueError(f"Path is not a file: {file_path}")
import tempfile
import os
# Create temporary file
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.jpg')
temp_path = temp_file.name
temp_file.close()
try:
# Use temp file
process_file(temp_path)
finally:
# Always clean up
try:
if os.path.exists(temp_path):
os.unlink(temp_path)
except Exception:
pass # Ignore cleanup errors
# Return multiple text contents
results = []
for item in processed_items:
results.append(types.TextContent(type="text", text=str(item)))
return results
import pytest
from unittest.mock import MagicMock, patch
from paddleocr_cli.mcp_server import handle_list_tools, handle_call_tool
@pytest.mark.asyncio
async def test_list_tools():
"""Test tool listing"""
tools = await handle_list_tools()
assert len(tools) == 1
assert tools[0].name == "tool_name"
assert "param1" in tools[0].inputSchema["properties"]
@pytest.mark.asyncio
async def test_call_tool_success():
"""Test successful tool call"""
with patch("module.external_dependency") as mock_dep:
mock_dep.return_value = "expected_result"
result = await handle_call_tool("tool_name", {"param1": "value"})
assert len(result) == 1
assert result[0].type == "text"
@pytest.mark.asyncio
async def test_call_tool_validation():
"""Test input validation"""
with pytest.raises(ValueError, match="Missing required argument"):
await handle_call_tool("tool_name", {})
Testing Best Practices:
pytest-asyncio for async testsimport asyncio
from mcp_server import handle_call_tool, handle_list_tools
async def test_e2e():
# Test tool listing
tools = await handle_list_tools()
print(f"Tools: {[t.name for t in tools]}")
# Test tool execution
result = await handle_call_tool("tool_name", {"param1": "test_value"})
print(f"Result: {result}")
asyncio.run(test_e2e())
Cause: Tool name mismatch between list_tools and call_tool.
Solution:
@server.call_tool() handler checks tool nameCause: Client didn't provide required parameter.
Solution:
inputSchema["required"] includes all mandatory parametersCause: Dependencies not installed in MCP server environment.
Solution:
sys.exit(1)pyproject.tomlCause: Server not properly configured for stdio.
Solution:
mcp.server.stdio.stdio_server() context managerserver.run() is called with proper streamsprint() for normal output (use stderr for errors only)types.TextContent or appropriate content typesCause: Arguments don't match expected types.
Solution:
isinstance() checksstr() for paths)Always validate inputs
Handle errors gracefully
RuntimeError for operational errorsUse async/await correctly
@pytest.mark.asyncio for async testsCache expensive resources
Clean up resources
Test thoroughly
Document clearly
project/
├── package/
│ ├── __init__.py # Package version
│ ├── __main__.py # Enable python -m package.mcp_server
│ └── mcp_server.py # Main MCP server implementation
├── tests/
│ └── test_mcp_server.py # Unit tests
├── pyproject.toml # Package metadata, dependencies
└── README.md # Documentation
Essential imports:
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationOptions
import mcp.server.stdio
import mcp.types as types
Tool definition:
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
return [types.Tool(name="...", description="...", inputSchema={...})]
Tool handler:
@server.call_tool()
async def handle_call_tool(name: str, arguments: Optional[dict]) -> list[types.TextContent]:
# Validate and process
return [types.TextContent(type="text", text="result")]
Main entry:
async def main_async():
async with mcp.server.stdio.stdio_server() as (read, write):
await server.run(read, write, InitializationOptions(...))
SSE (Server-Sent Events) mode allows MCP servers to run over HTTP, enabling remote access and web integration. This is useful for:
import asyncio
from aiohttp import web
from aiohttp_sse import sse_response
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationOptions
import mcp.types as types
# Initialize MCP server (same as stdio mode)
server = Server("your-server-name")
# Define tools (same as stdio mode)
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
# ... same implementation as stdio mode
pass
@server.call_tool()
async def handle_call_tool(name: str, arguments: Optional[dict[str, Any]]) -> list[types.TextContent]:
# ... same implementation as stdio mode
pass
# SSE endpoint handler
async def sse_handler(request: web.Request) -> web.StreamResponse:
"""Handle SSE connection for MCP communication"""
async with sse_response(request) as response:
# Create read/write streams from SSE
read_stream = request.content
write_stream = response
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="your-server-name",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)
return response
# HTTP server setup
async def create_app() -> web.Application:
"""Create aiohttp application with SSE endpoint"""
app = web.Application()
# Add CORS middleware for cross-origin requests
from aiohttp_cors import setup as cors_setup, ResourceOptions
cors = cors_setup(app, defaults={
"*": ResourceOptions(
allow_credentials=True,
expose_headers="*",
allow_headers="*",
allow_methods="*"
)
})
# Register SSE endpoint
app.router.add_get("/sse", sse_handler)
# Health check endpoint
async def health_check(request: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
app.router.add_get("/health", health_check)
return app
def main():
"""Start HTTP server with SSE support"""
app = asyncio.run(create_app())
web.run_app(app, host="0.0.0.0", port=8000)
if __name__ == "__main__":
main()
Add to pyproject.toml:
[project]
dependencies = [
"mcp",
"aiohttp>=3.9.0",
"aiohttp-sse>=0.8.0",
"aiohttp-cors>=0.7.0",
]
For MCP clients using SSE mode:
{
"mcpServers": {
"your-server": {
"url": "http://localhost:8000/sse"
}
}
}
| Feature | stdio Mode | SSE Mode |
|---|---|---|
| Communication | Standard input/output | HTTP with Server-Sent Events |
| Use Case | Local, same-machine | Remote, web-based |
| Setup | Simple (no HTTP server) | Requires HTTP server |
| Network | No network required | Requires network access |
| CORS | Not applicable | May need CORS configuration |
| Security | Process isolation | Need authentication/HTTPS |
Choose stdio mode when:
Choose SSE mode when:
Use HTTPS in production
import ssl
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain('cert.pem', 'key.pem')
web.run_app(app, host="0.0.0.0", port=8000, ssl_context=ssl_context)
Add authentication
async def sse_handler(request: web.Request) -> web.StreamResponse:
# Check authentication token
token = request.headers.get('Authorization')
if token != f"Bearer {expected_token}":
raise web.HTTPUnauthorized()
# ... rest of handler
Handle connection errors gracefully
async def sse_handler(request: web.Request) -> web.StreamResponse:
try:
async with sse_response(request) as response:
# ... server.run()
except asyncio.CancelledError:
# Client disconnected
pass
except Exception as e:
print(f"SSE error: {e}", file=sys.stderr)
raise
Add request logging
from aiohttp import web
async def logging_middleware(app, handler):
async def middleware_handler(request):
print(f"{request.method} {request.path}")
return await handler(request)
return middleware_handler
app.middlewares.append(logging_middleware)
Common patterns: See references/common-patterns.md for:
Template code:
scripts/template_mcp_server.py - Complete stdio mode starter templatescripts/template_mcp_server_sse.py - Complete SSE mode starter templateKey lessons from real development: