brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
mcp-client-integrationIntegrates MCP clients using Python SDK v2 and TypeScript SDK v2 to connect to MCP servers, manage tools/resources/prompts, handle transport (stdio/SSE), error recovery, and implement structured calling conventions in AI agent applications.
Independent GitHub snapshot · Automated classification · Original documentation governs
Implement MCP clients that connect to MCP servers, discover tools/resources/prompts, handle transport layers (stdio, SSE), and invoke server capabilities with proper error recovery in AI agent applications.
Use this skill when:
Avoid this skill for:
mcp-server-fastmcp-python instead)subprocess module directly)Choose the appropriate transport layer based on server type:
Checkpoint: Server is running, endpoint/executable is accessible, credentials are configured.
List and cache:
Checkpoint: Tool catalog is populated, resource URIs are validated.
Call tools with proper:
Checkpoint: Tool invocation succeeds or raises descriptive error with context.
Maintain connection with:
Checkpoint: Client can reconnect automatically, resources are freed on exit.
Use this for local MCP servers running as subprocesses (e.g., mcp-server-filesystem, mcp-server-postgres).
import asyncio
import json
from mcp import ClientSession, StdioServerParameters
from anthropic import Anthropic
class MCPClientManager:
"""Manage MCP client connection and tool invocation."""
def __init__(self, server_path: str, server_args: list = None):
"""Initialize stdio-based MCP client.
Args:
server_path: Path to MCP server executable
server_args: Command-line arguments for server
Raises:
FileNotFoundError: If server executable doesn't exist
ValueError: If server_path is empty
"""
if not server_path:
raise ValueError("server_path cannot be empty")
self.server_path = server_path
self.server_args = server_args or []
self.session: ClientSession = None
self.tools_cache: dict = {}
async def connect(self) -> None:
"""Establish connection to MCP server via stdio.
Raises:
ConnectionError: If server fails to start or handshake fails
TimeoutError: If connection takes longer than 10 seconds
"""
params = StdioServerParameters(
command=self.server_path,
args=self.server_args
)
try:
self.session = await asyncio.wait_for(
ClientSession.create(params),
timeout=10.0
)
except asyncio.TimeoutError:
raise TimeoutError(f"MCP server {self.server_path} failed to start within 10s")
except Exception as e:
raise ConnectionError(f"Failed to connect to MCP server: {e}")
async def discover_tools(self) -> list[dict]:
"""Discover available tools from MCP server.
Returns:
List of tool definitions with name, description, input schema
Raises:
RuntimeError: If not connected to server
ValueError: If tool discovery fails
"""
if not self.session:
raise RuntimeError("Not connected. Call connect() first.")
try:
response = await self.session.list_tools()
self.tools_cache = {tool.name: tool for tool in response.tools}
return [
{
"name": tool.name,
"description": tool.description,
"input_schema": tool.inputSchema
}
for tool in response.tools
]
except Exception as e:
raise ValueError(f"Tool discovery failed: {e}")
async def invoke_tool(self, tool_name: str, arguments: dict) -> str:
"""Invoke a tool on the MCP server.
Args:
tool_name: Name of tool to invoke
arguments: Input arguments (must match tool's inputSchema)
Returns:
Tool result as JSON string
Raises:
ValueError: If tool not found or arguments invalid
TimeoutError: If invocation exceeds 30 seconds
RuntimeError: If server returns error response
"""
if not self.session:
raise RuntimeError("Not connected. Call connect() first.")
if tool_name not in self.tools_cache:
raise ValueError(f"Tool '{tool_name}' not found. Available: {list(self.tools_cache.keys())}")
try:
result = await asyncio.wait_for(
self.session.call_tool(tool_name, arguments),
timeout=30.0
)
return json.dumps(result.content)
except asyncio.TimeoutError:
raise TimeoutError(f"Tool invocation '{tool_name}' exceeded 30s timeout")
except Exception as e:
raise RuntimeError(f"Tool invocation failed: {tool_name}: {e}")
async def close(self) -> None:
"""Close MCP session gracefully.
Raises:
RuntimeError: If close fails (logs error, continues shutdown)
"""
if self.session:
try:
await self.session.close()
except Exception as e:
print(f"Warning: Error closing MCP session: {e}")
finally:
self.session = None
async def example_usage():
"""Example: Connect to filesystem server and list files."""
manager = MCPClientManager("/usr/local/bin/mcp-server-filesystem")
try:
await manager.connect()
tools = await manager.discover_tools()
print(f"Available tools: {[t['name'] for t in tools]}")
# Invoke 'list_directory' tool
result = await manager.invoke_tool(
"list_directory",
{"path": "/tmp"}
)
print(f"Directory listing: {result}")
finally:
await manager.close()
# Run example
if __name__ == "__main__":
asyncio.run(example_usage())
Use this for integrating MCP clients with Claude SDK in TypeScript agents.
import Anthropic from "@anthropic-ai/sdk";
interface ToolDefinition {
name: string;
description: string;
input_schema: Record<string, unknown>;
}
class MCPClientWithRetry {
private client: Anthropic;
private toolsCache: Map<string, ToolDefinition> = new Map();
private maxRetries: number = 3;
private retryDelayMs: number = 1000;
constructor(apiKey?: string) {
this.client = new Anthropic({
apiKey: apiKey || process.env.ANTHROPIC_API_KEY,
});
}
/**
* Discover and cache tools from MCP server.
* Caching reduces server load on repeated agent loops.
*/
async discoverTools(): Promise<ToolDefinition[]> {
// In real implementation, fetch from MCP server
// For demo, return mock tools
const tools: ToolDefinition[] = [
{
name: "get_weather",
description: "Get current weather for a location",
input_schema: {
type: "object",
properties: {
location: {
type: "string",
description: "City name",
},
},
required: ["location"],
},
},
];
tools.forEach((tool) => this.toolsCache.set(tool.name, tool));
return tools;
}
/**
* Invoke tool with exponential backoff retry logic.
* Handles transient failures gracefully.
*/
async invokeTool(
toolName: string,
arguments: Record<string, unknown>
): Promise<string> {
if (!this.toolsCache.has(toolName)) {
throw new Error(
`Tool '${toolName}' not found. Available: ${Array.from(this.toolsCache.keys()).join(", ")}`
);
}
let lastError: Error | null = null;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
// In real implementation, call actual MCP server
// For demo, simulate tool invocation
return await this.simulateToolCall(toolName, arguments);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Check if error is retryable
if (!this.isRetryable(lastError)) {
throw lastError;
}
// Exponential backoff: 1s, 2s, 4s
if (attempt < this.maxRetries) {
const delayMs = this.retryDelayMs * Math.pow(2, attempt - 1);
console.warn(
`Tool invocation failed (attempt ${attempt}/${this.maxRetries}), retrying in ${delayMs}ms: ${lastError.message}`
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
throw new Error(
`Tool invocation '${toolName}' failed after ${this.maxRetries} retries: ${lastError?.message}`
);
}
/**
* Classify errors as retryable (transient) or permanent.
*/
private isRetryable(error: Error): boolean {
const message = error.message.toLowerCase();
return (
message.includes("timeout") ||
message.includes("econnrefused") ||
message.includes("econnreset") ||
message.includes("503") ||
message.includes("service unavailable")
);
}
/**
* Simulate tool call (replace with actual MCP server invocation).
*/
private async simulateToolCall(
toolName: string,
arguments: Record<string, unknown>
): Promise<string> {
// Simulate network call
await new Promise((resolve) => setTimeout(resolve, 50));
return JSON.stringify({
tool: toolName,
input: arguments,
result: "Tool executed successfully",
});
}
/**
* Run agent loop with tool use.
* Demonstrates integration with Claude SDK.
*/
async runAgent(userMessage: string): Promise<string> {
const tools = await this.discoverTools();
const messages: Anthropic.MessageParam[] = [
{
role: "user",
content: userMessage,
},
];
// Convert tool definitions to Claude SDK format
const claudeTools: Anthropic.Tool[] = tools.map((tool) => ({
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
}));
let response = await this.client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
tools: claudeTools,
messages: messages,
});
// Process tool calls in agent loop
while (response.stop_reason === "tool_use") {
const toolUseBlock = response.content.find(
(block) => block.type === "tool_use"
) as Anthropic.ToolUseBlock | undefined;
if (!toolUseBlock) break;
const toolName = toolUseBlock.name;
const toolInput = toolUseBlock.input as Record<string, unknown>;
try {
const toolResult = await this.invokeTool(toolName, toolInput);
// Continue conversation with tool result
messages.push({
role: "assistant",
content: response.content,
});
messages.push({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: toolUseBlock.id,
content: toolResult,
},
],
});
response = await this.client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
tools: claudeTools,
messages: messages,
});
} catch (error) {
// Return error to Claude
const errorMsg = error instanceof Error ? error.message : String(error);
messages.push({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: toolUseBlock.id,
is_error: true,
content: errorMsg,
},
],
});
response = await this.client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
tools: claudeTools,
messages: messages,
});
}
}
// Extract final text response
const textBlock = response.content.find((block) => block.type === "text");
return textBlock && "text" in textBlock ? textBlock.text : "";
}
}
// Usage
const client = new MCPClientWithRetry();
client.runAgent("What's the weather in San Francisco?");
async def invoke_tool_bad(tool_name: str, args: dict) -> dict:
"""Dangerous: Silently fails, no clear error context."""
try:
result = await session.call_tool(tool_name, args)
return result.content
except:
# What error? What tool failed? No context.
return {} # Silent failure — caller has no idea what went wrong
# No timeout protection → can hang indefinitely
# No retry logic → transient failures break the agent
# No validation → malformed responses corrupt downstream logic
Problems:
class ToolInvocationError(Exception):
"""Base exception for tool invocation failures."""
def __init__(self, tool_name: str, reason: str, is_retryable: bool = False):
self.tool_name = tool_name
self.reason = reason
self.is_retryable = is_retryable
super().__init__(f"Tool '{tool_name}' failed: {reason}")
async def invoke_tool_good(
tool_name: str,
arguments: dict,
max_retries: int = 3,
timeout_seconds: float = 30.0
) -> dict:
"""Correct: Clear errors, retry logic, timeout protection.
Args:
tool_name: Name of tool to invoke
arguments: Input arguments for tool
max_retries: Number of retries for transient errors
timeout_seconds: Maximum time for single invocation
Returns:
Tool result dictionary
Raises:
ToolInvocationError: With clear reason and retryability flag
ValueError: If arguments don't match tool schema
"""
if tool_name not in tools_cache:
raise ValueError(
f"Tool '{tool_name}' not found in available tools: {list(tools_cache.keys())}"
)
last_error = None
for attempt in range(1, max_retries + 1):
try:
# Timeout protection: prevents hanging on slow servers
result = await asyncio.wait_for(
session.call_tool(tool_name, arguments),
timeout=timeout_seconds
)
# Validate response structure
if not hasattr(result, "content"):
raise ToolInvocationError(
tool_name,
"Malformed response: missing 'content' field",
is_retryable=False
)
return result.content
except asyncio.TimeoutError:
last_error = ToolInvocationError(
tool_name,
f"Timeout after {timeout_seconds}s",
is_retryable=True
)
except ConnectionError as e:
last_error = ToolInvocationError(
tool_name,
f"Connection error: {e}",
is_retryable=True
)
except ValueError as e:
# Schema validation error — not retryable
raise ToolInvocationError(
tool_name,
f"Invalid arguments: {e}",
is_retryable=False
)
except Exception as e:
last_error = ToolInvocationError(
tool_name,
f"Unexpected error: {type(e).__name__}: {e}",
is_retryable=False
)
# If not retryable, fail immediately
if last_error and not last_error.is_retryable:
raise last_error
# Exponential backoff before retry
if attempt < max_retries:
backoff_seconds = 2 ** (attempt - 1) # 1s, 2s, 4s, ...
print(f"Attempt {attempt}/{max_retries} failed, retrying in {backoff_seconds}s: {last_error.reason}")
await asyncio.sleep(backoff_seconds)
# All retries exhausted
raise last_error or ToolInvocationError(
tool_name,
f"Failed after {max_retries} retries",
is_retryable=False
)
# Usage with proper error handling
try:
result = await invoke_tool_good("list_files", {"directory": "/tmp"})
print(f"Success: {result}")
except ToolInvocationError as e:
if e.is_retryable:
print(f"Transient error (safe to retry): {e.reason}")
# Agent can decide to retry or backoff
else:
print(f"Permanent error (don't retry): {e.reason}")
# Agent should fail immediately
Improvements:
is_retryable flag for agent decision-makingimport asyncio
async def invoke_with_timeout(tool_name: str, args: dict, timeout_sec: float = 30.0) -> dict:
"""Invoke tool with timeout and graceful timeout handling."""
try:
result = await asyncio.wait_for(
session.call_tool(tool_name, args),
timeout=timeout_sec
)
return result.content
except asyncio.TimeoutError:
raise TimeoutError(
f"Tool '{tool_name}' did not respond within {timeout_sec}s. "
f"Server may be overloaded or hung. Consider increasing timeout or retrying."
)
async def safe_invoke_tool(tool_name: str, args: dict) -> dict:
"""Invoke tool with validation that it exists first."""
available_tools = {t.name for t in (await session.list_tools()).tools}
if tool_name not in available_tools:
raise ValueError(
f"Tool '{tool_name}' not found. Available tools: {', '.join(sorted(available_tools))}"
)
return (await session.call_tool(tool_name, args)).content
import json
async def invoke_with_validation(tool_name: str, args: dict) -> dict:
"""Invoke tool and validate response structure."""
result = await session.call_tool(tool_name, args)
# Validate response has required fields
if not isinstance(result.content, (list, dict, str)):
raise ValueError(
f"Malformed response from '{tool_name}': "
f"expected dict/list/str, got {type(result.content).__name__}"
)
# Try to parse if JSON string
if isinstance(result.content, str):
try:
return json.loads(result.content)
except json.JSONDecodeError as e:
raise ValueError(
f"Tool '{tool_name}' returned invalid JSON: {e}"
)
return result.content
Best for:
from mcp import StdioServerParameters, ClientSession
# Launch server as subprocess
params = StdioServerParameters(
command="/usr/local/bin/mcp-server-postgres",
args=["--database", "production"]
)
session = await ClientSession.create(params)
Advantages: Direct process control, low latency, secure (no network)
Disadvantages: Server must run locally, subprocess management overhead
Best for:
from mcp import ServerParameters
import aiohttp
# Connect to HTTP SSE server
async with aiohttp.ClientSession() as http_session:
params = ServerParameters(
url="https://mcp-server.example.com/sse",
headers={"Authorization": f"Bearer {api_key}"}
)
session = await ClientSession.create(params)
Advantages: Remote access, scalable, firewall-friendly
Disadvantages: Network latency, requires server deployment
async def connect_and_verify(server_path: str) -> ClientSession:
"""Connect with verification that server is responsive."""
session = await ClientSession.create(StdioServerParameters(command=server_path))
try:
# Verify server is responsive by listing tools
tools_response = await asyncio.wait_for(
session.list_tools(),
timeout=5.0
)
print(f"Connected. Available tools: {[t.name for t in tools_response.tools]}")
return session
except Exception as e:
await session.close()
raise ConnectionError(f"Server verification failed: {e}")
async def shutdown_client(session: ClientSession) -> None:
"""Close connection and cleanup resources."""
try:
await session.close()
print("Client closed gracefully")
except Exception as e:
print(f"Warning: Error closing client: {e}")
finally:
# Ensure cleanup even if close() fails
session = None
from contextlib import asynccontextmanager
@asynccontextmanager
async def mcp_client(server_path: str):
"""Context manager ensures proper cleanup."""
session = None
try:
session = await ClientSession.create(StdioServerParameters(command=server_path))
yield session
finally:
if session:
await session.close()
# Usage
async with mcp_client("/usr/local/bin/mcp-server-filesystem") as session:
tools = await session.list_tools()
# Do work...
# Session automatically closed when exiting block
class MCPClient:
def __init__(self):
self.tools_cache: dict = {}
self.cache_timestamp = None
self.cache_ttl_seconds = 3600 # Refresh every hour
async def get_tools(self, force_refresh: bool = False) -> list:
"""Get tools with caching to reduce server load."""
import time
now = time.time()
should_refresh = (
force_refresh or
not self.tools_cache or
(self.cache_timestamp and now - self.cache_timestamp > self.cache_ttl_seconds)
)
if should_refresh:
tools = await self.session.list_tools()
self.tools_cache = {t.name: t for t in tools.tools}
self.cache_timestamp = now
return list(self.tools_cache.values())
async def health_check(session: ClientSession, timeout_sec: float = 5.0) -> bool:
"""Quick check that server is responsive."""
try:
await asyncio.wait_for(
session.list_tools(),
timeout=timeout_sec
)
return True
except Exception:
return False
from typing import TypedDict
class WeatherInput(TypedDict):
location: str
unit: str # "celsius" or "fahrenheit"
async def get_weather(args: WeatherInput) -> dict:
"""Type-safe wrapper for weather tool."""
return await invoke_tool("get_weather", args)
async def discover_resources(session: ClientSession) -> dict[str, list]:
"""Discover and categorize available resources."""
response = await session.list_resources()
resources_by_type = {}
for resource in response.resources:
resource_type = resource.uri.split("://")[0] # e.g., "file", "database"
if resource_type not in resources_by_type:
resources_by_type[resource_type] = []
resources_by_type[resource_type].append({
"uri": resource.uri,
"name": resource.name,
"description": resource.description,
"mime_type": resource.mimeType
})
return resources_by_type
async def read_resource(session: ClientSession, resource_uri: str) -> str:
"""Read content from a resource (file, database query, API response)."""
try:
response = await asyncio.wait_for(
session.read_resource(resource_uri),
timeout=10.0
)
# Handle different response types
if isinstance(response.contents, str):
return response.contents
elif isinstance(response.contents, bytes):
return response.contents.decode("utf-8")
else:
return str(response.contents)
except asyncio.TimeoutError:
raise TimeoutError(f"Reading resource '{resource_uri}' exceeded timeout")
except Exception as e:
raise RuntimeError(f"Failed to read resource '{resource_uri}': {e}")
import pytest
from unittest.mock import AsyncMock, MagicMock
@pytest.mark.asyncio
async def test_invoke_tool_success():
"""Test successful tool invocation."""
# Mock session
mock_session = AsyncMock()
mock_response = MagicMock()
mock_response.content = {"result": "success"}
mock_session.call_tool.return_value = mock_response
# Test invocation
result = await invoke_tool_good("test_tool", {"arg": "value"})
assert result == {"result": "success"}
mock_session.call_tool.assert_called_once_with("test_tool", {"arg": "value"})
@pytest.mark.asyncio
async def test_invoke_tool_not_found():
"""Test error when tool doesn't exist."""
with pytest.raises(ValueError, match="Tool 'missing_tool' not found"):
await invoke_tool_good("missing_tool", {})
@pytest.mark.asyncio
async def test_invoke_tool_timeout():
"""Test timeout handling and retry logic."""
mock_session = AsyncMock()
mock_session.call_tool.side_effect = asyncio.TimeoutError()
with pytest.raises(ToolInvocationError) as exc_info:
await invoke_tool_good("slow_tool", {}, max_retries=1, timeout_seconds=0.1)
assert "Timeout" in str(exc_info.value)
async def agent_loop_with_tools(user_message: str, max_iterations: int = 10) -> str:
"""Run agent loop that invokes tools until completion."""
messages = [{"role": "user", "content": user_message}]
for iteration in range(max_iterations):
# Get Claude response
response = await client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
tools=[tool.to_claude_format() for tool in available_tools],
messages=messages
)
if response.stop_reason == "end_turn":
# Claude finished
return extract_text(response)
if response.stop_reason == "tool_use":
# Process tool calls
for block in response.content:
if block.type == "tool_use":
try:
result = await invoke_tool_good(block.name, block.input)
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
}]
})
except ToolInvocationError as e:
# Return error to Claude
messages.append({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": block.id,
"is_error": True,
"content": e.reason
}]
})
return "Max iterations reached"
mcp-server-fastmcp-python skillmcp-tool-design-patterns skill