Most ways to give an API an MCP server add a second service: a proxy that reads your OpenAPI spec and calls your API over HTTP. It works, but now there are two things to deploy, two places to check auth, and a hop that has to carry your users' credentials.
We wanted something else: one middleware in the app you already run, where an agent's tool call goes through your own routes, with the auth middleware, validation and handlers you already test. That's openapi-agentify, MIT-licensed, for Express, Hono, Fastify and Next.js:
const app = express();
app.use(agentify(defineConfig({ name: "Acme Orders API", spec: "./openapi.yaml" })));
app.use(express.json());
app.get("/v1/orders/:id", requireApiKey, getOrder); // unchanged
/mcp then serves one tool per operation in your spec, reads on and writes off by default. Getting there took more than wiring a router to an MCP library. These are the three problems that took the longest.
1. Calling your own app without a port
A tool call becomes the HTTP request the operation describes. The obvious way to run it is fetch to localhost, but then the server needs to know its own port, and every call goes through the network stack.
Fastify has app.inject() and Hono has app.fetch(), so those two were easy. Express has nothing built in. We tried light-my-request, the library behind Fastify's inject. It works in a test, but when it detects an Express app it re-parents Express's shared request and response prototypes onto its own classes. In a test that's harmless. In a running server it breaks every later real request.
So the Express adapter builds the request itself: a plain IncomingMessage on an in-memory socket, a plain ServerResponse, and the app called as a function. The injected request carries the agent's IP as its remote address, so your rate limiter and logs see the caller, not 127.0.0.1. (src/adapters/node-inject.ts)
Re-entry. That request goes through your whole middleware stack, which includes Agentify itself. The middleware has to recognise its own calls without letting a client pretend to be one. Each in-process call carries an X-Agentify-Internal header whose value is a per-process random secret, valid only while that call runs. A forged header from outside doesn't match and is stripped. One more trap: if your spec is served by your own app, Agentify's first load calls the app, which calls Agentify, which is still loading. Waiting would wait for itself, so a marked call during the load just passes through. (src/dispatch.ts, src/core.ts)
2. Idempotency keys when the body can only be read once
Agents retry. For writes, Agentify honours Idempotency-Key: the same key with the same request replays the stored answer, and the same key with a different body is refused. That means hashing the body before your routes run.
But a Node request is a stream, and it can be read once. If middleware reads it, your express.json() finds nothing. Re-serialising a parsed body doesn't work either, because the hash has to match what the client sent, byte for byte, and your parser may have a verify hook that needs the raw bytes too.
What works: read the raw bytes, up to 1 MiB, then unshift them back onto the front of the stream before it ends. Your own body parser then reads the request exactly as the client sent it, with its own limit, its own verify hook and its own error handling. Agentify never parses the body and never sets req.body. Past 1 MiB it stops, puts back what it read, and doesn't hash. The key store is pluggable (beginIdempotent, completeIdempotent, releaseIdempotent), in memory by default and in your database when you run more than one instance. (src/adapters/express.ts prereadBody, src/idempotency.ts)
3. Deciding which operation a request is
Agentify has to match a request to a spec operation, both to label agent traffic and to know which tool a credential was handed out for. Your router decides which handler runs. If the two disagree, a call could be counted as one operation and run as another.
-
Static segments win.
/v1/orders/exportand/v1/orders/{id}both match/v1/orders/export, so paths are ranked by their number of literal segments, as most routers do. - Case. Matching ignores case, like Express's default router. But a credential is only handed over when the request is spelled the way the spec spells it.
-
Ambiguous paths are refused. Dot segments (plain or
%2e), empty segments, and an encoded/or\might be resolved differently by your router than by us, so agent requests like these are refused rather than guessed at. -
Shared templates. Every in-process call carries
X-Agentify-Operation, so a route that shares a path template with another can check that the call was meant for it.
(compilePath and ambiguousPath in src/core.ts, src/mcp/operation-tool.ts)
What else it does
The same spec drives what agents read before they call: llms.txt, an MCP server card, the AI Catalog, your spec with agent hints, and /agent, an index of all of them. And it scores how well a model will understand your spec (operationIds, descriptions, examples, error schemas, pagination, auth, idempotency, tool count) with the same rules as openapi-agent-check, whose --fix writes the rules-only fixes into your spec for free.
Try it on your API:
npx openapi-agentify init --write
Or check your spec first:
npx openapi-agent-check openapi.yaml --fix
Issues and pull requests are welcome; there are good first issues in both repositories. If you'd rather not run it yourself, Latitude 10 hosts the same thing from your OpenAPI document.

Top comments (1)
A random per-call secret header that gets stripped when forged keeps clients from faking in-process calls, neat trick for the re-entry problem.