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
| Component | Choice | Why |
|---|---|---|
| Tool generation | In-repo generator (generator/generate.mjs) plus an overlay | Emits every endpoint tool from our OpenAPI spec; we own naming, schemas and scopes |
| Hosting | Vercel Functions on Fluid Compute | Git-push deploys, preview deployments, waitUntil for background work |
| Analytics | PostHog (EU) via the /batch API | Server-side tracking of every tool call |
| Runtime | Node.js + @modelcontextprotocol/sdk | Standard MCP protocol implementation |
| Schema validation | Zod v4 | Input schemas, JSON Schema conversion, validation before dispatch |
| Distribution | npm @apideck/mcp, the MCP Registry, a .mcpb bundle | Hosted, 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:
- 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.
- 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.
- 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:
| Mode | What the agent sees | Initial cost | Best for |
|---|---|---|---|
dynamic (default) | 4 meta-tools | ~1,300 tokens | General agents working across several APIs |
code | apideck_search + apideck_run | ~600 tokens | Agents that write code and chain several calls in one step |
static | Tools registered directly | Up to ~35–55K tokens | Focused 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:
apideck_searchfinds endpoints by substring and returns camelCase method names.apideck_runexecutes a JavaScript snippet withapideck.<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:
| Tool | What it does |
|---|---|
apideck-month-end-close-check | Fetches aged creditors, aged debtors, balance sheet and P&L in parallel; returns partial results when a connector doesn't support a report |
apideck-pay-bill | Looks up a bill and creates a bill payment allocated against it, so the accounting system marks it (partially) paid |
apideck-receive-customer-payment | Looks up an invoice and creates a customer payment allocated against it |
apideck-onboard-employee | Turns 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:
| Scope | HTTP methods | What it means |
|---|---|---|
read | GET, HEAD | Safe, no side effects |
write | POST, PUT, PATCH | Creates or modifies data |
destructive | DELETE | Irreversible |
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:

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:
| Header | Purpose |
|---|---|
x-apideck-api-key (or Authorization: Bearer) | Your Apideck API key |
x-apideck-app-id | Your Apideck application |
x-apideck-consumer-id | The end customer whose connections to use |
x-apideck-auth-type: JWT | Optional: 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
AsyncLocalStoragefor 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"
}
}
}
}
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
| Company | MCP server | Tools | Approach |
|---|---|---|---|
| Xero | XeroAPI/xero-mcp-server | ~50 | Hand-built, single vendor |
| Stripe | stripe/ai | ~19 | Hand-built, curated |
| Intuit/QuickBooks | intuit/quickbooks-online-mcp-server | ~12 | Hand-built, single vendor |
| Plaid | Dashboard + Sandbox MCP | ~8 | Hand-built, operations only |
| Apideck | apideck-libraries/mcp | 381 | Generated 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
| Metric | April 2026 | October 2026 |
|---|---|---|
| Endpoint tools | 229 | 377 |
| Workflow tools | 0 | 4 |
| Unified APIs covered | 5 | 11 |
| Modes | 2 (dynamic, static) | 3 (dynamic, code, static) |
| Initial cost, dynamic mode | ~1,300 tokens | ~1,300 tokens |
| Initial cost, code mode | — | ~600 tokens |
| Auth | API key | API key or Vault session JWT |
| Generator | Speakeasy | In-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
- MCP Explorer - Browse and run every Apideck MCP tool in the browser
- A Primer on the Model Context Protocol - What MCP is and how it works
- MCP vs API - How MCP and REST APIs relate
- APIs, MCPs, or Both? - Choosing the right AI integration stack
- Understanding the Security Landscape of MCP - Auth, injection, and trust boundaries
- API Design Principles for the Agentic Era - Designing APIs with AI agents as first-class consumers
- Your MCP Server Is Eating Your Context Window - The CLI alternative and context window analysis
- MCP Protocol Specification - The official MCP spec
- Vercel Functions API Reference -
waitUntiland serverless lifecycle docs - PostHog Capture API - Server-side event tracking via
/batch
Ready to get started?
Scale your integration strategy and deliver the integrations your customers need in record time.








