Turning the Aimtell REST API into an MCP server
Aimtell, a web push notification platform, came to us with a clear goal: let their users drive push notifications, campaigns, segments, and analytics straight from an AI client like Claude. The way to do that is a Model Context Protocol (MCP) server, so we built one on top of their existing REST API. The engagement turned out to be a good case study in the parts of this work that are not really about HTTP plumbing at all. Here is the build log, with the lessons that generalize to anyone wrapping an API for an LLM.
The starting point
Aimtell had an existing REST API, documented as a Mintlify docs site with five OpenAPI specs covering roughly 60 endpoints. We had an empty folder where the MCP server would go. And we had a clear goal: give their users a way to do their real jobs (send a push, build a campaign, query a segment, pull a report) from inside an AI assistant.
The API surface grouped cleanly into a handful of areas:
- Sites: settings, keys, permissions, custom variables.
- Push and campaigns: one-off push plus manual, event, RSS, welcome, and API campaigns.
- Segments and subscribers: segment CRUD, subscribers, attribute tracking.
- Reports, logs, pixels, and prompts: analytics and opt-in prompts.
- User: get and update the account.
Four decisions that shaped everything
Rather than guess, we settled the architecture up front with the Aimtell team. Four choices did most of the work:
- Hosted remote, multi-tenant. One server serving many users, each with their own Aimtell key, rather than a local copy per user.
- TypeScript. The official SDK and the easiest path to hosting on the edge.
- A curated subset of about 18 tools, not a one-to-one mapping of all 60 endpoints. Dumping 60 raw tools bloats the model's context and tanks tool-selection accuracy. We wrapped the jobs users actually do, not every CRUD route.
- Cloudflare Workers, with API-key header passthrough. No auth server, and the key is never stored.
The third decision is the one worth dwelling on. The right number of MCP tools is a product decision, not a mechanical mapping. More tools does not mean more capable. It is usually the opposite.
The auth-header gotcha
The OpenAPI spec said the auth header was X-Authorization-Api-Key. But every curl sample in the published docs used a plain X-Authorization. They contradicted each other. We did not pick one and hope. We tested both against the live API with a real key:
X-Authorization-Api-Keyreturned HTTP 200 with real site data.X-Authorizationreturned HTTP 200 with{"result":"error","message":"Missing token."}.
The formal spec was right and the human-facing samples were stale. Two takeaways: verify auth against the live API before you build anything on top of it, and a doc bug like this is exactly the kind of thing an integration surfaces, so it is worth reporting back to the API team. We also noticed that the user endpoint leaked a base64 credential and billing internals, so our get_user tool strips those before returning, and we flagged the leak back to the Aimtell team. Wrapping an API is a chance to narrow what is exposed, not just relay it.
When the SDK fights your runtime
The well-known transport in the MCP TypeScript SDK is written for Node's request and response objects. That is the wrong shape for a Cloudflare Worker, which speaks the Web Standard Request and Response. The SDK ships a second transport whose handler signature is exactly the Worker fetch signature, and its own docstring even shows a Cloudflare example. Switching to it made the Worker idiomatic and stateless: one MCP server instance per request, which fits multi-tenant edge perfectly. The lesson is small but repeats often. When an SDK fights your runtime, check for a runtime-native variant before you reach for an adapter shim.
We proved the whole thing end to end against the live API with no mocks. The health check returned 200, a missing key produced a clean JSON-RPC error with a helpful message, the tools list came back with proper schemas, and a real tool call round-tripped live site data through MCP to the client and back.
The connect-it-to-a-client fork
Header passthrough works beautifully, but only for clients that let you set a header. The moment we went to wire the server into a polished connector, we hit a wall that every remote-MCP author hits. Clients split into two camps. Header-capable clients let you paste a token or set a header in config; this covers Claude Code, Cursor, Windsurf, and curl. OAuth-only clients run an OAuth handshake in their connector UI with no place to paste a token; this covers Claude.ai and Claude Desktop custom connectors, which are the most mainstream and least technical users, exactly the people a hosted remote server is for.
So we had a real fork. Option A was to stay header-only: keep the pure passthrough design, document the config snippets, and accept that you cannot connect from the OAuth-only clients. It is the simplest possible server with zero new code, and if your audience is developers in Claude Code or Cursor, it is genuinely fine to stop there. Option B was to implement just enough of the MCP Authorization spec to satisfy the OAuth-only clients. We built Option B, and the insight that made it palatable is that we control the authorize page, so that page can simply be a "paste your Aimtell API key" form. There is no MCP primitive for asking the user for a credential string, and OAuth is the only mechanism these clients honor, but nothing says the OAuth login screen has to be a username and password dance. It can be one input box.
Keeping the zero-storage promise through OAuth
OAuth normally implies the server mints and stores tokens, which would break the whole project's invariant that the key never lives on the server. The workaround within the workaround was to make the access token be the Aimtell key, and to carry it across the one stateful-looking hop inside an AES-GCM-encrypted authorization code. No database and no key-value store. The only new server secret seals the key in transit and never persists it. When the token endpoint hands the key back as the access token, our existing key-extraction path consumes it unchanged, so the entire downstream flow is identical to the header case.
All of it lives in one stateless module: the protected-resource and authorization-server discovery metadata, dynamic client registration (which Claude.ai requires), the paste-key authorize form, the token endpoint with PKCE verification, and a spec-compliant challenge on the MCP endpoint that kicks the flow off. The honest part is worth saying out loud. The MCP spec discourages this token-passthrough shape, and it means you cannot revoke a token independently of the key. We took that tradeoff deliberately to preserve zero storage. The spec-clean alternative is to mint and store tokens in a key-value store, which we would reach for the day revocation matters. The headline, though: adding OAuth sounds like a week of work and a stateful auth server, and for a passthrough proxy it was about 250 lines and zero storage.
Teaching the model the API's undocumented grammar
This was the sleeper feature, and the part we think is most transferable. A wrapped tool is only as good as what the model knows to put in it. Our create-segment tool took a definition string, and the API docs described that field as, literally, a string. So if a user said "make a segment of everyone who lives in California," the model had no way to know the real format. It would hallucinate something plausible like state = "CA", and the API would silently accept garbage that matches no one.
The same gap ran through the other tools. The analytics tool took a type the model had to guess. The campaign tool took automation and status codes that were just bare strings. The push tool did not make clear you can only target one segment. These are all magic-string parameters with a closed set of valid values that lives nowhere the model can see.
The OpenAPI specs were the wrong place to look. They document the shape of requests, not the vocabulary. The real grammar lived in the dashboard's own UI code, because the dashboard is the thing that builds these strings when a human clicks around the segment builder. So we went spelunking. The segment serializer in the dashboard concatenates field, operator, and value with no separators, joins conditions with a comma for AND or a pipe for OR, and wraps groups in parentheses. Its option list gave us every field, its allowed operators, and the detail that the region uses the full name. So "everyone in California" becomes (region==California), not CA. The analytics handler had an authoritative array of supported types. The campaign manager confirmed the automation interval codes and the status codes, and that campaigns take a comma-list of segments while a one-off push is single-segment only.
We fed that back to the model two ways, chosen for reliability across every client. Some clients surface MCP prompts and resources weakly, but tool descriptions are always sent to the model. So first, we encoded the grammar in the tool descriptions themselves, with the format rules, the operator legend, and worked examples including the California one. Second, we added a dedicated lookup tool that returns the full field and operator catalog on demand. We deliberately did not dump every field the dashboard supports. More parameters mean more ways for the model to misfire and more context bloat, so we added the high-value, commonly-asked ones and noted the rest as available on request. We also kept ourselves honest about the limits. Custom subscriber attributes are per-account and live in each site's data, not in any static list, so the guide tells the model to reference them by their literal name and points it at the list and subscriber tools to discover what a given site actually uses.
The lesson here deserves its own line. Wrapping an API for an LLM is as much a documentation-archaeology job as a coding one. The hard part was not the HTTP plumbing. It was recovering the implied vocabulary the original API never wrote down, and the most reliable place to find it was the vendor's own UI code, which had to know the answer in order to function. The payoff is direct. "Make a segment of everyone in California" now becomes (region==California) on the first try instead of a confident wrong guess.
Wrapping up
What we handed Aimtell is a small, stateless MCP server that stores nothing, runs on the edge for every one of their users, and speaks the API's real grammar so the model gets it right on the first try. Their users can now manage push notifications, campaigns, segments, and analytics from inside their AI client of choice. Along the way we surfaced and reported a couple of issues in their own API and docs, because wrapping an API for an LLM forces a level of scrutiny that a normal integration never does.
That is the kind of work we love. If your team is building agentic features, adding an MCP server to your product, wiring an LLM into your workflows, or trying to figure out where AI actually fits in your roadmap, we can help you get it right. The hard parts are rarely the obvious ones, and we have the scars to prove it. Get in touch and let us know what you are building.
Building something that needs to scale?
That is what we do. Tell us what is in the way.