diff --git a/.gitignore b/.gitignore index a36c59fdd0d3..496e1f303789 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,5 @@ dump.rdb /data/ # telemetry dumped by the otel collector during tests ci/pod/otelcol-contrib/data-otlp.json +# bytecode from importing the .py test drivers +t/plugin/__pycache__/ diff --git a/Makefile b/Makefile index 17788441003e..b6df7615ffaf 100644 --- a/Makefile +++ b/Makefile @@ -419,6 +419,14 @@ install: runtime $(ENV_INSTALL) apisix/plugins/mcp/broker/*.lua $(ENV_INST_LUADIR)/apisix/plugins/mcp/broker $(ENV_INSTALL) apisix/plugins/mcp/transport/*.lua $(ENV_INST_LUADIR)/apisix/plugins/mcp/transport + $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/openapi-to-mcp/openapi + $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/openapi-to-mcp/tools + $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/openapi-to-mcp/transport + $(ENV_INSTALL) apisix/plugins/openapi-to-mcp/*.lua $(ENV_INST_LUADIR)/apisix/plugins/openapi-to-mcp + $(ENV_INSTALL) apisix/plugins/openapi-to-mcp/openapi/*.lua $(ENV_INST_LUADIR)/apisix/plugins/openapi-to-mcp/openapi + $(ENV_INSTALL) apisix/plugins/openapi-to-mcp/tools/*.lua $(ENV_INST_LUADIR)/apisix/plugins/openapi-to-mcp/tools + $(ENV_INSTALL) apisix/plugins/openapi-to-mcp/transport/*.lua $(ENV_INST_LUADIR)/apisix/plugins/openapi-to-mcp/transport + $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/jwt-auth $(ENV_INSTALL) apisix/plugins/jwt-auth/*.lua $(ENV_INST_LUADIR)/apisix/plugins/jwt-auth diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua index f4c002835396..cfb757bb4d49 100644 --- a/apisix/cli/config.lua +++ b/apisix/cli/config.lua @@ -269,6 +269,7 @@ local _M = { "traffic-split", "redirect", "response-rewrite", + "openapi-to-mcp", "oas-validator", "mcp-bridge", "degraphql", diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index 42833de45835..f0a8475cdf6c 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -507,7 +507,7 @@ http { lua_shared_dict ext-plugin {* http.lua_shared_dict["ext-plugin"] *}; # cache for ext-plugin {% end %} - {% if enabled_plugins["mcp-bridge"] then %} + {% if enabled_plugins["mcp-bridge"] or enabled_plugins["openapi-to-mcp"] then %} lua_shared_dict mcp-session {* http.lua_shared_dict["mcp-session"] *}; # cache for mcp-session {% end %} diff --git a/apisix/plugins/openapi-to-mcp.lua b/apisix/plugins/openapi-to-mcp.lua new file mode 100644 index 000000000000..c0fc454408de --- /dev/null +++ b/apisix/plugins/openapi-to-mcp.lua @@ -0,0 +1,165 @@ +-- +-- 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. +-- +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 ngx = ngx +local pairs = pairs + +local schema = { + type = "object", + properties = { + transport = { + description = "The transport mechanisms for client-server communication", + type = "string", + default = "sse", + enum = {"sse", "streamable_http"}, + }, + openapi_url = { + description = "URL of the OpenAPI specification document", + type = "string", + minLength = 1, + }, + base_url = { + description = "Base URL of the external service", + type = "string", + minLength = 1, + }, + headers = { + description = "Headers to include in requests to the external service", + type = "object", + minProperties = 0, + patternProperties = { + ["^[^:]+$"] = { + oneOf = { + { type = "string" } + } + } + }, + }, + flatten_parameters = { + description = "Whether to flatten query and path parameters " .. + "in the tool inputSchema. When false (default), " .. + "parameters are nested under queryParameters or pathParameters. " .. + "When true, parameters are placed directly in properties.", + type = "boolean", + default = false, + }, + }, + required = { "openapi_url", "base_url" }, +} + +local plugin_name = "openapi-to-mcp" + +local _M = { + version = 0.1, + priority = 540, + name = plugin_name, + schema = schema, +} + + +function _M.check_schema(conf) + return core.schema.check(schema, conf) +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) + if err then + core.log.error("failed to resolve variable for base_url: ", + conf.base_url, ", error: ", err) + base_url = conf.base_url + end + + local headers = {} + for key, value in pairs(conf.headers or {}) do + local resolved_value, herr = core.utils.resolve_var(value, ctx.var) + if herr then + core.log.error("failed to resolve variable for header, key: ", key, + ", error: ", herr) + resolved_value = value + end + headers[key] = resolved_value + end + + return base_url, headers +end + + +function _M.access(conf, ctx) + if conf.transport == "streamable_http" then + local base_url, headers = resolve_conf(conf, ctx) + + -- 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. + ctx.mcp_inprocess_opts = { + conf = conf, + base_url = base_url, + headers = headers, + transport = "streamable_http", + } + -- The answer is produced in before_proxy, so the request never reaches + -- an upstream; handle_upstream() runs before_proxy and returns. + ctx.bypass_nginx_upstream = true + 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 + + ngx.ctx.disable_proxy_buffering = true + ctx.mcp_inprocess_opts = { + conf = conf, + base_url = base_url, + headers = headers, + transport = "sse", + message_path = message_path, + } + ctx.bypass_nginx_upstream = true +end + + +function _M.before_proxy(conf, ctx) + local opts = ctx.mcp_inprocess_opts + if not opts then + return + end + + -- Every body the transports produce is JSON, and core.response.exit() sets + -- no content type of its own, so without this they would all go out as + -- text/plain. Set once here so no rejection path can miss it; the two that + -- stream override it with text/event-stream on their way out. + core.response.set_header("Content-Type", "application/json") + + if opts.transport == "sse" then + return mcp_sse.handle(ctx, opts) + end + return streamable_http.handle(ctx, opts) +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/cache.lua b/apisix/plugins/openapi-to-mcp/cache.lua new file mode 100644 index 000000000000..6ebaee5247ab --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/cache.lua @@ -0,0 +1,74 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") +local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") +local generator = require("apisix.plugins.openapi-to-mcp.tools.generator") +local tostring = tostring + +local _M = {} + +-- A generated tool list is kept for an hour, for up to 100 documents. +local SPEC_TTL = 3600 +local SPEC_COUNT = 100 + +-- A failed fetch is cached only briefly. Without neg_ttl core.lrucache caches +-- nothing on failure, which would let an unreachable spec host be re-dialed on +-- every single request; a long negative TTL would instead keep the route broken +-- long after the host recovers. +local NEG_TTL = 5 +local NEG_COUNT = 32 + +local CACHE_VERSION = "1" + +-- invalid_stale: without it core.lrucache hands an expired entry back and +-- re-arms its TTL whenever the version still matches, and the version here +-- never changes, so a document updated at the same URL would never be fetched +-- again. +local lru = core.lrucache.new({ + ttl = SPEC_TTL, + count = SPEC_COUNT, + invalid_stale = true, + neg_ttl = NEG_TTL, + neg_count = NEG_COUNT, +}) + + +local function build_tools(openapi_url, flatten_parameters) + local spec, path_order, err = loader.fetch(openapi_url) + if not spec then + return nil, err + end + + local resolved = ref.resolve(spec) + return generator.generate(resolved, path_order, { + flatten_parameters = flatten_parameters, + }) +end + + +-- Returns the tool list for a plugin conf, building it on first use. +-- base_url and headers do not take part in the key: they affect how a tool is +-- invoked, never how it is generated. +function _M.get_tools(conf) + local flatten_parameters = conf.flatten_parameters == true + local key = conf.openapi_url .. "#" .. tostring(flatten_parameters) + return lru(key, CACHE_VERSION, build_tools, conf.openapi_url, flatten_parameters) +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/json_pretty.lua b/apisix/plugins/openapi-to-mcp/json_pretty.lua new file mode 100644 index 000000000000..b5674114b64f --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/json_pretty.lua @@ -0,0 +1,96 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local str_sub = string.sub +local str_rep = string.rep +local table_concat = table.concat + +local _M = {} + +local INDENT_UNIT = " " + + +-- cjson has no pretty printer, so re-flow its compact output instead of +-- re-implementing value encoding and string escaping. Matches +-- JSON.stringify(value, null, 2): two-space indent, ": " after keys, and +-- empty containers kept on one line. +-- +-- Object key order still comes from the Lua table, so the result is not +-- byte-identical to JSON.stringify for nested upstream payloads. That is a +-- known, semantically irrelevant difference. +function _M.encode(value) + local compact, err = core.json.encode(value) + if not compact then + return nil, err + end + + local out = {} + local indent = 0 + local in_string = false + local escaped = false + local index = 1 + local length = #compact + + while index <= length do + local char = str_sub(compact, index, index) + + if in_string then + out[#out + 1] = char + if escaped then + escaped = false + elseif char == "\\" then + escaped = true + elseif char == '"' then + in_string = false + end + + elseif char == '"' then + in_string = true + out[#out + 1] = char + + elseif char == "{" or char == "[" then + local next_char = str_sub(compact, index + 1, index + 1) + if (char == "{" and next_char == "}") or (char == "[" and next_char == "]") then + out[#out + 1] = char .. next_char + index = index + 1 + else + indent = indent + 1 + out[#out + 1] = char .. "\n" .. str_rep(INDENT_UNIT, indent) + end + + elseif char == "}" or char == "]" then + indent = indent - 1 + out[#out + 1] = "\n" .. str_rep(INDENT_UNIT, indent) .. char + + elseif char == "," then + out[#out + 1] = ",\n" .. str_rep(INDENT_UNIT, indent) + + elseif char == ":" then + out[#out + 1] = ": " + + else + out[#out + 1] = char + end + + index = index + 1 + end + + return table_concat(out) +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/jsonrpc.lua b/apisix/plugins/openapi-to-mcp/jsonrpc.lua new file mode 100644 index 000000000000..37830e478192 --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/jsonrpc.lua @@ -0,0 +1,97 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local type = type + +local _M = {} + +_M.VERSION = "2.0" + +-- JSON-RPC 2.0 standard error codes +_M.ERR_PARSE = -32700 +_M.ERR_INVALID_REQUEST = -32600 +_M.ERR_METHOD_NOT_FOUND = -32601 +_M.ERR_INVALID_PARAMS = -32602 +_M.ERR_INTERNAL = -32603 + + +-- The answer to any message that fails shape validation, as the MCP SDK gives it: always +-- a null id, always -32700, and a plain JSON body rather than an SSE frame -- +-- even when the request did carry an id. +function _M.invalid_message() + return { + jsonrpc = _M.VERSION, + error = { + code = _M.ERR_PARSE, + message = "Parse error: Invalid JSON-RPC message", + }, + id = core.json.null, + } +end + + +function _M.result(id, result) + return { jsonrpc = _M.VERSION, id = id, result = result } +end + + +function _M.error(id, code, message, data) + local err = { code = code, message = message } + if data ~= nil then + err.data = data + end + return { jsonrpc = _M.VERSION, id = id, error = err } +end + + +-- A JSON-RPC notification carries no id and must not be answered with a body. +function _M.is_notification(request) + return type(request) == "table" and request.id == nil +end + + +-- Only the shape is checked here; whether the method exists is the server's +-- business. Callers answer a failure with invalid_message() and HTTP 400. +function _M.validate(request) + if type(request) ~= "table" then + return false + end + if request.jsonrpc ~= _M.VERSION then + return false + end + if type(request.method) ~= "string" then + return false + end + -- An explicit "id": null is not a valid request id; JSON-RPC reserves null + -- for error responses. A missing id is a notification and stays legal. + if request.id == core.json.null then + return false + end + if request.params ~= nil then + if type(request.params) ~= "table" then + return false + end + -- MCP requires params to be an object; an array is rejected outright + if request.params[1] ~= nil then + return false + end + end + return true +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/openapi/endpoints.lua b/apisix/plugins/openapi-to-mcp/openapi/endpoints.lua new file mode 100644 index 000000000000..b0deb46988b3 --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/openapi/endpoints.lua @@ -0,0 +1,129 @@ +-- +-- 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. +-- +local pairs = pairs +local ipairs = ipairs +local type = type +local tostring = tostring +local math_huge = math.huge +local table_sort = table.sort + +local _M = {} + +-- Order of openapi-types' OpenAPIV3.HttpMethods enum. extractToolsFromApi +-- iterates Object.values(OpenAPIV3.HttpMethods), so tools come out in this +-- order per path. Do not "fix" this to CRUD order. +local METHOD_ORDER = { + "get", "put", "post", "delete", "options", "head", "patch", "trace", +} + +local METHOD_RANK = {} +for rank, method in ipairs(METHOD_ORDER) do + METHOD_RANK[method] = rank +end + + +local function param_key(param) + return tostring(param["in"]) .. "\0" .. tostring(param.name) +end + + +-- Parameters declared on a Path Item apply to every operation under it; an +-- operation parameter with the same name and location overrides the inherited +-- one. See https://spec.openapis.org/oas/v3.0.3#path-item-object +-- The operation's own parameters come first, in their order, followed by the +-- inherited ones it does not override. +local function with_path_parameters(operation, path_params) + if type(path_params) ~= "table" or #path_params == 0 then + return operation + end + + local merged = {} + local declared = {} + if type(operation.parameters) == "table" then + for _, param in ipairs(operation.parameters) do + merged[#merged + 1] = param + if type(param) == "table" then + declared[param_key(param)] = true + end + end + end + + local inherited = false + for _, param in ipairs(path_params) do + if type(param) == "table" and not declared[param_key(param)] then + merged[#merged + 1] = param + inherited = true + end + end + if not inherited then + return operation + end + + local copy = {} + for key, value in pairs(operation) do + copy[key] = value + end + copy.parameters = merged + return copy +end + + +function _M.extract(spec, path_order) + local out = {} + if type(spec) ~= "table" or type(spec.paths) ~= "table" then + return out + end + path_order = path_order or {} + + for path, path_item in pairs(spec.paths) do + if type(path_item) == "table" then + for _, method in ipairs(METHOD_ORDER) do + local operation = path_item[method] + if type(operation) == "table" then + out[#out + 1] = { + method = method, + path = path, + operation = with_path_parameters(operation, path_item.parameters), + _path_rank = path_order[path] or math_huge, + _method_rank = METHOD_RANK[method], + } + end + end + end + end + + table_sort(out, function(a, b) + if a._path_rank ~= b._path_rank then + return a._path_rank < b._path_rank + end + -- deterministic fallback when path_order has no entry for either path + if a.path ~= b.path then + return a.path < b.path + end + return a._method_rank < b._method_rank + end) + + for _, e in ipairs(out) do + e._path_rank = nil + e._method_rank = nil + end + + return out +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/openapi/loader.lua b/apisix/plugins/openapi-to-mcp/openapi/loader.lua new file mode 100644 index 000000000000..d1f0bff7c277 --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/openapi/loader.lua @@ -0,0 +1,165 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local http = require("resty.http") +local lyaml = require("lyaml") +local pcall = pcall +local type = type +local pairs = pairs +local ipairs = ipairs +local tostring = tostring +local str_find = string.find +local str_sub = string.sub +local str_gsub = string.gsub +local table_sort = table.sort +local re_gmatch = ngx.re.gmatch +local re_find = ngx.re.find + +local _M = {} + +local DEFAULT_TIMEOUT = 5000 + +-- A JSON key is quoted and may escape the solidus as "\/" -- cjson does this by +-- default, and several other encoders offer it -- so the key cannot be matched +-- by looking for a bare leading slash. Capture any quoted key and unescape it +-- instead. YAML keys are bare and need their own pattern. +local JSON_KEY_RE = [=["((?:[^"\\]|\\.)*)"\s*:]=] +local YAML_KEY_RE = [=[^[ \t]*(/[^\s:]*)[ \t]*:]=] + + +local function unescape_json_key(key) + if not str_find(key, "\\", 1, true) then + return key + end + key = str_gsub(key, "\\/", "/") + key = str_gsub(key, '\\"', '"') + key = str_gsub(key, "\\\\", "\\") + return key +end + + +-- Scanning the whole document would let a description such as +-- "see /apple : the fruit" register a path before the real `paths` section +-- does, flipping the tool order. Start the scan at the section itself. +local function find_paths_start(body) + local from = str_find(body, '"paths"', 1, true) + if from then + return from + end + return re_find(body, [[^[ \t]*paths[ \t]*:]], "jom") or 1 +end + + +-- Record the order in which paths appear in the raw document. Lua tables are +-- unordered, but the tool list must follow document order, the order a +-- JavaScript implementation walking Object.entries() would produce. +local function extract_path_order(body, spec) + local order = {} + local seen = 0 + + if type(spec.paths) ~= "table" then + return order + end + + local section = str_sub(body, find_paths_start(body)) + + local function scan(pattern, flags, unescape) + local iter, err = re_gmatch(section, pattern, flags) + if not iter then + core.log.warn("failed to scan path order: ", err) + return 0 + end + local matched = 0 + while true do + local m, merr = iter() + if merr then + core.log.warn("failed to scan path order: ", merr) + break + end + if not m then + break + end + local path = unescape and unescape_json_key(m[1]) or m[1] + if spec.paths[path] and not order[path] then + seen = seen + 1 + order[path] = seen + matched = matched + 1 + end + end + return matched + end + + if scan(JSON_KEY_RE, "jo", true) == 0 then + scan(YAML_KEY_RE, "jom", false) + end + + -- Any path the scan missed goes after the known ones, sorted so the result + -- stays deterministic across workers. + local missing = {} + for path in pairs(spec.paths) do + if not order[path] then + missing[#missing + 1] = path + end + end + table_sort(missing) + for _, path in ipairs(missing) do + seen = seen + 1 + order[path] = seen + end + + return order +end + + +function _M.parse(body) + if type(body) ~= "string" or body == "" then + return nil, nil, "empty openapi spec" + end + + local spec = core.json.decode(body) + if type(spec) ~= "table" then + local ok, decoded = pcall(lyaml.load, body) + if not ok or type(decoded) ~= "table" then + return nil, nil, "failed to parse openapi spec as JSON or YAML" + end + spec = decoded + end + + return spec, extract_path_order(body, spec), nil +end + + +function _M.fetch(url, timeout) + local httpc, err = http.new() + if not httpc then + return nil, nil, "failed to create http client: " .. tostring(err) + end + httpc:set_timeout(timeout or DEFAULT_TIMEOUT) + + local res, req_err = httpc:request_uri(url, { method = "GET" }) + if not res then + return nil, nil, "failed to fetch openapi spec: " .. tostring(req_err) + end + if res.status ~= 200 then + return nil, nil, "unexpected status " .. res.status .. " while fetching openapi spec" + end + + return _M.parse(res.body) +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/openapi/ref.lua b/apisix/plugins/openapi-to-mcp/openapi/ref.lua new file mode 100644 index 000000000000..bd7e296ee52d --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/openapi/ref.lua @@ -0,0 +1,203 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") +local pairs = pairs +local ipairs = ipairs +local type = type +local getmetatable = getmetatable +local setmetatable = setmetatable +local tostring = tostring +local str_sub = string.sub +local str_gsub = string.gsub +local str_find = string.find +local ngx_now = ngx.now + +local _M = {} + +local MAX_DEPTH = 16 +local GENERIC_OBJECT = "object" + +-- An http(s) $ref is followed and the document it names is fetched, so a spec +-- that splits its schemas across files works. This runs in the request path +-- of the gateway itself, so a +-- document naming hundreds of hosts must not be able to turn one tools/list +-- into hundreds of outbound requests. The result is cached with the spec, so +-- these bounds apply once per hour per route, not once per request. +local EXTERNAL_TIMEOUT = 3000 +local MAX_EXTERNAL_DOCS = 8 +local EXTERNAL_BUDGET = 10 + + +-- Split "#/a/b~1c" into { "a", "b/c" }. Returns nil for non-internal refs. +local function parse_pointer(ref) + if type(ref) ~= "string" or str_sub(ref, 1, 2) ~= "#/" then + return nil + end + + local parts = {} + local body = str_sub(ref, 3) + local pos = 1 + while true do + local sep = str_find(body, "/", pos, true) + local seg = sep and str_sub(body, pos, sep - 1) or str_sub(body, pos) + -- "~1" must be decoded before "~0", otherwise "~01" would become "/" + seg = str_gsub(seg, "~1", "/") + seg = str_gsub(seg, "~0", "~") + parts[#parts + 1] = seg + if not sep then + break + end + pos = sep + 1 + end + return parts +end + + +local function lookup(root, parts) + local cur = root + for _, seg in ipairs(parts) do + if type(cur) ~= "table" then + return nil + end + cur = cur[seg] + end + return cur +end + + +local function is_external(ref) + return str_sub(ref, 1, 7) == "http://" or str_sub(ref, 1, 8) == "https://" +end + + +-- Fetches the document an external $ref names and returns the node it points +-- at, together with the document it came from: an internal $ref inside a +-- fetched document resolves against that document, not against the main spec. +local function external_target(ref, ctx) + local hash = str_find(ref, "#", 1, true) + local url = hash and str_sub(ref, 1, hash - 1) or ref + local fragment = hash and str_sub(ref, hash + 1) or "" + + local doc = ctx.docs[url] + if doc == nil then + -- neither URL nor error is logged: an external $ref can carry + -- credentials in its userinfo or query string + if ctx.fetched >= MAX_EXTERNAL_DOCS then + core.log.warn("too many external $ref documents, using generic object") + return nil + end + if ctx.deadline and ngx_now() > ctx.deadline then + core.log.warn("external $ref time budget exhausted, using generic object") + return nil + end + ctx.deadline = ctx.deadline or (ngx_now() + EXTERNAL_BUDGET) + ctx.fetched = ctx.fetched + 1 + + doc = loader.fetch(url, EXTERNAL_TIMEOUT) + if type(doc) ~= "table" then + core.log.warn("failed to fetch an external $ref document, using generic object") + doc = false + end + ctx.docs[url] = doc + end + + if doc == false then + return nil + end + if fragment == "" then + return doc, doc + end + + local parts = parse_pointer("#" .. fragment) + if not parts then + return nil + end + return lookup(doc, parts), doc +end + + +local function expand(node, root, depth, active, ctx) + if type(node) ~= "table" then + return node + end + + if depth > MAX_DEPTH then + core.log.warn("$ref expansion exceeded max depth ", MAX_DEPTH, + ", using generic object") + return { type = GENERIC_OBJECT } + end + + local ref = node["$ref"] + if ref ~= nil then + -- The same pointer means different things in different documents, so a + -- cycle is a repeat of the pair, not of the pointer alone. A pointer + -- already on the current expansion path degrades immediately rather + -- than unrolling to MAX_DEPTH: cutting at the first repeat, as + -- json-schema-ref-parser does with circular:"ignore", keeps the schema + -- from growing many times the size of the reference. + local key = tostring(root) .. "\0" .. tostring(ref) + if active[key] then + return { type = GENERIC_OBJECT } + end + + local target, target_root + local parts = parse_pointer(ref) + if parts then + target, target_root = lookup(root, parts), root + elseif type(ref) == "string" and is_external(ref) then + target, target_root = external_target(ref, ctx) + else + -- A relative or bare-file $ref would name a file on the gateway's + -- own filesystem, which a route must not be able to read. The + -- reference is not logged: it can carry credentials in its + -- userinfo or query string. + core.log.warn("only internal and http(s) $ref is supported, ", + "using generic object") + return { type = GENERIC_OBJECT } + end + + if type(target) ~= "table" then + core.log.warn("failed to resolve $ref, using generic object") + return { type = GENERIC_OBJECT } + end + + active[key] = true + -- $ref replaces the whole object; sibling keys are dropped per OpenAPI 3.0 + local expanded = expand(target, target_root, depth + 1, active, ctx) + active[key] = nil + return expanded + end + + local out = {} + -- an empty array must stay an array once re-encoded + if getmetatable(node) == core.json.array_mt then + setmetatable(out, core.json.array_mt) + end + for key, value in pairs(node) do + out[key] = expand(value, root, depth, active, ctx) + end + return out +end + + +function _M.resolve(spec) + return expand(spec, spec, 0, {}, { docs = {}, fetched = 0 }) +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/openapi/schema.lua b/apisix/plugins/openapi-to-mcp/openapi/schema.lua new file mode 100644 index 000000000000..e40568f57c74 --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/openapi/schema.lua @@ -0,0 +1,119 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local pairs = pairs +local ipairs = ipairs +local type = type +local tostring = tostring + +local _M = {} + +-- OpenAPI-only keywords that are not valid JSON Schema. +local STRIP_KEYS = { + "nullable", "xml", "externalDocs", "deprecated", "readOnly", "writeOnly", +} + +local COMPOSITION_KEYS = { "allOf", "oneOf", "anyOf" } + + +local function convert(node, seen) + if type(node) ~= "table" then + return node + end + + if node["$ref"] ~= nil then + core.log.warn("unresolved $ref '", tostring(node["$ref"]), "', using generic object") + return { type = "object" } + end + + if seen[node] then + core.log.warn("cycle detected in schema, using generic object to break recursion") + return { type = "object" } + end + seen[node] = true + + local out = core.table.clone(node) + + -- "integer" is deliberately left alone rather than widened to "number": + -- JSON Schema has an integer type of its own. + + -- read nullable before stripping it + local nullable = out.nullable == true + for _, key in ipairs(STRIP_KEYS) do + out[key] = nil + end + + if nullable then + if type(out.type) == "table" then + local has_null = false + for _, t in ipairs(out.type) do + if t == "null" then + has_null = true + break + end + end + if not has_null then + out.type[#out.type + 1] = "null" + end + elseif type(out.type) == "string" then + out.type = { out.type, "null" } + else + out.type = "null" + end + end + + -- Deliberately checked *after* the nullable rewrite, which turns out.type + -- into an array. A nullable object therefore stops recursing here, leaving + -- OpenAPI-only keywords in its children. This is kept on purpose so the + -- generated tool list stays stable for existing clients; the edge-case + -- document in t/lib/mcp_edge_spec.lua pins it. + if out.type == "object" and type(out.properties) == "table" then + local props = {} + for key, prop in pairs(out.properties) do + props[key] = convert(prop, seen) + end + out.properties = props + end + + if out.type == "array" and type(out.items) == "table" then + out.items = convert(out.items, seen) + end + + -- Composition keywords hold complete sub-schemas and are converted whether + -- or not the parent carries a type. + for _, keyword in ipairs(COMPOSITION_KEYS) do + local branches = out[keyword] + if type(branches) == "table" then + local converted = {} + for index, branch in ipairs(branches) do + converted[index] = convert(branch, seen) + end + out[keyword] = converted + end + end + + seen[node] = nil + return out +end + + +function _M.to_json_schema(oas_schema) + return convert(oas_schema, {}) +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/protocol.lua b/apisix/plugins/openapi-to-mcp/protocol.lua new file mode 100644 index 000000000000..6d7024c4bf86 --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/protocol.lua @@ -0,0 +1,71 @@ +-- +-- 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. +-- +local ipairs = ipairs +local type = type + +local _M = {} + +-- Snapshot of @modelcontextprotocol/sdk 1.26.0's version table. Pinning it +-- here keeps the gateway's answer stable as the SDK moves on. +_M.LATEST_VERSION = "2025-11-25" + +_M.SUPPORTED_VERSIONS = { + "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07", +} + +local SUPPORTED = {} +for _, version in ipairs(_M.SUPPORTED_VERSIONS) do + SUPPORTED[version] = true +end + +_M.SERVER_NAME = "openapi2mcp" +-- the SSE transport reports a name of its own +_M.SSE_SERVER_NAME = "openapi2mcp-sse" +_M.SERVER_VERSION = "0.0.1" + + +function _M.is_supported(version) + return type(version) == "string" and SUPPORTED[version] == true +end + + +-- A version the server knows is echoed back; anything else falls back to the +-- newest one, matching the SDK's Server.oninitialize. +function _M.negotiate(requested) + if type(requested) == "string" and SUPPORTED[requested] then + return requested + end + return _M.LATEST_VERSION +end + + +-- A bare tools capability: the tool list of a route only changes with its +-- configuration, so there is no listChanged notification to advertise. +function _M.capabilities() + return { tools = {} } +end + + +function _M.server_info(transport) + if transport == "sse" then + return { name = _M.SSE_SERVER_NAME, version = _M.SERVER_VERSION } + end + return { name = _M.SERVER_NAME, version = _M.SERVER_VERSION } +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/server.lua b/apisix/plugins/openapi-to-mcp/server.lua new file mode 100644 index 000000000000..32a2536b7850 --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/server.lua @@ -0,0 +1,132 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local jsonrpc = require("apisix.plugins.openapi-to-mcp.jsonrpc") +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 type = type +local tostring = tostring +local setmetatable = setmetatable + +local _M = {} + + +-- Internal tool records use snake_case; the wire format does not. The SDK also +-- emits an `execution` field, which is a property of the SDK's own task support +-- rather than of openapi-to-mcp, so it is deliberately not reproduced. +local function to_wire_tools(tools) + local out = setmetatable({}, core.json.array_mt) + for index, tool in ipairs(tools) do + out[index] = { + name = tool.name, + description = tool.description, + inputSchema = tool.input_schema, + annotations = tool.annotations, + } + end + return out +end + + +local function find_tool(tools, name) + for _, tool in ipairs(tools) do + if tool.name == name then + return tool + end + end + return nil +end + + +local function tool_error(id, message) + return jsonrpc.result(id, { + content = { { type = "text", text = message } }, + isError = true, + }) +end + + +local function handle_tools_call(request, opts, tools) + local params = request.params or {} + local name = params.name + + if type(name) ~= "string" then + return tool_error(request.id, "MCP error -32602: Tool name is required") + end + + local tool = find_tool(tools, name) + if not tool then + return tool_error(request.id, "MCP error -32602: Tool " .. name .. " not found") + end + + local arguments = type(params.arguments) == "table" and params.arguments or {} + + local ok, err = core.schema.check(tool.input_schema, arguments) + if not ok then + -- The MCP SDK embeds its validator's error array here. jsonschema words + -- its failures differently, so only the prefix is reproduced; clients + -- key off isError, not the wording. + return tool_error(request.id, "MCP error -32602: Input validation error: " .. + "Invalid arguments for tool " .. name .. ": " .. tostring(err)) + end + + return jsonrpc.result(request.id, handler.call(tool, arguments, opts)) +end + + +-- Returns the response table, or nil for a notification (the caller answers 202). +-- The request has already passed jsonrpc.validate() in the transport. +function _M.handle(request, opts) + if jsonrpc.is_notification(request) then + return nil + end + + local method = request.method + local params = request.params or {} + + if method == "initialize" then + return jsonrpc.result(request.id, { + protocolVersion = protocol.negotiate(params.protocolVersion), + capabilities = protocol.capabilities(), + serverInfo = protocol.server_info(opts.transport), + }) + end + + if method == "ping" then + return jsonrpc.result(request.id, {}) + end + + if method == "tools/list" or method == "tools/call" then + local tools, err = cache.get_tools(opts.conf) + if not tools then + return jsonrpc.error(request.id, jsonrpc.ERR_INTERNAL, + "failed to load openapi spec: " .. tostring(err)) + end + + if method == "tools/list" then + return jsonrpc.result(request.id, { tools = to_wire_tools(tools) }) + end + return handle_tools_call(request, opts, tools) + end + + return jsonrpc.error(request.id, jsonrpc.ERR_METHOD_NOT_FOUND, "Method not found") +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/session.lua b/apisix/plugins/openapi-to-mcp/session.lua new file mode 100644 index 000000000000..6c9ef5328e3f --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/session.lua @@ -0,0 +1,140 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local ngx_shared = ngx.shared +local type = type +local tostring = tostring + +local _M = {} + +local DICT_NAME = "mcp-session" + +-- An idle session is dropped after 30 minutes. +local SESSION_TTL = 1800 + +local ALIVE_SUFFIX = ":alive" +local QUEUE_SUFFIX = ":queue" + + +local function store() + local dict = ngx_shared[DICT_NAME] + if not dict then + return nil, "shared dict '" .. DICT_NAME .. "' is not declared" + end + return dict +end + + +function _M.create() + local dict, err = store() + if not dict then + return nil, err + end + + local session_id = core.id.gen_uuid_v4() + local ok, set_err = dict:set(session_id .. ALIVE_SUFFIX, true, SESSION_TTL) + if not ok then + return nil, "failed to register session: " .. tostring(set_err) + end + return session_id +end + + +function _M.exists(session_id) + if type(session_id) ~= "string" or session_id == "" then + return false + end + local dict = store() + if not dict then + return false + end + return dict:get(session_id .. ALIVE_SUFFIX) ~= nil +end + + +-- Keeps the liveness marker from expiring while the stream is still open. +-- Returns false when the marker could not be refreshed, which means the stream +-- is about to become unreachable for its own message endpoint. +function _M.touch(session_id) + local dict, err = store() + if not dict then + return false, err + end + local ok, set_err = dict:set(session_id .. ALIVE_SUFFIX, true, SESSION_TTL) + if not ok then + return false, "failed to refresh session: " .. tostring(set_err) + end + return true +end + + +-- The POST that carries a JSON-RPC message and the GET that streams the answer +-- may land on different workers, so the queue lives in shared memory. +function _M.push(session_id, message) + local dict, err = store() + if not dict then + return nil, err + end + -- Never resurrect the queue of a torn-down session: nothing would drain it, + -- and a shared dict list carries no TTL, so the entry would sit there until + -- the dict runs out of room. + if dict:get(session_id .. ALIVE_SUFFIX) == nil then + return nil, "session is gone" + end + + local length, push_err = dict:rpush(session_id .. QUEUE_SUFFIX, message) + if not length then + return nil, "failed to queue message: " .. tostring(push_err) + end + + -- The stream that drains this queue usually runs in another worker, so it + -- can tear the session down between the check above and the push. There is + -- no compare-and-set on a shared dict, but checking again afterwards is + -- enough: either destroy ran before the push and this sees the session + -- gone, or it ran after and deleted the queue itself. Neither order leaves + -- a list behind. + if dict:get(session_id .. ALIVE_SUFFIX) == nil then + dict:delete(session_id .. QUEUE_SUFFIX) + return nil, "session is gone" + end + + return true +end + + +function _M.pop(session_id) + local dict = store() + if not dict then + return nil + end + local message = dict:lpop(session_id .. QUEUE_SUFFIX) + return message +end + + +function _M.destroy(session_id) + local dict = store() + if not dict then + return + end + -- shared dict lists carry no TTL of their own, so drop it explicitly + dict:delete(session_id .. QUEUE_SUFFIX) + dict:delete(session_id .. ALIVE_SUFFIX) +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/tools/generator.lua b/apisix/plugins/openapi-to-mcp/tools/generator.lua new file mode 100644 index 000000000000..4b7cac4ad65e --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/tools/generator.lua @@ -0,0 +1,425 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local endpoints = require("apisix.plugins.openapi-to-mcp.openapi.endpoints") +local oas_schema = require("apisix.plugins.openapi-to-mcp.openapi.schema") +local pairs = pairs +local ipairs = ipairs +local next = next +local type = type +local tostring = tostring +local str_sub = string.sub +local str_gsub = string.gsub +local str_upper = string.upper +local str_lower = string.lower +local str_gmatch = string.gmatch +local table_sort = table.sort + +local _M = {} + +local PARAM_LOCATIONS = { "path", "query", "header" } + +local FLATTEN_DESC_PREFIX = { + path = "Path parameter: ", + query = "Query parameter: ", + header = "Header parameter: ", +} + +local NESTED_CONTAINER = { + path = "pathParameters", + query = "queryParameters", + header = "headerParameters", +} + +local ANNOTATION_EXTENSION = "x-mcp-annotations" + +local BOOLEAN_ANNOTATION_KEYS = { + "readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint", +} + +local BOOLEAN_ANNOTATION_SET = {} +for _, key in ipairs(BOOLEAN_ANNOTATION_KEYS) do + BOOLEAN_ANNOTATION_SET[key] = true +end + + +-- JavaScript treats "" as falsy, so `a || b || c` skips an empty description +-- and falls through to the next candidate. Lua's `or` does not, which would +-- leave `"description": ""` where there should be either nothing or the +-- generated default. petstore's deletePet carries exactly such a parameter. +local function present(str) + if type(str) == "string" and str ~= "" then + return str + end + return nil +end + + +local function trim(str) + local out = str_gsub(str, "^%s*(.-)%s*$", "%1") + return out +end + + +-- It lowercases the whole string first, +-- so "{userId}" becomes "Userid", not "UserId". Keep that. +function _M.title_case(str) + local out = str_lower(str) + out = str_gsub(out, "([-_/])(.)", function(_, char) + return str_upper(char) + end) + out = str_gsub(out, "^{", "") + out = str_gsub(out, "}$", "") + out = str_gsub(out, "^.", str_upper) + return out +end + + +-- "By" is only appended when the *last* segment is a path parameter, so +-- "get /users/{userId}/posts" yields "GetUsersPosts", not +-- "GetUsersPostsByUserId". +function _M.gen_operation_id(method, path) + local parts = {} + for part in str_gmatch(path, "[^/]+") do + parts[#parts + 1] = part + end + + local lower_method = str_lower(method) + local name = lower_method + + for index, part in ipairs(parts) do + if str_sub(part, 1, 1) == "{" and str_sub(part, -1) == "}" then + if index == #parts then + name = name .. "By" .. _M.title_case(part) + end + else + name = name .. _M.title_case(part) + end + end + + if name == lower_method then + name = name .. "Root" + end + + return str_upper(str_sub(name, 1, 1)) .. str_sub(name, 2) +end + + +local function sanitize_name(name) + local out = str_gsub(name, "%.", "_") + out = str_gsub(out, "[^A-Za-z0-9_%-]", "_") + return out +end + + +local function infer_annotations(method) + local upper = str_upper(method) + if upper == "GET" or upper == "HEAD" or upper == "OPTIONS" then + return { readOnlyHint = true } + elseif upper == "DELETE" then + return { destructiveHint = true, idempotentHint = true } + elseif upper == "PUT" then + return { idempotentHint = true } + end + return {} +end + + +local function extract_custom_annotations(operation, op_id) + local raw = operation[ANNOTATION_EXTENSION] + -- an array is not a valid annotation object; in Lua an array has [1] set + if type(raw) ~= "table" or raw[1] ~= nil then + return {} + end + + local out = {} + + if raw.title ~= nil then + if type(raw.title) == "string" and trim(raw.title) ~= "" then + out.title = trim(raw.title) + else + core.log.warn("ignoring invalid ", ANNOTATION_EXTENSION, + ".title for operation ", tostring(op_id)) + end + end + + for _, key in ipairs(BOOLEAN_ANNOTATION_KEYS) do + if raw[key] ~= nil then + if type(raw[key]) == "boolean" then + out[key] = raw[key] + else + core.log.warn("ignoring invalid ", ANNOTATION_EXTENSION, ".", key, + " for operation ", tostring(op_id)) + end + end + end + + for key in pairs(raw) do + if key ~= "title" and not BOOLEAN_ANNOTATION_SET[key] then + core.log.warn("ignoring unsupported ", ANNOTATION_EXTENSION, ".", tostring(key), + " for operation ", tostring(op_id)) + end + end + + return out +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. +local function named(param, location) + if type(param.name) == "string" and param.name ~= "" then + return true + end + core.log.warn("skipping parameter without a name, in: ", location) + return false +end + + +-- Swagger 2.0 puts the schema keywords directly on a non-body parameter instead +-- of under `schema`; reading them from the parameter itself is enough. Keyed on +-- `type` being a string, which a 3.0 `content`-style parameter never has. +local SWAGGER2_SCHEMA_KEYS = { + "type", "format", "items", "default", "enum", "multipleOf", + "maximum", "exclusiveMaximum", "minimum", "exclusiveMinimum", + "maxLength", "minLength", "pattern", "maxItems", "minItems", "uniqueItems", +} + + +local function param_schema_source(param) + if param.schema ~= nil then + return param.schema + end + if type(param.type) ~= "string" then + return nil + end + + local inline = {} + for _, key in ipairs(SWAGGER2_SCHEMA_KEYS) do + inline[key] = param[key] + end + return inline +end + + +local function build_flat_params(params, properties, required) + for _, location in ipairs(PARAM_LOCATIONS) do + local prefix = FLATTEN_DESC_PREFIX[location] + for _, param in ipairs(params) do + if type(param) == "table" and param["in"] == location + and named(param, location) + then + local param_schema = oas_schema.to_json_schema(param_schema_source(param)) + if type(param_schema) == "table" then + param_schema.description = present(param.description) + or present(param_schema.description) + or (prefix .. param.name) + end + -- a parameter with neither `schema` nor a 2.0 `type` yields + -- nil, and the property is dropped + properties[param.name] = param_schema + if param.required then + required[#required + 1] = param.name + end + end + end + end +end + + +local function build_nested_params(params, properties, required) + for _, location in ipairs(PARAM_LOCATIONS) do + local container = {} + local container_required = {} + local matched = false + + for _, param in ipairs(params) do + if type(param) == "table" and param["in"] == location + and named(param, location) + then + local param_schema = oas_schema.to_json_schema(param_schema_source(param)) + if type(param_schema) == "table" then + param_schema.description = present(param.description) + or present(param_schema.description) + else + -- an empty schema still keeps the key + param_schema = {} + end + matched = true + container.type = "object" + container.properties = container.properties or {} + container.properties[param.name] = param_schema + if param.required then + container_required[#container_required + 1] = param.name + end + end + end + + if matched then + if #container_required > 0 then + container.required = container_required + required[#required + 1] = NESTED_CONTAINER[location] + end + container.additionalProperties = false + properties[NESTED_CONTAINER[location]] = container + end + end +end + + +local function build_request_body(operation, properties, required) + local request_body = operation.requestBody + if type(request_body) ~= "table" then + return nil + end + + local content = request_body.content + if type(content) ~= "table" then + return nil + end + + local json_content = content["application/json"] + if type(json_content) == "table" and json_content.schema ~= nil then + local body_schema = oas_schema.to_json_schema(json_content.schema) + if type(body_schema) == "table" then + body_schema.description = present(request_body.description) + or present(body_schema.description) + or "The JSON request body." + end + properties.requestBody = body_schema + if request_body.required then + required[#required + 1] = "requestBody" + end + return "application/json" + end + + -- Document order would be the natural choice, but Lua tables are + -- unordered, so pick the lexicographically smallest key to stay + -- deterministic. Only reachable when a body declares several non-JSON + -- content types. + local types = {} + for content_type in pairs(content) do + types[#types + 1] = content_type + end + if #types == 0 then + return nil + end + table_sort(types) + local content_type = types[1] + + properties.requestBody = { + type = "string", + description = present(request_body.description) + or ("Request body (content type: " .. content_type .. ")"), + } + if request_body.required then + required[#required + 1] = "requestBody" + end + return content_type +end + + +function _M.build_input_schema(operation, flatten_parameters) + local properties = {} + local required = {} + + local params = operation.parameters + if type(params) ~= "table" then + params = {} + end + + if flatten_parameters then + build_flat_params(params, properties, required) + else + build_nested_params(params, properties, required) + end + + local request_body_content_type = build_request_body(operation, properties, required) + + local input_schema = { type = "object", properties = properties } + if #required > 0 then + input_schema.required = required + end + + return input_schema, params, request_body_content_type +end + + +function _M.generate(spec, path_order, opts) + opts = opts or {} + local flatten_parameters = opts.flatten_parameters == true + + local tools = {} + local used_names = {} + + for _, endpoint in ipairs(endpoints.extract(spec, path_order)) do + local operation = endpoint.operation + + local base_name = operation.operationId + if type(base_name) ~= "string" or base_name == "" then + base_name = _M.gen_operation_id(endpoint.method, endpoint.path) + end + base_name = sanitize_name(base_name) + + local name = base_name + local counter = 1 + while used_names[name] do + name = base_name .. "_" .. counter + counter = counter + 1 + end + used_names[name] = true + + local description = present(operation.description) or present(operation.summary) + if not description then + description = "Executes " .. str_upper(endpoint.method) .. " " .. endpoint.path + end + + local input_schema, params, content_type = + _M.build_input_schema(operation, flatten_parameters) + + local execution_parameters = {} + for index, param in ipairs(params) do + execution_parameters[index] = { name = param.name, ["in"] = param["in"] } + end + + local annotations = infer_annotations(endpoint.method) + for key, value in pairs(extract_custom_annotations(operation, name)) do + annotations[key] = value + end + if next(annotations) == nil then + annotations = nil + end + + tools[#tools + 1] = { + name = name, + description = description, + input_schema = input_schema, + method = endpoint.method, + path_template = endpoint.path, + parameters = params, + execution_parameters = execution_parameters, + request_body_content_type = content_type, + annotations = annotations, + } + end + + return tools +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/tools/handler.lua b/apisix/plugins/openapi-to-mcp/tools/handler.lua new file mode 100644 index 000000000000..6f8b410e83cf --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/tools/handler.lua @@ -0,0 +1,373 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local http = require("resty.http") +local json_pretty = require("apisix.plugins.openapi-to-mcp.json_pretty") +local pairs = pairs +local ipairs = ipairs +local type = type +local getmetatable = getmetatable +local tostring = tostring +local str_lower = string.lower +local str_upper = string.upper +local str_gsub = string.gsub +local str_gmatch = string.gmatch +local str_find = string.find +local table_concat = table.concat +local table_sort = table.sort +local escape_uri = ngx.escape_uri + +local _M = {} + +local DEFAULT_TIMEOUT = 30000 +local NESTED_KEYS = { "pathParameters", "queryParameters", "headerParameters" } + + +local function path_param_names(template) + local names = {} + for name in str_gmatch(template, "{([^}]+)}") do + names[#names + 1] = name + end + return names +end + + +local function names_by_location(tool, location) + local names = {} + for _, param in ipairs(tool.parameters or {}) do + if type(param) == "table" and param["in"] == location then + names[#names + 1] = param.name + end + end + return names +end + + +local function pick(arguments, names) + local out = {} + for _, name in ipairs(names) do + if arguments[name] ~= nil then + out[name] = arguments[name] + end + end + return out +end + + +-- Whether the caller used the flat or the nested argument shape is decided by +-- the arguments themselves, not by the plugin's flatten_parameters setting. +-- A client that sends flat arguments to a nested tool therefore still works. +local function split_arguments(tool, arguments) + local nested = false + for _, key in ipairs(NESTED_KEYS) do + if arguments[key] ~= nil then + nested = true + break + end + end + + if nested then + return type(arguments.pathParameters) == "table" and arguments.pathParameters or {}, + type(arguments.queryParameters) == "table" and arguments.queryParameters or {}, + type(arguments.headerParameters) == "table" and arguments.headerParameters or {} + end + + return pick(arguments, path_param_names(tool.path_template)), + pick(arguments, names_by_location(tool, "query")), + pick(arguments, names_by_location(tool, "header")) +end + + +local function apply_query_defaults(tool, query) + for _, param in ipairs(tool.parameters or {}) do + if type(param) == "table" and param["in"] == "query" + and type(param.schema) == "table" + and param.schema.default ~= nil + and query[param.name] == nil + then + query[param.name] = param.schema.default + end + end +end + + +local function build_path(template, path_params) + local path = template + for name, value in pairs(path_params) do + -- the name is a literal, so escape any pattern magic in it + local pattern = "{" .. str_gsub(name, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") .. "}" + local escaped = escape_uri(tostring(value)) + -- The replacement has to be a function: percent-encoding produces "%2F" + -- and friends, and gsub would read those as capture references in a + -- string replacement and raise "invalid capture index". + path = str_gsub(path, pattern, function() + return escaped + end) + end + return path +end + + +local function sorted_keys(tab) + -- Lua tables are unordered; sort so the query string is the same on every + -- worker + local keys = {} + for key in pairs(tab) do + keys[#keys + 1] = key + end + table_sort(keys, function(a, b) + return tostring(a) < tostring(b) + end) + return keys +end + + +local function scalar(value) + if type(value) == "table" then + return core.json.encode(value) or "" + end + return tostring(value) +end + + +local function is_array(value) + return #value > 0 or getmetatable(value) == core.json.array_mt +end + + +local DELIMITERS = { + form = ",", + spaceDelimited = "%20", + pipeDelimited = "|", +} + + +-- Serialize one query parameter the way its Parameter Object says: +-- https://spec.openapis.org/oas/v3.0.3#style-values +-- `style` defaults to form, and `explode` defaults to true for form and false +-- otherwise. +-- +-- value form, explode form, no explode space/pipeDelimited deepObject +-- tags = {a, b} tags=a&tags=b tags=a,b tags=a%20b / a|b - +-- f = {x = 1} x=1 f=x,1 - f[x]=1 +local function encode_query_param(name, value, param, parts) + local style = type(param) == "table" and param.style or "form" + local explode = type(param) == "table" and param.explode + if explode == nil then + explode = style == "form" + end + + local ename = escape_uri(name) + if type(value) ~= "table" then + parts[#parts + 1] = ename .. "=" .. escape_uri(tostring(value)) + return + end + + if is_array(value) then + if #value == 0 then + return + end + if explode then + for _, item in ipairs(value) do + parts[#parts + 1] = ename .. "=" .. escape_uri(scalar(item)) + end + return + end + local items = {} + for i, item in ipairs(value) do + items[i] = escape_uri(scalar(item)) + end + parts[#parts + 1] = ename .. "=" .. table_concat(items, DELIMITERS[style] or ",") + return + end + + local keys = sorted_keys(value) + if #keys == 0 then + return + end + + if style == "deepObject" then + for _, key in ipairs(keys) do + parts[#parts + 1] = ename .. "%5B" .. escape_uri(tostring(key)) .. "%5D=" + .. escape_uri(scalar(value[key])) + end + return + end + + if explode then + for _, key in ipairs(keys) do + parts[#parts + 1] = escape_uri(tostring(key)) .. "=" .. escape_uri(scalar(value[key])) + end + return + end + + local items = {} + for _, key in ipairs(keys) do + items[#items + 1] = escape_uri(tostring(key)) + items[#items + 1] = escape_uri(scalar(value[key])) + end + parts[#parts + 1] = ename .. "=" .. table_concat(items, ",") +end + + +local function build_query(tool, query) + local keys = sorted_keys(query) + if #keys == 0 then + return nil + end + + local params = {} + for _, param in ipairs(tool.parameters or {}) do + if type(param) == "table" and param["in"] == "query" then + params[param.name] = param + end + end + + local parts = {} + for _, key in ipairs(keys) do + encode_query_param(tostring(key), query[key], params[key], parts) + end + if #parts == 0 then + return nil + end + return table_concat(parts, "&") +end + + +local function lower_headers(headers) + local out = {} + for key, value in pairs(headers or {}) do + out[str_lower(key)] = value + end + return out +end + + +-- Every response body goes through a JSON decode and falls back to the raw +-- string, without looking at the content type. +local function decode_body(body) + if body == nil or body == "" then + return body + end + local decoded = core.json.decode(body) + if decoded == nil then + return body + end + return decoded +end + + +local function text_result(payload, is_error) + local text, err = json_pretty.encode(payload) + if not text then + text = "failed to encode response: " .. tostring(err) + end + return { + content = { { type = "text", text = text } }, + isError = is_error or nil, + } +end + + +local function has_header(headers, lower_name) + for key in pairs(headers) do + if str_lower(key) == lower_name then + return true + end + end + return false +end + + +function _M.call(tool, arguments, opts) + arguments = type(arguments) == "table" and arguments or {} + + local path_params, query_params, header_params = split_arguments(tool, arguments) + apply_query_defaults(tool, query_params) + + local path = build_path(tool.path_template, path_params) + local query = build_query(tool, query_params) + + local headers = {} + for key, value in pairs(opts.headers or {}) do + headers[key] = value + end + for key, value in pairs(header_params) do + headers[key] = tostring(value) + end + + local body = arguments.requestBody + if body ~= nil then + if type(body) ~= "string" then + body = core.json.encode(body) + end + -- Label the body with the media type the operation declares, unless the + -- route's headers or a header parameter already set one. + if not has_header(headers, "content-type") then + headers["Content-Type"] = tool.request_body_content_type or "application/json" + end + end + + local base_url = opts.base_url or "" + if str_find(base_url, "/$") then + base_url = str_gsub(base_url, "/$", "") + end + local url = base_url .. path + if query then + url = url .. "?" .. query + end + + local httpc, client_err = http.new() + if not httpc then + return text_result({ + status = 0, + statusText = "Network Error", + headers = {}, + data = core.json.null, + error = { message = tostring(client_err), code = "NETWORK_ERROR" }, + }) + end + httpc:set_timeout(opts.timeout or DEFAULT_TIMEOUT) + + local res, req_err = httpc:request_uri(url, { + method = str_gsub(str_lower(tool.method), "^%l", str_upper), + headers = headers, + body = body, + }) + + if not res then + -- A transport failure is reported inside a normal text result, with + -- isError unset: the call reached no API, so there is no API error. + return text_result({ + status = 0, + statusText = "Network Error", + headers = {}, + data = core.json.null, + error = { message = tostring(req_err), code = "NETWORK_ERROR" }, + }) + end + + return text_result({ + status = res.status, + statusText = res.reason or "", + headers = lower_headers(res.headers), + data = decode_body(res.body), + }) +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/transport/sse.lua b/apisix/plugins/openapi-to-mcp/transport/sse.lua new file mode 100644 index 000000000000..616fa1d70340 --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/transport/sse.lua @@ -0,0 +1,244 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local cache = require("apisix.plugins.openapi-to-mcp.cache") +local session = require("apisix.plugins.openapi-to-mcp.session") +local server = require("apisix.plugins.openapi-to-mcp.server") +local jsonrpc = require("apisix.plugins.openapi-to-mcp.jsonrpc") +local ngx = ngx +local str_find = string.find +local ngx_print = ngx.print +local ngx_flush = ngx.flush +local ngx_exit = ngx.exit +local ngx_sleep = ngx.sleep +local ngx_now = ngx.now +local worker_exiting = ngx.worker.exiting +local type = type +local tostring = tostring + +local _M = {} + +-- An idle session is dropped after 30 minutes. +local STREAM_MAX_LIFETIME = 1800 + +-- How often an idle stream emits an SSE comment. Comments start with ":" and +-- are ignored by every SSE client, so this does not alter the protocol; it +-- gives intermediaries something to see and refreshes the session marker. +-- +-- It does not detect a client that went away. Writing to a dropped connection +-- reports no error here: that needs lua_check_client_abort, which is an +-- http-level directive and not one plugin's to turn on for the whole gateway. +-- A stream whose client is gone therefore runs until STREAM_MAX_LIFETIME; the +-- cost is one held connection and two shared +-- dict entries per abandoned stream, for at most half an hour. Measured, not +-- assumed: no write error appears after a FIN or an RST, past the keepalive. +local KEEPALIVE_INTERVAL = 30 + +local POLL_INTERVAL = 0.1 + + +local function emit(chunk) + local ok, err = ngx_print(chunk) + if not ok then + return nil, err + end + return ngx_flush(true) +end + + +-- The session id is what authorises a POST to this session's message endpoint, +-- so it is a bearer credential and stays out of the logs. Stream lifecycle +-- lines carry the reason, not the identifier. +local function handle_get(ctx, opts) + -- Build the tool list -- which means fetching and parsing the document -- + -- before opening the stream, and answer 500 when that fails. Opening the + -- stream first would let a route with an unreachable + -- openapi_url look like a working connection to the client, and every + -- request on it would fail instead. The result is cached, so this is the + -- same fetch the first tools/list would have paid for. + local _, tools_err = cache.get_tools(opts.conf) + if tools_err then + core.log.error("failed to build the MCP tool list: ", tools_err) + return core.response.exit(500, { + error = "Failed to create session", + message = tools_err, + }) + end + + local session_id, err = session.create() + if not session_id then + core.log.error("failed to create MCP session: ", err) + return core.response.exit(500) + end + + ngx.status = 200 + core.response.set_header("Content-Type", "text/event-stream") + -- no-transform keeps an intermediary + -- from rewriting or compressing the event stream + core.response.set_header("Cache-Control", "no-cache, no-transform") + + -- Tell the client where to POST its messages: + -- "?sessionId=". + local endpoint = tostring(opts.message_path) .. "?sessionId=" .. session_id + local ok, emit_err = emit("event: endpoint\ndata: " .. endpoint .. "\n\n") + if not ok then + core.log.info("MCP stream closed before start: ", emit_err) + session.destroy(session_id) + return ngx_exit(0) + end + + local deadline = ngx_now() + STREAM_MAX_LIFETIME + local last_keepalive = ngx_now() + + while not worker_exiting() do + local message = session.pop(session_id) + + if message then + local sent, send_err = emit("event: message\ndata: " .. message .. "\n\n") + if not sent then + core.log.info("MCP stream disconnected: ", send_err) + break + end + else + local now = ngx_now() + if now > deadline then + core.log.info("MCP stream reached its maximum lifetime") + break + end + + if now - last_keepalive >= KEEPALIVE_INTERVAL then + local alive, alive_err = emit(": keepalive\n\n") + if not alive then + core.log.info("MCP stream disconnected: ", alive_err) + break + end + last_keepalive = now + + local refreshed, refresh_err = session.touch(session_id) + if not refreshed then + -- the marker is gone, so the message endpoint would start + -- rejecting this session's POSTs; close instead of pretending + core.log.warn("MCP session could not be refreshed: ", refresh_err) + break + end + end + + ngx_sleep(POLL_INTERVAL) + end + end + + session.destroy(session_id) + return ngx_exit(0) +end + + +-- Both shapes follow the MCP SDK, including the -32000 code and the null id: +-- 400 when the query string carries no session, 404 when it names one the +-- gateway does not know. +local function session_error(status, message) + return core.response.exit(status, { + jsonrpc = "2.0", + error = { code = -32000, message = message }, + id = core.json.null, + }) +end + + +-- A message body is rejected on two different grounds, with two different +-- statuses: 415 when the POST carries no content type at all, and 400 when it +-- carries one that is not JSON. +local function content_type_error(content_type) + if type(content_type) ~= "string" or content_type == "" then + return 415, "Unsupported Media Type: Content-Type must be application/json" + end + if not str_find(content_type, "application/json", 1, true) then + return 400, "Unsupported content-type: " .. content_type + end + return nil +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. + if str_find(core.request.header(ctx, "content-type") or "", "application/json", 1, true) + and ctx._request_body_table == nil + and core.request.get_json_request_body_table() == nil + then + return core.response.exit(400, { error = "invalid message body" }) + end + + local session_id = ctx.var.arg_sessionId + if type(session_id) ~= "string" or session_id == "" then + return session_error(400, "Missing or invalid sessionId parameter") + end + if not session.exists(session_id) then + return session_error(404, "Session not found for sessionId") + end + + local ct_status, ct_message = content_type_error(core.request.header(ctx, "content-type")) + if ct_status then + return session_error(ct_status, ct_message) + end + + -- the check at the top of this function already decoded and cached this body + local request = ctx._request_body_table + if type(request) ~= "table" then + local body, err = core.request.get_json_request_body_table() + if type(body) ~= "table" then + core.log.warn("failed to parse MCP message body: ", + type(err) == "table" and err.message or err) + return core.response.exit(400, { error = "invalid message body" }) + end + request = body + end + + if not jsonrpc.validate(request) then + return core.response.exit(400, jsonrpc.invalid_message()) + end + + local response = server.handle(request, opts) + if response then + local encoded, encode_err = core.json.encode(response) + if not encoded then + core.log.error("failed to encode MCP response: ", encode_err) + return core.response.exit(500) + end + + local pushed, push_err = session.push(session_id, encoded) + if not pushed then + core.log.error("failed to deliver MCP response: ", push_err) + return core.response.exit(500) + end + end + + -- The answer travels on the SSE stream, not in this response, so there is + -- no body here and no content type to declare for it. + core.response.set_header("Content-Type", nil) + return core.response.exit(202) +end + + +function _M.handle(ctx, opts) + if core.request.get_method() == "GET" then + return handle_get(ctx, opts) + end + return handle_post(ctx, opts) +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp/transport/streamable_http.lua b/apisix/plugins/openapi-to-mcp/transport/streamable_http.lua new file mode 100644 index 000000000000..42057f6f5356 --- /dev/null +++ b/apisix/plugins/openapi-to-mcp/transport/streamable_http.lua @@ -0,0 +1,204 @@ +-- +-- 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. +-- +local core = require("apisix.core") +local cache = require("apisix.plugins.openapi-to-mcp.cache") +local server = require("apisix.plugins.openapi-to-mcp.server") +local protocol = require("apisix.plugins.openapi-to-mcp.protocol") +local jsonrpc = require("apisix.plugins.openapi-to-mcp.jsonrpc") +local table_concat = table.concat +local ngx = ngx +local ngx_print = ngx.print +local ngx_flush = ngx.flush +local ngx_exit = ngx.exit +local str_find = string.find +local type = type +local setmetatable = setmetatable +local tostring = tostring + +local _M = {} + +local SERVER_ERROR_CODE = -32000 + +-- Transport-level rejections are answered with a JSON-RPC envelope and the HTTP +-- status the MCP spec calls for. +local function transport_error(status, message, data) + return core.response.exit(status, { + jsonrpc = "2.0", + error = { code = SERVER_ERROR_CODE, message = message, data = data }, + id = core.json.null, + }) +end + + +-- The stateless handler builds its server -- which means fetching and parsing +-- the document -- before it looks at the message, so a document that cannot be +-- built fails every request that gets that far, ping and initialize included, +-- with this exact envelope. +local function stateless_internal_error(err) + return core.response.exit(500, { + jsonrpc = "2.0", + error = { + code = jsonrpc.ERR_INTERNAL, + message = "Internal error: Failed to process stateless request", + data = { mode = "stateless", error = tostring(err) }, + }, + id = core.json.null, + }) +end + + +local function is_json_content_type(content_type) + if type(content_type) ~= "string" then + return false + end + return str_find(content_type, "application/json", 1, true) ~= nil +end + + +-- MCP 2025-06-18 has the client repeat the negotiated version in a header on +-- every request after initialize. The SDK rejects a value it does not know; +-- absent is fine (a 2025-03-26 client never sends one), and initialize itself is +-- exempt, since that is the request doing the negotiating. +local SUPPORTED_LIST = table_concat(protocol.SUPPORTED_VERSIONS, ", ") + + +local function unsupported_protocol_version(request, ctx) + if request.method == "initialize" then + return nil + end + local header = core.request.header(ctx, "mcp-protocol-version") + if header == nil or protocol.is_supported(header) then + return nil + end + return header +end + + +-- MCP's Streamable HTTP binding requires the client to accept both media types; +-- the SDK rejects anything else before the message is even parsed. +local function accepts_both(accept) + if type(accept) ~= "string" then + return false + end + return str_find(accept, "application/json", 1, true) ~= nil + and str_find(accept, "text/event-stream", 1, true) ~= nil +end + + +-- An empty or unparsable JSON body is answered ahead of every check below, a +-- bad Accept header included. A body that parses to a scalar is not this case: it gets as far as +-- JSON-RPC validation. +local function body_unparsable(ctx) + if not is_json_content_type(core.request.header(ctx, "content-type")) then + return false + end + if ctx._request_body_table ~= nil then + return false + end + return core.request.get_json_request_body_table() == nil +end + + +local ALLOWED_METHODS = setmetatable({ "POST" }, core.json.array_mt) + + +function _M.handle(ctx, opts) + local http_method = core.request.get_method() + if http_method ~= "POST" then + return transport_error(405, "Method Not Allowed: " .. http_method .. + " requests are not supported for stateless MCP endpoint", + { mode = "stateless", allowedMethods = ALLOWED_METHODS }) + end + + if body_unparsable(ctx) then + return core.response.exit(400, jsonrpc.invalid_message()) + end + + -- The document is cached, so this is the fetch the first tools/list would + -- have paid for anyway; it only costs anything when the build fails. + local _, tools_err = cache.get_tools(opts.conf) + if tools_err then + core.log.error("failed to build the MCP tool list: ", tools_err) + return stateless_internal_error(tools_err) + end + + if not accepts_both(core.request.header(ctx, "accept")) then + return transport_error(406, "Not Acceptable: Client must accept both " .. + "application/json and text/event-stream") + end + + if not is_json_content_type(core.request.header(ctx, "content-type")) then + return transport_error(415, "Unsupported Media Type: " .. + "Content-Type must be application/json") + end + + -- body_unparsable() above already decoded and cached this body; do not + -- decode a second time. + local request = ctx._request_body_table + if type(request) ~= "table" then + local body, err = core.request.get_json_request_body_table() + if type(body) ~= "table" then + core.log.warn("failed to parse MCP request body: ", + type(err) == "table" and err.message or err) + return core.response.exit(400, jsonrpc.invalid_message()) + end + request = body + end + + if not jsonrpc.validate(request) then + return core.response.exit(400, jsonrpc.invalid_message()) + end + + -- after the message is validated and before it is dispatched, which is where + -- the SDK checks it + local bad_version = unsupported_protocol_version(request, ctx) + if bad_version then + return transport_error(400, "Bad Request: Unsupported protocol version: " .. + bad_version .. " (supported versions: " .. + SUPPORTED_LIST .. ")") + end + + local response = server.handle(request, opts) + if not response then + -- notification: accepted, nothing to answer. No body means no content + -- type; the plugin sets application/json for every reply that has one. + core.response.set_header("Content-Type", nil) + return core.response.exit(202) + end + + local encoded, err = core.json.encode(response) + if not encoded then + core.log.error("failed to encode MCP response: ", err) + return core.response.exit(500) + end + + ngx.status = 200 + core.response.set_header("Content-Type", "text/event-stream") + core.response.set_header("Cache-Control", "no-cache") + ngx_print("event: message\ndata: ", encoded, "\n\n") + + local flushed, flush_err = ngx_flush(true) + if not flushed then + -- nothing to recover here; the client is already gone + core.log.info("client left before the MCP response was flushed: ", flush_err) + end + + return ngx_exit(200) +end + + +return _M diff --git a/conf/config.yaml.example b/conf/config.yaml.example index b201c97fbde0..b9f10261b05a 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -616,6 +616,7 @@ plugins: # plugin list (sorted by priority) - traffic-split # priority: 966 - redirect # priority: 900 - response-rewrite # priority: 899 + - openapi-to-mcp # priority: 540 - oas-validator # priority: 512 - mcp-bridge # priority: 510 - degraphql # priority: 509 diff --git a/docker/debian-dev/Dockerfile b/docker/debian-dev/Dockerfile index 25358c8deb92..2af50d2f6500 100644 --- a/docker/debian-dev/Dockerfile +++ b/docker/debian-dev/Dockerfile @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -FROM debian:bullseye-slim AS build +FROM debian:bookworm-slim AS build ARG ENABLE_PROXY=false ARG CODE_PATH @@ -43,20 +43,21 @@ RUN set -x \ pkg-config \ libssl-dev \ zlib1g-dev \ + xz-utils \ && ls -al \ && make deps \ && mkdir -p ${ENV_INST_LUADIR} \ && cp -r deps ${ENV_INST_LUADIR} \ && make install -FROM debian:bullseye-slim +FROM debian:bookworm-slim ARG ENTRYPOINT_PATH=./docker-entrypoint.sh ARG INSTALL_BROTLI=./install-brotli.sh # Install the runtime libyaml package RUN apt-get -y update --fix-missing \ - && apt-get install -y libldap2-dev libyaml-0-2 libxml2 libxslt1.1 \ + && apt-get install -y libldap2-dev libyaml-0-2 libxml2 libxslt1.1 libpcre3 \ && apt-get remove --purge --auto-remove -y \ && mkdir -p /usr/local/apisix/ui diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json index 21e5026245b7..c2a816aecfe2 100644 --- a/docs/en/latest/config.json +++ b/docs/en/latest/config.json @@ -255,7 +255,8 @@ "plugins/mqtt-proxy", "plugins/kafka-proxy", "plugins/http-dubbo", - "plugins/mcp-bridge" + "plugins/mcp-bridge", + "plugins/openapi-to-mcp" ] } ] diff --git a/docs/en/latest/plugins/openapi-to-mcp.md b/docs/en/latest/plugins/openapi-to-mcp.md new file mode 100644 index 000000000000..33d8d09d72d4 --- /dev/null +++ b/docs/en/latest/plugins/openapi-to-mcp.md @@ -0,0 +1,193 @@ +--- +title: openapi-to-mcp +keywords: + - Apache APISIX + - API Gateway + - Plugin + - MCP + - OpenAPI + - openapi-to-mcp +description: This document contains information about the Apache APISIX openapi-to-mcp Plugin, which turns the operations of an OpenAPI document into MCP tools and serves them from the gateway. +--- + + + +## Description + +The `openapi-to-mcp` Plugin exposes an existing HTTP API to [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) clients, such as LLM agents, without changing the API. It fetches the API's OpenAPI document, generates one MCP tool per operation, and answers the MCP protocol itself. When a client calls a tool, the Plugin sends the corresponding HTTP request to the API and returns the response as the tool result. + +The MCP server runs inside APISIX. No additional process or service is required. + +The Plugin supports: + +* The Streamable HTTP transport (stateless) and the HTTP+SSE transport. +* MCP protocol versions `2024-10-07`, `2024-11-05`, `2025-03-26`, `2025-06-18` and `2025-11-25`, negotiated during `initialize`. +* The `initialize`, `ping`, `tools/list` and `tools/call` methods. +* OpenAPI 3.x documents in JSON or YAML. Internal and `http(s)` `$ref` references are resolved. Swagger 2.0 documents are read on a best-effort basis: `in: body` and `in: formData` parameters are not turned into tool inputs. + +## Attributes + +| Name | Type | Required | Default | Valid values | Description | +|--------------------|---------|----------|---------|-----------------------------|-------------| +| transport | string | False | `sse` | [`sse`, `streamable_http`] | MCP transport served on the Route. | +| openapi_url | string | True | | | URL of the OpenAPI document. The document is fetched on the first request and the generated tools are cached for an hour. | +| 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`. + +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. +* A request body is sent with the media type the operation declares, unless `headers` sets `Content-Type`. + +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 + +The examples below use a Route with the ID `mcp`. An [admin key](../admin-api.md) is required for the Admin API calls: + +```shell +admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"//g') +``` + +### Serve an API over Streamable HTTP + +Create a Route that serves the tools of the Swagger Petstore API: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "mcp", + "uri": "/mcp", + "plugins": { + "openapi-to-mcp": { + "transport": "streamable_http", + "openapi_url": "https://petstore3.swagger.io/api/v3/openapi.json", + "base_url": "https://petstore3.swagger.io/api/v3" + } + } + }' +``` + +List the tools: + +```shell +curl "http://127.0.0.1:9080/mcp" -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +The response is a single SSE event carrying the JSON-RPC result: + +```text +event: message +data: {"result":{"tools":[{"name":"updatePet","description":"Update an existing pet by Id", ...}]},"jsonrpc":"2.0","id":1} +``` + +Call a tool: + +```shell +curl "http://127.0.0.1:9080/mcp" -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "findPetsByStatus", + "arguments": { "queryParameters": { "status": "sold" } } + } + }' +``` + +The tool result carries the status, status text, headers and body the API returned, as JSON text: + +```text +event: message +data: {"result":{"content":[{"type":"text","text":"{\n \"status\": 200,\n \"statusText\": \"OK\", ..."}]},"jsonrpc":"2.0","id":2} +``` + +An MCP client connects to `http://127.0.0.1:9080/mcp` using its Streamable HTTP transport. + +### Serve an API over SSE + +With `transport` set to `sse`, or left unset, a client opens the stream with a `GET` request. The first event tells it where to send its messages: + +```shell +curl -N "http://127.0.0.1:9080/mcp" +``` + +```text +event: endpoint +data: /mcp?sessionId=4c9b0a4e-1bb0-4f4d-9b0b-2f3c3e0f7a51 +``` + +The client then `POST`s each JSON-RPC message to that endpoint, receives `202 Accepted`, and reads the answer from the stream. + +### Forward credentials to the API + +Pass the caller's token through to the API by reading it from a request header: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "mcp", + "uri": "/mcp", + "plugins": { + "openapi-to-mcp": { + "transport": "streamable_http", + "openapi_url": "https://petstore3.swagger.io/api/v3/openapi.json", + "base_url": "https://petstore3.swagger.io/api/v3", + "headers": { + "Authorization": "Bearer ${http_x_api_token}" + } + } + } + }' +``` + +Other Plugins on the Route keep working. For example, `key-auth` or `limit-count` run before the MCP request is answered, and a request they reject never reaches the tools. + +## Delete Plugin + +To remove the `openapi-to-mcp` Plugin, delete it from the Route configuration. APISIX reloads the configuration automatically: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "mcp", + "uri": "/mcp", + "plugins": {}, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }' +``` diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json index bbd0b3ba1104..4217ba555249 100644 --- a/docs/zh/latest/config.json +++ b/docs/zh/latest/config.json @@ -239,7 +239,8 @@ "items": [ "plugins/dubbo-proxy", "plugins/mqtt-proxy", - "plugins/http-dubbo" + "plugins/http-dubbo", + "plugins/openapi-to-mcp" ] } ] diff --git a/docs/zh/latest/plugins/openapi-to-mcp.md b/docs/zh/latest/plugins/openapi-to-mcp.md new file mode 100644 index 000000000000..bbed2d9f06bc --- /dev/null +++ b/docs/zh/latest/plugins/openapi-to-mcp.md @@ -0,0 +1,193 @@ +--- +title: openapi-to-mcp +keywords: + - Apache APISIX + - API 网关 + - Plugin + - MCP + - OpenAPI + - openapi-to-mcp +description: openapi-to-mcp 插件将 OpenAPI 文档中的每个操作转换为 MCP 工具,并直接由网关提供 MCP 服务。 +--- + + + +## 描述 + +`openapi-to-mcp` 插件无需修改已有的 HTTP API,即可将其提供给 [Model Context Protocol](https://modelcontextprotocol.io/)(MCP)客户端(例如 LLM Agent)使用。插件会获取 API 的 OpenAPI 文档,为每个操作生成一个 MCP 工具,并由插件自身应答 MCP 协议。客户端调用工具时,插件向 API 发送对应的 HTTP 请求,并将响应作为工具结果返回。 + +MCP 服务运行在 APISIX 内部,不需要额外的进程或服务。 + +插件支持: + +* Streamable HTTP 传输(无状态)和 HTTP+SSE 传输。 +* MCP 协议版本 `2024-10-07`、`2024-11-05`、`2025-03-26`、`2025-06-18` 和 `2025-11-25`,在 `initialize` 时协商。 +* `initialize`、`ping`、`tools/list` 和 `tools/call` 方法。 +* JSON 或 YAML 格式的 OpenAPI 3.x 文档,支持解析内部引用和 `http(s)` 形式的 `$ref`。Swagger 2.0 文档尽力兼容:`in: body` 和 `in: formData` 参数不会转换为工具输入。 + +## 属性 + +| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | +|------|------|--------|--------|--------|------| +| transport | string | 否 | `sse` | [`sse`, `streamable_http`] | 路由上提供的 MCP 传输方式。 | +| openapi_url | string | 是 | | | OpenAPI 文档的 URL。文档在首次请求时获取,生成的工具缓存一小时。 | +| 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` 的结果。 + +调用工具时,插件根据操作定义构造请求: + +* 声明在 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`。 +* 请求体使用操作中声明的媒体类型发送,除非 `headers` 中已设置 `Content-Type`。 + +使用 SSE 传输时,会话保存在共享字典 `mcp-session` 中,因此同一会话的事件流请求和消息请求可以由不同的 worker 进程处理。会话只在单个 APISIX 实例内有效:多个实例部署在负载均衡之后时,同一 SSE 会话的请求必须到达同一实例。Streamable HTTP 传输是无状态的,没有这一限制。 + +## 使用示例 + +以下示例使用 ID 为 `mcp` 的路由。调用 Admin API 需要 [admin key](../admin-api.md): + +```shell +admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"//g') +``` + +### 通过 Streamable HTTP 提供 API + +创建一个路由,提供 Swagger Petstore API 的工具: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "mcp", + "uri": "/mcp", + "plugins": { + "openapi-to-mcp": { + "transport": "streamable_http", + "openapi_url": "https://petstore3.swagger.io/api/v3/openapi.json", + "base_url": "https://petstore3.swagger.io/api/v3" + } + } + }' +``` + +列出工具: + +```shell +curl "http://127.0.0.1:9080/mcp" -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +响应是一个携带 JSON-RPC 结果的 SSE 事件: + +```text +event: message +data: {"result":{"tools":[{"name":"updatePet","description":"Update an existing pet by Id", ...}]},"jsonrpc":"2.0","id":1} +``` + +调用工具: + +```shell +curl "http://127.0.0.1:9080/mcp" -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "findPetsByStatus", + "arguments": { "queryParameters": { "status": "sold" } } + } + }' +``` + +工具结果以 JSON 文本的形式包含 API 返回的状态码、状态文本、响应头和响应体: + +```text +event: message +data: {"result":{"content":[{"type":"text","text":"{\n \"status\": 200,\n \"statusText\": \"OK\", ..."}]},"jsonrpc":"2.0","id":2} +``` + +MCP 客户端使用其 Streamable HTTP 传输连接 `http://127.0.0.1:9080/mcp` 即可。 + +### 通过 SSE 提供 API + +`transport` 设置为 `sse` 或不设置时,客户端通过 `GET` 请求建立事件流。第一个事件告诉客户端消息应发往哪里: + +```shell +curl -N "http://127.0.0.1:9080/mcp" +``` + +```text +event: endpoint +data: /mcp?sessionId=4c9b0a4e-1bb0-4f4d-9b0b-2f3c3e0f7a51 +``` + +之后客户端将每条 JSON-RPC 消息 `POST` 到该地址,收到 `202 Accepted`,并从事件流中读取应答。 + +### 将凭证透传给 API + +从请求头中读取调用方的令牌并透传给 API: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "mcp", + "uri": "/mcp", + "plugins": { + "openapi-to-mcp": { + "transport": "streamable_http", + "openapi_url": "https://petstore3.swagger.io/api/v3/openapi.json", + "base_url": "https://petstore3.swagger.io/api/v3", + "headers": { + "Authorization": "Bearer ${http_x_api_token}" + } + } + } + }' +``` + +路由上的其他插件照常生效。例如 `key-auth` 或 `limit-count` 会在 MCP 请求被应答之前执行,被它们拒绝的请求不会到达工具。 + +## 删除插件 + +如需删除 `openapi-to-mcp` 插件,从路由配置中移除即可,APISIX 会自动重新加载配置: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "mcp", + "uri": "/mcp", + "plugins": {}, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }' +``` diff --git a/t/admin/plugins.t b/t/admin/plugins.t index b5d96031e0d2..5bd48edbeb27 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -127,6 +127,7 @@ traffic-label traffic-split redirect response-rewrite +openapi-to-mcp oas-validator mcp-bridge degraphql diff --git a/t/cli/test_openapi_to_mcp.sh b/t/cli/test_openapi_to_mcp.sh new file mode 100755 index 000000000000..3aef7dde80de --- /dev/null +++ b/t/cli/test_openapi_to_mcp.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +# +# 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. + +. ./t/cli/common.sh + +# openapi-to-mcp keeps its SSE sessions in the mcp-session shared dict, which +# is also what mcp-bridge uses. Enabling either one declares it. + +echo ' +plugins: + - openapi-to-mcp +' > conf/config.yaml + +make init + +if ! grep "lua_shared_dict mcp-session 10m;" conf/nginx.conf > /dev/null; then + echo "failed: openapi-to-mcp should declare the mcp-session shared dict" + exit 1 +fi + +echo ' +plugins: + - echo +' > conf/config.yaml + +make init + +if grep "lua_shared_dict mcp-session" conf/nginx.conf > /dev/null; then + echo "failed: mcp-session should not be declared when no MCP plugin is enabled" + exit 1 +fi + +echo "passed: openapi-to-mcp declares the mcp-session shared dict" diff --git a/t/lib/openapi_to_mcp_fixture.lua b/t/lib/openapi_to_mcp_fixture.lua new file mode 100644 index 000000000000..7a96d7e93f1e --- /dev/null +++ b/t/lib/openapi_to_mcp_fixture.lua @@ -0,0 +1,178 @@ +-- +-- 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 the openapi-to-mcp suites point the plugin at: the OpenAPI documents +-- under test, served next to the API their tools call. +local core = require("apisix.core") +local t = require("lib.test_admin").test + + +local _M = {} + + +local DOCUMENTS = { + ["/openapi.json"] = { + openapi = "3.0.0", + info = { title = "Demo", version = "1.0.0" }, + paths = { ["/pet/{petId}"] = { get = { + operationId = "getPet", + summary = "Get a pet", + parameters = { + { name = "petId", ["in"] = "path", required = true, + schema = { type = "integer" } }, + { name = "verbose", ["in"] = "query", + schema = { type = "boolean", default = true } }, + }, + } } }, + }, + + -- one query parameter per OpenAPI serialization style + ["/styles.json"] = { + openapi = "3.0.0", + info = { title = "Styles", version = "1" }, + paths = { ["/s"] = { get = { + operationId = "styles", + parameters = { + { name = "formArr", ["in"] = "query", explode = false, + schema = { type = "array", items = { type = "string" } } }, + { name = "spaceArr", ["in"] = "query", style = "spaceDelimited", + schema = { type = "array", items = { type = "string" } } }, + { name = "pipeArr", ["in"] = "query", style = "pipeDelimited", + schema = { type = "array", items = { type = "string" } } }, + { name = "deep", ["in"] = "query", style = "deepObject", explode = true, + schema = { type = "object", properties = { x = { type = "string" } } } }, + { name = "formObj", ["in"] = "query", explode = false, + schema = { type = "object", properties = { k = { type = "string" } } } }, + }, + } } }, + }, + + -- parameters declared on the Path Item, one of them overridden + ["/pathitem.json"] = { + openapi = "3.0.0", + info = { title = "Path item", version = "1" }, + paths = { ["/pets/{id}"] = { + parameters = { + { name = "id", ["in"] = "path", required = true, schema = { type = "integer" } }, + { name = "verbose", ["in"] = "query", schema = { type = "boolean" } }, + }, + get = { + operationId = "getPetById", + parameters = { + { name = "verbose", ["in"] = "query", schema = { type = "string" } }, + }, + }, + } }, + }, + + -- a request body whose only media type is not JSON + ["/textbody.json"] = { + openapi = "3.0.0", + info = { title = "Text body", version = "1" }, + paths = { ["/notes"] = { post = { + operationId = "addNote", + requestBody = { required = true, content = { ["text/plain"] = { + schema = { type = "string" } } } }, + } } }, + }, + + -- query parameters that are an object and an array + ["/objq.json"] = { + openapi = "3.0.0", + info = { title = "Q", version = "1" }, + paths = { ["/q"] = { get = { + operationId = "objQuery", + parameters = { + { name = "filter", ["in"] = "query", schema = { + type = "object", properties = { a = { type = "string" } } } }, + { name = "tags", ["in"] = "query", schema = { + type = "array", items = { type = "string" } } }, + }, + } } }, + }, +} + + +-- Served from fixed files, so the path order a test sees is the order written +-- in the document rather than whatever order a Lua table encodes in. +local FILES = { + -- petstore3, the same document oas-validator is tested with + ["/petstore.json"] = "../spec/spec.json", + -- `in: body` / `in: formData` and `definitions`; source in + -- openapi_to_mcp_swagger2_spec.lua + ["/swagger2.json"] = "openapi_to_mcp_swagger2_spec.json", +} + + +local function read_body() + ngx.req.read_body() + return ngx.req.get_body_data() +end + + +-- content handler for `location /` +function _M.serve() + local uri = ngx.var.uri + ngx.header["Content-Type"] = "application/json" + + local doc = DOCUMENTS[uri] + if doc then + ngx.say(core.json.encode(doc)) + return + end + + local file = FILES[uri] + if file then + local f = assert(io.open(ngx.config.prefix() .. "../lib/" .. file, "r")) + ngx.print(f:read("*a")) + f:close() + 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, + seen_method = ngx.req.get_method(), + seen_auth = ngx.req.get_headers()["authorization"], + seen_content_type = ngx.req.get_headers()["content-type"], + seen_body = ngx.req.get_method() ~= "GET" and read_body() or nil, + })) +end + + +-- PUT one openapi-to-mcp route per { id, uri, conf[, plugins] } entry. +-- Prints and returns false on the first failure, true when all are in. +function _M.put_routes(routes) + for _, route in ipairs(routes) do + local id, uri, conf, plugins = route[1], route[2], route[3], route[4] or {} + plugins["openapi-to-mcp"] = conf + local code, body = t("/apisix/admin/routes/" .. id, ngx.HTTP_PUT, + core.json.encode({ + uri = uri, + plugins = plugins, + upstream = { nodes = { ["127.0.0.1:1980"] = 1 }, type = "roundrobin" }, + })) + if code >= 300 then + ngx.say("route ", id, ": ", body) + return false + end + end + return true +end + + +return _M diff --git a/t/lib/openapi_to_mcp_swagger2_spec.json b/t/lib/openapi_to_mcp_swagger2_spec.json new file mode 100644 index 000000000000..9f2731500994 --- /dev/null +++ b/t/lib/openapi_to_mcp_swagger2_spec.json @@ -0,0 +1 @@ +{"produces":["application\/json"],"definitions":{"Pet":{"required":["name"],"properties":{"name":{"type":"string"},"id":{"type":"integer","format":"int64"},"status":{"type":"string","enum":["available","sold"]}},"type":"object"}},"swagger":"2.0","info":{"title":"Swagger 2.0","version":"1.0.0"},"host":"127.0.0.1:11460","paths":{"\/s2\/pet\/noid":{"get":{}},"\/s2\/pet\/form":{"post":{"operationId":"formPetV2","consumes":["application\/x-www-form-urlencoded"],"parameters":[{"in":"formData","type":"string","name":"name","required":true},{"in":"formData","name":"status","type":"string"}]}},"\/s2\/pet\/{petId}":{"get":{"parameters":[{"in":"path","format":"int64","name":"petId","required":true,"type":"integer"},{"in":"query","type":"boolean","name":"verbose","default":true},{"in":"header","name":"X-Trace","type":"string"}],"operationId":"getPetV2"}},"\/s2\/pet":{"post":{"operationId":"addPetV2","parameters":[{"in":"body","required":true,"name":"body","schema":{"$ref":"#\/definitions\/Pet"}}],"summary":"body parameter, the 2.0 way"}}},"basePath":"\/v2","schemes":["http"],"consumes":["application\/json"]} \ No newline at end of file diff --git a/t/lib/openapi_to_mcp_swagger2_spec.lua b/t/lib/openapi_to_mcp_swagger2_spec.lua new file mode 100644 index 000000000000..8510522f595d --- /dev/null +++ b/t/lib/openapi_to_mcp_swagger2_spec.lua @@ -0,0 +1,71 @@ +-- +-- 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. +-- +-- A Swagger 2.0 document. The plugin schema does not restrict the document +-- version, and 2.0 differs structurally: bodies live in `parameters` with +-- `in: body` / `in: formData` rather than in `requestBody`, schemas live under +-- `definitions`, and the server comes from host/basePath/schemes. Whatever the +-- two implementations do with it, they have to do the same thing. +return { + swagger = "2.0", + info = { title = "Swagger 2.0", version = "1.0.0" }, + host = "127.0.0.1:11460", + basePath = "/v2", + schemes = { "http" }, + consumes = { "application/json" }, + produces = { "application/json" }, + definitions = { + Pet = { + type = "object", + required = { "name" }, + properties = { + id = { type = "integer", format = "int64" }, + name = { type = "string" }, + status = { type = "string", enum = { "available", "sold" } }, + }, + }, + }, + paths = { + ["/s2/pet"] = { post = { + operationId = "addPetV2", + summary = "body parameter, the 2.0 way", + parameters = { + { name = "body", ["in"] = "body", required = true, + schema = { ["$ref"] = "#/definitions/Pet" } }, + }, + } }, + ["/s2/pet/{petId}"] = { get = { + operationId = "getPetV2", + parameters = { + { name = "petId", ["in"] = "path", required = true, + type = "integer", format = "int64" }, + { name = "verbose", ["in"] = "query", type = "boolean", + default = true }, + { name = "X-Trace", ["in"] = "header", type = "string" }, + }, + } }, + ["/s2/pet/form"] = { post = { + operationId = "formPetV2", + consumes = { "application/x-www-form-urlencoded" }, + parameters = { + { name = "name", ["in"] = "formData", required = true, + type = "string" }, + { name = "status", ["in"] = "formData", type = "string" }, + }, + } }, + ["/s2/pet/noid"] = { get = {} }, + }, +} diff --git a/t/plugin/openapi-to-mcp-cache.t b/t/plugin/openapi-to-mcp-cache.t new file mode 100644 index 000000000000..04147a670fa2 --- /dev/null +++ b/t/plugin/openapi-to-mcp-cache.t @@ -0,0 +1,228 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: tools are built from the fetched spec +--- http_config + server { + listen 11454; + location /openapi.json { + content_by_lua_block { + ngx.header["Content-Type"] = "application/json" + ngx.say('{"openapi":"3.0.0","paths":{"/pet":{"get":{"operationId":"listPets"}}}}') + } + } + } +--- config + location /t { + content_by_lua_block { + local cache = require("apisix.plugins.openapi-to-mcp.cache") + local tools, err = cache.get_tools({ + openapi_url = "http://127.0.0.1:11454/openapi.json", + }) + ngx.say(err == nil) + ngx.say(#tools, ",", tools[1].name) + } + } +--- response_body +true +1,listPets + + + +=== TEST 2: a second call with the same conf hits the cache +--- http_config + server { + listen 11455; + location /openapi.json { + content_by_lua_block { + local n = (package.loaded._mcp_spec_hits or 0) + 1 + package.loaded._mcp_spec_hits = n + ngx.header["Content-Type"] = "application/json" + ngx.say('{"openapi":"3.0.0","paths":{"/p' .. n .. '":{"get":{}}}}') + } + } + } +--- config + location /t { + content_by_lua_block { + local cache = require("apisix.plugins.openapi-to-mcp.cache") + local conf = { openapi_url = "http://127.0.0.1:11455/openapi.json" } + local first = cache.get_tools(conf) + local second = cache.get_tools(conf) + ngx.say(first[1].name, ",", second[1].name) + ngx.say(package.loaded._mcp_spec_hits) + } + } +--- response_body +GetP1,GetP1 +1 + + + +=== TEST 3: flatten_parameters is part of the cache key +--- http_config + server { + listen 11456; + location /openapi.json { + content_by_lua_block { + local n = (package.loaded._mcp_spec_hits or 0) + 1 + package.loaded._mcp_spec_hits = n + ngx.header["Content-Type"] = "application/json" + ngx.say('{"openapi":"3.0.0","paths":{"/p' .. n .. '":{"get":{' .. + '"parameters":[{"name":"q","in":"query","schema":{"type":"string"}}]}}}}') + } + } + } +--- config + location /t { + content_by_lua_block { + local cache = require("apisix.plugins.openapi-to-mcp.cache") + local url = "http://127.0.0.1:11456/openapi.json" + local nested = cache.get_tools({ openapi_url = url, flatten_parameters = false }) + local flat = cache.get_tools({ openapi_url = url, flatten_parameters = true }) + ngx.say(nested[1].input_schema.properties.queryParameters ~= nil) + ngx.say(flat[1].input_schema.properties.q ~= nil) + ngx.say(package.loaded._mcp_spec_hits) + } + } +--- response_body +true +true +2 + + + +=== TEST 4: a fetch failure surfaces the error and caches nothing durably +--- config + location /t { + content_by_lua_block { + local cache = require("apisix.plugins.openapi-to-mcp.cache") + local tools, err = cache.get_tools({ + openapi_url = "http://127.0.0.1:11499/missing.json", + }) + ngx.say(tools == nil) + ngx.say(err ~= nil) + } + } +--- response_body +true +true + + + +=== TEST 5: $ref inside the spec is resolved before tools are generated +--- http_config + server { + listen 11457; + location /openapi.json { + content_by_lua_block { + ngx.header["Content-Type"] = "application/json" + ngx.say('{"openapi":"3.0.0",' .. + '"components":{"parameters":{"Limit":{"name":"limit","in":"query",' .. + '"schema":{"type":"integer"}}}},' .. + '"paths":{"/pet":{"get":{"operationId":"listPets",' .. + '"parameters":[{"$ref":"#/components/parameters/Limit"}]}}}}') + } + } + } +--- config + location /t { + content_by_lua_block { + local cache = require("apisix.plugins.openapi-to-mcp.cache") + local tools = cache.get_tools({ + openapi_url = "http://127.0.0.1:11457/openapi.json", + flatten_parameters = true, + }) + ngx.say(tools[1].input_schema.properties.limit.type) + ngx.say(tools[1].execution_parameters[1].name) + } + } +--- response_body +integer +limit + + + +=== TEST 6: an expired tool list is rebuilt, not served stale +--- http_config + server { + listen 11458; + location /openapi.json { + content_by_lua_block { + local n = (package.loaded._mcp_refresh_hits or 0) + 1 + package.loaded._mcp_refresh_hits = n + ngx.header["Content-Type"] = "application/json" + ngx.say('{"openapi":"3.0.0","paths":{"/p":{"get":{"operationId":"v' .. n .. '"}}}}') + } + } + } +--- config + location /t { + content_by_lua_block { + -- Load a private copy of the cache module whose entries live for one + -- second instead of an hour; everything else about the cache is real. + local core = require("apisix.core") + local real_new = core.lrucache.new + core.lrucache.new = function(opts) + local short = core.table.clone(opts) + short.ttl = 1 + return real_new(short) + end + local name = "apisix.plugins.openapi-to-mcp.cache" + local saved = package.loaded[name] + package.loaded[name] = nil + local cache = require(name) + package.loaded[name] = saved + core.lrucache.new = real_new + + local conf = { openapi_url = "http://127.0.0.1:11458/openapi.json" } + ngx.say(cache.get_tools(conf)[1].name) + ngx.say(cache.get_tools(conf)[1].name) + ngx.sleep(1.2) + ngx.say(cache.get_tools(conf)[1].name) + ngx.say(cache.get_tools(conf)[1].name) + ngx.say("fetches: ", package.loaded._mcp_refresh_hits) + } + } +--- response_body +v1 +v1 +v2 +v2 +fetches: 2 diff --git a/t/plugin/openapi-to-mcp-concurrent.t b/t/plugin/openapi-to-mcp-concurrent.t new file mode 100644 index 000000000000..39ee2a5e1520 --- /dev/null +++ b/t/plugin/openapi-to-mcp-concurrent.t @@ -0,0 +1,124 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +# Four workers, so the POST that carries a message and the GET that streams the +# answer land on different processes as a matter of course. +workers(4); + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request && !$block->exec) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } + + my $http_config = $block->http_config // <<_EOC_; + server { + listen 11480; + + location /openapi.json { + content_by_lua_block { + local core = require("apisix.core") + ngx.header["Content-Type"] = "application/json" + ngx.say(core.json.encode({ + openapi = "3.0.0", + info = { title = "Demo", version = "1.0.0" }, + paths = { ["/pet/{petId}"] = { get = { + operationId = "getPet", + summary = "Get a pet", + parameters = { + { name = "petId", ["in"] = "path", required = true, + schema = { type = "integer" } }, + }, + } } }, + })) + } + } + + location / { + content_by_lua_block { + local core = require("apisix.core") + ngx.header["Content-Type"] = "application/json" + ngx.say(core.json.encode({ seen_path = ngx.var.request_uri })) + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: one route per transport +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp-concurrent-sse", { + transport = "sse", + base_url = "http://127.0.0.1:11480", + openapi_url = "http://127.0.0.1:11480/openapi.json", + } }, + { 2, "/mcp-concurrent-http", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11480", + openapi_url = "http://127.0.0.1:11480/openapi.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 2: sixteen requests in flight at once, on both transports +--- timeout: 120 +--- max_size: 2048000 +--- exec +python3 t/plugin/openapi_to_mcp_concurrent.py /mcp-concurrent-sse /mcp-concurrent-http 2>&1 +--- response_body +0 problem(s) across 16 concurrent requests per transport + + + +=== TEST 3: clean up +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + t('/apisix/admin/routes/1', ngx.HTTP_DELETE) + t('/apisix/admin/routes/2', ngx.HTTP_DELETE) + ngx.say("cleaned") + } + } +--- response_body +cleaned diff --git a/t/plugin/openapi-to-mcp-e2e-sse-multiworker.t b/t/plugin/openapi-to-mcp-e2e-sse-multiworker.t new file mode 100644 index 000000000000..6a2294a3f216 --- /dev/null +++ b/t/plugin/openapi-to-mcp-e2e-sse-multiworker.t @@ -0,0 +1,90 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +workers(4); + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request && !$block->exec) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } + + my $http_config = $block->http_config // <<_EOC_; + server { + listen 11460; + + location / { + content_by_lua_block { + require("lib.openapi_to_mcp_fixture").serve() + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: sse route on a four-worker gateway +--- 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", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 2: concurrent sessions survive being spread across workers +--- timeout: 60 +--- max_size: 2048000 +--- exec +python3 t/plugin/openapi_to_mcp_sse_multiworker.py /mcp 6 2>&1 +--- response_body +ok 6/6 sessions + + + +=== TEST 3: the shared dict is what carries the session, not worker-local state +--- timeout: 60 +--- exec +python3 t/plugin/openapi_to_mcp_sse_multiworker.py /mcp 12 2>&1 +--- response_body +ok 12/12 sessions diff --git a/t/plugin/openapi-to-mcp-e2e-sse.t b/t/plugin/openapi-to-mcp-e2e-sse.t new file mode 100644 index 000000000000..ed1ac52a5a9f --- /dev/null +++ b/t/plugin/openapi-to-mcp-e2e-sse.t @@ -0,0 +1,82 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request && !$block->exec) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } + + my $http_config = $block->http_config // <<_EOC_; + server { + listen 11460; + + location / { + content_by_lua_block { + require("lib.openapi_to_mcp_fixture").serve() + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: route with the sse transport +--- 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", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 2: full SSE round trip served in-process +--- exec +python3 t/plugin/openapi_to_mcp_sse_roundtrip.py /mcp 2>&1 +--- response_body +endpoint path: /mcp +has sessionId: True +post status: 202 +protocolVersion: 2024-11-05 +serverInfo: openapi2mcp-sse 0.0.1 +unknown session status: 404 diff --git a/t/plugin/openapi-to-mcp-e2e-streamable.t b/t/plugin/openapi-to-mcp-e2e-streamable.t new file mode 100644 index 000000000000..13e8633dcaff --- /dev/null +++ b/t/plugin/openapi-to-mcp-e2e-streamable.t @@ -0,0 +1,396 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request && !$block->exec) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } + + my $http_config = $block->http_config // <<_EOC_; + server { + listen 11460; + + location / { + content_by_lua_block { + require("lib.openapi_to_mcp_fixture").serve() + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: route with the streamable_http transport +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "streamable_http", + headers = { Authorization = "test-api-key" }, + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/openapi.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 2: initialize is answered in-process +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' " +r = d['result'] +print(r['protocolVersion']) +print(r['serverInfo']['name'], r['serverInfo']['version']) +print(json.dumps(r['capabilities']['tools'])) +" +--- response_body +2025-03-26 +openapi2mcp 0.0.1 +{} + + + +=== TEST 3: an unknown protocol version falls back to the latest +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1999-01-01","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' " +print(d['result']['protocolVersion']) +" +--- response_body +2025-11-25 + + + +=== TEST 4: tools/list returns the generated tool +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' " +t = d['result']['tools'][0] +print(t['name'], '|', t['description']) +print(sorted(t['inputSchema']['properties'].keys())) +" +--- response_body +getPet | Get a pet +['pathParameters', 'queryParameters'] + + + +=== TEST 5: the generated inputSchema carries no $schema or additionalProperties +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' " +schema = d['result']['tools'][0]['inputSchema'] +print('\$schema' in schema) +print('additionalProperties' in schema) +" +--- response_body +False +False + + + +=== TEST 6: tools/call reaches the upstream with path, query default and conf header +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"getPet","arguments":{"pathParameters":{"petId":7}}}}' " +inner = json.loads(d['result']['content'][0]['text']) +print(inner['status']) +print(inner['data']['seen_path']) +print(inner['data']['seen_method']) +print(inner['data']['seen_auth']) +" +--- response_body +200 +/pet/7?verbose=true +GET +test-api-key + + + +=== TEST 7: ping +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":9,"method":"ping"}' " +print(json.dumps(d['result']), d['id']) +" +--- response_body +{} 9 + + + +=== TEST 8: a notification is accepted with 202 and no body +--- exec +timeout 5 curl -X POST -sS -o /dev/null -w '%{http_code}' http://localhost:1984/mcp \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" 2>&1 | cat +--- response_body chomp +202 + + + +=== TEST 9: an unknown method is a JSON-RPC method-not-found error +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":8,"method":"resources/list","params":{}}' " +print(d['error']['code'], d['error']['message']) +" +--- response_body +-32601 Method not found + + + +=== TEST 10: an Accept header missing text/event-stream gets 406 +--- exec +timeout 5 curl -X POST -sS -o /dev/null -w '%{http_code}' http://localhost:1984/mcp \ + -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" 2>&1 | cat +--- response_body chomp +406 + + + +=== TEST 11: calling an unknown tool sets isError instead of a JSON-RPC error +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"nope","arguments":{}}}' " +print(d['result']['isError']) +print(d['result']['content'][0]['text']) +" +--- response_body +True +MCP error -32602: Tool nope not found + + + +=== TEST 12: a malformed JSON-RPC message is rejected with a null-id parse error +--- exec +timeout 5 curl -X POST -sS -o /dev/null -w '%{http_code}' http://localhost:1984/mcp \ + -d '{"jsonrpc":"1.0","id":5,"method":"ping"}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" 2>&1 | cat +--- response_body chomp +400 + + + +=== TEST 13: the parse error reports a null id even when the request had one +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":6,"method":"ping","params":"notatable"}' " +print(d['error']['code'], '|', d['error']['message']) +print(d['id'] is None) +" +--- response_body +-32700 | Parse error: Invalid JSON-RPC message +True + + + +=== TEST 14: object and array query parameters with the default style +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "streamable_http", + flatten_parameters = true, + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/objq.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 15: form style with explode, the OpenAPI default, repeats arrays and spreads objects +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"objQuery","arguments":{"filter":{"a":"x"},"tags":["t1","t2"]}}}' " +inner = json.loads(d['result']['content'][0]['text']) +print(inner['data']['seen_path']) +" +--- response_body +/q?a=x&tags=t1&tags=t2 + + + +=== TEST 16: a route over a document using every query style +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + transport = "streamable_http", + flatten_parameters = true, + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/styles.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 17: each query parameter is serialized by its declared style and explode +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"styles","arguments":{"formArr":["a","b c"],"spaceArr":["a","b"],"pipeArr":["a","b"],"deep":{"x":"1"},"formObj":{"k":"v"}}}}' " +inner = json.loads(d['result']['content'][0]['text']) +print(inner['data']['seen_path']) +" +--- response_body +/s?deep%5Bx%5D=1&formArr=a,b%20c&formObj=k,v&pipeArr=a|b&spaceArr=a%20b + + + +=== TEST 18: a route over a document with Path Item parameters +--- 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/pathitem.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 19: Path Item parameters reach the tool, and the operation's override wins +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' " +props = d['result']['tools'][0]['inputSchema']['properties'] +print(props['pathParameters']['properties']['id']['type'], props['pathParameters']['required']) +print(props['queryParameters']['properties']['verbose']['type']) +" +--- response_body +integer ['id'] +string + + + +=== TEST 20: a call fills the inherited path parameter +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getPetById","arguments":{"pathParameters":{"id":5},"queryParameters":{"verbose":"yes"}}}}' " +inner = json.loads(d['result']['content'][0]['text']) +print(inner['data']['seen_path']) +" +--- response_body +/pets/5?verbose=yes + + + +=== TEST 21: a route over a document whose request body is text/plain +--- 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/textbody.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 22: the request body is sent with the media type the operation declares +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"addNote","arguments":{"requestBody":"hello"}}}' " +inner = json.loads(d['result']['content'][0]['text']) +print(inner['data']['seen_method'], inner['data']['seen_content_type'], inner['data']['seen_body']) +" +--- response_body +POST text/plain hello + + + +=== TEST 23: a Content-Type configured on the route is not overridden +--- 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", + headers = { ["content-type"] = "text/markdown" }, + openapi_url = "http://127.0.0.1:11460/textbody.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 24: the configured media type is what the API receives +--- exec +python3 t/plugin/openapi_to_mcp_harness.py /mcp \ + '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"addNote","arguments":{"requestBody":"hello"}}}' " +inner = json.loads(d['result']['content'][0]['text']) +print(inner['data']['seen_content_type'], inner['data']['seen_body']) +" +--- response_body +text/markdown hello diff --git a/t/plugin/openapi-to-mcp-interop.spec.mts b/t/plugin/openapi-to-mcp-interop.spec.mts new file mode 100644 index 000000000000..302628c7ac1d --- /dev/null +++ b/t/plugin/openapi-to-mcp-interop.spec.mts @@ -0,0 +1,138 @@ +/* + * 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. + */ + +/* + * Drives the gateway's in-process MCP server with the official client SDK. + * + * Hand-written curl assertions can only cover the cases we thought of. The SDK + * parses every response through its own zod schemas and runs the real handshake, + * so a protocol deviation fails here even when nobody predicted it -- which is + * the point, now that the protocol layer is our own Lua rather than the SDK's + * server half. + */ +import { afterEach, describe, expect, it } from '@jest/globals'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +const STREAMABLE_ENDPOINT = new URL('http://localhost:1984/mcp-interop-http'); +const SSE_ENDPOINT = new URL('http://localhost:1984/mcp-interop-sse'); + +const newClient = () => + new Client({ name: 'openapi-to-mcp-interop', version: '1.0.0' }); + +describe.each([ + ['streamable_http', () => new StreamableHTTPClientTransport(STREAMABLE_ENDPOINT)], + ['sse', () => new SSEClientTransport(SSE_ENDPOINT)], +])('MCP interop over %s', (_name, makeTransport) => { + let client: Client | undefined; + + afterEach(async () => { + await client?.close(); + client = undefined; + }); + + const connected = async () => { + client = newClient(); + await client.connect(makeTransport()); + return client; + }; + + it('completes the initialize handshake', async () => { + const c = await connected(); + const version = c.getServerVersion(); + expect(version).toEqual({ name: expect.any(String), version: '0.0.1' }); + expect(c.getServerCapabilities()).toHaveProperty('tools'); + }); + + it('lists tools the SDK can parse', async () => { + const c = await connected(); + const { tools } = await c.listTools(); + + expect(tools.length).toBeGreaterThan(0); + const getPet = tools.find((t) => t.name === 'getPet'); + expect(getPet).toBeDefined(); + expect(getPet!.description).toBe('Get a pet'); + expect(getPet!.inputSchema.type).toBe('object'); + expect(Object.keys(getPet!.inputSchema.properties ?? {})).toEqual( + expect.arrayContaining(['pathParameters', 'queryParameters']), + ); + }); + + it('calls a tool and returns text content', async () => { + const c = await connected(); + const result = await c.callTool({ + name: 'getPet', + arguments: { pathParameters: { petId: 7 } }, + }); + + expect(result.isError).toBeFalsy(); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].type).toBe('text'); + + const upstream = JSON.parse(content[0].text); + expect(upstream.status).toBe(200); + expect(upstream.data.seen_path).toBe('/pet/7?verbose=true'); + }); + + it('reports an unknown tool through isError rather than a transport failure', async () => { + const c = await connected(); + const result = await c.callTool({ name: 'nope', arguments: {} }); + + expect(result.isError).toBe(true); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain('Tool nope not found'); + }); + + it('answers ping', async () => { + const c = await connected(); + await expect(c.ping()).resolves.toBeDefined(); + }); + + it('serves several sequential calls on one connection', async () => { + const c = await connected(); + for (let i = 0; i < 3; i++) { + const { tools } = await c.listTools(); + expect(tools.length).toBeGreaterThan(0); + } + }); + + it('serves several calls in flight at once on one connection', async () => { + // A real client does not wait for one answer before sending the next; it + // matches them by id. The Python concurrency suite drives that with raw + // sockets, which cannot tell whether the SDK's own correlation still works. + const c = await connected(); + const [tools, pong, call] = await Promise.all([ + c.listTools(), + c.ping(), + c.callTool({ name: 'getPet', arguments: { pathParameters: { petId: 3 } } }), + ]); + + expect(tools.tools.length).toBeGreaterThan(0); + expect(pong).toBeDefined(); + expect((call as { isError?: boolean }).isError).toBeFalsy(); + }); + + it('can connect again after close()', async () => { + const first = await connected(); + const before = (await first.listTools()).tools.length; + await first.close(); + + const second = await connected(); + expect((await second.listTools()).tools).toHaveLength(before); + }); +}); diff --git a/t/plugin/openapi-to-mcp-interop.t b/t/plugin/openapi-to-mcp-interop.t new file mode 100644 index 000000000000..69c2bc9a6bf4 --- /dev/null +++ b/t/plugin/openapi-to-mcp-interop.t @@ -0,0 +1,100 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request && !$block->exec) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } + + my $http_config = $block->http_config // <<_EOC_; + server { + listen 11460; + + location / { + content_by_lua_block { + require("lib.openapi_to_mcp_fixture").serve() + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: one route per transport +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp-interop-http", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/openapi.json", + } }, + { 2, "/mcp-interop-sse", { + transport = "sse", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/openapi.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 2: the official MCP client SDK drives both transports +--- timeout: 300 +--- max_size: 2048000 +--- exec +cd t && pnpm test plugin/openapi-to-mcp-interop.spec.mts 2>&1 +--- no_error_log +failed to execute the script with status +--- response_body eval +qr/Tests:\s+(\d+) passed, \1 total/ + + + +=== TEST 3: remove the extra route so it 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) + } + } +--- response_body +deleted diff --git a/t/plugin/openapi-to-mcp-json-pretty.t b/t/plugin/openapi-to-mcp-json-pretty.t new file mode 100644 index 000000000000..bd51e260a2b8 --- /dev/null +++ b/t/plugin/openapi-to-mcp-json-pretty.t @@ -0,0 +1,111 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: two-space indent with a space after the colon +--- config + location /t { + content_by_lua_block { + local jp = require("apisix.plugins.openapi-to-mcp.json_pretty") + ngx.say(jp.encode({ a = 1 })) + } + } +--- response_body +{ + "a": 1 +} + + + +=== TEST 2: empty containers stay on one line +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local jp = require("apisix.plugins.openapi-to-mcp.json_pretty") + local arr = setmetatable({}, core.json.array_mt) + -- one key per object: a multi-key Lua table has no stable + -- serialisation order, which makes the assertion flaky + ngx.say(jp.encode({ headers = {} })) + ngx.say(jp.encode({ items = arr })) + } + } +--- response_body +{ + "headers": {} +} +{ + "items": [] +} + + + +=== TEST 3: nesting increases the indent +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local jp = require("apisix.plugins.openapi-to-mcp.json_pretty") + local arr = setmetatable({ 1, 2 }, core.json.array_mt) + ngx.say(jp.encode({ outer = { inner = arr } })) + } + } +--- response_body +{ + "outer": { + "inner": [ + 1, + 2 + ] + } +} + + + +=== TEST 4: braces and commas inside strings are left alone +--- config + location /t { + content_by_lua_block { + local jp = require("apisix.plugins.openapi-to-mcp.json_pretty") + ngx.say(jp.encode({ s = 'a{b},c"d' })) + } + } +--- response_body +{ + "s": "a{b},c\"d" +} diff --git a/t/plugin/openapi-to-mcp-openapi-endpoints.t b/t/plugin/openapi-to-mcp-openapi-endpoints.t new file mode 100644 index 000000000000..45a8a275a13a --- /dev/null +++ b/t/plugin/openapi-to-mcp-openapi-endpoints.t @@ -0,0 +1,202 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: methods follow the OpenAPIV3.HttpMethods enum order +--- config + location /t { + content_by_lua_block { + local endpoints = require("apisix.plugins.openapi-to-mcp.openapi.endpoints") + local spec = { paths = { ["/a"] = { + patch = {}, get = {}, delete = {}, post = {}, put = {}, + } } } + local out = endpoints.extract(spec, { ["/a"] = 1 }) + local names = {} + for _, e in ipairs(out) do names[#names+1] = e.method end + ngx.say(table.concat(names, ",")) + } + } +--- response_body +get,put,post,delete,patch + + + +=== TEST 2: paths follow document order, not alphabetical +--- config + location /t { + content_by_lua_block { + local endpoints = require("apisix.plugins.openapi-to-mcp.openapi.endpoints") + local spec = { paths = { + ["/zebra"] = { get = {} }, + ["/apple"] = { get = {} }, + } } + local out = endpoints.extract(spec, { ["/zebra"] = 1, ["/apple"] = 2 }) + ngx.say(out[1].path, ",", out[2].path) + } + } +--- response_body +/zebra,/apple + + + +=== TEST 3: non-operation keys are skipped +--- config + location /t { + content_by_lua_block { + local endpoints = require("apisix.plugins.openapi-to-mcp.openapi.endpoints") + local spec = { paths = { ["/a"] = { + get = {}, summary = "x", parameters = {}, servers = {}, + } } } + local out = endpoints.extract(spec, { ["/a"] = 1 }) + ngx.say(#out, ",", out[1].method) + } + } +--- response_body +1,get + + + +=== TEST 4: empty or missing paths yields empty list +--- config + location /t { + content_by_lua_block { + local endpoints = require("apisix.plugins.openapi-to-mcp.openapi.endpoints") + ngx.say(#endpoints.extract({}, {})) + ngx.say(#endpoints.extract({ paths = {} }, {})) + } + } +--- response_body +0 +0 + + + +=== TEST 5: paths absent from path_order fall back to sorted order +--- config + location /t { + content_by_lua_block { + local endpoints = require("apisix.plugins.openapi-to-mcp.openapi.endpoints") + local spec = { paths = { + ["/zebra"] = { get = {} }, + ["/apple"] = { get = {} }, + } } + local out = endpoints.extract(spec, {}) + ngx.say(out[1].path, ",", out[2].path) + } + } +--- response_body +/apple,/zebra + + + +=== TEST 6: operation table is carried through untouched +--- config + location /t { + content_by_lua_block { + local endpoints = require("apisix.plugins.openapi-to-mcp.openapi.endpoints") + local op = { operationId = "listPets", summary = "list" } + local out = endpoints.extract({ paths = { ["/a"] = { get = op } } }, { ["/a"] = 1 }) + ngx.say(out[1].operation.operationId) + ngx.say(out[1].operation == op) + ngx.say(out[1]._path_rank == nil) + } + } +--- response_body +listPets +true +true + + + +=== TEST 7: path item parameters are inherited, and an operation parameter overrides by name and location +--- config + location /t { + content_by_lua_block { + local endpoints = require("apisix.plugins.openapi-to-mcp.openapi.endpoints") + local get = { + operationId = "getPet", + parameters = { + { name = "verbose", ["in"] = "query", schema = { type = "string" } }, + { name = "id", ["in"] = "header", schema = { type = "string" } }, + }, + } + local spec = { paths = { ["/pets/{id}"] = { + parameters = { + { name = "id", ["in"] = "path", required = true, schema = { type = "integer" } }, + { name = "verbose", ["in"] = "query", schema = { type = "boolean" } }, + }, + get = get, + } } } + local out = endpoints.extract(spec, {}) + for _, p in ipairs(out[1].operation.parameters) do + ngx.say(p["in"], " ", p.name, " ", p.schema.type) + end + -- the document itself is left alone + ngx.say(#get.parameters, " ", out[1].operation ~= get, " ", out[1].operation.operationId) + } + } +--- response_body +query verbose string +header id string +path id integer +2 true getPet + + + +=== TEST 8: an operation without parameters of its own takes the path item's +--- config + location /t { + content_by_lua_block { + local endpoints = require("apisix.plugins.openapi-to-mcp.openapi.endpoints") + local generator = require("apisix.plugins.openapi-to-mcp.tools.generator") + local spec = { paths = { ["/pets/{id}"] = { + parameters = { + { name = "id", ["in"] = "path", required = true, schema = { type = "integer" } }, + }, + delete = { operationId = "deletePet" }, + } } } + local tool = generator.generate(spec, { ["/pets/{id}"] = 1 })[1] + ngx.say(tool.input_schema.properties.pathParameters.properties.id.type) + ngx.say(tool.input_schema.required[1]) + ngx.say(tool.execution_parameters[1]["in"], " ", tool.execution_parameters[1].name) + } + } +--- response_body +integer +pathParameters +path id diff --git a/t/plugin/openapi-to-mcp-openapi-loader.t b/t/plugin/openapi-to-mcp-openapi-loader.t new file mode 100644 index 000000000000..6569ee775727 --- /dev/null +++ b/t/plugin/openapi-to-mcp-openapi-loader.t @@ -0,0 +1,201 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: json spec parses and keeps path order +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local body = '{"openapi":"3.0.0","paths":{"/zebra":{"get":{}},"/apple":{"get":{}},"/mango":{"get":{}}}}' + local spec, order, err = loader.parse(body) + ngx.say(err == nil) + ngx.say(spec.openapi) + ngx.say(order["/zebra"], ",", order["/apple"], ",", order["/mango"]) + } + } +--- response_body +true +3.0.0 +1,2,3 + + + +=== TEST 2: yaml spec parses and keeps path order +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local body = table.concat({ + "openapi: 3.0.0", + "paths:", + " /zebra:", + " get: {}", + " /apple:", + " get: {}", + }, "\n") + local spec, order, err = loader.parse(body) + ngx.say(err == nil) + ngx.say(spec.openapi) + ngx.say(order["/zebra"], ",", order["/apple"]) + } + } +--- response_body +true +3.0.0 +1,2 + + + +=== TEST 3: invalid content returns error +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local spec, order, err = loader.parse("{ not json and: [not yaml") + ngx.say(spec == nil) + ngx.say(err ~= nil) + } + } +--- response_body +true +true + + + +=== TEST 4: fetch pulls a spec over http +--- http_config + server { + listen 11452; + location /openapi.json { + content_by_lua_block { + ngx.header["Content-Type"] = "application/json" + ngx.say('{"openapi":"3.0.0","paths":{"/a":{"get":{}}}}') + } + } + } +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local spec, order, err = loader.fetch("http://127.0.0.1:11452/openapi.json") + ngx.say(err == nil) + ngx.say(spec.openapi, ",", order["/a"]) + } + } +--- response_body +true +3.0.0,1 + + + +=== TEST 5: fetch reports non-200 as error +--- http_config + server { + listen 11453; + location / { + content_by_lua_block { ngx.exit(404) } + } + } +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local spec, order, err = loader.fetch("http://127.0.0.1:11453/nope.json") + ngx.say(spec == nil) + ngx.say(err) + } + } +--- response_body +true +unexpected status 404 while fetching openapi spec + + + +=== TEST 6: paths with template parameters keep document order +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local body = '{"openapi":"3.0.0","paths":{"/pets/{petId}":{"get":{}},"/pets":{"get":{}}}}' + local spec, order, err = loader.parse(body) + ngx.say(err == nil) + ngx.say(order["/pets/{petId}"], ",", order["/pets"]) + } + } +--- response_body +true +1,2 + + + +=== TEST 7: descriptions mentioning a slash path do not shift the order +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + local body = '{"openapi":"3.0.0","info":{"description":"see /apple : the fruit"},' .. + '"paths":{"/zebra":{"get":{}},"/apple":{"get":{}}}}' + local spec, order, err = loader.parse(body) + ngx.say(err == nil) + ngx.say(order["/zebra"], ",", order["/apple"]) + } + } +--- response_body +true +1,2 + + + +=== TEST 8: a JSON document that escapes the solidus still yields document order +--- config + location /t { + content_by_lua_block { + local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") + -- cjson escapes "/" as "\/" by default; the key must still be + -- recognised, otherwise every path falls back to sorted order + local body = '{"openapi":"3.0.0","paths":{"\\/zebra":{"get":{}},' .. + '"\\/apple":{"get":{}},"\\/mango":{"get":{}}}}' + local spec, order, err = loader.parse(body) + ngx.say(err == nil) + ngx.say(order["/zebra"], ",", order["/apple"], ",", order["/mango"]) + } + } +--- response_body +true +1,2,3 diff --git a/t/plugin/openapi-to-mcp-openapi-ref.t b/t/plugin/openapi-to-mcp-openapi-ref.t new file mode 100644 index 000000000000..133239b21898 --- /dev/null +++ b/t/plugin/openapi-to-mcp-openapi-ref.t @@ -0,0 +1,302 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: internal ref is expanded +--- config + location /t { + content_by_lua_block { + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + local spec = { + components = { schemas = { Pet = { type = "object" } } }, + paths = { ["/p"] = { get = { responses = { ["$ref"] = "#/components/schemas/Pet" } } } }, + } + local out = ref.resolve(spec) + ngx.say(out.paths["/p"].get.responses.type) + } + } +--- response_body +object + + + +=== TEST 2: json pointer escapes are decoded in the right order +--- config + location /t { + content_by_lua_block { + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + local spec = { + components = { schemas = { ["a/b~c"] = { type = "string" } } }, + x = { ["$ref"] = "#/components/schemas/a~1b~0c" }, + } + local out = ref.resolve(spec) + ngx.say(out.x.type) + } + } +--- response_body +string + + + +=== TEST 3: a file or relative ref degrades +--- config + location /t { + content_by_lua_block { + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + -- these would name a file on the gateway's own filesystem + local out = ref.resolve({ + a = { ["$ref"] = "./common.yaml#/Pet" }, + b = { ["$ref"] = "common.json#/components/schemas/Pet" }, + }) + ngx.say(out.a.type, " ", out.b.type) + } + } +--- response_body +object object +--- error_log +only internal and http(s) $ref is supported + + + +=== TEST 4: dangling pointer degrades +--- config + location /t { + content_by_lua_block { + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + local out = ref.resolve({ x = { ["$ref"] = "#/components/schemas/Missing" } }) + ngx.say(out.x.type) + } + } +--- response_body +object +--- error_log +failed to resolve $ref + + + +=== TEST 5: a circular ref is cut at the first repeat, not unrolled +--- config + location /t { + content_by_lua_block { + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + local spec = { + components = { schemas = { + Node = { type = "object", properties = { next = { ["$ref"] = "#/components/schemas/Node" } } }, + } }, + x = { ["$ref"] = "#/components/schemas/Node" }, + } + local out = ref.resolve(spec) + + -- one level of Node, then the self-reference degrades. Unrolling to + -- MAX_DEPTH would emit a schema many times this size. + local cur, depth = out.x, 0 + while cur and cur.properties and cur.properties.next do + cur = cur.properties.next + depth = depth + 1 + if depth > 40 then break end + end + ngx.say(depth) + ngx.say(cur.type) + ngx.say(cur.properties == nil) + } + } +--- response_body +1 +object +true + + + +=== TEST 6: two schemas referring to each other also degrade at the repeat +--- config + location /t { + content_by_lua_block { + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + local spec = { + components = { schemas = { + A = { type = "object", properties = { b = { ["$ref"] = "#/components/schemas/B" } } }, + B = { type = "object", properties = { a = { ["$ref"] = "#/components/schemas/A" } } }, + } }, + x = { ["$ref"] = "#/components/schemas/A" }, + } + local out = ref.resolve(spec) + ngx.say(out.x.properties.b.properties.a.type) + ngx.say(out.x.properties.b.properties.a.properties == nil) + } + } +--- response_body +object +true + + + +=== TEST 7: input spec is not mutated +--- config + location /t { + content_by_lua_block { + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + local spec = { + components = { schemas = { Pet = { type = "object" } } }, + x = { ["$ref"] = "#/components/schemas/Pet" }, + } + ref.resolve(spec) + ngx.say(spec.x["$ref"]) + } + } +--- response_body +#/components/schemas/Pet + + + +=== TEST 8: an http ref is fetched, and its own internal refs resolve there +--- http_config + server { + listen 11490; + location /target.json { + content_by_lua_block { + ngx.header["Content-Type"] = "application/json" + ngx.print([==[{ + "components": { "schemas": { + "Name": { "type": "string", "maxLength": 8 }, + "Pet": { "type": "object", "properties": { + "who": { "$ref": "#/components/schemas/Name" } } } + } } + }]==]) + } + } + } +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + local out = ref.resolve({ + x = { ["$ref"] = "http://127.0.0.1:11490/target.json#/components/schemas/Pet" }, + }) + ngx.say(out.x.type) + -- the inner "#/components/schemas/Name" is a pointer into the + -- fetched document, not into the spec that named it + ngx.say(out.x.properties.who.type, " ", out.x.properties.who.maxLength) + } + } +--- response_body +object +string 8 + + + +=== TEST 9: a whole-document http ref carries no fragment +--- http_config + server { + listen 11490; + location /target.json { + content_by_lua_block { + ngx.header["Content-Type"] = "application/json" + ngx.print([==[{"type": "object", "title": "whole"}]==]) + } + } + } +--- config + location /t { + content_by_lua_block { + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + local out = ref.resolve({ x = { ["$ref"] = "http://127.0.0.1:11490/target.json" } }) + ngx.say(out.x.type, " ", out.x.title) + } + } +--- response_body +object whole + + + +=== TEST 10: an http ref that cannot be fetched degrades +--- config + location /t { + content_by_lua_block { + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + -- port 1 refuses immediately, so this does not wait for a timeout + local out = ref.resolve({ x = { ["$ref"] = "http://127.0.0.1:1/a.json#/Pet" } }) + ngx.say(out.x.type) + } + } +--- response_body +object +--- error_log +failed to fetch an external $ref document + + + +=== TEST 11: a failed document is not re-fetched for every reference to it +--- config + location /t { + content_by_lua_block { + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + local spec = {} + for i = 1, 5 do + spec["k" .. i] = { ["$ref"] = "http://127.0.0.1:1/a.json#/Pet" } + end + local out = ref.resolve(spec) + ngx.say(out.k1.type, " ", out.k5.type) + } + } +--- response_body +object object +--- grep_error_log eval +qr/failed to fetch an external \$ref document/ +--- grep_error_log_out +failed to fetch an external $ref document + + + +=== TEST 12: no more than eight external documents are pulled in +--- config + location /t { + content_by_lua_block { + local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") + local spec = {} + for i = 1, 12 do + spec["k" .. i] = { ["$ref"] = "http://127.0.0.1:1/doc" .. i .. ".json#/Pet" } + end + local out = ref.resolve(spec) + ngx.say(out.k1.type) + } + } +--- response_body +object +--- error_log +too many external $ref documents diff --git a/t/plugin/openapi-to-mcp-openapi-schema.t b/t/plugin/openapi-to-mcp-openapi-schema.t new file mode 100644 index 000000000000..56c57542216a --- /dev/null +++ b/t/plugin/openapi-to-mcp-openapi-schema.t @@ -0,0 +1,234 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: integer is left as-is +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local schema = require("apisix.plugins.openapi-to-mcp.openapi.schema") + local out = schema.to_json_schema({ type = "integer" }) + ngx.say(core.json.encode(out)) + } + } +--- response_body +{"type":"integer"} + + + +=== TEST 2: nullable integer becomes ["integer","null"] +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local schema = require("apisix.plugins.openapi-to-mcp.openapi.schema") + local out = schema.to_json_schema({ type = "integer", nullable = true }) + ngx.say(core.json.encode(out.type)) + ngx.say(out.nullable == nil) + } + } +--- response_body +["integer","null"] +true + + + +=== TEST 3: openapi-only keys are stripped +--- config + location /t { + content_by_lua_block { + local schema = require("apisix.plugins.openapi-to-mcp.openapi.schema") + local out = schema.to_json_schema({ + type = "string", + xml = { name = "x" }, + externalDocs = { url = "http://e" }, + deprecated = true, + readOnly = true, + writeOnly = true, + }) + local keys = {} + for k in pairs(out) do keys[#keys+1] = k end + table.sort(keys) + ngx.say(table.concat(keys, ",")) + } + } +--- response_body +type + + + +=== TEST 4: nested properties and array items recurse +--- config + location /t { + content_by_lua_block { + local schema = require("apisix.plugins.openapi-to-mcp.openapi.schema") + local out = schema.to_json_schema({ + type = "object", + properties = { + n = { type = "integer" }, + list = { type = "array", items = { type = "integer" } }, + }, + }) + ngx.say(out.properties.n.type) + ngx.say(out.properties.list.items.type) + } + } +--- response_body +integer +integer + + + +=== TEST 5: cycle degrades to generic object +--- config + location /t { + content_by_lua_block { + local schema = require("apisix.plugins.openapi-to-mcp.openapi.schema") + local node = { type = "object", properties = {} } + node.properties.self = node + local out = schema.to_json_schema(node) + ngx.say(out.properties.self.type) + } + } +--- response_body +object +--- error_log +cycle detected in schema + + + +=== TEST 6: unresolved $ref degrades to generic object +--- config + location /t { + content_by_lua_block { + local schema = require("apisix.plugins.openapi-to-mcp.openapi.schema") + local out = schema.to_json_schema({ ["$ref"] = "#/components/schemas/Pet" }) + ngx.say(out.type) + } + } +--- response_body +object +--- error_log +unresolved $ref + + + +=== TEST 7: a nullable object deliberately stops converting its children +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local schema = require("apisix.plugins.openapi-to-mcp.openapi.schema") + -- The nullable rewrite turns type into an array, and the recursion + -- checks below it test for the string "object". The children are + -- therefore left alone, on purpose: the generated tool list stays + -- stable for existing clients. + local out = schema.to_json_schema({ + type = "object", + nullable = true, + properties = { inner = { type = "string", readOnly = true, xml = { name = "x" } } }, + }) + ngx.say(core.json.encode(out.type)) + ngx.say(out.properties.inner.readOnly) + ngx.say(out.properties.inner.xml ~= nil) + } + } +--- response_body +["object","null"] +true +true + + + +=== TEST 8: a nullable array likewise leaves its items untouched +--- config + location /t { + content_by_lua_block { + local schema = require("apisix.plugins.openapi-to-mcp.openapi.schema") + local out = schema.to_json_schema({ + type = "array", + nullable = true, + items = { ["$ref"] = "#/components/schemas/Pet" }, + }) + ngx.say(out.items["$ref"]) + } + } +--- response_body +#/components/schemas/Pet + + + +=== TEST 9: composition keywords are converted even without a parent type +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local schema = require("apisix.plugins.openapi-to-mcp.openapi.schema") + local out = schema.to_json_schema({ + allOf = { + { type = "object", properties = { a = { type = "string", readOnly = true } } }, + { type = "object", properties = { b = { type = "string", nullable = true } } }, + }, + }) + ngx.say(out.allOf[1].properties.a.readOnly == nil) + ngx.say(core.json.encode(out.allOf[2].properties.b.type)) + } + } +--- response_body +true +["string","null"] + + + +=== TEST 10: oneOf and anyOf recurse the same way +--- config + location /t { + content_by_lua_block { + local schema = require("apisix.plugins.openapi-to-mcp.openapi.schema") + local out = schema.to_json_schema({ + oneOf = { { type = "object", properties = { x = { type = "string", xml = {} } } } }, + anyOf = { { type = "object", properties = { y = { type = "string", deprecated = true } } } }, + }) + ngx.say(out.oneOf[1].properties.x.xml == nil) + ngx.say(out.anyOf[1].properties.y.deprecated == nil) + } + } +--- response_body +true +true diff --git a/t/plugin/openapi-to-mcp-plugin-stack.t b/t/plugin/openapi-to-mcp-plugin-stack.t new file mode 100644 index 000000000000..1d17997112e0 --- /dev/null +++ b/t/plugin/openapi-to-mcp-plugin-stack.t @@ -0,0 +1,210 @@ +# +# 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. +# +# A route running openapi-to-mcp is still an ordinary route: the plugins +# configured alongside it have to keep working. This covers the ones above it in +# the access chain (key-auth at 2500, limit-count at 1002) and the response +# filters that see a response the gateway produced itself rather than proxied. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request && !$block->exec) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } + + my $http_config = $block->http_config // <<_EOC_; + server { + listen 11500; + + location /openapi.json { + content_by_lua_block { + local core = require("apisix.core") + ngx.header["Content-Type"] = "application/json" + ngx.say(core.json.encode({ + openapi = "3.0.0", + info = { title = "Demo", version = "1.0.0" }, + paths = { ["/pet/{petId}"] = { get = { + operationId = "getPet", + parameters = { + { name = "petId", ["in"] = "path", required = true, + schema = { type = "integer" } }, + }, + } } }, + })) + } + } + + location / { + content_by_lua_block { + local core = require("apisix.core") + ngx.header["Content-Type"] = "application/json" + ngx.say(core.json.encode({ seen_path = ngx.var.request_uri })) + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: a consumer for the auth plugin above openapi-to-mcp +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', ngx.HTTP_PUT, [[{ + "username": "stackuser", + "plugins": { "key-auth": { "key": "stack-key" } } + }]]) + if code >= 300 then ngx.status = code end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 2: key-auth, limit-count and response-rewrite stacked on one route +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp-stack", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11500", + openapi_url = "http://127.0.0.1:11500/openapi.json", + }, { + ["key-auth"] = {}, + ["limit-count"] = { + count = 2, + time_window = 60, + rejected_code = 429, + key = "remote_addr", + }, + ["response-rewrite"] = { headers = { add = { "X-Mcp-Stack: seen" } } }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 3: without a key the auth plugin answers, not the MCP server +--- request +POST /mcp-stack +{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}} +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +--- error_code: 401 +--- response_body +{"message":"Missing API key in request"} + + + +=== TEST 4: with a key the MCP server answers, through the response filters +--- request +POST /mcp-stack +{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}} +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +apikey: stack-key +--- response_body_like +.*"name":"getPet".* +--- response_headers +X-Mcp-Stack: seen +X-RateLimit-Remaining: 1 + + + +=== TEST 5: limit-count counts MCP requests and cuts the third one off +--- pipelined_requests eval +["POST /mcp-stack\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}", + "POST /mcp-stack\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"}", + "POST /mcp-stack\n{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"ping\"}"] +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +apikey: stack-key +--- error_code eval +[200, 200, 429] + + + +=== TEST 6: the same stack in front of an SSE route +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 2, "/mcp-stack-sse", { + transport = "sse", + base_url = "http://127.0.0.1:11500", + openapi_url = "http://127.0.0.1:11500/openapi.json", + }, { + ["key-auth"] = {}, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 7: an unauthenticated stream is refused before it opens +--- request +GET /mcp-stack-sse +--- more_headers +Accept: text/event-stream +--- error_code: 401 + + + +=== TEST 8: clean up +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + t('/apisix/admin/routes/1', ngx.HTTP_DELETE) + t('/apisix/admin/routes/2', ngx.HTTP_DELETE) + t('/apisix/admin/consumers/stackuser', ngx.HTTP_DELETE) + ngx.say("cleaned") + } + } +--- response_body +cleaned diff --git a/t/plugin/openapi-to-mcp-protocol.t b/t/plugin/openapi-to-mcp-protocol.t new file mode 100644 index 000000000000..5e6b47c1eb4c --- /dev/null +++ b/t/plugin/openapi-to-mcp-protocol.t @@ -0,0 +1,174 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: result and error envelopes +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local rpc = require("apisix.plugins.openapi-to-mcp.jsonrpc") + local ok = rpc.result(1, {}) + ngx.say(ok.jsonrpc, ",", ok.id, ",", core.json.encode(ok.result)) + local err = rpc.error(2, rpc.ERR_METHOD_NOT_FOUND, "Method not found") + ngx.say(err.jsonrpc, ",", err.id, ",", err.error.code, ",", err.error.message) + ngx.say(err.error.data == nil, ",", err.result == nil) + } + } +--- response_body +2.0,1,{} +2.0,2,-32601,Method not found +true,true + + + +=== TEST 2: a request without an id is a notification +--- config + location /t { + content_by_lua_block { + local rpc = require("apisix.plugins.openapi-to-mcp.jsonrpc") + ngx.say(rpc.is_notification({ jsonrpc = "2.0", method = "notifications/initialized" })) + ngx.say(rpc.is_notification({ jsonrpc = "2.0", id = 1, method = "ping" })) + } + } +--- response_body +true +false + + + +=== TEST 3: request shape validation +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local rpc = require("apisix.plugins.openapi-to-mcp.jsonrpc") + ngx.say(rpc.validate({ jsonrpc = "2.0", id = 1, method = "ping" })) + ngx.say(rpc.validate({ jsonrpc = "1.0", method = "ping" })) + ngx.say(rpc.validate({ jsonrpc = "2.0" })) + ngx.say(rpc.validate({ jsonrpc = "2.0", method = "ping", params = "x" })) + ngx.say(rpc.validate("not a table")) + -- an explicit null id is not a valid request id + ngx.say(rpc.validate({ jsonrpc = "2.0", id = core.json.null, method = "ping" })) + -- MCP wants params to be an object, never an array + ngx.say(rpc.validate({ jsonrpc = "2.0", id = 1, method = "ping", params = { 1, 2 } })) + -- an empty method passes shape validation and is answered -32601 + ngx.say(rpc.validate({ jsonrpc = "2.0", id = 1, method = "" })) + -- a missing id is a notification, still legal + ngx.say(rpc.validate({ jsonrpc = "2.0", method = "notifications/initialized" })) + } + } +--- response_body +true +false +false +false +false +false +false +true +true + + + +=== TEST 4: the parse-error envelope always carries a null id +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local rpc = require("apisix.plugins.openapi-to-mcp.jsonrpc") + local msg = rpc.invalid_message() + ngx.say(msg.jsonrpc, ",", msg.error.code, ",", msg.error.message) + ngx.say(core.json.encode(msg.id)) + } + } +--- response_body +2.0,-32700,Parse error: Invalid JSON-RPC message +null + + + +=== TEST 5: protocol version negotiation echoes known versions +--- config + location /t { + content_by_lua_block { + local p = require("apisix.plugins.openapi-to-mcp.protocol") + ngx.say(p.negotiate("2025-03-26")) + ngx.say(p.negotiate("2024-11-05")) + ngx.say(p.negotiate("2025-11-25")) + } + } +--- response_body +2025-03-26 +2024-11-05 +2025-11-25 + + + +=== TEST 6: an unknown version falls back to the latest +--- config + location /t { + content_by_lua_block { + local p = require("apisix.plugins.openapi-to-mcp.protocol") + ngx.say(p.negotiate("1999-01-01")) + ngx.say(p.negotiate(nil)) + ngx.say(p.negotiate(42)) + } + } +--- response_body +2025-11-25 +2025-11-25 +2025-11-25 + + + +=== TEST 7: capabilities and serverInfo +--- config + location /t { + content_by_lua_block { + local p = require("apisix.plugins.openapi-to-mcp.protocol") + local core = require("apisix.core") + local caps = p.capabilities() + ngx.say(core.json.encode(caps.tools)) + local info = p.server_info() + ngx.say(info.name, ",", info.version) + } + } +--- response_body +{} +openapi2mcp,0.0.1 diff --git a/t/plugin/openapi-to-mcp-session-lifecycle.t b/t/plugin/openapi-to-mcp-session-lifecycle.t new file mode 100644 index 000000000000..3578feca22f7 --- /dev/null +++ b/t/plugin/openapi-to-mcp-session-lifecycle.t @@ -0,0 +1,206 @@ +# +# 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 happens to an SSE session when the client goes away and comes back. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request && !$block->exec) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } + + my $http_config = $block->http_config // <<_EOC_; + server { + listen 11520; + + location /openapi.json { + content_by_lua_block { + local core = require("apisix.core") + ngx.header["Content-Type"] = "application/json" + ngx.say(core.json.encode({ + openapi = "3.0.0", + info = { title = "Demo", version = "1.0.0" }, + paths = { ["/pet"] = { get = { operationId = "getPet" } } }, + })) + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: a new session expires on its own after 30 minutes +--- config + location /t { + content_by_lua_block { + local session = require("apisix.plugins.openapi-to-mcp.session") + local id = assert(session.create()) + local dict = ngx.shared["mcp-session"] + ngx.say("alive: ", tostring(session.exists(id))) + ngx.say("ttl: ", dict:ttl(id .. ":alive")) + } + } +--- response_body +alive: true +ttl: 1800 + + + +=== TEST 2: destroying a session takes its queue with it +--- config + location /t { + content_by_lua_block { + local session = require("apisix.plugins.openapi-to-mcp.session") + local id = assert(session.create()) + assert(session.push(id, "queued")) + + session.destroy(id) + + local dict = ngx.shared["mcp-session"] + ngx.say("alive: ", tostring(dict:get(id .. ":alive"))) + -- a shared dict list carries no TTL of its own, so a queue left + -- behind would sit there until the dict runs out of room + ngx.say("queue: ", tostring(dict:llen(id .. ":queue"))) + ngx.say("exists: ", tostring(session.exists(id))) + } + } +--- response_body +alive: nil +queue: 0 +exists: false + + + +=== TEST 3: a message for a session that is gone is refused, not queued +--- config + location /t { + content_by_lua_block { + local session = require("apisix.plugins.openapi-to-mcp.session") + local id = assert(session.create()) + session.destroy(id) + + local ok, err = session.push(id, "late") + ngx.say("push: ", tostring(ok), " ", tostring(err)) + -- nothing would ever drain a queue recreated here + ngx.say("queue: ", tostring(ngx.shared["mcp-session"]:llen(id .. ":queue"))) + } + } +--- response_body +push: nil session is gone +queue: 0 + + + +=== TEST 4: a message that races the teardown leaves no queue behind +--- config + location /t { + content_by_lua_block { + local session = require("apisix.plugins.openapi-to-mcp.session") + local dict = ngx.shared["mcp-session"] + local id = assert(session.create()) + + -- the stream that drains the queue usually runs in another worker, + -- so it can destroy the session between push's liveness check and + -- its rpush. Reproduced by dropping the marker at exactly that + -- point, which is what the other worker's destroy would do. + local real_rpush = dict.rpush + dict.rpush = function(self, key, value) + local length = real_rpush(self, key, value) + dict:delete(id .. ":alive") + return length + end + + local ok, err = session.push(id, "racing") + dict.rpush = real_rpush + + ngx.say("push: ", tostring(ok), " ", tostring(err)) + ngx.say("queue: ", tostring(dict:llen(id .. ":queue"))) + } + } +--- response_body +push: nil session is gone +queue: 0 + + + +=== TEST 5: an sse route to reconnect against +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp-session-life", { + transport = "sse", + base_url = "http://127.0.0.1:11520", + openapi_url = "http://127.0.0.1:11520/openapi.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 6: a message for a session the gateway never issued is refused +--- request +POST /mcp-session-life?sessionId=00000000-0000-4000-8000-000000000000 +{"jsonrpc":"2.0","id":1,"method":"ping"} +--- more_headers +Content-Type: application/json +--- error_code: 404 +--- response_body_like eval +qr/(?=.*"code":-32000)(?=.*"message":"Session not found for sessionId")(?=.*"id":null)/ + + + +=== TEST 7: reconnecting after a close gets a new session, and it works +--- timeout: 60 +--- exec +python3 t/plugin/openapi_to_mcp_session_reconnect.py /mcp-session-life 2>&1 +--- response_body +0 problem(s); the abandoned session answers 202 + + + +=== TEST 8: clean up +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + t('/apisix/admin/routes/1', ngx.HTTP_DELETE) + ngx.say("cleaned") + } + } +--- response_body +cleaned diff --git a/t/plugin/openapi-to-mcp-swagger2.t b/t/plugin/openapi-to-mcp-swagger2.t new file mode 100644 index 000000000000..bb1e6ecab3f6 --- /dev/null +++ b/t/plugin/openapi-to-mcp-swagger2.t @@ -0,0 +1,180 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request && !$block->exec) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } + + my $http_config = $block->http_config // <<_EOC_; + server { + listen 11470; + + location / { + content_by_lua_block { + require("lib.openapi_to_mcp_fixture").serve() + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: a Swagger 2.0 document produces tools +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp-swagger2", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11470", + openapi_url = "http://127.0.0.1:11470/swagger2.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 2: parameters carrying their schema inline keep their type +--- request +POST /mcp-swagger2 +{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}} +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +--- response_body_like eval +qr/"petId":\{"type":"integer","format":"int64"\}|"petId":\{"format":"int64","type":"integer"\}/ + + + +=== TEST 3: the tool list itself, field by field +--- request +GET /t +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local core = require("apisix.core") + local function payload(body) + -- the streamable transport frames the reply as one SSE event + return require("apisix.core").json.decode(body:match("data: (.-)\n") or body) + end + local httpc = http.new() + local res, err = httpc:request_uri("http://127.0.0.1:1984/mcp-swagger2", { + method = "POST", + headers = { + ["Content-Type"] = "application/json", + ["Accept"] = "application/json, text/event-stream", + }, + body = '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}', + }) + if not res then ngx.say(err) return end + + local data = payload(res.body) + local tools = data.result.tools + for _, tool in ipairs(tools) do + ngx.say(tool.name) + end + + -- a body parameter is not turned into an input + for _, tool in ipairs(tools) do + if tool.name == "addPetV2" then + ngx.say("addPetV2 properties: ", + core.json.encode(tool.inputSchema.properties)) + end + end + } + } +--- response_body +GetS2PetNoid +formPetV2 +getPetV2 +addPetV2 +addPetV2 properties: {} + + + +=== TEST 4: calling a 2.0 tool reaches the upstream path +--- request +GET /t +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local core = require("apisix.core") + local function payload(body) + -- the streamable transport frames the reply as one SSE event + return require("apisix.core").json.decode(body:match("data: (.-)\n") or body) + end + local httpc = http.new() + local body = [[{"jsonrpc":"2.0","id":2,"method":"tools/call","params":]] .. + [[{"name":"getPetV2","arguments":{"pathParameters":{"petId":7},]] .. + [["queryParameters":{"verbose":false}}}}]] + local res, err = httpc:request_uri("http://127.0.0.1:1984/mcp-swagger2", { + method = "POST", + headers = { + ["Content-Type"] = "application/json", + ["Accept"] = "application/json, text/event-stream", + }, + body = body, + }) + if not res then ngx.say(err) return end + + local data = payload(res.body) + local upstream = core.json.decode(data.result.content[1].text) + -- basePath is not prepended: the upstream is base_url, and the + -- path comes from the document key alone + ngx.say(upstream.data.seen_method, " ", upstream.data.seen_path) + } + } +--- response_body +GET /s2/pet/7?verbose=false + + + +=== TEST 5: clean up +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + t('/apisix/admin/routes/1', ngx.HTTP_DELETE) + ngx.say("cleaned") + } + } +--- response_body +cleaned diff --git a/t/plugin/openapi-to-mcp-tools-generator.t b/t/plugin/openapi-to-mcp-tools-generator.t new file mode 100644 index 000000000000..6e482fd195a6 --- /dev/null +++ b/t/plugin/openapi-to-mcp-tools-generator.t @@ -0,0 +1,575 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: title_case lowercases first, so {userId} becomes Userid +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + ngx.say(g.title_case("pet")) + ngx.say(g.title_case("user-posts")) + ngx.say(g.title_case("user_posts")) + ngx.say(g.title_case("{userId}")) + ngx.say(g.title_case("{petId}")) + } + } +--- response_body +Pet +UserPosts +UserPosts +Userid +Petid + + + +=== TEST 2: gen_operation_id only appends By for a trailing path parameter +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + ngx.say(g.gen_operation_id("get", "/pet/{petId}")) + ngx.say(g.gen_operation_id("get", "/users/{userId}/posts")) + ngx.say(g.gen_operation_id("post", "/pet")) + ngx.say(g.gen_operation_id("get", "/")) + } + } +--- response_body +GetPetByPetid +GetUsersPosts +PostPet +GetRoot + + + +=== TEST 3: operationId from the spec wins over the generated one +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local spec = { paths = { ["/pet"] = { get = { operationId = "listPets" } } } } + local tools = g.generate(spec, { ["/pet"] = 1 }, {}) + ngx.say(tools[1].name) + } + } +--- response_body +listPets + + + +=== TEST 4: tool names are sanitized to [A-Za-z0-9_-] +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local spec = { paths = { ["/a"] = { get = { operationId = "pets.list v2" } } } } + local tools = g.generate(spec, { ["/a"] = 1 }, {}) + ngx.say(tools[1].name) + } + } +--- response_body +pets_list_v2 + + + +=== TEST 5: duplicate names get a numeric suffix +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local spec = { paths = { + ["/a"] = { get = { operationId = "dup" }, post = { operationId = "dup" } }, + ["/b"] = { get = { operationId = "dup" } }, + } } + local tools = g.generate(spec, { ["/a"] = 1, ["/b"] = 2 }, {}) + local names = {} + for _, t in ipairs(tools) do names[#names+1] = t.name end + ngx.say(table.concat(names, ",")) + } + } +--- response_body +dup,dup_1,dup_2 + + + +=== TEST 6: description falls back description -> summary -> Executes +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local spec = { paths = { + ["/a"] = { get = { description = "D", summary = "S" } }, + ["/b"] = { get = { summary = "S only" } }, + ["/c"] = { get = {} }, + } } + local tools = g.generate(spec, { ["/a"] = 1, ["/b"] = 2, ["/c"] = 3 }, {}) + ngx.say(tools[1].description) + ngx.say(tools[2].description) + ngx.say(tools[3].description) + } + } +--- response_body +D +S only +Executes GET /c + + + +=== TEST 7: annotations are inferred from the http method +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local spec = { paths = { ["/a"] = { + get = {}, put = {}, delete = {}, post = {}, + } } } + local tools = g.generate(spec, { ["/a"] = 1 }, {}) + ngx.say(tools[1].method, " ", tostring(tools[1].annotations.readOnlyHint)) + ngx.say(tools[2].method, " ", tostring(tools[2].annotations.idempotentHint)) + ngx.say(tools[3].method, " ", tostring(tools[3].annotations)) + ngx.say(tools[4].method, " ", tostring(tools[4].annotations.destructiveHint), + " ", tostring(tools[4].annotations.idempotentHint)) + } + } +--- response_body +get true +put true +post nil +delete true true + + + +=== TEST 8: x-mcp-annotations overrides the inferred ones +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local spec = { paths = { ["/a"] = { get = { + ["x-mcp-annotations"] = { title = " My Tool ", readOnlyHint = false, openWorldHint = true }, + } } } } + local tools = g.generate(spec, { ["/a"] = 1 }, {}) + ngx.say(tools[1].annotations.title) + ngx.say(tostring(tools[1].annotations.readOnlyHint)) + ngx.say(tostring(tools[1].annotations.openWorldHint)) + } + } +--- response_body +My Tool +false +true + + + +=== TEST 9: invalid x-mcp-annotations entries are ignored with a warning +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local spec = { paths = { ["/a"] = { get = { + operationId = "op", + ["x-mcp-annotations"] = { title = " ", readOnlyHint = "yes", bogus = 1 }, + } } } } + local tools = g.generate(spec, { ["/a"] = 1 }, {}) + ngx.say(tools[1].annotations.title == nil) + -- inferred readOnlyHint survives because the invalid override is dropped + ngx.say(tostring(tools[1].annotations.readOnlyHint)) + ngx.say(tools[1].annotations.bogus == nil) + } + } +--- response_body +true +true +true +--- error_log +ignoring invalid x-mcp-annotations + + + +=== TEST 10: flatten mode puts path, query and header params at top level +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { parameters = { + { name = "petId", ["in"] = "path", required = true, schema = { type = "integer" } }, + { name = "limit", ["in"] = "query", schema = { type = "integer" } }, + { name = "X-Trace", ["in"] = "header", required = true, schema = { type = "string" } }, + } } + local s = g.build_input_schema(op, true) + ngx.say(s.properties.petId.type) + ngx.say(s.properties.limit.type) + ngx.say(s.properties["X-Trace"].type) + ngx.say(table.concat(s.required, ",")) + ngx.say(s.properties.pathParameters == nil) + } + } +--- response_body +integer +integer +string +petId,X-Trace +true + + + +=== TEST 11: flatten mode has a three-level description fallback with a prefix +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { parameters = { + { name = "a", ["in"] = "path", schema = { type = "string" } }, + { name = "b", ["in"] = "query", schema = { type = "string", description = "from schema" } }, + { name = "c", ["in"] = "header", description = "from param", schema = { type = "string" } }, + } } + local s = g.build_input_schema(op, true) + ngx.say(s.properties.a.description) + ngx.say(s.properties.b.description) + ngx.say(s.properties.c.description) + } + } +--- response_body +Path parameter: a +from schema +from param + + + +=== TEST 12: nested mode groups params into three containers +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { parameters = { + { name = "petId", ["in"] = "path", required = true, schema = { type = "integer" } }, + { name = "limit", ["in"] = "query", schema = { type = "integer" } }, + { name = "X-Trace", ["in"] = "header", schema = { type = "string" } }, + } } + local s = g.build_input_schema(op, false) + ngx.say(s.properties.pathParameters.properties.petId.type) + ngx.say(s.properties.queryParameters.properties.limit.type) + ngx.say(s.properties.headerParameters.properties["X-Trace"].type) + ngx.say(s.properties.petId == nil) + } + } +--- response_body +integer +integer +string +true + + + +=== TEST 13: nested containers set additionalProperties and propagate required +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { parameters = { + { name = "petId", ["in"] = "path", required = true, schema = { type = "string" } }, + { name = "limit", ["in"] = "query", schema = { type = "string" } }, + } } + local s = g.build_input_schema(op, false) + ngx.say(tostring(s.properties.pathParameters.additionalProperties)) + ngx.say(table.concat(s.properties.pathParameters.required, ",")) + ngx.say(table.concat(s.required, ",")) + -- queryParameters has no required member, so it is not listed at the top + ngx.say(s.properties.queryParameters.required == nil) + } + } +--- response_body +false +petId +pathParameters +true + + + +=== TEST 14: nested mode has no third-level description default +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { parameters = { + { name = "a", ["in"] = "path", schema = { type = "string" } }, + } } + local s = g.build_input_schema(op, false) + ngx.say(s.properties.pathParameters.properties.a.description == nil) + } + } +--- response_body +true + + + +=== TEST 15: json request body becomes the requestBody property +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { requestBody = { + required = true, + content = { ["application/json"] = { schema = { type = "object" } } }, + } } + local s, params, ct = g.build_input_schema(op, false) + ngx.say(ct) + ngx.say(s.properties.requestBody.type) + ngx.say(s.properties.requestBody.description) + ngx.say(table.concat(s.required, ",")) + } + } +--- response_body +application/json +object +The JSON request body. +requestBody + + + +=== TEST 16: non-json request body degrades to a string +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { requestBody = { + content = { ["text/plain"] = { schema = { type = "string" } } }, + } } + local s, params, ct = g.build_input_schema(op, false) + ngx.say(ct) + ngx.say(s.properties.requestBody.type) + ngx.say(s.properties.requestBody.description) + ngx.say(s.required == nil) + } + } +--- response_body +text/plain +string +Request body (content type: text/plain) +true + + + +=== TEST 17: empty required list is omitted entirely +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local s = g.build_input_schema({}, false) + ngx.say(s.type) + ngx.say(s.required == nil) + } + } +--- response_body +object +true + + + +=== TEST 18: generate carries method, path template and execution parameters +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local spec = { paths = { ["/pet/{petId}"] = { get = { + operationId = "getPet", + parameters = { + { name = "petId", ["in"] = "path", required = true, schema = { type = "string" } }, + { name = "verbose", ["in"] = "query", schema = { type = "boolean" } }, + }, + } } } } + local tools = g.generate(spec, { ["/pet/{petId}"] = 1 }, {}) + local t = tools[1] + ngx.say(t.method, " ", t.path_template) + ngx.say(t.execution_parameters[1].name, "/", t.execution_parameters[1]["in"]) + ngx.say(t.execution_parameters[2].name, "/", t.execution_parameters[2]["in"]) + } + } +--- response_body +get /pet/{petId} +petId/path +verbose/query + + + +=== TEST 19: tool order follows path order then method enum order +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local spec = { paths = { + ["/zebra"] = { post = { operationId = "zp" }, get = { operationId = "zg" } }, + ["/apple"] = { get = { operationId = "ag" } }, + } } + local tools = g.generate(spec, { ["/zebra"] = 1, ["/apple"] = 2 }, {}) + local names = {} + for _, t in ipairs(tools) do names[#names+1] = t.name end + ngx.say(table.concat(names, ",")) + } + } +--- response_body +zg,zp,ag + + + +=== TEST 20: flatten_parameters option reaches build_input_schema +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local spec = { paths = { ["/a"] = { get = { + operationId = "op", + parameters = { { name = "q", ["in"] = "query", schema = { type = "string" } } }, + } } } } + local flat = g.generate(spec, { ["/a"] = 1 }, { flatten_parameters = true }) + local nested = g.generate(spec, { ["/a"] = 1 }, { flatten_parameters = false }) + ngx.say(flat[1].input_schema.properties.q ~= nil) + ngx.say(nested[1].input_schema.properties.queryParameters ~= nil) + } + } +--- response_body +true +true + + + +=== TEST 21: the top level carries no $schema and no additionalProperties +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { requestBody = { content = { ["application/json"] = { schema = { + type = "object", + properties = { nested = { type = "object", properties = { deep = { type = "string" } } } }, + } } } } } + local s = g.build_input_schema(op, false) + ngx.say(s["$schema"] == nil) + ngx.say(s.additionalProperties == nil) + ngx.say(s.properties.requestBody.additionalProperties == nil) + ngx.say(s.properties.requestBody.properties.nested.additionalProperties == nil) + } + } +--- response_body +true +true +true +true + + + +=== TEST 22: an explicit additionalProperties is passed through untouched +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { requestBody = { content = { ["application/json"] = { schema = { + type = "object", + properties = { open = { type = "object", additionalProperties = true, + properties = { k = { type = "string" } } } }, + } } } } } + local s = g.build_input_schema(op, false) + ngx.say(tostring(s.properties.requestBody.properties.open.additionalProperties)) + } + } +--- response_body +true + + + +=== TEST 23: objects inside array items keep their integer type and stay open +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { requestBody = { content = { ["application/json"] = { schema = { + type = "object", + properties = { arr = { type = "array", items = { + type = "object", properties = { i = { type = "integer" } }, + } } }, + } } } } } + local s = g.build_input_schema(op, false) + local items = s.properties.requestBody.properties.arr.items + ngx.say(items.additionalProperties == nil) + ngx.say(items.properties.i.type) + } + } +--- response_body +true +integer + + + +=== TEST 24: nested parameter containers still close themselves +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { parameters = { + { name = "petId", ["in"] = "path", required = true, schema = { type = "string" } }, + } } + local s = g.build_input_schema(op, false) + ngx.say(tostring(s.properties.pathParameters.additionalProperties)) + ngx.say(s.additionalProperties == nil) + } + } +--- response_body +false +true + + + +=== TEST 25: a parameter without a name is skipped, not fatal +--- config + location /t { + content_by_lua_block { + local g = require("apisix.plugins.openapi-to-mcp.tools.generator") + local op = { parameters = { + { ["in"] = "query", schema = { type = "string" } }, + { name = "ok", ["in"] = "query", schema = { type = "string" } }, + } } + local flat = g.build_input_schema(op, true) + ngx.say(flat.properties.ok ~= nil) + local nested = g.build_input_schema(op, false) + ngx.say(nested.properties.queryParameters.properties.ok ~= nil) + } + } +--- response_body +true +true +--- error_log +skipping parameter without a name diff --git a/t/plugin/openapi-to-mcp.t b/t/plugin/openapi-to-mcp.t new file mode 100644 index 000000000000..536aaf15342d --- /dev/null +++ b/t/plugin/openapi-to-mcp.t @@ -0,0 +1,630 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request && !$block->exec) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } + + my $http_config = $block->http_config // <<_EOC_; + server { + listen 11460; + + location / { + content_by_lua_block { + require("lib.openapi_to_mcp_fixture").serve() + } + } + } + + server { + listen 11451; + + location / { + content_by_lua_block { + local api_key = ngx.req.get_headers()["api_key"] or "" + ngx.log(ngx.INFO, "request: ", ngx.var.request_uri, + ", upstream received header api_key: ", api_key) + ngx.header["Content-Type"] = "application/json" + ngx.say('{}') + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: sanity +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openapi-to-mcp") + local ok, err = plugin.check_schema({ + base_url = "http://127.0.0.1:11460", + headers = { + ["Authorization"] = "test-api-key" + }, + openapi_url = "http://127.0.0.1:11460/petstore.json" + }) + if not ok then + ngx.say(err) + return + end + + ngx.say("done") + } + } +--- response_body +done + + + +=== TEST 2: missing required fields +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openapi-to-mcp") + local ok, err = plugin.check_schema({ + base_url = "http://127.0.0.1:11460" + }) + if not ok then + ngx.say(err) + return + end + + ngx.say("done") + } + } +--- response_body +property "openapi_url" is required + + + +=== TEST 3: create a route with openapi-to-mcp plugin +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + base_url = "http://127.0.0.1:11460", + headers = { Authorization = "test-api-key" }, + openapi_url = "http://127.0.0.1:11460/petstore.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 4: a GET on an sse route advertises the message endpoint +--- exec +timeout 1 curl -X GET -N -sS http://localhost:1984/mcp 2>&1 | cat +--- response_body_like +event:\s*endpoint +data:\s*/mcp\?sessionId=.* + + + +=== TEST 5: a message POST without a sessionId is rejected +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp 2>&1 | cat +--- response_body eval +qr/Missing or invalid sessionId parameter/ + + + +=== TEST 6: create a route with openapi-to-mcp plugin that using streamable http transport +--- 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", + headers = { Authorization = "test-api-key" }, + openapi_url = "http://127.0.0.1:11460/petstore.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 7: tools/list is answered in-process +--- max_size: 2048000 +--- 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/event: message\ndata: .*"tools":\[/ + + + +=== TEST 8: openapi-to-mcp's headers can use variables +--- 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", + headers = { Authorization = "${arg_username}-${http_apikey}" }, + openapi_url = "http://127.0.0.1:11460/petstore.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 9: confirm that variables in headers are correctly replaced +--- max_size: 2048000 +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp?username=alice \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"findPetsByStatus","arguments":{"queryParameters":{"status":"sold"}}}}' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "apikey: user-key" \ + 2>&1 | cat +--- response_body eval +qr/alice-user-key/ + + + +=== TEST 10: streamable_http without headers +--- 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/petstore.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 11: mcp request should be working when no headers in plugin config +--- max_size: 2048000 +--- 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/event: message\ndata: .*"tools":\[/ + + + +=== TEST 12: base_url can use variables +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + base_url = "http://${http_variable_host}", + headers = { Authorization = "test-api-key" }, + openapi_url = "http://127.0.0.1:11460/petstore.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 13: a GET on an sse route with a variable base_url advertises the message endpoint +--- exec +timeout 1 curl -X GET -N -sS http://localhost:1984/mcp \ + -H "variable_host: 127.0.0.1:11460" \ + 2>&1 | cat +--- response_body_like +event:\s*endpoint +data:\s*/mcp\?sessionId=.* + + + +=== TEST 14: schema validation with flatten_parameters +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openapi-to-mcp") + for _, flatten in ipairs({ true, false }) do + local ok, err = plugin.check_schema({ + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/petstore.json", + flatten_parameters = flatten, + }) + if not ok then + ngx.say(err) + return + end + end + + ngx.say("done") + } + } +--- response_body +done + + + +=== TEST 15: an sse route with flatten_parameters +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp", { + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/petstore.json", + flatten_parameters = true, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 16: an sse route with flatten_parameters still opens a stream +--- exec +timeout 1 curl -X GET -N -sS http://localhost:1984/mcp 2>&1 | cat +--- response_body_like +event:\s*endpoint +data:\s*/mcp\?sessionId=.* + + + +=== TEST 17: flatten_parameters in streamable_http transport +--- 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/petstore.json", + flatten_parameters = true, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 18: flattened parameters are not nested under queryParameters +--- max_size: 2048000 +--- 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/(?s)^(?=.*"tools":\[)(?:(?!queryParameters).)*$/ + + + +=== TEST 19: flatten_parameters false in streamable_http transport +--- 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/petstore.json", + flatten_parameters = false, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 20: nested parameters are grouped under queryParameters +--- max_size: 2048000 +--- 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/queryParameters/ + + + +=== TEST 21: verify mcp tools call works +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "findPetsByStatus", + "arguments": { + "queryParameters": { + "status": "pending" + } + } + }, + "id": 1 + }' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/findByStatus\?status=pending/ + + + +=== TEST 22: headerParameters appears in inputSchema for endpoints with in:header params (nested mode) +--- max_size: 2048000 +--- 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/headerParameters/ + + + +=== TEST 23: tools/call with no headerParameters argument still works +--- max_size: 2048000 +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "deletePet", + "arguments": { + "pathParameters": { + "petId": 9999 + } + } + }, + "id": 1 + }' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/(?s)(?=.*"jsonrpc":"2.0")(?=.*pet\/9999)/ + + + +=== TEST 24: route with flatten_parameters=true to check header params appear as top-level properties +--- 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:11451", + openapi_url = "http://127.0.0.1:11460/petstore.json", + flatten_parameters = true, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 25: headerParameters container is absent in flattened mode +--- max_size: 2048000 +--- 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/(?s)^(?=.*"api_key")(?:(?!headerParameters).)*$/ + + + +=== TEST 26: tools/call forwards flattened header params as HTTP headers to upstream +--- max_size: 2048000 +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "deletePet", + "arguments": { + "petId": 1, + "api_key": "flat-api-key" + } + }, + "id": 1 + }' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/"jsonrpc":"2.0"/ +--- error_log +upstream received header api_key: flat-api-key + + + +=== TEST 27: route with base_url pointing to header-capture server +--- 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:11451", + openapi_url = "http://127.0.0.1:11460/petstore.json", + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 28: tools/call forwards headerParameters as HTTP headers to upstream +--- max_size: 2048000 +--- exec +timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "deletePet", + "arguments": { + "pathParameters": { + "petId": 1 + }, + "headerParameters": { + "api_key": "special-api-key" + } + } + }, + "id": 1 + }' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + 2>&1 | cat +--- response_body eval +qr/"jsonrpc":"2.0"/ +--- error_log +upstream received header api_key: special-api-key + + + +=== TEST 29: a route needs no upstream of its own +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = 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/petstore.json" + } } + }]]) + ngx.say(code < 300 and "passed" or body) + } + } +--- response_body +passed + + + +=== TEST 30: tools/list on a route without an upstream +--- max_size: 2048000 +--- 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/event: message\ndata: .*"name":"findPetsByStatus"/ + + + +=== TEST 31: an sse route without an upstream +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/mcp", + "plugins": { "openapi-to-mcp": { + "transport": "sse", + "base_url": "http://127.0.0.1:11460", + "openapi_url": "http://127.0.0.1:11460/petstore.json" + } } + }]]) + ngx.say(code < 300 and "passed" or body) + } + } +--- response_body +passed + + + +=== TEST 32: the stream opens and nothing is proxied +--- exec +timeout 1 curl -X GET -N -sS http://localhost:1984/mcp 2>&1 | cat +--- response_body_like +event:\s*endpoint +data:\s*/mcp\?sessionId=.* +--- no_error_log +failed to fetch upstream diff --git a/t/plugin/openapi_to_mcp_concurrent.py b/t/plugin/openapi_to_mcp_concurrent.py new file mode 100644 index 000000000000..2eb827cc9459 --- /dev/null +++ b/t/plugin/openapi_to_mcp_concurrent.py @@ -0,0 +1,156 @@ +#!/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. +# +"""Fire many MCP requests at once and check every answer comes back exactly once. + +The MCP SDK client does not wait for one request to finish before sending the +next: it matches answers to requests by id. Both transports have to survive that. + +On the SSE transport the risk is real -- the answer to a POST is not written on +that POST's own connection, it is queued in a shared dict and drained by a +separate streaming coroutine that polls. A lost, duplicated or mis-tagged entry +in that queue would be invisible to any test that sends one request at a time. +On the streamable transport each request answers on its own connection, so this +is mostly a check that nothing is stashed in request-shared state. + +Prints one line per problem plus a summary the .t asserts on. +""" +import json +import sys +import threading + +import openapi_to_mcp_harness as h + +# Enough to interleave inside the stream's 0.1s poll, small enough that the +# whole run stays well inside Test::Nginx's budget. +CONCURRENCY = 16 + + +def request_body(index): + # Mixed methods: a tools/call goes out to the upstream and takes visibly + # longer than a ping, so answers cannot come back in send order. + if index % 3 == 0: + return {"jsonrpc": "2.0", "id": index, "method": "ping"} + if index % 3 == 1: + return {"jsonrpc": "2.0", "id": index, "method": "tools/list", "params": {}} + return { + "jsonrpc": "2.0", "id": index, "method": "tools/call", + "params": {"name": "getPet", + "arguments": {"pathParameters": {"petId": index}}}, + } + + +def check_payload(index, payload): + if not isinstance(payload, dict): + return "id %r answered with %r" % (index, payload) + if payload.get("id") != index: + return "id %r came back as %r" % (index, payload.get("id")) + if "error" in payload: + return "id %r answered with an error: %r" % (index, payload["error"]) + if "result" not in payload: + return "id %r answered without a result" % (index,) + return None + + +def fire_all(send): + """Call send(i) for every index on its own thread, all at once.""" + threads = [threading.Thread(target=send, args=(i,)) for i in range(CONCURRENCY)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=25) + + +def sse_case(route): + """One session, CONCURRENCY requests in flight at the same time.""" + stream = h.SseStream(h.GATEWAY, route) + if not stream.open(timeout=10): + stream.close() + return ["sse: no endpoint event (%s)" % stream.error] + + problems = [] + statuses = [None] * CONCURRENCY + + def send(i): + statuses[i], _, _ = h.post_json(h.GATEWAY, stream.endpoint, request_body(i), timeout=20) + + fire_all(send) + for index, status in enumerate(statuses): + if status != 202: + problems.append("sse: POST for id %d returned %r" % (index, status)) + + def all_answers(events): + return sum(1 for e in events if e.startswith("event: message")) >= CONCURRENCY + + events = stream.wait_until(all_answers, timeout=25) + stream.close() + + seen = {} + for event in events: + if not event.startswith("event: message"): + continue + payload = json.loads(event.split("data: ", 1)[1]) + ident = payload.get("id") + if ident in seen: + problems.append("sse: id %r answered twice" % (ident,)) + seen[ident] = payload + + for index in range(CONCURRENCY): + if index not in seen: + problems.append("sse: id %d never answered" % index) + continue + bad = check_payload(index, seen[index]) + if bad: + problems.append("sse: " + bad) + return problems + + +def streamable_case(route): + """CONCURRENCY independent POSTs on the same route at the same time.""" + answers = [None] * CONCURRENCY + + def send(i): + status, _, text = h.post_json(h.GATEWAY, route, request_body(i), timeout=20) + answers[i] = (status, h.first_json(text)) + + fire_all(send) + problems = [] + for index, answer in enumerate(answers): + if answer is None: + problems.append("streamable: id %d never answered" % index) + continue + status, payload = answer + if status != 200: + problems.append("streamable: id %d returned %r (%r)" % (index, status, payload)) + continue + bad = check_payload(index, payload) + if bad: + problems.append("streamable: " + bad) + return problems + + +def main(): + sse_route, streamable_route = sys.argv[1], sys.argv[2] + problems = sse_case(sse_route) + streamable_case(streamable_route) + for line in problems: + print(line) + print("%d problem(s) across %d concurrent requests per transport" + % (len(problems), CONCURRENCY)) + + +if __name__ == "__main__": + h.run(main) diff --git a/t/plugin/openapi_to_mcp_harness.py b/t/plugin/openapi_to_mcp_harness.py new file mode 100644 index 000000000000..16708e22e56c --- /dev/null +++ b/t/plugin/openapi_to_mcp_harness.py @@ -0,0 +1,197 @@ +#!/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. +# +"""Plumbing shared by the openapi-to-mcp test drivers in this directory.""" +import http.client +import json +import socket +import struct +import sys +import threading +import traceback +import urllib.parse + +GATEWAY = ("127.0.0.1", 1984) +BOTH = "application/json, text/event-stream" +JSON_HEADERS = {"Content-Type": "application/json", "Accept": BOTH} + + +def merge(base, overrides): + """Apply header overrides; a value of None removes the header.""" + headers = dict(base or {}) + for key, value in (overrides or {}).items(): + if value is None: + headers.pop(key, None) + else: + headers[key] = value + return headers + + +def request(target, method, path, body=None, headers=None, timeout=10): + """One HTTP exchange. + + Returns (status, headers, text), headers lower-cased. status is None when the + exchange itself failed, and text then says why. + """ + host, port = target + conn = http.client.HTTPConnection(host, port, timeout=timeout) + data = None + if body is not None: + data = body.encode() if isinstance(body, str) else json.dumps(body).encode() + try: + conn.request(method, path, body=data, headers=headers or {}) + resp = conn.getresponse() + text = resp.read().decode(errors="replace") + return resp.status, {k.lower(): v for k, v in resp.getheaders()}, text + except Exception as exc: # noqa: BLE001 + return None, {}, "transport error: %r" % (exc,) + finally: + conn.close() + + +def post_json(target, path, body, headers=None, timeout=10): + """POST a JSON-RPC message the way a client would.""" + return request(target, "POST", path, body, merge(JSON_HEADERS, headers), timeout) + + +def first_json(text): + """The first JSON payload in a reply, SSE-framed or not, else None.""" + for line in (text or "").splitlines(): + line = line[6:] if line.startswith("data: ") else line + if line[:1] in ("{", "["): + try: + return json.loads(line) + except ValueError: + return None + return None + + +class SseStream: + """One open SSE connection, collecting its events on a background thread.""" + + def __init__(self, target, path, headers=None): + self.target, self.path = target, path + self.headers = merge({"Accept": "text/event-stream"}, headers) + self.events = [] + self.cond = threading.Condition() + self.stop = threading.Event() + self.conn = None + self.error = None + self.endpoint = None + self.session_id = None + + def _read(self): + try: + self.conn = http.client.HTTPConnection(*self.target, timeout=30) + self.conn.request("GET", self.path, headers=self.headers) + resp = self.conn.getresponse() + if resp.status != 200: + raise RuntimeError("stream status %d" % resp.status) + buf = b"" + while not self.stop.is_set(): + chunk = resp.read(1) + if not chunk: + break + buf += chunk + if buf.endswith(b"\n\n"): + with self.cond: + self.events.append(buf.decode().strip()) + self.cond.notify_all() + buf = b"" + except Exception as exc: # noqa: BLE001 + if not self.stop.is_set(): + self.error = "stream: %s" % (exc,) + with self.cond: + self.cond.notify_all() + + def open(self, timeout=8): + """Start reading and wait for the endpoint event. False if none came.""" + threading.Thread(target=self._read, daemon=True).start() + event = self.wait_for("event: endpoint", timeout) + if not event: + return False + self.endpoint = event.split("data: ", 1)[1].strip() + query = urllib.parse.urlparse(self.endpoint).query + self.session_id = urllib.parse.parse_qs(query).get("sessionId", [None])[0] + return self.session_id is not None + + def wait_until(self, predicate, timeout): + """Wait until predicate(events) holds; returns a snapshot of the events.""" + with self.cond: + self.cond.wait_for(lambda: self.error is not None or predicate(self.events), + timeout=timeout) + return list(self.events) + + def wait_for(self, prefix, timeout=6, after=0): + """The first event after index `after` that starts with prefix, else None.""" + def found(events): + return any(e.startswith(prefix) for e in events[after:]) + + events = self.wait_until(found, timeout) + return next((e for e in events[after:] if e.startswith(prefix)), None) + + def close(self, reset=False): + """Drop the connection without draining it. + + HTTPConnection.close() reads the rest of the response first, which on a + stream that stays open means waiting for the server's keepalive to fail. + With reset=True the socket is closed with an RST instead of a FIN. + """ + self.stop.set() + sock = getattr(self.conn, "sock", None) + if sock is None: + return + try: + if reset: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + else: + sock.shutdown(socket.SHUT_RDWR) + sock.close() + except OSError: + pass + + +def run(main): + """Run a driver's main and always exit 0. + + Test::Nginx discards an --- exec block's stdout entirely when the command + exits non-zero, which turns a failure into an empty body with no clue in + it. A driver says what happened in what it prints instead. + """ + try: + main() + except Exception: # noqa: BLE001 + print("harness error:") + traceback.print_exc(file=sys.stdout) + sys.stdout.flush() + sys.exit(0) + + +def rpc_main(): + """openapi_to_mcp_harness.py PATH MESSAGE CODE + + POST one JSON-RPC message to the gateway the way a client would, then run + CODE with the reply's JSON payload bound to d, for a .t to assert on what + CODE prints. + """ + path, message, code = sys.argv[1:4] + _, _, text = post_json(GATEWAY, path, message) + exec(code, {"json": json, "d": first_json(text)}) + + +if __name__ == "__main__": + run(rpc_main) diff --git a/t/plugin/openapi_to_mcp_session_reconnect.py b/t/plugin/openapi_to_mcp_session_reconnect.py new file mode 100644 index 000000000000..9ac4932aadfa --- /dev/null +++ b/t/plugin/openapi_to_mcp_session_reconnect.py @@ -0,0 +1,82 @@ +#!/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. +# +"""Close an MCP SSE stream, open another, and see what the gateway does. + +An SDK client that calls close() and connects again gets a second session; the +first one has to stop being the client's session without taking the second one +with it. + +The last line also records what happens to the session the client walked away +from. The gateway does not learn that an SSE client is gone: writing to the +dropped connection never reports an error without lua_check_client_abort, which +is an http-level directive and not something one plugin gets to turn on for the +whole gateway. The abandoned session therefore stays addressable until its +30-minute lifetime runs out. If that ever changes, this line changes with it. +""" +import json +import sys + +import openapi_to_mcp_harness as h + + +def ping(path, ident): + status, _, _ = h.post_json(h.GATEWAY, path, {"jsonrpc": "2.0", "id": ident, "method": "ping"}) + return status + + +def main(): + route = sys.argv[1] + problems = [] + + first = h.SseStream(h.GATEWAY, route) + if not first.open(timeout=15): + print("no endpoint on the first stream") + return + if ping(first.endpoint, 1) != 202: + problems.append("the first session did not accept a message") + if first.wait_for("event: message", timeout=10) is None: + problems.append("the first session never answered") + first.close(reset=True) + + second = h.SseStream(h.GATEWAY, route) + if not second.open(timeout=15): + problems.append("no endpoint on the second stream") + else: + if second.endpoint == first.endpoint: + problems.append("reconnecting handed out the same session id") + if ping(second.endpoint, 2) != 202: + problems.append("the second session did not accept a message") + event = second.wait_for("event: message", timeout=10) + if event is None: + problems.append("the second session never answered") + elif json.loads(event.split("data: ", 1)[1]).get("id") != 2: + problems.append("the second session answered with the wrong id") + + unknown = ping(route + "?sessionId=00000000-0000-4000-8000-000000000000", 3) + if unknown != 404: + problems.append("an unknown session id returned %s, not 404" % unknown) + + for line in problems: + print(line) + print("%d problem(s); the abandoned session answers %s" + % (len(problems), ping(first.endpoint, 4))) + second.close(reset=True) + + +if __name__ == "__main__": + h.run(main) diff --git a/t/plugin/openapi_to_mcp_sse_multiworker.py b/t/plugin/openapi_to_mcp_sse_multiworker.py new file mode 100644 index 000000000000..6c22d6564c78 --- /dev/null +++ b/t/plugin/openapi_to_mcp_sse_multiworker.py @@ -0,0 +1,76 @@ +#!/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. +# +"""Drive several concurrent MCP SSE sessions against a multi-worker gateway. + +The GET that opens a stream and the POST that feeds it are separate connections, +so with more than one nginx worker they routinely land on different processes. +Session state therefore has to live in shared memory, not in a worker-local Lua +table -- a single-worker test cannot tell the two apart. + +Prints one line per failed session plus a summary the .t asserts on. +""" +import json +import sys +import threading + +import openapi_to_mcp_harness as h + + +def one_session(route, index): + stream = h.SseStream(h.GATEWAY, route) + try: + if not stream.open(timeout=10): + return "session %d: no endpoint event (%s)" % (index, stream.error) + status, _, _ = h.post_json(h.GATEWAY, stream.endpoint, + {"jsonrpc": "2.0", "id": index, "method": "ping"}) + if status != 202: + return "session %d: post returned %s" % (index, status) + event = stream.wait_for("event: message", timeout=10) + if not event: + return "session %d: answer never came back on the stream" % index + got = json.loads(event.split("data: ", 1)[1]).get("id") + if got != index: + return "session %d: got id %r" % (index, got) + return None + finally: + stream.close() + + +def main(): + route = sys.argv[1] + count = int(sys.argv[2]) if len(sys.argv) > 2 else 6 + + results = [None] * count + + def drive(i): + results[i] = one_session(route, i + 1) + + threads = [threading.Thread(target=drive, args=(i,)) for i in range(count)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=30) + + failures = [r for r in results if r] + for line in failures: + print(line) + print("ok %d/%d sessions" % (count - len(failures), count)) + + +if __name__ == "__main__": + h.run(main) diff --git a/t/plugin/openapi_to_mcp_sse_roundtrip.py b/t/plugin/openapi_to_mcp_sse_roundtrip.py new file mode 100644 index 000000000000..98259e929815 --- /dev/null +++ b/t/plugin/openapi_to_mcp_sse_roundtrip.py @@ -0,0 +1,64 @@ +#!/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. +# +"""Drive one full MCP SSE round trip against the gateway. + +Opens the GET stream, reads the endpoint event to learn the session id, POSTs a +JSON-RPC request to the advertised message endpoint, then reads the answer back +off the stream. Prints a fixed set of lines the .t asserts on. +""" +import json +import sys + +import openapi_to_mcp_harness as h + + +def main(): + route = sys.argv[1] + stream = h.SseStream(h.GATEWAY, route) + if not stream.open(timeout=4): + print("FAIL no endpoint event (%s)" % stream.error) + return + + path, _, query = stream.endpoint.partition("?") + print("endpoint path:", path) + print("has sessionId:", query.startswith("sessionId=") and len(query) > 10) + + status, _, _ = h.post_json(h.GATEWAY, stream.endpoint, { + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "probe", "version": "1"}}, + }) + print("post status:", status) + + event = stream.wait_for("event: message", timeout=2) + if not event: + print("FAIL no message pushed back") + return + result = json.loads(event.split("data: ", 1)[1])["result"] + print("protocolVersion:", result["protocolVersion"]) + print("serverInfo:", result["serverInfo"]["name"], result["serverInfo"]["version"]) + + # a message for an unknown session must be rejected + bad, _, _ = h.post_json(h.GATEWAY, path + "?sessionId=does-not-exist", + {"jsonrpc": "2.0", "id": 2, "method": "ping"}) + print("unknown session status:", bad) + stream.close() + + +if __name__ == "__main__": + h.run(main) diff --git a/utils/install-dependencies.sh b/utils/install-dependencies.sh index 34e4d5fb9dfe..cc901e4f6e14 100755 --- a/utils/install-dependencies.sh +++ b/utils/install-dependencies.sh @@ -73,7 +73,10 @@ function install_dependencies_with_apt() { if [[ "${1}" == "ubuntu" ]]; then sudo add-apt-repository -y "deb http://openresty.org/package/${arch_path}ubuntu $(lsb_release -sc) main" elif [[ "${1}" == "debian" ]]; then - sudo add-apt-repository -y "deb http://openresty.org/package/${arch_path}debian $(lsb_release -sc) openresty" + # add-apt-repository on Debian 12 writes an empty list file for a plain + # deb line, so the repository never makes it into apt + echo "deb http://openresty.org/package/${arch_path}debian $(lsb_release -sc) openresty" \ + | sudo tee /etc/apt/sources.list.d/openresty.list fi sudo apt-get update