fastapi-mcp-neo 0.11.1 (this repo: FastAPI → GraphQL → MCP) vs
FastMCP.from_openapi(fastmcp 4, the mainstream way to expose an existing OpenAPI API as MCP tools today).Every number below is a real measurement from one machine (13th Gen Intel i5-13500H, 2026-10-10), not an estimate. Both sides ran on identical stacks — fastmcp 4.1.0, fastapi 0.143.0, the same in-memory client. Raw data and how to rerun everything: bench/.
Both consume an existing FastAPI app with zero decorators.
from_openapi turns each operation into a tool — one call per endpoint,
whole payloads, generic (any OpenAPI spec, any framework). fastapi-mcp-neo
turns the whole API into one typed query graph — the schema is the
contract, which gives a smaller catalog at every size, composed queries in
one round trip, and field projection. from_openapi is the right generic
answer for non-FastAPI services; for a FastAPI app that will grow, the
GraphQL contract starts paying immediately.
flowchart LR
subgraph theirs ["from_openapi: operation = tool"]
A1[FastAPI app] -->|"get_openapi()"| O[OpenAPI schema]
O -->|one tool per operation| T1["N tools (operationId-derived names)"]
T1 --> C1[tool call 1] & C2[tool call 2] & C3[tool call 3]
C1 & C2 & C3 -.->|"rebuilt as an HTTP request (httpx, wire-shaped)"| A1
end
flowchart LR
subgraph ours ["fastapi-mcp-neo: schema = contract"]
A2[FastAPI app] -->|"tag tree + function names"| G[GraphQL schema SDL]
G --> T2["2-6 constant tools: get_schema / graphql_query / graphql_mutation / progressive"]
T2 -->|one composed query| Q["{ a… b… c… }"]
Q -->|GraphQL execution| A2
G --> H["GraphiQL + POST /graphql, same schema serves humans"]
end
Both carry your docs: from_openapi puts endpoint docstrings and parameter
descriptions into each tool's description and input schema; fastapi-mcp-neo
puts the same docstrings, Field(description) and Query() descriptions
into the SDL the agent reads via get_schema.
Tool naming: get_note_api_notes (operationId-derived) vs get_note
(under a path-derived group, e.g. notes { mine { get_note } }).
One shared app (bench/shared_app.py, a notes CRUD API). Their tools call
the app through an in-process ASGI httpx client; ours invokes routes
in-process — same no-network shape on both sides.
Token estimate = JSON bytes ÷ 4.
| Routes | from_openapi catalog | gql-mcp simple (tools + SDL) | gql-mcp progressive (one domain) |
|---|---|---|---|
| 5 | 1,075 tok | 891 tok | 1,467 tok |
| 10 | 1,823 tok | 1,039 tok | 1,509 tok |
| 25 | 4,077 tok | 1,276 tok | 1,509 tok |
| 50 | 7,842 tok | 1,676 tok | 1,509 tok |
| 100 | 15,377 tok | 2,476 tok | 1,509 tok (flat) |
from_openapigrows linearly (~153 tok/route — each tool carries its OpenAPI description and a full JSON Schema); at 100 routes the catalog alone is ~15k tokens, every session.- gql-mcp simple is smaller at every measured size, and grows slowly (the SDL is compact); progressive stays constant — the agent fetches one domain fragment on demand.
- Honest counterexamples: at ~5 routes the gap is small (1,075 vs 891) and
progressive costs MORE up front (1,467) — it pays off from ~25 routes.
And catalog size is one dimension:
from_openapineeds no GraphQL mental model at all.
| Task (median of 3 runs) | from_openapi | fastapi-mcp-neo |
|---|---|---|
| "filtered notes + stats" | 2 tool calls = 2 agent turns, 2.35 ms | 1 graphql_query, 1.69 ms |
| same list (20 notes) | 2,285 B, whole payload | 2,341 B full / 610 B with field projection |
| p50 | p95 | p50 spread over 3 runs | |
|---|---|---|---|
| from_openapi, single call | 1.41 ms | 1.54 ms | 1.41–1.42 |
| gql-mcp, single query (fastmcp client) | 1.39 ms | 1.69 ms | 1.38–1.40 |
Statistically tied. Their side rebuilds an HTTP request and rides httpx; ours runs GraphQL validation and execution — on this hardware the costs net out within noise. Either way, real agent cost is dominated by turns — each turn includes LLM reasoning, seconds at a time — so "1 turn vs 2 turns" dwarfs a 0.02 ms gap.
One request that mixes a good field (stats) with a missing id (note 9999):
from_openapi — the whole tool call fails (raises ToolError):
Error calling tool 'get_note_api_notes': HTTP error 404: Not Found - {'detail': 'note not found'}
— the call raises; getting stats needs another call;
the failure reason and the result share one text line.
fastapi-mcp-neo — the bad field is isolated:
{
"data": {
"meta": { "stats": { "notes": 20 } },
"notes": { "mine": { "get_note": null } }
},
"errors": [{
"message": "GET /api/notes/{note_id} -> 404: …",
"path": ["notes", "mine", "get_note"],
"extensions": { "code": "HTTP_404", "http_status": 404 }
}]
}| Dimension | from_openapi | fastapi-mcp-neo |
|---|---|---|
| Tool model | one tool per operation (N tools) | 2–6 constant tools, schema is the contract |
| Works on | any OpenAPI spec, any framework, any language | FastAPI apps only (in-process) |
| Calls the API | over HTTP (httpx; remote base_url works) | in-process ASGI (zero network, real Depends run) |
| Scale control | route_maps / tags (flat) | domain tree + progressive disclosure (auto above 25 routes) |
| Composed queries | no — one call = one endpoint | one graphql_query joins domains, fields, aliases |
| Field projection | no (whole payload) | pick the fields you want |
| Error semantics | HTTP ≥400 → whole call raises | field-level null + extensions.code, siblings survive |
| Write gating | route_maps per method | allow_mutation + mutation_include + operation-type guards |
| Credential forwarding | client-side headers / auth per call | header whitelist (default authorization, cookie addable), per caller |
| Human entry point | none | GraphiQL + POST /graphql, same schema for humans and agents |
| Dependency pinning | part of fastmcp (actively maintained) | fastmcp<5 upper bound, verified end to end |
"Lazy loading saves context" mixes four different things:
What this means for the comparison, honestly:
from_openapi's linear catalog growth is the out-of-the-box state, not a permanent verdict —SearchTransformcan fold it, or the Tool Search extension may spread.- Our progressive disclosure is the same pattern as plain tools — it needs no extension and works with every client today.
- Even if catalog size is fully solved by the extension later, composed queries, field projection, field-level error isolation and write gating remain GraphQL-only. Catalog size is one dimension of this comparison, not all of it.
- fastmcp's
SearchTransformstays in reserve for us — our catalog is already constant at 2–6 tools.
from_openapi: the service is not FastAPI (other language, remote, spec-only), or it is small and stable, agents only, no composition needs, and you want the generic bridge with zero new concepts.- fastapi-mcp-neo: it is a FastAPI app that will grow; context budget is tight; agents need composed views in one round trip; you need field projection, write gating — or humans want GraphQL too.
cd comparison/bench
uv run --project env_ours python run_ours.py # our side
uv run --project env_theirs python run_theirs.py # from_openapi side
python3 merge_results.py # merge -> results.jsonshared_app.py is the one app both bridges consume. Environment,
dependency versions and run counts are recorded in results.json
(method block). No local clone of anything is needed — both sides install
from PyPI.
Earlier revisions of this comparison benched
tadata-org/fastapi_mcp
instead. That project has been dormant since 2025-08 (last release
v0.4.0, 2025-07; its mcp>=1.12 pin breaks on mcp 2.x), so the live
mainstream alternative — FastMCP.from_openapi — replaced it as the
comparison target in 2026-10. The old numbers remain in git history.
