MCP Tool Development Guide

Tier 2 | Walkthrough for creating MCP tools Hub: README.md | Architecture: MCP_PROTOCOL.md


Overview

This guide walks through creating new MCP tools for nexus-agents. Tools follow the MCP 2025-11-25 specification and use Zod for input validation.


Tool Architecture

Core Interface

interface ITool {
  readonly name: string;
  readonly description: string;
  readonly inputSchema: z.ZodSchema;

  execute(input: unknown): Promise<ToolResult>;
}

interface ToolResult {
  isError?: boolean;
  content: ToolContentBlock[];
}

type ToolContentBlock =
  | { type: 'text'; text: string }
  | { type: 'image'; data: string; mimeType: string }
  | { type: 'resource'; uri: string; mimeType?: string };

Creating a New Tool

Step 1: Define Input Schema

// src/mcp/tools/my-tool.ts
import { z } from 'zod';

const MyToolInputSchema = z.object({
  // Required parameters
  query: z.string().min(1).describe('Search query to execute'),

  // Optional parameters
  limit: z.number().min(1).max(100).default(10).describe('Maximum results to return'),

  // Enum parameters
  format: z.enum(['json', 'text', 'markdown']).default('text').describe('Output format'),
});

type MyToolInput = z.infer<typeof MyToolInputSchema>;

Step 2: Implement Tool Class

import type { ITool, ToolResult } from '../types.js';

export const myTool: ITool = {
  name: 'my_tool',
  description: `Search for items matching a query.

Use this tool when you need to:
- Find specific items
- Search by criteria

Parameters:
- query: The search query (required)
- limit: Maximum results (default: 10)
- format: Output format (default: text)`,

  inputSchema: MyToolInputSchema,

  async execute(input: unknown): Promise<ToolResult> {
    // 1. Validate input
    const parsed = MyToolInputSchema.safeParse(input);
    if (!parsed.success) {
      return {
        isError: true,
        content: [
          {
            type: 'text',
            text: `Validation error: ${parsed.error.message}`,
          },
        ],
      };
    }

    const { query, limit, format } = parsed.data;

    try {
      // 2. Execute tool logic
      const results = await searchItems(query, limit);

      // 3. Format output
      const output = formatResults(results, format);

      return {
        content: [{ type: 'text', text: output }],
      };
    } catch (error) {
      return {
        isError: true,
        content: [
          {
            type: 'text',
            text: `Error: ${error instanceof Error ? error.message : String(error)}`,
          },
        ],
      };
    }
  },
};

Step 3: Register Tool

// src/mcp/tools/index.ts
import { myTool } from './my-tool.js';

export function registerTools(server: McpServer, registry: IToolRegistry): void {
  registry.register(myTool);
  // ... other tools
}

Tool Design Patterns

Pattern 1: Direct Registration

For simple tools, register directly with the server:

server.tool(
  'simple_tool',
  {
    param: z.string().describe('What this does'),
  },
  async (args) => {
    return {
      content: [{ type: 'text', text: 'Result' }],
    };
  }
);

Pattern 2: Factory Pattern

For tools with dependencies:

function createMyTool(deps: { adapter: IModelAdapter }): ITool {
  return {
    name: 'my_tool',
    description: '...',
    inputSchema: Schema,
    async execute(input) {
      // Use deps.adapter
      const response = await deps.adapter.complete(request);
      return { content: [{ type: 'text', text: response }] };
    },
  };
}

Pattern 3: Async Resource Loading

For tools that need to load resources:

async execute(input: unknown): Promise<ToolResult> {
  const parsed = Schema.safeParse(input);
  if (!parsed.success) {
    return { isError: true, content: [{ type: 'text', text: parsed.error.message }] };
  }

  // Validate resource exists
  const exists = await resourceExists(parsed.data.path);
  if (!exists) {
    return {
      isError: true,
      content: [{ type: 'text', text: 'Resource not found' }],
    };
  }

  // Load and process
  const content = await loadResource(parsed.data.path);
  return { content: [{ type: 'text', text: content }] };
}

Error Handling

Tool Errors vs Protocol Errors

TypeUse isError: trueThrow Exception
Invalid inputYesNo
Resource not foundYesNo
Rate limitedYesNo
Internal errorNoYes
Protocol errorNoYes

Error Response Pattern

// Recoverable error (tool error)
return {
  isError: true,
  content: [
    {
      type: 'text',
      text: 'Validation failed: query cannot be empty',
    },
  ],
};

// Unrecoverable error (protocol error)
throw new McpError(ErrorCode.InternalError, 'Database connection failed');

Security Considerations

Path Traversal Prevention

import { validatePath } from '../../security/path-validator.js';

async execute(input: unknown): Promise<ToolResult> {
  const { filePath } = Schema.parse(input);

  // Validate path is within allowed directory
  const validPath = validatePath(filePath, process.cwd());
  if (!validPath.ok) {
    return {
      isError: true,
      content: [{ type: 'text', text: 'Invalid path' }],
    };
  }

  // Safe to use validPath.value
}

Input Sanitization

// Use Zod for type validation
const Schema = z.object({
  // Never allow arbitrary regex
  pattern: z.string().regex(/^[a-zA-Z0-9_-]+$/),

  // Limit string lengths
  content: z.string().max(10000),

  // Validate URLs
  url: z.string().url(),
});

Rate Limiting

import { rateLimiter } from '../../security/rate-limiter.js';

async execute(input: unknown): Promise<ToolResult> {
  // Check rate limit before execution
  if (!rateLimiter.consume('my_tool', 1)) {
    return {
      isError: true,
      content: [{ type: 'text', text: 'Rate limit exceeded. Try again later.' }],
    };
  }

  // Proceed with execution
}

Testing MCP Tools

Unit Tests

// src/mcp/tools/my-tool.test.ts
import { describe, it, expect } from 'vitest';
import { myTool } from './my-tool.js';

describe('my_tool', () => {
  it('should handle valid input', async () => {
    const result = await myTool.execute({
      query: 'test',
      limit: 5,
    });

    expect(result.isError).toBeFalsy();
    expect(result.content).toHaveLength(1);
    expect(result.content[0].type).toBe('text');
  });

  it('should return error for invalid input', async () => {
    const result = await myTool.execute({
      query: '', // Empty string should fail
    });

    expect(result.isError).toBe(true);
  });

  it('should handle missing optional parameters', async () => {
    const result = await myTool.execute({
      query: 'test',
    });

    expect(result.isError).toBeFalsy();
  });
});

Integration Tests

import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';

describe('my_tool integration', () => {
  let client: Client;
  let server: Server;

  beforeEach(async () => {
    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

    server = createServer();
    client = new Client({ name: 'test-client' });

    await server.connect(serverTransport);
    await client.connect(clientTransport);
  });

  it('should execute via MCP protocol', async () => {
    const result = await client.callTool({
      name: 'my_tool',
      arguments: { query: 'test' },
    });

    expect(result.isError).toBe(false);
    expect(result.content).toHaveLength(1);
  });
});

Tool Description Guidelines

Good descriptions help Claude decide when to use your tool:

// Good description
description: `Analyze code files for security vulnerabilities.

Use this tool when:
- Reviewing code for security issues
- Checking for common vulnerability patterns
- Auditing authentication/authorization code

Parameters:
- files: List of file paths to analyze (required)
- severity: Minimum severity to report (default: medium)

Returns:
- List of vulnerabilities found with severity and remediation`;

// Bad description
description: 'Analyze files'; // Too vague

Source Files

FilePurpose
src/mcp/index.tsServer creation
src/mcp/tools/Tool implementations
src/mcp/types.tsType definitions
src/mcp/resources/Resource handlers