Skip to content

Adding MCP Tools

Learn how to create custom MCP (Model Context Protocol) tools to extend Seed's capabilities.

Overview

MCP tools are functions that Claude can call to perform actions or retrieve data. Seed implements tools using the @modelcontextprotocol/sdk.

Tool Architecture

Tool Components

src/mcp/tools/
├── index.ts              # Tool registry
├── seed_ping.ts        # Example tool
└── seed_ping.test.ts   # Example tool tests

Tool workflow:

  1. Client calls tools/list → Server returns available tools
  2. Client calls tools/call with tool name and arguments
  3. Server executes tool and returns result

Tool Structure

typescript
export function registerMyTool(server: McpServer): void {
  server.registerTool(
    "tool_name",              // Tool identifier
    {
      description: "...",     // Human-readable description
      inputSchema: {...}      // JSON Schema for parameters
    },
    async (args) => {         // Tool handler function
      // Tool logic here
      return {
        content: [...]        // Tool result
      };
    }
  );
}

Creating a Tool

Step 1: Create Tool File

Create src/mcp/tools/my_tool.ts:

typescript
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

export function registerMyTool(server: McpServer): void {
  server.registerTool(
    "my_tool",
    {
      description: "Description of what the tool does",
      inputSchema: {
        type: "object",
        properties: {
          // Define parameters here
        },
        required: [], // Required parameter names
        additionalProperties: false,
      },
    },
    async (args) => {
      // Tool implementation
      return {
        content: [
          {
            type: "text",
            text: "Result text",
          },
        ],
      };
    }
  );
}

Step 2: Define Input Schema

Use JSON Schema to define parameters:

typescript
inputSchema: {
  type: "object",
  properties: {
    message: {
      type: "string",
      description: "Message to process",
    },
    count: {
      type: "number",
      description: "Number of times to repeat",
      minimum: 1,
      maximum: 10,
    },
    options: {
      type: "object",
      properties: {
        uppercase: {
          type: "boolean",
          description: "Convert to uppercase",
        },
      },
      additionalProperties: false,
    },
  },
  required: ["message"], // 'message' is required, others optional
  additionalProperties: false,
}

Supported types:

  • string - Text values
  • number - Numeric values
  • boolean - True/false
  • object - Nested objects
  • array - Lists of values

Step 3: Implement Tool Logic

typescript
async (args) => {
  // Type the arguments
  interface ToolArgs {
    message: string;
    count?: number;
    options?: {
      uppercase?: boolean;
    };
  }

  const { message, count = 1, options } = args as ToolArgs;

  // Validate arguments
  if (!message) {
    return {
      content: [
        {
          type: "text",
          text: "Error: message is required",
        },
      ],
      isError: true,
    };
  }

  // Implement tool logic
  let result = message.repeat(count);
  if (options?.uppercase) {
    result = result.toUpperCase();
  }

  // Return result
  return {
    content: [
      {
        type: "text",
        text: result,
      },
    ],
  };
}

Step 4: Register Tool

Add to src/mcp/tools/index.ts:

typescript
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerSeedPing } from "./seed_ping.js";
import { registerMyTool } from "./my_tool.js"; // Import your tool

export function registerTools(server: McpServer): void {
  registerSeedPing(server);
  registerMyTool(server);  // Register your tool
}

Step 5: Test Tool

Create src/mcp/tools/my_tool.test.ts:

typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerMyTool } from "./my_tool.js";

describe("my_tool", () => {
  let server: McpServer;
  let toolHandler: (args: unknown) => Promise<{
    content: { type: string; text: string }[];
    isError?: boolean;
  }>;

  beforeEach(() => {
    server = {
      registerTool: vi.fn((name, options, handler) => {
        toolHandler = handler;
      }),
    } as unknown as McpServer;

    registerMyTool(server);
  });

  it("should register the tool", () => {
    expect(server.registerTool).toHaveBeenCalledWith(
      "my_tool",
      expect.objectContaining({
        description: expect.any(String),
      }),
      expect.any(Function)
    );
  });

  it("should process message", async () => {
    const result = await toolHandler({ message: "test" });

    expect(result.content).toHaveLength(1);
    expect(result.content[0]?.text).toBe("test");
  });

  it("should repeat message", async () => {
    const result = await toolHandler({ message: "hi", count: 3 });

    expect(result.content[0]?.text).toBe("hihihi");
  });

  it("should convert to uppercase", async () => {
    const result = await toolHandler({
      message: "hello",
      options: { uppercase: true },
    });

    expect(result.content[0]?.text).toBe("HELLO");
  });

  it("should return error for missing message", async () => {
    const result = await toolHandler({});

    expect(result.isError).toBe(true);
    expect(result.content[0]?.text).toContain("required");
  });
});

Content Types

Text Content

Most common type for returning text or JSON:

typescript
return {
  content: [
    {
      type: "text",
      text: "Plain text or markdown response",
    },
  ],
};

// Multiple text blocks
return {
  content: [
    {
      type: "text",
      text: "First block",
    },
    {
      type: "text",
      text: "Second block",
    },
  ],
};

// JSON as text
return {
  content: [
    {
      type: "text",
      text: JSON.stringify({ key: "value" }, null, 2),
    },
  ],
};

Image Content

Return base64-encoded images:

typescript
import { readFileSync } from "fs";

return {
  content: [
    {
      type: "image",
      data: readFileSync("/path/to/image.png").toString("base64"),
      mimeType: "image/png",
    },
  ],
};

Resource Content

Reference external resources:

typescript
return {
  content: [
    {
      type: "resource",
      resource: {
        uri: "https://example.com/resource",
        name: "Resource Name",
        description: "Resource description",
      },
    },
  ],
};

Error Handling

Returning Errors

Use isError: true for expected failures:

typescript
return {
  content: [
    {
      type: "text",
      text: "Error: Invalid parameter value",
    },
  ],
  isError: true,
};

Throwing Exceptions

For unexpected errors, throw exceptions:

typescript
async (args) => {
  try {
    const result = await someAsyncOperation();
    return {
      content: [{ type: "text", text: result }],
    };
  } catch (error) {
    // Log error for debugging
    console.error("Tool error:", error);

    // Re-throw to let SDK handle it
    throw new Error(`Operation failed: ${error.message}`);
  }
}

Advanced Patterns

Accessing User Context

Access authenticated user information:

typescript
import { config } from "../../config/index.js";

async (args, { req }) => {
  // Access user from JWT token
  const user = req?.user;

  if (!user) {
    return {
      content: [{ type: "text", text: "Authentication required" }],
      isError: true,
    };
  }

  return {
    content: [
      {
        type: "text",
        text: `Hello, ${user.name ?? user.sub}!`,
      },
    ],
  };
}

Using External Services

Integrate with Redis or other services:

typescript
import { redisClient } from "../../services/redis.js";

async (args) => {
  const { key } = args as { key: string };

  try {
    const value = await redisClient.get(key);

    if (!value) {
      return {
        content: [{ type: "text", text: "Key not found" }],
        isError: true,
      };
    }

    return {
      content: [{ type: "text", text: value }],
    };
  } catch (error) {
    throw new Error(`Redis error: ${error.message}`);
  }
}

Async Operations

Handle promises and async operations:

typescript
async (args) => {
  const { url } = args as { url: string };

  // Fetch external data
  const response = await fetch(url);
  const data = await response.json();

  // Process data
  const result = processData(data);

  return {
    content: [
      {
        type: "text",
        text: JSON.stringify(result, null, 2),
      },
    ],
  };
}

Parameter Validation

Validate parameters before processing:

typescript
async (args) => {
  interface Args {
    email: string;
    age?: number;
  }

  const { email, age } = args as Args;

  // Validate email format
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) {
    return {
      content: [{ type: "text", text: "Invalid email format" }],
      isError: true,
    };
  }

  // Validate age range
  if (age !== undefined && (age < 0 || age > 150)) {
    return {
      content: [{ type: "text", text: "Age must be between 0 and 150" }],
      isError: true,
    };
  }

  // Process valid input
  return {
    content: [{ type: "text", text: `Valid: ${email}, age ${age ?? "unknown"}` }],
  };
}

Real-World Example

Database Query Tool

typescript
// src/mcp/tools/db_query.ts
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { redisClient } from "../../services/redis.js";

export function registerDbQuery(server: McpServer): void {
  server.registerTool(
    "db_query",
    {
      description: "Query Redis database for OAuth client information",
      inputSchema: {
        type: "object",
        properties: {
          client_id: {
            type: "string",
            description: "OAuth client ID to query",
            pattern: "^seed-[a-zA-Z0-9_-]+$",
          },
          fields: {
            type: "array",
            items: {
              type: "string",
              enum: ["client_name", "redirect_uris", "grant_types"],
            },
            description: "Specific fields to retrieve (optional)",
          },
        },
        required: ["client_id"],
        additionalProperties: false,
      },
    },
    async (args, { req }) => {
      interface Args {
        client_id: string;
        fields?: string[];
      }

      const { client_id, fields } = args as Args;

      // Check authentication
      if (!req?.user) {
        return {
          content: [{ type: "text", text: "Authentication required" }],
          isError: true,
        };
      }

      try {
        // Query Redis
        const key = `dcr:client:${client_id}`;
        const data = await redisClient.get(key);

        if (!data) {
          return {
            content: [{ type: "text", text: `Client not found: ${client_id}` }],
            isError: true,
          };
        }

        // Parse client data
        const client = JSON.parse(data);

        // Filter fields if specified
        const result = fields
          ? Object.fromEntries(
              Object.entries(client).filter(([key]) => fields.includes(key))
            )
          : client;

        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(result, null, 2),
            },
          ],
        };
      } catch (error) {
        console.error("DB query error:", error);
        throw new Error(`Database query failed: ${error.message}`);
      }
    }
  );
}

Testing the Tool

typescript
// src/mcp/tools/db_query.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerDbQuery } from "./db_query.js";

// Mock Redis
vi.mock("../../services/redis.js", () => ({
  redisClient: {
    get: vi.fn(),
  },
}));

import { redisClient } from "../../services/redis.js";

describe("db_query tool", () => {
  let server: McpServer;
  let toolHandler: (args: unknown, context: unknown) => Promise<unknown>;

  beforeEach(() => {
    server = {
      registerTool: vi.fn((name, options, handler) => {
        toolHandler = handler;
      }),
    } as unknown as McpServer;

    registerDbQuery(server);
    vi.clearAllMocks();
  });

  it("should query client data", async () => {
    const clientData = JSON.stringify({
      client_id: "seed-abc123",
      client_name: "Test Client",
      redirect_uris: ["http://localhost:3000"],
    });

    vi.mocked(redisClient.get).mockResolvedValue(clientData);

    const result = await toolHandler(
      { client_id: "seed-abc123" },
      { req: { user: { sub: "user1" } } }
    );

    expect(result).toMatchObject({
      content: [
        {
          type: "text",
          text: expect.stringContaining("Test Client"),
        },
      ],
    });
  });

  it("should filter fields", async () => {
    const clientData = JSON.stringify({
      client_id: "seed-abc123",
      client_name: "Test Client",
      redirect_uris: ["http://localhost:3000"],
      grant_types: ["authorization_code"],
    });

    vi.mocked(redisClient.get).mockResolvedValue(clientData);

    const result = await toolHandler(
      { client_id: "seed-abc123", fields: ["client_name"] },
      { req: { user: { sub: "user1" } } }
    );

    const parsed = JSON.parse(result.content[0].text);
    expect(parsed).toEqual({ client_name: "Test Client" });
    expect(parsed.redirect_uris).toBeUndefined();
  });

  it("should require authentication", async () => {
    const result = await toolHandler(
      { client_id: "seed-abc123" },
      { req: {} } // No user
    );

    expect(result.isError).toBe(true);
    expect(result.content[0].text).toContain("Authentication required");
  });

  it("should handle missing client", async () => {
    vi.mocked(redisClient.get).mockResolvedValue(null);

    const result = await toolHandler(
      { client_id: "seed-nonexistent" },
      { req: { user: { sub: "user1" } } }
    );

    expect(result.isError).toBe(true);
    expect(result.content[0].text).toContain("not found");
  });
});

Best Practices

1. Clear Tool Names

Use descriptive, snake_case names:

typescript
// Good
"get_user_info"
"send_email"
"calculate_total"

// Bad
"tool1"
"doStuff"
"x"

2. Detailed Descriptions

Write clear, helpful descriptions:

typescript
// Good
description: "Retrieves user information from the database by user ID. Returns name, email, and registration date."

// Bad
description: "Gets user"

3. Comprehensive Input Schemas

Document all parameters:

typescript
properties: {
  user_id: {
    type: "string",
    description: "Unique user identifier (UUID format)",
    pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
  },
}

4. Validate Input

Always validate parameters:

typescript
if (!args.email || typeof args.email !== "string") {
  return {
    content: [{ type: "text", text: "Invalid email parameter" }],
    isError: true,
  };
}

5. Handle Errors Gracefully

Provide helpful error messages:

typescript
catch (error) {
  return {
    content: [
      {
        type: "text",
        text: `Failed to process request: ${error.message}. Please check the parameters and try again.`,
      },
    ],
    isError: true,
  };
}

6. Return Structured Data

Use JSON for complex data:

typescript
return {
  content: [
    {
      type: "text",
      text: JSON.stringify({
        status: "success",
        data: result,
        timestamp: new Date().toISOString(),
      }, null, 2),
    },
  ],
};

7. Test Thoroughly

Cover all scenarios:

  • Valid inputs
  • Invalid inputs
  • Edge cases
  • Error conditions
  • Authentication requirements

Debugging Tools

Logging

Add logging for development:

typescript
console.log("Tool called with args:", args);
console.log("User context:", req?.user);

Testing Manually

Test tools using the MCP endpoint:

bash
curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -H "mcp-session-id: $SESSION_ID" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "my_tool",
      "arguments": {"message": "test"}
    },
    "id": 1
  }'

Next Steps

Released under the MIT License.