OpenAPI to MCP server: 150 endpoints, 49 AI tools
Botoi's REST API has 150+ endpoints. When we built the MCP server, we registered 49 of them as tools. Not because the rest don't work. Because giving an AI model 150 tools is like handing someone a 200-page menu; they'll pick something, but it won't be the right thing.
This post walks through the full process: curating the tool list, converting OpenAPI schemas to Zod objects, writing descriptions AI models parse well, adding MCP annotations, and running the whole thing as a stateless Cloudflare Worker. If you maintain an API and want to build an MCP server from it, this is the playbook.
Why 49 tools, not 150
Every tool you register in an MCP server gets serialized into the model's context window. The tool name, description, and full input schema all consume tokens. A 150-tool manifest can burn 30,000+ tokens before the user types a single word.
That creates two problems:
- Fewer tokens left for the conversation itself
- The model picks the wrong tool more often when the list is long
We tested this. With all 150+ endpoints registered, Claude picked the correct tool on the first attempt about 72% of the time. With 49 curated tools, that number jumped to 94%. The smaller, focused list made the model better at its job.
The curation criteria were simple:
- Does an AI agent need this mid-conversation? (DNS lookup: yes. PDF generation: rarely.)
- Does the tool return structured data the model can reason about? (JSON: yes. Binary image: no.)
- Can the model fill in the required parameters from natural language? (Domain name: yes. Complex nested config objects: no.)
The tool manifest structure
Each curated tool maps an MCP tool name to an API path, HTTP method, description, and annotations. Here's the TypeScript interface:
export interface CuratedTool {
path: string;
method: 'get' | 'post';
title: string;
description: string;
annotations: {
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
};
} And here's what two entries look like in practice:
// curated-tools.ts
export const CURATED_TOOLS: Record<string, CuratedTool> = {
lookup_dns: {
path: '/v1/dns/lookup',
method: 'post',
title: 'DNS Lookup',
description:
'Query DNS records (A, AAAA, MX, TXT, CNAME, NS) for a domain. ' +
'Use when you need to check DNS configuration or troubleshoot domain resolution.',
annotations: { readOnlyHint: true, openWorldHint: true },
},
dev_hash: {
path: '/v1/hash',
method: 'post',
title: 'Hash Text',
description:
'Generate a hash (MD5, SHA-1, SHA-256, SHA-512) of input text. ' +
'Use for checksums, data integrity, or fingerprinting.',
annotations: { readOnlyHint: true },
},
// ... 47 more tools
};
The path and method fields point to the existing REST endpoint. The description tells the model when to use the tool. The annotations tell the model how the tool behaves.
Converting OpenAPI schemas to Zod
The MCP SDK expects tool input schemas as Zod objects. Our API already has an OpenAPI 3.1 spec with full request body definitions for every endpoint. The schema-builder reads those definitions and generates Zod types at server startup.
The core conversion function maps each OpenAPI property type to its Zod equivalent:
// schema-builder.ts
import { z } from 'zod';
import { paths } from '../../openapi-paths';
function mapPropertyToZod(
prop: OpenApiProperty,
isRequired: boolean
): z.ZodTypeAny {
let schema: z.ZodTypeAny;
if (prop.enum && prop.enum.length > 0) {
schema = z.enum(prop.enum as [string, ...string[]]);
} else {
switch (prop.type) {
case 'number':
case 'integer':
schema = z.number();
break;
case 'boolean':
schema = z.boolean();
break;
case 'array':
schema = z.array(z.string());
break;
case 'object':
schema = z.record(z.unknown());
break;
default:
schema = z.string();
break;
}
}
if (prop.description) {
schema = schema.describe(prop.description);
}
if (prop.default !== undefined) {
schema = schema.default(prop.default);
}
if (!isRequired) {
schema = schema.optional();
}
return schema;
} The key decisions in this function:
enumvalues becomez.enum(), giving the model a fixed set of valid options- Required fields stay mandatory; optional fields get
.optional() - The OpenAPI
descriptioncarries over via.describe(), which the MCP SDK includes in the tool manifest - Default values propagate through
.default()
The buildZodSchema function handles both POST (request body) and GET (query parameters) endpoints:
export function buildZodSchema(
apiPath: string,
method: 'get' | 'post'
): Record<string, z.ZodTypeAny> {
const operation = getOperation(apiPath, method);
if (!operation) return {};
// POST: read from requestBody schema
if (method === 'post') {
const schema = operation.requestBody
?.content?.['application/json']?.schema;
if (!schema?.properties) return {};
const required = new Set(schema.required ?? []);
const result: Record<string, z.ZodTypeAny> = {};
for (const [key, prop] of Object.entries(schema.properties)) {
result[key] = mapPropertyToZod(prop, required.has(key));
}
return result;
}
// GET: read from query parameters
const params = operation.parameters;
if (!params || params.length === 0) return {};
const result: Record<string, z.ZodTypeAny> = {};
for (const param of params) {
if (param.in !== 'query') continue;
const prop: OpenApiProperty = {
type: param.schema?.type ?? 'string',
description: param.description ?? param.schema?.description,
default: param.schema?.default,
enum: param.schema?.enum,
};
result[param.name] = mapPropertyToZod(prop, param.required === true);
}
return result;
}
This function runs once per tool during server creation. It returns a flat Record<string, z.ZodTypeAny> that the MCP SDK serializes into JSON Schema for the tool manifest.
Writing tool descriptions AI models parse well
The tool description is the single most important field for correct tool selection. Models read it to decide whether a tool matches the user's intent. Vague descriptions lead to wrong tool picks.
We settled on a two-sentence pattern:
- First sentence: what the tool does, starting with a verb. Include the specific data types or formats it handles.
- Second sentence: when to use it, starting with "Use when". This gives the model a trigger condition.
Compare these two descriptions for the same DNS lookup tool:
| Version | Description | Problem |
|---|---|---|
| Bad | "DNS tool for looking things up" | No record types listed, no trigger condition, vague |
| Good | "Query DNS records (A, AAAA, MX, TXT, CNAME, NS) for a domain. Use when you need to check DNS configuration or troubleshoot domain resolution." | None |
The good version tells the model the exact record types it can query (so it knows this tool handles MX lookups) and the situations that should trigger it (DNS configuration checks, troubleshooting). The model matches user intent against these keywords.
MCP annotations: telling models how tools behave
Annotations are metadata flags on each tool. They don't affect execution. They tell the model what kind of side effects to expect.
// Read-only tool that hits an external service
lookup_dns: {
annotations: { readOnlyHint: true, openWorldHint: true },
}
// Encryption tool: no external calls, same input = same output
security_encrypt: {
annotations: { idempotentHint: true },
} The four annotations and what they signal:
| Annotation | Signal | Example |
|---|---|---|
readOnlyHint | This tool reads data but never modifies anything | DNS lookup, WHOIS, SSL check |
destructiveHint | This tool deletes or overwrites data | Webhook inbox delete (not in our curated set) |
idempotentHint | Calling this tool twice with the same input produces the same result | AES encrypt, AES decrypt |
openWorldHint | This tool makes external network requests | IP lookup, URL metadata, tech detect |
Of our 49 tools, 44 carry readOnlyHint: true. The 12 lookup tools also carry openWorldHint: true because they call external DNS servers, WHOIS registries, or fetch live web pages. The encrypt/decrypt tools carry idempotentHint: true because they're deterministic transformations.
None of our curated tools carry destructiveHint. That was a deliberate choice. We excluded tools like webhook inbox deletion and paste deletion from the curated set because AI models shouldn't delete user data without strong guardrails.
Registering tools on the MCP server
The registration loop ties everything together. It iterates over the curated tool list, builds the Zod schema from the OpenAPI spec, and registers each tool with its description and annotations:
// server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { CURATED_TOOLS } from './curated-tools';
import { buildZodSchema } from './schema-builder';
function createMcpServer(apiKey: string | undefined, env: Env) {
const server = new McpServer(
{ name: 'botoi', version: '1.0.0' },
{ jsonSchemaValidator: new CfWorkerJsonSchemaValidator() }
);
for (const [toolName, tool] of Object.entries(CURATED_TOOLS)) {
const zodSchema = buildZodSchema(tool.path, tool.method);
server.registerTool(toolName, {
title: tool.title,
description: tool.description,
inputSchema: zodSchema,
annotations: tool.annotations,
}, async (args: Record<string, unknown>) => {
return callApi(tool.path, tool.method, args, apiKey, env);
});
}
return server;
}
When the model calls a tool, the handler function receives the parsed arguments and forwards them to the internal API route. The callApi function builds an internal HTTP request and returns the response as MCP-formatted content:
async function callApi(
path: string,
method: string,
body: unknown,
apiKey: string | undefined,
env: Env
) {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey) headers['X-API-Key'] = apiKey;
const req = new Request(`http://internal${path}`, {
method: method.toUpperCase(),
headers,
body: method === 'post' ? JSON.stringify(body) : undefined,
});
const res = appFetcher
? await appFetcher(req, env)
: await fetch(req);
const json = await res.json();
if (!json.success) {
return {
content: [{ type: 'text', text: JSON.stringify(json.error, null, 2) }],
isError: true,
};
}
return {
content: [{ type: 'text', text: JSON.stringify(json.data, null, 2) }],
};
}
The appFetcher pattern lets the MCP server call the API routes through an internal function reference instead of making an external HTTP request. This avoids network round-trips. The MCP handler and the API routes run in the same Cloudflare Worker, so internal routing is a function call, not an HTTP hop.
Stateless HTTP transport on Cloudflare Workers
MCP supports two transports: stdio (for local processes) and Streamable HTTP (for remote servers). We chose Streamable HTTP because the server runs on Cloudflare Workers, which don't support long-running processes.
// Hono route handler
import { WebStandardStreamableHTTPServerTransport }
from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
app.all('/mcp', async (c) => {
const apiKey =
c.req.header('X-API-Key') ||
c.req.header('Authorization')?.replace('Bearer ', '');
const server = createMcpServer(apiKey, c.env);
const transport = new WebStandardStreamableHTTPServerTransport();
await server.connect(transport);
return transport.handleRequest(c.req.raw);
});
Each request creates a fresh McpServer instance. No session state persists between requests. This is fine because every tool call is self-contained; the model sends the tool name and arguments, and the server returns the result. No multi-step transactions.
The stateless design has three advantages:
- No session storage needed (no Redis, no KV, no database)
- Scales to zero when idle, scales horizontally under load
- Deploys to 300+ Cloudflare edge locations with zero configuration
API key handling happens at the MCP layer. The client sends the key via X-API-Key or Authorization: Bearer header. The MCP route extracts it and passes it through to the internal API call. No separate auth middleware on the MCP route itself.
The playbook for your own API
If you have an OpenAPI spec and want to build an MCP server, here's the condensed version:
- Curate your tool list. Pick the 20-80 endpoints that return structured data and accept simple inputs. Skip endpoints that return binary data, require file uploads, or have deep nested input schemas.
- Write a schema converter. Map your OpenAPI property types to Zod. Carry over descriptions, defaults, and enum values. Handle both request body (POST) and query parameter (GET) patterns.
- Write two-sentence descriptions. Sentence one: what the tool does, starting with a verb. Sentence two: "Use when" + trigger condition. Be specific about data types and formats.
- Add annotations. Mark read-only tools. Flag tools that make external network calls. Identify idempotent operations. Exclude destructive tools unless you have confirmation flows.
- Pick your transport. Use Streamable HTTP for remote servers, stdio for local CLI tools. The MCP SDK provides both.
- Route tool calls to your existing API. Don't rewrite business logic. Call your own routes internally. The MCP server is a thin adapter layer.
Botoi's MCP server is 4 files: curated-tools.ts (49 tool definitions), schema-builder.ts (OpenAPI-to-Zod converter), server.ts (registration and routing), and tools.ts (public manifest endpoint). The whole thing adds about 400 lines of TypeScript to an existing API.
Try it
The Botoi MCP server is live at https://api.botoi.com/mcp. Connect it to Claude Desktop, Claude Code, Cursor, or VS Code in under a minute. See the MCP setup docs for config snippets for every supported client.
Browse the full tool manifest to see all 49 tool definitions with their schemas and annotations. The API docs cover the complete set of 150+ REST endpoints behind the MCP server.
Frequently asked questions
- How do you build an MCP server from an OpenAPI spec?
- Parse your OpenAPI path definitions, extract the request body schema (for POST) or query parameters (for GET), convert each property to a Zod type, then register each tool on an McpServer instance with the Zod schema as inputSchema. The MCP SDK handles JSON-RPC transport and tool discovery.
- Why not expose all API endpoints as MCP tools?
- AI models have a context window limit. Each tool definition consumes tokens. A 150-tool manifest can eat 30,000+ tokens before the conversation starts. Curating down to 49 high-value tools keeps the manifest under 8,000 tokens and improves tool selection accuracy.
- What are MCP tool annotations and why do they matter?
- Annotations are metadata hints like readOnlyHint, destructiveHint, idempotentHint, and openWorldHint. They tell AI models whether a tool reads or writes data, whether it contacts external services, and whether it is safe to retry. Models use these hints to plan multi-step workflows and avoid destructive actions without confirmation.
- Can an MCP server run on Cloudflare Workers?
- Yes. Use the WebStandardStreamableHTTPServerTransport from the MCP SDK. It works with any runtime that supports the Web Standards Request/Response API. Cloudflare Workers, Deno Deploy, and Vercel Edge Functions all qualify. Each request creates a fresh McpServer instance, so no session state is needed.
- How should I write MCP tool descriptions for AI models?
- Start with a verb. State what the tool does in one sentence. Add a second sentence starting with "Use when" that describes the trigger condition. Skip implementation details. Example: "Query DNS records (A, AAAA, MX, TXT, CNAME, NS) for a domain. Use when you need to check DNS configuration or troubleshoot domain resolution."
Try this API
DNS Lookup API — interactive playground and code examples
More integration posts
Start building with botoi
150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.