Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apisix/plugins/openapi-to-mcp.lua
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
local core = require("apisix.core")
local streamable_http = require("apisix.plugins.openapi-to-mcp.transport.streamable_http")
local mcp_sse = require("apisix.plugins.openapi-to-mcp.transport.sse")
local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref")
local ngx = ngx
local pairs = pairs
local ipairs = ipairs
Expand Down Expand Up @@ -101,6 +102,16 @@ local schema = {
uniqueItems = true,
items = { type = "string", minLength = 1 },
},
allowed_base_url_hosts = {
description = "Hosts the resolved base_url may point at. Each " ..
"entry is a hostname or a `*.example.com` wildcard, optionally " ..
"followed by `:port`; without a port it matches any port. When " ..
"set, a request whose base_url resolves elsewhere is rejected.",
type = "array",
minItems = 1,
uniqueItems = true,
items = { type = "string", minLength = 1 },
},
allowed_origins = {
description = "Origin header values accepted on MCP requests, as " ..
"scheme://host[:port]. A request with no Origin header is always " ..
Expand Down Expand Up @@ -170,6 +181,30 @@ local function resolve_conf(conf, ctx)
end


-- base_url may be built from request variables, so the host it resolves to is
-- only known per request.
local function base_url_allowed(conf, base_url)
if not conf.allowed_base_url_hosts then
return true
end

-- neither the URL nor the host is logged: after resolution they may carry
-- values taken from the request
local origin, host, port = ref.origin_of(base_url)
if not origin then
core.log.error("resolved base_url is not an http(s) URL")
return false, "invalid base_url"
end
for _, entry in ipairs(conf.allowed_base_url_hosts) do
if ref.host_entry_matches(entry, host, port) then
return true
end
end
core.log.error("resolved base_url host is not in allowed_base_url_hosts")
return false, "base_url host is not allowed"
end


function _M.access(conf, ctx)
if conf.transport == "streamable_http" then
local base_url, headers = resolve_conf(conf, ctx)
Expand Down Expand Up @@ -376,6 +411,11 @@ function _M.before_proxy(conf, ctx)
"allowed_origins on this route to accept it." })
end

local allowed, message = base_url_allowed(conf, opts.base_url)
if not allowed then
return core.response.exit(400, { message = message })
end

if opts.transport == "sse" then
return mcp_sse.handle(ctx, opts)
end
Expand Down
3 changes: 3 additions & 0 deletions apisix/plugins/openapi-to-mcp/openapi/ref.lua
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ local function entry_matches(entry, host, port)
end


_M.host_entry_matches = entry_matches


local function host_allowed(url, ctx)
local origin, host, port = origin_of(url)
if not origin then
Expand Down
1 change: 1 addition & 0 deletions apisix/plugins/openapi-to-mcp/server.lua
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ local function to_wire_tools(tools)
name = tool.name,
description = tool.description,
inputSchema = tool.input_schema,
outputSchema = tool.output_schema,
annotations = tool.annotations,
}
end
Expand Down
84 changes: 84 additions & 0 deletions apisix/plugins/openapi-to-mcp/tools/generator.lua
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,87 @@ local function extract_custom_annotations(operation, op_id)
end


-- Which response the output schema is taken from: the success answer, most
-- specific first. `default` is not a success response and is not considered.
local function success_response(responses)
-- appended one by one: a nil in the middle of a table constructor makes
-- ipairs stop at it, and an operation that declares only a 201 would then
-- be read as declaring nothing
local ordered = {}
if responses["200"] ~= nil then
ordered[#ordered + 1] = responses["200"]
end
if responses["201"] ~= nil then
ordered[#ordered + 1] = responses["201"]
end

local others = {}
for code in pairs(responses) do
if type(code) == "string" and #code == 3 and str_sub(code, 1, 1) == "2"
and code ~= "200" and code ~= "201" and code ~= "2XX" then
others[#others + 1] = code
end
end
table_sort(others)
for _, code in ipairs(others) do
ordered[#ordered + 1] = responses[code]
end

if responses["2XX"] ~= nil then
ordered[#ordered + 1] = responses["2XX"]
end
return ordered
end


-- Validate the body the way the client will: the MCP SDKs check
-- `structuredContent` against the advertised schema with a validator that does
-- not assert `format`, so asserting it here would reject bodies the client
-- would have accepted. The keyword is a string; a table under that key is a
-- property named "format" and is kept.
local function without_formats(schema)
if type(schema) ~= "table" then
return schema
end
local out = {}
for key, value in pairs(schema) do
if key ~= "format" or type(value) ~= "string" then
out[key] = without_formats(value)
end
end
return out
end


-- The JSON object a successful call returns, when the document describes one.
-- Only a plain object with properties qualifies: an array, a composition the
-- document leaves unmerged (`allOf`, `oneOf`) and a bare `type: object` carry
-- nothing a client could bind to, and advertising them would only promise
-- structure that is not there.
local function build_output_schema(operation)
local responses = operation.responses
if type(responses) ~= "table" then
return nil
end

for _, response in ipairs(success_response(responses)) do
if type(response) == "table" and type(response.content) == "table" then
local json_content = response.content["application/json"]
if type(json_content) == "table" and type(json_content.schema) == "table" then
local schema = oas_schema.to_json_schema(json_content.schema)
if type(schema) == "table" and schema.type == "object"
and type(schema.properties) == "table" and next(schema.properties) ~= nil
then
return schema
end
end
end
end

return nil
end


-- A remote OpenAPI document controls param.name. Using a nil name as a table
-- key raises "table index is nil" and takes down generation for the whole spec,
-- so a nameless parameter is dropped instead.
Expand Down Expand Up @@ -405,6 +486,7 @@ function _M.generate(spec, path_order, opts)
annotations = nil
end

local output_schema = build_output_schema(operation)
tools[#tools + 1] = {
name = name,
description = description,
Expand All @@ -414,6 +496,8 @@ function _M.generate(spec, path_order, opts)
parameters = params,
execution_parameters = execution_parameters,
request_body_content_type = content_type,
output_schema = output_schema,
output_check_schema = output_schema and without_formats(output_schema),
annotations = annotations,
}
end
Expand Down
52 changes: 49 additions & 3 deletions apisix/plugins/openapi-to-mcp/tools/handler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,35 @@ local function decode_body(body)
end


local function structured_result(tool, status, data)
-- Only a successful answer carries the structure the schema describes; an
-- error body is a different shape and is reported as an error result,
-- which MCP exempts from the structured-content requirement.
if status < 200 or status >= 300 or type(data) ~= "table" then
return nil
end
-- an array body cannot satisfy an object schema, and would be encoded as
-- an object by structuredContent
if data[1] ~= nil then
return nil
end

local ok = core.schema.check(tool.output_check_schema, data)
if not ok then
return nil
end

local text, err = json_pretty.encode(data)
if not text then
text = "failed to encode response: " .. tostring(err)
end
return {
content = { { type = "text", text = text } },
structuredContent = data,
}
end


local function text_result(payload, is_error)
local text, err = json_pretty.encode(payload)
if not text then
Expand Down Expand Up @@ -445,12 +474,29 @@ function _M.call(tool, arguments, opts)
}, true)
end

return text_result({
local data = decode_body(res.body)
local envelope = {
status = res.status,
statusText = res.reason or "",
headers = lower_headers(res.headers),
data = decode_body(res.body),
})
data = data,
}

if not tool.output_schema then
return text_result(envelope)
end

local structured = structured_result(tool, res.status, data)
if structured then
return structured
end

-- The tool advertised an output schema and this answer cannot satisfy it.
-- Returning the envelope on its own would leave a client that enforces the
-- schema with a protocol error, so the envelope is returned as an error
-- result instead, which is the case MCP allows to carry no structured
-- content.
return text_result(envelope, true)
end


Expand Down
7 changes: 6 additions & 1 deletion docs/en/latest/plugins/openapi-to-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ The Plugin supports:
| 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_base_url_hosts | array[string] | False | | | Hosts the resolved `base_url` may point at, in the same form as `allowed_ref_hosts`. When set, a request whose `base_url` resolves to another host or port, or to a URL that is not `http(s)`, is rejected with HTTP 400 before any tool is listed or called. |
| 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.
Expand All @@ -66,6 +67,10 @@ When a tool is called, the Plugin builds the request from the operation:
* A request body is sent with the media type the operation declares, unless `headers` sets `Content-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.

When the document describes the success response of an operation as a JSON object, the generated tool advertises that schema as `outputSchema`. The response is taken from `200`, then `201`, then any other explicit `2xx`, then `2XX`; only an `application/json` schema that is an object with properties qualifies, so an array, a `default` response and a composition the document leaves unmerged advertise nothing.

A call to such a tool returns the API's body as `structuredContent`, with the same body as the text block. When the answer cannot satisfy the schema -- a status outside `2xx`, a body that is not a JSON object, or one that does not validate -- the result carries the usual `{status, statusText, headers, data}` envelope with `isError` set to `true`, which is the case MCP allows to carry no structured content. Tools that advertise no `outputSchema` always return that envelope, whatever the status.

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.
Expand All @@ -78,7 +83,7 @@ Failed tool-list builds are cached for five seconds per worker. Requests hitting

* **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 (`80` for HTTP, `443` for HTTPS when omitted). Wildcard Route hosts provide no trust anchor. Configure `allowed_origins` for other trusted origins, including when TLS terminates before APISIX. Requests without `Origin` are 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_url` at a source you control.
* **`base_url` should 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.
* **`base_url` should 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. Where a variable is needed, set `allowed_base_url_hosts` to the hosts it may resolve to. The rejection and its log line name neither the URL nor the host, since a resolved `base_url` can carry values taken from the request.
* **An `http(s)` `$ref` is followed only to the document's own scheme, host and port** by default, so a document served from `127.0.0.1` cannot reach another port on that same address. Add `allowed_ref_hosts` to allow more, and keep internal addresses out of that list.
* **The Plugin's `headers` reach every operation in the document.** Expose only operations intended to share those credentials. [`consumer-restriction`](consumer-restriction.md) controls consumer access to the Route; it does not authorize individual MCP tools.
* **The Plugin's `headers` win 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`, `Expect` and the other hop-by-hop ones -- even where the document declares them as header parameters.
Expand Down
Loading
Loading