Module 3: Three Primitives — Resources, Tools, Prompts
Tools: Functions the Model Can Invoke
Tools: Functions the Model Can Invoke
Capsule description
If Resources are the model's eyes (they let it see data), Tools are the hands — they let it do things. A tool is a function the model can invoke through the MCP server to execute actions with real effects: create files, insert records into a database, send messages, call external APIs, run calculations.
The fundamental difference from Resources is that tools have side effects. When a tool runs, something changes in the world. That's why tools require an additional level of design: they need schemas that define their inputs, parameter validation, error handling, and a permission model where the user approves the execution.
Tools is the most used primitive in real MCP servers. If you look at any popular MCP server — Filesystem, GitHub, Slack — most of its capabilities are tools. Understanding how to design and implement tools is the most valuable skill of this module.
What is a Tool?
Formal definition
A Tool in MCP is a function with a name, a description, an input schema (JSON Schema), and a handler that executes the logic. The flow is:
- The server registers the available tools with their schemas
- The host discovers the tools and presents them to the model
- The model decides to invoke a tool based on the conversation
- The host requests approval from the user
- The server executes the tool and returns the result
Practical definition
A tool answers: "What actions can the model execute through this server?"
Examples of tools:
├── create_file → Creates a new file
├── insert_user → Inserts a user into the database
├── send_slack_message → Sends a message to Slack
├── run_query → Executes a SQL query
├── deploy_app → Deploys the application
└── resize_image → Resizes an image
Key characteristics
| Characteristic | Detail |
|---|---|
| Name | Unique identifier of the tool (snake_case by convention) |
| Description | Text the model uses to decide when to invoke the tool |
| Input Schema | JSON Schema that defines the required and optional parameters |
| Handler | Function that executes the tool's logic |
| Side effects | Tools can and usually do modify state |
| Approval | The user must approve the execution (in most hosts) |
Anatomy of a Tool
Registration structure
When you register a tool, you define:
interface ToolDefinition {
name: string; // "create_file"
description: string; // "Creates a new file with the specified content"
inputSchema: { // JSON Schema for the inputs
type: "object";
properties: {
path: { type: "string"; description: "Path of the file" };
content: { type: "string"; description: "Content of the file" };
};
required: ["path", "content"];
};
}
Response structure
When a tool runs, it returns:
interface ToolResult {
content: Array<{
type: "text" | "image" | "resource";
text?: string;
data?: string; // base64 for images
mimeType?: string;
}>;
isError?: boolean; // true if the execution failed
}
The complete lifecycle
1. Registration
Server: "I have a tool 'create_file' that accepts path and content"
2. Discovery
Host to the model: "You have create_file(path, content) available"
3. Model's decision
User: "Create a file hello.txt with 'Hello World'"
Model: "I'm going to use create_file with path='hello.txt' and content='Hello World'"
4. Approval
Host to the user: "Claude wants to run create_file. Approve?"
User: "Yes"
5. Execution
Host to the Server: tools/call { name: "create_file", arguments: { path: "hello.txt", content: "Hello World" } }
Server: *runs the function* → returns result
6. Response
Server to the Host: { content: [{ type: "text", text: "File hello.txt created successfully" }] }
Model to the user: "I created the file hello.txt with the content 'Hello World'"
The importance of the Input Schema
The input schema isn't a technical detail — it's what tells the model which parameters it needs and how to use them. A well-designed schema results in better tool use.
Poor schema vs rich schema
// ❌ Poor schema — the model doesn't know what to put
{
properties: {
data: { type: "string" }
}
}
// ✅ Rich schema — the model knows exactly what it needs
{
properties: {
filePath: {
type: "string",
description: "Full path of the file to create (e.g., 'src/utils/helpers.ts')"
},
content: {
type: "string",
description: "Full content of the file"
},
overwrite: {
type: "boolean",
description: "If true, overwrites existing files. Default: false",
default: false
}
},
required: ["filePath", "content"]
}
Rule: The descriptions in the schema are instructions for the model. Be specific, include examples, and clearly mark what's required and what's optional.
Implementation in TypeScript
Basic tool with Zod
The TypeScript SDK uses Zod to define schemas — it's more ergonomic than JSON Schema and provides automatic validation:
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(
"create_file",
"Creates a new file with the specified content",
{
filePath: z.string().describe("Path of the file to create"),
content: z.string().describe("Content of the file"),
overwrite: z.boolean().default(false).describe("Overwrite if it exists"),
},
async ({ filePath, content, overwrite }) => {
const fs = await import("fs/promises");
const path = await import("path");
if (!overwrite) {
try {
await fs.access(filePath);
return {
content: [{ type: "text", text: `Error: the file ${filePath} already exists. Use overwrite: true to overwrite.` }],
isError: true,
};
} catch {
// The file doesn't exist, we can continue
}
}
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, "utf-8");
return {
content: [{
type: "text",
text: `File created successfully: ${filePath} (${content.length} characters)`,
}],
};
}
);
Tool with database operations
server.tool(
"insert_user",
"Inserts a new user into the database",
{
name: z.string().min(1).describe("The user's name"),
email: z.string().email().describe("The user's email"),
role: z.enum(["admin", "user", "viewer"]).default("user").describe("The user's role"),
},
async ({ name, email, role }) => {
try {
const result = await db.query(
"INSERT INTO users (name, email, role) VALUES ($1, $2, $3) RETURNING id",
[name, email, role]
);
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
userId: result.rows[0].id,
message: `User '${name}' created with role '${role}'`,
}, null, 2),
}],
};
} catch (error) {
return {
content: [{
type: "text",
text: `Error creating user: ${error instanceof Error ? error.message : "Unknown error"}`,
}],
isError: true,
};
}
}
);
Tool with an external API call
server.tool(
"send_notification",
"Sends a notification to a Slack channel",
{
channel: z.string().describe("Channel name (e.g., '#general')"),
message: z.string().describe("Message to send"),
urgent: z.boolean().default(false).describe("Mark as urgent"),
},
async ({ channel, message, urgent }) => {
const prefix = urgent ? "🚨 URGENT: " : "";
const response = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SLACK_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel,
text: `${prefix}${message}`,
}),
});
const result = await response.json();
if (!result.ok) {
return {
content: [{ type: "text", text: `Error sending: ${result.error}` }],
isError: true,
};
}
return {
content: [{
type: "text",
text: `Message sent to ${channel}: "${message}"${urgent ? " (urgent)" : ""}`,
}],
};
}
);
Implementation in Python
Basic tool with FastMCP
from mcp.server.fastmcp import FastMCP
server = FastMCP("my-server")
@server.tool()
async def create_file(file_path: str, content: str, overwrite: bool = False) -> str:
"""Creates a new file with the specified content.
Args:
file_path: Path of the file to create
content: Content of the file
overwrite: If True, overwrites existing files
"""
import os
if not overwrite and os.path.exists(file_path):
raise ValueError(f"The file {file_path} already exists. Use overwrite=True to overwrite.")
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w") as f:
f.write(content)
return f"File created successfully: {file_path} ({len(content)} characters)"
Tool with validation
@server.tool()
async def insert_user(name: str, email: str, role: str = "user") -> str:
"""Inserts a new user into the database.
Args:
name: The user's name (minimum 1 character)
email: The user's valid email
role: The user's role (admin, user, viewer). Default: user
"""
if role not in ("admin", "user", "viewer"):
raise ValueError(f"Invalid role: {role}. Options: admin, user, viewer")
if "@" not in email:
raise ValueError(f"Invalid email: {email}")
import json
user_id = await db.execute(
"INSERT INTO users (name, email, role) VALUES ($1, $2, $3) RETURNING id",
name, email, role
)
return json.dumps({
"success": True,
"userId": user_id,
"message": f"User '{name}' created with role '{role}'",
}, indent=2)
For tools that call external APIs in Python, use httpx (async HTTP client). The pattern is the same: validate inputs, execute the call, handle errors, return a formatted result.
Design patterns for Tools
Pattern 1: Complete CRUD
If you expose CRUD operations, create one tool per operation:
// Create
server.tool("create_task", "Creates a new task", { ... }, async (args) => { ... });
// Read (could be a Resource, but as a tool it allows complex queries)
server.tool("search_tasks", "Searches tasks with filters", { ... }, async (args) => { ... });
// Update
server.tool("update_task", "Updates an existing task", { ... }, async (args) => { ... });
// Delete
server.tool("delete_task", "Deletes a task by ID", { ... }, async (args) => { ... });
Pattern 2: Tool with confirmation
For destructive operations, return a preview before executing:
server.tool(
"delete_files",
"Deletes files that match a pattern",
{
pattern: z.string().describe("Glob pattern of files to delete"),
dryRun: z.boolean().default(true).describe("If true, only shows what would be deleted"),
},
async ({ pattern, dryRun }) => {
const files = await glob(pattern);
if (dryRun) {
return {
content: [{
type: "text",
text: `${files.length} files would be deleted:\n${files.join("\n")}\n\nUse dryRun: false to execute.`,
}],
};
}
for (const file of files) {
await fs.unlink(file);
}
return {
content: [{ type: "text", text: `${files.length} files deleted.` }],
};
}
);
Error Handling in Tools
Error handling isn't an afterthought — it's part of the design. A tool that fails without explanation is useless.
Expected vs unexpected errors
server.tool(
"get_user_data",
"Gets a user's complete data",
{ userId: z.string().describe("The user's ID") },
async ({ userId }) => {
try {
const user = await db.findUser(userId);
if (!user) {
// Expected error — the user doesn't exist
return {
content: [{ type: "text", text: `User '${userId}' not found.` }],
isError: true,
};
}
return {
content: [{ type: "text", text: JSON.stringify(user, null, 2) }],
};
} catch (error) {
// Unexpected error — DB failure, network, etc.
return {
content: [{
type: "text",
text: `Internal error while looking up the user: ${error instanceof Error ? error.message : "Unknown error"}`,
}],
isError: true,
};
}
}
);
The isError field
When a tool returns isError: true, the model knows something failed and can:
- Inform the user of the error
- Try with different parameters
- Suggest alternative actions
Always use isError: true when the operation wasn't successful.
Troubleshooting
"The model doesn't invoke the tool"
Cause: The tool's description isn't clear or doesn't match what the user is asking for.
Solution: Improve the tool's description:
// ❌ Vague description
"Processes data"
// ✅ Specific description
"Inserts a new sale record into the database with customer, product, and amount"
"The parameters arrive incorrect"
Cause: The schema doesn't have clear descriptions or is missing constraints.
Solution:
// ❌ No description — the model guesses
{ path: z.string() }
// ✅ With description and example
{ path: z.string().describe("Absolute path of the file (e.g., '/home/user/data.json')") }
"The tool fails with 'invalid arguments'"
Cause: The arguments don't pass Zod's validation.
Solution:
# Use MCP Inspector to see exactly what the model sends
npx @modelcontextprotocol/inspector
# Verify that the schema matches what you expect
# Required vs optional fields, types, defaults
"Timeout while running a tool"
Cause: The operation inside the tool takes too long.
Solution:
// Add a timeout to external operations
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(url, { signal: controller.signal });
// ...
} finally {
clearTimeout(timeoutId);
}
"The tool runs but the result isn't shown"
Cause: The format of the result isn't correct.
Solution:
// Make sure you return the correct structure
return {
content: [
{
type: "text", // ← type is required
text: "result" // ← text is required for type: "text"
}
],
};
Exercises
Exercise 1: Design tool schemas (Easy)
Design the input schema (name, description, parameters) for these 3 tools:
- A tool that converts temperatures between Celsius and Fahrenheit
- A tool that counts words in a text
- A tool that generates a secure password
See solution
// 1. Convert temperature
server.tool(
"convert_temperature",
"Converts temperature between Celsius and Fahrenheit",
{
value: z.number().describe("Temperature value to convert"),
from: z.enum(["celsius", "fahrenheit"]).describe("Source unit"),
},
async ({ value, from }) => {
const result = from === "celsius"
? { fahrenheit: (value * 9/5) + 32 }
: { celsius: (value - 32) * 5/9 };
return { content: [{ type: "text", text: JSON.stringify(result) }] };
}
);
// 2. Count words
server.tool(
"count_words",
"Counts the number of words, characters, and lines in a text",
{
text: z.string().describe("Text to analyze"),
},
async ({ text }) => {
const result = {
words: text.split(/\s+/).filter(Boolean).length,
characters: text.length,
lines: text.split("\n").length,
};
return { content: [{ type: "text", text: JSON.stringify(result) }] };
}
);
// 3. Generate password
server.tool(
"generate_password",
"Generates a secure password with the specified criteria",
{
length: z.number().min(8).max(128).default(16).describe("Length of the password"),
includeSymbols: z.boolean().default(true).describe("Include symbols (!@#$...)"),
includeNumbers: z.boolean().default(true).describe("Include numbers"),
},
async ({ length, includeSymbols, includeNumbers }) => {
let chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
if (includeNumbers) chars += "0123456789";
if (includeSymbols) chars += "!@#$%^&*()_+-=[]{}";
const password = Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
return { content: [{ type: "text", text: `Password generated: ${password}` }] };
}
);
Exercise 2: Implement a Tool with validation (Medium)
Implement a tool create_directory in TypeScript that:
- Receives the path of the directory to create
- Validates that the path doesn't contain ".." (prevent path traversal)
- Creates the directory structure recursively
- Returns confirmation with the created path
See solution
import * as fs from "fs/promises";
import * as path from "path";
server.tool(
"create_directory",
"Creates a directory and its parents if they don't exist",
{
dirPath: z.string().describe("Path of the directory to create (e.g., 'src/components/ui')"),
},
async ({ dirPath }) => {
if (dirPath.includes("..")) {
return {
content: [{ type: "text", text: "Error: the path can't contain '..' for security" }],
isError: true,
};
}
const absolutePath = path.resolve(dirPath);
try {
await fs.mkdir(absolutePath, { recursive: true });
const stats = await fs.stat(absolutePath);
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
path: absolutePath,
created: stats.birthtime.toISOString(),
}, null, 2),
}],
};
} catch (error) {
return {
content: [{
type: "text",
text: `Error creating directory: ${error instanceof Error ? error.message : "unknown"}`,
}],
isError: true,
};
}
}
);
Exercise 3: CRUD Tool in Python (Medium)
Implement a tool manage_todo in Python that supports creating and completing tasks in an in-memory list:
See solution
import json
from datetime import datetime
todos: list[dict] = []
@server.tool()
async def manage_todo(action: str, title: str = "", todo_id: int = -1) -> str:
"""Manages a task list (create, complete, list).
Args:
action: Action to perform (create, complete, list)
title: Task title (required for create)
todo_id: Task ID (required for complete)
"""
if action == "create":
if not title:
raise ValueError("The title is required to create a task")
todo = {
"id": len(todos) + 1,
"title": title,
"completed": False,
"created_at": datetime.now().isoformat(),
}
todos.append(todo)
return json.dumps({"message": f"Task created: {title}", "todo": todo}, indent=2)
elif action == "complete":
if todo_id < 0:
raise ValueError("The todo_id is required to complete a task")
for todo in todos:
if todo["id"] == todo_id:
todo["completed"] = True
return json.dumps({"message": f"Task completed: {todo['title']}", "todo": todo}, indent=2)
raise ValueError(f"Task with ID {todo_id} not found")
elif action == "list":
return json.dumps({"total": len(todos), "todos": todos}, indent=2)
else:
raise ValueError(f"Invalid action: {action}. Options: create, complete, list")
Exercise 4: Tool with dryRun (Hard)
Implement a tool rename_files in TypeScript that:
- Receives a directory, a search pattern, and a replacement pattern
- In
dryRun: truemode, shows which files would be renamed - In
dryRun: falsemode, executes the renaming
See solution
import * as fs from "fs/promises";
import * as path from "path";
server.tool(
"rename_files",
"Renames files in a directory by replacing a pattern in the name",
{
directory: z.string().describe("Path of the directory"),
searchPattern: z.string().describe("Text to search for in the file names"),
replaceWith: z.string().describe("Replacement text"),
dryRun: z.boolean().default(true).describe("If true, only shows a preview without executing"),
},
async ({ directory, searchPattern, replaceWith, dryRun }) => {
const files = await fs.readdir(directory);
const changes: Array<{ original: string; newName: string }> = [];
for (const file of files) {
if (file.includes(searchPattern)) {
changes.push({
original: file,
newName: file.replace(searchPattern, replaceWith),
});
}
}
if (changes.length === 0) {
return {
content: [{
type: "text",
text: `No files found with '${searchPattern}' in ${directory}`,
}],
};
}
if (dryRun) {
return {
content: [{
type: "text",
text: `Rename preview (dryRun):\n\n${changes.map(c => ` ${c.original} → ${c.newName}`).join("\n")}\n\nTotal: ${changes.length} files. Use dryRun: false to execute.`,
}],
};
}
for (const change of changes) {
await fs.rename(
path.join(directory, change.original),
path.join(directory, change.newName)
);
}
return {
content: [{
type: "text",
text: `${changes.length} files renamed:\n${changes.map(c => ` ${c.original} → ${c.newName}`).join("\n")}`,
}],
};
}
);
Summary
In this capsule you learned:
- Tools are executable functions the model can invoke through the MCP server
- They have side effects — they can create files, insert data, send messages
- They require an input schema that defines parameters with types, descriptions, and validation
- The flow includes user approval before executing
- Zod (TypeScript) and type hints + docstrings (Python) define the schemas
- Error handling is part of the design, not an afterthought
- The tool's description is crucial — it's what tells the model when to use it
- Patterns like dryRun, complete CRUD, and progress make tools more robust
Next capsule: Prompts — reusable templates with parameters. The least intuitive primitive for developers but fundamental for standardizing interactions.
Additional resources
- MCP Specification — Tools - Official Tools specification
- Zod Documentation - Validation library used in the TypeScript SDK
- JSON Schema - Underlying format of the input schemas
- MCP TypeScript SDK — Tools - Tools implementation in TS
- MCP Python SDK — Tools - Tools implementation in Python
- Building Effective Tools (Anthropic) - Best practices for tool design