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 testsTool workflow:
- Client calls
tools/list→ Server returns available tools - Client calls
tools/callwith tool name and arguments - Server executes tool and returns result
Tool Structure
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:
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:
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 valuesnumber- Numeric valuesboolean- True/falseobject- Nested objectsarray- Lists of values
Step 3: Implement Tool Logic
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:
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:
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:
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:
import { readFileSync } from "fs";
return {
content: [
{
type: "image",
data: readFileSync("/path/to/image.png").toString("base64"),
mimeType: "image/png",
},
],
};Resource Content
Reference external resources:
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:
return {
content: [
{
type: "text",
text: "Error: Invalid parameter value",
},
],
isError: true,
};Throwing Exceptions
For unexpected errors, throw exceptions:
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:
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:
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:
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:
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
// 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
// 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:
// Good
"get_user_info"
"send_email"
"calculate_total"
// Bad
"tool1"
"doStuff"
"x"2. Detailed Descriptions
Write clear, helpful descriptions:
// 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:
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:
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:
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:
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:
console.log("Tool called with args:", args);
console.log("User context:", req?.user);Testing Manually
Test tools using the MCP endpoint:
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
- Testing Guide - Test your MCP tools
- Contributing Guide - Submit your tools
- MCP Server Architecture - Understand tool infrastructure