---
title: "Using MCP Custom Tools to Build Multi-Step AI Workflows"
description: "Direct API-to-tool mapping works for simple operations, but complex workflows need more. Learn how to use Zuplo to build custom MCP tools that orchestrate multiple API calls and apply business logic server-side."
canonicalUrl: "https://zuplo.com/blog/2025/12/02/mcp-server-custom-tools"
pageType: "blog"
date: "2025-12-02"
authors: "martyn"
tags: "Model Context Protocol"
image: "https://zuplo.com/og?text=Using%20MCP%20Custom%20Tools%20to%20Build%20Multi-Step%20AI%20Workflows"
---
When you expose an API through MCP, the simplest approach is direct mapping: one
API endpoint to one MCP tool. This works well for straightforward operations
where the AI just needs to call an endpoint and get a result.

But, what if an AI needs to call three different endpoints and combine the
results? What if there's business logic that should live server-side rather than
in the AI's reasoning? That's where custom MCP tools come in.

## Why Custom Tools?

The example we'll use for this is based around a travel planning API: to answer
"What should I pack for my trip to Tokyo?", an AI would need to:

1. Fetch weather data for Tokyo (using one tool)
2. Get activity recommendations (using a second tool)
3. Look up packing suggestions for the climate (using a third tool)
4. Combine everything into useful advice

With direct endpoint mapping, that's three separate tool calls. It's not very
efficient and there's reliance on the AI client you're using to orchestrate
them, handle errors, and synthesize the results.

It _will work_, but it's inefficient and puts the burden of workflow logic on
the AI.

A custom tool handles this differently. You define a single `planTrip` tool
that, thanks to some Zuplo magic, _internally_ calls all three endpoints,
applies your business logic, and returns a structured travel brief.

One tool call, one response, better results.

## Why Not Use MCP Prompts?

[MCP Prompts](https://zuplo.com/blog/mcp-server-prompts) also let you encode
workflows into your MCP server, but they work differently. Prompts guide the
AI's reasoning while the AI still makes individual tool calls. This is useful
when you want the AI to adapt based on intermediate results or handle edge cases
dynamically.

Custom tools are better when you need _guaranteed execution_. The workflow runs
server-side exactly as coded, with lower latency (one round trip instead of
many) and predictable results.

Consider it this way: If the business logic is complex enough that you don't
want the AI improvising, use a custom tool.

## Demo & Example

<CalloutVideo
  variant="card"
  title="Custom MCP Tools: Combining API Endpoints"
  description="See how to create multi-step AI workflows using custom MCP tools that orchestrate multiple API calls server-side."
  videoUrl="https://www.youtube.com/watch?v=BCytuQSaxYA"
  thumbnailUrl="http://i3.ytimg.com/vi/BCytuQSaxYA/hqdefault.jpg"
/>

<CalloutSample
  title="MCP Custom Tools Example"
  description="A complete Travel Advisor API with custom MCP tools that orchestrate weather, activities, and packing suggestions."
  deployUrl="https://zuplo.com/examples/mcp-server-custom-tools"
  repoUrl="https://github.com/zuplo/zuplo/tree/main/examples/mcp-server-custom-tools"
  localCommand="npx create-zuplo-api --example mcp-server-custom-tools"
/>

## Zuplo Magic: Composing Routes with `invokeRoute`

The magic behind custom tools in Zuplo is `context.invokeRoute()`. This method
lets your handler call other routes on the same gateway without making external
HTTP requests. The call stays within the Zuplo runtime, which means:

- **No network overhead**: Requests don't leave the gateway, so latency stays
  low even when chaining multiple calls
- **Policy enforcement**: Each invoked route still runs through its configured
  policies, so authentication, rate limiting, and validation all apply
- **No code duplication**: You're calling your existing API routes, not
  reimplementing their logic

This is what makes custom tools in Zuplo really powerful. You're not building a
separate system; you're writing the code that composes existing endpoints in
your gateway into higher-level workflows.

## Requirements

Custom tools in Zuplo combine three things:

1. **An OpenAPI operation** that defines the tool's interface (what arguments it
   accepts, what it returns)
2. **A TypeScript handler** that implements the logic of your tool using
   `invokeRoute` to orchestrate calls
3. **The MCP Server Handler** that exposes the tool via MCP to whatever AI
   client requires it

<CalloutDoc
  title="MCP Custom Tools"
  description={`Build multi-step AI workflows by composing existing API routes into higher-level MCP tools.`}
  href="https://zuplo.com/docs/mcp-server/custom-tools"
  features={[
    `invokeRoute()
composition`,
    `Server-side orchestration`,
    `No network overhead`,
  ]}
/>

## Building a Custom Tool with Zuplo

Let's walk through the travel advisor example. We have three backend API
endpoints for weather, activities, and packing suggestions. We want a single
`planTrip` tool that orchestrates calls to all three.

### Custom Tool Handler

The handler fetches data from each endpoint, analyzes the weather for rainy
days, and returns a structured brief that the AI client can work with:

```typescript
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";

export default async function (request: ZuploRequest, context: ZuploContext) {
  const { destination } = await request.json();
  const city = destination.toLowerCase().replace(/\s+/g, "-");

  // Fetch weather data from the API
  const weatherResp = await context.invokeRoute(`/weather/${city}`);
  if (!weatherResp.ok) {
    throw new Error(`Could not fetch weather for ${destination}`);
  }
  const weather = await weatherResp.json();

  // Fetch activities from the API
  const activitiesResp = await context.invokeRoute(`/activities/${city}`);
  if (!activitiesResp.ok) {
    throw new Error(`Could not fetch activities for ${destination}`);
  }
  const activities = await activitiesResp.json();

  // Fetch packing list based on climate from the API
  const packingResp = await context.invokeRoute(
    `/packing/${weather.climate_type}`,
  );
  if (!packingResp.ok) {
    throw new Error(`Could not fetch packing list`);
  }
  const packing = await packingResp.json();

  // Analyze weather for rainy days
  const rainyDays = weather.forecast.filter(
    (day: any) => day.precipitation_chance >= 50,
  );
  const hasSignificantRain = rainyDays.length > 0;

  // Build a full response as JSON for the LLM to work with
  // This is the only data that is returned from the tool call
  return {
    destination: weather.city,
    climate: weather.climate_type,
    weather_summary: {
      temperature_range: {
        high: Math.max(...weather.forecast.map((d: any) => d.high_celsius)),
        low: Math.min(...weather.forecast.map((d: any) => d.low_celsius)),
      },
      rainy_days: rainyDays.length,
      rain_warning: hasSignificantRain
        ? `Rain expected on ${rainyDays.length} day(s). Consider indoor activities.`
        : null,
    },
    activities: activities.activities.map((activity: any) => ({
      ...activity,
      weather_suitable: hasSignificantRain ? activity.type === "indoor" : true,
    })),
    packing: {
      ...packing,
      rain_gear_priority: hasSignificantRain ? "high" : "low",
    },
  };
}
```

As mentioned previously, this custom tool takes advantage of the `invokeRoute`
method to call an existing route on the gateway. Which makes up the majority of
the code.

This handler focuses purely on orchestration and business logic.

### The Route Configuration

The OpenAPI spec defines the tool's interface and connects it to the MCP
handler:

```json
{
  "/plan-trip": {
    "post": {
      "operationId": "planTrip",
      "summary": "Plan a trip to a destination",
      "description": "Aggregates weather, activities, and packing suggestions into a travel brief.",
      "x-zuplo-route": {
        "corsPolicy": "none",
        "handler": {
          "export": "default",
          "module": "$import(./modules/plan-trip)"
        },
        "mcp": {
          "type": "tool"
        }
      },
      "requestBody": {
        "required": true,
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": ["destination"],
              "properties": {
                "destination": {
                  "type": "string",
                  "description": "The city to plan a trip to"
                }
              }
            }
          }
        }
      }
    }
  }
}
```

The `"mcp": { type: "tool" }` marker tells the MCP Server Handler to expose this
operation.

### Exposing a Custom Tool via MCP

Finally, add the tool to your MCP server configuration:

```json
{
  "/mcp": {
    "post": {
      "operationId": "mcpServer",
      "x-zuplo-route": {
        "handler": {
          "export": "mcpServerHandler",
          "module": "$import(@zuplo/runtime)",
          "options": {
            "name": "Travel Advisor",
            "version": "1.0.0",
            "operations": [
              {
                "file": "./config/routes.oas.json",
                "id": "planTrip"
              }
            ]
          }
        }
      }
    }
  }
}
```

This can also be achieved without the need to go tinkering with the OpenAPI spec
inside Zuplo by using the _Add MCP Server_ feature of the Route Designer in the
Zuplo Portal.

## The Tool in Action

The result is a single MCP tool that returns a highly informative response. In
this case I tested it using
[MCPJam](https://www.mcpjam.com/?utm_source=zuplo.com), but because it's an MCP
tool it will work in any MCP compatible client, such as Claude, ChatGPT, and
many others.

![The output of using the custom tool in MCPJam](/media/posts/2025-12-03-mcp-server-custom-tools/custom-tools-output.png)

## When to Use Custom Tools

Direct endpoint mapping works fine when:

- The operation is self-contained
- No additional business logic is needed
- The AI can handle orchestration

Custom tools make sense when:

- Multiple API calls need to happen together
- Business logic should live server-side
- You want to reduce round trips between AI and API
- Error handling needs to be centralized

The travel advisor example is a very basic one, but the pattern scales. You
could build tools that validate inventory before creating orders, aggregate data
from multiple microservices, or implement approval workflows that span several
systems.

## See the Code & Try It Yourself

<CalloutSample
  title="MCP Custom Tools Example"
  description="A complete Travel Advisor API with custom MCP tools that orchestrate weather, activities, and packing suggestions."
  deployUrl="https://zuplo.com/examples/mcp-server-custom-tools"
  repoUrl="https://github.com/zuplo/zuplo/tree/main/examples/mcp-server-custom-tools"
  localCommand="npx create-zuplo-api --example mcp-server-custom-tools"
/>

<CalloutNextStep
  label="Documentation"
  title="MCP Custom Tools Reference"
  href="https://zuplo.com/docs/mcp-server/custom-tools"
/>