Skip to main content

Tools

Tools are actions AI can perform — the primary way AI interacts with your system. When an AI agent needs to do something (send email, create task, query database), it calls a tool.
Tools are triggered by the AI agent, not the user. The LLM decides when to call a tool based on the user’s request and the tool’s description. Users don’t directly invoke tools — they ask the AI, and the AI decides which tools to use.

Testing Your Tools

Since tools are AI-triggered, you’ll need a way to test them:
  • MCP Inspectornpx @modelcontextprotocol/inspector http://localhost:3001/mcp
  • Claude Desktop / Cursor — Connect your MCP and chat with it
  • LeanMCP Sandbox — Test tools directly in the browser
  • Postman — Send raw MCP requests to your server
Every tool you add increases token usage. Keep descriptions concise and only expose tools the AI actually needs. See Reducing Tokens in MCPs for optimization strategies.

Tool Structure

import { Tool, SchemaConstraint, Optional } from "@leanmcp/core";

// 1. Define input schema with descriptions for AI
class YourToolInput {
  @SchemaConstraint({ description: "Describe what this param is for" })
  requiredParam!: string;

  @Optional()
  @SchemaConstraint({ description: "Optional param", default: 10 })
  optionalParam?: number;
}

// 2. Create service class
export class YourService {
  // 3. Decorate method with @Tool + inputClass
  @Tool({ 
    description: "Clear description of what this tool does",
    inputClass: YourToolInput 
  })
  async yourToolName(input: YourToolInput) {
    // 4. Implementation
    return { success: true, data: "result" };
  }
}

Full Example: Task Manager

A complete task management service with CRUD operations:
// mcp/tasks/index.ts
import { Tool, Resource, SchemaConstraint, Optional } from "@leanmcp/core";

// In-memory store (replace with database in production)
interface Task {
  id: string;
  title: string;
  description: string;
  status: "todo" | "in_progress" | "done";
  priority: "low" | "medium" | "high";
  createdAt: Date;
  updatedAt: Date;
}

const tasks: Map<string, Task> = new Map();

// --- Input Schemas ---

class CreateTaskInput {
  @SchemaConstraint({ description: "Task title" })
  title!: string;

  @Optional()
  @SchemaConstraint({ description: "Task description" })
  description?: string;

  @Optional()
  @SchemaConstraint({ 
    description: "Priority level", 
    enum: ["low", "medium", "high"], 
    default: "medium" 
  })
  priority?: "low" | "medium" | "high";
}

class UpdateTaskInput {
  @SchemaConstraint({ description: "Task ID to update" })
  id!: string;

  @Optional()
  @SchemaConstraint({ description: "New title" })
  title?: string;

  @Optional()
  @SchemaConstraint({ description: "New status", enum: ["todo", "in_progress", "done"] })
  status?: "todo" | "in_progress" | "done";

  @Optional()
  @SchemaConstraint({ description: "New priority", enum: ["low", "medium", "high"] })
  priority?: "low" | "medium" | "high";
}

class DeleteTaskInput {
  @SchemaConstraint({ description: "Task ID to delete" })
  id!: string;
}

class ListTasksInput {
  @Optional()
  @SchemaConstraint({ description: "Filter by status", enum: ["todo", "in_progress", "done"] })
  status?: "todo" | "in_progress" | "done";

  @Optional()
  @SchemaConstraint({ description: "Filter by priority", enum: ["low", "medium", "high"] })
  priority?: "low" | "medium" | "high";
}

// --- Service ---

export class TaskService {
  
  @Tool({ description: "Create a new task", inputClass: CreateTaskInput })
  createTask(input: CreateTaskInput) {
    const id = `task_${Date.now()}`;
    const now = new Date();
    
    const task: Task = {
      id,
      title: input.title,
      description: input.description || "",
      status: "todo",
      priority: input.priority || "medium",
      createdAt: now,
      updatedAt: now
    };
    
    tasks.set(id, task);
    
    return {
      created: true,
      task: { id: task.id, title: task.title, status: task.status, priority: task.priority }
    };
  }

  @Tool({ description: "Update an existing task", inputClass: UpdateTaskInput })
  updateTask(input: UpdateTaskInput) {
    const task = tasks.get(input.id);
    if (!task) throw new Error(`Task ${input.id} not found`);
    
    if (input.title) task.title = input.title;
    if (input.status) task.status = input.status;
    if (input.priority) task.priority = input.priority;
    task.updatedAt = new Date();
    
    tasks.set(input.id, task);
    
    return {
      updated: true,
      task: { id: task.id, title: task.title, status: task.status, priority: task.priority }
    };
  }

  @Tool({ description: "Delete a task", inputClass: DeleteTaskInput })
  deleteTask(input: DeleteTaskInput) {
    const task = tasks.get(input.id);
    if (!task) throw new Error(`Task ${input.id} not found`);
    
    tasks.delete(input.id);
    return { deleted: true, id: input.id, title: task.title };
  }

  @Tool({ description: "List tasks with optional filters", inputClass: ListTasksInput })
  listTasks(input: ListTasksInput) {
    let result = Array.from(tasks.values());
    
    if (input.status) result = result.filter(t => t.status === input.status);
    if (input.priority) result = result.filter(t => t.priority === input.priority);
    
    return {
      count: result.length,
      tasks: result.map(t => ({ id: t.id, title: t.title, status: t.status, priority: t.priority }))
    };
  }

  @Resource({ description: "Task statistics", mimeType: "application/json" })
  getStats() {
    const all = Array.from(tasks.values());
    return {
      total: all.length,
      todo: all.filter(t => t.status === "todo").length,
      inProgress: all.filter(t => t.status === "in_progress").length,
      done: all.filter(t => t.status === "done").length
    };
  }
}
How AI uses this:
  • “Create a task called ‘Review PR #42’ with high priority”
  • “List all tasks that are in progress”
  • “Mark task_1234 as complete”
  • “Delete the task about reviewing PR”

Schema Validation

Use @SchemaConstraint for input validation and better AI understanding:
import { Tool, SchemaConstraint, Optional } from "@leanmcp/core";

class SendEmailInput {
  @SchemaConstraint({ description: "Recipient email address", format: "email" })
  to!: string;

  @SchemaConstraint({ description: "Email subject line" })
  subject!: string;

  @SchemaConstraint({ description: "Email body content" })
  body!: string;

  @Optional()
  @SchemaConstraint({ 
    description: "Priority", 
    enum: ["low", "normal", "high"], 
    default: "normal" 
  })
  priority?: string;
}

export class EmailService {
  @Tool({ description: "Send an email to a recipient", inputClass: SendEmailInput })
  async sendEmail(input: SendEmailInput) {
    // Send email implementation
    return { sent: true, messageId: `msg_${Date.now()}` };
  }
}

Return Types

Tools can return different types:
@Tool({ description: "Get user" })
getUser(input: { id: string }) {
  return { name: "John", email: "[email protected]" };
}

Async Tools

Tools can be async for API calls, database queries, file operations:
@Tool({ description: "Search database" })
async search(input: { query: string }) {
  const results = await db.query(input.query);
  return { results, count: results.length };
}

Error Handling

Throw errors — they’re caught and returned properly to the AI:
@Tool({ description: "Delete user" })
async deleteUser(input: { id: string }) {
  const user = await db.users.find(input.id);
  if (!user) {
    throw new Error(`User ${input.id} not found`);
  }
  await db.users.delete(input.id);
  return { deleted: true };
}

Organizing Services

Group related tools into services. One file per domain:
mcp/
├── email/index.ts       # EmailService - send, draft, search
├── calendar/index.ts    # CalendarService - create, list, update events
├── contacts/index.ts    # ContactsService - lookup, create, update
└── analytics/index.ts   # AnalyticsService - reports, metrics

Best Practices

AI uses descriptions to understand when to use tools.Bad: "Process data"Good: "Search customer orders by date range and status"
Every tool call costs tokens and time. Design tools that accomplish goals in one call, not a chain of 7-8 calls.❌ BAD: Hotel booking with 7+ tool callsEach call: AI generates request → waits for response → processes → generates next request. 7 round trips, thousands of tokens.✅ GOOD: One tool that does it all
class BookHotelInput {
  @SchemaConstraint({ description: "City name" })
  city!: string;
  
  @SchemaConstraint({ description: "Check-in date (YYYY-MM-DD)" })
  checkIn!: string;
  
  @SchemaConstraint({ description: "Check-out date (YYYY-MM-DD)" })
  checkOut!: string;
  
  @SchemaConstraint({ description: "Number of guests" })
  guests!: number;
  
  @Optional()
  @SchemaConstraint({ description: "Max price per night in USD" })
  maxPrice?: number;
}

@Tool({ 
  description: "Book a hotel room. Returns available options with prices, or confirms booking if user approves.", 
  inputClass: BookHotelInput 
})
async bookHotel(input: BookHotelInput) {
  // All logic happens server-side in one call
  const options = await this.findAndPriceRooms(input);
  return { 
    options: options.slice(0, 3),  // Top 3 choices
    message: "Reply with option number to confirm booking"
  };
}
One call, one response. The server handles the complexity, not the AI.
One tool, one job. Don’t combine unrelated actions.Bad: "manageUser" (create, update, delete in one)Good: "createUser", "updateUser", "deleteUser"
Use @SchemaConstraint for all inputs. AI makes fewer mistakes with validated schemas.
Return useful data AI can use in follow-up actions.Bad: return { success: true }Good: return { created: true, id: "123", name: "John" }
Throw clear errors. AI can explain issues to users.

The Three MCP Primitives

Understanding when to use each:
PrimitiveControlPurposeExample
ToolsModel-drivenActions the AI performsSend email, create task, query API
ResourcesApplication-drivenContext data for AIFiles, preferences, schedules
PromptsUser-drivenTemplates users invoke/analyze, /review, /support

Next Steps