mcp-client-builder

MCP Client Development Guide

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "mcp-client-builder" with this command: npx skills add kastalien-research/thoughtbox-dot-claude/kastalien-research-thoughtbox-dot-claude-mcp-client-builder

MCP Client Development Guide

Core Mental Model

Host Application (user-facing app like Claude Desktop) └─> Creates 1+ MCP Clients (protocol components) └─> Each Client connects to exactly 1 Server (1:1 mapping) └─> Server exposes Tools/Resources/Prompts └─> LLM decides which to use └─> Client executes, returns results

Key Principle: Client = stateful messenger, NOT decision maker. LLM chooses tools, client facilitates execution.

Development Workflow

Phase 1: Architecture Design

1.1 Determine Requirements

Client Capabilities (what client provides TO servers):

  • sampling

  • Allow server to request LLM completions

  • roots

  • Declare filesystem boundaries

  • elicitation

  • Allow server to request user input

Expected Server Capabilities (what servers provide TO client):

  • tools

  • Execute operations

  • resources

  • Access data

  • prompts

  • Use templates

1.2 Select Transport Strategy

Transport Use When Pros Cons

stdio Server on same machine Fast, simple Local only

HTTP Stream Remote server, modern Bidirectional, sessions More complex

SSE Legacy compatibility Simple Unidirectional

Decision Rule: stdio for local development/testing, HTTP Stream for production remote servers.

1.3 Plan Connection Management

1:1 Mapping Pattern:

// CORRECT: One client per server const weatherClient = new Client(/* weather server config /); const calendarClient = new Client(/ calendar server config */);

// INCORRECT: One client trying to talk to multiple servers const multiClient = new Client(/* won't work */);

Host Manages Multiple Clients:

class HostApplication { private clients: Map<string, Client> = new Map();

connectToServer(serverConfig) { const client = new Client(config); this.clients.set(serverConfig.id, client); } }

Phase 2: Implementation

2.1 Project Structure

TypeScript:

src/ client.ts # Main Client class transports/ # stdio, http, sse implementations types.ts # Zod schemas errors.ts # Error handling session.ts # Session management

Python:

client.py # Main Client class transports/ # stdio, http, sse schemas.py # Pydantic models errors.py # Error handling session.py # Session management

2.2 Connection Lifecycle Implementation

Three-Phase Pattern:

// Phase 1: Initialize async connect(transport: Transport) { await transport.connect();

const initResponse = await this.sendRequest({ method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: { sampling: {}, // If client supports sampling roots: { listChanged: true }, // If client supports roots elicitation: {} // If client supports elicitation }, clientInfo: { name: "my-client", version: "1.0.0" } } });

this.serverCapabilities = initResponse.capabilities;

// Phase 2: Confirm await this.sendNotification({ method: "initialized" });

// Phase 3: Ready for operations }

Server Capabilities Extraction:

interface ServerCapabilities { tools?: { listChanged?: boolean }; resources?: { subscribe?: boolean, listChanged?: boolean }; prompts?: { listChanged?: boolean }; logging?: {}; }

2.3 Transport Abstraction

Interface Pattern:

interface Transport { connect(): Promise<void>; send(message: JSONRPCMessage): Promise<void>; receive(): AsyncIterator<JSONRPCMessage>; close(): Promise<void>; }

class StdioTransport implements Transport { /* ... / } class HTTPStreamTransport implements Transport { / ... / } class SSETransport implements Transport { / ... */ }

Usage:

const transport = config.remote ? new HTTPStreamTransport(config.url) : new StdioTransport(config.command, config.args);

await client.connect(transport);

2.4 Tool Orchestration Pattern

Critical: LLM Decides, Client Executes:

async processUserQuery(query: string): Promise<string> { // 1. Get available tools from server const toolsResponse = await this.request({ method: "tools/list" }); const tools = toolsResponse.tools;

// 2. Present tools to LLM in structured format const llmTools = tools.map(tool => ({ name: tool.name, description: tool.description, input_schema: tool.inputSchema }));

// 3. LLM DECIDES which tools to use const llmResponse = await anthropic.messages.create({ model: "claude-3-5-sonnet-20241022", messages: [{ role: "user", content: query }], tools: llmTools // LLM sees available tools });

// 4. Execute LLM-requested tool calls for (const toolUse of llmResponse.content) { if (toolUse.type === 'tool_use') { const result = await this.request({ method: "tools/call", params: { name: toolUse.name, arguments: toolUse.input } });

  // 5. Return results to LLM for final response
  // ... continuation logic
}

} }

Key Point: Client never chooses tools. Client only:

  • Discovers available tools

  • Presents them to LLM

  • Executes LLM's choices

  • Returns results to LLM

2.5 Error Handling Strategy

JSON-RPC Error Codes:

enum ErrorCode { ParseError = -32700, // Invalid JSON InvalidRequest = -32600, // Malformed request MethodNotFound = -32601, // Tool doesn't exist InvalidParams = -32602, // Wrong arguments InternalError = -32603, // Server failure

// Custom range: -32000 to -32099 Timeout = -32001, ResourceNotFound = -32002, Unauthorized = -32003 }

Error Classification & Retry Logic:

class ErrorHandler { async handleError(error: JSONRPCError): Promise<'retry' | 'fail' | 'escalate'> { // Transient errors: retry with exponential backoff if ([ErrorCode.InternalError, ErrorCode.Timeout].includes(error.code)) { return 'retry'; }

// Permanent errors: fail immediately
if ([ErrorCode.MethodNotFound, ErrorCode.InvalidParams].includes(error.code)) {
  return 'fail';
}

// Security errors: escalate to user
if (error.code === ErrorCode.Unauthorized) {
  return 'escalate';
}

} }

Retry Pattern:

async executeWithRetry<T>( fn: () => Promise<T>, maxRetries = 3, baseDelay = 1000 ): Promise<T> { for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await fn(); } catch (error) { const action = await this.errorHandler.handleError(error);

  if (action === 'retry' &#x26;&#x26; attempt &#x3C; maxRetries - 1) {
    await sleep(baseDelay * Math.pow(2, attempt));
    continue;
  }
  throw error;
}

} }

2.6 Session Management (HTTP Transport)

Session ID Propagation:

class HTTPStreamTransport { private sessionId?: string;

async send(message: JSONRPCMessage) { const headers: Record<string, string> = { 'Content-Type': 'application/json' };

// Propagate session ID bidirectionally
if (this.sessionId) {
  headers['Mcp-Session-Id'] = this.sessionId;
}

const response = await fetch(this.url, {
  method: 'POST',
  headers,
  body: JSON.stringify(message)
});

// Extract session ID from response
const receivedSessionId = response.headers.get('Mcp-Session-Id');
if (receivedSessionId) {
  this.sessionId = receivedSessionId;
}

} }

Session State Management:

interface SessionState { id: string; lastActivity: Date; conversationHistory: Message[]; resources: Map<string, ResourceState>; }

Phase 3: Security Implementation

3.1 Multi-Layer Defense

Layer 1: Network Security:

class SecureTransport { validateTLS(url: string) { if (!url.startsWith('https://') && !this.isLocalhost(url)) { throw new Error('Remote servers must use HTTPS'); } }

validateOrigin(origin: string) { // DNS rebinding protection if (!this.allowedOrigins.includes(origin)) { throw new Error(Untrusted origin: ${origin}); } } }

Layer 2: Authentication:

interface AuthProvider { authenticate(): Promise<Credentials>; refresh(credentials: Credentials): Promise<Credentials>; }

class OAuth2PKCEProvider implements AuthProvider { async authenticate(): Promise<Credentials> { const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier);

// OAuth 2.1 with PKCE flow
const authUrl = buildAuthUrl({ challenge: codeChallenge });
const code = await getUserConsent(authUrl);

return await exchangeCodeForToken(code, codeVerifier);

} }

Layer 3: Authorization:

class AuthorizationManager { async checkPermissions(toolName: string, params: any): Promise<boolean> { const tool = await this.getToolMetadata(toolName);

// Destructive operations require explicit user consent
if (tool.destructiveHint === true) {
  return await this.requestUserApproval(
    `Allow ${toolName}? This will modify data.`
  );
}

// Check scopes
const requiredScopes = tool.requiredScopes || [];
return this.hasScopes(requiredScopes);

} }

Layer 4: Validation:

async callTool(name: string, args: unknown) { // 1. Schema validation const tool = await this.getTool(name); const validatedArgs = tool.inputSchema.parse(args); // Zod/Pydantic

// 2. Sanitization const sanitized = sanitizeInputs(validatedArgs);

// 3. Authorization check const authorized = await this.authz.checkPermissions(name, sanitized); if (!authorized) throw new UnauthorizedError();

// 4. Execute return await this.executeToolCall(name, sanitized); }

3.2 Credential Management

NEVER:

// ❌ WRONG: Hardcoded credentials const client = new Client({ apiKey: "sk-1234..." });

// ❌ WRONG: Environment variables (visible to process) const client = new Client({ apiKey: process.env.API_KEY });

ALWAYS:

// ✅ CORRECT: OS keychain import { getSecret } from '@keychain/secure-store'; const apiKey = await getSecret('mcp-server-credentials');

// ✅ CORRECT: Vault service const credentials = await vault.getCredentials('mcp-server');

Phase 4: Performance & Optimization

4.1 Token Efficiency (Primary Goal)

Problem: Every token in tool I/O consumes LLM context window.

Solution Pattern:

interface ToolResponse { format: 'concise' | 'detailed'; // Let LLM choose }

async executeTool(name: string, args: { format?: string }) { const result = await this.server.callTool(name, args);

// Default to concise if (args.format !== 'detailed') { return this.truncateResponse(result, MAX_TOKENS); }

return result; }

private truncateResponse(data: any, maxTokens: number): any { // Remove low-signal fields const { id, timestamp, metadata, ...essential } = data;

// Truncate arrays if (Array.isArray(essential.items)) { essential.items = essential.items.slice(0, 10); essential.truncated = true; }

return essential; }

Server Response Design:

// ❌ BAD: Verbose response { "temperature": 72.5, "temperature_unit": "fahrenheit", "humidity": 65, "humidity_unit": "percentage", "wind_speed": 5, "wind_speed_unit": "mph", "wind_direction": "N", "pressure": 1013, "pressure_unit": "mb", // ... 20 more fields }

// ✅ GOOD: Concise response { "temp": "72°F", "conditions": "cloudy", "wind": "5mph N" }

4.2 Connection Pooling

For Database-Backed Servers:

class ConnectionPool { private pool: Connection[] = []; private maxSize = 10;

async acquire(): Promise<Connection> { if (this.pool.length > 0) { return this.pool.pop()!; }

if (this.activeConnections &#x3C; this.maxSize) {
  return await this.createConnection();
}

// Wait for available connection
return await this.waitForConnection();

}

release(conn: Connection) { this.pool.push(conn); } }

4.3 Request Queuing

Handle Burst Traffic:

class RequestQueue { private queue: PendingRequest[] = []; private processing = 0; private maxConcurrent = 5;

async enqueue(request: Request): Promise<Response> { return new Promise((resolve, reject) => { this.queue.push({ request, resolve, reject }); this.processQueue(); }); }

private async processQueue() { while (this.queue.length > 0 && this.processing < this.maxConcurrent) { const { request, resolve, reject } = this.queue.shift()!; this.processing++;

  try {
    const result = await this.executeRequest(request);
    resolve(result);
  } catch (error) {
    reject(error);
  } finally {
    this.processing--;
    this.processQueue();
  }
}

} }

Phase 5: Testing & Validation

5.1 Use MCP Inspector

npx @modelcontextprotocol/inspector <server-command>

UI: http://localhost:5173

Proxy: http://localhost:3000

Validation Checklist:

  • Connection establishes successfully

  • Capabilities negotiated correctly

  • Tools discovered and listed

  • Tool calls execute and return results

  • Errors display meaningful messages

  • Session IDs propagate (HTTP transport)

  • OAuth flow completes (if applicable)

5.2 Automated Testing

Technical Tests (fast, comprehensive):

describe('Client', () => { it('negotiates capabilities', async () => { const client = new Client({ capabilities: { sampling: {} } }); await client.connect(mockTransport);

expect(client.serverCapabilities).toBeDefined();

});

it('handles tool calls', async () => { const result = await client.callTool('test_tool', { arg: 'value' }); expect(result.content).toBeDefined(); });

it('retries on transient errors', async () => { mockTransport.failTimes(2); // Fail twice, then succeed const result = await client.callTool('flaky_tool', {}); expect(result).toBeDefined(); }); });

Behavioral Tests (with real LLM):

describe('Client with LLM', () => { it('LLM can discover and use tools', async () => { const query = "What's the weather in Tokyo?"; const response = await client.processQuery(query);

expect(response).toContain('Tokyo');
expect(response).toMatch(/\d+°[FC]/);  // Contains temperature

}); });

Reference Architecture

Recommended Client Structure

class MCPClient { private transport: Transport; private session: SessionManager; private auth: AuthProvider; private authz: AuthorizationManager; private errorHandler: ErrorHandler; private requestQueue: RequestQueue;

// Core protocol methods async connect(transport: Transport): Promise<void> { /* ... / } async listTools(): Promise<Tool[]> { / ... / } async callTool(name: string, args: any): Promise<ToolResult> { / ... / } async listResources(): Promise<Resource[]> { / ... / } async readResource(uri: string): Promise<ResourceContent> { / ... / } async listPrompts(): Promise<Prompt[]> { / ... / } async getPrompt(name: string, args: any): Promise<PromptContent> { / ... */ }

// Client capability implementations async handleSamplingRequest(request: SamplingRequest): Promise<SamplingResult> { /* ... / } async handleElicitationRequest(request: ElicitationRequest): Promise<ElicitationResult> { / ... */ }

// Lifecycle async close(): Promise<void> { /* ... */ } }

Common Patterns

Pattern: Bidirectional Communication

class Client { // Client → Server requests async request(method: string, params: any): Promise<any> { return this.sendRequest({ method, params }); }

// Server → Client requests (handlers) private handlers = new Map<string, RequestHandler>();

registerHandler(method: string, handler: RequestHandler) { this.handlers.set(method, handler); }

// Message router private async handleIncomingMessage(message: JSONRPCMessage) { if ('method' in message && 'id' in message) { // This is a request FROM server TO client const handler = this.handlers.get(message.method); if (handler) { const result = await handler(message.params); await this.sendResponse(message.id, result); } } } }

// Usage: client.registerHandler('sampling/createMessage', async (params) => { // Server is asking client to get LLM completion return await this.llm.complete(params.messages); });

Pattern: Resource vs Tool Usage

// Resources: Data client can READ const calendarData = await client.readResource('calendar://events/today'); // Returns: { text: "Meeting at 3pm, Lunch at 12pm" }

// Tools: Actions client can EXECUTE const result = await client.callTool('create_event', { title: "Team Meeting", time: "2024-01-15T15:00:00Z" }); // Returns: { content: [{ type: "text", text: "Event created" }] }

Pattern: Prompts as Templates

// Get prompt template from server const prompt = await client.getPrompt('write_email', { recipient: "boss@company.com", topic: "project update" });

// prompt.messages contains pre-filled conversation // Send to LLM with additional context const completion = await llm.complete([ ...prompt.messages, { role: "user", content: "Include metrics from Q4" } ]);

Anti-Patterns to Avoid

❌ Client Choosing Tools

// WRONG: Client decides what to do if (query.includes('weather')) { return await client.callTool('check_weather', { city: extractCity(query) }); }

❌ Forgetting Session IDs

// WRONG: Not propagating session fetch(url, { headers: { 'Content-Type': 'application/json' } // Missing Mcp-Session-Id });

❌ No Retry Logic

// WRONG: Fail on first error const result = await client.callTool('flaky_api', {}); // Will fail randomly

❌ Verbose Responses

// WRONG: Returning all data return await database.query('SELECT * FROM users'); // 10,000 rows

// CORRECT: Return summary return { count: 10000, sample: users.slice(0, 10) };

Quick Start Templates

See reference files:

  • TypeScript: typescript_mcp_client.md

  • Python: python_mcp_client.md

  • Architecture: client_architecture.md

  • Best Practices: mcp_client_best_practices.md

Success Criteria

Client is production-ready when:

  • Connects to servers via all required transports

  • Negotiates capabilities correctly

  • Discovers and executes tools, resources, prompts

  • Implements retry logic with exponential backoff

  • Handles errors gracefully with actionable messages

  • Enforces security at network, auth, authz, validation layers

  • Optimizes for token efficiency

  • Passes both technical and behavioral tests

  • Includes comprehensive logging to stderr (never stdout in stdio mode)

  • Manages sessions correctly (HTTP transport)

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Research

effect-ts

No summary provided by upstream source.

Repository SourceNeeds Review
Research

zod4

No summary provided by upstream source.

Repository SourceNeeds Review
Research

docker patterns

No summary provided by upstream source.

Repository SourceNeeds Review