This is an automated email from the ASF dual-hosted git repository.
shreemaan-abhishek pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new 77055643b fix(openapi-to-mcp): confine a path parameter to one path
segment (#14154)
77055643b is described below
commit 77055643b8db34e2058f6a90b1b0d346136be968
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Fri Nov 6 13:12:24 2026 +0800
fix(openapi-to-mcp): confine a path parameter to one path segment (#14154)
---
apisix/plugins/openapi-to-mcp.lua | 8 +
apisix/plugins/openapi-to-mcp/server.lua | 8 +-
apisix/plugins/openapi-to-mcp/tools/handler.lua | 161 ++++++-
docs/en/latest/plugins/openapi-to-mcp.md | 2 +
docs/zh/latest/plugins/openapi-to-mcp.md | 2 +
t/lib/openapi_to_mcp_fixture.lua | 126 +++++
t/plugin/openapi-to-mcp.t | 608 +++++++++++++++++++++++-
7 files changed, 902 insertions(+), 13 deletions(-)
diff --git a/apisix/plugins/openapi-to-mcp.lua
b/apisix/plugins/openapi-to-mcp.lua
index f7ce38033..48fa9c22c 100644
--- a/apisix/plugins/openapi-to-mcp.lua
+++ b/apisix/plugins/openapi-to-mcp.lua
@@ -127,6 +127,14 @@ local schema = {
type = "boolean",
default = false,
},
+ allow_encoded_path_separators = {
+ description = "Whether path parameter values may contain slash " ..
+ "or backslash characters, which are percent-encoded before the " ..
+ "upstream request. Separator-delimited empty and dot components "
..
+ "remain forbidden.",
+ type = "boolean",
+ default = false,
+ },
},
required = { "openapi_url", "base_url" },
}
diff --git a/apisix/plugins/openapi-to-mcp/server.lua
b/apisix/plugins/openapi-to-mcp/server.lua
index ef9bf2437..1585a7127 100644
--- a/apisix/plugins/openapi-to-mcp/server.lua
+++ b/apisix/plugins/openapi-to-mcp/server.lua
@@ -187,7 +187,13 @@ local function handle_tools_call(request, opts, tools)
"Invalid arguments for tool " .. name .. ": " ..
tostring(err))
end
- return jsonrpc.result(request.id, handler.call(tool, arguments, opts))
+ local result, call_err = handler.call(tool, arguments, opts)
+ if not result then
+ return tool_error(request.id, "MCP error -32602: Input validation
error: " ..
+ "Invalid arguments for tool " .. name .. ": " ..
tostring(call_err))
+ end
+
+ return jsonrpc.result(request.id, result)
end
diff --git a/apisix/plugins/openapi-to-mcp/tools/handler.lua
b/apisix/plugins/openapi-to-mcp/tools/handler.lua
index 5ee2a0974..55794d540 100644
--- a/apisix/plugins/openapi-to-mcp/tools/handler.lua
+++ b/apisix/plugins/openapi-to-mcp/tools/handler.lua
@@ -27,6 +27,7 @@ local str_upper = string.upper
local str_gsub = string.gsub
local str_gmatch = string.gmatch
local str_find = string.find
+local str_sub = string.sub
local table_concat = table.concat
local table_sort = table.sort
local escape_uri = ngx.escape_uri
@@ -124,12 +125,140 @@ local function apply_query_defaults(tool, query)
end
-local function build_path(template, path_params)
+-- A separator in a path parameter is percent-encoded, but servers disagree on
+-- whether to preserve it as data or decode it into path syntax. The secure
+-- default is to reject it; a Route may opt in for an API that deliberately
+-- accepts encoded separators.
+local function has_separator(value)
+ -- core.string.find is a plain search; this is a pattern, so it needs
+ -- string.find itself. A backslash is a separator on some servers.
+ return str_find(value, "[/\\]") ~= nil
+end
+
+
+-- Even with encoded separators enabled, refuse values whose components would
+-- be resolved away after a server decodes them. Empty components cover
leading,
+-- trailing and repeated separators. Both slash spellings are considered: a
+-- backslash is a separator on some servers.
+local function resolved_away(segment)
+ return segment == "" or segment == "." or segment == ".."
+end
+
+
+local function separator_creates_unsafe_component(value)
+ if not has_separator(value) then
+ return false
+ end
+
+ for component in str_gmatch(value .. "/", "([^/\\]*)[/\\]") do
+ if resolved_away(component) then
+ return true
+ end
+ end
+ return false
+end
+
+
+-- Return everything before one of the given URI delimiters. A template
+-- expression name may itself contain a delimiter, so only one outside braces
+-- counts. Parameter values cannot introduce one: escape_uri encodes them.
+local function before_uri_delimiter(value, delimiters)
+ local in_expression = false
+ for index = 1, #value do
+ local char = str_sub(value, index, index)
+ if char == "{" then
+ in_expression = true
+ elseif char == "}" then
+ in_expression = false
+ elseif not in_expression and str_find(delimiters, char, 1, true) then
+ return str_sub(value, 1, index - 1)
+ end
+ end
+ return value
+end
+
+
+local function uri_path(value)
+ return before_uri_delimiter(value, "?#")
+end
+
+
+local function without_fragment(value)
+ return before_uri_delimiter(value, "#")
+end
+
+
+-- The segments of a path, with the empty one a leading or trailing slash
+-- leaves: "/items/x" is { "", "items", "x" }.
+local function path_segments(value)
+ local segments = {}
+ local path = uri_path(value)
+ for segment in str_gmatch(path .. "/", "([^/]*)/") do
+ segments[#segments + 1] = segment
+ end
+ return segments
+end
+
+
+-- The segments of a path template. Only a slash outside braces separates them,
+-- because a template expression name may contain one: the OpenAPI grammar for
+-- the name admits every character except a brace, so "/x/{a/b}/{c}" declares
+-- two expressions rather than three segments. Splitting on every slash would
+-- shift every later entry, which both skips the segment "{c}" fills and
+-- attributes a literal segment of the template to an expression.
+--
+-- A parameter value cannot shift them: a separator in one is either refused or
+-- percent-encoded, so the substituted path keeps this segment count.
+local function template_segments(template)
+ local segments = {}
+ local path = uri_path(template)
+ local in_expression = false
+ local start = 1
+ for index = 1, #path do
+ local char = str_sub(path, index, index)
+ if char == "{" then
+ in_expression = true
+ elseif char == "}" then
+ in_expression = false
+ elseif char == "/" and not in_expression then
+ segments[#segments + 1] = str_sub(path, start, index - 1)
+ start = index + 1
+ end
+ end
+ segments[#segments + 1] = str_sub(path, start)
+ return segments
+end
+
+
+-- A server receiving the request resolves "." and ".." away, but only where
+-- one is a whole segment. A template expression may fill part of a segment,
+-- and "{name}.json" filled with ".." comes out as "...json", which addresses
+-- nothing else, so the value alone does not say whether it moves the request.
+-- The finished path does.
+--
+-- Only the segments an expression helped build are checked: one the template
+-- contributes by itself is not the caller's to shift. Encoded separators do
+-- not add a literal slash to the finished path, so the segment lists still
+-- line up by index when the Route opts in to them.
+local function build_path(template, path_params, allow_encoded_separators)
local path = template
+ local declared_path = uri_path(template)
for name, value in pairs(path_params) do
+ local text = tostring(value)
+ local expression = "{" .. name .. "}"
+ local is_in_path = str_find(declared_path, expression, 1, true) ~= nil
+ if is_in_path and has_separator(text) and not allow_encoded_separators
then
+ return nil, "path parameter " .. name .. " cannot contain a path
separator"
+ end
+ if is_in_path and allow_encoded_separators
+ and separator_creates_unsafe_component(text)
+ then
+ return nil, "path parameter " .. name ..
+ " cannot contain an empty or dot path component"
+ end
-- the name is a literal, so escape any pattern magic in it
local pattern = "{" .. str_gsub(name, "([%^%$%(%)%%%.%[%]%*%+%-%?])",
"%%%1") .. "}"
- local escaped = escape_uri(tostring(value))
+ local escaped = escape_uri(text)
-- 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".
@@ -137,7 +266,22 @@ local function build_path(template, path_params)
return escaped
end)
end
- return path
+
+ local declared = template_segments(template)
+ for index, segment in ipairs(path_segments(path)) do
+ local from_template = declared[index]
+ if from_template and str_find(from_template, "{", 1, true)
+ and resolved_away(segment)
+ then
+ return nil, "path parameter in segment " .. from_template ..
+ " cannot produce an empty or dot path segment"
+ end
+ end
+
+ -- A URI fragment is client-side metadata and must not be sent in an HTTP
+ -- request target. A literal hash can only come from the template because
+ -- hashes in parameter values have already been percent-encoded.
+ return without_fragment(path)
end
@@ -354,13 +498,22 @@ local function put_header(headers, seen, name, value)
end
+-- Returns the tool result, or nil plus a message for an argument that cannot
+-- be sent at all.
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 allow_encoded_separators = opts.conf and
+ opts.conf.allow_encoded_path_separators
== true
+ local path, path_err = build_path(tool.path_template, path_params,
+ allow_encoded_separators)
+ if not path then
+ core.log.warn("rejected an openapi-to-mcp tool call: ", path_err)
+ return nil, path_err
+ end
local query = build_query(tool, query_params)
-- Header parameters are written first so that the route's own headers win:
diff --git a/docs/en/latest/plugins/openapi-to-mcp.md
b/docs/en/latest/plugins/openapi-to-mcp.md
index 7c8644b06..811d2e107 100644
--- a/docs/en/latest/plugins/openapi-to-mcp.md
+++ b/docs/en/latest/plugins/openapi-to-mcp.md
@@ -51,6 +51,7 @@ The Plugin supports:
| base_url | string | True | |
| Base URL of the API the tools call. The path of each operation is appended
to it. Supports [APISIX variables](../apisix-variable.md) and [NGINX
variables](http://nginx.org/en/docs/varindex.html), for example
`http://${http_x_backend}`. |
| headers | object | False | |
| Headers added to every request sent to the API. Values support variables,
for example `"Authorization": "Bearer ${http_x_api_token}"`. |
| flatten_parameters | boolean | False | `false` |
| When `false`, the tool input nests parameters under `pathParameters`,
`queryParameters` and `headerParameters`. When `true`, they are placed directly
in the input object. |
+| allow_encoded_path_separators | boolean | False | `false` | | Whether path
parameter values may contain `/` or `\`, which are percent-encoded before the
upstream request. Enable this only when the upstream consistently treats
encoded separators as data. Separator-delimited empty, `.`, and `..` components
remain forbidden. |
| max_response_body_size | integer | False | `1048576` | >= 1024 | Maximum
size, in bytes, of an upstream response read into a tool result. A larger
response fails the call with `RESPONSE_TOO_LARGE` instead of being buffered. A
tool result over 256 KiB is returned as compact JSON rather than indented. |
| max_document_size | integer | False | `4194304` | >= 1024 | Maximum size, in
bytes, of the OpenAPI document, and of any document an `http(s)` `$ref` pulls
in. A larger document fails the Route rather than being read into the worker. |
| max_expanded_nodes | integer | False | `50000` | >= 1000 | Maximum number of
nodes one `$ref` expansion may produce in the tool input schemas. Past it,
every `$ref` still being expanded and every one after it degrades to a generic
object, which a very large document can reach legitimately. Only `paths` is
expanded, so the document's other sections do not count towards it. |
@@ -65,6 +66,7 @@ When a tool is called, the Plugin builds the request from the
operation:
* Query parameters are serialized according to their `style` and `explode`, as
defined by the [OpenAPI Parameter
Object](https://spec.openapis.org/oas/v3.0.3#style-values). With the defaults
(`form`, exploded), `tags: ["a", "b"]` is sent as `tags=a&tags=b` -- not as
`tags[]=a&tags[]=b`, and an array parameter declared `explode: false` is sent
as `tags=a,b`. `spaceDelimited`, `pipeDelimited` and `deepObject` are
supported. An API that expects the bracket form has to be reached through a P
[...]
* A request body is sent with the media type the operation declares, unless
`headers` sets `Content-Type`.
* Header parameters are applied before the Plugin's own `headers`, so a tool
call cannot replace a credential the route adds, and a header whose value
contains a newline is dropped.
+* By default, a path parameter cannot contain `/` or `\`. Set
`allow_encoded_path_separators` to `true` only for an upstream that
consistently treats `%2F` and `%5C` as data; the Plugin then permits separators
but still rejects a value with an empty, `.` or `..` separator-delimited
component. Once every parameter is filled in, a segment an expression helped
build also cannot be empty, `.` or `..`. What counts is the finished segment,
so `/files/{name}.json` with `name` set to `..` is acc [...]
For the SSE transport, variables in `base_url` and in `headers` are resolved
on the request that opens the stream, and the resolved values are used for
every message of that session. This is what makes a configuration such as
`"Authorization": "Bearer ${http_x_api_token}"` usable over SSE: the message
requests that follow carry only the session id, so there is nothing left to
resolve from at that point. Those resolved values are kept with the session
record in the `mcp-session` shared di [...]
diff --git a/docs/zh/latest/plugins/openapi-to-mcp.md
b/docs/zh/latest/plugins/openapi-to-mcp.md
index 95a9e86d6..14313de17 100644
--- a/docs/zh/latest/plugins/openapi-to-mcp.md
+++ b/docs/zh/latest/plugins/openapi-to-mcp.md
@@ -51,6 +51,7 @@ MCP 服务运行在 APISIX 内部,不需要额外的进程或服务。
| base_url | string | 是 | | | 工具调用的 API 基础地址,每个操作的路径拼接在其后。支持 [APISIX
变量](../apisix-variable.md) 和 [NGINX
变量](http://nginx.org/en/docs/varindex.html),例如 `http://${http_x_backend}`。 |
| headers | object | 否 | | | 发往 API 的每个请求都会携带的请求头。值支持变量,例如 `"Authorization":
"Bearer ${http_x_api_token}"`。 |
| flatten_parameters | boolean | 否 | `false` | | 为 `false` 时,工具输入中的参数分别嵌套在
`pathParameters`、`queryParameters` 和 `headerParameters` 下;为 `true`
时,参数直接放在输入对象的顶层。 |
+| allow_encoded_path_separators | boolean | 否 | `false` | | 是否允许路径参数值包含 `/` 或
`\`;向上游发送请求前会对它们进行百分号编码。仅当上游始终将编码后的分隔符视为数据时才启用。由分隔符划分的空片段、`.` 片段和 `..` 片段仍不允许。 |
| max_response_body_size | integer | 否 | `1048576` | >= 1024 |
读取到工具结果中的上游响应体大小上限(字节)。超出时调用失败并返回 `RESPONSE_TOO_LARGE`,不会把响应缓冲下来。超过 256 KiB
的工具结果以紧凑 JSON 返回,不再缩进。 |
| max_document_size | integer | 否 | `4194304` | >= 1024 | OpenAPI 文档,以及文档内
`http(s)` 形式 `$ref` 拉取的文档的大小上限(字节)。超出上限的文档不会读入 worker,该路由直接报错。 |
| max_expanded_nodes | integer | 否 | `50000` | >= 1000 | 单次 `$ref` 展开在工具输入
Schema 中最多产生的节点数。超出后,仍在展开中的 `$ref` 及其后的所有 `$ref` 都降级为通用对象;足够大的正常文档也可能触及此上限。只有
`paths` 会被展开,文档其他部分不计入。 |
@@ -65,6 +66,7 @@ MCP 服务运行在 APISIX 内部,不需要额外的进程或服务。
* 查询参数按其 `style` 和 `explode` 序列化,规则见 [OpenAPI Parameter
Object](https://spec.openapis.org/oas/v3.0.3#style-values)。使用默认值(`form`,展开)时,`tags:
["a", "b"]` 发送为 `tags=a&tags=b`,而不是 `tags[]=a&tags[]=b`;声明为 `explode: false`
的数组参数发送为 `tags=a,b`。同时支持 `spaceDelimited`、`pipeDelimited` 和 `deepObject`。如果 API
要求方括号形式,需要另外通过改写查询字符串的插件处理。
* 请求体使用操作中声明的媒体类型发送,除非 `headers` 中已设置 `Content-Type`。
* header 参数在插件自身的 `headers` 之前写入,因此工具调用无法覆盖路由添加的凭据;取值中含换行符的 header 会被丢弃。
+* 默认情况下,路径参数不能包含 `/` 或 `\`。仅当上游始终将 `%2F` 和 `%5C` 视为数据时,才应将
`allow_encoded_path_separators` 设为 `true`;此时插件允许分隔符,但仍拒绝含有空、`.` 或 `..`
分隔片段的值。所有参数填充完成后,由模板表达式参与构成的片段也不能为空、为 `.` 或 `..`。判断以最终片段为准,因此
`/files/{name}.json` 的 `name` 取 `..` 时会被接受,访问 `/files/...json`。这些检查仅适用于 URI
路径中的表达式;模板查询后缀中的表达式会编码为查询数据,片段后缀则会被移除,因为 HTTP 请求不会发送 URI 片段。拒绝时调用失败并返回
`isError`。
使用 SSE 传输时,`base_url` 和 `headers`
中的变量在打开事件流的那次请求上解析,解析结果用于该会话的所有消息。`"Authorization": "Bearer
${http_x_api_token}"` 这类配置因此在 SSE 下同样可用:后续的消息请求只携带会话
ID,此时已无从解析变量。解析结果会随会话记录存放在共享字典 `mcp-session` 中直到会话结束,因此调用方以这种方式提供的凭据会在网关内存中最长保留
30 分钟;配置中不含变量时则不存储。
diff --git a/t/lib/openapi_to_mcp_fixture.lua b/t/lib/openapi_to_mcp_fixture.lua
index 3483acd59..f56934960 100644
--- a/t/lib/openapi_to_mcp_fixture.lua
+++ b/t/lib/openapi_to_mcp_fixture.lua
@@ -61,6 +61,132 @@ local DOCUMENTS = {
} } },
},
+ -- a slash inside a template expression name, which the OpenAPI grammar
+ -- admits, so the template's segments have to be counted accordingly
+ ["/slashinname.json"] = {
+ openapi = "3.0.0",
+ info = { title = "SlashInName", version = "1" },
+ paths = {
+ ["/x/{scope/key}/{id}/.."] = { get = {
+ operationId = "literalDotTail",
+ parameters = {
+ { name = "scope/key", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ { name = "id", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ ["/x/{a/b}/{c}"] = { get = {
+ operationId = "slashNameTail",
+ parameters = {
+ { name = "a/b", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ { name = "c", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ ["/x/{a/b}/{c}/y"] = { get = {
+ operationId = "slashNameMiddle",
+ parameters = {
+ { name = "a/b", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ { name = "c", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ },
+ },
+
+ -- a path parameter that fills only part of a segment
+ ["/partialsegment.json"] = {
+ openapi = "3.0.0",
+ info = { title = "Partial", version = "1" },
+ paths = { ["/files/{name}.json"] = { get = {
+ operationId = "getFile",
+ parameters = {
+ { name = "name", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } } },
+ },
+
+ -- path templates that exercise validation after every expression is filled
+ ["/path-safety.json"] = {
+ openapi = "3.0.0",
+ info = { title = "Path safety", version = "1" },
+ paths = {
+ ["/x/{a}{b}"] = { get = {
+ operationId = "adjacentPathParameters",
+ parameters = {
+ { name = "a", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ { name = "b", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ ["/x/{a}.{b}"] = { get = {
+ operationId = "separatedPathParameters",
+ parameters = {
+ { name = "a", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ { name = "b", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ ["/x/.{a}"] = { get = {
+ operationId = "prefixedPathParameter",
+ parameters = {
+ { name = "a", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ ["/{Bucket}?acl"] = { get = {
+ operationId = "getBucketAcl",
+ parameters = {
+ { name = "Bucket", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ ["/{Bucket}/{Key}#acl"] = { get = {
+ operationId = "getObjectAcl",
+ parameters = {
+ { name = "Bucket", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ { name = "Key", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ ["/search?q={term}"] = { get = {
+ operationId = "searchByTerm",
+ parameters = {
+ { name = "term", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ ["/search?url={u}"] = { get = {
+ operationId = "searchByUrl",
+ parameters = {
+ { name = "u", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ ["/x#/{a}"] = { get = {
+ operationId = "discardFragmentExpression",
+ parameters = {
+ { name = "a", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ ["/encoded/{path}"] = { get = {
+ operationId = "getEncodedPath",
+ parameters = {
+ { name = "path", ["in"] = "path", required = true,
+ schema = { type = "string" } },
+ },
+ } },
+ },
+ },
+
-- a tool that declares one header parameter
["/headerparam.json"] = {
openapi = "3.0.0",
diff --git a/t/plugin/openapi-to-mcp.t b/t/plugin/openapi-to-mcp.t
index 416edafa5..b11ec8bcc 100644
--- a/t/plugin/openapi-to-mcp.t
+++ b/t/plugin/openapi-to-mcp.t
@@ -282,14 +282,17 @@ data:\s*/mcp\?sessionId=.*
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
+ for _, allow_separators 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,
+ allow_encoded_path_separators = allow_separators,
+ })
+ if not ok then
+ ngx.say(err)
+ return
+ end
end
end
@@ -1573,3 +1576,592 @@ streamable_http 5: true
streamable_http 6: true
streamable_http 7: true
streamable_http 8: true
+
+
+
+=== TEST 86: a route over a document with a string path parameter
+--- config
+ location /t {
+ content_by_lua_block {
+ local ok = require("lib.openapi_to_mcp_fixture").put_routes({
+ { 1, "/mcp", {
+ transport = "streamable_http",
+ base_url = "http://127.0.0.1:11460",
+ openapi_url = "http://127.0.0.1:11460/defaults.json",
+ } },
+ })
+ if ok then ngx.say("passed") end
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 87: a path parameter fills the segment it was declared for
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getItem","arguments":{"pathParameters":{"id":"abc"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*items\/abc/
+
+
+
+=== TEST 88: a dot segment in a path parameter fails the call
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getItem","arguments":{"pathParameters":{"id":".."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*path parameter in segment \{id\} cannot
produce an empty or dot path segment)/
+
+
+
+=== TEST 89: a single dot in a path parameter fails the call
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getItem","arguments":{"pathParameters":{"id":"."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*path parameter in segment \{id\} cannot
produce an empty or dot path segment)/
+
+
+
+=== TEST 90: a separator in a path parameter fails the call
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getItem","arguments":{"pathParameters":{"id":"../items"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*path parameter id cannot contain a path
separator)/
+
+
+
+=== TEST 91: a backslash in a path parameter fails the call
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getItem","arguments":{"pathParameters":{"id":"..\\\\items"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*path parameter id cannot contain a path
separator)/
+
+
+
+=== TEST 92: an empty path parameter fails the call
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getItem","arguments":{"pathParameters":{"id":""}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*path parameter in segment \{id\} cannot
produce an empty or dot path segment)/
+
+
+
+=== TEST 93: a route over a document whose parameter fills part of a segment
+--- 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/partialsegment.json",
+ } },
+ })
+ if ok then ngx.say("passed") end
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 94: a parameter that fills part of a segment is sent as given
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getFile","arguments":{"pathParameters":{"name":"report"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*files\/report\.json/
+
+
+
+=== TEST 95: a dot-dot value that is not a whole segment is allowed through
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getFile","arguments":{"pathParameters":{"name":".."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*files\/\.\.\.json/
+
+
+
+=== TEST 96: a single dot that is not a whole segment is allowed through
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getFile","arguments":{"pathParameters":{"name":"."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*files\/\.\.json/
+
+
+
+=== TEST 97: an empty value that leaves a non-empty segment is allowed through
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getFile","arguments":{"pathParameters":{"name":""}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*files\/\.json/
+
+
+
+=== TEST 98: a separator is still rejected when the parameter fills part of a
segment
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getFile","arguments":{"pathParameters":{"name":"../secret"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*path parameter name cannot contain a path
separator)/
+
+
+
+=== TEST 99: a route over path templates with several expressions and URI
suffixes
+--- 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/path-safety.json",
+ } },
+ })
+ if ok then ngx.say("passed") end
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 100: adjacent expressions are judged as one finished segment
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"adjacentPathParameters","arguments":{"pathParameters":{"a":".","b":"."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*cannot produce an empty or dot path segment)/
+
+
+
+=== TEST 101: literals between expressions take part in the finished segment
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"separatedPathParameters","arguments":{"pathParameters":{"a":"","b":""}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*cannot produce an empty or dot path segment)/
+
+
+
+=== TEST 102: a literal prefix can complete a dot segment
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"prefixedPathParameter","arguments":{"pathParameters":{"a":"."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*cannot produce an empty or dot path segment)/
+
+
+
+=== TEST 103: a query suffix does not hide a dot path segment
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getBucketAcl","arguments":{"pathParameters":{"Bucket":".."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*cannot produce an empty or dot path segment)/
+
+
+
+=== TEST 104: a query suffix does not hide an empty path segment
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getBucketAcl","arguments":{"pathParameters":{"Bucket":""}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*cannot produce an empty or dot path segment)/
+
+
+
+=== TEST 105: a fragment suffix does not hide a dot path segment
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getObjectAcl","arguments":{"pathParameters":{"Bucket":"b","Key":".."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*cannot produce an empty or dot path segment)/
+
+
+
+=== TEST 106: a safe query-suffixed path is still sent unchanged
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getBucketAcl","arguments":{"pathParameters":{"Bucket":"reports"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*reports\?acl/
+
+
+
+=== TEST 107: an ordinary separator is rejected by default and logged
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getEncodedPath","arguments":{"pathParameters":{"path":"group/project"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*path parameter path cannot contain a path
separator)/
+--- error_log
+rejected an openapi-to-mcp tool call: path parameter path cannot contain a
path separator
+
+
+
+=== TEST 108: path validation also applies to flattened arguments
+--- 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/path-safety.json",
+ flatten_parameters = true,
+ } },
+ })
+ if ok then ngx.say("passed") end
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 109: a flattened call is judged on its finished segment
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"adjacentPathParameters","arguments":{"a":".","b":"."}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*cannot produce an empty or dot path segment)/
+
+
+
+=== TEST 110: a route can opt in to encoded path separators
+--- 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/path-safety.json",
+ allow_encoded_path_separators = true,
+ } },
+ })
+ if ok then ngx.say("passed") end
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 111: the opt-in sends a slash as data
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getEncodedPath","arguments":{"pathParameters":{"path":"group/project"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*encoded\/group%2Fproject/
+
+
+
+=== TEST 112: the opt-in sends a backslash as data
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getEncodedPath","arguments":{"pathParameters":{"path":"group\\project"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*encoded\/group%5Cproject/
+
+
+
+=== TEST 113: the opt-in still rejects a leading parent component
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getEncodedPath","arguments":{"pathParameters":{"path":"../admin"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*cannot contain an empty or dot path component)/
+
+
+
+=== TEST 114: the opt-in still rejects an embedded parent component
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getEncodedPath","arguments":{"pathParameters":{"path":"a/../b"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*cannot contain an empty or dot path component)/
+
+
+
+=== TEST 115: the opt-in still rejects an empty component
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getEncodedPath","arguments":{"pathParameters":{"path":"a//b"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*cannot contain an empty or dot path component)/
+
+
+
+=== TEST 116: encoded query and fragment characters remain parameter data
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getEncodedPath","arguments":{"pathParameters":{"path":"a?b#c"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*encoded\/a%3Fb%23c/
+
+
+
+=== TEST 117: restore the secure-default path-safety route
+--- 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/path-safety.json",
+ } },
+ })
+ if ok then ngx.say("passed") end
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 118: a separator in a query expression is not path syntax
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"searchByTerm","arguments":{"pathParameters":{"term":"a/b"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*search\?q=a%2Fb/
+
+
+
+=== TEST 119: a fragment suffix is not sent upstream
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getObjectAcl","arguments":{"pathParameters":{"Bucket":"b","Key":"k"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*seen_path.*b\/k)(?!.*#acl)/
+
+
+
+=== TEST 120: an expression in a discarded fragment is not path syntax
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"discardFragmentExpression","arguments":{"pathParameters":{"a":".."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*seen_path.*\/x)(?!.*#\/)/
+
+
+
+=== TEST 121: restore the encoded-separator opt-in route
+--- 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/path-safety.json",
+ allow_encoded_path_separators = true,
+ } },
+ })
+ if ok then ngx.say("passed") end
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 122: the opt-in does not apply path checks to a query expression
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"searchByUrl","arguments":{"pathParameters":{"u":"http://x/y"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*search\?url=http%3A%2F%2Fx%2Fy/
+
+
+
+=== TEST 123: the opt-in still rejects a separator-free parent segment
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"getEncodedPath","arguments":{"pathParameters":{"path":".."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*cannot produce an empty or dot path segment)/
+
+
+
+=== TEST 124: a route over a document with a slash in an expression name
+--- 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/slashinname.json",
+ } },
+ })
+ if ok then ngx.say("passed") end
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 125: a literal dot segment of the template is not the caller's to
answer for
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"literalDotTail","arguments":{"pathParameters":{"scope/key":"safe","id":"ok"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*x\/safe\/ok\/\.\./
+
+
+
+=== TEST 126: a dot-dot after a slashed expression name is rejected
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"slashNameTail","arguments":{"pathParameters":{"a/b":"v","c":".."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*path parameter in segment \{c\} cannot produce
an empty or dot path segment)/
+
+
+
+=== TEST 127: an empty value after a slashed expression name is rejected
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"slashNameTail","arguments":{"pathParameters":{"a/b":"v","c":""}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*path parameter in segment \{c\} cannot produce
an empty or dot path segment)/
+
+
+
+=== TEST 128: a dot-dot between a slashed expression name and a literal is
rejected
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"slashNameMiddle","arguments":{"pathParameters":{"a/b":"v","c":".."}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"isError":true)(?=.*path parameter in segment \{c\} cannot produce
an empty or dot path segment)/
+
+
+
+=== TEST 129: a safe value for a slashed expression name is sent
percent-encoded
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+ -d
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"slashNameTail","arguments":{"pathParameters":{"a/b":"v","c":"ok"}}}}'
\
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ 2>&1 | cat
+--- response_body eval
+qr/seen_path.*x\/v\/ok/