# How to chain multiple MCP tools in a workflow

- Tool: MCP
- Difficulty: Advanced
- Time required: 30-40 min
- Compatibility: MCP TypeScript SDK v1.x, Node.js 18+, multiple MCP servers
- Last updated: March 2026

## TL;DR

Chain multiple MCP tools into multi-step workflows where each tool's output feeds the next step. Build pipelines like: query database, process results, write to file, and send notification — all orchestrated by the AI or by a custom client script. This tutorial covers tool chaining patterns, error handling between steps, and building a workflow orchestrator MCP server.

## Building Multi-Step Workflows by Chaining MCP Tools

Individual MCP tools are useful, but the real power comes from chaining them together. A database query feeds into a file write, which triggers a notification. This tutorial shows three approaches to tool chaining: letting the AI orchestrate naturally, building a custom client that scripts the chain, and creating a workflow MCP server with composite tools that execute multiple steps internally.

## Before you start

- Two or more MCP servers with tools to chain
- Node.js 18+ and npm installed
- Understanding of MCP tool call and result formats
- Basic TypeScript knowledge

## Step-by-step guide

### 1. Understand the three tool chaining patterns

There are three ways to chain MCP tools. First, natural AI orchestration: the AI calls tools sequentially, using each result to decide the next step. This is the simplest approach but depends on the AI's judgment. Second, scripted client orchestration: a custom MCP client calls tools in a predetermined sequence, passing data between steps programmatically. Third, composite server tools: build a single MCP tool that internally calls multiple operations and returns the combined result.

```
// Pattern 1: AI orchestration (natural)
// You just ask: "Query the orders table for this month, export as CSV,
// and notify the team on Slack"
// The AI calls: query_db → write_file → send_slack_message

// Pattern 2: Scripted client orchestration
const dbResult = await client.callTool({ name: "query_db", arguments: { sql: "SELECT ..." } });
const csvPath = await client.callTool({ name: "write_csv", arguments: { data: dbResult, path: "report.csv" } });
await client.callTool({ name: "send_notification", arguments: { message: `Report ready: ${csvPath}` } });

// Pattern 3: Composite server tool
server.tool("generate_and_notify_report", "...", schema, async (params) => {
  const data = await queryDatabase(params.sql);
  const path = await writeCsv(data, params.filename);
  await sendNotification(`Report ready: ${path}`);
  return { content: [{ type: "text", text: `Report generated and team notified` }] };
});
```

**Expected result:** Understanding of the three chaining patterns and when to use each.

### 2. Build a scripted workflow using the MCP client SDK

Create a script that connects to multiple MCP servers and chains tool calls in a predetermined sequence. Use the MCP Client class to connect to each server, call tools in order, and pass results between steps. Handle errors at each step so the pipeline fails gracefully with a clear error message indicating which step failed.

```
// src/workflow.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

async function connectServer(command: string, args: string[]): Promise<Client> {
  const transport = new StdioClientTransport({ command, args });
  const client = new Client({ name: "workflow", version: "1.0.0" }, { capabilities: {} });
  await client.connect(transport);
  return client;
}

function extractText(result: any): string {
  return (result.content as any[])
    .filter(c => c.type === "text")
    .map(c => c.text)
    .join("\n");
}

async function runPipeline() {
  // Connect to servers
  const dbClient = await connectServer("node", ["dist/db-server.js"]);
  const fileClient = await connectServer("node", ["dist/file-server.js"]);

  try {
    // Step 1: Query database
    console.error("[pipeline] Step 1: Querying database...");
    const queryResult = await dbClient.callTool({
      name: "query",
      arguments: { sql: "SELECT * FROM orders WHERE created_at >= NOW() - INTERVAL '7 days'" },
    });
    if ((queryResult as any).isError) throw new Error(`Query failed: ${extractText(queryResult)}`);
    const data = JSON.parse(extractText(queryResult));
    console.error(`[pipeline] Got ${data.rows} rows`);

    // Step 2: Write CSV report
    console.error("[pipeline] Step 2: Writing CSV...");
    const csvResult = await fileClient.callTool({
      name: "write_file",
      arguments: {
        filePath: "reports/weekly-orders.csv",
        content: formatAsCsv(data),
      },
    });
    if ((csvResult as any).isError) throw new Error(`CSV write failed: ${extractText(csvResult)}`);

    // Step 3: Write summary
    console.error("[pipeline] Step 3: Writing summary...");
    const summary = `Weekly Orders Report\nGenerated: ${new Date().toISOString()}\nTotal rows: ${data.rows}\nTotal revenue: $${data.data.reduce((s: number, r: any) => s + (r.total || 0), 0).toFixed(2)}`;
    await fileClient.callTool({
      name: "write_file",
      arguments: { filePath: "reports/weekly-summary.txt", content: summary },
    });

    console.error("[pipeline] Complete!");
  } finally {
    await dbClient.close();
    await fileClient.close();
  }
}

function formatAsCsv(data: { columns: string[]; data: any[] }): string {
  const header = data.columns.join(",");
  const rows = data.data.map((row: any) =>
    data.columns.map(col => JSON.stringify(row[col] ?? "")).join(",")
  );
  return [header, ...rows].join("\n");
}

runPipeline().catch(e => { console.error("Pipeline failed:", e); process.exit(1); });
```

**Expected result:** A script that chains database query, CSV export, and summary generation across MCP servers.

### 3. Build a composite workflow MCP server

For workflows that should be available as MCP tools themselves, build a server with composite tools that execute multi-step operations internally. Each composite tool encapsulates an entire pipeline — query, transform, write, notify — as a single tool call. This is the cleanest approach for standardized workflows that users trigger regularly.

```
// src/workflow-server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import pg from "pg";
import fs from "fs/promises";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const server = new McpServer({ name: "workflow-server", version: "1.0.0" });

server.tool(
  "generate_report",
  "Query data, generate CSV, and create summary report in one step",
  {
    query: z.string().describe("SQL SELECT query for the report data"),
    reportName: z.string().describe("Name for the report files (no extension)"),
    outputDir: z.string().default("/tmp/reports"),
  },
  async ({ query, reportName, outputDir }) => {
    const steps: string[] = [];

    try {
      // Step 1: Execute query
      if (/\b(DELETE|DROP|UPDATE|INSERT)\b/i.test(query)) {
        return { content: [{ type: "text", text: "Error: Only SELECT queries allowed" }], isError: true };
      }
      const result = await pool.query({ text: query, timeout: 30000 });
      steps.push(`Queried ${result.rowCount} rows`);

      // Step 2: Generate CSV
      await fs.mkdir(outputDir, { recursive: true });
      const csvPath = `${outputDir}/${reportName}.csv`;
      const header = result.fields.map(f => f.name).join(",");
      const rows = result.rows.map(r =>
        result.fields.map(f => JSON.stringify(r[f.name] ?? "")).join(",")
      );
      await fs.writeFile(csvPath, [header, ...rows].join("\n"));
      steps.push(`CSV written to ${csvPath}`);

      // Step 3: Generate summary
      const summaryPath = `${outputDir}/${reportName}-summary.json`;
      const summary = {
        reportName,
        generatedAt: new Date().toISOString(),
        rowCount: result.rowCount,
        columns: result.fields.map(f => f.name),
        csvPath,
      };
      await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
      steps.push(`Summary written to ${summaryPath}`);

      return {
        content: [{ type: "text", text: `Report pipeline complete:\n${steps.map((s, i) => `  ${i + 1}. ${s}`).join("\n")}` }],
      };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `Pipeline failed at step ${steps.length + 1}:\n` +
            `Completed: ${steps.join(", ") || "none"}\n` +
            `Error: ${error instanceof Error ? error.message : String(error)}`,
        }],
        isError: true,
      };
    }
  }
);
```

**Expected result:** A composite MCP tool that executes a multi-step report generation pipeline.

### 4. Add error handling and partial rollback to chains

Multi-step workflows need robust error handling. Track completed steps, clean up partial outputs on failure, and report exactly where the chain broke. For critical workflows, implement compensation logic that undoes completed steps when a later step fails. This prevents half-finished operations from leaving inconsistent state.

```
// src/safe-pipeline.ts
interface PipelineStep<T> {
  name: string;
  execute: (context: T) => Promise<T>;
  rollback?: (context: T) => Promise<void>;
}

async function runPipeline<T>(steps: PipelineStep<T>[], initialContext: T): Promise<T> {
  const completed: PipelineStep<T>[] = [];
  let context = initialContext;

  for (const step of steps) {
    try {
      console.error(`[pipeline] Running: ${step.name}`);
      context = await step.execute(context);
      completed.push(step);
      console.error(`[pipeline] Completed: ${step.name}`);
    } catch (error) {
      console.error(`[pipeline] Failed: ${step.name} - ${error}`);

      // Rollback completed steps in reverse order
      for (const completedStep of completed.reverse()) {
        if (completedStep.rollback) {
          try {
            console.error(`[pipeline] Rolling back: ${completedStep.name}`);
            await completedStep.rollback(context);
          } catch (rollbackError) {
            console.error(`[pipeline] Rollback failed: ${completedStep.name} - ${rollbackError}`);
          }
        }
      }

      throw new Error(
        `Pipeline failed at "${step.name}": ${error instanceof Error ? error.message : String(error)}\n` +
        `Completed steps: ${completed.map(s => s.name).join(", ") || "none"}\n` +
        `Rolled back: ${completed.filter(s => s.rollback).map(s => s.name).join(", ") || "none"}`
      );
    }
  }

  return context;
}

// Usage:
// const result = await runPipeline([
//   { name: "query", execute: queryStep },
//   { name: "write-csv", execute: writeCsvStep, rollback: deleteFileStep },
//   { name: "notify", execute: notifyStep },
// ], { sql: "SELECT ...", outputDir: "/tmp" });
```

**Expected result:** A pipeline runner with step tracking, error handling, and rollback capability.

### 5. Orchestrate cross-server tool chains for complex workflows

The most powerful pattern chains tools across multiple MCP servers: query a database, search related documents, post results to a project management tool, and send a notification. Build a client script that connects to all required servers and orchestrates the full workflow. For enterprises building complex multi-server workflows, RapidDev specializes in designing and implementing production-grade MCP orchestration pipelines.

```
// Example: Incident investigation pipeline
// Chains: Sentry → Database → Filesystem → Linear

async function investigateIncident(project: string) {
  const sentry = await connectServer("node", ["dist/sentry-server.js"]);
  const db = await connectServer("node", ["dist/db-server.js"]);
  const files = await connectServer("npx", ["-y", "@modelcontextprotocol/server-filesystem", "."]);
  const linear = await connectServer("node", ["dist/linear-server.js"]);

  try {
    // Step 1: Get recent errors from Sentry
    const errors = await sentry.callTool({
      name: "recent_errors", arguments: { project, hours: 1 },
    });
    const topError = JSON.parse(extractText(errors))[0];

    // Step 2: Get error details and stack trace
    const details = await sentry.callTool({
      name: "error_details", arguments: { issueId: topError.id },
    });
    const errorInfo = JSON.parse(extractText(details));

    // Step 3: Query related database records
    const dbResult = await db.callTool({
      name: "query", arguments: {
        sql: `SELECT * FROM error_logs WHERE message LIKE '%${topError.title.slice(0, 50)}%' LIMIT 5`,
      },
    });

    // Step 4: Read the failing source file
    const frame = errorInfo.frames?.[0];
    const sourceCode = frame ? await files.callTool({
      name: "read_file", arguments: { path: frame.file },
    }) : null;

    // Step 5: Create a Linear ticket with all context
    await linear.callTool({
      name: "create_issue",
      arguments: {
        title: `[Auto] ${topError.title}`,
        description: `Error count: ${topError.count}\nStack: ${frame?.file}:${frame?.line}\nLast seen: ${topError.lastSeen}`,
        teamKey: "ENG",
        priority: topError.count > 100 ? 1 : 2,
      },
    });

    console.error("Incident investigated and ticket created");
  } finally {
    await Promise.all([sentry.close(), db.close(), files.close(), linear.close()]);
  }
}
```

**Expected result:** A cross-server workflow that investigates an incident and creates a ticket automatically.

## Complete code example

File: `src/workflow-server.ts`

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import pg from "pg";
import fs from "fs/promises";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const BLOCKED = /\b(DELETE|DROP|TRUNCATE|UPDATE|INSERT|ALTER)\b/i;

const server = new McpServer({ name: "workflow", version: "1.0.0" });

server.tool("generate_report", "Run query → export CSV → create summary", {
  sql: z.string(), name: z.string(), dir: z.string().default("/tmp/reports"),
}, async ({ sql, name, dir }) => {
  if (BLOCKED.test(sql)) return { content: [{ type: "text", text: "Only SELECT allowed" }], isError: true };
  const steps: string[] = [];
  try {
    const r = await pool.query({ text: sql, timeout: 30000 });
    steps.push(`Queried ${r.rowCount} rows`);

    await fs.mkdir(dir, { recursive: true });
    const csv = [r.fields.map(f => f.name).join(","),
      ...r.rows.map(row => r.fields.map(f => JSON.stringify(row[f.name] ?? "")).join(","))
    ].join("\n");
    await fs.writeFile(`${dir}/${name}.csv`, csv);
    steps.push(`CSV: ${dir}/${name}.csv`);

    await fs.writeFile(`${dir}/${name}.json`, JSON.stringify({
      name, at: new Date().toISOString(), rows: r.rowCount,
      cols: r.fields.map(f => f.name),
    }, null, 2));
    steps.push(`Summary: ${dir}/${name}.json`);

    return { content: [{ type: "text", text: steps.map((s,i) => `${i+1}. ${s}`).join("\n") }] };
  } catch (e) {
    return { content: [{ type: "text", text:
      `Failed at step ${steps.length+1}. Done: ${steps.join("; ")}. Error: ${e}` }], isError: true };
  }
});

server.tool("pipeline_status", "Check if pipeline output files exist", {
  dir: z.string().default("/tmp/reports"), name: z.string(),
}, async ({ dir, name }) => {
  const csvExists = await fs.access(`${dir}/${name}.csv`).then(() => true).catch(() => false);
  const jsonExists = await fs.access(`${dir}/${name}.json`).then(() => true).catch(() => false);
  return { content: [{ type: "text", text: JSON.stringify({ csv: csvExists, summary: jsonExists }) }] };
});

async function main() {
  await server.connect(new StdioServerTransport());
  console.error("Workflow MCP server running");
}
main().catch(e => { console.error(e); process.exit(1); });
```

## Common mistakes

- **Not handling errors between chain steps, causing cascading failures without context** — undefined Fix: Wrap each step in try-catch, track completed steps, and report exactly which step failed with its error message.
- **Not cleaning up partial outputs when a chain fails mid-way** — undefined Fix: Implement rollback logic for write steps. Delete files that were created before the failure to avoid inconsistent state.
- **Passing raw MCP result objects between steps instead of extracting the text content** — undefined Fix: Always extract text from result.content before passing to the next step. Parse JSON results before using them.
- **Not closing MCP client connections, leaving zombie server processes** — undefined Fix: Close all client connections in a finally block, even when the pipeline fails.

## Best practices

- Track completed steps so error messages show exactly where the chain broke
- Implement rollback logic for write operations in multi-step chains
- Close all MCP client connections in finally blocks to prevent zombie processes
- Extract and parse text content from results before passing to the next step
- Use composite server tools for standardized workflows that run frequently
- Use scripted client orchestration for complex cross-server pipelines
- Log each step with [pipeline] prefix for clear workflow tracing
- Set timeouts on each step to prevent the entire chain from hanging

## Frequently asked questions

### Should I let the AI orchestrate tool chains or script them?

Let the AI orchestrate for ad-hoc, exploratory workflows. Script chains for recurring, predictable pipelines where you need reliability and consistency. Use composite tools when the pipeline should be callable as a single MCP tool.

### How do I handle one step being slow in a long chain?

Set per-step timeouts and implement partial result reporting. If step 3 of 5 times out, report what steps 1-2 produced and let the user decide whether to retry.

### Can I run chain steps in parallel instead of sequentially?

Yes, if steps are independent. Use Promise.all to run independent steps concurrently and Promise.allSettled if some steps can fail without blocking others.

### How many steps can a tool chain have?

There is no technical limit. Practically, keep chains under 10 steps for maintainability. If a chain grows beyond that, split it into sub-workflows.

### Can RapidDev help design complex MCP workflows?

Yes. RapidDev designs and implements multi-server MCP orchestration pipelines for enterprise workflows, including error handling, monitoring, and automated scheduling.

---

Source: https://www.rapidevelopers.com/mcp-tutorial/how-to-chain-multiple-mcp-tools
© RapidDev — https://www.rapidevelopers.com/mcp-tutorial/how-to-chain-multiple-mcp-tools
