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:
- Fetch weather data for Tokyo (using one tool)
- Get activity recommendations (using a second tool)
- Look up packing suggestions for the climate (using a third tool)
- 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 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.
Watch the Demo#
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:
- An OpenAPI operation that defines the tool's interface (what arguments it accepts, what it returns)
- A TypeScript handler that implements the logic of your tool using
invokeRouteto orchestrate calls - The MCP Server Handler that exposes the tool via MCP to whatever AI client requires it
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:
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:
{
"/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:
{
"/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, but because it's an MCP tool it will work in any MCP compatible client, such as Claude, ChatGPT, and many others.

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#
We've published a complete working example of this Travel Advisor API, MCP Server and Custom Tool that you can deploy to Zuplo in one click:
Deploy the Travel Advisor Example
For more information on using Custom Tools with MCP in Zuplo, check out the MCP Custom Tools documentation for the full reference.