Custom Tool & MCP Server Development Guide for AI Coding

# Custom Tool and MCP Server Development: Advanced Techniques You've built basic MCP servers and custom tools. Now it's time to level up. In this advanced guide, we'll explore sophisticated patterns that separate production-grade tools from prototypes—techniques that make your AI integrations faster, more reliable, and genuinely useful in complex development workflows. ## Beyond Basic Request-Response: Streaming and Progressive Updates Most MCP tutorials show simple request-response patterns. But what happens when your tool needs to process a large codebase, run extensive tests, or perform time-consuming analysis? Users staring at frozen interfaces while waiting 30 seconds for a response won't stick around. ### Implementing Server-Sent Events for Progress Tracking ```typescript import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; class AdvancedMCPServer { private server: Server; constructor() { this.server = new Server( { name: "advanced-analyzer", version: "1.0.0", }, { capabilities: { tools: {}, // Enable progress notifications experimental: { progressNotifications: true } }, } ); this.setupTools(); } private setupTools() { this.server.setRequestHandler("tools/call", async (request, extra) => { const { name, arguments: args } = request.params; if (name === "analyze_codebase") { return await this.analyzeCodebaseWithProgress( args.path, extra.signal ); } throw new Error(`Unknown tool: ${name}`); }); } private async analyzeCodebaseWithProgress( path: string, signal?: AbortSignal ) { const files = await this.discoverFiles(path); const totalFiles = files.length; const results = []; for (let i = 0; i < files.length; i++) { // Check for cancellation if (signal?.aborted) { throw new Error("Analysis cancelled"); } // Send progress update await this.server.notification({ method: "notifications/progress", params: { progressToken: `analyze-${Date.now()}`, progress: Math.round((i / totalFiles) * 100), total: 100, message: `Analyzing ${files[i].name} (${i + 1}/${totalFiles})` } }); const analysis = await this.analyzeFile(files[i]); results.push(analysis); } return { content: [ { type: "text", text: JSON.stringify(results, null, 2) } ] }; } } ``` This pattern keeps users informed and prevents timeouts. More importantly, it gives AI assistants context they can relay to users: "I'm analyzing file 47 of 203..." is far better than silence. ## Caching Strategies for Performance at Scale When your tool fetches repository metadata, parses configuration files, or loads large datasets, repeated calls become painfully slow. Smart caching transforms user experience. ### Multi-Layer Cache Implementation ```typescript import { createHash } from "crypto"; import { promises as fs } from "fs"; import { join } from "path"; interface CacheEntry { data: T; timestamp: number; etag: string; } class SmartCache { private memoryCache = new Map>(); private diskCachePath: string; private ttl: number; constructor(cachePath: string, ttlMs: number = 5 * 60 * 1000) { this.diskCachePath = cachePath; this.ttl = ttlMs; } private generateEtag(data: T): string { const hash = createHash("sha256"); hash.update(JSON.stringify(data)); return hash.digest("hex").substring(0, 16); } async get( key: string, fetchFn: () => Promise, options?: { forceRefresh?: boolean } ): Promise { if (options?.forceRefresh) { return await this.refresh(key, fetchFn); } // Check memory cache first const memEntry = this.memoryCache.get(key); if (memEntry && Date.now() - memEntry.timestamp < this.ttl) { return memEntry.data; } // Check disk cache try { const diskPath = join(this.diskCachePath, `${key}.json`); const diskData = await fs.readFile(diskPath, "utf-8"); const diskEntry: CacheEntry = JSON.parse(diskData); if (Date.now() - diskEntry.timestamp < this.ttl * 10) { // Disk cache valid for 10x memory TTL this.memoryCache.set(key, diskEntry); return diskEntry.data; } } catch (error) { // Disk cache miss or invalid } // Fetch fresh data return await this.refresh(key, fetchFn); } private async refresh(key: string, fetchFn: () => Promise): Promise { const data = await fetchFn(); const entry: CacheEntry = { data, timestamp: Date.now(), etag: this.generateEtag(data) }; // Update both caches this.memoryCache.set(key, entry); const diskPath = join(this.diskCachePath, `${key}.json`); await fs.mkdir(this.diskCachePath, { recursive: true }); await fs.writeFile(diskPath, JSON.stringify(entry), "utf-8"); return data; } async invalidate(key: string): Promise { this.memoryCache.delete(key); try { await fs.unlink(join(this.diskCachePath, `${key}.json`)); } catch { // Already deleted } } } // Usage in your MCP server class CachedAPIServer { private cache: SmartCache; constructor() { this.cache = new SmartCache(".mcp-cache"); } async getProjectMetadata(projectId: string) { return await this.cache.get( `project-${projectId}`, async () => { // Expensive API call const response = await fetch(`https://api.example.com/projects/${projectId}`); return await response.json(); } ); } } ``` This two-tier approach keeps hot data in memory while persisting across sessions. The key insight: different data has different staleness tolerance. Project metadata can be hours old; build status must be real-time. ## Dynamic Tool Generation Based on Context Static tool definitions work for general-purpose servers, but advanced implementations adapt to their environment. Imagine an MCP server that discovers project-specific npm scripts, custom make targets, or CI/CD pipelines—and exposes them as callable tools. ### Context-Aware Tool Discovery ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { execSync } from "child_process"; class AdaptiveMCPServer { private discoveredTools: Map = new Map(); async discoverProjectTools(projectPath: string): Promise { // Discover npm scripts const packageJson = await this.loadPackageJson(projectPath); if (packageJson?.scripts) { for (const [scriptName, scriptCmd] of Object.entries(packageJson.scripts)) { this.discoveredTools.set(`npm:${scriptName}`, { name: `npm_${scriptName.replace(/[^a-z0-9]/gi, "_")}`, description: `Run npm script: ${scriptName} (${scriptCmd})`, inputSchema: { type: "object", properties: { args: { type: "string", description: "Additional arguments to pass to the script" } } } }); } } // Discover Makefile targets try { const makeTargets = execSync("make -qp | grep '^[^#[:space:]]' | grep -v Makefile", { cwd: projectPath, encoding: "utf-8" }).split("\n"); for (const target of makeTargets) { const targetName = target.split(":")[0].trim(); if (targetName) { this.discoveredTools.set(`make:${targetName}`, { name: `make_${targetName}`, description: `Run make target: ${targetName}`, inputSchema: { type: "object", properties: {} } }); } } } catch { // No Makefile or make not available } } async handleListTools(): Promise<{ tools: Tool[] }> { return { tools: Array.from(this.discoveredTools.values()) }; } async handleToolCall(name: string, args: any): Promise { const toolEntry = Array.from(this.discoveredTools.entries()) .find(([_, tool]) => tool.name === name); if (!toolEntry) { throw new Error(`Unknown tool: ${name}`); } const [toolKey] = toolEntry; const [type, target] = toolKey.split(":"); if (type === "npm") { const result = execSync( `npm run ${target}${args.args ? ` -- ${args.args}` : ""}`, { encoding: "utf-8" } ); return { content: [{ type: "text", text: result }] }; } if (type === "make") { const result = execSync(`make ${target}`, { encoding: "utf-8" }); return { content: [{ type: "text", text: result }] }; } } } ``` This pattern makes your MCP server a chameleon—it becomes exactly what each project needs. The AI can now say "I see you have a `test:integration` script. Should I run it?" ## Error Recovery and Graceful Degradation Production tools fail. APIs time out. File systems fill up. Networks disconnect. Advanced MCP servers anticipate failure and handle it gracefully—providing partial results when possible, clear error messages always. ### Resilient Tool Implementation ```typescript class ResilientTool { private maxRetries = 3; private backoffMs = 1000; async executeWithRetry( operation: () => Promise, context: string ): Promise { let lastError: Error | null = null; for (let attempt = 0; attempt < this.maxRetries; attempt++) { try { return await this.withTimeout(operation(), 30000); // 30s timeout } catch (error) { lastError = error as Error; if (!this.isRetryable(error)) { throw this.enrichError(error, context, attempt); } if (attempt < this.maxRetries - 1) { await this.sleep(this.backoffMs * Math.pow(2, attempt)); } } } throw this.enrichError(lastError!, context, this.maxRetries); } private async withTimeout( promise: Promise, timeoutMs: number ): Promise { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error("Operation timeout")), timeoutMs) ) ]); } private isRetryable(error: any): boolean { // Network errors are retryable if (error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT") { return true; } // 5xx status codes are retryable if (error.response?.status >= 500) { return true; } // Rate limiting is retryable if (error.response?.status === 429) { return true; } return false; } private enrichError(error: any, context: string, attempts: number): Error { const enriched = new Error( `Failed after ${attempts} attempts in ${context}: ${error.message}` ); enriched.stack = error.stack; (enriched as any).originalError = error; (enriched as any).context = context; return enriched; } private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } async executeWithFallback( primary: () => Promise, fallback: () => Promise, context: string ): Promise { try { return await this.executeWithRetry(primary, `${context} (primary)`); } catch (primaryError) { console.warn(`Primary operation failed, trying fallback:`, primaryError); try { return await this.executeWithRetry(fallback, `${context} (fallback)`); } catch (fallbackError) { throw new Error( `Both primary and fallback failed for ${context}. ` + `Primary: ${primaryError.message}. Fallback: ${fallbackError.message}` ); } } } } // Example usage class DataFetcher extends ResilientTool { async fetchUserData(userId: string) { return await this.executeWithFallback( // Try primary API async () => { const response = await fetch(`https://api.primary.com/users/${userId}`); return await response.json(); }, // Fall back to cache async () => { const cached = await this.loadFromCache(userId); if (!cached) { throw new Error("No cached data available"); } return { ...cached, _fromCache: true }; }, `fetchUserData(${userId})` ); } } ``` Notice how errors carry context. When an AI tool fails, the assistant needs to explain *why* to the user. "The API timed out after 3 retries" is actionable. "Error: undefined" is useless. ## Schema Validation and Type Safety As covered in our [quality-control](/quality-control) lesson, validation prevents garbage-in-garbage-out scenarios. For advanced tools, go beyond basic type checking. ### Runtime Schema Validation with Detailed Feedback ```typescript import Ajv from "ajv"; import addFormats from "ajv-formats"; class ValidatedMCPTool { private ajv: Ajv; constructor() { this.ajv = new Ajv({ allErrors: true, verbose: true }); addFormats(this.ajv); } async callTool(name: string, args: unknown): Promise { const toolSchema = this.getToolSchema(name); // Validate input const validate = this.ajv.compile(toolSchema.inputSchema); const valid = validate(args); if (!valid) { const errors = validate.errors!.map(err => ({ path: err.instancePath, message: err.message, params: err.params })); throw new Error( `Invalid arguments for tool '${name}':\n` + errors.map(e => ` - ${e.path}: ${e.message}`).join("\n") + "\n\nExpected schema:\n" + JSON.stringify(toolSchema.inputSchema, null, 2) ); } // Execute tool const result = await this.executeTool(name, args); // Validate output (ensures tool contract is maintained) if (toolSchema.outputSchema) { const validateOutput = this.ajv.compile(toolSchema.outputSchema); if (!validateOutput(result)) { console.error("Tool output validation failed:", validateOutput.errors); // Log but don't throw - we still want to return the result // This helps catch bugs in tool implementation } } return result; } private getToolSchema(name: string) { // Define comprehensive schemas const schemas = { create_database: { inputSchema: { type: "object", required: ["name", "type"], properties: { name: { type: "string", pattern: "^[a-z0-9_]{3,63}$", description: "Database name (3-63 chars, lowercase, numbers, underscores)" }, type: { type: "string", enum: ["postgres", "mysql", "mongodb"] }, config: { type: "object", properties: { maxConnections: { type: "number", minimum: 1, maximum: 1000 }, ssl: { type: "boolean" } } } }, additionalProperties: false }, outputSchema: { type: "object", required: ["connectionString", "status"], properties: { connectionString: { type: "string", format: "uri" }, status: { type: "string", enum: ["created", "pending"] } } } } }; return schemas[name] || { inputSchema: { type: "object" } }; } } ``` The detailed error messages help AI assistants course-correct. Instead of failing silently or with cryptic errors, they can try again with valid input. ## Security Considerations in Custom Tools This deserves special attention. As detailed in [security-considerations](/security-considerations), MCP servers often have significant system access. Advanced techniques require advanced security. ### Sandboxing and Permission Models ```typescript import { spawn } from "child_process"; import { createHash } from "crypto"; class SecureMCPServer { private allowedPaths: Set; private commandWhitelist: Map; constructor(config: { allowedPaths: string[] }) { this.allowedPaths = new Set(config.allowedPaths); // Define what commands are allowed and how this.commandWhitelist = new Map([ ["git", /^git (status|log|diff|show) /], ["npm", /^npm (list|outdated|view) /], ["cat", /^cat [a-zA-Z0-9\/_\-\.]+\.md$/] ]); } validatePath(requestedPath: string): boolean { const normalized = path.resolve(requestedPath); // Check if path is within allowed directories for (const allowedPath of this.allowedPaths) { if (normalized.startsWith(allowedPath)) { return true; } } throw new Error( `Access denied: ${requestedPath} is outside allowed paths` ); } async executeCommand(command: string): Promise { // Extract command name const commandName = command.split(" ")[0]; // Check if command is whitelisted const pattern = this.commandWhitelist.get(commandName); if (!pattern) { throw new Error(`Command not allowed: ${commandName}`); } // Verify command matches allowed pattern if (!pattern.test(command)) { throw new Error( `Command '${command}' doesn't match allowed pattern for ${commandName}` ); } // Execute in isolated environment return new Promise((resolve, reject) => { const process = spawn("sh", ["-c", command], { timeout: 5000, env: { // Minimal environment PATH: "/usr/bin:/bin", HOME: "/tmp/mcp-sandbox" }, cwd: "/tmp/mcp-sandbox" }); let stdout = ""; let stderr = ""; process.stdout.on("data", data => stdout += data); process.stderr.on("data", data => stderr += data); process.on("close", code => { if (code === 0) { resolve(stdout); } else { reject(new Error(`Command failed: ${stderr}`)); } }); }); } } ``` Never trust input from AI assistants. They're not malicious, but they can be tricked or make mistakes. Defense in depth protects your users. ## Monitoring and Observability You can't improve what you can't measure. Advanced MCP servers include telemetry that helps you understand usage patterns, identify bottlenecks, and catch errors before users complain. ### Built-in Metrics and Logging ```typescript class ObservableMCPServer { private metrics = { toolCalls: new Map(), errors: new Map(), latencies: new Map() }; async callToolWithMetrics(name: string, args: any): Promise { const startTime = Date.now(); try { // Increment call counter this.metrics.toolCalls.set( name, (this.metrics.toolCalls.get(name) || 0) + 1 ); const result = await this.executeTool(name, args); // Record latency const latency = Date.now() - startTime; const latencies = this.metrics.latencies.get(name) || []; latencies.push(latency); this.metrics.latencies.set(name, latencies.slice(-100)); // Keep last 100 // Log slow operations if (latency > 2000) { console.warn(`Slow tool execution: ${name} took ${latency}ms`); } return result; } catch (error) { // Track error rates this.metrics.errors.set( name, (this.metrics.errors.get(name) || 0) + 1 ); // Structured error logging console.error({ event: "tool_error", tool: name, error: error.message, stack: error.stack, args: this.sanitizeArgs(args), timestamp: new Date().toISOString() }); throw error; } } getMetrics() { const stats = {}; for (const [tool, calls] of this.metrics.toolCalls.entries()) { const latencies = this.metrics.latencies.get(tool) || []; const errors = this.metrics.errors.get(tool) || 0; stats[tool] = { calls, errors, errorRate: calls > 0 ? (errors / calls) * 100 : 0, avgLatencyMs: latencies.length > 0 ? latencies.reduce((a, b) => a + b) / latencies.length : 0, p95LatencyMs: this.percentile(latencies, 95) }; } return stats; } private percentile(values: number[], p: number): number { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); const index = Math.ceil((p / 100) * sorted.length) - 1; return sorted[index]; } private sanitizeArgs(args: any): any { // Remove sensitive data before logging const sanitized = { ...args }; const sensitiveKeys = ["password", "token", "apiKey", "secret"]; for (const key of sensitiveKeys) { if (key in sanitized) { sanitized[key] = "[REDACTED]"; } } return sanitized; } } ``` This data tells you which tools are actually being used, which are slow, and which are failing. It's the difference between guessing and knowing. ## Putting It All Together Advanced MCP development isn't about cramming every technique into every server. It's about choosing the right patterns for your use case: - **Streaming progress** for long-running operations - **Multi-tier caching** for frequently accessed, slow-to-fetch data - **Dynamic tool discovery** when working with diverse projects - **Resilient execution** for tools that interact with unreliable services - **Strict validation** for tools that modify state or incur costs - **Security sandboxing** whenever executing user-provided code or commands - **Observability** in production servers that others depend on The most important technique? **Iterate based on real usage.** Build the simplest thing that works, deploy it, gather data, then enhance the hot paths. Don't prematurely optimize tools nobody uses. Watch for patterns in how AI assistants use your tools. If they frequently call tool A then tool B, consider combining them. If they repeatedly pass the same argument, make it a default. If they make the same mistake, improve your error messages. This is vibe coding at its best: building tools that make AI assistants more effective, which makes developers more productive, which creates better software. Your custom MCP servers are force multipliers—invest in making them excellent. For more on avoiding common pitfalls as you build, check out [top-mistakes](/top-mistakes) and [over-reliance](/over-reliance). And remember: even the best tools need human judgment about [when-not-to-use-ai](/when-not-to-use-ai).