The Model Context Protocol follows a specific flow of messages between client and server. Understanding this flow is helpful for debugging issues and for implementing MCP servers.
What Happens: The MCP client (Claude Code) discovers and connects to your server.
Process:
- Client reads configuration file (
claude_desktop_config.json) - Client finds server command and arguments
- Client launches server process
- Client establishes stdio communication channel
Example Configuration:
{
"mcpServers": {
"simple-mcp-debug": {
"command": "python",
"args": ["c:\\path\\to\\simple_mcp_server\\server.py"],
"cwd": "c:\\path\\to\\project"
}
}
}Debug Points:
- Server must start without errors
- Server must accept stdio input/output
- Server must implement proper JSON-RPC 2.0 protocol
What Happens: Client discovers what tools are available on the server.
Message Flow:
Client → Server: list_tools() request
Server → Client: list_tools() response with tool definitions
Actual Protocol Messages:
Request:
{
"jsonrpc": "2.0",
"method": "list_tools",
"params": {},
"id": 1
}Response:
{
"jsonrpc": "2.0",
"result": [
{
"name": "hello_world",
"description": "A simple greeting tool",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name to greet"
}
},
"required": []
}
}
],
"id": 1
}Implementation in Our Server:
@self.server.list_tools()
async def handle_list_tools() -> List[types.Tool]:
"""Handle tool discovery requests"""
logger.info("📋 Client requesting tool list")
tools = get_all_tools()
logger.info(f"📋 Returning {len(tools)} tools")
return toolsCommon Issues:
- Server not responding to
list_tools()requests - Invalid tool schema causing client errors
- Missing required fields in tool definitions
What Happens: Client executes a specific tool with parameters.
Message Flow:
Client → Server: call_tool(name, arguments) request
Server → Client: call_tool() response with results
Actual Protocol Messages:
Request:
{
"jsonrpc": "2.0",
"method": "call_tool",
"params": {
"name": "hello_world",
"arguments": {
"name": "Claude"
}
},
"id": 2
}Response:
{
"jsonrpc": "2.0",
"result": [
{
"type": "text",
"text": "Hello, Claude! 👋\n\nGenerated at: 2025-07-13T10:30:00"
}
],
"id": 2
}Implementation in Our Server:
@self.server.call_tool()
async def handle_call_tool(name: str, arguments: dict) -> List[types.TextContent]:
"""Handle tool execution requests"""
logger.info(f"🔧 Client requesting tool execution: {name}")
logger.info(f"🔧 Arguments: {arguments}")
# Route to appropriate handler
if name == "hello_world":
result = await handle_hello_world(arguments)
else:
raise ValueError(f"Unknown tool: {name}")
# Format response
return [types.TextContent(type="text", text=result)]Every MCP message follows JSON-RPC 2.0 format:
Request Format:
{
"jsonrpc": "2.0", // Protocol version
"method": "method_name", // Method to call
"params": {...}, // Method parameters
"id": 123 // Request ID for matching responses
}Response Format:
{
"jsonrpc": "2.0", // Protocol version
"result": {...}, // Method result (success)
"id": 123 // Matches request ID
}Error Format:
{
"jsonrpc": "2.0", // Protocol version
"error": { // Error object
"code": -32000, // Error code
"message": "Error description",
"data": {...} // Additional error data
},
"id": 123 // Matches request ID
}- Purpose: Tool discovery
- Parameters: None
- Returns: Array of tool definitions
- When Called: Client startup, tool refresh
- Purpose: Tool execution
- Parameters:
name(string),arguments(object) - Returns: Array of content objects
- When Called: User invokes a tool
- Purpose: Prompt template discovery
- Parameters: None
- Returns: Array of prompt definitions
- When Called: Client startup, prompt refresh
- Purpose: Prompt template retrieval
- Parameters:
name(string),arguments(object) - Returns: Prompt result object
- When Called: User requests a prompt
Our server includes comprehensive protocol tracing:
def log_mcp_message(message_type: str, direction: str, data: dict):
"""Log MCP protocol messages for debugging"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"message_type": message_type,
"direction": direction,
"data": data
}
logger.info(f"MCP {message_type} {direction}: {json.dumps(log_entry)}")Tool Discovery:
[10:30:00] MCP list_tools request: {"params": {}}
[10:30:00] MCP list_tools response: {"tool_count": 5}
Tool Execution:
[10:30:15] MCP call_tool request: {"tool_name": "hello_world", "arguments": {"name": "Test"}}
[10:30:15] MCP call_tool response: {"success": true, "response_length": 45}
Symptoms:
- Client can't discover tools
- No response to
list_tools()requests - Connection timeouts
Debug Steps:
- Check server startup logs
- Verify server is listening on stdio
- Test server independently
Solution:
# Add startup logging
logger.info("🌟 Server starting up...")
logger.info("📊 Server ready to accept connections")
# Test with our test suite
python tests/test_server.pySymptoms:
- Tools discovered but not usable
- Client errors when trying to call tools
- Schema validation failures
Debug Steps:
- Validate tool schema format
- Check required vs optional parameters
- Verify parameter types
Solution:
# Validate tool definitions
def validate_tool_schema(tool):
required_fields = ["name", "description", "inputSchema"]
for field in required_fields:
if not hasattr(tool, field):
raise ValueError(f"Tool missing required field: {field}")Symptoms:
- Tools discoverable but fail when called
- Error responses from server
- Timeout or crash during execution
Debug Steps:
- Add logging to tool handlers
- Validate input parameters
- Check error handling
Solution:
async def handle_call_tool(name: str, arguments: dict):
try:
# Log request
logger.info(f"Executing tool: {name}")
# Validate arguments
validate_arguments(name, arguments)
# Execute tool
result = await execute_tool(name, arguments)
# Log success
logger.info(f"Tool execution successful: {name}")
return result
except Exception as e:
# Log error
logger.error(f"Tool execution failed: {name}, error: {str(e)}")
raiseFor long-running operations, MCP supports streaming:
# Stream results as they become available
async def handle_streaming_tool():
for chunk in process_data():
yield types.TextContent(type="text", text=chunk)MCP can handle file and resource references:
# Return file references
return [types.EmbeddedResource(
type="resource",
resource={
"uri": "file:///path/to/file.txt",
"mimeType": "text/plain"
}
)]Graceful Error Handling:
try:
result = await risky_operation()
return [types.TextContent(type="text", text=result)]
except ValidationError as e:
return [types.TextContent(type="text", text=f"Validation Error: {str(e)}")]
except Exception as e:
logger.error(f"Unexpected error: {str(e)}")
return [types.TextContent(type="text", text="Internal server error")]async def test_list_tools_protocol():
"""Test tool discovery protocol"""
# Simulate client request
tools = await handle_list_tools()
# Verify response format
assert isinstance(tools, list)
assert all(hasattr(tool, 'name') for tool in tools)
assert all(hasattr(tool, 'description') for tool in tools)
assert all(hasattr(tool, 'inputSchema') for tool in tools)
async def test_call_tool_protocol():
"""Test tool execution protocol"""
# Simulate client request
result = await handle_call_tool("hello_world", {"name": "Test"})
# Verify response format
assert isinstance(result, list)
assert all(hasattr(item, 'type') for item in result)
assert all(hasattr(item, 'text') for item in result)# Test complete protocol flow
async def test_full_protocol_flow():
# 1. Tool discovery
tools = await handle_list_tools()
assert len(tools) > 0
# 2. Tool execution
for tool in tools:
# Test with minimal valid arguments
result = await handle_call_tool(tool.name, {})
assert result is not None- Cache tool definitions
- Minimize tool list size
- Use efficient data structures
- Validate arguments early
- Use async operations for I/O
- Implement timeouts for long operations
- Fail fast on invalid inputs
- Provide clear error messages
- Log errors for debugging
After understanding protocol flow:
- Learn about Tool Registration (Chapter 3)
- Understand Authentication (Chapter 4)
- Implement Error Handling (Chapter 5)
- Set up Debugging (Chapter 6)
- MCP follows a request/response pattern using JSON-RPC 2.0
- Tool discovery happens first, then tool execution
- Proper logging is essential for debugging protocol issues
- Error handling must be implemented at every protocol level
- Testing protocol handlers independently helps isolate issues
This chapter covered the complete MCP protocol flow. The next chapter will focus on how tools are registered and managed within the server.