← Back to DevBytes

Tool Use Patterns with MCP (Model Context Protocol): Complete Guide

Introduction to Tool Use Patterns with MCP

The Model Context Protocol (MCP) has emerged as a powerful open standard for connecting AI models to external tools, data sources, and services. At its core, MCP standardizes how language models discover, invoke, and interact with tools — but the way you design those interactions can dramatically affect reliability, performance, and developer experience. This guide explores the most important tool use patterns in MCP, complete with practical implementations you can apply immediately.

What Is MCP Tool Use?

MCP defines a client-server architecture where an MCP server exposes tools, resources, and prompts to an MCP client (typically an AI application or agent framework). Tool use refers to the lifecycle of a model requesting to call a tool, the client executing that call against the server, and the result being fed back into the model's context.

A single MCP tool is defined by three components: a name, a JSON Schema describing its input parameters, and a handler function that executes the actual work. The model never executes tools directly — it emits structured tool-call requests, and the client application is responsible for dispatching them.

Why Tool Use Patterns Matter

Naive tool integration often leads to fragile agents that hallucinate parameters, loop endlessly, or fail silently when a tool returns an error. Adopting deliberate patterns solves several recurring problems:

Setting Up an MCP Server

Before exploring patterns, let's establish a baseline MCP server using the official TypeScript SDK. This server will host the tools we use throughout the guide.

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
  { name: "tutorial-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

// Tool registry — we will expand this throughout the guide
const tools = new Map();

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: Array.from(tools.values()).map((t) => ({
    name: t.name,
    description: t.description,
    inputSchema: t.inputSchema,
  })),
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  const tool = tools.get(name);
  if (!tool) {
    return {
      content: [{ type: "text", text: `Unknown tool: ${name}` }],
      isError: true,
    };
  }
  return tool.handler(args);
});

const transport = new StdioServerTransport();
await server.connect(transport);

With this foundation in place, we can register tools and explore each pattern.

Pattern 1: Direct Tool Invocation

The simplest pattern is a single, stateless tool call. The model requests a tool, the server executes it, and the result returns as context. This is appropriate for read-only operations like fetching data, performing calculations, or querying APIs.

// Register a weather lookup tool
tools.set("get_weather", {
  name: "get_weather",
  description: "Get current weather for a city",
  inputSchema: {
    type: "object",
    properties: {
      city: { type: "string", description: "City name" },
      units: { type: "string", enum: ["celsius", "fahrenheit"] },
    },
    required: ["city"],
  },
  handler: async (args) => {
    const response = await fetch(
      `https://api.weather.example.com/current?city=${encodeURIComponent(args.city)}&units=${args.units || "celsius"}`
    );
    const data = await response.json();
    return {
      content: [
        {
          type: "text",
          text: `Weather in ${args.city}: ${data.temperature}°, ${data.condition}`,
        },
      ],
    };
  },
});

The key principle here is that the tool is self-contained: it validates its own input via the JSON Schema, performs one logical operation, and returns a structured result. Keep direct invocation tools small and focused.

Pattern 2: Tool Discovery and Dynamic Registration

In production systems, the set of available tools may change at runtime. Plugins get loaded, services go offline, and permissions shift. Dynamic discovery lets the client adapt without restarts.

// Track tool availability based on external service health
const serviceHealth = new Map();

async function refreshToolRegistry() {
  const services = await fetch("https://registry.internal/services").then((r) =>
    r.json()
  );

  for (const service of services) {
    if (service.status === "healthy") {
      tools.set(service.toolName, {
        name: service.toolName,
        description: service.description,
        inputSchema: service.inputSchema,
        handler: createDynamicHandler(service.endpoint),
      });
      serviceHealth.set(service.toolName, "available");
    } else {
      tools.delete(service.toolName);
      serviceHealth.set(service.toolName, "unavailable");
    }
  }
}

function createDynamicHandler(endpoint) {
  return async (args) => {
    const response = await fetch(endpoint, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(args),
    });
    if (!response.ok) {
      return {
        content: [{ type: "text", text: `Tool call failed: ${response.statusText}` }],
        isError: true,
      };
    }
    const result = await response.json();
    return {
      content: [{ type: "text", text: JSON.stringify(result) }],
    };
  };
}

// Refresh every 60 seconds
setInterval(refreshToolRegistry, 60_000);
await refreshToolRegistry();

This pattern pairs well with the MCP notifications/tools/list_changed notification, which tells the client to re-fetch the tool list after changes occur.

Pattern 3: Chained Tool Calls

Many real-world tasks require multiple tool calls in sequence, where each call depends on the output of the previous one. The model naturally drives this by issuing one tool call, reading the result, and then issuing the next. Your job as a developer is to make the intermediate results easy to parse.

// Tool 1: Search for a user
tools.set("search_user", {
  name: "search_user",
  description: "Find a user by email or name. Returns user ID.",
  inputSchema: {
    type: "object",
    properties: {
      query: { type: "string", description: "Email or name to search" },
    },
    required: ["query"],
  },
  handler: async (args) => {
    const users = await db.users.search(args.query);
    if (users.length === 0) {
      return {
        content: [{ type: "text", text: "No users found." }],
        isError: true,
      };
    }
    const user = users[0];
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify({ userId: user.id, name: user.name, email: user.email }),
        },
      ],
    };
  },
});

// Tool 2: Get user orders (depends on userId from Tool 1)
tools.set("get_user_orders", {
  name: "get_user_orders",
  description: "Get all orders for a user by user ID.",
  inputSchema: {
    type: "object",
    properties: {
      userId: { type: "string", description: "User ID from search_user" },
      limit: { type: "number", description: "Max orders to return", default: 10 },
    },
    required: ["userId"],
  },
  handler: async (args) => {
    const orders = await db.orders.findByUserId(args.userId, args.limit || 10);
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            orders.map((o) => ({
              orderId: o.id,
              total: o.total,
              status: o.status,
              date: o.createdAt,
            }))
          ),
        },
      ],
    };
  },
});

The critical design choice here is returning structured JSON in the text content. This makes it trivial for the model to extract the userId from the first call and pass it to the second. Always design tool outputs with the next step in mind.

Pattern 4: Parallel Tool Execution

When a model requests multiple independent tool calls in a single response, the client can execute them concurrently rather than sequentially. This dramatically reduces latency for tasks like gathering data from multiple sources.

// Client-side handler for parallel execution
async function handleToolCalls(toolCalls) {
  // Identify independent calls — those with no dependencies on each other
  const independentCalls = toolCalls.filter(
    (call) => !call.dependsOn || call.dependsOn.length === 0
  );

  // Execute all independent calls concurrently
  const results = await Promise.allSettled(
    independentCalls.map(async (call) => {
      try {
        const result = await mcpClient.callTool(call.name, call.arguments);
        return { callId: call.id, result, status: "fulfilled" };
      } catch (error) {
        return {
          callId: call.id,
          result: { content: [{ type: "text", text: error.message }] },
          status: "rejected",
        };
      }
    })
  );

  return results.map((r) => r.value);
}

// Example: model requests weather for three cities simultaneously
const sampleCalls = [
  { id: "call_1", name: "get_weather", arguments: { city: "Tokyo" } },
  { id: "call_2", name: "get_weather", arguments: { city: "London" } },
  { id: "call_3", name: "get_weather", arguments: { city: "New York" } },
];

const parallelResults = await handleToolCalls(sampleCalls);
parallelResults.forEach((r) => {
  console.log(`Call ${r.callId}:`, r.result.content[0].text);
});

Using Promise.allSettled instead of Promise.all ensures that one failing tool doesn't reject the entire batch. Each result carries its own status, so the model can decide how to handle partial failures.

Pattern 5: Tool Composition

Composition means building higher-level tools that internally call other tools or services. This reduces the number of round-trips the model needs to make and encapsulates multi-step logic that would be error-prone if left to the model to orchestrate.

// Low-level tools
tools.set("get_repo_info", {
  name: "get_repo_info",
  description: "Get repository metadata from GitHub",
  inputSchema: {
    type: "object",
    properties: {
      owner: { type: "string" },
      repo: { type: "string" },
    },
    required: ["owner", "repo"],
  },
  handler: async (args) => {
    const data = await github.repos.get(args);
    return {
      content: [{ type: "text", text: JSON.stringify(data) }],
    };
  },
});

tools.set("get_repo_issues", {
  name: "get_repo_issues",
  description: "Get open issues for a repository",
  inputSchema: {
    type: "object",
    properties: {
      owner: { type: "string" },
      repo: { type: "string" },
      state: { type: "string", enum: ["open", "closed", "all"], default: "open" },
    },
    required: ["owner", "repo"],
  },
  handler: async (args) => {
    const issues = await github.issues.listForRepo(args);
    return {
      content: [{ type: "text", text: JSON.stringify(issues) }],
    };
  },
});

// Composed tool: summarizes a repository's health in one call
tools.set("repo_health_summary", {
  name: "repo_health_summary",
  description:
    "Get a comprehensive health summary of a repository including metadata, open issues count, and recent activity.",
  inputSchema: {
    type: "object",
    properties: {
      owner: { type: "string" },
      repo: { type: "string" },
    },
    required: ["owner", "repo"],
  },
  handler: async (args) => {
    // Internally call multiple data sources in parallel
    const [repoInfo, issues, commits] = await Promise.all([
      github.repos.get(args),
      github.issues.listForRepo({ ...args, state: "open", per_page: 100 }),
      github.repos.listCommits({ ...args, per_page: 30 }),
    ]);

    const summary = {
      repository: `${args.owner}/${args.repo}`,
      stars: repoInfo.stargazers_count,
      forks: repoInfo.forks_count,
      openIssues: issues.length,
      recentCommitActivity: commits.length,
      lastCommitDate: commits[0]?.commit?.author?.date || "unknown",
      healthScore: calculateHealthScore(repoInfo, issues, commits),
    };

    return {
      content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
    };
  },
});

function calculateHealthScore(repoInfo, issues, commits) {
  let score = 100;
  if (issues.length > 50) score -= 20;
  if (commits.length < 5) score -= 15;
  if (repoInfo.archived) score -= 50;
  return Math.max(0, score);
}

Composition is especially valuable when the model would otherwise need three or more sequential calls to assemble the information it needs. By collapsing those into one tool, you reduce token usage, latency, and the chance of intermediate failures.

Pattern 6: Error Handling and Retry

Tools fail. Network requests time out, databases reject queries, and APIs rate-limit. A robust tool use implementation handles these gracefully and communicates errors in a way the model can act on.

// Wrapper that adds retry logic and structured error reporting
function withRetry(handler, options = {}) {
  const {
    maxRetries = 3,
    baseDelay = 1000,
    retryableErrors = ["ECONNRESET", "ETIMEDOUT", "429", "503"],
  } = options;

  return async (args) => {
    let lastError;

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        const result = await handler(args);

        // If the handler itself returned an error, check if retryable
        if (result.isError) {
          const errorText = result.content[0]?.text || "";
          const isRetryable = retryableErrors.some((code) =>
            errorText.includes(code)
          );
          if (!isRetryable || attempt === maxRetries) {
            return result;
          }
          lastError = errorText;
        } else {
          return result;
        }
      } catch (error) {
        lastError = error.message;
        const isRetryable = retryableErrors.some((code) =>
          error.message.includes(code)
        );
        if (!isRetryable || attempt === maxRetries) {
          return {
            content: [
              {
                type: "text",
                text: JSON.stringify({
                  error: error.message,
                  retryable: false,
                  suggestion: "This error cannot be retried. Try a different approach.",
                }),
              },
            ],
            isError: true,
          };
        }
      }

      // Exponential backoff with jitter
      const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 500;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify({
            error: lastError,
            retryable: true,
            attempts: maxRetries + 1,
            suggestion: "The service may be temporarily unavailable. Inform the user.",
          }),
        },
      ],
      isError: true,
    };
  };
}

// Apply the retry wrapper to a flaky tool
tools.set("fetch_external_api", {
  name: "fetch_external_api",
  description: "Fetch data from an external API endpoint",
  inputSchema: {
    type: "object",
    properties: {
      url: { type: "string", description: "Full URL to fetch" },
    },
    required: ["url"],
  },
  handler: withRetry(async (args) => {
    const response = await fetch(args.url);
    if (response.status === 429) {
      throw new Error("429: Rate limited");
    }
    if (!response.ok) {
      return {
        content: [
          { type: "text", text: `HTTP ${response.status}: ${response.statusText}` },
        ],
        isError: true,
      };
    }
    const data = await response.json();
    return {
      content: [{ type: "text", text: JSON.stringify(data) }],
    };
  }),
});

Notice that error responses include a suggestion field. This gives the model actionable guidance — instead of just knowing something failed, it knows whether to retry, inform the user, or try an alternative approach.

Pattern 7: Confirmation for Destructive Operations

Some tools have irreversible effects: deleting records, sending emails, making payments. For these, you should implement a confirmation pattern where the client intercepts the tool call and asks the user (or a policy engine) for approval before executing.

// Define which tools require confirmation
const destructiveTools = new Set([
  "delete_record",
  "send_email",
  "process_payment",
]);

// Client-side middleware
async function executeWithConfirmation(toolCall, context) {
  const { name, arguments: args } = toolCall;

  if (destructiveTools.has(name)) {
    const confirmation = await requestUserConfirmation({
      tool: name,
      arguments: args,
      message: `The model wants to call "${name}" with these arguments: ${JSON.stringify(args, null, 2)}. Do you approve?`,
    });

    if (!confirmation.approved) {
      return {
        content: [
          {
            type: "text",
            text: `Tool call to "${name}" was rejected by the user. Reason: ${confirmation.reason || "No reason provided."}`,
          },
        ],
        isError: true,
      };
    }
  }

  return mcpClient.callTool(name, args);
}

// Example destructive tool
tools.set("delete_record", {
  name: "delete_record",
  description: "Permanently delete a record by ID. This action cannot be undone.",
  inputSchema: {
    type: "object",
    properties: {
      recordId: { type: "string", description: "ID of the record to delete" },
      confirm: {
        type: "boolean",
        description: "Must be true to proceed. The model should set this only after user confirmation.",
      },
    },
    required: ["recordId", "confirm"],
  },
  handler: async (args) => {
    if (!args.confirm) {
      return {
        content: [
          { type: "text", text: "Deletion requires confirm=true. Ask the user first." },
        ],
        isError: true,
      };
    }
    await db.records.delete(args.recordId);
    return {
      content: [{ type: "text", text: `Record ${args.recordId} deleted successfully.` }],
    };
  },
});

This pattern uses defense in depth: the tool description warns the model, the input schema requires a confirm flag, and the client middleware intercepts the call for user approval. No single layer is sufficient on its own.

Pattern 8: Streaming Results

For long-running tools — such as large data exports, multi-step migrations, or live data feeds — returning a single result at the end creates poor UX. MCP supports streaming via server-sent notifications, allowing the tool to emit progress updates.

tools.set("generate_report", {
  name: "generate_report",
  description: "Generate a large analytics report. Streams progress updates.",
  inputSchema: {
    type: "object",
    properties: {
      dateRange: {
        type: "object",
        properties: {
          start: { type: "string", format: "date" },
          end: { type: "string", format: "date" },
        },
        required: ["start", "end"],
      },
      format: { type: "string", enum: ["csv", "json", "pdf"], default: "json" },
    },
    required: ["dateRange"],
  },
  handler: async (args, context) => {
    const steps = [
      "Querying transaction database",
      "Aggregating by category",
      "Computing statistical summaries",
      "Generating charts",
      "Compiling final report",
    ];

    for (let i = 0; i < steps.length; i++) {
      // Send progress notification via MCP
      await context.sendNotification({
        method: "notifications/progress",
        params: {
          progress: ((i + 1) / steps.length) * 100,
          message: steps[i],
        },
      });

      // Simulate work
      await performStep(steps[i], args);
    }

    const reportUrl = await uploadReport(args.format);
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify({
            status: "complete",
            reportUrl,
            format: args.format,
            generatedAt: new Date().toISOString(),
          }),
        },
      ],
    };
  },
});

Streaming keeps the user informed and prevents timeouts on long operations. The client can display progress bars or status messages while the tool works.

Best Practices

Write Descriptions for the Model, Not for Humans

Tool descriptions are consumed by the language model to decide when and how to use a tool. Be explicit about preconditions, expected input formats, and what the output looks like. Avoid marketing language.

// Bad: vague description
tools.set("search", {
  name: "search",
  description: "Search stuff",
  // ...
});

// Good: precise and actionable
tools.set("search_knowledge_base", {
  name: "search_knowledge_base",
  description:
    "Search the internal knowledge base for articles, docs, and FAQs. " +
    "Returns up to 10 results with title, URL, and snippet. " +
    "Use this when the user asks about internal documentation or company policies.",
  // ...
});

Validate Inputs Rigorously

Never trust that the model will provide valid inputs. Use JSON Schema constraints (types, enums, patterns, min/max) and add runtime validation in the handler for anything the schema can't express.

tools.set("create_calendar_event", {
  name: "create_calendar_event",
  description: "Create a calendar event",
  inputSchema: {
    type: "object",
    properties: {
      title: { type: "string", minLength: 1, maxLength: 200 },
      startTime: { type: "string", format: "date-time" },
      endTime: { type: "string", format: "date-time" },
      attendees: {
        type: "array",
        items: { type: "string", format: "email" },
        maxItems: 50,
      },
    },
    required: ["title", "startTime", "endTime"],
  },
  handler: async (args) => {
    // Runtime validation beyond what JSON Schema can express
    const start = new Date(args.startTime);
    const end = new Date(args.endTime);

    if (end <= start) {
      return {
        content: [
          { type: "text", text: "endTime must be after startTime." },
        ],
        isError: true,
      };
    }

    if (start < new Date()) {
      return {
        content: [
          { type: "text", text: "Cannot create events in the past." },
        ],
        isError: true,
      };
    }

    // Proceed with creation
    const event = await calendar.create(args);
    return {
      content: [{ type: "text", text: JSON.stringify(event) }],
    };
  },
});

Keep Tools Orthogonal and Composable

Avoid creating tools that overlap in functionality. If two tools do similar things, the model may get confused about which to use. Instead, design tools around distinct capabilities and let the model compose them.

Return Structured Data, Not Prose

When a tool returns data that the model needs to process further, return JSON. When a tool returns a final answer meant for the user, plain text is fine. Mixing the two creates parsing ambiguity.

Log Everything

Tool calls are the primary source of bugs in agent systems. Log the tool name, input arguments, output, latency, and any errors for every call. This data is invaluable for debugging and improving tool descriptions.

// Logging middleware
function withLogging(handler, toolName) {
  return async (args) => {
    const startTime = Date.now();
    const logEntry = {
      tool: toolName,
      input: args,
      timestamp: new Date().toISOString(),
    };

    try {
      const result = await handler(args);
      logEntry.duration = Date.now() - startTime;
      logEntry.success = !result.isError;
      logEntry.output = result.content[0]?.text?.substring(0, 500);
      logger.info("tool_call", logEntry);
      return result;
    } catch (error) {
      logEntry.duration = Date.now() - startTime;
      logEntry.success = false;
      logEntry.error = error.message;
      logger.error("tool_call", logEntry);
      throw error;
    }
  };
}

// Apply logging to all registered tools
for (const [name, tool] of tools) {
  tool.handler = withLogging(tool.handler, name);
}

Limit Tool Count

Models struggle when presented with dozens of tools at once. If you have more than 15-20 tools, consider grouping them into separate MCP servers or implementing a tool-routing layer that presents only relevant tools based on the current task context.

Conclusion

Effective tool use with MCP is about more than just exposing functions to a model — it's about designing interactions that are predictable, resilient, and composable. The patterns in this guide provide a toolkit for building production-grade agent systems: direct invocation for simple reads, composition for complex workflows, parallel execution for performance, retry logic for resilience, confirmation for safety, and streaming for long-running operations. By combining these patterns with rigorous input validation, clear tool descriptions, and comprehensive logging, you can build MCP-powered agents that are reliable enough for real-world use. Start with the simplest pattern that solves your problem, and introduce more sophisticated patterns as your requirements demand them — the best architecture is always the one that matches your actual needs, not the most complex one available.

— Ad —

Google AdSense will appear here after approval

← Back to all articles