Custom Tools
Add custom tools beyond your router actions to extend your MCP server's capabilities.
Overview
While the adapter automatically exposes all your Igniter.js router actions as MCP tools, you can also add custom tools that don't correspond to any router action. Custom tools are useful for:
- Utility functions (calculations, formatting, etc.)
- External API integrations
- Business logic operations
- Data transformations
Adding Custom Tools
Builder Pattern
import { IgniterMcpServer } from '@igniter-js/adapter-mcp-server';
import { z } from 'zod';
const { handler } = IgniterMcpServer
.create()
.router(AppRouter)
.addTool({
name: 'calculateTax',
description: 'Calculate tax for a given amount',
args: {
amount: z.number().describe('The amount to calculate tax for'),
taxRate: z.number().min(0).max(1).describe('Tax rate as decimal (e.g., 0.08 for 8%)'),
},
handler: async (args, context) => {
// context is automatically typed from your router!
const tax = args.amount * args.taxRate;
const total = args.amount + tax;
return {
content: [{
type: 'text',
text: `Amount: $${args.amount.toFixed(2)}\n` +
`Tax (${(args.taxRate * 100).toFixed(1)}%): $${tax.toFixed(2)}\n` +
`Total: $${total.toFixed(2)}`
}]
};
},
})
.build();Function API
import { createMcpAdapter } from '@igniter-js/adapter-mcp-server';
import { z } from 'zod';
const { server } = createMcpAdapter({
router: AppRouter,
tools: {
custom: [{
name: 'calculateTax',
description: 'Calculate tax for a given amount',
args: {
amount: z.number(),
taxRate: z.number().min(0).max(1),
},
handler: async (args, context) => {
const tax = args.amount * args.taxRate;
return {
content: [{
type: 'text',
text: `Tax: $${tax.toFixed(2)}`
}]
};
}
}]
}
});Tool Definition
Required Fields
name (string): Tool name (must be MCP-compliant, will be sanitized automatically)
description (string): Tool description shown to AI agents
args (ZodRawShape): Zod schema defining tool arguments
handler (function): Async function that executes the tool
Tool Arguments Schema
Define arguments using Zod schemas. These are automatically converted to JSON Schema for MCP:
.addTool({
name: 'processOrder',
description: 'Process an order with validation',
args: {
orderId: z.string().uuid().describe('Order UUID'),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().positive(),
price: z.number().positive(),
})).min(1).describe('Order items'),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
state: z.string().length(2),
zipCode: z.string().regex(/^\d{5}$/),
}).describe('Shipping address'),
priority: z.enum(['standard', 'express', 'overnight']).default('standard'),
},
handler: async (args, context) => {
// args is fully typed based on the schema above
// args.orderId is string
// args.items is array of { productId, quantity, price }
// args.shippingAddress is { street, city, state, zipCode }
// args.priority is 'standard' | 'express' | 'overnight'
// Your tool logic here
return {
content: [{
type: 'text',
text: `Order ${args.orderId} processed successfully`
}]
};
},
})Tool Handler
The handler function receives two arguments:
args: Tool arguments, typed based on your Zod schema
context: MCP context with router context, tools list, request info, etc.
handler: async (args, context) => {
// Access router context
const db = context.context.db;
const services = context.context.services;
// Access request information
const userAgent = context.client;
const timestamp = context.timestamp;
// Access available tools
const availableTools = context.tools;
// Your tool logic
return {
content: [{
type: 'text',
text: 'Tool result'
}]
};
}Return Format
Tools must return an MCP-compliant CallToolResult:
// Simple text response
return {
content: [{
type: 'text',
text: 'Result here'
}]
};
// Multiple content items
return {
content: [
{
type: 'text',
text: 'Primary result'
},
{
type: 'text',
text: 'Additional information'
}
]
};
// With data (for structured responses)
return {
content: [{
type: 'text',
text: JSON.stringify({ result: 'data' }, null, 2)
}]
};Practical Examples
Example 1: Currency Converter
.addTool({
name: 'convertCurrency',
description: 'Convert amount between currencies',
args: {
amount: z.number().positive(),
from: z.string().length(3).describe('Source currency code (e.g., USD)'),
to: z.string().length(3).describe('Target currency code (e.g., EUR)'),
},
handler: async (args, context) => {
// In a real app, you'd call an exchange rate API
const exchangeRates = await context.context.services.exchangeRates.getRate(args.from, args.to);
const converted = args.amount * exchangeRates.rate;
return {
content: [{
type: 'text',
text: `${args.amount} ${args.from} = ${converted.toFixed(2)} ${args.to} ` +
`(rate: ${exchangeRates.rate})`
}]
};
},
})Example 2: Data Formatter
.addTool({
name: 'formatDate',
description: 'Format a date in various formats',
args: {
date: z.string().or(z.coerce.date()),
format: z.enum(['iso', 'us', 'european', 'relative']).default('iso'),
},
handler: async (args, context) => {
const date = new Date(args.date);
let formatted: string;
switch (args.format) {
case 'iso':
formatted = date.toISOString();
break;
case 'us':
formatted = date.toLocaleDateString('en-US');
break;
case 'european':
formatted = date.toLocaleDateString('en-GB');
break;
case 'relative':
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
formatted = days === 0 ? 'Today' : `${days} days ago`;
break;
}
return {
content: [{
type: 'text',
text: formatted
}]
};
},
})Example 3: External API Integration
.addTool({
name: 'sendSlackMessage',
description: 'Send a message to a Slack channel',
args: {
channel: z.string().describe('Slack channel name or ID'),
message: z.string().min(1),
threadTs: z.string().optional().describe('Thread timestamp for replies'),
},
handler: async (args, context) => {
const slackService = context.context.services.slack;
const result = await slackService.postMessage({
channel: args.channel,
text: args.message,
thread_ts: args.threadTs,
});
return {
content: [{
type: 'text',
text: `Message sent to ${args.channel} at ${result.ts}`
}]
};
},
})Multiple Custom Tools
You can add multiple custom tools:
Builder Pattern:
const { handler } = IgniterMcpServer
.create()
.router(AppRouter)
.addTool({ /* tool 1 */ })
.addTool({ /* tool 2 */ })
.addTool({ /* tool 3 */ })
.build();Function API:
const { server } = createMcpAdapter({
router: AppRouter,
tools: {
custom: [
{ /* tool 1 */ },
{ /* tool 2 */ },
{ /* tool 3 */ },
]
}
});Error Handling
Handle errors gracefully in your tool handlers:
.addTool({
name: 'riskyOperation',
description: 'Perform a risky operation',
args: {
input: z.string(),
},
handler: async (args, context) => {
try {
const result = await performRiskyOperation(args.input);
return {
content: [{
type: 'text',
text: `Success: ${result}`
}]
};
} catch (error) {
return {
content: [{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`
}],
isError: true
};
}
},
})Best Practices
1. Descriptive Names and Descriptions
// ✅ Good
{
name: 'calculateTax',
description: 'Calculate sales tax for a given amount based on location and tax rate',
}
// ❌ Bad
{
name: 'tax',
description: 'Calculate tax',
}2. Use Zod Descriptions
Add .describe() to your schema fields to help AI agents understand parameters:
args: {
amount: z.number().describe('The monetary amount in USD'),
taxRate: z.number().min(0).max(1).describe('Tax rate as decimal (0.08 = 8%)'),
}3. Validate Inputs
Use Zod's validation features:
args: {
email: z.string().email(),
age: z.number().int().min(0).max(150),
status: z.enum(['active', 'inactive', 'pending']),
}4. Provide Clear Error Messages
handler: async (args, context) => {
try {
// Operation
} catch (error) {
return {
content: [{
type: 'text',
text: `Failed to process: ${error.message}. Please check your inputs and try again.`
}],
isError: true
};
}
}Next Steps
- Learn about Prompts & Resources
- Explore Event Handlers for monitoring
- Check out Best Practices