openapi2mcp
by dspachos
README.md
# openapi2mcp
[](#license)
[]()
[]()
[]()
[](#status)
[]()
<h3 align="center"><b>Turn any OpenAPI spec into a token-efficient MCP server —<br>the entire API through two tools, no matter how big it is.</b></h3>
<a id="status"></a>
> ⚠️ **Work in progress.** This project is early and actively evolving — the API,
> generated output, and configuration may change without notice. Not production-ready.
> Expect rough edges; ideas and PRs welcome (see [CONTRIBUTING.md](./CONTRIBUTING.md)).
`openapi2mcp` generates a standalone, Dockerizable MCP server that exposes a whole
REST API via just `search` and `execute`, following the
[**Code Mode**](https://blog.cloudflare.com/code-mode-mcp/) pattern popularized by
Cloudflare. The model writes a little JavaScript; the server runs it safely. The
result: a **fixed ~1–2k token footprint** that never grows with your API.
```
your OpenAPI spec ──► openapi2mcp ──► a runnable MCP server (2 tools, ~1–2k tokens)
```
---
## The problem
Every endpoint you expose as a normal MCP tool fills the model's context window.
For a large API this breaks entirely:
| Approach | Tools | Tokens (200k context) |
| --- | --- | --- |
| Raw spec in prompt | — | ~2,000,000 (**977%**) |
| Native MCP — full schemas | 2,500 | 1,170,000 (**585%** ❌) |
| Native MCP — minimal schemas | 2,500 | 244,000 (**122%**) |
| **Code Mode — this project** | **2** | **~1,200 (0.5% ✅)** |
Code Mode inverts the model: instead of picking from thousands of fixed tools, the
agent **writes code** that says what it wants, and the server executes it.
## Quickstart
```bash
git clone https://github.com/dspachos/openapi2mcp.git openapi2mcp && cd openapi2mcp
npm install
# Generate a server from the canonical Petstore spec (no creds needed)
npm run example
# Run it
cd generated/petstore-mcp && npm install && npm start # → http://localhost:8787/mcp
```
Connect any MCP client:
```json
{
"mcpServers": {
"petstore": { "type": "http", "url": "http://localhost:8787/mcp" }
}
}
```
Then just ask your agent: *"find the endpoints for managing orders, then list the
stored orders."* It will `search` the spec, then `execute` the calls.
## How it works
```
┌──────────┐ tools/call(search) ┌────────────────────────┐
│ │ ─────────────────────► │ Generated MCP server │
│ Agent │ tools/call(execute) │ ┌──────────────────┐ │
│ (LLM) │ ◄───────────────────── │ │ sandbox │ │
│ │ results only │ │ (isolated-vm) │ │
│ │ (the spec never │ │ • no fs / env │ │
│ │ reaches the agent) │ │ • no free fetch │ │
└──────────┘ │ └────────┬─────────┘ │
│ │ api.request │
│ ┌────────▼─────────┐ │
│ │ host process │ │
│ │ • injects auth │───► your API
│ │ • host allow- │ │
│ │ list (fetch) │ │
│ └──────────────────┘ │
└────────────────────────┘
```
- **`search({ code })`** — read-only JavaScript over the resolved OpenAPI spec
(`spec.paths`). The agent discovers the endpoints it needs; the full spec
**never enters its context**.
- **`execute({ code })`** — JavaScript that calls
`api.request({ method, path, query, body })` to hit the API, compose calls,
paginate, filter results, and return just what's needed.
## Security model
This tool executes **model-authored code at runtime**, so the sandbox is the whole
game. Each generated server runs untrusted code in a hardened
[isolated-vm](https://github.com/laverdet/isolated-vm) V8 isolate — **not** Node's
`vm` module, which is not a security boundary.
| Threat | Mitigation |
| --- | --- |
| Secret exfiltration (`JSON.stringify(process.env)`) | No `process`, `require`, or env access inside the isolate |
| Data exfiltration (`fetch('https://evil/…')`) | No `fetch`; `api.request` is the **only** network primitive, locked to your API base URL |
| Token leakage | The API secret is injected by the host and is **never visible** to sandboxed code |
| DoS (`while(true){}` / memory bombs) | Per-call memory limit + wall-clock timeout; a fresh isolate per call |
## Where AI fits
At **generation time only** — zero LLM cost per request. If you provide an
OpenAI-compatible endpoint, the generator analyzes your spec and writes tailored
`search`/`execute` descriptions, grounded examples using **real** API paths, and
notes on response envelopes / pagination / auth quirks. If unavailable, it falls
back to deterministic descriptions. Disable with `--no-ai`.
Works with any OpenAI-compatible provider — OpenAI, Azure, OpenRouter, LiteLLM,
Ollama, a local gateway, etc.:
```bash
export OPENAI_API_KEY=sk-...
export OPENAI_BASE_URL=https://api.openai.com/v1 # or your gateway
```
## OAuth 2.1 — per-user, downscoped access
For multi-user deployments, generate with `--oauth` and the server becomes its own
OAuth 2.1 authorization server (the model Cloudflare uses):
```bash
openapi2mcp generate --spec ... --oauth --auth bearer --auth-env API_TOKEN
```
Each end-user then authorizes via a browser consent flow instead of sharing one
baked-in token:
1. The MCP client hits `/mcp` unauthenticated → `401` + Protected Resource Metadata.
2. It discovers `/.well-known/oauth-authorization-server`, registers a client
(`/register` — Dynamic Client Registration), and runs **authorization-code +
PKCE (S256)**.
3. The consent page asks the user for **their** upstream API token and which
**scopes** to grant. Scopes are **derived from the spec** automatically —
`<product>:<read|write>`, e.g. `orders:read`, `billing:write`.
4. The server issues a short-lived RS256 JWT access token + refresh token,
storing the user's upstream token server-side (it never enters the sandbox).
5. On every `execute` the granted scopes are enforced — the agent cannot call
operations the user didn't approve.
Endpoints: `/.well-known/oauth-protected-resource`,
`/.well-known/oauth-authorization-server` (RFC 8414), `/authorize`, `/token`,
`/register`, `/revoke`, `/jwks`.
> ⚠️ **Security notes.** Access tokens are signed by a per-process key (restart
> invalidates them; refresh tokens survive). The token store is in-memory /
> single-instance — swap in Redis/KV/Postgres for multi-instance. The
> paste-token consent model trusts the resource owner to paste into a flow they
> initiated. This is a focused MCP subset — security review recommended before
> production.
## Usage
```bash
npx tsx src/index.ts generate \
--spec https://api.example.com/openapi.json \
--name example \
--base-url https://api.example.com \
--auth bearer --auth-env EXAMPLE_API_TOKEN \
--out ./generated/example-mcp
```
| Flag | Purpose |
| --- | --- |
| `--spec <url\|file>` | OpenAPI spec (**required**) |
| `--name <name>` | server / output name (**required**) |
| `--out <dir>` | output dir (default `./generated/<name>-mcp`) |
| `--base-url <url>` | target API base URL (else spec `servers`, or spec URL origin) |
| `--base-url-env <VAR>` | env var to read the base URL at runtime (preferred) |
| `--auth bearer\|apikey\|none` | auth scheme (default: auto-detect from `securitySchemes`) |
| `--auth-env <VAR>` | env var holding the target API secret (default `API_TOKEN`) |
| `--auth-header <name>` | header for apikey auth (default `X-Api-Key`) |
| `--oauth` | enable OAuth 2.1 authorization-server mode (per-user, downscoped tokens) |
| `--no-ai` | skip AI augmentation |
| `--llm-base-url <url>` | LLM base (default `$OPENAI_BASE_URL`) |
| `--llm-api-key <key>` | LLM key (default `$OPENAI_API_KEY`) |
| `--llm-model <id>` | model id (default: auto-detect via `/v1/models`) |
## The generated server
```
<name>-mcp/
├── spec.json # resolved, trimmed OpenAPI spec (for the search tool)
├── meta.json # auth, base URL, AI-generated descriptions & examples
├── package.json
├── Dockerfile
└── src/
├── index.ts # MCP server + streamable HTTP transport
├── sandbox.ts # isolated-vm runner (the security boundary)
├── search.ts # search tool (read-only over the spec)
└── execute.ts # execute tool (locked-down api.request)
```
```bash
cd generated/example-mcp
cp .env.example .env # set base URL + secret + PORT
npm install && npm start # → http://0.0.0.0:8787/mcp
```
**Docker:**
```bash
docker build -t example-mcp .
docker run -p 8787:8787 \
-e BASE_URL=https://api.example.com \
-e API_TOKEN=secret \
example-mcp
```
The runtime is **fixed code** — only `spec.json` and `meta.json` vary per API. It
runs on plain Node + Docker (no Cloudflare Workers dependency; `isolated-vm`
replaces their Dynamic Worker Loader).
## Project structure
```
openapi2mcp/
├── src/ # the generator
│ ├── spec/ # fetch · $ref resolver · spec trimmer
│ ├── analyze.ts # detect base URL + auth scheme
│ ├── ai.ts # optional AI augmentation (OpenAI-compatible)
│ ├── emit.ts # scaffold from template/ + inject spec.json/meta.json
│ └── generator.ts # orchestration
└── template/ # the runtime, copied verbatim into every generated server
```
## How it compares
| Approach | Token cost | Scales to huge APIs | Sandbox needed | Agent-side changes |
| --- | :---: | :---: | :---: | :---: |
| Native MCP (one tool / endpoint) | high (grows with API) | ❌ | no | none |
| CLI-per-server (progressive disclosure) | low | ✅ | shell (larger surface) | needs a shell |
| Dynamic tool search | medium | ⚠️ | no | needs a search fn |
| **openapi2mcp (Code Mode)** | **~1–2k fixed** | ✅ | isolated-vm | none |
## Contributing
Contributions welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md) for setup,
where things live, and PR conventions.
## Roadmap
- [x] **OAuth 2.1** per-user, downscoped authorization-server mode ✅
- [ ] Durable / multi-instance token store (Redis, KV, Postgres) + persisted signing key
- [ ] B1 path: delegated upstream OAuth (GitHub/Google-style refresh-token storage)
- [ ] Streaming / chunked responses for large payloads
- [ ] Heuristic response-envelope + pagination auto-handling
- [ ] Published as an `npx`-able npm package
- [ ] Tests across more OpenAPI edge cases (Swagger 2.0, `allOf`/`oneOf`, webhooks)
## Acknowledgements
Inspired by Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode-mcp/)
and Anthropic's [Code Execution with MCP](https://www.anthropic.com/engineering/code-execution-with-mcp).
Security is built on [isolated-vm](https://github.com/laverdet/isolated-vm) by
[Karl Miller](https://github.com/laverdet). MCP via the official
[Model Context Protocol SDK](https://github.com/modelcontextprotocol/typescript-sdk).
## License
[MIT](./LICENSE)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues