Custom Tool and MCP Server Development

25 minpublished

Develop custom Model Context Protocol servers to extend AI assistant capabilities with specialized tools.

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

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

import { createHash } from "crypto";
import { promises as fs } from "fs";
import { join } from "path";

interface CacheEntry<T> {
  data: T;
  timestamp: number;
  etag: string;
}

class SmartCache<T> {
  private memoryCache = new Map<string, CacheEntry<T>>();
  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<T>,
    options?: { forceRefresh?: boolean }
  ): Promise<T> {
    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<T> = 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<T>): Promise<T> {
    const data = await fetchFn();
    const entry: CacheEntry<T> = {
      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<void> {
    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<any>;
  
  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

import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { execSync } from "child_process";

class AdaptiveMCPServer {
  private discoveredTools: Map<string, Tool> = new Map();
  
  async discoverProjectTools(projectPath: string): Promise<void> {
    // 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<any> {
    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

class ResilientTool {
  private maxRetries = 3;
  private backoffMs = 1000;
  
  async executeWithRetry<T>(
    operation: () => Promise<T>,
    context: string
  ): Promise<T> {
    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<T>(
    promise: Promise<T>,
    timeoutMs: number
  ): Promise<T> {
    return Promise.race([
      promise,
      new Promise<T>((_, 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<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
  
  async executeWithFallback<T>(
    primary: () => Promise<T>,
    fallback: () => Promise<T>,
    context: string
  ): Promise<T> {
    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 lesson, validation prevents garbage-in-garbage-out scenarios. For advanced tools, go beyond basic type checking.

Runtime Schema Validation with Detailed Feedback

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<any> {
    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, MCP servers often have significant system access. Advanced techniques require advanced security.

Sandboxing and Permission Models

import { spawn } from "child_process";
import { createHash } from "crypto";

class SecureMCPServer {
  private allowedPaths: Set<string>;
  private commandWhitelist: Map<string, RegExp>;
  
  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<string> {
    // 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

class ObservableMCPServer {
  private metrics = {
    toolCalls: new Map<string, number>(),
    errors: new Map<string, number>(),
    latencies: new Map<string, number[]>()
  };
  
  async callToolWithMetrics(name: string, args: any): Promise<any> {
    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 and over-reliance. And remember: even the best tools need human judgment about when-not-to-use-ai.