Introduction
Once you understand the basics of defining tools, you can apply advanced patterns for common real-world scenarios: calling external APIs, performing file operations, querying databases, handling long-running operations, and composing tools together.
Key Concepts
- API wrapper tools: Tools that act as a bridge between the LLM and external REST or GraphQL APIs.
- File operation tools: Tools that read, write, or transform files on the server's filesystem.
- Database query tools: Tools that execute SQL or NoSQL queries and return results.
- Tool composition: Combining multiple tool results within a single handler for complex workflows.
Real World Context
A DevOps MCP server might expose tools for checking deployment status (API call), reading log files (file operation), querying metrics from a time-series database (database query), and running a full health check that combines all three (tool composition). Each pattern requires slightly different error handling and result formatting.
Deep Dive
API wrapper tools translate LLM requests into HTTP calls. The key pattern is to handle HTTP errors gracefully and format the response for readability.
Here is a tool that wraps a weather API:
typescriptserver.registerTool('get-weather', { description: 'Get current weather for a city', inputSchema: z.object({ city: z.string().describe('City name, e.g. "San Francisco"') }) }, async ({ city }): Promise<CallToolResult> => { const res = await fetch( `https://api.weather.example.com/current?city=${encodeURIComponent(city)}` ); if (!res.ok) { return { content: [{ type: 'text', text: `Weather API error: ${res.status}` }], isError: true }; } const data = await res.json(); return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; });
The handler checks the HTTP status and returns isError: true for failed requests. This lets the LLM know the operation failed and why, without crashing the server.
File operation tools need careful path validation to prevent directory traversal attacks.
Here is a safe file reading tool:
typescriptimport * as path from 'path'; import * as fs from 'fs/promises'; const ALLOWED_DIR = '/data/reports'; server.registerTool('read-report', { description: 'Read a report file from the reports directory', inputSchema: z.object({ filename: z.string().describe('Report filename') }), annotations: { readOnlyHint: true, destructiveHint: false } }, async ({ filename }): Promise<CallToolResult> => { const fullPath = path.resolve(ALLOWED_DIR, filename); if (!fullPath.startsWith(ALLOWED_DIR)) { return { content: [{ type: 'text', text: 'Access denied: path outside allowed directory' }], isError: true }; } const content = await fs.readFile(fullPath, 'utf-8'); return { content: [{ type: 'text', text: content }] }; });
The path.resolve() and prefix check ensure the user cannot escape the allowed directory with ../ sequences. This is critical for any tool that accesses the filesystem.
Database query tools follow a similar pattern. Execute the query, format results, and handle errors.
Here is an example using a SQL client:
typescriptserver.registerTool('query-users', { description: 'Query users with optional filters', inputSchema: z.object({ status: z.enum(['active', 'inactive']).optional(), limit: z.number().max(100).default(10) }), annotations: { readOnlyHint: true } }, async ({ status, limit }): Promise<CallToolResult> => { const rows = await db.query( 'SELECT id, name, email FROM users WHERE ($1::text IS NULL OR status = $1) LIMIT $2', [status ?? null, limit] ); return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] }; });
This tool uses parameterized queries to prevent SQL injection. The Zod schema provides input validation, and the database client handles parameterization.
For tool composition, you can call shared utility functions from multiple tool handlers to build complex workflows.
Here is an example of a health check tool that aggregates results:
typescriptasync function checkApi(): Promise<string> { const res = await fetch('https://api.example.com/health'); return res.ok ? 'API: healthy' : 'API: unhealthy'; } async function checkDb(): Promise<string> { const result = await db.query('SELECT 1'); return result ? 'DB: healthy' : 'DB: unhealthy'; } server.registerTool('health-check', { description: 'Run a full system health check', inputSchema: z.object({}) }, async (): Promise<CallToolResult> => { const results = await Promise.all([checkApi(), checkDb()]); return { content: [{ type: 'text', text: results.join('\n') }] }; });
The Promise.all call runs checks in parallel and aggregates the results into a single response. This is more efficient than sequential calls and gives the LLM a complete picture in one tool invocation.
Common Pitfalls
- No path sanitization in file tools: Always validate and sanitize file paths to prevent directory traversal.
- Unbounded database queries: Always include a LIMIT clause or maximum result count to prevent returning enormous datasets.
- Not encoding URL parameters: Use
encodeURIComponent()when building URLs from user input to prevent injection.
Best Practices
- Use parameterized queries for all database tools to prevent SQL injection.
- Set reasonable defaults and maximum limits for query tools (e.g.,
limit: z.number().max(100).default(10)). - Run independent checks in parallel with
Promise.allfor composition tools. - Add appropriate annotations: API tools get
openWorldHint: true, file readers getreadOnlyHint: true.
Summary
You explored four advanced tool patterns: API wrappers with error handling, secure file operations with path validation, parameterized database queries, and tool composition with parallel execution. These patterns cover the most common real-world use cases for MCP server tools.
Code Examples
server.registerTool('get-weather', {
description: 'Get current weather for a city',
inputSchema: z.object({
city: z.string().describe('City name')
})
}, async ({ city }): Promise<CallToolResult> => {
const res = await fetch(
`https://api.weather.example.com/current?city=${encodeURIComponent(city)}`
);
if (!res.ok) {
return {
content: [{ type: 'text', text: `Weather API error: ${res.status}` }],
isError: true
};
}
const data = await res.json();
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
});