Skip to content

feat: add openapi-to-mcp plugin - #13942

Merged
AlinsRan merged 7 commits into
apache:masterfrom
AlinsRan:feat/openapi-to-mcp
Sep 16, 2026
Merged

AlinsRan merged 7 commits into
apache:masterfrom
AlinsRan:feat/openapi-to-mcp

Conversation

@AlinsRan

@AlinsRan AlinsRan commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Description

Proposal: openapi-to-mcp, serve an HTTP API to MCP clients from its OpenAPI document

Background

LLM agents increasingly reach tools through the Model Context Protocol (MCP). Most of the APIs an organization would want to hand to an agent already exist, already sit behind APISIX, and already publish an OpenAPI document. Today, exposing one of them over MCP means writing and operating an MCP server per API, whose only job is to restate what the OpenAPI document says and forward each call.

APISIX has mcp-bridge, which relays an HTTP client to a stdio MCP server process. That helps when an MCP server already exists; it does not help when what exists is an HTTP API.

Goals

  • Expose an existing HTTP API to MCP clients by configuration only: an OpenAPI URL and a base URL.
  • Implement the MCP server side inside APISIX: no extra process, sidecar or language runtime.
  • Keep the route an ordinary APISIX route: authentication, rate limiting and response plugins configured on it keep applying to MCP traffic.
  • Support both transports current clients use: Streamable HTTP and HTTP+SSE.
  • Work with multiple worker processes.

Non-goals

  • MCP capabilities other than tools: resources, prompts, sampling, elicitation, completion, tasks. They have no counterpart in an OpenAPI document; the server answers them with JSON-RPC -32601.
  • Stateful Streamable HTTP sessions (Mcp-Session-Id), resumable streams and server-initiated messages. The Streamable HTTP transport is stateless.
  • Sharing SSE sessions across APISIX instances (see State).
  • Full Swagger 2.0 support: in: body and in: formData parameters are not turned into tool inputs.

User-facing interface

{
  "uri": "/mcp",
  "plugins": {
    "openapi-to-mcp": {
      "transport": "streamable_http",
      "openapi_url": "https://petstore3.swagger.io/api/v3/openapi.json",
      "base_url": "https://petstore3.swagger.io/api/v3",
      "headers": { "Authorization": "Bearer ${http_x_api_token}" },
      "flatten_parameters": false
    }
  }
}
Attribute Required Default Meaning
transport no sse sse or streamable_http
openapi_url yes where the OpenAPI document is fetched from
base_url yes base of every tool call; supports variables
headers no added to every tool call; values support variables
flatten_parameters no false nest parameters under pathParameters / queryParameters / headerParameters, or put them at the top level of the tool input

One tool is generated per operation. The tool name is the operationId (sanitized; generated from method and path when absent, de-duplicated when repeated), the description is the operation's description, else its summary, else Executes <METHOD> <path>, and inputSchema is the operation's parameters and request body converted from OpenAPI Schema to JSON Schema. Annotations (readOnlyHint, destructiveHint, ...) are inferred from the HTTP method and can be set with an x-mcp-annotations extension.

Design overview

                         APISIX worker
MCP client  ───────►  route /mcp
                        ├─ other plugins (auth, limits, ...)   access phase
                        └─ openapi-to-mcp                      before_proxy
                              │
                              ├─ transport        streamable_http.lua / sse.lua
                              ├─ MCP server       jsonrpc.lua, protocol.lua, server.lua
                              ├─ tool list        cache.lua → loader → ref → generator
                              │                        │
                              │                        └── GET openapi_url ─────────►  document host
                              └─ tool call        tools/handler.lua
                                                       └── HTTP request ────────────►  the API (base_url)
                       mcp-session shared dict   ◄── SSE session queues, shared by all workers
Module Responsibility
openapi-to-mcp.lua schema, variable resolution, hands the request to a transport
transport/streamable_http.lua stateless Streamable HTTP: transport checks, one JSON-RPC exchange per POST
transport/sse.lua HTTP+SSE: the stream (GET) and the message endpoint (POST)
session.lua SSE session marker and message queue in the mcp-session shared dict
jsonrpc.lua, protocol.lua, server.lua message validation, version negotiation, method dispatch
cache.lua per-route cache of the generated tool list
openapi/loader.lua fetch the document, parse JSON or YAML, record path order
openapi/ref.lua resolve internal and http(s) $refs, with cycle and fetch bounds
openapi/schema.lua, openapi/endpoints.lua OpenAPI Schema to JSON Schema; paths × methods in document order
tools/generator.lua tool definitions
tools/handler.lua a tools/call into an HTTP request, and its response into a tool result
json_pretty.lua the indented JSON text of a tool result

The modules live under apisix/plugins/openapi-to-mcp/, named after the plugin like ai-proxy/, and apart from mcp-bridge's apisix/plugins/mcp/.

Request flow

1. Phases

http_access_phase()
├─ run_plugin("rewrite")                authentication, e.g. key-auth
├─ consumer resolution
├─ run_plugin("access")                 descending priority
│    limit-count       1002
│    openapi-to-mcp     540  ──►  _M.access()
│    ...                           · resolve base_url and headers against ctx.var
│                                  · stash ctx.mcp_inprocess_opts
│                                  · set ctx.bypass_nginx_upstream
│                                  · return without answering
└─ handle_upstream()
   └─ bypass_nginx_upstream is set, so no upstream is selected
      └─ common_phase("before_proxy")  ──►  _M.before_proxy()
                                          · Content-Type: application/json by default
                                          · dispatch to the transport, which calls ngx.exit()
      (proxy_pass is never reached)

header_filter / body_filter / log       run as for any response

The answer is produced in before_proxy, not in access: exiting in access would end the access chain and skip every lower-priority plugin on the route. ctx.bypass_nginx_upstream, the flag ai-proxy uses, makes handle_upstream() run before_proxy and return without selecting an upstream, so a route with the plugin needs no upstream of its own. Because the response leaves through the normal filter chain, response-rewrite, limit-count's headers and the log plugins see it like any other.

2. Streamable HTTP (transport: streamable_http)

One POST carries one JSON-RPC message and gets its answer in the same response.

before_proxy → streamable_http.handle()
 1. method is not POST                              → 405  -32000
 2. JSON body that does not parse                   → 400  -32700, id null
 3. build the tool list (cached)       fails        → 500  -32603
 4. Accept lacks application/json or text/event-stream
                                                    → 406  -32000
 5. Content-Type is not application/json            → 415  -32000
 6. not a valid JSON-RPC request                    → 400  -32700, id null
 7. MCP-Protocol-Version present and unsupported
    (initialize is exempt)                          → 400  -32000
 8. server.handle()
      notification                                  → 202, no body
      request                                       → 200  text/event-stream
                                                           event: message
                                                           data: {"jsonrpc":"2.0","id":…,"result"|"error":…}

The order of these checks is part of the observable behaviour and is pinned by tests: a malformed body with a bad Accept header gets the body error, and a route whose document cannot be fetched fails before Accept is looked at.

3. HTTP+SSE (transport: sse)

Three legs over two connections. The GET and the POSTs of one session may be served by different workers.

client                         worker A (GET)                    mcp-session dict           worker B (POST)
  │ GET /mcp ─────────────────► build tool list (cached)
  │                              fails → 500, no stream
  │                             session.create()  ─────────────► <id>:alive  ttl 1800s
  │ ◄── event: endpoint ──────  data: /mcp?sessionId=<id>
  │                             loop every 100ms:
  │                               session.pop() ◄──────────────  <id>:queue
  │ POST /mcp?sessionId=<id> ───────────────────────────────────────────────────────────► handle_post()
  │                                                                                         unparsable JSON body → 400
  │                                                                                         no sessionId        → 400 -32000
  │                                                                                         unknown session     → 404 -32000
  │                                                                                         no Content-Type     → 415
  │                                                                                         non-JSON type       → 400
  │                                                                                         invalid JSON-RPC    → 400 -32700
  │                                                                                         server.handle()
  │                                                               <id>:queue  ◄────────── session.push(answer)
  │ ◄── 202 Accepted ───────────────────────────────────────────────────────────────────────
  │ ◄── event: message ───────  popped answer
  │                             every 30s idle: ": keepalive" comment + refresh <id>:alive
  │                             stops on write error, 30 min lifetime or worker exit;
  │                             session.destroy() removes marker and queue

session.push() checks the session marker before and after rpush, so a message racing the stream's teardown in another worker cannot leave an orphaned queue behind (a shared dict list has no TTL). Notifications are accepted with 202 and produce no event.

before_proxy does not return for the GET until the stream ends, so its log phase runs when the stream closes.

4. initialize, ping, tools/list

server.handle(request)
├─ initialize   protocolVersion = the client's version if supported, else the latest (2025-11-25)
│               capabilities    = { tools = {} }
├─ ping         {}
├─ tools/list   cache.get_tools(conf)
│                 key: openapi_url + flatten_parameters     lrucache, 100 entries
│                 hit  → cached list                         ttl 3600s, expired entries rebuilt
│                 miss → build:                              failures cached 5s
│                   loader.fetch(openapi_url)       resty.http GET, 5s timeout
│                   loader.parse()                  JSON, falling back to YAML
│                   ref.resolve()                   internal pointers; http(s) refs fetched
│                                                   (≤ 8 documents, 3s each, 10s total);
│                                                   a pointer already on the expansion
│                                                   path becomes { type = "object" }
│                   generator.generate()            paths × methods in document order;
│                                                   Path Item parameters inherited,
│                                                   overridden by name + location
└─ anything else  -32601 Method not found

initialize and ping need neither the document nor the API. Supported protocol versions: 2024-10-07, 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25.

5. tools/call

server.handle_tools_call()
├─ params.name missing              → result { isError: true, "Tool name is required" }
├─ unknown tool                     → result { isError: true, "Tool <name> not found" }
├─ arguments fail inputSchema       → result { isError: true, "Input validation error: …" }
└─ tools/handler.call()
     split arguments into path / query / header / body
       (by the shape the client sent, not by flatten_parameters)
     apply query parameter defaults
     path   : template with URI-escaped values
     query  : by each parameter's style / explode (default form, exploded:
              tags=a&tags=b; also form unexploded, spaceDelimited,
              pipeDelimited, deepObject)
     headers: plugin `headers` (variables already resolved) + header parameters
     body   : requestBody, JSON-encoded unless it is a string; Content-Type is
              the declared media type unless `headers` sets one
     resty.http request_uri(base_url .. path .. "?" .. query), 30s timeout
     ├─ transport failure → result { status: 0, statusText: "Network Error", error: {…} }
     └─ response          → result { status, statusText, headers, data }
                             data = decoded JSON when the body parses, else the raw string

The result is returned as one text content item holding that object as indented JSON. An API error (4xx/5xx) is a normal result carrying its status, so the model can see and react to it.

State, scaling and limits

  • Tool list: cached per worker per openapi_url + flatten_parameters; base_url and headers do not affect generation and are not in the key. A changed document is picked up within an hour.
  • SSE sessions: kept in mcp-session (10m by default, nginx_config.http.lua_shared_dict). The dict is shared by the workers of one instance, not across instances: behind a load balancer, one SSE session must stay on one instance. Streamable HTTP is stateless and has no such constraint.
  • Held connections: each SSE stream holds a connection for up to 30 minutes. Writing to a client that disconnected does not always fail in OpenResty without lua_check_client_abort, so an abandoned stream can hold its connection and two dict entries until the keepalive write fails or the lifetime ends.
  • Outbound requests: the document fetch and the tool call use resty.http directly, not the route's upstream. base_url is therefore not load-balanced or health-checked by APISIX. TLS verification follows request_uri's default.
  • $ref bounds: a document cannot make one tools/list fetch more than 8 external documents or spend more than 10 seconds resolving; relative and file references are not followed.

Compatibility

  • New plugin at priority 540, not enabled on any existing route.
  • ngx_tpl.lua now declares mcp-session when either mcp-bridge or openapi-to-mcp is enabled. Both use UUID session ids, so they can share the dict.
  • No change to core behaviour.

Alternatives considered

  • Run a converter as a separate service and proxy to it. Adds a process to deploy and scale per gateway, and a second hop whose failures surface as gateway errors. Doing it in-process keeps the route's plugins, logging and configuration in one place.
  • Extend mcp-bridge. mcp-bridge relays messages to an MCP server running as a stdio process; it does not generate tools or implement MCP methods itself, and its configuration and process lifecycle are unrelated to an OpenAPI document.
  • Answer in the access phase. Simpler, but would skip every lower-priority access plugin on the route.

Tests

Suite Covers
openapi-to-mcp.t schema, both transports end to end, variables in base_url / headers, flattened and nested parameters, header parameters reaching the API, a route without an upstream
openapi-to-mcp-openapi-{loader,ref,schema,endpoints}.t, -tools-generator.t, -json-pretty.t document parsing, $ref resolution and its bounds, schema conversion, naming, annotations
openapi-to-mcp-protocol.t, -cache.t JSON-RPC validation, version negotiation, cache keys and negative caching
openapi-to-mcp-e2e-streamable.t, -e2e-sse.t the flows above, including status codes and error objects
openapi-to-mcp-session-lifecycle.t session TTL, teardown races, reconnect after close
openapi-to-mcp-e2e-sse-multiworker.t, -concurrent.t workers(4): sessions whose GET and POST land on different workers, 16 in-flight requests per transport, every answer delivered exactly once
openapi-to-mcp-plugin-stack.t key-auth, limit-count and response-rewrite on the same route
openapi-to-mcp-swagger2.t Swagger 2.0 documents
openapi-to-mcp-interop.t the official @modelcontextprotocol/sdk client over both transports: handshake, tools/list, tools/call, ping, concurrent requests on one connection, reconnect after close()
t/cli/test_openapi_to_mcp.sh mcp-session is declared when the plugin is enabled

Documents and the API are served by a local fixture (t/lib/openapi_to_mcp_fixture.lua), including the petstore document oas-validator already uses; no external service is needed.

Documentation

docs/en/latest/plugins/openapi-to-mcp.md and docs/zh/latest/plugins/openapi-to-mcp.md, linked from the sidebar under Other protocols / 其它协议.

Unrelated CI fix included

Changing the Makefile triggers the Docker Standalone Test, which has been failing on master since Debian 11 left LTS: apt gets 404s or an expired InRelease from bullseye-security. The commit ci: build the debian-dev image on Debian 12 moves docker/debian-dev to bookworm-slim (plus xz-utils at build time and libpcre3 at runtime, which bullseye-slim pulled in implicitly), and has install-dependencies.sh write the OpenResty apt source directly on Debian, because add-apt-repository on Debian 12 writes an empty list file. Happy to split it into its own PR if preferred.

Which issue(s) this PR fixes:

N/A

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible (If not, please discuss on the APISIX mailing list first)

Turn the operations of an OpenAPI document into MCP tools and serve them
from the gateway. A route with the plugin answers the Model Context
Protocol itself, over the Streamable HTTP (stateless) or the HTTP+SSE
transport; a tools/call is sent to the API as an ordinary HTTP request and
its response is returned as the tool result. No process besides APISIX is
involved.

The plugin fetches the document (JSON or YAML, OpenAPI 3.x, Swagger 2.0 on
a best-effort basis), resolves internal and http(s) $refs within fixed
bounds, and caches the generated tool list per route for an hour. Tool
arguments are validated against the generated input schema before the API
is called.

The response is produced in before_proxy rather than access, so every
plugin configured on the route still runs first. SSE sessions live in the
mcp-session shared dict, which mcp-bridge already declares, so the stream
and the message requests of one session can be served by different
workers.

The official MCP client SDK drives both transports in
openapi-to-mcp-interop.t, and the multi-worker and concurrency suites run
under four workers.
Debian 11 left LTS at the end of August and its security archive is being
wound down: from the GitHub runners, apt now gets 404s for packages the
bullseye-security index still lists, or an expired InRelease, so the
Docker Standalone Test fails on master whenever it is triggered.

Move both stages to bookworm-slim. That needs xz-utils to unpack the
wasmtime archive in the runtime build and libpcre3 at runtime for
rex_pcre, which bullseye-slim happened to pull in. On Debian 12,
add-apt-repository writes an empty list file for the OpenResty repository,
so install-dependencies.sh writes the entry itself there.

Built locally and passed t/cli/test_standalone_docker.sh against the image.
@AlinsRan
AlinsRan marked this pull request as ready for review September 15, 2026 00:14

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four non-blocking observations for a second look. These comments do not block merging this PR. Please double-check the behavior described in each comment and decide whether it should be fixed in this PR or handled in a follow-up.


local CACHE_VERSION = "1"

local lru = core.lrucache.new({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: cache expiry behavior

With a constant CACHE_VERSION and invalid_stale unset, core.lrucache revives expired entries whose version still matches instead of invoking build_tools again. In a targeted probe using the existing cache wrapper with a mocked clock and loader, calls after 3,601 and 7,202 seconds still returned the original tools, with only one fetch. A document updated at the same URL can therefore stay stale beyond the advertised one-hour TTL, until eviction or worker restart.

This does not block merging the PR. Please double-check the intended refresh policy and decide whether to fix this behavior, for example by invalidating expired entries and adding a same-URL refresh regression test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in a6b4674. core.lrucache re-arms an expired entry whose version still matches unless invalid_stale is set, and CACHE_VERSION never changes, so a document updated at the same URL was never fetched again. The cache now sets invalid_stale = true. openapi-to-mcp-cache.t TEST 6 loads the cache with a one-second TTL, serves a document that changes on every fetch, and checks the tool list is rebuilt once after expiry (it fails without the fix).

out[#out + 1] = {
method = method,
path = path,
operation = operation,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: Path Item parameter inheritance

Only operation is forwarded here, and the generator reads operation.parameters; parameters declared on the enclosing Path Item are not merged in. For example, a required id declared under paths["/pets/{id}"].parameters is omitted from the tool input schema, and a generated call can retain /pets/{id} in the request URL. OpenAPI defines these parameters as applying to all operations on that path, with operation-level overrides matched by name and location: Path Item Object.

This does not block merging the PR. Please double-check whether Path Item parameters should be supported and decide whether to add inheritance, override handling, and corresponding tests here or in a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a6b4674. endpoints.extract() now merges the Path Item's parameters into each operation: the operation's own parameters first, then the inherited ones it does not override, matched by name + in. The operation table in the document is left untouched. Covered by openapi-to-mcp-openapi-endpoints.t TEST 7/8 (override and pure inheritance) and end to end in openapi-to-mcp-e2e-streamable.t TEST 18-20: the inherited required id appears in the input schema and a call reaches /pets/5.

Comment thread apisix/plugins/openapi-to-mcp/tools/handler.lua Outdated
Comment thread apisix/plugins/openapi-to-mcp/tools/handler.lua Outdated
… OpenAPI defines

- Cache: core.lrucache revives an expired entry whose version still matches
  unless invalid_stale is set, and the version never changes here, so a
  document updated at the same URL was never fetched again. Set it, and test
  the refresh with a one-second TTL.
- Parameters declared on a Path Item are now inherited by its operations; an
  operation parameter with the same name and location overrides them.
- Query parameters are serialized by their style and explode instead of
  bracket notation: form exploded (the default) repeats arrays and spreads
  objects, and form unexploded, spaceDelimited, pipeDelimited and deepObject
  follow the Parameter Object's table.
- A request body is sent with the media type the operation declares unless
  the route's headers already set Content-Type.
Comment thread apisix/plugins/openapi-to-mcp.lua Outdated
nic-6443
nic-6443 previously approved these changes Sep 16, 2026
…g a placeholder

handle_upstream() runs before_proxy and returns as soon as
ctx.bypass_nginx_upstream is set, which is what ai-proxy does. The plugin
answers in before_proxy, so it no longer has to invent an upstream just to
get there: the placeholder pointing at 127.0.0.1:1 is gone, and so is the
dependency on apisix.upstream.

A route with the plugin still needs no upstream of its own; the new test
covers that for the SSE transport, alongside the existing streamable one.

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@AlinsRan
AlinsRan merged commit e564adb into apache:master Sep 16, 2026
30 of 32 checks passed
@AlinsRan
AlinsRan deleted the feat/openapi-to-mcp branch September 16, 2026 10:05
AlinsRan added a commit to AlinsRan/apisix that referenced this pull request Sep 16, 2026
t/cli/test_openapi_to_mcp.sh arrived with apache#13942 and asserts the shared dict
is absent when no MCP plugin is listed, which is the behaviour this branch
removes: /apisix/plugins in etcd can enable openapi-to-mcp or mcp-bridge after
nginx.conf has been rendered, and a shared dict cannot be added then. The case
now asserts the dict is there whatever the config file lists, matching what
test_http_config.sh and test_stream_config.sh already do.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants