From de96235f4ead0f55079aeda9bca2376f64db7c4b Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 17 Sep 2026 08:46:22 +0800 Subject: [PATCH 1/7] fix(openapi-to-mcp): apply defaults, keep SSE variables, reject non-OpenAPI documents Three fixes to the contract the plugin documents, found by driving it with the official MCP SDK client. Defaults declared in the document were applied after the arguments had been validated, so a parameter or a body property that is both `required` and has a `default` was rejected as missing and the call never reached the API. They are now filled in before validation, and an argument the client sent is never replaced. With the SSE transport, `${...}` in `base_url` or in `headers` was resolved again on every message POST. That request carries only the session id -- the endpoint handed to the client is `?sessionId=` -- so a header such as `Bearer ${http_x_api_token}` resolved to `Bearer ` and a variable base_url failed to resolve at all. The values are now resolved once, on the request that opens the stream, stored with the session and used for every message on it. Any JSON or YAML document parsed, so a route pointed at the wrong URL -- an error page, an index document, a spec that failed to render -- served an empty tool list and looked healthy. The route's own document must now declare an `openapi` or `swagger` version and a `paths` object; a document pulled in by an external `$ref` is a fragment and is still accepted as before. Adds tests for each, and documents the default handling, the SSE resolution point and the query serialization the plugin performs. --- apisix/plugins/openapi-to-mcp/cache.lua | 5 ++ .../plugins/openapi-to-mcp/openapi/loader.lua | 24 +++++ apisix/plugins/openapi-to-mcp/server.lua | 46 ++++++++++ apisix/plugins/openapi-to-mcp/session.lua | 48 +++++++++- .../plugins/openapi-to-mcp/transport/sse.lua | 31 ++++++- docs/en/latest/plugins/openapi-to-mcp.md | 8 +- docs/zh/latest/plugins/openapi-to-mcp.md | 8 +- t/lib/openapi_to_mcp_fixture.lua | 34 +++++++ t/plugin/openapi-to-mcp-e2e-sse.t | 29 ++++++ t/plugin/openapi-to-mcp-openapi-loader.t | 78 ++++++++++++++++ t/plugin/openapi-to-mcp.t | 88 +++++++++++++++++++ t/plugin/openapi_to_mcp_sse_frozen_vars.py | 58 ++++++++++++ 12 files changed, 446 insertions(+), 11 deletions(-) create mode 100644 t/plugin/openapi_to_mcp_sse_frozen_vars.py diff --git a/apisix/plugins/openapi-to-mcp/cache.lua b/apisix/plugins/openapi-to-mcp/cache.lua index 6ebaee5247ab..d5123dc326ba 100644 --- a/apisix/plugins/openapi-to-mcp/cache.lua +++ b/apisix/plugins/openapi-to-mcp/cache.lua @@ -54,6 +54,11 @@ local function build_tools(openapi_url, flatten_parameters) return nil, err end + local ok, invalid = loader.validate(spec) + if not ok then + return nil, invalid + end + local resolved = ref.resolve(spec) return generator.generate(resolved, path_order, { flatten_parameters = flatten_parameters, diff --git a/apisix/plugins/openapi-to-mcp/openapi/loader.lua b/apisix/plugins/openapi-to-mcp/openapi/loader.lua index d1f0bff7c277..029afd2d7725 100644 --- a/apisix/plugins/openapi-to-mcp/openapi/loader.lua +++ b/apisix/plugins/openapi-to-mcp/openapi/loader.lua @@ -143,6 +143,30 @@ function _M.parse(body) end +-- Whether a parsed document is an OpenAPI document at all. Any JSON or YAML +-- parses, so without this a route pointed at the wrong URL -- an error page, an +-- index document, a spec that failed to render -- comes up as a healthy MCP +-- server with an empty tool list, and nothing anywhere says why. A document +-- with no `paths` is rejected for the same reason: there is nothing to serve +-- from it. +-- +-- Only the route's own document is held to this. A document pulled in by an +-- external `$ref` is usually a fragment -- components, a single schema -- and +-- has neither key. +function _M.validate(spec) + if type(spec) ~= "table" then + return nil, "openapi spec is not an object" + end + if type(spec.openapi) ~= "string" and type(spec.swagger) ~= "string" then + return nil, "not an openapi document: no openapi or swagger version" + end + if type(spec.paths) ~= "table" then + return nil, "not an openapi document: no paths object" + end + return true +end + + function _M.fetch(url, timeout) local httpc, err = http.new() if not httpc then diff --git a/apisix/plugins/openapi-to-mcp/server.lua b/apisix/plugins/openapi-to-mcp/server.lua index 32a2536b7850..70a0260c9863 100644 --- a/apisix/plugins/openapi-to-mcp/server.lua +++ b/apisix/plugins/openapi-to-mcp/server.lua @@ -20,6 +20,7 @@ local protocol = require("apisix.plugins.openapi-to-mcp.protocol") local cache = require("apisix.plugins.openapi-to-mcp.cache") local handler = require("apisix.plugins.openapi-to-mcp.tools.handler") local ipairs = ipairs +local pairs = pairs local type = type local tostring = tostring local setmetatable = setmetatable @@ -62,6 +63,50 @@ local function tool_error(id, message) end +-- A default is copied, never shared: the schema is cached for the lifetime of +-- the tool list, and handing the same table to every call would let one call's +-- mutation leak into the next. +local function clone(value) + if type(value) ~= "table" then + return value + end + local out = {} + for key, item in pairs(value) do + out[key] = clone(item) + end + return out +end + + +-- Fill in `default`s the way a client is entitled to expect, before the +-- arguments are validated: a parameter that is `required` and carries a +-- `default` must not be reported as missing. Only objects the caller actually +-- sent are descended into, so an absent `queryParameters` is still a missing +-- required property rather than one this pass invents. +local function apply_defaults(schema, value) + if type(schema) ~= "table" or type(value) ~= "table" then + return + end + + local properties = schema.properties + if type(properties) ~= "table" then + return + end + + for name, property in pairs(properties) do + if type(property) == "table" then + if value[name] == nil then + if property.default ~= nil then + value[name] = clone(property.default) + end + else + apply_defaults(property, value[name]) + end + end + end +end + + local function handle_tools_call(request, opts, tools) local params = request.params or {} local name = params.name @@ -76,6 +121,7 @@ local function handle_tools_call(request, opts, tools) end local arguments = type(params.arguments) == "table" and params.arguments or {} + apply_defaults(tool.input_schema, arguments) local ok, err = core.schema.check(tool.input_schema, arguments) if not ok then diff --git a/apisix/plugins/openapi-to-mcp/session.lua b/apisix/plugins/openapi-to-mcp/session.lua index 6c9ef5328e3f..7bb8a53b4a49 100644 --- a/apisix/plugins/openapi-to-mcp/session.lua +++ b/apisix/plugins/openapi-to-mcp/session.lua @@ -39,14 +39,29 @@ local function store() end -function _M.create() +-- `context` is what the stream resolved when it was opened -- the base_url and +-- the headers, after `${...}` substitution. It is stored with the session +-- because a message POST carries none of the request state those variables were +-- read from: the endpoint the client is handed is "?sessionId=" and +-- nothing else. Resolving again there would silently produce empty values. +function _M.create(context) local dict, err = store() if not dict then return nil, err end + local marker = true + if type(context) == "table" then + local encoded, encode_err = core.json.encode(context) + if encoded then + marker = encoded + else + core.log.warn("failed to store MCP session context: ", encode_err) + end + end + local session_id = core.id.gen_uuid_v4() - local ok, set_err = dict:set(session_id .. ALIVE_SUFFIX, true, SESSION_TTL) + local ok, set_err = dict:set(session_id .. ALIVE_SUFFIX, marker, SESSION_TTL) if not ok then return nil, "failed to register session: " .. tostring(set_err) end @@ -54,6 +69,27 @@ function _M.create() end +-- The values frozen by _M.create(), or nil for a session that stored none. +function _M.context(session_id) + if type(session_id) ~= "string" or session_id == "" then + return nil + end + local dict = store() + if not dict then + return nil + end + local marker = dict:get(session_id .. ALIVE_SUFFIX) + if type(marker) ~= "string" then + return nil + end + local context = core.json.decode(marker) + if type(context) ~= "table" then + return nil + end + return context +end + + function _M.exists(session_id) if type(session_id) ~= "string" or session_id == "" then return false @@ -74,7 +110,13 @@ function _M.touch(session_id) if not dict then return false, err end - local ok, set_err = dict:set(session_id .. ALIVE_SUFFIX, true, SESSION_TTL) + -- Re-set the value that is already there: it carries the frozen context, + -- and writing `true` back would drop it halfway through the session. + local marker = dict:get(session_id .. ALIVE_SUFFIX) + if marker == nil then + marker = true + end + local ok, set_err = dict:set(session_id .. ALIVE_SUFFIX, marker, SESSION_TTL) if not ok then return false, "failed to refresh session: " .. tostring(set_err) end diff --git a/apisix/plugins/openapi-to-mcp/transport/sse.lua b/apisix/plugins/openapi-to-mcp/transport/sse.lua index 616fa1d70340..76f239c513df 100644 --- a/apisix/plugins/openapi-to-mcp/transport/sse.lua +++ b/apisix/plugins/openapi-to-mcp/transport/sse.lua @@ -79,7 +79,13 @@ local function handle_get(ctx, opts) }) end - local session_id, err = session.create() + -- Freeze what the stream resolved from this request; every message POST on + -- this session then reaches the upstream with the same base_url and the + -- same headers. + local session_id, err = session.create({ + base_url = opts.base_url, + headers = opts.headers, + }) if not session_id then core.log.error("failed to create MCP session: ", err) return core.response.exit(500) @@ -172,6 +178,27 @@ local function content_type_error(content_type) end +-- Answer a message with what the stream resolved, not with what this POST +-- resolves: the message endpoint carries only the session id, so `${...}` in +-- base_url or in a header would read empty here. A session created before this +-- was stored keeps the old behaviour rather than failing. +local function frozen_opts(opts, session_id) + local context = session.context(session_id) + if not context then + return opts + end + + local merged = core.table.clone(opts) + if context.base_url ~= nil then + merged.base_url = context.base_url + end + if context.headers ~= nil then + merged.headers = context.headers + end + return merged +end + + local function handle_post(ctx, opts) -- An empty or unparsable JSON body is a 400 even for a session nobody -- issued: it is rejected before the session is looked up. @@ -211,7 +238,7 @@ local function handle_post(ctx, opts) return core.response.exit(400, jsonrpc.invalid_message()) end - local response = server.handle(request, opts) + local response = server.handle(request, frozen_opts(opts, session_id)) if response then local encoded, encode_err = core.json.encode(response) if not encoded then diff --git a/docs/en/latest/plugins/openapi-to-mcp.md b/docs/en/latest/plugins/openapi-to-mcp.md index 33d8d09d72d4..74ef2b757e44 100644 --- a/docs/en/latest/plugins/openapi-to-mcp.md +++ b/docs/en/latest/plugins/openapi-to-mcp.md @@ -47,19 +47,21 @@ The Plugin supports: | 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. | +| 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](../apisix-variable.md) and [NGINX variables](http://nginx.org/en/docs/varindex.html), 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. | -Tool call arguments are validated against the generated input schema before the API is called. A call to an unknown tool, or with invalid arguments, returns a result with `isError` set to `true`. +Tool call arguments are validated against the generated input schema before the API is called. 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 `style` and `explode`, as defined by the [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.0.3#style-values). With the defaults (`form`, exploded), `tags: ["a", "b"]` is sent as `tags=a&tags=b`. `spaceDelimited`, `pipeDelimited` and `deepObject` are supported. +* Query parameters are serialized according to their `style` and `explode`, as defined by the [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.0.3#style-values). With the defaults (`form`, exploded), `tags: ["a", "b"]` is sent as `tags=a&tags=b` -- not as `tags[]=a&tags[]=b`, and an array parameter declared `explode: false` is sent as `tags=a,b`. `spaceDelimited`, `pipeDelimited` and `deepObject` are 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 `headers` sets `Content-Type`. +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. + 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. ## Example usage diff --git a/docs/zh/latest/plugins/openapi-to-mcp.md b/docs/zh/latest/plugins/openapi-to-mcp.md index bbed2d9f06bc..d9a4d7aefa5e 100644 --- a/docs/zh/latest/plugins/openapi-to-mcp.md +++ b/docs/zh/latest/plugins/openapi-to-mcp.md @@ -47,19 +47,21 @@ MCP 服务运行在 APISIX 内部,不需要额外的进程或服务。 | 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | |------|------|--------|--------|--------|------| | transport | string | 否 | `sse` | [`sse`, `streamable_http`] | 路由上提供的 MCP 传输方式。 | -| openapi_url | string | 是 | | | OpenAPI 文档的 URL。文档在首次请求时获取,生成的工具缓存一小时。 | +| openapi_url | string | 是 | | | OpenAPI 文档的 URL。文档在首次请求时获取,生成的工具缓存一小时。返回内容必须是带 `paths` 对象的 OpenAPI 或 Swagger 文档,否则每次 MCP 请求都会返回错误。 | | base_url | string | 是 | | | 工具调用的 API 基础地址,每个操作的路径拼接在其后。支持 [APISIX 变量](../apisix-variable.md) 和 [NGINX 变量](http://nginx.org/en/docs/varindex.html),例如 `http://${http_x_backend}`。 | | headers | object | 否 | | | 发往 API 的每个请求都会携带的请求头。值支持变量,例如 `"Authorization": "Bearer ${http_x_api_token}"`。 | | flatten_parameters | boolean | 否 | `false` | | 为 `false` 时,工具输入中的参数分别嵌套在 `pathParameters`、`queryParameters` 和 `headerParameters` 下;为 `true` 时,参数直接放在输入对象的顶层。 | -调用 API 之前,插件会按生成的输入 Schema 校验工具参数。调用不存在的工具或参数不合法时,返回 `isError` 为 `true` 的结果。 +调用 API 之前,插件会按生成的输入 Schema 校验工具参数。调用不存在的工具或参数不合法时,返回 `isError` 为 `true` 的结果。校验之前会先填入文档中声明的 `default`,因此同时带有 `required` 和 `default` 的参数或请求体属性可以由客户端省略;客户端显式传入的参数不会被默认值覆盖。 调用工具时,插件根据操作定义构造请求: * 声明在 Path Item 上的参数适用于该路径下的所有操作;操作中同名且位置相同的参数会覆盖它。 -* 查询参数按其 `style` 和 `explode` 序列化,规则见 [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.0.3#style-values)。使用默认值(`form`,展开)时,`tags: ["a", "b"]` 发送为 `tags=a&tags=b`。同时支持 `spaceDelimited`、`pipeDelimited` 和 `deepObject`。 +* 查询参数按其 `style` 和 `explode` 序列化,规则见 [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.0.3#style-values)。使用默认值(`form`,展开)时,`tags: ["a", "b"]` 发送为 `tags=a&tags=b`,而不是 `tags[]=a&tags[]=b`;声明为 `explode: false` 的数组参数发送为 `tags=a,b`。同时支持 `spaceDelimited`、`pipeDelimited` 和 `deepObject`。如果 API 要求方括号形式,需要另外通过改写查询字符串的插件处理。 * 请求体使用操作中声明的媒体类型发送,除非 `headers` 中已设置 `Content-Type`。 +使用 SSE 传输时,`base_url` 和 `headers` 中的变量在打开事件流的那次请求上解析,解析结果用于该会话的所有消息。`"Authorization": "Bearer ${http_x_api_token}"` 这类配置因此在 SSE 下同样可用:后续的消息请求只携带会话 ID,此时已无从解析变量。 + 使用 SSE 传输时,会话保存在共享字典 `mcp-session` 中,因此同一会话的事件流请求和消息请求可以由不同的 worker 进程处理。会话只在单个 APISIX 实例内有效:多个实例部署在负载均衡之后时,同一 SSE 会话的请求必须到达同一实例。Streamable HTTP 传输是无状态的,没有这一限制。 ## 使用示例 diff --git a/t/lib/openapi_to_mcp_fixture.lua b/t/lib/openapi_to_mcp_fixture.lua index 7a96d7e93f1e..428af2c4d016 100644 --- a/t/lib/openapi_to_mcp_fixture.lua +++ b/t/lib/openapi_to_mcp_fixture.lua @@ -90,6 +90,40 @@ local DOCUMENTS = { } } }, }, + -- a required parameter and a required body property, both with a default + ["/defaults.json"] = { + openapi = "3.0.0", + info = { title = "Defaults", version = "1" }, + paths = { + ["/items"] = { + get = { + operationId = "listItems", + parameters = { + { name = "status", ["in"] = "query", required = true, + schema = { type = "string", default = "available" } }, + { name = "limit", ["in"] = "query", + schema = { type = "integer", default = 10 } }, + }, + }, + post = { + operationId = "createItem", + requestBody = { required = true, content = { ["application/json"] = { + schema = { + type = "object", + required = { "mode" }, + properties = { + mode = { type = "string", default = "fast" }, + n = { type = "integer", default = 3 }, + }, + } } } }, + }, + }, + }, + }, + + -- parses as JSON and is not an OpenAPI document + ["/notaspec.json"] = { this = "is not an openapi document" }, + -- query parameters that are an object and an array ["/objq.json"] = { openapi = "3.0.0", diff --git a/t/plugin/openapi-to-mcp-e2e-sse.t b/t/plugin/openapi-to-mcp-e2e-sse.t index ed1ac52a5a9f..21a195c051b7 100644 --- a/t/plugin/openapi-to-mcp-e2e-sse.t +++ b/t/plugin/openapi-to-mcp-e2e-sse.t @@ -80,3 +80,32 @@ post status: 202 protocolVersion: 2024-11-05 serverInfo: openapi2mcp-sse 0.0.1 unknown session status: 404 + + + +=== TEST 3: an sse route whose header carries a request variable +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "sse", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/openapi.json", + headers = { Authorization = "Bearer ${http_x_user}" }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 4: the value resolved when the stream opened is the one used +--- exec +python3 t/plugin/openapi_to_mcp_sse_frozen_vars.py /mcp 2>&1 +--- response_body +post status: 202 +upstream saw: Bearer alice diff --git a/t/plugin/openapi-to-mcp-openapi-loader.t b/t/plugin/openapi-to-mcp-openapi-loader.t index 6569ee775727..632655e336b3 100644 --- a/t/plugin/openapi-to-mcp-openapi-loader.t +++ b/t/plugin/openapi-to-mcp-openapi-loader.t @@ -199,3 +199,81 @@ true --- response_body true 1,2,3 + + + +=== TEST 9: validate accepts an openapi document +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local spec = loader.parse('{"openapi":"3.0.0","paths":{"/a":{"get":{}}}}') + local ok, err = loader.validate(spec) + ngx.say(ok, " ", err) + } + } +--- response_body +true nil + + + +=== TEST 10: validate accepts a swagger 2.0 document +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local spec = loader.parse('{"swagger":"2.0","paths":{"/a":{"get":{}}}}') + local ok, err = loader.validate(spec) + ngx.say(ok, " ", err) + } + } +--- response_body +true nil + + + +=== TEST 11: validate rejects a document that carries no version +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local spec = loader.parse('{"this":"is not an openapi document"}') + local ok, err = loader.validate(spec) + ngx.say(ok, " ", err) + } + } +--- response_body +nil not an openapi document: no openapi or swagger version + + + +=== TEST 12: validate rejects a document that carries no paths +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local spec = loader.parse('{"openapi":"3.0.0","components":{"schemas":{}}}') + local ok, err = loader.validate(spec) + ngx.say(ok, " ", err) + } + } +--- response_body +nil not an openapi document: no paths object + + + +=== TEST 13: an external $ref document is not held to the document check +--- config + location /t { + content_by_lua_block { + -- a fragment pulled in by $ref has neither openapi nor paths, and + -- parse() must keep accepting it + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local spec, _, err = loader.parse('{"components":{"schemas":{"A":{"type":"string"}}}}') + ngx.say(err == nil) + ngx.say(spec.components.schemas.A.type) + } + } +--- response_body +true +string diff --git a/t/plugin/openapi-to-mcp.t b/t/plugin/openapi-to-mcp.t index 536aaf15342d..58b51d1dc7ad 100644 --- a/t/plugin/openapi-to-mcp.t +++ b/t/plugin/openapi-to-mcp.t @@ -628,3 +628,91 @@ event:\s*endpoint data:\s*/mcp\?sessionId=.* --- no_error_log failed to fetch upstream + + + +=== TEST 33: a route whose document has defaults on required members +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/defaults.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 34: a required query parameter that has a default may be omitted +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"listItems","arguments":{"queryParameters":{}}}}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/seen_path.*items\?limit=10&status=available/ + + + +=== TEST 35: a required body property that has a default may be omitted +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"createItem","arguments":{"requestBody":{}}}}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/seen_body.*mode.*fast/ + + + +=== TEST 36: an argument that is sent still wins over the default +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"listItems","arguments":{"queryParameters":{"status":"sold"}}}}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/seen_path.*items\?limit=10&status=sold/ + + + +=== TEST 37: a route pointed at a document that is not an OpenAPI document +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/notaspec.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 38: it is reported as an error instead of an empty tool list +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/"code":-32603.*not an openapi document: no openapi or swagger version/ +--- error_log +not an openapi document diff --git a/t/plugin/openapi_to_mcp_sse_frozen_vars.py b/t/plugin/openapi_to_mcp_sse_frozen_vars.py new file mode 100644 index 000000000000..541b67288a68 --- /dev/null +++ b/t/plugin/openapi_to_mcp_sse_frozen_vars.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""What a ${...} in base_url or in a header resolves to for an SSE session. + +The GET that opens the stream carries the variable's source -- here the +X-User request header. The message POST that follows carries only the session +id, so a value resolved at that point would be empty: the stream has to hand +its own resolution to every message on the session. +""" +import json +import sys + +import openapi_to_mcp_harness as h + + +def main(): + route = sys.argv[1] + stream = h.SseStream(h.GATEWAY, route, {"X-User": "alice"}) + if not stream.open(timeout=4): + print("FAIL no endpoint event (%s)" % stream.error) + return + + # no X-User on this request: the endpoint the client was handed carries + # nothing but the session id + status, _, _ = h.post_json(h.GATEWAY, stream.endpoint, { + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "getPet", "arguments": {"pathParameters": {"petId": 1}}}, + }) + print("post status:", status) + + event = stream.wait_for("event: message", timeout=4) + if not event: + print("FAIL no message pushed back") + return + + result = json.loads(event.split("data: ", 1)[1])["result"] + upstream = json.loads(result["content"][0]["text"]) + print("upstream saw:", upstream["data"]["seen_auth"]) + stream.close() + + +if __name__ == "__main__": + h.run(main) From cb2821f227e88cdb0a175439cac5757a50a6738c Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 17 Sep 2026 09:08:07 +0800 Subject: [PATCH 2/7] feat(openapi-to-mcp): advertise outputSchema and return structuredContent When the document describes an operation's success response as a JSON object, the generated tool now advertises that schema as `outputSchema`, and a call whose answer satisfies it returns the API body as `structuredContent` with the same body as the text block. The response is taken from `200`, then `201`, then any other explicit `2xx`, then `2XX`; only an `application/json` object schema with properties qualifies, so an array, a `default` response and an unmerged composition advertise nothing. An answer that cannot satisfy the schema -- a status outside `2xx`, a body that is not a JSON object, or one that does not validate -- is returned as an error result carrying the usual envelope. MCP requires a tool that declares an `outputSchema` to produce structured content unless the result is an error, so returning the envelope on its own would leave a client that enforces the schema with a protocol error instead of a readable failure. The body is validated the way the client will validate it: the MCP SDKs check structured content with a validator that does not assert `format`, so asserting it here would reject bodies the client would have accepted. --- apisix/plugins/openapi-to-mcp/server.lua | 1 + .../openapi-to-mcp/tools/generator.lua | 63 +++++++++++++ .../plugins/openapi-to-mcp/tools/handler.lua | 70 +++++++++++++- docs/en/latest/plugins/openapi-to-mcp.md | 4 + docs/zh/latest/plugins/openapi-to-mcp.md | 4 + t/lib/openapi_to_mcp_fixture.lua | 40 ++++++++ t/plugin/openapi-to-mcp-tools-generator.t | 93 +++++++++++++++++++ t/plugin/openapi-to-mcp.t | 73 ++++++++++++++- 8 files changed, 344 insertions(+), 4 deletions(-) diff --git a/apisix/plugins/openapi-to-mcp/server.lua b/apisix/plugins/openapi-to-mcp/server.lua index 70a0260c9863..d628a85483af 100644 --- a/apisix/plugins/openapi-to-mcp/server.lua +++ b/apisix/plugins/openapi-to-mcp/server.lua @@ -38,6 +38,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 diff --git a/apisix/plugins/openapi-to-mcp/tools/generator.lua b/apisix/plugins/openapi-to-mcp/tools/generator.lua index 4b7cac4ad65e..d3c0006cc72a 100644 --- a/apisix/plugins/openapi-to-mcp/tools/generator.lua +++ b/apisix/plugins/openapi-to-mcp/tools/generator.lua @@ -179,6 +179,68 @@ 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 + + +-- 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. @@ -414,6 +476,7 @@ function _M.generate(spec, path_order, opts) parameters = params, execution_parameters = execution_parameters, request_body_content_type = content_type, + output_schema = build_output_schema(operation), annotations = annotations, } end diff --git a/apisix/plugins/openapi-to-mcp/tools/handler.lua b/apisix/plugins/openapi-to-mcp/tools/handler.lua index 6f8b410e83cf..b5042c41c942 100644 --- a/apisix/plugins/openapi-to-mcp/tools/handler.lua +++ b/apisix/plugins/openapi-to-mcp/tools/handler.lua @@ -271,6 +271,53 @@ local function decode_body(body) 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. +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" then + out[key] = without_formats(value) + end + end + return out +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(without_formats(tool.output_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 @@ -361,12 +408,29 @@ function _M.call(tool, arguments, opts) }) 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 diff --git a/docs/en/latest/plugins/openapi-to-mcp.md b/docs/en/latest/plugins/openapi-to-mcp.md index 74ef2b757e44..8db8e45b0228 100644 --- a/docs/en/latest/plugins/openapi-to-mcp.md +++ b/docs/en/latest/plugins/openapi-to-mcp.md @@ -60,6 +60,10 @@ When a tool is called, the Plugin builds the request from the operation: * Query parameters are serialized according to their `style` and `explode`, as defined by the [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.0.3#style-values). With the defaults (`form`, exploded), `tags: ["a", "b"]` is sent as `tags=a&tags=b` -- not as `tags[]=a&tags[]=b`, and an array parameter declared `explode: false` is sent as `tags=a,b`. `spaceDelimited`, `pipeDelimited` and `deepObject` are 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 `headers` sets `Content-Type`. +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. 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. diff --git a/docs/zh/latest/plugins/openapi-to-mcp.md b/docs/zh/latest/plugins/openapi-to-mcp.md index d9a4d7aefa5e..3d13851fe9a8 100644 --- a/docs/zh/latest/plugins/openapi-to-mcp.md +++ b/docs/zh/latest/plugins/openapi-to-mcp.md @@ -60,6 +60,10 @@ MCP 服务运行在 APISIX 内部,不需要额外的进程或服务。 * 查询参数按其 `style` 和 `explode` 序列化,规则见 [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.0.3#style-values)。使用默认值(`form`,展开)时,`tags: ["a", "b"]` 发送为 `tags=a&tags=b`,而不是 `tags[]=a&tags[]=b`;声明为 `explode: false` 的数组参数发送为 `tags=a,b`。同时支持 `spaceDelimited`、`pipeDelimited` 和 `deepObject`。如果 API 要求方括号形式,需要另外通过改写查询字符串的插件处理。 * 请求体使用操作中声明的媒体类型发送,除非 `headers` 中已设置 `Content-Type`。 +当文档把某个操作的成功响应描述为 JSON 对象时,生成的工具会以 `outputSchema` 公布该 Schema。响应按 `200`、`201`、其它明确的 `2xx`、`2XX` 的顺序选取;只有媒体类型为 `application/json` 且为带属性的对象才会被采用,因此数组、`default` 响应以及文档中未合并的组合(如 `allOf`)不会公布 Schema。 + +调用这类工具时,API 的响应体会作为 `structuredContent` 返回,文本块中也是同一份响应体。当响应无法满足该 Schema(状态码不在 `2xx`、响应体不是 JSON 对象,或校验不通过)时,结果仍为 `{status, statusText, headers, data}` 信封,并将 `isError` 置为 `true`——MCP 允许错误结果不携带结构化内容。未公布 `outputSchema` 的工具则始终返回该信封,与状态码无关。 + 使用 SSE 传输时,`base_url` 和 `headers` 中的变量在打开事件流的那次请求上解析,解析结果用于该会话的所有消息。`"Authorization": "Bearer ${http_x_api_token}"` 这类配置因此在 SSE 下同样可用:后续的消息请求只携带会话 ID,此时已无从解析变量。 使用 SSE 传输时,会话保存在共享字典 `mcp-session` 中,因此同一会话的事件流请求和消息请求可以由不同的 worker 进程处理。会话只在单个 APISIX 实例内有效:多个实例部署在负载均衡之后时,同一 SSE 会话的请求必须到达同一实例。Streamable HTTP 传输是无状态的,没有这一限制。 diff --git a/t/lib/openapi_to_mcp_fixture.lua b/t/lib/openapi_to_mcp_fixture.lua index 428af2c4d016..0330465f4935 100644 --- a/t/lib/openapi_to_mcp_fixture.lua +++ b/t/lib/openapi_to_mcp_fixture.lua @@ -121,6 +121,39 @@ local DOCUMENTS = { }, }, + -- responses that do and do not describe a JSON object + ["/output.json"] = { + openapi = "3.0.0", + info = { title = "Output", version = "1" }, + paths = { + ["/echo"] = { get = { + operationId = "echoOp", + responses = { ["200"] = { content = { ["application/json"] = { schema = { + type = "object", + required = { "seen_method" }, + properties = { + seen_method = { type = "string" }, + seen_path = { type = "string" }, + }, + } } } } }, + } }, + ["/list"] = { get = { + operationId = "listOp", + responses = { ["200"] = { content = { ["application/json"] = { schema = { + type = "array", items = { type = "string" }, + } } } } }, + } }, + ["/status404"] = { get = { + operationId = "goneOp", + responses = { ["200"] = { content = { ["application/json"] = { schema = { + type = "object", + required = { "seen_method" }, + properties = { seen_method = { type = "string" } }, + } } } } }, + } }, + }, + }, + -- parses as JSON and is not an OpenAPI document ["/notaspec.json"] = { this = "is not an openapi document" }, @@ -177,6 +210,13 @@ function _M.serve() return end + -- an API answer that is not a success, for the output schema cases + if uri == "/status404" then + ngx.status = 404 + ngx.say(core.json.encode({ error = "not found" })) + return + end + -- anything else is the API a generated tool called: say what it received ngx.say(core.json.encode({ seen_path = ngx.var.request_uri, diff --git a/t/plugin/openapi-to-mcp-tools-generator.t b/t/plugin/openapi-to-mcp-tools-generator.t index 6e482fd195a6..3d6439936873 100644 --- a/t/plugin/openapi-to-mcp-tools-generator.t +++ b/t/plugin/openapi-to-mcp-tools-generator.t @@ -573,3 +573,96 @@ true true --- error_log skipping parameter without a name + + + +=== TEST 100: the output schema comes from the success response +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local object = { type = "object", properties = { id = { type = "string" } } } + local spec = { paths = { ["/a"] = { get = { operationId = "a", responses = { + ["200"] = { content = { ["application/json"] = { schema = object } } }, + } } } } } + local tools = g.generate(spec, { ["/a"] = 1 }, {}) + ngx.say(tools[1].output_schema.type) + ngx.say(tools[1].output_schema.properties.id.type) + } + } +--- response_body +object +string + + + +=== TEST 101: 200 wins over 201, 201 over another 2xx, and 2XX comes last +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local function obj(name) + return { type = "object", properties = { [name] = { type = "string" } } } + end + local function op(responses) + local content = {} + for code, name in pairs(responses) do + content[code] = { content = { ["application/json"] = { schema = obj(name) } } } + end + return { operationId = "x", responses = content } + end + local function pick(responses) + local spec = { paths = { ["/a"] = { get = op(responses) } } } + local tools = g.generate(spec, { ["/a"] = 1 }, {}) + for key in pairs(tools[1].output_schema.properties) do + return key + end + end + ngx.say(pick({ ["200"] = "from200", ["201"] = "from201" })) + ngx.say(pick({ ["201"] = "from201", ["2XX"] = "fromXX" })) + ngx.say(pick({ ["202"] = "from202", ["2XX"] = "fromXX" })) + ngx.say(pick({ ["201"] = "from201" })) + } + } +--- response_body +from200 +from201 +from202 +from201 + + + +=== TEST 102: nothing a client could bind to yields no output schema +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local function schema_of(response) + local spec = { paths = { ["/a"] = { get = { + operationId = "a", responses = response } } } } + local tools = g.generate(spec, { ["/a"] = 1 }, {}) + return tools[1].output_schema + end + local json = function(schema) + return { ["200"] = { content = { ["application/json"] = { schema = schema } } } } + end + -- an array, a bare object, an unmerged composition, a non-JSON + -- media type, a response with no content, and an error response + ngx.say(schema_of(json({ type = "array", items = { type = "string" } })) == nil) + ngx.say(schema_of(json({ type = "object" })) == nil) + ngx.say(schema_of(json({ allOf = { { type = "object", + properties = { a = { type = "string" } } } } })) == nil) + ngx.say(schema_of({ ["200"] = { content = { ["application/xml"] = { + schema = { type = "object", properties = { a = {} } } } } } }) == nil) + ngx.say(schema_of({ ["200"] = { description = "ok" } }) == nil) + ngx.say(schema_of({ ["default"] = { content = { ["application/json"] = { + schema = { type = "object", properties = { a = {} } } } } } }) == nil) + } + } +--- response_body +true +true +true +true +true +true diff --git a/t/plugin/openapi-to-mcp.t b/t/plugin/openapi-to-mcp.t index 58b51d1dc7ad..448b162ee115 100644 --- a/t/plugin/openapi-to-mcp.t +++ b/t/plugin/openapi-to-mcp.t @@ -713,6 +713,77 @@ timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ -H "Accept: application/json, text/event-stream" \ 2>&1 | cat --- response_body eval -qr/"code":-32603.*not an openapi document: no openapi or swagger version/ +qr/(?=.*"code":-32603)(?=.*not an openapi document: no openapi or swagger version)/s --- error_log not an openapi document + + + +=== TEST 39: a route whose document describes its success responses +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/output.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 40: only the operations that describe an object carry an output schema +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | grep '^data: ' | sed 's/^data: //' \ + | python3 -c "import json,sys; d=json.loads(sys.stdin.read().strip()); print(' '.join(sorted(t['name'] for t in d['result']['tools'] if t.get('outputSchema'))))" +--- response_body +echoOp goneOp + + + +=== TEST 41: the advertised schema is the one the document declares +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | grep '^data: ' | sed 's/^data: //' \ + | python3 -c "import json,sys; d=json.loads(sys.stdin.read().strip()); t=[x for x in d['result']['tools'] if x['name']=='echoOp'][0]; s=t['outputSchema']; print(s['type']); print(' '.join(sorted(s['properties']))); print(' '.join(s['required']))" +--- response_body +object +seen_method seen_path +seen_method + + + +=== TEST 42: a matching answer is returned as structured content +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"echoOp","arguments":{}}}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/"structuredContent":\{.*"seen_method":"GET"/ + + + +=== TEST 43: an answer that cannot satisfy the schema is an error result +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"goneOp","arguments":{}}}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/"isError":true/ From 5585fdc4c37b4a61042d1ddd63711b2a0ab51fa6 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 17 Sep 2026 09:14:13 +0800 Subject: [PATCH 3/7] feat(openapi-to-mcp): add allowed_hosts to restrict where base_url may point `base_url` accepts variables, so a route can be written whose upstream host comes from the request -- `http://${http_x_backend}` is a documented configuration. Nothing constrained what that resolved to, which makes the gateway a usable relay to any host reachable from it. `allowed_hosts` takes exact host names and `*.example.com` wildcards matching one or more leading labels. When it is set, the host the `base_url` resolves to is checked before the document is fetched and before the API is called, and a `base_url` that is not an `http` or `https` URL is rejected as well; both answer 400. Neither the URL nor the host appears in the rejection or in the log line, because a resolved `base_url` can carry values taken from the request. Leaving the attribute unset keeps the previous behaviour. --- apisix/plugins/openapi-to-mcp.lua | 112 +++++++++++++++-- docs/en/latest/plugins/openapi-to-mcp.md | 3 + docs/zh/latest/plugins/openapi-to-mcp.md | 3 + t/plugin/openapi-to-mcp-tools-generator.t | 6 +- t/plugin/openapi-to-mcp.t | 147 ++++++++++++++++++++++ 5 files changed, 259 insertions(+), 12 deletions(-) diff --git a/apisix/plugins/openapi-to-mcp.lua b/apisix/plugins/openapi-to-mcp.lua index c0fc454408de..cfdf8da08bd6 100644 --- a/apisix/plugins/openapi-to-mcp.lua +++ b/apisix/plugins/openapi-to-mcp.lua @@ -17,8 +17,13 @@ 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 http = require("resty.http") local ngx = ngx +local ipairs = ipairs local pairs = pairs +local type = type +local str_lower = string.lower +local str_sub = string.sub local schema = { type = "object", @@ -59,6 +64,22 @@ local schema = { type = "boolean", default = false, }, + allowed_hosts = { + description = "Optional allow-list of hosts the resolved base_url may " .. + "resolve to. Each entry is an exact hostname (e.g. `api.example.com`) " .. + "or a `*.example.com` wildcard that matches one or more leading labels. " .. + "When set, requests whose resolved base_url host is not in the list " .. + "are rejected with HTTP 400.", + type = "array", + minItems = 1, + uniqueItems = true, + items = { + type = "string", + minLength = 1, + pattern = "^(\\*\\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?" .. + "(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$", + }, + }, }, required = { "openapi_url", "base_url" }, } @@ -78,6 +99,76 @@ function _M.check_schema(conf) end +-- Extract the lowercase host from a base URL after variable resolution. +-- Requires an http or https scheme and a non-empty host. Bracketed IPv6 +-- literals are normalized by removing the surrounding brackets before the +-- host is returned. +local function parse_base_url_host(url) + if type(url) ~= "string" or url == "" then + return nil, "base_url is empty" + end + local parsed = http:parse_uri(url, false) + if not parsed then + -- lua-resty-http's parse_uri rejects anything that isn't http(s)://... + -- Use our own message instead of propagating its error string, which + -- would echo the original URL (potentially request-derived) into logs. + return nil, "base_url must use http or https scheme" + end + local scheme, host = parsed[1], parsed[2] + if scheme ~= "http" and scheme ~= "https" then + return nil, "base_url must use http or https scheme" + end + if type(host) ~= "string" or host == "" then + return nil, "base_url has no host" + end + if str_sub(host, 1, 1) == "[" and str_sub(host, -1) == "]" then + host = str_sub(host, 2, -2) + end + return str_lower(host) +end + + +local function host_matches_allowed(host, allowed_hosts) + for _, pattern in ipairs(allowed_hosts) do + local lower_pattern = str_lower(pattern) + if lower_pattern == host then + return true + end + if str_sub(lower_pattern, 1, 2) == "*." then + local suffix = str_sub(lower_pattern, 2) + if #host > #suffix and str_sub(host, -#suffix) == suffix then + return true + end + end + end + return false +end + + +-- `base_url` may be built from request variables, so the host it resolves to is +-- only known per request. When the route declares allowed_hosts, that host is +-- checked here, before anything is fetched or called. +local function check_allowed_host(conf, base_url) + if not conf.allowed_hosts then + return true + end + + local host, err = parse_base_url_host(base_url) + if not host then + -- base_url is not logged: after resolution it may carry request-derived + -- values (${arg_*}, ${http_*}) and with them a secret + core.log.error("invalid resolved base_url: ", err) + return false, 400, "invalid base_url" + end + if not host_matches_allowed(host, conf.allowed_hosts) then + core.log.error("resolved base_url host is not in allowed_hosts") + return false, 400, "base_url host is not in allowed_hosts" + end + + return true +end + + -- Resolve base_url and headers against request variables. local function resolve_conf(conf, ctx) local base_url, err = core.utils.resolve_var(conf.base_url, ctx.var) @@ -103,9 +194,19 @@ end function _M.access(conf, ctx) - if conf.transport == "streamable_http" then - local base_url, headers = resolve_conf(conf, ctx) + if conf.transport ~= "sse" and conf.transport ~= "streamable_http" then + core.log.error("Invalid MCP transport: ", conf.transport) + return 500, { message = "Invalid MCP transport"} + end + + local base_url, headers = resolve_conf(conf, ctx) + local allowed, status, message = check_allowed_host(conf, base_url) + if not allowed then + return status, { message = message } + end + + if conf.transport == "streamable_http" then -- Defer the answer to before_proxy. Exiting here would skip every -- plugin with a lower priority that still has to run in access, such -- as an authorization check on the tool being called. @@ -121,13 +222,6 @@ function _M.access(conf, ctx) return end - if conf.transport ~= "sse" then - core.log.error("Invalid MCP transport: ", conf.transport) - return 500, { message = "Invalid MCP transport"} - end - - local base_url, headers = resolve_conf(conf, ctx) - -- The client is told to POST its messages to the path the route matched. local message_path = ctx.curr_req_matched and ctx.curr_req_matched._path diff --git a/docs/en/latest/plugins/openapi-to-mcp.md b/docs/en/latest/plugins/openapi-to-mcp.md index 8db8e45b0228..d6674e92deac 100644 --- a/docs/en/latest/plugins/openapi-to-mcp.md +++ b/docs/en/latest/plugins/openapi-to-mcp.md @@ -51,6 +51,7 @@ The Plugin supports: | base_url | string | True | | | Base URL of the API the tools call. The path of each operation is appended to it. Supports [APISIX variables](../apisix-variable.md) and [NGINX variables](http://nginx.org/en/docs/varindex.html), 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. | +| allowed_hosts | array | False | | | Hosts the resolved `base_url` is allowed to point at. Each entry is an exact host name, for example `api.example.com`, or a `*.example.com` wildcard matching one or more leading labels. A request whose resolved `base_url` names a host outside the list is rejected with HTTP 400. | Tool call arguments are validated against the generated input schema before the API is called. 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. @@ -60,6 +61,8 @@ When a tool is called, the Plugin builds the request from the operation: * Query parameters are serialized according to their `style` and `explode`, as defined by the [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.0.3#style-values). With the defaults (`form`, exploded), `tags: ["a", "b"]` is sent as `tags=a&tags=b` -- not as `tags[]=a&tags[]=b`, and an array parameter declared `explode: false` is sent as `tags=a,b`. `spaceDelimited`, `pipeDelimited` and `deepObject` are 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 `headers` sets `Content-Type`. +`allowed_hosts` is worth setting whenever `base_url` is built from a variable: without it, whatever host the request resolves to is called. With it, the host is checked before the API is called and before the document is fetched, and a URL that does not use the `http` or `https` scheme is rejected as well. The rejection names neither the URL nor the host, since a resolved `base_url` can carry values taken from the request. + 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. diff --git a/docs/zh/latest/plugins/openapi-to-mcp.md b/docs/zh/latest/plugins/openapi-to-mcp.md index 3d13851fe9a8..92378e0d8f28 100644 --- a/docs/zh/latest/plugins/openapi-to-mcp.md +++ b/docs/zh/latest/plugins/openapi-to-mcp.md @@ -51,6 +51,7 @@ MCP 服务运行在 APISIX 内部,不需要额外的进程或服务。 | base_url | string | 是 | | | 工具调用的 API 基础地址,每个操作的路径拼接在其后。支持 [APISIX 变量](../apisix-variable.md) 和 [NGINX 变量](http://nginx.org/en/docs/varindex.html),例如 `http://${http_x_backend}`。 | | headers | object | 否 | | | 发往 API 的每个请求都会携带的请求头。值支持变量,例如 `"Authorization": "Bearer ${http_x_api_token}"`。 | | flatten_parameters | boolean | 否 | `false` | | 为 `false` 时,工具输入中的参数分别嵌套在 `pathParameters`、`queryParameters` 和 `headerParameters` 下;为 `true` 时,参数直接放在输入对象的顶层。 | +| allowed_hosts | array | 否 | | | 允许 `base_url` 解析到的主机列表。每一项是精确的主机名(如 `api.example.com`),或匹配一级及以上前缀标签的 `*.example.com` 通配符。解析后的 `base_url` 主机不在列表中时,请求返回 HTTP 400。 | 调用 API 之前,插件会按生成的输入 Schema 校验工具参数。调用不存在的工具或参数不合法时,返回 `isError` 为 `true` 的结果。校验之前会先填入文档中声明的 `default`,因此同时带有 `required` 和 `default` 的参数或请求体属性可以由客户端省略;客户端显式传入的参数不会被默认值覆盖。 @@ -60,6 +61,8 @@ MCP 服务运行在 APISIX 内部,不需要额外的进程或服务。 * 查询参数按其 `style` 和 `explode` 序列化,规则见 [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.0.3#style-values)。使用默认值(`form`,展开)时,`tags: ["a", "b"]` 发送为 `tags=a&tags=b`,而不是 `tags[]=a&tags[]=b`;声明为 `explode: false` 的数组参数发送为 `tags=a,b`。同时支持 `spaceDelimited`、`pipeDelimited` 和 `deepObject`。如果 API 要求方括号形式,需要另外通过改写查询字符串的插件处理。 * 请求体使用操作中声明的媒体类型发送,除非 `headers` 中已设置 `Content-Type`。 +当 `base_url` 由变量拼接时,建议配置 `allowed_hosts`:不配置时,请求解析出什么主机就会调用什么主机;配置后,会在获取文档和调用 API 之前先校验主机,并拒绝非 `http`/`https` 协议的地址。由于解析后的 `base_url` 可能包含来自请求的内容,拒绝信息中不会回显 URL 或主机名。 + 当文档把某个操作的成功响应描述为 JSON 对象时,生成的工具会以 `outputSchema` 公布该 Schema。响应按 `200`、`201`、其它明确的 `2xx`、`2XX` 的顺序选取;只有媒体类型为 `application/json` 且为带属性的对象才会被采用,因此数组、`default` 响应以及文档中未合并的组合(如 `allOf`)不会公布 Schema。 调用这类工具时,API 的响应体会作为 `structuredContent` 返回,文本块中也是同一份响应体。当响应无法满足该 Schema(状态码不在 `2xx`、响应体不是 JSON 对象,或校验不通过)时,结果仍为 `{status, statusText, headers, data}` 信封,并将 `isError` 置为 `true`——MCP 允许错误结果不携带结构化内容。未公布 `outputSchema` 的工具则始终返回该信封,与状态码无关。 diff --git a/t/plugin/openapi-to-mcp-tools-generator.t b/t/plugin/openapi-to-mcp-tools-generator.t index 3d6439936873..7d60e2626ac9 100644 --- a/t/plugin/openapi-to-mcp-tools-generator.t +++ b/t/plugin/openapi-to-mcp-tools-generator.t @@ -576,7 +576,7 @@ skipping parameter without a name -=== TEST 100: the output schema comes from the success response +=== TEST 26: the output schema comes from the success response --- config location /t { content_by_lua_block { @@ -596,7 +596,7 @@ string -=== TEST 101: 200 wins over 201, 201 over another 2xx, and 2XX comes last +=== TEST 27: 200 wins over 201, 201 over another 2xx, and 2XX comes last --- config location /t { content_by_lua_block { @@ -632,7 +632,7 @@ from201 -=== TEST 102: nothing a client could bind to yields no output schema +=== TEST 28: nothing a client could bind to yields no output schema --- config location /t { content_by_lua_block { diff --git a/t/plugin/openapi-to-mcp.t b/t/plugin/openapi-to-mcp.t index 448b162ee115..5effdb1dbccd 100644 --- a/t/plugin/openapi-to-mcp.t +++ b/t/plugin/openapi-to-mcp.t @@ -787,3 +787,150 @@ timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ 2>&1 | cat --- response_body eval qr/"isError":true/ + + + +=== TEST 44: a route that restricts which host base_url may resolve to +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/openapi.json", + allowed_hosts = { "api.example.com" }, + } }, + { 2, "/mcp-exact", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/openapi.json", + allowed_hosts = { "127.0.0.1" }, + } }, + { 3, "/mcp-wildcard", { + transport = "streamable_http", + base_url = "http://${http_x_backend}", + openapi_url = "http://127.0.0.1:11460/openapi.json", + allowed_hosts = { "*.example.com" }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 45: a host outside the list is rejected +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -o /dev/null -w "%{http_code}" 2>&1 | cat +--- response_body chomp +400 +--- error_log +resolved base_url host is not in allowed_hosts + + + +=== TEST 46: a host in the list is served +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp-exact \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/"name":"getPet"/ + + + +=== TEST 47: a wildcard entry matches the resolved host, and only a sub-domain +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp-wildcard \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "X-Backend: api.example.com" \ + -o /dev/null -w "%{http_code} " 2>&1 | cat + timeout 1 curl -X POST -N -sS http://localhost:1984/mcp-wildcard \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "X-Backend: example.com" \ + -o /dev/null -w "%{http_code}" 2>&1 | cat +--- response_body chomp +200 400 +--- error_log +resolved base_url host is not in allowed_hosts + + + +=== TEST 48: base_url that resolves to something that is not an http URL +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "streamable_http", + base_url = "${http_x_backend}", + openapi_url = "http://127.0.0.1:11460/openapi.json", + allowed_hosts = { "api.example.com" }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 49: is rejected without echoing the URL +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "X-Backend: file:///etc/passwd" \ + 2>&1 | cat +--- response_body eval +qr/"message":"invalid base_url"/ +--- error_log +base_url must use http or https scheme + + + +=== TEST 50: the allow-list entries are validated +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/mcp", + "plugins": { "openapi-to-mcp": { + "transport": "streamable_http", + "base_url": "http://127.0.0.1:11460", + "openapi_url": "http://127.0.0.1:11460/openapi.json", + "allowed_hosts": [] + } } + }]]) + ngx.say(code >= 300 and "rejected" or "accepted") + local code2 = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/mcp", + "plugins": { "openapi-to-mcp": { + "transport": "streamable_http", + "base_url": "http://127.0.0.1:11460", + "openapi_url": "http://127.0.0.1:11460/openapi.json", + "allowed_hosts": ["http://api.example.com"] + } } + }]]) + ngx.say(code2 >= 300 and "rejected" or "accepted") + } + } +--- response_body +rejected +rejected From 1556e4f9f30622822d9fe8bf4760fbe073795642 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 17 Sep 2026 10:56:45 +0800 Subject: [PATCH 4/7] fix(openapi-to-mcp): accept a YAML version that is not quoted YAML reads an unquoted version as a number: `swagger: 2.0` and `openapi: 3.1` both come back as one, and only a version with two dots such as `3.0.0` is a string. The document check accepted a string alone, so it turned away the form Swagger 2.0 documents are usually written in. JSON documents were never affected, since JSON quotes the value. The check now takes either, and the loader tests cover all four shapes. --- .../plugins/openapi-to-mcp/openapi/loader.lua | 6 ++- t/plugin/openapi-to-mcp-openapi-loader.t | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apisix/plugins/openapi-to-mcp/openapi/loader.lua b/apisix/plugins/openapi-to-mcp/openapi/loader.lua index 029afd2d7725..51d1ce496f89 100644 --- a/apisix/plugins/openapi-to-mcp/openapi/loader.lua +++ b/apisix/plugins/openapi-to-mcp/openapi/loader.lua @@ -157,7 +157,11 @@ function _M.validate(spec) if type(spec) ~= "table" then return nil, "openapi spec is not an object" end - if type(spec.openapi) ~= "string" and type(spec.swagger) ~= "string" then + -- YAML leaves an unquoted version as a number: "swagger: 2.0" and + -- "openapi: 3.1" both parse that way, and only a two-dot version such as + -- "3.0.0" comes back as a string. JSON documents always quote it. + local version = spec.openapi or spec.swagger + if type(version) ~= "string" and type(version) ~= "number" then return nil, "not an openapi document: no openapi or swagger version" end if type(spec.paths) ~= "table" then diff --git a/t/plugin/openapi-to-mcp-openapi-loader.t b/t/plugin/openapi-to-mcp-openapi-loader.t index 632655e336b3..fef2003081bd 100644 --- a/t/plugin/openapi-to-mcp-openapi-loader.t +++ b/t/plugin/openapi-to-mcp-openapi-loader.t @@ -277,3 +277,47 @@ nil not an openapi document: no paths object --- response_body true string + + + +=== TEST 14: a YAML document whose version is not quoted is still an openapi document +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + -- YAML reads an unquoted "2.0" or "3.1" as a number; only a version + -- with two dots, such as 3.0.0, comes back as a string + local cases = { + "swagger: 2.0\npaths:\n /a:\n get: {}\n", + "openapi: 3.1\npaths:\n /a:\n get: {}\n", + "openapi: 3.0.0\npaths:\n /a:\n get: {}\n", + 'swagger: "2.0"\npaths:\n /a:\n get: {}\n', + } + for _, body in ipairs(cases) do + local spec = loader.parse(body) + local version = spec and (spec.openapi or spec.swagger) + local ok, err = loader.validate(spec) + ngx.say(type(version), " ", tostring(ok), " ", tostring(err)) + end + } + } +--- response_body +number true nil +number true nil +string true nil +string true nil + + + +=== TEST 15: a YAML document that is not an openapi document is still rejected +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local spec = loader.parse("title: an index\nitems:\n - one\n") + local ok, err = loader.validate(spec) + ngx.say(tostring(ok), " ", tostring(err)) + } + } +--- response_body +nil not an openapi document: no openapi or swagger version From 3b21a2c9d0e2500f91dabedd1e9cee1513d346f4 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 17 Sep 2026 09:08:07 +0800 Subject: [PATCH 5/7] feat(openapi-to-mcp): advertise outputSchema and return structuredContent When the document describes an operation's success response as a JSON object, the generated tool now advertises that schema as `outputSchema`, and a call whose answer satisfies it returns the API body as `structuredContent` with the same body as the text block. The response is taken from `200`, then `201`, then any other explicit `2xx`, then `2XX`; only an `application/json` object schema with properties qualifies, so an array, a `default` response and an unmerged composition advertise nothing. An answer that cannot satisfy the schema -- a status outside `2xx`, a body that is not a JSON object, or one that does not validate -- is returned as an error result carrying the usual envelope. MCP requires a tool that declares an `outputSchema` to produce structured content unless the result is an error, so returning the envelope on its own would leave a client that enforces the schema with a protocol error instead of a readable failure. The body is validated the way the client will validate it: the MCP SDKs check structured content with a validator that does not assert `format`, so asserting it here would reject bodies the client would have accepted. --- apisix/plugins/openapi-to-mcp/server.lua | 1 + .../openapi-to-mcp/tools/generator.lua | 84 +++++++++++++ .../plugins/openapi-to-mcp/tools/handler.lua | 52 +++++++- docs/en/latest/plugins/openapi-to-mcp.md | 4 + docs/zh/latest/plugins/openapi-to-mcp.md | 4 + t/lib/openapi_to_mcp_fixture.lua | 40 ++++++ t/plugin/openapi-to-mcp-tools-generator.t | 119 ++++++++++++++++++ t/plugin/openapi-to-mcp.t | 71 +++++++++++ 8 files changed, 372 insertions(+), 3 deletions(-) diff --git a/apisix/plugins/openapi-to-mcp/server.lua b/apisix/plugins/openapi-to-mcp/server.lua index ef9bf2437657..d53ff1620738 100644 --- a/apisix/plugins/openapi-to-mcp/server.lua +++ b/apisix/plugins/openapi-to-mcp/server.lua @@ -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 diff --git a/apisix/plugins/openapi-to-mcp/tools/generator.lua b/apisix/plugins/openapi-to-mcp/tools/generator.lua index 4b7cac4ad65e..ab88d7730271 100644 --- a/apisix/plugins/openapi-to-mcp/tools/generator.lua +++ b/apisix/plugins/openapi-to-mcp/tools/generator.lua @@ -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. @@ -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, @@ -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 diff --git a/apisix/plugins/openapi-to-mcp/tools/handler.lua b/apisix/plugins/openapi-to-mcp/tools/handler.lua index 5ee2a0974194..9f3b519a9aa0 100644 --- a/apisix/plugins/openapi-to-mcp/tools/handler.lua +++ b/apisix/plugins/openapi-to-mcp/tools/handler.lua @@ -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 @@ -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 diff --git a/docs/en/latest/plugins/openapi-to-mcp.md b/docs/en/latest/plugins/openapi-to-mcp.md index 7c8644b0658d..47ea4c1d7ef8 100644 --- a/docs/en/latest/plugins/openapi-to-mcp.md +++ b/docs/en/latest/plugins/openapi-to-mcp.md @@ -66,6 +66,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. diff --git a/docs/zh/latest/plugins/openapi-to-mcp.md b/docs/zh/latest/plugins/openapi-to-mcp.md index 95a9e86d6cc2..e5dfbb287165 100644 --- a/docs/zh/latest/plugins/openapi-to-mcp.md +++ b/docs/zh/latest/plugins/openapi-to-mcp.md @@ -66,6 +66,10 @@ MCP 服务运行在 APISIX 内部,不需要额外的进程或服务。 * 请求体使用操作中声明的媒体类型发送,除非 `headers` 中已设置 `Content-Type`。 * header 参数在插件自身的 `headers` 之前写入,因此工具调用无法覆盖路由添加的凭据;取值中含换行符的 header 会被丢弃。 +当文档把某个操作的成功响应描述为 JSON 对象时,生成的工具会以 `outputSchema` 公布该 Schema。响应按 `200`、`201`、其它明确的 `2xx`、`2XX` 的顺序选取;只有媒体类型为 `application/json` 且为带属性的对象才会被采用,因此数组、`default` 响应以及文档中未合并的组合(如 `allOf`)不会公布 Schema。 + +调用这类工具时,API 的响应体会作为 `structuredContent` 返回,文本块中也是同一份响应体。当响应无法满足该 Schema(状态码不在 `2xx`、响应体不是 JSON 对象,或校验不通过)时,结果仍为 `{status, statusText, headers, data}` 信封,并将 `isError` 置为 `true`——MCP 允许错误结果不携带结构化内容。未公布 `outputSchema` 的工具则始终返回该信封,与状态码无关。 + 使用 SSE 传输时,`base_url` 和 `headers` 中的变量在打开事件流的那次请求上解析,解析结果用于该会话的所有消息。`"Authorization": "Bearer ${http_x_api_token}"` 这类配置因此在 SSE 下同样可用:后续的消息请求只携带会话 ID,此时已无从解析变量。解析结果会随会话记录存放在共享字典 `mcp-session` 中直到会话结束,因此调用方以这种方式提供的凭据会在网关内存中最长保留 30 分钟;配置中不含变量时则不存储。 使用 SSE 传输时,会话保存在共享字典 `mcp-session` 中,因此同一会话的事件流请求和消息请求可以由不同的 worker 进程处理。会话只在单个 APISIX 实例内有效:多个实例部署在负载均衡之后时,同一 SSE 会话的请求必须到达同一实例。Streamable HTTP 传输是无状态的,没有这一限制。会话归属于签发它的路由以及该路由上通过认证的 consumer,因此会话 ID 无法在另一条路由上使用。 diff --git a/t/lib/openapi_to_mcp_fixture.lua b/t/lib/openapi_to_mcp_fixture.lua index 3483acd59235..26ad0fbd4815 100644 --- a/t/lib/openapi_to_mcp_fixture.lua +++ b/t/lib/openapi_to_mcp_fixture.lua @@ -184,6 +184,39 @@ local DOCUMENTS = { }, }, + -- responses that do and do not describe a JSON object + ["/output.json"] = { + openapi = "3.0.0", + info = { title = "Output", version = "1" }, + paths = { + ["/echo"] = { get = { + operationId = "echoOp", + responses = { ["200"] = { content = { ["application/json"] = { schema = { + type = "object", + required = { "seen_method" }, + properties = { + seen_method = { type = "string" }, + seen_path = { type = "string" }, + }, + } } } } }, + } }, + ["/list"] = { get = { + operationId = "listOp", + responses = { ["200"] = { content = { ["application/json"] = { schema = { + type = "array", items = { type = "string" }, + } } } } }, + } }, + ["/status404"] = { get = { + operationId = "goneOp", + responses = { ["200"] = { content = { ["application/json"] = { schema = { + type = "object", + required = { "seen_method" }, + properties = { seen_method = { type = "string" } }, + } } } } }, + } }, + }, + }, + -- parses as JSON and is not an OpenAPI document ["/notaspec.json"] = { this = "is not an openapi document" }, @@ -286,6 +319,13 @@ function _M.serve() return end + -- an API answer that is not a success, for the output schema cases + if uri == "/status404" then + ngx.status = 404 + ngx.say(core.json.encode({ error = "not found" })) + return + end + -- anything else is the API a generated tool called: say what it received ngx.say(core.json.encode({ seen_path = ngx.var.request_uri, diff --git a/t/plugin/openapi-to-mcp-tools-generator.t b/t/plugin/openapi-to-mcp-tools-generator.t index 6e482fd195a6..778215337881 100644 --- a/t/plugin/openapi-to-mcp-tools-generator.t +++ b/t/plugin/openapi-to-mcp-tools-generator.t @@ -573,3 +573,122 @@ true true --- error_log skipping parameter without a name + + + +=== TEST 26: the output schema comes from the success response +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local object = { type = "object", properties = { id = { type = "string" } } } + local spec = { paths = { ["/a"] = { get = { operationId = "a", responses = { + ["200"] = { content = { ["application/json"] = { schema = object } } }, + } } } } } + local tools = g.generate(spec, { ["/a"] = 1 }, {}) + ngx.say(tools[1].output_schema.type) + ngx.say(tools[1].output_schema.properties.id.type) + } + } +--- response_body +object +string + + + +=== TEST 27: 200 wins over 201, 201 over another 2xx, and 2XX comes last +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local function obj(name) + return { type = "object", properties = { [name] = { type = "string" } } } + end + local function op(responses) + local content = {} + for code, name in pairs(responses) do + content[code] = { content = { ["application/json"] = { schema = obj(name) } } } + end + return { operationId = "x", responses = content } + end + local function pick(responses) + local spec = { paths = { ["/a"] = { get = op(responses) } } } + local tools = g.generate(spec, { ["/a"] = 1 }, {}) + for key in pairs(tools[1].output_schema.properties) do + return key + end + end + ngx.say(pick({ ["200"] = "from200", ["201"] = "from201" })) + ngx.say(pick({ ["201"] = "from201", ["2XX"] = "fromXX" })) + ngx.say(pick({ ["202"] = "from202", ["2XX"] = "fromXX" })) + ngx.say(pick({ ["201"] = "from201" })) + } + } +--- response_body +from200 +from201 +from202 +from201 + + + +=== TEST 28: nothing a client could bind to yields no output schema +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local function schema_of(response) + local spec = { paths = { ["/a"] = { get = { + operationId = "a", responses = response } } } } + local tools = g.generate(spec, { ["/a"] = 1 }, {}) + return tools[1].output_schema + end + local json = function(schema) + return { ["200"] = { content = { ["application/json"] = { schema = schema } } } } + end + -- an array, a bare object, an unmerged composition, a non-JSON + -- media type, a response with no content, and an error response + ngx.say(schema_of(json({ type = "array", items = { type = "string" } })) == nil) + ngx.say(schema_of(json({ type = "object" })) == nil) + ngx.say(schema_of(json({ allOf = { { type = "object", + properties = { a = { type = "string" } } } } })) == nil) + ngx.say(schema_of({ ["200"] = { content = { ["application/xml"] = { + schema = { type = "object", properties = { a = {} } } } } } }) == nil) + ngx.say(schema_of({ ["200"] = { description = "ok" } }) == nil) + ngx.say(schema_of({ ["default"] = { content = { ["application/json"] = { + schema = { type = "object", properties = { a = {} } } } } } }) == nil) + } + } +--- response_body +true +true +true +true +true +true + + + +=== TEST 29: the check schema drops format keywords but keeps a property named format +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local object = { type = "object", properties = { + at = { type = "string", format = "date-time" }, + format = { type = "string" }, + }, required = { "format" } } + local spec = { paths = { ["/a"] = { get = { operationId = "a", responses = { + ["200"] = { content = { ["application/json"] = { schema = object } } }, + } } } } } + local tool = g.generate(spec, { ["/a"] = 1 }, {})[1] + local check = tool.output_check_schema + ngx.say(tool.output_schema.properties.at.format) + ngx.say(check.properties.at.format == nil) + ngx.say(check.properties.format.type) + } + } +--- response_body +date-time +true +string diff --git a/t/plugin/openapi-to-mcp.t b/t/plugin/openapi-to-mcp.t index 416edafa5972..98645206638e 100644 --- a/t/plugin/openapi-to-mcp.t +++ b/t/plugin/openapi-to-mcp.t @@ -1573,3 +1573,74 @@ streamable_http 5: true streamable_http 6: true streamable_http 7: true streamable_http 8: true + + + +=== TEST 86: a route whose document describes its success responses +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/output.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 87: only the operations that describe an object carry an output schema +--- exec +timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | grep '^data: ' | sed 's/^data: //' \ + | python3 -c "import json,sys; d=json.loads(sys.stdin.read().strip()); print(' '.join(sorted(t['name'] for t in d['result']['tools'] if t.get('outputSchema'))))" +--- response_body +echoOp goneOp + + + +=== TEST 88: the advertised schema is the one the document declares +--- exec +timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | grep '^data: ' | sed 's/^data: //' \ + | python3 -c "import json,sys; d=json.loads(sys.stdin.read().strip()); t=[x for x in d['result']['tools'] if x['name']=='echoOp'][0]; s=t['outputSchema']; print(s['type']); print(' '.join(sorted(s['properties']))); print(' '.join(s['required']))" +--- response_body +object +seen_method seen_path +seen_method + + + +=== TEST 89: a matching answer is returned as structured content +--- exec +timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"echoOp","arguments":{}}}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/"structuredContent":\{.*"seen_method":"GET"/ + + + +=== TEST 90: an answer that cannot satisfy the schema is an error result +--- exec +timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"goneOp","arguments":{}}}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/"isError":true/ From 9abb30d20a66a4d5338640b8781b589967c7781a Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 17 Sep 2026 09:14:13 +0800 Subject: [PATCH 6/7] feat(openapi-to-mcp): add allowed_base_url_hosts to restrict where base_url may point `base_url` accepts variables, so a route can be written whose upstream host comes from the request. Nothing constrained what that resolved to, which makes the gateway a usable relay to any host reachable from it. `allowed_base_url_hosts` takes entries in the same form as `allowed_ref_hosts` and is matched with the same code: a hostname or `*.example.com` wildcard, optionally followed by `:port`. When it is set, the resolved `base_url` is checked in before_proxy, after the Origin check, and a request whose host is outside the list, or whose `base_url` is not an http(s) URL, is answered with 400. Neither the URL nor the host appears in the rejection or in the log line. Leaving the attribute unset keeps the previous behaviour. --- apisix/plugins/openapi-to-mcp.lua | 40 +++++ apisix/plugins/openapi-to-mcp/openapi/ref.lua | 3 + docs/en/latest/plugins/openapi-to-mcp.md | 3 +- docs/zh/latest/plugins/openapi-to-mcp.md | 3 +- t/plugin/openapi-to-mcp.t | 138 ++++++++++++++++++ 5 files changed, 185 insertions(+), 2 deletions(-) diff --git a/apisix/plugins/openapi-to-mcp.lua b/apisix/plugins/openapi-to-mcp.lua index f7ce380336ed..4f8ae4414e83 100644 --- a/apisix/plugins/openapi-to-mcp.lua +++ b/apisix/plugins/openapi-to-mcp.lua @@ -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 @@ -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 " .. @@ -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) @@ -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 diff --git a/apisix/plugins/openapi-to-mcp/openapi/ref.lua b/apisix/plugins/openapi-to-mcp/openapi/ref.lua index ed6ab396b628..265cab84ade5 100644 --- a/apisix/plugins/openapi-to-mcp/openapi/ref.lua +++ b/apisix/plugins/openapi-to-mcp/openapi/ref.lua @@ -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 diff --git a/docs/en/latest/plugins/openapi-to-mcp.md b/docs/en/latest/plugins/openapi-to-mcp.md index 47ea4c1d7ef8..073599901b56 100644 --- a/docs/en/latest/plugins/openapi-to-mcp.md +++ b/docs/en/latest/plugins/openapi-to-mcp.md @@ -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. @@ -82,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. diff --git a/docs/zh/latest/plugins/openapi-to-mcp.md b/docs/zh/latest/plugins/openapi-to-mcp.md index e5dfbb287165..c4c0c0beae87 100644 --- a/docs/zh/latest/plugins/openapi-to-mcp.md +++ b/docs/zh/latest/plugins/openapi-to-mcp.md @@ -55,6 +55,7 @@ MCP 服务运行在 APISIX 内部,不需要额外的进程或服务。 | max_document_size | integer | 否 | `4194304` | >= 1024 | OpenAPI 文档,以及文档内 `http(s)` 形式 `$ref` 拉取的文档的大小上限(字节)。超出上限的文档不会读入 worker,该路由直接报错。 | | max_expanded_nodes | integer | 否 | `50000` | >= 1000 | 单次 `$ref` 展开在工具输入 Schema 中最多产生的节点数。超出后,仍在展开中的 `$ref` 及其后的所有 `$ref` 都降级为通用对象;足够大的正常文档也可能触及此上限。只有 `paths` 会被展开,文档其他部分不计入。 | | allowed_ref_hosts | array[string] | 否 | | | 文档内 `http(s)` 形式的 `$ref` 除 `openapi_url` 自身所在来源(scheme、主机与端口)外还可以指向的主机。每项为主机名或 `*.example.com` 形式的通配符,可加 `:port`;不带端口时匹配该主机的任意端口。 | +| allowed_base_url_hosts | array[string] | 否 | | | 解析后的 `base_url` 允许指向的主机,格式与 `allowed_ref_hosts` 相同。设置后,若 `base_url` 解析到其他主机或端口,或解析结果不是 `http(s)` URL,请求会在列出或调用工具之前被拒绝,返回 HTTP 400。 | | allowed_origins | array[string] | 否 | | | MCP 请求中允许的 `Origin` 头取值,写成 `scheme://host[:port]`。配置后完全以列表为准;`["*"]` 表示接受任意 origin。不配置时,`Origin` 只有在其主机是路由 `host`/`hosts` 声明的字面量主机(`*.example.com` 这类通配项不算),或它与请求两端都是回环地址时才被接受。默认检查还要求协议及有效端口一致。不带 `Origin` 头的请求始终放行。 | 调用 API 之前,插件会按生成的输入 Schema 校验工具参数,并按操作声明的参数过滤:文档中未声明的参数会被丢弃,不会发往 API。调用不存在的工具或参数不合法时,返回 `isError` 为 `true` 的结果。校验之前会先填入文档中声明的 `default`,因此同时带有 `required` 和 `default` 的参数或请求体属性可以由客户端省略;客户端显式传入的参数不会被默认值覆盖。 @@ -82,7 +83,7 @@ MCP 服务运行在 APISIX 内部,不需要额外的进程或服务。 * **Origin 校验用于防护浏览器访问中的 DNS rebinding。** 默认信任路由声明的字面量主机或回环地址,并要求请求与 origin 的协议及有效端口一致(省略端口时,HTTP 为 `80`,HTTPS 为 `443`)。路由通配主机不作为信任依据。其他可信 origin,包括 TLS 在 APISIX 之前终止的场景,应通过 `allowed_origins` 配置。不带 `Origin` 的请求会被接受;此检查不能替代身份认证。 * **OpenAPI 文档是输入,不是可信配置。** 工具名称、描述和 Schema 都来自文档并会被模型读取,文档来源一旦被攻陷,就可以左右使用该路由的 Agent。`openapi_url` 应指向自己可控的来源。 -* **`base_url` 不应由客户端可控的变量拼成。** `http://${http_x_backend}` 会让调用方决定工具请求发往何处;请使用固定主机,或网关自身设置的变量。 +* **`base_url` 不应由客户端可控的变量拼成。** `http://${http_x_backend}` 会让调用方决定工具请求发往何处;请使用固定主机,或网关自身设置的变量。确需使用变量时,用 `allowed_base_url_hosts` 限定其可解析到的主机。拒绝响应和日志都不包含 URL 或主机,因为解析后的 `base_url` 可能带有来自请求的值。 * **文档内 `http(s)` 形式的 `$ref` 默认只跟随文档自身的 scheme、主机与端口。** 因此从 `127.0.0.1` 提供的文档无法指向同一地址上的其他端口。需要跨来源时用 `allowed_ref_hosts` 显式声明,并避免把内部地址写进去。 * **插件的 `headers` 会附加到文档中的每一个操作上。** 只应暴露允许使用这些凭据的操作。[`consumer-restriction`](consumer-restriction.md) 控制消费者对路由的访问,不能按 MCP 工具名授权。 * **插件的 `headers` 优先于调用方传入的同名 header**,两者大小写写法不同也一样,因此工具调用无法替换网关附加的凭据。调用方也不能设置决定请求分帧或属于连接本身的 header —— `Transfer-Encoding`、`Content-Length`、`Host`、`Connection`、`Upgrade`、`Expect` 以及其他 hop-by-hop header —— 即使文档把它们声明成了 header 参数。 diff --git a/t/plugin/openapi-to-mcp.t b/t/plugin/openapi-to-mcp.t index 98645206638e..ea65907eeb8a 100644 --- a/t/plugin/openapi-to-mcp.t +++ b/t/plugin/openapi-to-mcp.t @@ -1644,3 +1644,141 @@ timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \ 2>&1 | cat --- response_body eval qr/"isError":true/ + + + +=== TEST 91: a route that restricts which host base_url may resolve to +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/openapi.json", + allowed_base_url_hosts = { "api.example.com", "127.0.0.1:1" }, + } }, + { 2, "/mcp-exact", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/openapi.json", + allowed_base_url_hosts = { "127.0.0.1:11460" }, + } }, + { 3, "/mcp-wildcard", { + transport = "streamable_http", + base_url = "http://${http_x_backend}", + openapi_url = "http://127.0.0.1:11460/openapi.json", + allowed_base_url_hosts = { "*.example.com" }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 92: a host outside the list, or on another port, is rejected +--- exec +timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -o /dev/null -w "%{http_code}" 2>&1 | cat +--- response_body chomp +400 +--- error_log +resolved base_url host is not in allowed_base_url_hosts + + + +=== TEST 93: a host and port in the list is served +--- exec +timeout 5 curl -X POST -N -sS http://localhost:1984/mcp-exact \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/"name":"getPet"/ + + + +=== TEST 94: a wildcard entry matches the resolved host, and only a sub-domain +--- exec +timeout 5 curl -X POST -N -sS http://localhost:1984/mcp-wildcard \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "X-Backend: api.example.com" \ + -o /dev/null -w "%{http_code} " 2>&1 | cat +timeout 5 curl -X POST -N -sS http://localhost:1984/mcp-wildcard \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "X-Backend: example.com" \ + -o /dev/null -w "%{http_code}" 2>&1 | cat +--- response_body chomp +200 400 +--- error_log +resolved base_url host is not in allowed_base_url_hosts + + + +=== TEST 95: base_url that resolves to something that is not an http URL +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "streamable_http", + base_url = "${http_x_backend}", + openapi_url = "http://127.0.0.1:11460/openapi.json", + allowed_base_url_hosts = { "api.example.com" }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 96: is rejected without echoing the URL +--- exec +timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "X-Backend: file:///etc/passwd" \ + 2>&1 | cat +--- response_body eval +qr/"message":"invalid base_url"/ +--- error_log +resolved base_url is not an http(s) URL +--- no_error_log +/etc/passwd + + + +=== TEST 97: an empty allow-list is rejected +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/mcp", + "plugins": { "openapi-to-mcp": { + "transport": "streamable_http", + "base_url": "http://127.0.0.1:11460", + "openapi_url": "http://127.0.0.1:11460/openapi.json", + "allowed_base_url_hosts": [] + } } + }]]) + ngx.say(code >= 300 and "rejected" or "accepted") + } + } +--- response_body +rejected From 177a163c9eaa7e5d7b4dca35db8db17fc0a72b4d Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 29 Oct 2026 08:28:00 +0800 Subject: [PATCH 7/7] test(openapi-to-mcp): drive the output schema through the official MCP client SDK --- t/plugin/openapi-to-mcp-interop.spec.ts | 38 +++++++++++++++++++++++++ t/plugin/openapi-to-mcp-interop.t | 14 +++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/t/plugin/openapi-to-mcp-interop.spec.ts b/t/plugin/openapi-to-mcp-interop.spec.ts index e8db2e39814d..86f9960911dd 100644 --- a/t/plugin/openapi-to-mcp-interop.spec.ts +++ b/t/plugin/openapi-to-mcp-interop.spec.ts @@ -135,3 +135,41 @@ describe.each([ expect((await second.listTools()).tools).toHaveLength(before); }); }); + +describe('MCP interop with an output schema', () => { + // The SDK validates structuredContent against the advertised outputSchema + // and throws when a non-error result of such a tool carries none. + let client: Client | undefined; + + afterEach(async () => { + await client?.close(); + client = undefined; + }); + + const connected = async () => { + client = newClient(); + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost:1984/mcp-interop-output')), + ); + return client; + }; + + it('accepts the structured content of a matching answer', async () => { + const c = await connected(); + const { tools } = await c.listTools(); + expect(tools.find((t) => t.name === 'echoOp')?.outputSchema?.type).toBe('object'); + + const result = await c.callTool({ name: 'echoOp', arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ seen_method: 'GET' }); + }); + + it('gets an error result when the answer cannot satisfy the schema', async () => { + const c = await connected(); + await c.listTools(); + + const result = await c.callTool({ name: 'goneOp', arguments: {} }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + }); +}); diff --git a/t/plugin/openapi-to-mcp-interop.t b/t/plugin/openapi-to-mcp-interop.t index 241b10933796..bbfb0ef91a98 100644 --- a/t/plugin/openapi-to-mcp-interop.t +++ b/t/plugin/openapi-to-mcp-interop.t @@ -66,6 +66,11 @@ __DATA__ base_url = "http://127.0.0.1:11460", openapi_url = "http://127.0.0.1:11460/openapi.json", } }, + { 3, "/mcp-interop-output", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/output.json", + } }, }) if ok then ngx.say("passed") end } @@ -87,14 +92,17 @@ qr/Tests\s+(\d+) passed \(\1\)/ -=== TEST 3: remove the extra route so it cannot collide with later suites +=== TEST 3: remove the extra routes so they cannot collide with later suites --- config location /t { content_by_lua_block { local t = require("lib.test_admin").test - local code, body = t('/apisix/admin/routes/2', ngx.HTTP_DELETE) - ngx.say(code < 300 and "deleted" or body) + for _, id in ipairs({ 2, 3 }) do + local code, body = t('/apisix/admin/routes/' .. id, ngx.HTTP_DELETE) + ngx.say(code < 300 and "deleted" or body) + end } } --- response_body deleted +deleted