The first post on openapi-mcp-gateway closed with a roadmap. The resource primitive and dynamic exposure both shipped, plus a few more worth writing down. All in v0.5.0:
- Dynamic exposure for huge specs like GitHub
- Auto-promote eligible GETs to MCP resources
- MCP spec compliance (audience-bound auth + protocol-native tool metadata)
- Tool name and description override
Try It
One config exercising every new surface:
# servers.yml
host: 0.0.0.0
port: 8000
servers:
# Eligible GETs become MCP resources; everything else stays a tool.
- name: petstore
spec: https://petstore3.swagger.io/api/v3/openapi.json
base_url: https://petstore.swagger.io/v2
mode: auto
# ~1,200 ops behind three meta-tools instead of 1,200 schemas in tools/list.
- name: github
spec: https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json
exposure: dynamic
auth:
type: bearer
token: ${GITHUB_TOKEN}
# Per-user OAuth2, audience-bound tokens, no passthrough.
- name: asana
spec: https://raw.githubusercontent.com/Asana/openapi/master/defs/asana_oas.yaml
auth:
type: oauth2
client_id: ${ASANA_CLIENT_ID}
client_secret: ${ASANA_CLIENT_SECRET}
scopes: [openid, email, profile, users:read]
uvx openapi-mcp-gateway --config servers.yml
What you get at http://127.0.0.1:8000:
-
/petstore/mcp: 13 tools + 6 resources (3 concrete, 3 templates), partitioned bymode: autowith no spec edits. -
/github/mcp: three meta-tools (list_operations,get_operation,call_operation) fronting ~1,200 endpoints. -
/asana/mcp: per-user OAuth2 against Asana's IdP.
1. Dynamic Exposure: Three Meta-Tools for Huge Specs
GitHub's REST API spec carries 1,190 operations. Static registration ships every name, description, and JSON Schema at connect time. At ~300 tokens per tool that is 350K+ tokens in tools/list alone, larger than any frontier model's context window, before the agent makes a call.
servers:
- name: github
spec: https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json
exposure: dynamic
auth:
type: bearer
token: ${GITHUB_TOKEN}
The client now sees three tools instead of 1,190:
async def list_operations(ctx: Context) -> CallToolResult:
# returns: {"operations": [{"name": str, "description": str}, ...]}
async def get_operation(name: str, ctx: Context) -> CallToolResult:
# returns: {"name": str, "description": str, "input_schema": JSONSchema}
async def call_operation(name: str, arguments: dict[str, Any], ctx: Context) -> CallToolResult:
# returns: upstream response wrapped as CallToolResult
The agent walks them in order:
-
list_operations()to discover. -
get_operation("issues_create")to read the schema for the one it wants. -
call_operation(name, args)to invoke.
Auth, path templating, and per-operation request shape are identical to static mode. Only the surfacing changes.
When NOT to Use It
Dynamic exposure adds two round trips before the first real call. For small specs that fit comfortably in context, those round trips are pure latency tax.
Rough rule of thumb (the numbers are guidance, not benchmarks):
| Operation Count | Recommended Mode |
|---|---|
| Under ~30 | static |
| 30 to ~100 |
static + policy.allow narrowing to the hot subset |
| Over ~100 | dynamic |
It is a per-server flag, so /github/mcp can run dynamic while /petstore/mcp runs static in the same gateway.
2. Resources Are a Primitive, and Most GETs Should Be One
Tools are actions. The client picks one, fills in arguments, and the result enters the conversation. Most OpenAPI-to-MCP gateways register every operation that way. But MCP has a second primitive. Resources are addressable content returned as text or blob, and a GET /pets/{petId} or GET /store/inventory is a far better fit for the resource shape than the tool shape.
Mode: Auto
servers:
- name: petstore
spec: https://petstore3.swagger.io/api/v3/openapi.json
mode: auto
At startup the gateway partitions every operation:
| Operation Shape | Becomes |
|---|---|
GET, no required query / header / body
|
Resource |
| Everything else (POST, PUT, DELETE, GET with required args) | Tool |
Vanilla Petstore3 produces 13 tools, 3 concrete resources, and 3 resource templates. The default mode: tool_only keeps everything as tools.
Per-Operation YAML Overrides
When you want finer control without forking the spec, layer overrides in the same server YAML. Common reasons:
- Rename a resource
- Set a custom URI template
- Set a non-JSON MIME type
servers:
- name: petstore
spec: https://petstore3.swagger.io/api/v3/openapi.json
mode: auto
operations:
getPetById:
expose:
resource:
name: pet
uri_template: petstore://v2/pet/{petId}
getInventory:
expose:
resource:
name: inventory
Keys match operationId. Unknown ids raise at startup so typos do not silently no-op. Each entry fully replaces any spec-side x-mcp-integration for that operation. This is the path when the upstream spec belongs to a vendor or a different team.
Spec-Side Opt-In
If you own the spec, the same opt-in fits inline as x-mcp-integration.expose.resource on the operation. All three paths (mode, YAML, spec) land in the same registration:
| Spec Shape | Registered As | Example URI |
|---|---|---|
| GET with path params | Resource Template | petstore://pet/{petId} |
| GET without path params | Concrete Resource | petstore://store/inventory |
Auth, base URL, and retries are shared with the tool path.
3. MCP Spec Compliance
The 2025-11-25 MCP spec tightened requirements on two fronts: how the server handles tokens it forwards upstream, and what shape it returns tool results in. Both shipped in v0.5.0.
Audience-Bound Auth
A naive gateway forwards the MCP client's token straight to whatever third-party upstream the tool eventually hits. That is the token passthrough anti-pattern, and the spec explicitly forbids it.
The new default:
- Gateway is an OAuth client to the upstream and mints upstream tokens server-side for
authorization_codeandclient_credentials. - MCP client's token is never forwarded to a different audience.
- Tokens are bound to the gateway's canonical resource URI per RFC 8707.
- Passthrough is opt-in (
auth.flow: passthrough) and only safe when gateway and upstream share the same audience. The FastAPI integration is the one place this opts in automatically.
If authorization_code is configured without client_id / client_secret (i.e. you meant to mint upstream tokens but forgot credentials) the gateway logs an INFO message and falls back to passthrough. Set the credentials, or set auth.flow explicitly.
Protocol-Native Tool Metadata
The other half is what comes back in tools/call. Generated tools now emit:
| Field | Source | Effect |
|---|---|---|
title |
OpenAPI summary
|
Clients show "Fetch one pet by id" instead of getPetById. |
annotations |
HTTP method |
readOnlyHint for GET, destructiveHint for DELETE, idempotentHint for GET/PUT/PATCH/DELETE. |
structuredContent |
Parsed JSON body (success and error) | Agent reads error codes etc. without re-parsing the text body. |
Tool callables also return CallToolResult directly. Upstream 4xx/5xx no longer raises out of the tool. It returns isError=True with the parsed body in structuredContent. The agent can read the error code, retry with backoff, or surface a useful message. Network failures get the same treatment.
4. Tool Name and Description Override
Two recurring problems with OpenAPI specs in the wild:
- Ugly operationIds. The GitHub spec ships
actions/list-jobs-for-workflow-run-attemptandpackages/get-all-package-versions-for-package-owned-by-authenticated-user. - Empty descriptions. Most of the GitHub
gists/*endpoints (gists/delete,gists/list-commits,gists/fork, ...) ship withdescription: "".
The LLM has to guess intent from the name alone. Override via x-mcp-integration.expose.tool:
x-mcp-integration:
expose:
tool:
name: list_gist_commits
description: |
List commits for a Gist. Returns up to 30 commits per page with
commit SHA, author, and timestamp. Use ?page= for pagination.
Cheap fix with outsized impact when the agent picks the wrong tool because two operations had near-identical default descriptions.
Closing
Still a side project. The recurring situations driving this work:
- Huge specs eating context.
- GETs that want to be resources.
- B2B APIs that want real per-user auth.
The answers turned out worth writing down. If any of this was useful, a star on the repo would mean a lot. It is the clearest signal back that this is solving real problems for other people, and it is what keeps the next release coming.
- Repository: github.com/mroops0111/openapi-mcp-gateway
- PyPI: pypi.org/project/openapi-mcp-gateway (v0.5.0)
- First post: I Built openapi-mcp-gateway
Top comments (0)