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/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..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. @@ -66,6 +67,10 @@ When a tool is called, the Plugin builds the request from the operation: * A request body is sent with the media type the operation declares, unless `headers` sets `Content-Type`. * Header parameters are applied before the Plugin's own `headers`, so a tool call cannot replace a credential the route adds, and a header whose value contains a newline is dropped. +When the document describes the success response of an operation as a JSON object, the generated tool advertises that schema as `outputSchema`. The response is taken from `200`, then `201`, then any other explicit `2xx`, then `2XX`; only an `application/json` schema that is an object with properties qualifies, so an array, a `default` response and a composition the document leaves unmerged advertise nothing. + +A call to such a tool returns the API's body as `structuredContent`, with the same body as the text block. When the answer cannot satisfy the schema -- a status outside `2xx`, a body that is not a JSON object, or one that does not validate -- the result carries the usual `{status, statusText, headers, data}` envelope with `isError` set to `true`, which is the case MCP allows to carry no structured content. Tools that advertise no `outputSchema` always return that envelope, whatever the status. + For the SSE transport, variables in `base_url` and in `headers` are resolved on the request that opens the stream, and the resolved values are used for every message of that session. This is what makes a configuration such as `"Authorization": "Bearer ${http_x_api_token}"` usable over SSE: the message requests that follow carry only the session id, so there is nothing left to resolve from at that point. Those resolved values are kept with the session record in the `mcp-session` shared dict for the life of the session, so a credential a caller supplied that way lives in gateway memory for up to 30 minutes; a configuration with no variable in it stores nothing. For the SSE transport, sessions are kept in the `mcp-session` shared dict, so the stream and the message requests of one session may be handled by different worker processes. Sessions are local to one APISIX instance: when several instances run behind a load balancer, the requests of an SSE session must reach the same instance. The Streamable HTTP transport is stateless and has no such requirement. A session belongs to the Route that issued it, and to the consumer authenticated on it, so a session id cannot be used on another Route. @@ -78,7 +83,7 @@ Failed tool-list builds are cached for five seconds per worker. Requests hitting * **Origin validation protects browser access against DNS rebinding.** The defaults trust literal Route hosts or loopback addresses, and require the request and origin to use the same scheme and effective port (`80` for HTTP, `443` for HTTPS when omitted). Wildcard Route hosts provide no trust anchor. Configure `allowed_origins` for other trusted origins, including when TLS terminates before APISIX. Requests without `Origin` are accepted; this check does not replace authentication. * **The OpenAPI document is an input, not a trusted configuration.** Tool names, descriptions and schemas come from it and are read by the model, so a document host that is compromised can steer the agents using the Route. Point `openapi_url` at a source you control. -* **`base_url` should not be built from client-controlled variables.** `http://${http_x_backend}` lets the caller choose where the tool call goes; use a fixed host, or a variable the gateway itself sets. +* **`base_url` should not be built from client-controlled variables.** `http://${http_x_backend}` lets the caller choose where the tool call goes; use a fixed host, or a variable the gateway itself sets. Where a variable is needed, set `allowed_base_url_hosts` to the hosts it may resolve to. The rejection and its log line name neither the URL nor the host, since a resolved `base_url` can carry values taken from the request. * **An `http(s)` `$ref` is followed only to the document's own scheme, host and port** by default, so a document served from `127.0.0.1` cannot reach another port on that same address. Add `allowed_ref_hosts` to allow more, and keep internal addresses out of that list. * **The Plugin's `headers` reach every operation in the document.** Expose only operations intended to share those credentials. [`consumer-restriction`](consumer-restriction.md) controls consumer access to the Route; it does not authorize individual MCP tools. * **The Plugin's `headers` win over a header the caller supplies**, whichever case each is spelled in, so a tool call cannot replace a credential the gateway adds. A call also cannot set the headers that frame the request or belong to the connection -- `Transfer-Encoding`, `Content-Length`, `Host`, `Connection`, `Upgrade`, `Expect` and the other hop-by-hop ones -- even where the document declares them as header parameters. diff --git a/docs/zh/latest/plugins/openapi-to-mcp.md b/docs/zh/latest/plugins/openapi-to-mcp.md index 95a9e86d6cc2..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` 的参数或请求体属性可以由客户端省略;客户端显式传入的参数不会被默认值覆盖。 @@ -66,6 +67,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 无法在另一条路由上使用。 @@ -78,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/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-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 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..ea65907eeb8a 100644 --- a/t/plugin/openapi-to-mcp.t +++ b/t/plugin/openapi-to-mcp.t @@ -1573,3 +1573,212 @@ 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/ + + + +=== 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