openapi-to-mcp
Description#
The openapi-to-mcp Plugin exposes an existing HTTP API to Model Context Protocol (MCP) clients, such as LLM agents, without changing the API. It fetches the API's OpenAPI document, generates one MCP tool per operation, and answers the MCP protocol itself. When a client calls a tool, the Plugin sends the corresponding HTTP request to the API and returns the response as the tool result.
The MCP server runs inside APISIX. No additional process or service is required.
The Plugin supports:
- The Streamable HTTP transport (stateless) and the HTTP+SSE transport.
- MCP protocol versions
2024-10-07,2024-11-05,2025-03-26,2025-06-18and2025-11-25, negotiated duringinitialize. - The
initialize,ping,tools/listandtools/callmethods. - OpenAPI 3.x documents in JSON or YAML. Internal and
http(s)$refreferences are resolved. Swagger 2.0 documents are read on a best-effort basis:in: bodyandin: formDataparameters are not turned into tool inputs.
Attributes#
| Name | Type | Required | Default | Valid values | Description |
|---|---|---|---|---|---|
| transport | string | False | sse | [sse, streamable_http] | MCP transport served on the Route. |
| openapi_url | string | True | URL of the OpenAPI document. The document is fetched on the first request and the generated tools are cached for an hour. The response must be an OpenAPI or Swagger document with a paths object; anything else is reported as an error on every MCP request. | ||
| base_url | string | True | Base URL of the API the tools call. The path of each operation is appended to it. Supports APISIX variables and NGINX variables, for example http://${http_x_backend}. | ||
| headers | object | False | Headers added to every request sent to the API. Values support variables, for example "Authorization": "Bearer ${http_x_api_token}". | ||
| flatten_parameters | boolean | False | false | When false, the tool input nests parameters under pathParameters, queryParameters and headerParameters. When true, they are placed directly in the input object. | |
| max_response_body_size | integer | False | 1048576 | >= 1024 | Maximum size, in bytes, of an upstream response read into a tool result. A larger response fails the call with RESPONSE_TOO_LARGE instead of being buffered. A tool result over 256 KiB is returned as compact JSON rather than indented. |
| max_document_size | integer | False | 4194304 | >= 1024 | Maximum size, in bytes, of the OpenAPI document, and of any document an http(s) $ref pulls in. A larger document fails the Route rather than being read into the worker. |
| max_expanded_nodes | integer | False | 50000 | >= 1000 | Maximum number of nodes one $ref expansion may produce in the tool input schemas. Past it, every $ref still being expanded and every one after it degrades to a generic object, which a very large document can reach legitimately. Only paths is expanded, so the document's other sections do not count towards it. |
| allowed_ref_hosts | array[string] | False | Hosts an http(s) $ref inside the document may point at, besides the origin openapi_url itself was fetched from. Each entry is a hostname or a *.example.com wildcard, optionally followed by :port; without a port it matches any port on that host. | ||
| allowed_origins | array[string] | False | Origin header values accepted on MCP requests, written as scheme://host[:port]. When set, the list governs on its own; ["*"] accepts any origin. When unset, an Origin is accepted only if its host is a literal host the Route declares in host/hosts -- a *.example.com entry does not count -- or if both it and the request are on a loopback address. The default check also requires matching schemes and effective ports. A request with no Origin header is always accepted. |
Tool call arguments are validated against the generated input schema before the API is called, and then filtered to the parameters the operation declares: an argument the document does not mention is dropped rather than sent. A call to an unknown tool, or with invalid arguments, returns a result with isError set to true. Every default declared in the document is filled in before that validation, so a parameter or a body property that is required and has a default may be omitted by the client; an argument the client does send is never replaced by the default.
When a tool is called, the Plugin builds the request from the operation:
- Parameters declared on the Path Item apply to every operation under it; an operation parameter with the same name and location overrides them.
- Query parameters are serialized according to their
styleandexplode, as defined by the OpenAPI Parameter Object. With the defaults (form, exploded),tags: ["a", "b"]is sent astags=a&tags=b-- not astags[]=a&tags[]=b, and an array parameter declaredexplode: falseis sent astags=a,b.spaceDelimited,pipeDelimitedanddeepObjectare supported. An API that expects the bracket form has to be reached through a Plugin that rewrites the query string. - A request body is sent with the media type the operation declares, unless
headerssetsContent-Type. - Header parameters are applied before the Plugin's own
headers, so a tool call cannot replace a credential the route adds, and a header whose value contains a newline is dropped.
For the SSE transport, variables in base_url and in headers are resolved on the request that opens the stream, and the resolved values are used for every message of that session. This is what makes a configuration such as "Authorization": "Bearer ${http_x_api_token}" usable over SSE: the message requests that follow carry only the session id, so there is nothing left to resolve from at that point. Those resolved values are kept with the session record in the mcp-session shared dict for the life of the session, so a credential a caller supplied that way lives in gateway memory for up to 30 minutes; a configuration with no variable in it stores nothing.
For the SSE transport, sessions are kept in the mcp-session shared dict, so the stream and the message requests of one session may be handled by different worker processes. Sessions are local to one APISIX instance: when several instances run behind a load balancer, the requests of an SSE session must reach the same instance. The Streamable HTTP transport is stateless and has no such requirement. A session belongs to the Route that issued it, and to the consumer authenticated on it, so a session id cannot be used on another Route.
Each SSE stream holds a connection for up to 30 minutes, and a client that disappears without closing the connection is only noticed when a write to it fails. Configure limit-conn on an SSE Route that is reachable by untrusted clients.
Failed tool-list builds are cached for five seconds per worker. Requests hitting that negative cache still receive an error, but do not fetch the document or repeat the build-error log. After expiry, the next request retries.
Security considerations#
- Origin validation protects browser access against DNS rebinding. The defaults trust literal Route hosts or loopback addresses, and require the request and origin to use the same scheme and effective port (
80for HTTP,443for HTTPS when omitted). Wildcard Route hosts provide no trust anchor. Configureallowed_originsfor other trusted origins, including when TLS terminates before APISIX. Requests withoutOriginare accepted; this check does not replace authentication. - The OpenAPI document is an input, not a trusted configuration. Tool names, descriptions and schemas come from it and are read by the model, so a document host that is compromised can steer the agents using the Route. Point
openapi_urlat a source you control. base_urlshould not be built from client-controlled variables.http://${http_x_backend}lets the caller choose where the tool call goes; use a fixed host, or a variable the gateway itself sets.- An
http(s)$refis followed only to the document's own scheme, host and port by default, so a document served from127.0.0.1cannot reach another port on that same address. Addallowed_ref_hoststo allow more, and keep internal addresses out of that list. - The Plugin's
headersreach every operation in the document. Expose only operations intended to share those credentials.consumer-restrictioncontrols consumer access to the Route; it does not authorize individual MCP tools. - The Plugin's
headerswin over a header the caller supplies, whichever case each is spelled in, so a tool call cannot replace a credential the gateway adds. A call also cannot set the headers that frame the request or belong to the connection --Transfer-Encoding,Content-Length,Host,Connection,Upgrade,Expectand the other hop-by-hop ones -- even where the document declares them as header parameters.
Example usage#
The examples below use a Route with the ID mcp. An admin key is required for the Admin API calls:
admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"//g')
Serve an API over Streamable HTTP#
Create a Route that serves the tools of the Swagger Petstore API:
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${admin_key}" \
-d '{
"id": "mcp",
"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"
}
}
}'
List the tools:
curl "http://127.0.0.1:9080/mcp" -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
The response is a single SSE event carrying the JSON-RPC result:
event: message
data: {"result":{"tools":[{"name":"updatePet","description":"Update an existing pet by Id", ...}]},"jsonrpc":"2.0","id":1}
Call a tool:
curl "http://127.0.0.1:9080/mcp" -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "findPetsByStatus",
"arguments": { "queryParameters": { "status": "sold" } }
}
}'
The tool result carries the status, status text, headers and body the API returned, as JSON text:
event: message
data: {"result":{"content":[{"type":"text","text":"{\n \"status\": 200,\n \"statusText\": \"OK\", ..."}]},"jsonrpc":"2.0","id":2}
An MCP client connects to http://127.0.0.1:9080/mcp using its Streamable HTTP transport.
Serve an API over SSE#
With transport set to sse, or left unset, a client opens the stream with a GET request. The first event tells it where to send its messages:
curl -N "http://127.0.0.1:9080/mcp"
event: endpoint
data: /mcp?sessionId=4c9b0a4e-1bb0-4f4d-9b0b-2f3c3e0f7a51
The client then POSTs each JSON-RPC message to that endpoint, receives 202 Accepted, and reads the answer from the stream.
Forward credentials to the API#
Pass the caller's token through to the API by reading it from a request header:
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${admin_key}" \
-d '{
"id": "mcp",
"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}"
}
}
}
}'
Other Plugins on the Route keep working. For example, key-auth or limit-count run before the MCP request is answered, and a request they reject never reaches the tools.
Delete Plugin#
To remove the openapi-to-mcp Plugin, delete it from the Route configuration. APISIX reloads the configuration automatically:
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${admin_key}" \
-d '{
"id": "mcp",
"uri": "/mcp",
"plugins": {},
"upstream": {
"type": "roundrobin",
"nodes": {
"127.0.0.1:1980": 1
}
}
}'