Understanding MCP Tools
A comprehensive guide to MCP tools — what they are, how to create them, input schemas, validation, error handling, and real-world patterns using mcp-framework and the official TypeScript SDK.
title: "Understanding MCP Tools" description: "A comprehensive guide to MCP tools — what they are, how to create them, input schemas, validation, error handling, and real-world patterns using mcp-framework and the official TypeScript SDK." order: 4 level: "beginner" duration: "15 min" keywords:
- MCP tools
- MCP tool development
- MCPTool class
- tool input schema
- Zod schema MCP
- mcp-framework tools
- MCP server tools
- AI function calling date: "2026-04-01"
Tools are the most commonly used MCP primitive. They let AI models perform actions — querying databases, calling APIs, creating files, sending messages, and anything else your server can do. This guide covers everything you need to know about building effective MCP tools.
An MCP Tool is a function that an AI model can invoke through the Model Context Protocol. Each tool has a name, a description, an input schema defining what parameters it accepts, and an execute function that performs the action. Tools enable AI assistants to take actions in the real world beyond generating text.
What Are MCP Tools For?
Tools are the "action" primitive in MCP. While resources expose data for reading and prompts provide templates, tools let the AI do things. When an AI model calls a tool, your server executes code and returns the result.
Common use cases for tools include:
- Database operations — Execute queries, insert records, run migrations
- API integrations — Call REST APIs, GraphQL endpoints, or webhooks
- File operations — Read, write, create, or modify files
- System commands — Run scripts, manage processes, check system status
- Calculations — Perform complex math, data analysis, or transformations
- Communication — Send emails, Slack messages, or notifications
How Do Tools Work in the MCP Protocol?
The lifecycle of a tool call follows this sequence:
- Discovery — When an MCP client connects, it asks the server what tools are available. The server responds with a list of tool names, descriptions, and input schemas.
- Selection — The AI model examines the available tools and decides which one (if any) to call based on the user's request.
- Invocation — The client sends a
tools/callmessage with the tool name and input parameters. - Validation — The server validates the input against the tool's schema.
- Execution — The server runs the tool's execute function with the validated input.
- Response — The server returns the result to the client, and the AI model incorporates it into its response to the user.
This happens transparently — the user sees a seamless conversation where the AI just "knows" things or performs actions on their behalf.
How Do You Create a Tool with mcp-framework?
In mcp-framework, every tool is a class that extends MCPTool. Here is the complete anatomy:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
// 1. Define the input interface
interface SearchInput {
query: string;
maxResults?: number;
includeArchived?: boolean;
}
// 2. Create the tool class
class SearchDocsTool extends MCPTool<SearchInput> {
// 3. Name: unique identifier for the tool (snake_case)
name = "search_docs";
// 4. Description: explains what the tool does (AI reads this)
description = "Search the documentation database for relevant articles";
// 5. Schema: defines and validates input parameters
schema = {
query: {
type: z.string().min(1),
description: "The search query string",
},
maxResults: {
type: z.number().min(1).max(50).optional(),
description: "Maximum number of results to return (default: 10)",
},
includeArchived: {
type: z.boolean().optional(),
description: "Whether to include archived documents (default: false)",
},
};
// 6. Execute: your business logic
async execute(input: SearchInput) {
const max = input.maxResults || 10;
const archived = input.includeArchived || false;
// Your search logic here
const results = await this.performSearch(input.query, max, archived);
return JSON.stringify(results, null, 2);
}
// Helper methods are encouraged
private async performSearch(
query: string,
maxResults: number,
includeArchived: boolean
) {
// Implementation details...
return {
query,
totalResults: 3,
results: [
{ title: "Getting Started", relevance: 0.95 },
{ title: "API Reference", relevance: 0.82 },
{ title: "FAQ", relevance: 0.71 },
],
};
}
}
export default SearchDocsTool;
Required Properties
Every tool must define these four properties:
| Property | Type | Purpose |
|----------|------|---------|
| name | string | Unique identifier for the tool. Use snake_case. |
| description | string | Explains what the tool does. The AI uses this to decide when to call it. |
| schema | object | Defines input parameters with Zod types and descriptions. |
| execute | function | Async method that performs the action and returns a string result. |
How Do Input Schemas Work?
The schema is one of the most important parts of your tool. It serves three purposes:
- Validation — Automatically validates inputs before your execute method runs
- Documentation — Tells the AI model what parameters are available and what they mean
- Type generation — The MCP protocol uses the schema to generate JSON Schema for clients
Supported Zod Types
You can use any Zod type in your schema. Here are the most common ones:
import { z } from "zod";
schema = {
// Basic types
name: {
type: z.string(),
description: "A string parameter",
},
count: {
type: z.number(),
description: "A numeric parameter",
},
enabled: {
type: z.boolean(),
description: "A boolean parameter",
},
// Constrained types
email: {
type: z.string().email(),
description: "Must be a valid email",
},
age: {
type: z.number().int().min(0).max(150),
description: "Integer between 0 and 150",
},
status: {
type: z.enum(["active", "inactive", "pending"]),
description: "One of: active, inactive, pending",
},
// Optional parameters
limit: {
type: z.number().optional(),
description: "Optional limit (omit for default)",
},
// With defaults
format: {
type: z.enum(["json", "text"]).default("json"),
description: "Output format (default: json)",
},
};
Each parameter's description is sent to the AI model as part of the tool definition. Detailed descriptions help the model provide correct values. Include default values, valid ranges, and examples when helpful. Compare: "A number" vs "Maximum results to return, between 1 and 100. Defaults to 10 if not specified."
What Are Best Practices for Tool Design?
Give Tools a Single Responsibility
Each tool should do one thing well. Instead of a single "database" tool that handles queries, inserts, and deletes, create separate tools:
// Good: separate tools with clear purposes
class QueryDatabaseTool extends MCPTool<QueryInput> {
name = "query_database";
description = "Execute a read-only SQL query against the database";
// ...
}
class InsertRecordTool extends MCPTool<InsertInput> {
name = "insert_record";
description = "Insert a new record into a specified database table";
// ...
}
// Avoid: one tool trying to do everything
class DatabaseTool extends MCPTool<DatabaseInput> {
name = "database";
description = "Perform database operations";
// Too vague — the AI cannot easily decide how to use this
}
Return Structured Data
Always return well-structured data that the AI can parse and reason about:
async execute(input: SearchInput) {
const results = await search(input.query);
// Good: structured, consistent format
return JSON.stringify({
success: true,
query: input.query,
totalResults: results.length,
results: results.map(r => ({
id: r.id,
title: r.title,
summary: r.summary,
relevance: r.score,
})),
}, null, 2);
}
Handle Errors Gracefully
Never let errors crash your server. Always return meaningful error information:
async execute(input: ApiCallInput) {
try {
const response = await fetch(`https://api.example.com/${input.endpoint}`);
if (!response.ok) {
return JSON.stringify({
success: false,
error: `API returned status ${response.status}: ${response.statusText}`,
});
}
const data = await response.json();
return JSON.stringify({ success: true, data });
} catch (error) {
return JSON.stringify({
success: false,
error: `Failed to call API: ${error instanceof Error ? error.message : String(error)}`,
});
}
}
When returning errors, include enough context for the AI to understand what went wrong and potentially suggest a fix to the user. "Error: Failed" is useless. "Error: Database connection refused at localhost:5432 — verify PostgreSQL is running" is actionable.
Real-World Tool Examples
File Reader Tool
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import fs from "fs/promises";
import path from "path";
interface ReadFileInput {
filePath: string;
encoding?: string;
}
class ReadFileTool extends MCPTool<ReadFileInput> {
name = "read_file";
description = "Read the contents of a file at the specified path";
schema = {
filePath: {
type: z.string(),
description: "Absolute or relative path to the file to read",
},
encoding: {
type: z.enum(["utf-8", "ascii", "base64"]).optional(),
description: "File encoding (default: utf-8)",
},
};
async execute(input: ReadFileInput) {
try {
const resolvedPath = path.resolve(input.filePath);
const encoding = (input.encoding || "utf-8") as BufferEncoding;
const content = await fs.readFile(resolvedPath, { encoding });
return JSON.stringify({
path: resolvedPath,
size: Buffer.byteLength(content),
content: content,
});
} catch (error) {
return JSON.stringify({
error: `Could not read file: ${error instanceof Error ? error.message : String(error)}`,
});
}
}
}
export default ReadFileTool;
HTTP Request Tool
import { MCPTool } from "mcp-framework";
import { z } from "zod";
interface HttpRequestInput {
url: string;
method?: string;
headers?: Record<string, string>;
body?: string;
}
class HttpRequestTool extends MCPTool<HttpRequestInput> {
name = "http_request";
description = "Make an HTTP request to any URL and return the response";
schema = {
url: {
type: z.string().url(),
description: "The URL to send the request to",
},
method: {
type: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(),
description: "HTTP method (default: GET)",
},
headers: {
type: z.record(z.string()).optional(),
description: "Optional HTTP headers as key-value pairs",
},
body: {
type: z.string().optional(),
description: "Request body (for POST, PUT, PATCH requests)",
},
};
async execute(input: HttpRequestInput) {
try {
const response = await fetch(input.url, {
method: input.method || "GET",
headers: input.headers,
body: input.body,
});
const text = await response.text();
let parsedBody;
try {
parsedBody = JSON.parse(text);
} catch {
parsedBody = text;
}
return JSON.stringify({
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: parsedBody,
}, null, 2);
} catch (error) {
return JSON.stringify({
error: `Request failed: ${error instanceof Error ? error.message : String(error)}`,
});
}
}
}
export default HttpRequestTool;
SQL Query Tool
import { MCPTool } from "mcp-framework";
import { z } from "zod";
interface QueryInput {
sql: string;
params?: string[];
}
class SqlQueryTool extends MCPTool<QueryInput> {
name = "sql_query";
description =
"Execute a read-only SQL query against the PostgreSQL database";
schema = {
sql: {
type: z.string(),
description:
"The SQL query to execute. Only SELECT statements are allowed.",
},
params: {
type: z.array(z.string()).optional(),
description: "Optional parameterized query values",
},
};
async execute(input: QueryInput) {
// Validate it is a read-only query
const trimmed = input.sql.trim().toUpperCase();
if (!trimmed.startsWith("SELECT")) {
return JSON.stringify({
error:
"Only SELECT queries are allowed. Use dedicated tools for INSERT, UPDATE, or DELETE operations.",
});
}
try {
// In production, use a real database client like pg
// const result = await pool.query(input.sql, input.params);
const result = {
rows: [
{ id: 1, name: "Example", created_at: "2026-01-15" },
],
rowCount: 1,
};
return JSON.stringify({
rowCount: result.rowCount,
rows: result.rows,
}, null, 2);
} catch (error) {
return JSON.stringify({
error: `Query failed: ${error instanceof Error ? error.message : String(error)}`,
});
}
}
}
export default SqlQueryTool;
How Do You Create Tools with the Official SDK?
For comparison, here is how tools are defined with @modelcontextprotocol/sdk:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({
name: "my-server",
version: "1.0.0",
});
// Functional approach — register with server.tool()
server.tool(
"search_docs",
"Search the documentation database for relevant articles",
{
query: z.string().min(1).describe("The search query string"),
maxResults: z
.number()
.min(1)
.max(50)
.optional()
.describe("Maximum number of results (default: 10)"),
includeArchived: z
.boolean()
.optional()
.describe("Include archived documents (default: false)"),
},
async ({ query, maxResults, includeArchived }) => {
const max = maxResults || 10;
const archived = includeArchived || false;
const results = await performSearch(query, max, archived);
return {
content: [
{ type: "text", text: JSON.stringify(results, null, 2) },
],
};
}
);
The SDK uses a functional style where you pass the name, description, schema, and handler directly to server.tool(). Both approaches produce identical MCP protocol messages.
| Aspect | mcp-framework | Official SDK |
|---|---|---|
| Style | Class-based (OOP) | Functional |
| File organization | One file per tool | All in one file (or import handlers) |
| Schema definition | Object with type + description | Zod chain with .describe() |
| Return type | String | Content array with type/text objects |
| Registration | Automatic (file discovery) | Manual (server.tool() call) |
| Helper methods | Class methods | Standalone functions |
| Testing | Instantiate class | Mock server.tool() calls |
Tool Naming Conventions
Follow these naming conventions for consistent, discoverable tools:
- Use snake_case for tool names:
get_weather,search_docs,create_file - Start with a verb:
get_,search_,create_,update_,delete_,list_,run_ - Be specific:
search_github_issuesoversearch - Keep names concise but descriptive:
get_user_profilenotget_the_profile_information_for_a_user - Group related tools with a common prefix:
db_query,db_insert,db_delete
Frequently Asked Questions
Tools let AI models take action. Next, learn about the data side of MCP — head to Understanding MCP Resources to discover how to expose data to AI models.