DEV Community

MrOops
MrOops

Posted on

openapi-mcp-gateway: Resources, Dynamic Exposure, and Spec-Compliant Auth

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:

  1. Dynamic exposure for huge specs like GitHub
  2. Auto-promote eligible GETs to MCP resources
  3. MCP spec compliance (audience-bound auth + protocol-native tool metadata)
  4. 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]
Enter fullscreen mode Exit fullscreen mode
uvx openapi-mcp-gateway --config servers.yml
Enter fullscreen mode Exit fullscreen mode

What you get at http://127.0.0.1:8000:

  • /petstore/mcp: 13 tools + 6 resources (3 concrete, 3 templates), partitioned by mode: auto with 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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The agent walks them in order:

  1. list_operations() to discover.
  2. get_operation("issues_create") to read the schema for the one it wants.
  3. call_operation(name, args) to invoke.

Auth, path templating, and per-operation request shape are identical to static mode. Only the surfacing changes.

Dynamic exposure walk: list_operations, get_operation, call_operation, then upstream HTTP

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_code and client_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.

Token flow: passthrough forwards the MCP client token; current default mints an upstream-audience token instead

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-attempt and packages/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 with description: "".

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.
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)