MCP Tools: Defining, Validating, and Executing LLM-Callable Functions

Tools are the heart of MCP. When people say “the AI can use tools”, they mean it can call functions exposed through this primitive. Tools are what let an AI model search your database, send an email, read a file, call an API, or run a command. Everything else in MCP is scaffolding around this core capability. This lesson covers the full tool API: defining schemas, validation, error handling, streaming, annotations, and the failure modes that will destroy a production system if you do not anticipate them.

MCP tools architecture diagram showing tool definition schema validation handler and response on dark background
The anatomy of an MCP tool: name, description, input schema, and async handler returning content blocks.

The Tool Definition API

A tool in MCP has four required components: a name (unique identifier, snake_case by convention), a description (what the tool does – this is what the LLM reads to decide when to use it), an input schema (a Zod object shape describing what arguments the tool takes), and a handler (an async function that receives validated arguments and returns a result).

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

const server = new McpServer({ name: 'my-server', version: '1.0.0' });

server.tool(
  'search_products',                    // name
  'Search the product catalogue',       // description
  {                                     // input schema (Zod object shape)
    query: z.string().min(1).max(200).describe('Search terms'),
    category: z.enum(['electronics', 'clothing', 'books']).optional()
      .describe('Optional category filter'),
    max_price: z.number().positive().optional()
      .describe('Maximum price in USD'),
    limit: z.number().int().min(1).max(50).default(10)
      .describe('Number of results to return'),
  },
  async ({ query, category, max_price, limit }) => {  // handler
    const results = await db.searchProducts({ query, category, max_price, limit });
    return {
      content: results.map(p => ({
        type: 'text',
        text: `${p.name} - $${p.price} (${p.category})\n${p.description}`,
      })),
    };
  }
);

The description is the most important field for LLM usability. It is what the model reads when deciding whether to use this tool. Write it as if explaining to a smart colleague what the function does, when to use it, and what it returns. Vague descriptions cause the model to either misuse the tool or avoid it entirely.

“Tools are exposed to the client with a JSON schema for their inputs. Clients SHOULD present tools to the LLM with appropriate context about what the tool does and when to use it.” – MCP Documentation, Tools

Content Types and Rich Responses

Tool handlers return an object with a content array. Each item in the array is a content block. MCP supports three content types: text, image, and resource.

// Text content (most common)
return {
  content: [{ type: 'text', text: 'The result as a string' }],
};

// Multiple text blocks (e.g. separate sections)
return {
  content: [
    { type: 'text', text: '## Summary\nHere is what I found...' },
    { type: 'text', text: '## Details\nFull results below...' },
  ],
};

// Image content (base64-encoded)
const imageData = fs.readFileSync('./chart.png').toString('base64');
return {
  content: [{
    type: 'image',
    data: imageData,
    mimeType: 'image/png',
  }],
};

// Resource reference (URI to a resource the client can read)
return {
  content: [{
    type: 'resource',
    resource: { uri: 'file:///data/report.pdf', mimeType: 'application/pdf' },
  }],
};

// Mixed content (text + image)
return {
  content: [
    { type: 'text', text: 'Here is the sales chart for Q1:' },
    { type: 'image', data: chartBase64, mimeType: 'image/png' },
  ],
};
MCP tool content types diagram showing text image and resource content blocks with example structures
The three tool content types: text, image (base64), and resource (URI reference).

Tool Annotations

MCP supports optional annotations on tools that hint to clients about the tool’s behaviour. These help hosts make better security and UX decisions before invoking a tool. Annotations are hints, not enforceable constraints – a well-behaved host should respect them, but the protocol does not validate them at runtime.

server.tool(
  'delete_file',
  'Permanently deletes a file from the filesystem',
  { path: z.string().describe('Absolute path to the file') },
  {
    // Tool annotations
    annotations: {
      destructive: true,         // Hint: this action cannot be undone
      requiresConfirmation: true, // Hint: ask user before executing
    },
  },
  async ({ path }) => {
    await fs.promises.unlink(path);
    return { content: [{ type: 'text', text: `Deleted: ${path}` }] };
  }
);

// Read-only tool annotation
server.tool(
  'read_file',
  'Reads a file from the filesystem',
  { path: z.string() },
  { annotations: { readOnly: true } }, // Hint: no side effects
  async ({ path }) => {
    const content = await fs.promises.readFile(path, 'utf8');
    return { content: [{ type: 'text', text: content }] };
  }
);

Failure Modes in Tool Design

Case 1: Vague Tool Descriptions Causing Misuse

When the description is too vague, the LLM will either call the wrong tool, pass wrong arguments, or skip the tool when it should use it. This causes subtle, hard-to-debug failures in production.

// BAD: Vague description - what does "process" mean?
server.tool('process', 'Process some data', { data: z.string() }, handler);

// GOOD: Specific description with context and return value
server.tool(
  'summarise_text',
  'Summarises a long text to under 100 words. Use when the user asks for a summary or when text exceeds 2000 characters and needs to be condensed. Returns: a concise summary string.',
  { text: z.string().min(1).describe('The text to summarise') },
  handler
);

Case 2: Throwing Errors Instead of Returning isError

Throwing an uncaught error from a tool handler causes the server to return a JSON-RPC error (protocol-level failure). The LLM sees this as a system failure, not a domain error. For domain errors – “user not found”, “quota exceeded”, “invalid file type” – return isError: true so the LLM can reason about the failure.

// BAD: Protocol error - LLM cannot reason about this
async ({ user_id }) => {
  const user = await db.findUser(user_id);
  if (!user) throw new Error('User not found'); // JSON-RPC error - not helpful to LLM
}

// GOOD: Domain error - LLM can adjust response
async ({ user_id }) => {
  const user = await db.findUser(user_id);
  if (!user) return {
    isError: true,
    content: [{ type: 'text', text: `No user found with ID ${user_id}. Check if the ID is correct.` }],
  };
  return { content: [{ type: 'text', text: JSON.stringify(user) }] };
}

Case 3: Missing Zod .describe() on Input Fields

Every Zod field in a tool’s input schema should have a .describe() call. The description appears in the JSON Schema that gets sent to the LLM. Without it, the model has to guess what the field means from its name alone – which leads to wrong values being passed.

// BAD: No descriptions - LLM must guess what max_items means
{ query: z.string(), max_items: z.number(), include_archived: z.boolean() }

// GOOD: Descriptions guide the LLM to pass correct values
{
  query: z.string().describe('Search query - supports AND, OR, NOT operators'),
  max_items: z.number().int().min(1).max(100).describe('Maximum results to return (1-100)'),
  include_archived: z.boolean().default(false).describe('Set to true to include archived items in results'),
}

Dynamic Tool Registration

Tools do not have to be registered at server startup. You can register tools dynamically and notify connected clients:

// Register a tool at startup
const toolRegistry = new Map();

function registerTool(name, description, schema, handler) {
  server.tool(name, description, schema, handler);
  toolRegistry.set(name, { name, description });
  // Notify connected clients that the tool list changed
  server.server.notification({ method: 'notifications/tools/list_changed' });
}

// Call this at any point after the server is connected
registerTool(
  'new_dynamic_tool',
  'A tool added at runtime',
  { input: z.string() },
  async ({ input }) => ({ content: [{ type: 'text', text: `Got: ${input}` }] })
);

“Servers MAY notify clients when the list of available tools changes. Clients that support the tools.listChanged capability SHOULD re-fetch the tool list when they receive this notification.” – MCP Documentation, Tools

What to Check Right Now

  • Audit your tool descriptions – for each tool you build, ask: if an LLM read only the name and description, would it know exactly when to use this tool and what it returns? If not, rewrite the description.
  • Add .describe() to every Zod field – do this as a rule, not an afterthought. The descriptions are part of the tool API surface.
  • Test isError handling – build a tool that deliberately returns isError: true with an informative message. Test it with the Inspector to see what the LLM would receive.
  • Check your annotation hints – mark every destructive tool (delete, update, send) with destructive: true. This lets compliant hosts ask for confirmation before executing.

nJoy 😉

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.