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"

Quick Summary

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.

MCP Tool

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
Most usedprimitive — tools appear in virtually every MCP server

How Do Tools Work in the MCP Protocol?

The lifecycle of a tool call follows this sequence:

  1. 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.
  2. Selection — The AI model examines the available tools and decides which one (if any) to call based on the user's request.
  3. Invocation — The client sends a tools/call message with the tool name and input parameters.
  4. Validation — The server validates the input against the tool's schema.
  5. Execution — The server runs the tool's execute function with the validated input.
  6. 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:

  1. Validation — Automatically validates inputs before your execute method runs
  2. Documentation — Tells the AI model what parameters are available and what they mean
  3. 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)",
  },
};
Write Detailed Schema Descriptions

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)}`,
    });
  }
}
Error Messages Should Be Actionable

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.

Aspectmcp-frameworkOfficial SDK
StyleClass-based (OOP)Functional
File organizationOne file per toolAll in one file (or import handlers)
Schema definitionObject with type + descriptionZod chain with .describe()
Return typeStringContent array with type/text objects
RegistrationAutomatic (file discovery)Manual (server.tool() call)
Helper methodsClass methodsStandalone functions
TestingInstantiate classMock server.tool() calls

Tool Naming Conventions

Tool Naming Guidelines

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_issues over search
  • Keep names concise but descriptive: get_user_profile not get_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.