Skip to main content
Glama

openapi-mcp-gateway. Point one YAML file at your OpenAPI specs, and each API gets its own MCP endpoint and auth.

Mount any OpenAPI (Swagger) spec as a Model Context Protocol (MCP) server, or expose an existing FastAPI app the same way. One YAML file runs multiple APIs in one process, each with its own mount path and auth, and none of it needs Python.

uvx openapi-mcp-gateway --spec https://petstore3.swagger.io/api/v3/openapi.json
# Server live at http://127.0.0.1:8000/api/mcp
  • Multi-Spec, Multi-Auth. Mount GitHub, an OAuth2 SaaS, and your internal API side by side from one config file, each at its own endpoint with its own auth and token namespace.

  • Spec-Compliant Authorization. The gateway runs its own OAuth server, or exchanges the caller's token with an issuer you already run, and calls the API with a per-user upstream token. The MCP client's credential is never replayed against a third party.

  • Tool Shaping. Rename ugly operationIds, hide knobs the model should never touch, and rewrite requests and responses with JSONata, all in YAML with no fork required. Every expression is checked at startup.

  • Dynamic Exposure. Front a 1,200-operation spec with three search → get → call meta-tools, so connecting to it does not spend the LLM's whole context window on tool schemas.

  • Resources, Not Just Tools. Eligible read-only GETs register as MCP resources instead, addressable by URI and surfaced by the client rather than guessed at by the model.

  • FastAPI-Native. Decorate routes with @mcp_tool to expose them in-process over ASGI, no extra hop and no second spec to maintain.


Installation

uv add openapi-mcp-gateway
uv add "openapi-mcp-gateway[redis]"  # optional, Redis token store for multi-replica OAuth

Requires Python 3.11+. To skip the install entirely, uvx openapi-mcp-gateway runs the published package directly.

Container Image

Each release publishes an image to GHCR. Mount a directory holding config.yml at /config:

docker run -p 8000:8000 --mount type=bind,src="$PWD/config",dst=/config,readonly \
  ghcr.io/mroops0111/openapi-mcp-gateway:latest

compose.yaml runs it beside Redis as the shared token store.

Related MCP server: FastMCP OpenAPI

Quick Start

Every example below uses uv run, which assumes the install above.

1. Public API, No Auth

uv run openapi-mcp-gateway --spec https://petstore3.swagger.io/api/v3/openapi.json --name petstore

Connect an MCP client to http://127.0.0.1:8000/petstore/mcp.

2. Bearer Token or API Key

export GITHUB_TOKEN="ghp_..."
uv run openapi-mcp-gateway \
    --spec https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json \
    --name github \
    --auth-type bearer \
    --auth-token '${GITHUB_TOKEN}'

Use a config file so the header name is explicit:

servers:
  - name: petstore
    spec: https://petstore3.swagger.io/api/v3/openapi.json
    auth:
      type: api_key
      token: ${PETSTORE_API_KEY}
      api_key_header: api_key

3. OAuth2

Rather than asking you to paste an upstream token into config, the gateway obtains one per caller. authorization_code runs the gateway as the authorization server and mints each end-user their own upstream token. client_credentials shares a single service token across every client. token_exchange hands issuance to an identity provider you already run. See Authorization for how each pairs a check on the MCP endpoint with a credential for the API.

export ASANA_CLIENT_ID="..." ASANA_CLIENT_SECRET="..."
uv run openapi-mcp-gateway \
    --spec https://raw.githubusercontent.com/Asana/openapi/master/defs/asana_oas.yaml \
    --name asana \
    --auth-type oauth2 \
    --auth-client-id '${ASANA_CLIENT_ID}' \
    --auth-client-secret '${ASANA_CLIENT_SECRET}' \
    --auth-upstream-scopes "openid,email,profile,users:read,workspaces:read"

For the service-token flow, add --auth-flow client_credentials. Those two are what the CLI reaches. token_exchange needs an issuer and an audience, so it is configured per server under auth: in YAML, described in Authorization.

4. Multiple APIs at Once

Mix public, bearer, and OAuth2 services in a single config. Each server is mounted at /{name}/mcp:

# servers.yml
url: http://127.0.0.1:8000  # public base URL for OAuth callbacks

servers:
  # Resource auto-promotion: eligible GETs become MCP resources, the rest stay tools.
  - name: petstore
    spec: https://petstore3.swagger.io/api/v3/openapi.json
    base_url: https://petstore.swagger.io/v2
    exposure:
      promote_resources: true

  # Dynamic exposure: ~1,200 GitHub ops behind three meta-tools instead of 1,200 tool schemas.
  - name: github
    spec: https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json
    exposure:
      style: dynamic
    auth:
      type: bearer
      token: ${GITHUB_TOKEN}

  # Per-user OAuth2 with audience-bound tokens, no passthrough.
  - name: asana
    spec: https://raw.githubusercontent.com/Asana/openapi/master/defs/asana_oas.yaml
    auth:
      type: oauth2
      upstream:
        client_id: ${ASANA_CLIENT_ID}
        client_secret: ${ASANA_CLIENT_SECRET}
        scopes: [openid, email, profile, users:read, workspaces:read]

That one file serves 13 tools with 3 concrete resources and 3 resource templates at /petstore/mcp, three meta-tools fronting ~1,200 endpoints at /github/mcp, and per-user OAuth2 against Asana's IdP at /asana/mcp. No spec edits anywhere. Run it with uv run openapi-mcp-gateway --config servers.yml.

5. Local Desktop Client (stdio)

For Claude Desktop, IDE integrations, or any MCP client that prefers stdio:

{
  "mcpServers": {
    "petstore": {
      "command": "uvx",
      "args": [
        "openapi-mcp-gateway",
        "--spec", "/abs/path/to/openapi.json",
        "--transport", "stdio"
      ]
    }
  }
}

More Examples

Runnable configs for every scenario above live in examples/, each with its prerequisites documented at the top.

Architecture

Each tool call is authorized, shaped, and sent upstream in turn, and its response is reshaped on the way back.

openapi-mcp-gateway architecture. An MCP client calls an MCP endpoint, one per API, which servers.yml builds at startup. Each call is authorized first, by the gateway's own OAuth server or by token exchange, with per-user upstream tokens from the identity provider kept in a memory or Redis token store. The request is then shaped by params and JSONata, and the call goes out with the upstream token, over HTTP to a REST API or over ASGI to a FastAPI app in the same process. The response comes back to the client, reshaped by JSONata.

Authorization

Every request crosses two boundaries, and one auth: block settles both of them. One is who may call the MCP endpoint, the other is what credential reaches the API behind it. Setting auth.type, plus auth.flow under oauth2, picks a pairing of the two. Everything the gateway sends upstream lives under auth.upstream, so the indentation separates the two directions.

auth.type / auth.flow

MCP Endpoint

Credential Sent Upstream

none

open

none

bearer, api_key

open

a fixed one from config, shared by every caller

passthrough

open

the caller's own header, forwarded unchanged

oauth2 + client_credentials

open

one service token, shared by every caller

oauth2 + authorization_code

the gateway is the authorization server

a per-user token the gateway obtained on their behalf

oauth2 + token_exchange

an external issuer is the authorization server

a per-user token exchanged from the caller's

Only the last two put a check in front of the MCP endpoint. The others suit a gateway on localhost or inside a private network, and leave it open to anyone who can reach the port.

token_exchange verifies JWT signatures, so it needs the oidc extra. Run the gateway as uvx --from "openapi-mcp-gateway[oidc]" openapi-mcp-gateway. Without it the gateway refuses to start and says so.

The MCP spec requires a server to accept only tokens minted for itself, and forbids relaying one to an upstream API. So under both protected flows the upstream is reached with a second, separately obtained credential rather than the one the caller presented. See Access Token Privilege Restriction.

passthrough is the one exception, and it exists for the FastAPI integration, where the gateway runs in-process as part of the app it exposes. There is no separate upstream to be confused about. Setting it against a genuinely separate API is the confused-deputy pattern the spec forbids, which is why nothing selects it automatically.

An API with no authorization server of its own, which accepts tokens from a provider the deployment already runs, needs the gateway to say which API its upstream token is for. Point the OAuth URLs at that provider and name the API:

servers:
  - name: internal
    spec: https://internal.example.com/openapi.json
    auth:
      type: oauth2
      flow: authorization_code
      upstream:
        authorization_url: https://you.auth0.com/authorize
        token_url: https://you.auth0.com/oauth/token
        client_id: ${GATEWAY_CLIENT_ID}
        client_secret: ${GATEWAY_CLIENT_SECRET}
        audience: https://internal.example.com

Without it the provider mints for its own default audience and the API refuses the result. The parameter rides on the authorization request and on every token request, refreshes included, so a rotated token stays usable.

Authorization servers disagree on the spelling. upstream.audience is what Auth0 expects, upstream.resource is the RFC 8707 parameter.

Set the one yours reads, or set both. A server that does not recognise a parameter ignores it silently rather than refusing, so Keycloak given only upstream.resource returns a perfectly ordinary token whose audience is wrong, and the upstream then rejects it for reasons that look unrelated. Sending both is legal, since RFC 8707 §2.1 defines both names, and it is the portable choice.

MCP clients still authorize against the gateway and receive a gateway-issued token, while the provider-issued one is a second credential held on their behalf. End users see whatever login the provider federates to, so this works on any plan and needs nothing of the upstream but that it accept what the provider issues.

The Auth0 Management API is a worked example of exactly this shape, since its own audience differs from the tenant that issues for it. See examples/auth0-management.yml.

authorization_code leaves the gateway issuing credentials of its own, so revoking someone at the provider has no effect until the gateway's token expires. token_exchange removes that second issuer. The provider mints tokens for the MCP endpoint directly, the gateway validates them, and each call exchanges one under RFC 8693 for a second token naming the upstream:

servers:
  - name: internal
    spec: https://internal.example.com/openapi.json
    auth:
      type: oauth2
      flow: token_exchange
      issuer: https://auth.example.com/realms/internal
      upstream:
        audience: https://internal.example.com
        client_id: ${GATEWAY_CLIENT_ID}
        client_secret: ${GATEWAY_CLIENT_SECRET}

The gateway serves no /authorize or /token here. Its protected resource metadata names the issuer, clients authorize there, and the JWKS comes from the issuer's own metadata so key rotation needs no restart.

The endpoint identifies itself as {url}{mount_path}/mcp, built from the gateway's url and the server's mount path, so the example above is https://gw.example.com/internal/mcp. That exact string is what an inbound token's aud must contain, so you need it when creating the matching client and audience mapping at the issuer.

Two things to check before committing to this mode. Token exchange support varies:

Authorization Server

Token Exchange

Keycloak

generally available, enabled by default

authentik

2026.8 and later

Zitadel

can only narrow an audience the token already carries

Auth0

Custom Token Exchange, on Professional and Enterprise plans, with an Action to write

Logto

not implemented

Keycloak is generally available in the sense that the feature flag is on, but a working realm still needs three things that its documentation does not connect to this use case:

  • The inbound aud comes from an audience mapper, added to a client scope. Keycloak ignores resource and audience on the authorization and token endpoints, so without a mapper the token's audience is just account and the gateway rejects it.

  • The client performing the exchange must itself be in the subject token's audience, or the exchange fails with access_denied: Client is not within the token audience. The tidiest arrangement is to make the MCP endpoint a client whose clientId is its canonical URI, so it is both the audience target and the exchanging client.

  • The exchange target must exist as a client and be reachable from the exchanging client's scopes, or the exchange fails with invalid_request: Requested audience not available.

And because the issuer is the authorization server for this endpoint, MCP clients register there rather than with the gateway. Check whether yours supports dynamic client registration, or whether each client needs pre-registering. If it does support it, set required_scopes so the advertised scopes_supported tells a registering client what to ask for. Leave it empty and a client may register with a minimal scope set whose tokens then carry neither the audience nor the claims the upstream needs.

Under authorization_code the gateway's own access token lives 1 hour and its refresh token 24 hours. Each refresh issues a fresh refresh token, so the refresh TTL is the practical re-authorization cadence. A client refreshing within it never signs in again, while one idle past it must re-authorize. Tune both with auth.mcp_access_token_ttl and auth.mcp_refresh_token_ttl.

token_exchange mints nothing, so neither applies. Lifetimes are the issuer's to set.

Tool Results

Every registered tool carries a protocol-native title and annotations (readOnlyHint, destructiveHint, idempotentHint), so an agent can judge a tool before calling it. Results carry structuredContent, so a client reads a typed body and structured error payloads without re-parsing text. A binary response, such as an image or a PDF, arrives as a content block rather than decoded text. None of this needs configuration.

Configuration

Run uv run openapi-mcp-gateway --help for the CLI reference. The Quick Start covers most setups, and the full field reference is below.

Configuration merges in this order, with each layer overriding the previous one. Defaults → YAML (--config) → CLI flags → Gateway.run(...) kwargs. A layer only overrides the fields it actually sets, so --log-level=DEBUG won't reset logging.format from your YAML. Nested objects like logging and per-server auth merge field-by-field. The servers list is the exception, replaced wholesale rather than merged entry-by-entry.

${ENV_VAR} and ${ENV_VAR:-default} work in any string field, resolved at request time. An unrecognised key is refused at startup rather than ignored, so a typo in a field that narrows access fails closed. For OAuth2, authorizationUrl / tokenUrl / scopes are auto-detected from the spec's securitySchemes, and the auth.* fields below override them when the spec is incomplete.

Field

Type

Default

Description

host

string

0.0.0.0

Bind address (0.0.0.0 = all interfaces). Clients on the same machine usually open http://localhost:{port} or http://127.0.0.1:{port}.

port

int

8000

Bind port

url

string

(empty)

Public base URL for OAuth redirects and discovery. When unset: http://localhost:{port} if host is 0.0.0.0, otherwise http://{host}:{port}. Override when your registered redirect URI uses another host (tunnel, reverse proxy, etc.).

transport

string

streamable-http

streamable-http, stdio, or sse (deprecated)

dns_rebinding_protection.allowed_hosts

list

[]

Host values the MCP endpoints accept, as host or host:port (host:* for any port), which refuses a web page reaching the gateway through DNS rebinding. Listing any turns the check on. When empty, a loopback host accepts only loopback names and any other bind accepts any Host. A mounted gateway is checked only when hosts are listed.

dns_rebinding_protection.allowed_origins

list

[]

Origin values accepted from browser clients, as scheme://host[:port]. Requires allowed_hosts.

store.type

string

memory

memory or redis. Redis shares OAuth credential state across replicas. It holds OAuth tokens and client registrations, never MCP protocol sessions, so single-replica or non-OAuth deployments can stay on memory.

store.redis_url

string

redis://localhost:6379

Redis URL when store.type: redis

logging.level

string

INFO

DEBUG, INFO, WARNING, ERROR, CRITICAL

logging.format

string

text

text or json

logging.file

string

Mirror logs to this file

servers

list

required

List of per-server config entries

Field

Type

Default

Description

name

string

required

Unique identifier. Mount path defaults to /{name}

path_prefix

string

name

Mount path for this server, when it should differ from /{name}

spec

string

required

Path or URL to OpenAPI document (JSON or YAML)

base_url

string

from spec

Override the upstream base URL

auth.type

string

none

none, bearer, api_key, oauth2, or passthrough. Says where the upstream credential comes from: a fixed one, one the gateway obtains, or the caller's own forwarded

auth.token

string

Required for bearer / api_key

auth.api_key_header

string

X-API-Key

Header name for api_key

auth.flow

string

from spec

authorization_code for per-user delegation, client_credentials for a shared service token, token_exchange to delegate this endpoint's authorization to an external issuer. When unset the gateway prefers the spec's declared authorizationCode flow, falling back to whatever else it declares.

auth.issuer

string

Required for token_exchange. The authorization server that mints tokens for this MCP endpoint

auth.required_scopes

list

For token_exchange, what an inbound token must already carry. Advertised as scopes_supported, so a client doing dynamic registration knows what to ask for

auth.upstream.client_id, auth.upstream.client_secret

string

Required for oauth2. The gateway's own credential at the upstream authorization server

auth.upstream.scopes, auth.upstream.authorization_url, auth.upstream.token_url

from spec

What the gateway requests from the upstream authorization server, and where. The URLs override an incomplete securitySchemes

auth.upstream.resource, auth.upstream.audience

string

Names the API the upstream token is for, when the API and its authorization server are different parties. resource is the RFC 8707 parameter, audience is the spelling Auth0 uses. Only what you set is sent

auth.upstream.issuer

string

For authorization_code. The upstream authorization server's issuer identifier, exactly as its metadata publishes it. Enables the RFC 9207 iss check on its authorization responses

auth.mcp_access_token_ttl

int

3600

Lifetime in seconds of the MCP access token the gateway mints for authorization_code

auth.mcp_refresh_token_ttl

int

86400

Lifetime in seconds of the MCP refresh token. This is the practical re-authorization cadence, since each refresh slides the window forward

policy.allow

list

Only expose matching operations

policy.deny

list

Exclude matching operations

timeout

float

90

HTTP timeout in seconds

headers

map

Static headers sent on every upstream call, such as an API version the whole API requires. Values take ${ENV_VAR}, and an unset variable leaves the header out. A header the call itself sends wins. Authorization is refused, since it belongs to auth

instructions

string

Guidance about the server as a whole, such as when to use it rather than another server or which tool to call first. Sent in the MCP initialize result, which clients place in the model's context. Unset leaves the field out

exposure.style

string

static

static registers one MCP tool per operation. dynamic registers three meta-tools (search_operations, get_operation, call_operation) for the LLM to walk on demand, and ignores promote_resources.

exposure.promote_resources

bool

false

Promotes eligible GETs (no required non-path parameter) to MCP resources. A per-operation resource declaration applies either way.

exposure.search.field_weights

map

Per-field search weights for dynamic exposure, over name, summary, description, path, and tags. Unset fields keep their defaults, listed under Search Ranking. Refused under static exposure

operations

map

{}

YAML-side x-mcp-integration overrides, keyed by operationId. Fully replaces (does not merge) the spec-side x-mcp-integration on that operation. Useful when you do not control the upstream spec.

Filtering Operations

Use policy.allow and policy.deny with fnmatch syntax against operation IDs (getUsers, create*) or method + path (GET /users/*).

policy:
  allow: ["GET /repos/*"]
  deny:  ["GET /repos/*/actions/secrets*"]

Operations can also be opted in from the spec side with x-mcp-integration: {tool: {}} plus policy.annotated_only: true. Filters apply in the order annotated_only, then allow, then deny.

Resource Exposure

Read-only GET operations are a better fit for the MCP resource primitive than for a tool. Tools are model-controlled, so the LLM decides when to call one. Resources are application-controlled, surfaced by the client or picked by the user. A GET that is fully identified by its URL is a thing that exists at an address, which is what a URI is for.

Set exposure.promote_resources: true and every eligible GET promotes automatically. Eligible means no required query, header, or body parameter. Required path parameters are fine and turn the operation into a resource template. Against the vanilla Petstore3 spec that yields 13 tools, 3 concrete resources, and 3 resource templates with zero spec edits.

Keeping those endpoints off the tool list also saves context, since most clients do not auto-load resources. Resource support is uneven across the ecosystem, though, and an agent framework that ignores resources entirely will not reach a promoted operation at all. Leave promote_resources off when that is your target.

To rename a resource, set a custom URI template, or set a non-JSON MIME type, use the operations map keyed by operationId:

servers:
  - name: petstore
    spec: https://petstore3.swagger.io/api/v3/openapi.json
    exposure:
      promote_resources: true
    operations:
      getPetById:
        resource:
          name: pet
          mime_type: application/json
      getInventory:
        resource:
          name: inventory

If you own the upstream spec, write the same opt-in inline instead:

paths:
  /pets/{petId}:
    get:
      operationId: getPet
      x-mcp-integration:
        resource:
          name: pet
          mime_type: application/json
          # uri_template: petstore://v2/pets/{petId}  # optional, must start with "<server>://"

Declaring both tool and resource registers the operation on both surfaces. Each entry fully replaces (does not merge with) the spec-side x-mcp-integration. A runnable demo lives at examples/petstore-override.yml.

An unknown operationId raises at startup so typos do not silently no-op. Resource declarations are validated there too, so non-GET methods, required non-path parameters, and uri_template values that do not start with <server>:// abort Gateway.from_config with a concrete error. Subscriptions are not implemented because REST has no native push.

Tool Shaping

A raw operation rarely makes a good tool. Its operationId is ugly (GitHub's actions/list-jobs-for-workflow-run-attempt), its description is empty (most of gists/*), it takes a cryptic filter DSL alongside a dozen knobs the model should never touch, and it wraps the few useful fields in a large envelope. x-mcp-integration.tool fixes all of that without forking the spec. name and description fix how the tool presents itself, while params, params_strategy, request, and response reshape the interface behind it. media turns base64 fields into content blocks.

servers:
  - name: github
    spec: https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json
    operations:
      pulls/list-files:
        tool:
          name: list_pull_request_files
          description: |
            List files changed in a pull request. Returns up to 3000 files,
            each with status (added / modified / removed), patch text, and
            line counts.

If you own the upstream spec, write the same block inline as x-mcp-integration.tool on the operation.

The input layer is declarative and the value transforms are JSONata expressions.

params and params_strategy shape what the model sees. Each params entry is a JSON Schema fragment (type, enum, default, description, format, minimum, items, and so on) plus two flags. required lifts the parameter into the schema's required list, and hidden removes a spec parameter from the surface. params_strategy is mandatory whenever params is set:

  • merge: tweaks the operation's existing parameters and keeps the rest visible, so declaring a parameter the spec does not define is an error.

  • replace: makes the declared entries the whole schema and drops every spec parameter, so it always needs a request to route the friendly arguments upstream. An entry that names a spec header is the exception. It is adjusted the same way as under merge and stays a header, so the request can still send it.

operations:
  searchIssues:
    tool:
      params_strategy: merge
      params:
        internal_flag: { hidden: true }
        per_page: { default: 30 }
        sort: { description: "One of comments, created, updated." }

request and response transform the values. Both are optional and independent of each other. request builds the entire upstream request, and response reshapes a successful body before it reaches the client.

operations:
  searchIssues:
    tool:
      request: |
        $merge([$, { "per_page": 30, "state": "open" }])
      response: |
        [items.{ "title": title, "url": html_url }]
  • Routing: a key that names a path placeholder fills the path, a key that names a header parameter becomes that header, and the rest become query parameters for a body-less method or the JSON body otherwise. Like every other location, a header goes out only if the result carries it, so pass a hidden default through with $merge([$, ...]) or name it explicitly.

  • Passthrough: $merge([$, { ... }]) forwards the incoming arguments and overrides only the keys you name, as above.

  • Lists: wrapping a mapping in [ ... ] keeps the result an array even when a single item matches.

  • Errors: a broken expression is rejected at startup, and a runtime failure returns an isError result naming the side that broke.

For a full replace example, where the declared params are the entire surface and request maps a friendly enum onto the raw query with $lookup, see examples/movie-shaping.yml.

A base64 field moves into its own image, audio, or blob content block, leaving a placeholder such as <content[1]: image/png> in the JSON. A field the spec marks with OpenAPI 3.1 contentEncoding: base64 is promoted as is. Name any other field in media, by its path in the upstream response:

operations:
  getContract:
    tool:
      media:
        attachments[*].content:
          encoding: base64
          media_type: application/pdf  # defaults to application/octet-stream

Dynamic Exposure

For APIs with hundreds of operations (GitHub, Stripe, etc.), registering each as its own tool can blow the LLM's context window before the agent does anything. Set exposure.style: dynamic and the client sees three meta-tools instead, which the LLM walks as search → get → call to discover and invoke operations on demand. It is per-server, so /github/mcp can run dynamic while /petstore/mcp runs static in the same process.

  • search_operations(query, tags, cursor) ranks operations against query with BM25F and returns [{name, description}, ...], 20 per page, with nextCursor when more matches exist. tags limits the search to operations carrying any of them. An omitted argument filters nothing, so with no arguments it lists every operation, and the first page also carries each tag with its operation count.

  • get_operation(name) returns one operation's JSON Schema for input arguments.

  • call_operation(name, arguments) invokes that operation against the upstream.

Auth, path templating, and per-operation request shape match static mode, so only the surfacing changes. See examples/github-dynamic.yml for a runnable config.

search_operations scores each operation's name, summary, description, path, and tags separately, then weighs them. Words match across camelCase, snake_case, and paths, and across forms such as branch and branches or merge and merged. Synonyms do not match, so close an issue does not find an operation described only as "update an issue".

Field

Default weight

summary

3

name

2

description

0.5

path

0.5

tags

0.5

A weight multiplies how much a matching word counts in that field, so 1 is an ordinary match and the weights need not add up to anything. exposure.search.field_weights overrides them per server, field by field, and 0 leaves a field out of search:

servers:
  - name: internal
    spec: ./internal-api.yml
    exposure:
      style: dynamic
      search:
        field_weights:
          description: 2  # this spec's summaries are terse, and its descriptions say what each call does

Raise a field when it describes operations well in your spec, and lower it when it repeats boilerplate. To fix a single operation that ranks poorly, rewrite its description through operations instead, since search reads the overridden text.

Logging

Configure via the logging.* YAML keys or via CLI flags (--log-level, --log-format, --log-file). -v and -q are shortcuts for DEBUG and WARNING. CLI flags override YAML field-by-field, following the precedence rule above.

Inspecting a Config

--dry-run validates a config and prints what it would expose without starting a server. --output json emits the same facts as data, on stdout with logging kept to stderr, and Gateway.describe() returns the identical document from Python. Names in the output are the sanitised ones the model is shown, so an operation with no operationId appears under a name derived from its method and path.

Authoring Configs with AI

generate-config is a companion Claude Code skill that writes a config.yml from a plain-language request, deriving the operations, auth, and shaping for you. This repo doubles as its plugin marketplace:

/plugin marketplace add mroops0111/openapi-mcp-gateway
/plugin install openapi-mcp-gateway

/generate-config connect our GitHub so my assistant can manage issues

Python API

The gateway works as a library, either standalone or wrapped around an app you already run.

from openapi_mcp_gateway import Gateway

gateway = Gateway()
gateway.add_server(
    name="petstore",
    spec="https://petstore3.swagger.io/api/v3/openapi.json",
)
gateway.add_server(
    name="github",
    spec="./github-openapi.json",
    auth={"type": "bearer", "token": "${GITHUB_TOKEN}"},
    policy={"allow": ["GET /repos/*"]},
)
gateway.run(port=8000)

FastAPI Integration

If you already run FastAPI, decorate the routes you want exposed with @mcp_tool and the gateway picks them up. No second spec, no separate process, and no extra network hop, since calls go in-process through httpx.ASGITransport. Auth is auto-detected from the app's securitySchemes, and passing an explicit auth=AuthConfig(...) to Gateway.from_fastapi overrides it.

from fastapi import FastAPI
from openapi_mcp_gateway import Gateway, mcp_tool

app = FastAPI()

@app.get("/items/{item_id}")
@mcp_tool()
def read_item(item_id: int):
    return {"id": item_id}

@app.get("/internal/health")  # not decorated → not exposed
def health():
    return {"ok": True}

Gateway.from_fastapi(app, name="myapp").run()

Because the gateway runs in-process and routes through httpx.ASGITransport, gateway and upstream share the same OAuth audience, so the MCP client's Authorization header passes through verbatim (auth.type: passthrough, set automatically for this integration only). For client_credentials schemes the gateway mints upstream tokens from its own credentials instead.

Mounting into an Existing App

To serve MCP alongside your own routes, build a Gateway and mount it onto your app. mount attaches every MCP sub-app at its configured path and also registers the OAuth authorization-server and .well-known discovery routes those servers own, so an OAuth flow works end to end. Enter gateway.lifespan() in your app's lifespan, or MCP requests fail.

import contextlib

from fastapi import FastAPI
from openapi_mcp_gateway import Gateway, GatewayConfig, ServerConfig

gateway = Gateway.from_config(
    GatewayConfig(
        url="https://your-app.example.com",  # public URL, used for discovery and redirect URLs
        servers=[ServerConfig(name="petstore", spec="petstore.json")],
    )
)


@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
    async with gateway.lifespan():
        yield


app = FastAPI(lifespan=lifespan)
gateway.mount(app)  # mounts /petstore/mcp plus its OAuth and .well-known routes

Set GatewayConfig.url to the host app's public URL so discovery documents and OAuth redirect URLs point at the right origin. The upstream OAuth callback for a server named <server> is fixed at /<server>/auth/callback, so keep it clear of your app's own callback paths.

License

MIT

Available Tools

19 tools
add_petAdd a new pet to the store.D

Add a new pet to the store.

Inputs: name (required), photoUrls (required), id, tags, +2 more.

create_userCreate user.B

This can only be done by the logged in user.

Inputs: id, email, phone, lastName, +4 more.

create_users_with_list_inputCreates list of users with given input array.C

Creates list of users with given input array.

Inputs: no named parameters.

delete_orderDelete purchase order by identifier.A
DestructiveIdempotent

For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.

Inputs: orderId (required).

delete_petDeletes a pet.D
DestructiveIdempotent

Delete a pet.

Inputs: petId (required), api_key.

delete_userDelete user resource.B
DestructiveIdempotent

This can only be done by the logged in user.

Inputs: username (required).

find_pets_by_statusFinds Pets by status.A
Read-onlyIdempotent

Multiple status values can be provided with comma separated strings.

Inputs: status (required).

find_pets_by_tagsFinds Pets by tags.B
Read-onlyIdempotent

Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.

Inputs: tags (required).

get_inventoryReturns pet inventories by status.A
Read-onlyIdempotent

Returns a map of status codes to quantities.

Inputs: no named parameters.

get_order_by_idFind purchase order by ID.B
Read-onlyIdempotent

For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.

Inputs: orderId (required).

get_pet_by_idFind pet by ID.C
Read-onlyIdempotent

Returns a single pet.

Inputs: petId (required).

get_user_by_nameGet user by user name.A
Read-onlyIdempotent

Get user detail based on username.

Inputs: username (required).

login_userLogs user into the system.C
Read-onlyIdempotent

Log into the system.

Inputs: password, username.

logout_userLogs out current logged in user session.B
Read-onlyIdempotent

Log user out of the system.

Inputs: no named parameters.

place_orderPlace an order for a pet.C

Place a new order in the store.

Inputs: id, petId, status, complete, +2 more.

update_petUpdate an existing pet.C
Idempotent

Update an existing pet by Id.

Inputs: name (required), photoUrls (required), id, tags, +2 more.

update_pet_with_formUpdates a pet in the store with form data.C

Updates a pet resource based on the form data.

Inputs: petId (required), name, status.

update_userUpdate user resource.D
Idempotent

This can only be done by the logged in user.

Inputs: username (required), id, email, phone, +4 more.

upload_fileUploads an image.C

Upload image of the pet.

Inputs: petId (required), additionalMetadata.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    B3/5.0

    Scored across 19 tools

    Disambiguation4/5

    Tools are generally distinct per resource and action, but update_pet and update_pet_with_form could confuse agents expecting a single update path. Similarly, create_user vs create_users_with_list_input are similar in purpose though descriptions clarify the difference.

    Naming Consistency5/5

    All tool names follow a consistent snake_case verb_noun pattern (e.g., get_pet_by_id, delete_order, create_user). There is minor variation in verbs like add vs create, but the pattern remains predictable throughout.

    Tool Count5/5

    19 tools is well-scoped for a pet store API covering pets, users, and orders. Each tool serves a clear purpose without redundancy, and the count is within the typical range for a domain-specific server.

    Completeness4/5

    The toolset provides solid CRUD coverage for pets, users, and orders, including listing/filtering and file upload. Minor gaps exist—for example, no update order and no way to list all users or all pets generically—but core workflows are covered.

    Maintenance

    ActivityActive
    ResponsivenessLimited data

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Dynamically generates MCP tools from OpenAPI specifications, enabling AI assistants to interact with any REST API through natural language. Supports multiple APIs with authentication, parameter validation, and integration with Claude Desktop and LangChain.
      1
      MIT
    • A
      license
      A
      quality
      D
      maintenance
      A generic MCP server that dynamically exposes any OpenAPI-documented REST API to LLMs by auto-discovering endpoints. It provides tools for exploring API capabilities and making authenticated requests directly through natural language interfaces.
      2
      9 npm
      MIT