The Embedded Finance Index is live: 6,292 companies and 1,383 providers, mapped across the stack. Explore the Index →

How We Built an MCP Server With 381 Tools From Our OpenAPI Spec

How Apideck generates a 381-tool MCP server from its OpenAPI spec: why we moved from Speakeasy to our own generator, dynamic and code modes from ~600 tokens, workflow tools, validation, scopes and Vault session auth, all on one Vercel deployment.

GJGJ

GJ · Co-founder, Apideck

19 min readView as .md
How We Built an MCP Server With 381 Tools From Our OpenAPI Spec

TL;DR: We generate a production MCP server from our OpenAPI spec: 377 endpoint tools plus 4 hand-written workflow tools, served from one Vercel deployment. Agents choose between three ways to load those tools, from ~600 tokens to the full catalog. We started on Speakeasy and moved to our own generator once we needed things a third-party generator couldn't give us. This is the stack, the design decisions and the lessons from shipping it.

Updated October 2026. The original April post covered our Speakeasy-generated server with 229 tools. Since then we rewrote it as mcp-native, a clean-room TypeScript server with an in-repo generator. It now has 381 tools across 11 Unified APIs, a third mode (code mode), workflow tools, input validation and Vault session auth. You can try every tool in the browser with the MCP Explorer.


In an earlier post, we showed how MCP servers eat context windows (55,000+ tokens before an agent reads a single message) and made the case for CLIs as a lighter alternative.

But CLIs don't solve everything. Every major agent framework connects to tools via MCP: OpenAI Agents SDK, Pydantic AI, LangChain, Google ADK. If you want your API to work across all of them, you need an MCP server. We wrote about the tradeoffs between APIs, MCPs, or both previously, and for multi-framework compatibility, MCP wins. So we ship both: the MCP server for agent frameworks and the Apideck CLI for terminal agents.

The challenge is different for us than for single-vendor API companies. Xero ships an MCP server with ~50 tools for one accounting system. Stripe ships a curated set of tools for one payments system. They hand-build their tool definitions and move on.

Apideck is a Unified API platform. One integration gives developers access to 50+ accounting systems (QuickBooks, Xero, NetSuite, Sage), HRIS platforms, CRMs, file storage providers, and more through a single API. That means our MCP server doesn't expose "QuickBooks invoices" or "Xero invoices." It exposes "accounting invoices," and the right connector fires based on which integration the consumer has authorized through Vault.

That architecture has a direct consequence: our tool surface is massive. Today it is 377 operations across accounting, CRM, file storage, HRIS, Vault, ATS, issue tracking, ecommerce, webhooks, connector metadata and a proxy API. Hand-building that many tool definitions is a maintenance nightmare, and every new API group would mean another batch of them. So we generate the whole thing from the OpenAPI spec.

The Stack

ComponentChoiceWhy
Tool generationIn-repo generator (generator/generate.mjs) plus an overlayEmits every endpoint tool from our OpenAPI spec; we own naming, schemas and scopes
HostingVercel Functions on Fluid ComputeGit-push deploys, preview deployments, waitUntil for background work
AnalyticsPostHog (EU) via the /batch APIServer-side tracking of every tool call
RuntimeNode.js + @modelcontextprotocol/sdkStandard MCP protocol implementation
Schema validationZod v4Input schemas, JSON Schema conversion, validation before dispatch
Distributionnpm @apideck/mcp, the MCP Registry, a .mcpb bundleHosted, local stdio and Claude Desktop from one codebase

From Speakeasy to Our Own Generator

This is the fourth iteration of an Apideck MCP server. The version described in the original post used Speakeasy's standalone mcp-typescript target. We already used Speakeasy for SDK generation, so speakeasy run gave us 229 tools, Zod schemas and dynamic discovery in one command. It was the fastest way to get a real server in front of agents, and we'd make the same call again for a first version.

But the features we wanted next all sat outside what a third-party generator could give us:

  1. Workflow tools. A 1:1 OpenAPI-to-tool generator can't compose calls. We wanted intent-level tools like a month-end close check that fans out four reports in parallel. The generated output had no extension point for that.
  2. Generator control. Tool naming, schema shape, scope inference and code-mode method names all needed to be ours. When a tool description is wrong, we want to fix it in our repo, not file an upstream issue and wait for a release.
  3. One runtime path. We only deploy to Vercel, and we want every tool call wrapped in the same analytics and per-request credential handling. The generated code shipped a Cloudflare Workers target we never used and had no hook to wrap every handler.

So in April we started mcp-native, a clean-room, MIT-licensed rewrite. The generator reads our OpenAPI spec plus spec/mcp-overlay.yaml and writes src/tools.ts directly:

pnpm generate       # OpenAPI spec + overlay -> src/tools.ts
pnpm parity-probe   # compare tool counts against the spec, write reports/

A generate-and-pr GitHub Action runs both on demand and opens a PR with the tool-count and token-count deltas, so a spec change becomes a reviewable diff instead of a manual edit. The overlay decides which API groups become tools and annotates each operation with a scope. That's how we went from 229 tools to 377 endpoint tools without hand-writing one: adding CRM, ATS, issue tracking and ecommerce was an overlay change plus a regeneration.

Three Ways to Load 381 Tools

Dumping every tool schema into an agent's context costs tens of thousands of tokens before the agent reads a message. Single-vendor servers can live with that. A Unified API can't. So the server exposes its tools three ways, selected with ?mode= on the hosted endpoint or --mode on the CLI:

ModeWhat the agent seesInitial costBest for
dynamic (default)4 meta-tools~1,300 tokensGeneral agents working across several APIs
codeapideck_search + apideck_run~600 tokensAgents that write code and chain several calls in one step
staticTools registered directlyUp to ~35–55K tokensFocused agents on one API, combined with scope filters

You can switch between the modes and watch the exact JSON-RPC each one sends in the MCP Explorer.

Dynamic mode: progressive discovery

Dynamic mode is the default and the design decision that makes the whole thing work. The agent gets four meta-tools: list_tools, describe_tool_input, execute_tool and list_scopes.

list_tools({})
  -> { total: 381, domains: [{ name: "accounting", count: 186 }, ...] }

list_tools({ search_terms: ["invoices"] })
  -> accounting-invoices-all, accounting-invoices-add, accounting-invoices-one, ...

describe_tool_input({ name: "accounting-invoices-all" })
  -> JSON Schema for the tool's input

execute_tool({ name: "accounting-invoices-all", input: { limit: 10 } })
  -> invoices from the consumer's connected accounting system

The initial cost stays at ~1,300 tokens whether we expose 50 tools or 500. list_tools returns a domain index first and caps each response at 50 tools, so a broad search can't blow the budget either. That decoupling of API surface from token cost is what makes a Unified API viable as an MCP server at all.

It also changes how agents work with multi-vendor APIs. An agent building a financial report doesn't need to know whether it's talking to QuickBooks, Xero or NetSuite. It searches for "invoices", discovers the tool and calls it. The Unified API routes to the right connector based on the consumer's Vault connections.

Code mode: one script instead of ten tool calls

Dynamic discovery costs round trips: an agent that needs four reports makes discovery calls and then four execute_tool calls. Code mode replaces the endpoint tools with two:

  1. apideck_search finds endpoints by substring and returns camelCase method names.
  2. apideck_run executes a JavaScript snippet with apideck.<methodName> bound to live endpoints.
apideck_search("invoices")
  -> { count: 4, endpoints: [{ name: "accounting-invoices-all", method: "accountingInvoicesAll" }, ...] }

apideck_run({
  script: "const [inv, bills] = await Promise.all([apideck.accountingInvoicesAll({ limit: 5 }), apideck.accountingBillsAll({ limit: 5 })]); return { inv, bills }"
})

The script runs in a node:vm sandbox with process, require, fs and the other Node globals removed. The only thing it can reach is the apideck object, and every apideck.* call still respects the connection's scopes and is recorded in analytics like a normal tool call.

Static mode: honest status

Static mode registers tools directly, so tools/list returns everything at once. That's the right fit for a focused agent that always uses the same handful of operations. Today, static mode registers the four workflow tools only; registering all endpoint tools directly is still on our list. For endpoint calls, use dynamic or code mode.

Workflow Tools: Intent Over Endpoints

Generated tools map 1:1 to API operations, and agents routinely get multi-step flows wrong: they forget a field, call things in the wrong order or give up halfway. Workflow tools are hand-written composites that orchestrate several calls behind one tool:

ToolWhat it does
apideck-month-end-close-checkFetches aged creditors, aged debtors, balance sheet and P&L in parallel; returns partial results when a connector doesn't support a report
apideck-pay-billLooks up a bill and creates a bill payment allocated against it, so the accounting system marks it (partially) paid
apideck-receive-customer-paymentLooks up an invoice and creates a customer payment allocated against it
apideck-onboard-employeeTurns a hired ATS applicant into an HRIS employee and can move the applicant to a hired stage

They are registered alongside the generated tools and follow the same scope rules. This was the feature that pushed us off a generated-only server.

Validation Before Dispatch

An early lesson from watching real agent traffic: agents send malformed input a lot, and a downstream 400 from a third-party accounting system is a terrible error to recover from. So execute_tool validates input against the tool's schema before any call leaves the server. A schema violation returns isError: true with code: "invalid_tool_input" and the offending field paths, and nothing is sent to the connector. Write tools also reject unknown top-level fields, and request schemas leave out read-only response fields, so an agent can't echo an object it read back into a create call. Validation errors never echo the submitted values.

The runtime also retries transient failures (408, 5xx and network errors) with exponential backoff, but only for idempotent methods. A POST that timed out is never silently replayed.

Scopes: Structural Permissions Without Prompts

Every tool is annotated with a scope based on its HTTP method:

ScopeHTTP methodsWhat it means
readGET, HEADSafe, no side effects
writePOST, PUT, PATCHCreates or modifies data
destructiveDELETEIrreversible

On the hosted server, scopes are a query parameter: https://mcp.apideck.dev/mcp?scopes=read or ?scopes=read,write. On the CLI, repeat --scope. Scopes apply in every mode, including inside apideck_run scripts.

This is enforced at registration, not in a prompt. A read-only agent doesn't have delete tools available. There's nothing to misuse. Telling an agent "never delete production data" in a system prompt is like putting a sticky note on the nuclear launch button. For a deeper look at the security landscape of MCP, we published a separate analysis covering auth, injection and trust boundaries.

Specialized agents from one server

Because scopes are just a URL parameter, a team of agents can share one deployment with different permissions:

from agents.mcp import MCPServerStreamableHttp

headers = {
    "x-apideck-api-key": "your-key",
    "x-apideck-app-id": "your-app",
    "x-apideck-consumer-id": "acme-inc",
}

# Financial analyst: read-only, can never create or delete
analyst = MCPServerStreamableHttp(params={"url": "https://mcp.apideck.dev/mcp?scopes=read", "headers": headers})

# AP processor: can read and create, but never delete
ap_processor = MCPServerStreamableHttp(params={"url": "https://mcp.apideck.dev/mcp?scopes=read,write", "headers": headers})

If you embed the server in your own Node service, createServer() also takes an allowedTools list to mount an exact set of tools.

Real-world example: AP automation in read-only mode

Here's what this looks like in practice. Our AP Automation agent processes vendor invoices end-to-end: supplier lookup, PO matching, duplicate checks, GL coding and bill creation. Running read-only, it completes every analysis step and stops at the write boundary:

AP Automation Agent

The agent identifies that a supplier (LinkedIn Ireland Unlimited Company) needs to be created and a bill needs to be entered. Instead of failing silently or hallucinating a success, it reports exactly what's blocked and why:

  • Duplicate Check: Passed — no existing bill with this invoice number
  • Supplier: Needs to be created (blocked by read-only mode)
  • GL Coding: Appropriate expense account identified (ID: 2)
  • Bill Creation: Ready to proceed once supplier is created (blocked by read-only mode)

A read-only agent does the analysis and flags what needs approval; a write-enabled agent or a human acts on it. The read-only agent cannot create a supplier, no matter what the prompt says.

Identity: API Keys, Session Tokens and Consumers

Each request carries its own credentials in headers, so the same deployment serves every customer without sessions or tenant routing:

HeaderPurpose
x-apideck-api-key (or Authorization: Bearer)Your Apideck API key
x-apideck-app-idYour Apideck application
x-apideck-consumer-idThe end customer whose connections to use
x-apideck-auth-type: JWTOptional: the bearer is a Vault session token, not an API key

Two additions since the first version matter for production use.

Vault session tokens. Your API key should never reach a browser or an end user's agent. With x-apideck-auth-type: JWT, the server accepts a short-lived Vault session token instead and forwards it to the Unified API. The MCP Explorer uses exactly this: open the API Explorer from the Apideck dashboard and the MCP Explorer reuses the same temporary session.

Per-call consumers, with an opt-in lock. The consumer ID is optional at connect time. An agent can pass consumer_id (and service_id to target a specific connector) on any call, which suits back-office agents that work across customers. For agents that act for one customer, append ?lock_identity=true: per-call overrides are then ignored and identity comes only from the connection headers.

Why Vercel

MCP over HTTP is Server-Sent Events over POST. The host needs to handle streaming responses, reasonable cold starts and background work after the response is sent. We evaluated three options.

Cloudflare Workers. The Speakeasy version shipped a Workers target, and the runtime is appealing. But the free plan's CPU time limit is too tight to load hundreds of tools and their Zod schemas on a cold start, and we didn't want to force a paid runtime for an open-source project. The rewrite dropped it.

Self-hosted. npx @apideck/mcp serve runs the same handler over plain HTTP, which is great for local development or Railway and Fly.io. For a public endpoint, though, you own uptime, scaling, TLS and monitoring.

Vercel. The hosted endpoint is one function, api/mcp.ts, behind a /mcp rewrite:

  • Git-push deploys and previews. Every PR gets a preview deployment we can point a real agent at before merging.
  • Per-request isolation. Each request builds a server with its own credentials from the headers, held in AsyncLocalStorage for the duration of the call. No shared state, no leaks between tenants.
  • Fluid Compute. Instances are reused across concurrent requests, so the cost of loading the tool catalog is paid once per instance, not per request.
  • waitUntil. The only reason server-side analytics work at all in this setup. More on that below.

PostHog Analytics in Serverless (The Hard Part)

We wanted to know which tools agents actually call: tool name, duration, error rate and mode. PostHog's /batch API works server-side without the browser SDK. Simple enough, right? It took three attempts.

Attempt 1: Fire-and-forget (broken)

// This doesn't work in serverless
capture(event) {
  fetch("https://eu.i.posthog.com/batch", { method: "POST", body: JSON.stringify(payload) });
  // Function exits, fetch gets killed
}

Vercel freezes the function after the response ends. In-flight fetch calls die with no errors and no logs. Just missing events.

Attempt 2: Buffer and flush (also broken)

await transport.handleRequest(req, res, req.body);
await analytics.flush(); // too late

The MCP StreamableHTTPServerTransport ends the response internally when it sends the SSE message. By the time flush() would run, the function is already frozen.

Attempt 3: waitUntil (works)

import { waitUntil } from "@vercel/functions";

// Simplified: each capture registers its own delivery promise as it is created
capture(event) {
  const delivery = fetch("https://eu.i.posthog.com/batch", { method: "POST", body: JSON.stringify(payload) });
  onBackground?.(delivery); // waitUntil on Vercel
}

waitUntil tells Vercel the response is sent but the function must stay alive until these promises settle. The key insight: you can't schedule background work after handleRequest, because by then the function is dead. You register each promise as it is created. Passing waitUntil in as a callback keeps the analytics module platform-agnostic.

What we track

Every tool call is wrapped once, in one place, so generated tools, workflows and apideck_run dispatches are all measured the same way:

analytics.capture("mcp_tool_called", {
  tool_name: "accounting-invoices-all",
  mode: "dynamic",
  scope: "read",
  status: "ok",
  is_error: false,
  error_type: null,
  duration_ms: 245,
});

Inside apideck_run, each apideck.* call is recorded under the real endpoint name, not as one opaque script run. Invalid input is recorded with error_type: "invalid_input", which is how we found the most common schema mistakes agents make. Without a POSTHOG_API_KEY, analytics is a no-op stub.

Works Everywhere

Hosted, any MCP client:

{
  "mcpServers": {
    "apideck": {
      "url": "https://mcp.apideck.dev/mcp",
      "headers": {
        "x-apideck-api-key": "YOUR_API_KEY",
        "x-apideck-app-id": "YOUR_APP_ID",
        "x-apideck-consumer-id": "YOUR_CONSUMER_ID"
      }
    }
  }
}

Claude Code:

claude mcp add --transport http apideck https://mcp.apideck.dev/mcp \
  --header "Authorization: Bearer $APIDECK_API_KEY" \
  --header "x-apideck-app-id: $APIDECK_APP_ID" \
  --header "x-apideck-consumer-id: $APIDECK_CONSUMER_ID"

Local stdio (Claude Desktop, Cursor):

{
  "mcpServers": {
    "apideck": {
      "command": "npx",
      "args": ["-y", "@apideck/mcp", "start"],
      "env": {
        "APIDECK_API_KEY": "YOUR_API_KEY",
        "APIDECK_APP_ID": "YOUR_APP_ID",
        "APIDECK_CONSUMER_ID": "YOUR_CONSUMER_ID"
      }
    }
  }
}

OpenAI Agents SDK:

from agents import Agent
from agents.mcp import MCPServerStreamableHttp

async with MCPServerStreamableHttp(params={
    "url": "https://mcp.apideck.dev/mcp",
    "headers": {
        "x-apideck-api-key": "your-key",
        "x-apideck-app-id": "your-app",
        "x-apideck-consumer-id": "your-consumer",
    },
}) as server:
    agent = Agent(name="Accounting Agent", mcp_servers=[server])

Client-specific setup for Cursor, VS Code, Windsurf and more is on the MCP page in our docs.

What We'd Do Differently

Own the generator sooner. Speakeasy got us to a working server fast, and that was worth it. But once agents were using it, nearly every improvement we wanted (workflow tools, better descriptions, code-mode names, validation) touched the generated output. If your MCP server is a product surface rather than a demo, plan to own that layer.

Validate before you dispatch. Agents are confident and often wrong about input shape. Rejecting a bad call locally, with field paths, gives the agent something to fix. Passing it through gives it a connector-specific 400 to puzzle over, or worse, a half-applied write.

Start with waitUntil from day one. We lost hours to PostHog events disappearing in serverless. If you do any background I/O on Vercel, reach for @vercel/functions immediately.

Test on the actual deployment target. Analytics worked perfectly in local stdio mode and broke on Vercel with no error output. Preview deployments on every PR made it debuggable.

Watch for trailing newlines in env vars. Our production PostHog key had a trailing \n from a copy-paste. PostHog rejected it silently. Always vercel env ls and verify.

How This Compares

CompanyMCP serverToolsApproach
XeroXeroAPI/xero-mcp-server~50Hand-built, single vendor
Stripestripe/ai~19Hand-built, curated
Intuit/QuickBooksintuit/quickbooks-online-mcp-server~12Hand-built, single vendor
PlaidDashboard + Sandbox MCP~8Hand-built, operations only
Apideckapideck-libraries/mcp381Generated from OpenAPI plus workflow tools, multi-vendor, three modes

Tool counts for other vendors are from April 2026. The difference isn't tool count. It's what happens when the API surface grows. Xero adding 10 endpoints means hand-updating 10 tool definitions. Apideck adding an entire API group is an overlay change and a regeneration PR.

The Numbers

MetricApril 2026October 2026
Endpoint tools229377
Workflow tools04
Unified APIs covered511
Modes2 (dynamic, static)3 (dynamic, code, static)
Initial cost, dynamic mode~1,300 tokens~1,300 tokens
Initial cost, code mode—~600 tokens
AuthAPI keyAPI key or Vault session JWT
GeneratorSpeakeasyIn-repo, MIT-licensed

Try It

The fastest way to see the server is the MCP Explorer. It lets you browse every tool, read its input schema, switch between modes and run calls against your own connection. It shows the exact JSON-RPC an agent would send.

The server is live at https://mcp.apideck.dev/mcp, and the code is open source at github.com/apideck-libraries/mcp.

# Quick test with curl
curl -X POST https://mcp.apideck.dev/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18","capabilities":{},
       "clientInfo":{"name":"test","version":"1.0"}}}'

Next up: registering every endpoint tool in static mode, more workflow tools for common accounting and HRIS jobs, and using the tool-call data from PostHog to decide which descriptions and schemas to improve first. As AI reshapes the accounting stack, the overlap of agentic workflows and unified accounting APIs is where we see the most traction. If you're generating MCP tools from an OpenAPI spec, open an issue on the repo and tell us what you're running into.

Further Reading

Ready to get started?

Scale your integration strategy and deliver the integrations your customers need in record time.

Ready to get started?
Talk to an expert

Trusted by fast-moving product & engineering teams

Bonsai by Zoom
JobNimbus
Blue Zinc
Exact
Drata