playbook/outfitter-agents/plugins/outfitter-stack/skills/stack-templates/templates/mcp-tool.md

7.4 KiB

MCP Tool Template

Zod-schema-based tool for MCP servers returning Result<T, E>.

Template

import { Result, ValidationError, NotFoundError } from "@outfitter/contracts";
import { z } from "zod";

// ============================================================================
// Input Schema
// ============================================================================

const InputSchema = z.object({
  // Always use .describe() for AI understanding
  query: z.string().min(1).describe("The search term to look for"),

  // Provide defaults where sensible
  limit: z.number().int().positive().default(10)
    .describe("Maximum number of results to return"),

  // Use enums for fixed choices
  sortBy: z.enum(["name", "date", "relevance"]).default("relevance")
    .describe("Field to sort results by"),

  // Mark optional fields explicitly
  tags: z.array(z.string()).optional()
    .describe("Filter results by these tags"),

  // Boolean options
  includeArchived: z.boolean().default(false)
    .describe("Whether to include archived items"),
});

// ============================================================================
// Output Type
// ============================================================================

interface SearchResult {
  id: string;
  title: string;
  score: number;
}

interface Output {
  results: SearchResult[];
  total: number;
  hasMore: boolean;
}

// ============================================================================
// Error Types
// ============================================================================

type ToolErrors = ValidationError | NotFoundError;

// ============================================================================
// Tool Definition
// ============================================================================

export const searchTool = {
  name: "search_items",
  description: `Search for items in the database.

Use this tool when the user wants to:
- Find items by keyword
- Search for specific content
- List items matching criteria

Returns matching items with relevance scores.`,

  inputSchema: InputSchema,

  handler: async (
    input: z.infer<typeof InputSchema>
  ): Promise<Result<Output, ToolErrors>> => {
    // Business logic
    const results = await performSearch({
      query: input.query,
      limit: input.limit,
      sortBy: input.sortBy,
      tags: input.tags,
      includeArchived: input.includeArchived,
    });

    return Result.ok({
      results,
      total: results.length,
      hasMore: results.length === input.limit,
    });
  },
};

// ============================================================================
// Helper Functions
// ============================================================================

async function performSearch(options: {
  query: string;
  limit: number;
  sortBy: string;
  tags?: string[];
  includeArchived: boolean;
}): Promise<SearchResult[]> {
  // Implementation
  return [];
}

Registration

import { createMcpServer } from "@outfitter/mcp";
import { searchTool } from "./tools/search.js";
import { createTool } from "./tools/create.js";

const server = createMcpServer({
  name: "my-server",
  version: "0.1.0",
  description: "MCP server for item management",
});

// Register tools
server.registerTool(searchTool);
server.registerTool(createTool);

// Start server
server.start();

Checklist

  • Every schema field has .describe() for AI understanding
  • Sensible defaults with .default() where appropriate
  • Description explains WHEN to use the tool
  • Returns Result, not raw values
  • Error types from taxonomy

Patterns

Tool with Context

export const myTool = {
  name: "my_tool",
  description: "Tool with context access",
  inputSchema: InputSchema,

  handler: async (input, ctx) => {
    // Log invocation
    ctx.logger.debug("Tool invoked", { input });

    // Call handler
    const result = await myHandler(input, ctx);

    // Log outcome
    if (result.isErr()) {
      ctx.logger.error("Tool failed", { error: result.error });
    }

    return result;
  },
};

CRUD Tool Set

// List
export const listItemsTool = {
  name: "list_items",
  description: "List all items. Use when user wants to see available items.",
  inputSchema: z.object({
    limit: z.number().default(20).describe("Max items to return"),
    offset: z.number().default(0).describe("Number of items to skip"),
  }),
  handler: async (input) => { /* ... */ },
};

// Get
export const getItemTool = {
  name: "get_item",
  description: "Get a specific item by ID. Use when user asks about a specific item.",
  inputSchema: z.object({
    id: z.string().describe("The item ID to retrieve"),
  }),
  handler: async (input) => { /* ... */ },
};

// Create
export const createItemTool = {
  name: "create_item",
  description: "Create a new item. Use when user wants to add something new.",
  inputSchema: z.object({
    name: z.string().describe("Name for the new item"),
    description: z.string().optional().describe("Optional description"),
  }),
  handler: async (input) => { /* ... */ },
};

// Update
export const updateItemTool = {
  name: "update_item",
  description: "Update an existing item. Use when user wants to modify an item.",
  inputSchema: z.object({
    id: z.string().describe("The item ID to update"),
    name: z.string().optional().describe("New name"),
    description: z.string().optional().describe("New description"),
  }),
  handler: async (input) => { /* ... */ },
};

// Delete
export const deleteItemTool = {
  name: "delete_item",
  description: "Delete an item. Use when user wants to remove an item.",
  inputSchema: z.object({
    id: z.string().describe("The item ID to delete"),
  }),
  handler: async (input) => { /* ... */ },
};

Deferred Loading

server.registerDeferredTool({
  name: "heavy_analysis",
  description: "Perform heavy analysis (loads on demand)",

  load: async () => {
    // Only loaded when tool is first called
    const { analysisTool } = await import("./tools/analysis.js");
    return analysisTool;
  },
});

Test Template

import { describe, test, expect } from "bun:test";
import { createMcpHarness } from "@outfitter/testing";
import { searchTool } from "../tools/search.js";

const harness = createMcpHarness(searchTool);

describe("search_items", () => {
  test("returns results for valid query", async () => {
    const result = await harness.invoke({
      query: "test",
      limit: 5,
    });

    expect(result.isOk()).toBe(true);
    expect(result.value.results).toBeInstanceOf(Array);
  });

  test("uses default limit", async () => {
    const result = await harness.invoke({ query: "test" });

    expect(result.isOk()).toBe(true);
    // Default limit is 10
  });

  test("returns ValidationError for empty query", async () => {
    const result = await harness.invoke({ query: "" });

    expect(result.isErr()).toBe(true);
    expect(result.error._tag).toBe("ValidationError");
  });
});

Schema Best Practices

// DO: Use descriptive field names
query: z.string().describe("Search query")

// DON'T: Cryptic names
q: z.string()

// DO: Provide sensible defaults
limit: z.number().default(10)

// DON'T: Require every field
limit: z.number()

// DO: Use enums for fixed options
format: z.enum(["json", "csv", "xml"])

// DON'T: Accept any string
format: z.string()

// DO: Validate ranges
page: z.number().int().min(1).max(100)

// DON'T: Accept any number
page: z.number()