Adding custom tools to an MCP server in TypeScript

Kashish Hora

Kashish Hora

Co-founder of MCPcat

Try out MCPcat

The Quick Answer

Add custom tools to your TypeScript MCP server using the server.tool() method with Zod schema validation:

import { z } from "zod";

server.tool("fetch_weather", 
  {
    location: z.string().describe("City name or coordinates"),
    unit: z.enum(["celsius", "fahrenheit"]).default("celsius")
  },
  async ({ location, unit }) => ({
    content: [{
      type: "text",
      text: `Weather in ${location}: 22°${unit === "celsius" ? "C" : "F"}`
    }]
  })
);

This creates a type-safe tool that AI assistants can call with validated parameters. The Zod schema provides runtime validation, type inference, and auto-generated documentation for the tool's parameters.

Prerequisites

  • TypeScript MCP server project set up
  • Node.js 18+ and npm/pnpm installed
  • Basic understanding of TypeScript and async/await
  • Familiarity with MCP server structure

Installation

Install the required dependencies for custom tool development:

$npm install @modelcontextprotocol/sdk zod
$npm install -D @types/node typescript tsx

For additional functionality, you might need:

# For HTTP requests in tools
$npm install axios
 
# For database operations
$npm install prisma @prisma/client
 
# For file system operations
$npm install fs-extra

Basic Tool Structure

Every MCP tool consists of three essential components: a unique name, a parameter schema, and a handler function. The TypeScript SDK uses Zod for schema definition, providing both runtime validation and compile-time type safety.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
import { z } from "zod";

const server = new McpServer({
  name: "my-tools-server",
  version: "1.0.0"
});

// Tool definition pattern
server.tool(
  "tool_name",                    // Unique identifier
  {                              // Zod schema object
    param1: z.string(),
    param2: z.number()
  },
  async (args) => {              // Handler function
    // Implementation logic
    return {
      content: [{
        type: "text",
        text: "Result"
      }]
    };
  }
);

The tool name should be descriptive and follow snake_case convention. The schema defines what parameters the AI can provide when calling your tool. The handler receives validated arguments matching the schema's TypeScript type.

Parameter Validation with Zod

Zod enables sophisticated parameter validation beyond simple type checking. Each parameter can have constraints, defaults, and descriptions that help AI assistants understand how to use your tool correctly.

server.tool("search_database",
  {
    query: z.string()
      .min(3, "Query too short")
      .max(100, "Query too long")
      .describe("Search query for database lookup"),
    
    filters: z.object({
      category: z.enum(["posts", "users", "comments"]),
      dateRange: z.object({
        start: z.string().datetime(),
        end: z.string().datetime()
      }).optional()
    }).optional(),
    
    limit: z.number()
      .int()
      .positive()
      .max(50)
      .default(10)
      .describe("Maximum results to return")
  },
  async ({ query, filters, limit }) => {
    // TypeScript knows the exact types here
    // query: string, filters?: {...}, limit: number
    
    const results = await database.search({
      q: query,
      ...filters,
      limit
    });
    
    return {
      content: [{
        type: "text",
        text: JSON.stringify(results, null, 2)
      }]
    };
  }
);

The validation happens automatically before your handler runs. If the AI provides invalid parameters, the MCP protocol returns a structured error without executing your handler. This prevents runtime errors and provides clear feedback to the AI about what went wrong.

Implementing Error Handling

Robust error handling ensures your tools fail gracefully and provide helpful feedback. MCP tools should distinguish between different error types and return appropriate error responses.

server.tool("process_payment",
  {
    amount: z.number().positive(),
    currency: z.string().length(3),
    customerId: z.string().uuid()
  },
  async ({ amount, currency, customerId }) => {
    try {
      // Validate business logic
      const customer = await getCustomer(customerId);
      if (!customer) {
        return {
          content: [{
            type: "text",
            text: "Customer not found"
          }],
          isError: true
        };
      }
      
      // Process payment
      const result = await paymentGateway.charge({
        amount,
        currency,
        customer: customer.id
      });
      
      return {
        content: [{
          type: "text",
          text: `Payment processed: ${result.transactionId}`
        }]
      };
      
    } catch (error) {
      // Log for debugging
      console.error("Payment processing error:", error);
      
      // Return user-friendly error
      if (error.code === "INSUFFICIENT_FUNDS") {
        return {
          content: [{
            type: "text",
            text: "Payment failed: Insufficient funds"
          }],
          isError: true
        };
      }
      
      // Generic error fallback
      return {
        content: [{
          type: "text",
            text: "Payment processing failed. Please try again."
        }],
        isError: true
      };
    }
  }
);

Always log errors for debugging while returning sanitized messages to the AI. Include the isError: true flag in your response to indicate failure. This helps AI assistants understand when to retry or take alternative actions.

Advanced Tool Patterns

Complex tools often require sophisticated patterns for handling real-world scenarios. Here's an example of a tool that manages long-running operations with progress updates:

server.tool("analyze_repository",
  {
    repoUrl: z.string().url(),
    metrics: z.array(z.enum(["complexity", "coverage", "dependencies"])),
    branch: z.string().default("main")
  },
  async ({ repoUrl, metrics, branch }) => {
    const analysisId = generateId();
    
    // Start async analysis
    startBackgroundAnalysis(analysisId, repoUrl, metrics, branch);
    
    // Return immediately with tracking info
    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          status: "started",
          analysisId,
          message: "Analysis started. Use check_analysis tool to monitor progress.",
          estimatedTime: "2-5 minutes"
        }, null, 2)
      }]
    };
  }
);

// Companion tool to check progress
server.tool("check_analysis",
  {
    analysisId: z.string()
  },
  async ({ analysisId }) => {
    const status = await getAnalysisStatus(analysisId);
    
    if (!status) {
      return {
        content: [{
          type: "text",
          text: "Analysis not found"
        }],
        isError: true
      };
    }
    
    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          status: status.state,
          progress: status.progress,
          results: status.state === "completed" ? status.results : null
        }, null, 2)
      }]
    };
  }
);

This pattern separates initiation from status checking, allowing AI assistants to perform other tasks while waiting for long operations to complete. It's particularly useful for file processing, API calls to slow services, or complex computations.

Type Safety Best Practices

TypeScript's type system combined with Zod provides end-to-end type safety. Extract schemas and types for reuse across your codebase:

// Define schemas separately for reusability
const UserQuerySchema = z.object({
  id: z.string().uuid().optional(),
  email: z.string().email().optional(),
  username: z.string().optional()
}).refine(
  data => data.id || data.email || data.username,
  "At least one identifier required"
);

// Infer TypeScript type from schema
type UserQuery = z.infer<typeof UserQuerySchema>;

// Use in multiple tools
server.tool("get_user", 
  { query: UserQuerySchema },
  async ({ query }) => {
    const user = await findUser(query);
    // ... handle response
  }
);

server.tool("update_user",
  {
    query: UserQuerySchema,
    updates: z.object({
      name: z.string().optional(),
      bio: z.string().max(500).optional()
    })
  },
  async ({ query, updates }) => {
    const user = await updateUser(query, updates);
    // ... handle response
  }
);

Using shared schemas ensures consistency across related tools and makes maintenance easier. The TypeScript compiler catches type mismatches at build time, preventing runtime errors.

Testing Your Tools

Comprehensive testing ensures your tools work correctly with various inputs. Create unit tests that verify both successful operations and error cases:

import { testTool } from "@modelcontextprotocol/sdk/testing";

describe("Weather Tool", () => {
  it("returns weather for valid location", async () => {
    const result = await testTool(server, "fetch_weather", {
      location: "London",
      unit: "celsius"
    });
    
    expect(result.content[0].text).toContain("London");
    expect(result.isError).toBeFalsy();
  });
  
  it("validates temperature unit", async () => {
    await expect(testTool(server, "fetch_weather", {
      location: "Paris",
      unit: "kelvin" // Invalid unit
    })).rejects.toThrow();
  });
  
  it("handles API failures gracefully", async () => {
    // Mock API failure
    weatherAPI.mockRejectedValueOnce(new Error("API down"));
    
    const result = await testTool(server, "fetch_weather", {
      location: "Berlin",
      unit: "fahrenheit"
    });
    
    expect(result.isError).toBe(true);
    expect(result.content[0].text).toContain("weather service unavailable");
  });
});

Test edge cases, validation boundaries, and error scenarios. Integration tests can verify tools work correctly with external services:

describe("Database Tool Integration", () => {
  beforeEach(async () => {
    await database.reset();
    await database.seed(testData);
  });
  
  it("searches with complex filters", async () => {
    const result = await testTool(server, "search_database", {
      query: "typescript",
      filters: {
        category: "posts",
        dateRange: {
          start: "2024-01-01T00:00:00Z",
          end: "2024-12-31T23:59:59Z"
        }
      },
      limit: 5
    });
    
    const data = JSON.parse(result.content[0].text);
    expect(data.results).toHaveLength(5);
    expect(data.results[0].category).toBe("posts");
  });
});

Common Issues

Error: Cannot find module 'zod' The TypeScript compiler can't locate Zod even though it's installed. This typically happens with incorrect TypeScript module resolution settings. Fix by updating your tsconfig.json:

{
  "compilerOptions": {
    "moduleResolution": "node",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  }
}

Also ensure Zod is in your dependencies, not devDependencies, since it's required at runtime.

Error: Tool handler async function never resolves Your tool starts but never returns a response, causing timeouts. This occurs when promises aren't properly awaited or when circular await dependencies exist. Always ensure every code path returns a response:

// Bad: Missing return in error path
async ({ param }) => {
  try {
    const data = await fetchData(param);
    return { content: [{ type: "text", text: data }] };
  } catch (error) {
    console.error(error); // Missing return!
  }
}

// Good: All paths return
async ({ param }) => {
  try {
    const data = await fetchData(param);
    return { content: [{ type: "text", text: data }] };
  } catch (error) {
    console.error(error);
    return { 
      content: [{ type: "text", text: "Error fetching data" }],
      isError: true 
    };
  }
}

Error: Zod validation passes but handler receives undefined Parameters are undefined despite Zod validation passing. This happens with schema transformation or when using .transform() without proper type updates. Ensure schema output types match handler expectations:

// Problem: Transform changes type
const schema = z.object({
  date: z.string().transform(str => new Date(str))
});

// Solution: Use preprocessing or handle in handler
const schema = z.object({
  date: z.string().datetime()
});

async ({ date }) => {
  const dateObj = new Date(date); // Transform in handler
  // ... rest of implementation
}

Production Deployment Considerations

When deploying tools to production, implement proper observability and resource management. Add structured logging to track tool usage and performance:

import { Logger } from "winston";

const withLogging = (toolName: string, handler: Function) => {
  return async (args: any) => {
    const startTime = Date.now();
    const requestId = generateRequestId();
    
    logger.info("Tool invoked", {
      tool: toolName,
      requestId,
      args: sanitizeArgs(args)
    });
    
    try {
      const result = await handler(args);
      
      logger.info("Tool completed", {
        tool: toolName,
        requestId,
        duration: Date.now() - startTime,
        success: !result.isError
      });
      
      return result;
    } catch (error) {
      logger.error("Tool failed", {
        tool: toolName,
        requestId,
        error: error.message,
        duration: Date.now() - startTime
      });
      
      throw error;
    }
  };
};

// Apply to all tools
server.tool("production_tool",
  schema,
  withLogging("production_tool", async (args) => {
    // Tool implementation
  })
);

Implement rate limiting for resource-intensive tools to prevent abuse:

const rateLimiter = new Map<string, number[]>();

const withRateLimit = (limit: number, window: number) => {
  return (handler: Function) => {
    return async (args: any, context: any) => {
      const key = context.sessionId || "global";
      const now = Date.now();
      const timestamps = rateLimiter.get(key) || [];
      
      // Remove old timestamps
      const recent = timestamps.filter(t => now - t < window);
      
      if (recent.length >= limit) {
        return {
          content: [{
            type: "text",
            text: "Rate limit exceeded. Please try again later."
          }],
          isError: true
        };
      }
      
      recent.push(now);
      rateLimiter.set(key, recent);
      
      return handler(args, context);
    };
  };
};

Monitor resource usage and set appropriate timeouts for tools that make external API calls or perform intensive computations. Use environment variables for configuration to avoid hardcoding sensitive values.

Next Steps

With custom tools implemented, enhance your MCP server by exploring advanced patterns. Add comprehensive error handling strategies from our error handling guide. Implement thorough testing using patterns from the validation testing guide.

Consider implementing more complex tool interactions like multi-step workflows, tool chaining, and conditional execution based on previous results. The MCP protocol's flexibility allows sophisticated tool ecosystems that can handle real-world business logic while maintaining type safety and reliability.