feat: add openapi-to-mcp plugin - #13942
Conversation
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.
membphis
left a comment
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
… 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.
…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.
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.
Description
Proposal:
openapi-to-mcp, serve an HTTP API to MCP clients from its OpenAPI documentBackground
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
Non-goals
-32601.Mcp-Session-Id), resumable streams and server-initiated messages. The Streamable HTTP transport is stateless.in: bodyandin: formDataparameters 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 } } }transportssesseorstreamable_httpopenapi_urlbase_urlheadersflatten_parametersfalsepathParameters/queryParameters/headerParameters, or put them at the top level of the tool inputOne 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'sdescription, else itssummary, elseExecutes <METHOD> <path>, andinputSchemais 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 anx-mcp-annotationsextension.Design overview
openapi-to-mcp.luatransport/streamable_http.luatransport/sse.luasession.luamcp-sessionshared dictjsonrpc.lua,protocol.lua,server.luacache.luaopenapi/loader.luaopenapi/ref.luahttp(s)$refs, with cycle and fetch boundsopenapi/schema.lua,openapi/endpoints.luatools/generator.luatools/handler.luatools/callinto an HTTP request, and its response into a tool resultjson_pretty.luaThe modules live under
apisix/plugins/openapi-to-mcp/, named after the plugin likeai-proxy/, and apart frommcp-bridge'sapisix/plugins/mcp/.Request flow
1. Phases
The answer is produced in
before_proxy, not inaccess: exiting inaccesswould end the access chain and skip every lower-priority plugin on the route.ctx.bypass_nginx_upstream, the flagai-proxyuses, makeshandle_upstream()runbefore_proxyand return without selecting an upstream, so a route with the plugin needs noupstreamof 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.
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.
session.push()checks the session marker before and afterrpush, 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_proxydoes not return for the GET until the stream ends, so itslogphase runs when the stream closes.4.
initialize,ping,tools/listinitializeandpingneed 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/callThe 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
openapi_url+flatten_parameters;base_urlandheadersdo not affect generation and are not in the key. A changed document is picked up within an hour.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.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.resty.httpdirectly, not the route's upstream.base_urlis therefore not load-balanced or health-checked by APISIX. TLS verification followsrequest_uri's default.$refbounds: a document cannot make onetools/listfetch more than 8 external documents or spend more than 10 seconds resolving; relative and file references are not followed.Compatibility
ngx_tpl.luanow declaresmcp-sessionwhen eithermcp-bridgeoropenapi-to-mcpis enabled. Both use UUID session ids, so they can share the dict.Alternatives considered
mcp-bridge.mcp-bridgerelays 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.accessphase. Simpler, but would skip every lower-priority access plugin on the route.Tests
openapi-to-mcp.tbase_url/headers, flattened and nested parameters, header parameters reaching the API, a route without an upstreamopenapi-to-mcp-openapi-{loader,ref,schema,endpoints}.t,-tools-generator.t,-json-pretty.t$refresolution and its bounds, schema conversion, naming, annotationsopenapi-to-mcp-protocol.t,-cache.topenapi-to-mcp-e2e-streamable.t,-e2e-sse.topenapi-to-mcp-session-lifecycle.topenapi-to-mcp-e2e-sse-multiworker.t,-concurrent.tworkers(4): sessions whose GET and POST land on different workers, 16 in-flight requests per transport, every answer delivered exactly onceopenapi-to-mcp-plugin-stack.tkey-auth,limit-countandresponse-rewriteon the same routeopenapi-to-mcp-swagger2.topenapi-to-mcp-interop.t@modelcontextprotocol/sdkclient over both transports: handshake,tools/list,tools/call,ping, concurrent requests on one connection, reconnect afterclose()t/cli/test_openapi_to_mcp.shmcp-sessionis declared when the plugin is enabledDocuments and the API are served by a local fixture (
t/lib/openapi_to_mcp_fixture.lua), including the petstore documentoas-validatoralready uses; no external service is needed.Documentation
docs/en/latest/plugins/openapi-to-mcp.mdanddocs/zh/latest/plugins/openapi-to-mcp.md, linked from the sidebar under Other protocols / 其它协议.Unrelated CI fix included
Changing the
Makefiletriggers the Docker Standalone Test, which has been failing on master since Debian 11 left LTS: apt gets 404s or an expiredInReleasefrom bullseye-security. The commitci: build the debian-dev image on Debian 12movesdocker/debian-devtobookworm-slim(plusxz-utilsat build time andlibpcre3at runtime, which bullseye-slim pulled in implicitly), and hasinstall-dependencies.shwrite the OpenResty apt source directly on Debian, becauseadd-apt-repositoryon 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