Zuplo
MCP Server

MCP Server Custom Tools

The MCP Server Handler supports custom tools that allow you to create sophisticated MCP (Model Context Protocol) tools using OpenAPI specifications and custom handler functions. This provides the flexibility to build complex workflows that can invoke multiple API routes, implement custom business logic, and provide rich responses to AI systems without having to sacrifice the governance and power of an OpenAPI config.

Custom tools give you full programmatic control over tool behavior within an MCP Server Handler. Define tools using standard OpenAPI patterns with custom TypeScript handlers for complex multi-step workflows and custom logic.

Key Features

  • OpenAPI Standard: Define tools using standard OpenAPI specifications
  • Custom Handlers: Implement complex logic using TypeScript functions
  • Complex Workflows: Chain multiple API calls, implement business logic, and handle complex data transformations
  • Type Safety: Built-in JSON Schema validation for LLM tool arguments and inputs
  • Runtime Integration: Access to context.invokeRoute(), logging, and other Zuplo runtime features

Quick Start

1. Define Your API Specification

Create an OpenAPI specification that defines your tools:

JSONCode
{ "openapi": "3.1.0", "info": { "version": "0.0.1", "title": "My Calculator API", "description": "A simple calculator API with basic arithmetic operations" }, "paths": { "/add": { "post": { "summary": "Add two numbers", "description": "Adds two numbers together and returns the result", "operationId": "addNumbers", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TwoNumberOperation" } } } }, "x-zuplo-route": { "corsPolicy": "none", "handler": { "export": "default", "module": "$import(./modules/add)" } } } }, "/multiply": { "post": { "summary": "Multiply two numbers", "description": "Multiplies two numbers together and returns the result", "operationId": "multiplyNumbers", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TwoNumberOperation" } } } }, "x-zuplo-route": { "corsPolicy": "none", "handler": { "export": "default", "module": "$import(./modules/multiply)" } } } } }, "components": { "schemas": { "TwoNumberOperation": { "type": "object", "required": ["a", "b"], "properties": { "a": { "type": "number", "description": "First number" }, "b": { "type": "number", "description": "Second number" } }, "example": { "a": 10, "b": 5 } } } } }

POST routes with a requestBody and a defined schema are translated into an MCP tool's parameters. When invoked, these are validated by the MCP server to ensure the tool is being correctly used by the LLM.

Other methods like GET, DELETE, etc. work in a similar fashion in order to support complex tools in the shape of your APIs.

2. Create Custom Handler Functions

Create handler modules for your tools that map to your routes:

TypeScriptCode
// modules/add.ts import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; export default async function (request: ZuploRequest, context: ZuploContext) { const args = await request.json(); context.log.info(`Adding ${args.a} + ${args.b}`); return args.a + args.b; }
TypeScriptCode
// modules/multiply.ts import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; export default async function (request: ZuploRequest, context: ZuploContext) { const args = await request.json(); context.log.info(`Multiplying ${args.a} * ${args.b}`); return args.a * args.b; }

3. Configure the MCP Server Handler

Configure the MCP Server Handler to use your OpenAPI specification:

JSONCode
{ "paths": { "/mcp": { "post": { "operationId": "mcp-server-handler", "x-zuplo-route": { "handler": { "export": "mcpServerHandler", "module": "$import(@zuplo/runtime)", "options": { "name": "Calculator MCP Server", "version": "0.0.0", "openApiFilePaths": [ { "filePath": "./config/routes.oas.json" } ] } } } } } } }

4. Deploy and Test

Deploy your project and test your MCP server:

TerminalCode
# Test with MCP Inspector npx @modelcontextprotocol/inspector # Or test with curl curl https://your-gateway.zuplo.dev/mcp \ -X POST \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": "0", "method": "tools/list" }'

Advanced Usage

Complex Multi-Step Workflows

Create sophisticated workflows that chain multiple operations:

TypeScriptCode
// modules/process-order.ts import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; export default async function (request: ZuploRequest, context: ZuploContext) { const args = await request.json(); // Step 1: Validate customer const customerResp = await context.invokeRoute( `/customers/${args.customerId}`, ); if (!customerResp.ok) { throw new Error("Customer not found"); } // Step 2: Check inventory const inventoryChecks = await Promise.all( args.items.map((item: any) => context.invokeRoute( `/inventory/${item.productId}/check?quantity=${item.quantity}`, ), ), ); const unavailableItems = inventoryChecks .map((resp, i) => ({ resp, item: args.items[i] })) .filter(({ resp }) => !resp.ok) .map(({ item }) => item.productId); if (unavailableItems.length > 0) { throw new Error(`Items not available: ${unavailableItems.join(", ")}`); } // Step 3: Create order const orderResp = await context.invokeRoute("/orders", { method: "POST", body: JSON.stringify({ customerId: args.customerId, items: args.items, }), headers: { "Content-Type": "application/json" }, }); const order = await orderResp.json(); return { orderId: order.id, status: "created", total: order.total, estimatedDelivery: order.estimatedDelivery, }; }

With the corresponding OpenAPI definition:

JSONCode
{ "/process-order": { "post": { "summary": "Process a customer order", "description": "Process a customer order through multiple validation steps", "operationId": "processOrder", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["customerId", "items"], "properties": { "customerId": { "type": "string", "description": "Unique customer identifier" }, "items": { "type": "array", "description": "List of items to order", "items": { "type": "object", "required": ["productId", "quantity"], "properties": { "productId": { "type": "string", "description": "Product identifier" }, "quantity": { "type": "number", "description": "Number of items to order" } } } } } } } } }, "responses": { "200": { "description": "Order processed successfully", "content": { "application/json": { "schema": { "type": "object", "properties": { "orderId": { "type": "string", "description": "Generated order ID" }, "status": { "type": "string", "enum": ["created", "pending", "confirmed"], "description": "Order status" }, "total": { "type": "number", "description": "Total amount" } } } } } } }, "x-zuplo-route": { "handler": { "export": "default", "module": "$import(./modules/process-order)" } } } } }

Error Handling

Handle errors gracefully by throwing standard JavaScript errors:

TypeScriptCode
// modules/validate-user.ts import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; export default async function (request: ZuploRequest, context: ZuploContext) { const args = await request.json(); if (args.shouldFail) { throw new Error(args.errorMessage || "Validation failed"); } return { valid: true, userId: args.userId }; }

Accessing Request Headers

Access original request headers through the standard request object:

TypeScriptCode
// modules/check-headers.ts import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; export default async function (request: ZuploRequest, context: ZuploContext) { const args = await request.json(); const headerValue = request.headers.get(args.headerName); if (headerValue) { return `Header '${args.headerName}': ${headerValue}`; } else { return `Header '${args.headerName}' not found`; } }

context.invokeRoute will utilize the full inbound and outbound policy pipeline. This means that policies you set on your MCP server route will be invoked alongside policies that are associated with any calls made through invokeRoute.

Testing Custom Tools

Using MCP Inspector

The MCP Inspector is ideal for testing custom tools:

TerminalCode
npx @modelcontextprotocol/inspector
  1. Set Transport Type to "Streamable HTTP"
  2. Set URL to your MCP endpoint (e.g., https://your-gateway.zuplo.dev/mcp)
  3. Connect and test your tools interactively

Using cURL

Test individual tools directly:

TerminalCode
# List available tools curl https://your-gateway.zuplo.dev/mcp \ -X POST \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": "0", "method": "tools/list" }' # Call a specific tool curl https://your-gateway.zuplo.dev/mcp \ -X POST \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": "1", "method": "tools/call", "params": { "name": "addNumbers", "arguments": { "a": 5, "b": 3 } } }'

Best Practices

Schema Design

Use descriptive and well-structured JSON schemas for your tools. This is used by the server to validate MCP client inputs (i.e., JSON generated by an LLM). Providing descriptive schemas ensures an MCP Client's LLM always has the appropriate context on exactly what arguments to provide to tools and can dramatically reduce invalid tool usage. This validation is done automatically.

JSONCode
// Good! Uses descriptive names and specific types with limiters and formats. { "type": "object", "required": ["userId"], "properties": { "userId": { "type": "string", "format": "uuid", "description": "Valid UUID for user ID" }, "amount": { "type": "number", "minimum": 0, "maximum": 10000, "description": "Amount in cents" } } }
JSONCode
// Bad! Confusing. What is "a"? What is "b"? An LLM won't understand this. { "type": "object", "required": ["userId"], "properties": { "a": { "type": "string" }, "b": { "type": "number" } } }

Tool Design

  1. Clear Operation IDs: Use descriptive, action-oriented operation IDs (addNumbers, processOrder)
  2. Detailed Descriptions: Help AI systems understand what your tool does
  3. Error Handling: Throw meaningful JavaScript errors
  4. Unique Names: Ensure operation IDs are unique across your API

Troubleshooting

Common Issues

Tool not appearing in tools/list:

  • Check that the endpoint is a POST method in your OpenAPI spec
  • Verify the operation has an operationId
  • Check for validation errors in your OpenAPI specification
  • Ensure the handler module exports a default function

Handler execution failures:

  • Use context.log.error(), context.log.warn(), context.log.info() for logging
  • Verify API routes being invoked through invokeRoute exist and are accessible
  • Test individual API calls outside the MCP context
  • Check that your handler function is properly exported as default

Schema validation errors:

  • Ensure JSON schemas are properly defined in the OpenAPI spec
  • Check that request body schemas match the data your handler expects
  • Verify response schemas match the data your handler returns

Debugging Tips

  1. Enable Debug Logging: Use context.log.debug() liberally and turn on debug mode in your MCP server
  2. Test Components Separately: Test API routes and business logic independently
  3. Use MCP Inspector: Interactive testing is invaluable for development
  4. Validate OpenAPI: Use tools like Swagger Editor to validate your OpenAPI specification

Learn More

Last modified on