This is an automated email from the ASF dual-hosted git repository.

AlinsRan 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 789d4b8220 fix(openapi-to-mcp): apply defaults, keep SSE variables, 
reject non-OpenAPI documents (#13956)
789d4b8220 is described below

commit 789d4b8220f9d54c0cd20f8e921639b164bbfd84
Author: AlinsRan <[email protected]>
AuthorDate: Thu Sep 17 16:52:16 2026 +0800

    fix(openapi-to-mcp): apply defaults, keep SSE variables, reject non-OpenAPI 
documents (#13956)
---
 apisix/plugins/openapi-to-mcp/cache.lua          |   5 +
 apisix/plugins/openapi-to-mcp/openapi/loader.lua |  35 +++++
 apisix/plugins/openapi-to-mcp/server.lua         | 101 +++++++++++++++
 apisix/plugins/openapi-to-mcp/session.lua        |  48 ++++++-
 apisix/plugins/openapi-to-mcp/transport/sse.lua  |  64 ++++++++-
 docs/en/latest/plugins/openapi-to-mcp.md         |   8 +-
 docs/zh/latest/plugins/openapi-to-mcp.md         |   8 +-
 t/lib/openapi_to_mcp_fixture.lua                 |  41 ++++++
 t/plugin/openapi-to-mcp-e2e-sse.t                | 106 +++++++++++++++
 t/plugin/openapi-to-mcp-openapi-loader.t         | 145 +++++++++++++++++++++
 t/plugin/openapi-to-mcp.t                        | 158 ++++++++++++++++++++---
 t/plugin/openapi_to_mcp_sse_frozen_vars.py       |  58 +++++++++
 12 files changed, 749 insertions(+), 28 deletions(-)

diff --git a/apisix/plugins/openapi-to-mcp/cache.lua 
b/apisix/plugins/openapi-to-mcp/cache.lua
index 6ebaee5247..d5123dc326 100644
--- a/apisix/plugins/openapi-to-mcp/cache.lua
+++ b/apisix/plugins/openapi-to-mcp/cache.lua
@@ -54,6 +54,11 @@ local function build_tools(openapi_url, flatten_parameters)
         return nil, err
     end
 
+    local ok, invalid = loader.validate(spec)
+    if not ok then
+        return nil, invalid
+    end
+
     local resolved = ref.resolve(spec)
     return generator.generate(resolved, path_order, {
         flatten_parameters = flatten_parameters,
diff --git a/apisix/plugins/openapi-to-mcp/openapi/loader.lua 
b/apisix/plugins/openapi-to-mcp/openapi/loader.lua
index d1f0bff7c2..141a5fd9cb 100644
--- a/apisix/plugins/openapi-to-mcp/openapi/loader.lua
+++ b/apisix/plugins/openapi-to-mcp/openapi/loader.lua
@@ -143,6 +143,41 @@ function _M.parse(body)
 end
 
 
+-- Whether a parsed document is an OpenAPI document at all. Any JSON or YAML
+-- parses, so without this a route pointed at the wrong URL -- an error page, 
an
+-- index document, a spec that failed to render -- comes up as a healthy MCP
+-- server with an empty tool list, and nothing anywhere says why. A document
+-- with no `paths` is rejected for the same reason: there is nothing to serve
+-- from it.
+--
+-- Only the route's own document is held to this. A document pulled in by an
+-- external `$ref` is usually a fragment -- components, a single schema -- and
+-- has neither key.
+function _M.validate(spec)
+    if type(spec) ~= "table" then
+        return nil, "openapi spec is not an object"
+    end
+    -- YAML leaves an unquoted version as a number: "swagger: 2.0" and
+    -- "openapi: 3.1" both parse that way, and only a two-dot version such as
+    -- "3.0.0" comes back as a string. JSON documents always quote it.
+    local version = spec.openapi or spec.swagger
+    if type(version) ~= "string" and type(version) ~= "number" then
+        return nil, "not an openapi document: no openapi or swagger version"
+    end
+    -- OpenAPI 3.1 lets a document carry webhooks or components alone, so the
+    -- absence of paths is not proof that this is not an OpenAPI document. It
+    -- does mean there is nothing to turn into tools, which the caller reports
+    -- as its own error rather than as "this is not an OpenAPI document".
+    if type(spec.paths) ~= "table" then
+        if type(spec.webhooks) == "table" or type(spec.components) == "table" 
then
+            return nil, "openapi document declares no paths"
+        end
+        return nil, "not an openapi document: no paths object"
+    end
+    return true
+end
+
+
 function _M.fetch(url, timeout)
     local httpc, err = http.new()
     if not httpc then
diff --git a/apisix/plugins/openapi-to-mcp/server.lua 
b/apisix/plugins/openapi-to-mcp/server.lua
index 32a2536b78..ef9bf24376 100644
--- a/apisix/plugins/openapi-to-mcp/server.lua
+++ b/apisix/plugins/openapi-to-mcp/server.lua
@@ -20,8 +20,10 @@ local protocol = 
require("apisix.plugins.openapi-to-mcp.protocol")
 local cache    = require("apisix.plugins.openapi-to-mcp.cache")
 local handler  = require("apisix.plugins.openapi-to-mcp.tools.handler")
 local ipairs   = ipairs
+local pairs    = pairs
 local type     = type
 local tostring = tostring
+local next     = next
 local setmetatable = setmetatable
 
 local _M = {}
@@ -62,6 +64,104 @@ local function tool_error(id, message)
 end
 
 
+-- A default is copied, never shared: the schema is cached for the lifetime of
+-- the tool list, and handing the same table to every call would let one call's
+-- mutation leak into the next.
+local function clone(value)
+    if type(value) ~= "table" then
+        return value
+    end
+    local out = {}
+    for key, item in pairs(value) do
+        out[key] = clone(item)
+    end
+    return out
+end
+
+
+-- Fill in `default`s the way a client is entitled to expect, before the
+-- arguments are validated: a parameter that is `required` and carries a
+-- `default` must not be reported as missing. Only objects the caller actually
+-- sent are descended into, so an absent `queryParameters` is still a missing
+-- required property rather than one this pass invents.
+-- Builds the value a required object property would have if the caller had
+-- sent it empty: only when every member the schema requires can be filled
+-- from a default, recursively. Returns nil when one of them cannot, because
+-- inventing a half-filled container would turn "you left this out" into a
+-- request the API rejects for a reason further away.
+local function from_defaults(schema)
+    if type(schema) ~= "table" or type(schema.properties) ~= "table" then
+        return nil
+    end
+
+    local out = {}
+    for _, name in ipairs(schema.required or {}) do
+        local property = schema.properties[name]
+        if type(property) ~= "table" then
+            return nil
+        end
+        if property.default ~= nil then
+            out[name] = clone(property.default)
+        else
+            local nested = from_defaults(property)
+            if nested == nil then
+                return nil
+            end
+            out[name] = nested
+        end
+    end
+
+    -- non-required members with a default are worth filling in too
+    for name, property in pairs(schema.properties) do
+        if out[name] == nil and type(property) == "table" and property.default 
~= nil then
+            out[name] = clone(property.default)
+        end
+    end
+
+    if next(out) == nil then
+        return nil
+    end
+    return out
+end
+
+
+-- An operation may declare a member required and give it a default in the same
+-- breath; the generated schema reproduces both, and validation would reject a
+-- call that left it out even though the document says what to send. Defaults
+-- are filled in before validation, into the objects the caller sent and into a
+-- required object the caller left out entirely -- the nested parameter
+-- containers are exactly that shape.
+local function apply_defaults(schema, value)
+    if type(schema) ~= "table" or type(value) ~= "table" then
+        return
+    end
+
+    local properties = schema.properties
+    if type(properties) ~= "table" then
+        return
+    end
+
+    local required = {}
+    for _, name in ipairs(schema.required or {}) do
+        required[name] = true
+    end
+
+    for name, property in pairs(properties) do
+        if type(property) == "table" then
+            if value[name] == nil then
+                if property.default ~= nil then
+                    value[name] = clone(property.default)
+                elseif required[name] then
+                    value[name] = from_defaults(property)
+                end
+            else
+                apply_defaults(property, value[name])
+            end
+        end
+    end
+end
+
+
 local function handle_tools_call(request, opts, tools)
     local params = request.params or {}
     local name = params.name
@@ -76,6 +176,7 @@ local function handle_tools_call(request, opts, tools)
     end
 
     local arguments = type(params.arguments) == "table" and params.arguments 
or {}
+    apply_defaults(tool.input_schema, arguments)
 
     local ok, err = core.schema.check(tool.input_schema, arguments)
     if not ok then
diff --git a/apisix/plugins/openapi-to-mcp/session.lua 
b/apisix/plugins/openapi-to-mcp/session.lua
index 6c9ef5328e..7bb8a53b4a 100644
--- a/apisix/plugins/openapi-to-mcp/session.lua
+++ b/apisix/plugins/openapi-to-mcp/session.lua
@@ -39,14 +39,29 @@ local function store()
 end
 
 
-function _M.create()
+-- `context` is what the stream resolved when it was opened -- the base_url and
+-- the headers, after `${...}` substitution. It is stored with the session
+-- because a message POST carries none of the request state those variables 
were
+-- read from: the endpoint the client is handed is "<path>?sessionId=<uuid>" 
and
+-- nothing else. Resolving again there would silently produce empty values.
+function _M.create(context)
     local dict, err = store()
     if not dict then
         return nil, err
     end
 
+    local marker = true
+    if type(context) == "table" then
+        local encoded, encode_err = core.json.encode(context)
+        if encoded then
+            marker = encoded
+        else
+            core.log.warn("failed to store MCP session context: ", encode_err)
+        end
+    end
+
     local session_id = core.id.gen_uuid_v4()
-    local ok, set_err = dict:set(session_id .. ALIVE_SUFFIX, true, SESSION_TTL)
+    local ok, set_err = dict:set(session_id .. ALIVE_SUFFIX, marker, 
SESSION_TTL)
     if not ok then
         return nil, "failed to register session: " .. tostring(set_err)
     end
@@ -54,6 +69,27 @@ function _M.create()
 end
 
 
+-- The values frozen by _M.create(), or nil for a session that stored none.
+function _M.context(session_id)
+    if type(session_id) ~= "string" or session_id == "" then
+        return nil
+    end
+    local dict = store()
+    if not dict then
+        return nil
+    end
+    local marker = dict:get(session_id .. ALIVE_SUFFIX)
+    if type(marker) ~= "string" then
+        return nil
+    end
+    local context = core.json.decode(marker)
+    if type(context) ~= "table" then
+        return nil
+    end
+    return context
+end
+
+
 function _M.exists(session_id)
     if type(session_id) ~= "string" or session_id == "" then
         return false
@@ -74,7 +110,13 @@ function _M.touch(session_id)
     if not dict then
         return false, err
     end
-    local ok, set_err = dict:set(session_id .. ALIVE_SUFFIX, true, SESSION_TTL)
+    -- Re-set the value that is already there: it carries the frozen context,
+    -- and writing `true` back would drop it halfway through the session.
+    local marker = dict:get(session_id .. ALIVE_SUFFIX)
+    if marker == nil then
+        marker = true
+    end
+    local ok, set_err = dict:set(session_id .. ALIVE_SUFFIX, marker, 
SESSION_TTL)
     if not ok then
         return false, "failed to refresh session: " .. tostring(set_err)
     end
diff --git a/apisix/plugins/openapi-to-mcp/transport/sse.lua 
b/apisix/plugins/openapi-to-mcp/transport/sse.lua
index 616fa1d703..cbe8b40dec 100644
--- a/apisix/plugins/openapi-to-mcp/transport/sse.lua
+++ b/apisix/plugins/openapi-to-mcp/transport/sse.lua
@@ -20,6 +20,7 @@ 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 re_find        = ngx.re.find
 local str_find       = string.find
 local ngx_print      = ngx.print
 local ngx_flush      = ngx.flush
@@ -27,6 +28,7 @@ local ngx_exit       = ngx.exit
 local ngx_sleep      = ngx.sleep
 local ngx_now        = ngx.now
 local worker_exiting = ngx.worker.exiting
+local pairs          = pairs
 local type           = type
 local tostring       = tostring
 
@@ -63,6 +65,32 @@ 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.
+-- A variable anywhere in base_url or a header value means the values depend on
+-- the request they were resolved from. The pattern is the one
+-- core.utils.resolve_var substitutes with, braces included or left out --
+-- "$http_x_token" resolves exactly like "${http_x_token}", and a backslash
+-- escapes the dollar.
+local VARIABLE_PATTERN = [[(?<!\\)\$(\{\s*[^}]+?\s*\}|[\w\.]+)]]
+
+
+local function has_variable(value)
+    return type(value) == "string" and re_find(value, VARIABLE_PATTERN, "jo") 
~= nil
+end
+
+
+local function uses_variables(conf)
+    if has_variable(conf.base_url) then
+        return true
+    end
+    for _, value in pairs(conf.headers or {}) do
+        if has_variable(value) then
+            return true
+        end
+    end
+    return false
+end
+
+
 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
@@ -79,7 +107,18 @@ local function handle_get(ctx, opts)
         })
     end
 
-    local session_id, err = session.create()
+    -- Freeze what the stream resolved from this request, so every message POST
+    -- on this session reaches the upstream with the same base_url and headers.
+    -- Only when the configuration holds a variable: otherwise every POST
+    -- resolves to the same values anyway, and the record would keep a copy of
+    -- whatever those headers carry -- a caller's token among them -- in the
+    -- shared dict for as long as the session lives.
+    local context
+    if uses_variables(opts.conf) then
+        context = { base_url = opts.base_url, headers = opts.headers }
+    end
+
+    local session_id, err = session.create(context)
     if not session_id then
         core.log.error("failed to create MCP session: ", err)
         return core.response.exit(500)
@@ -172,6 +211,27 @@ local function content_type_error(content_type)
 end
 
 
+-- Answer a message with what the stream resolved, not with what this POST
+-- resolves: the message endpoint carries only the session id, so `${...}` in
+-- base_url or in a header would read empty here. A session created before this
+-- was stored keeps the old behaviour rather than failing.
+local function frozen_opts(opts, session_id)
+    local context = session.context(session_id)
+    if not context then
+        return opts
+    end
+
+    local merged = core.table.clone(opts)
+    if context.base_url ~= nil then
+        merged.base_url = context.base_url
+    end
+    if context.headers ~= nil then
+        merged.headers = context.headers
+    end
+    return merged
+end
+
+
 local function handle_post(ctx, opts)
     -- An empty or unparsable JSON body is a 400 even for a session nobody
     -- issued: it is rejected before the session is looked up.
@@ -211,7 +271,7 @@ local function handle_post(ctx, opts)
         return core.response.exit(400, jsonrpc.invalid_message())
     end
 
-    local response = server.handle(request, opts)
+    local response = server.handle(request, frozen_opts(opts, session_id))
     if response then
         local encoded, encode_err = core.json.encode(response)
         if not encoded then
diff --git a/docs/en/latest/plugins/openapi-to-mcp.md 
b/docs/en/latest/plugins/openapi-to-mcp.md
index 33d8d09d72..786eedc73a 100644
--- a/docs/en/latest/plugins/openapi-to-mcp.md
+++ b/docs/en/latest/plugins/openapi-to-mcp.md
@@ -47,19 +47,21 @@ The Plugin supports:
 | Name               | Type    | Required | Default | Valid values             
   | Description |
 
|--------------------|---------|----------|---------|-----------------------------|-------------|
 | transport          | string  | False    | `sse`   | [`sse`, 
`streamable_http`]  | MCP transport served on the Route. |
-| openapi_url        | string  | True     |         |                          
   | URL of the OpenAPI document. The document is fetched on the first request 
and the generated tools are cached for an hour. |
+| openapi_url        | string  | True     |         |                          
   | URL of the OpenAPI document. The document is fetched on the first request 
and the generated tools are cached for an hour. The response must be an OpenAPI 
or Swagger document with a `paths` object; anything else is reported as an 
error on every MCP request. |
 | base_url           | string  | True     |         |                          
   | Base URL of the API the tools call. The path of each operation is appended 
to it. Supports [APISIX variables](../apisix-variable.md) and [NGINX 
variables](http://nginx.org/en/docs/varindex.html), for example 
`http://${http_x_backend}`. |
 | headers            | object  | False    |         |                          
   | Headers added to every request sent to the API. Values support variables, 
for example `"Authorization": "Bearer ${http_x_api_token}"`. |
 | flatten_parameters | boolean | False    | `false` |                          
   | When `false`, the tool input nests parameters under `pathParameters`, 
`queryParameters` and `headerParameters`. When `true`, they are placed directly 
in the input object. |
 
-Tool call arguments are validated against the generated input schema before 
the API is called. A call to an unknown tool, or with invalid arguments, 
returns a result with `isError` set to `true`.
+Tool call arguments are validated against the generated input schema before 
the API is called. A call to an unknown tool, or with invalid arguments, 
returns a result with `isError` set to `true`. Every `default` declared in the 
document is filled in before that validation, so a parameter or a body property 
that is `required` and has a `default` may be omitted by the client; an 
argument the client does send is never replaced by the default.
 
 When a tool is called, the Plugin builds the request from the operation:
 
 * Parameters declared on the Path Item apply to every operation under it; an 
operation parameter with the same name and location overrides them.
-* Query parameters are serialized according to their `style` and `explode`, as 
defined by the [OpenAPI Parameter 
Object](https://spec.openapis.org/oas/v3.0.3#style-values). With the defaults 
(`form`, exploded), `tags: ["a", "b"]` is sent as `tags=a&tags=b`. 
`spaceDelimited`, `pipeDelimited` and `deepObject` are supported.
+* Query parameters are serialized according to their `style` and `explode`, as 
defined by the [OpenAPI Parameter 
Object](https://spec.openapis.org/oas/v3.0.3#style-values). With the defaults 
(`form`, exploded), `tags: ["a", "b"]` is sent as `tags=a&tags=b` -- not as 
`tags[]=a&tags[]=b`, and an array parameter declared `explode: false` is sent 
as `tags=a,b`. `spaceDelimited`, `pipeDelimited` and `deepObject` are 
supported. An API that expects the bracket form has to be reached through a P 
[...]
 * A request body is sent with the media type the operation declares, unless 
`headers` sets `Content-Type`.
 
+For the SSE transport, variables in `base_url` and in `headers` are resolved 
on the request that opens the stream, and the resolved values are used for 
every message of that session. This is what makes a configuration such as 
`"Authorization": "Bearer ${http_x_api_token}"` usable over SSE: the message 
requests that follow carry only the session id, so there is nothing left to 
resolve from at that point. Those resolved values are kept with the session 
record in the `mcp-session` shared di [...]
+
 For the SSE transport, sessions are kept in the `mcp-session` shared dict, so 
the stream and the message requests of one session may be handled by different 
worker processes. Sessions are local to one APISIX instance: when several 
instances run behind a load balancer, the requests of an SSE session must reach 
the same instance. The Streamable HTTP transport is stateless and has no such 
requirement.
 
 ## Example usage
diff --git a/docs/zh/latest/plugins/openapi-to-mcp.md 
b/docs/zh/latest/plugins/openapi-to-mcp.md
index bbed2d9f06..ab21b1fec0 100644
--- a/docs/zh/latest/plugins/openapi-to-mcp.md
+++ b/docs/zh/latest/plugins/openapi-to-mcp.md
@@ -47,19 +47,21 @@ MCP 服务运行在 APISIX 内部,不需要额外的进程或服务。
 | 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 |
 |------|------|--------|--------|--------|------|
 | transport | string | 否 | `sse` | [`sse`, `streamable_http`] | 路由上提供的 MCP 
传输方式。 |
-| openapi_url | string | 是 | | | OpenAPI 文档的 URL。文档在首次请求时获取,生成的工具缓存一小时。 |
+| openapi_url | string | 是 | | | OpenAPI 文档的 
URL。文档在首次请求时获取,生成的工具缓存一小时。返回内容必须是带 `paths` 对象的 OpenAPI 或 Swagger 文档,否则每次 MCP 
请求都会返回错误。 |
 | base_url | string | 是 | | | 工具调用的 API 基础地址,每个操作的路径拼接在其后。支持 [APISIX 
变量](../apisix-variable.md) 和 [NGINX 
变量](http://nginx.org/en/docs/varindex.html),例如 `http://${http_x_backend}`。 |
 | headers | object | 否 | | | 发往 API 的每个请求都会携带的请求头。值支持变量,例如 `"Authorization": 
"Bearer ${http_x_api_token}"`。 |
 | flatten_parameters | boolean | 否 | `false` | | 为 `false` 时,工具输入中的参数分别嵌套在 
`pathParameters`、`queryParameters` 和 `headerParameters` 下;为 `true` 
时,参数直接放在输入对象的顶层。 |
 
-调用 API 之前,插件会按生成的输入 Schema 校验工具参数。调用不存在的工具或参数不合法时,返回 `isError` 为 `true` 的结果。
+调用 API 之前,插件会按生成的输入 Schema 校验工具参数。调用不存在的工具或参数不合法时,返回 `isError` 为 `true` 
的结果。校验之前会先填入文档中声明的 `default`,因此同时带有 `required` 和 `default` 
的参数或请求体属性可以由客户端省略;客户端显式传入的参数不会被默认值覆盖。
 
 调用工具时,插件根据操作定义构造请求:
 
 * 声明在 Path Item 上的参数适用于该路径下的所有操作;操作中同名且位置相同的参数会覆盖它。
-* 查询参数按其 `style` 和 `explode` 序列化,规则见 [OpenAPI Parameter 
Object](https://spec.openapis.org/oas/v3.0.3#style-values)。使用默认值(`form`,展开)时,`tags:
 ["a", "b"]` 发送为 `tags=a&tags=b`。同时支持 `spaceDelimited`、`pipeDelimited` 和 
`deepObject`。
+* 查询参数按其 `style` 和 `explode` 序列化,规则见 [OpenAPI Parameter 
Object](https://spec.openapis.org/oas/v3.0.3#style-values)。使用默认值(`form`,展开)时,`tags:
 ["a", "b"]` 发送为 `tags=a&tags=b`,而不是 `tags[]=a&tags[]=b`;声明为 `explode: false` 
的数组参数发送为 `tags=a,b`。同时支持 `spaceDelimited`、`pipeDelimited` 和 `deepObject`。如果 API 
要求方括号形式,需要另外通过改写查询字符串的插件处理。
 * 请求体使用操作中声明的媒体类型发送,除非 `headers` 中已设置 `Content-Type`。
 
+使用 SSE 传输时,`base_url` 和 `headers` 
中的变量在打开事件流的那次请求上解析,解析结果用于该会话的所有消息。`"Authorization": "Bearer 
${http_x_api_token}"` 这类配置因此在 SSE 下同样可用:后续的消息请求只携带会话 
ID,此时已无从解析变量。解析结果会随会话记录存放在共享字典 `mcp-session` 中直到会话结束,因此调用方以这种方式提供的凭据会在网关内存中最长保留 
30 分钟;配置中不含变量时则不存储。
+
 使用 SSE 传输时,会话保存在共享字典 `mcp-session` 中,因此同一会话的事件流请求和消息请求可以由不同的 worker 
进程处理。会话只在单个 APISIX 实例内有效:多个实例部署在负载均衡之后时,同一 SSE 会话的请求必须到达同一实例。Streamable HTTP 
传输是无状态的,没有这一限制。
 
 ## 使用示例
diff --git a/t/lib/openapi_to_mcp_fixture.lua b/t/lib/openapi_to_mcp_fixture.lua
index 7a96d7e93f..83210405a4 100644
--- a/t/lib/openapi_to_mcp_fixture.lua
+++ b/t/lib/openapi_to_mcp_fixture.lua
@@ -90,6 +90,47 @@ local DOCUMENTS = {
         } } },
     },
 
+    -- a required parameter and a required body property, both with a default
+    ["/defaults.json"] = {
+        openapi = "3.0.0",
+        info = { title = "Defaults", version = "1" },
+        paths = {
+            ["/items/{id}"] = { get = {
+                operationId = "getItem",
+                parameters = {
+                    { name = "id", ["in"] = "path", required = true,
+                      schema = { type = "string" } },
+                },
+            } },
+            ["/items"] = {
+                get = {
+                    operationId = "listItems",
+                    parameters = {
+                        { name = "status", ["in"] = "query", required = true,
+                          schema = { type = "string", default = "available" } 
},
+                        { name = "limit", ["in"] = "query",
+                          schema = { type = "integer", default = 10 } },
+                    },
+                },
+                post = {
+                    operationId = "createItem",
+                    requestBody = { required = true, content = { 
["application/json"] = {
+                        schema = {
+                            type = "object",
+                            required = { "mode" },
+                            properties = {
+                                mode = { type = "string", default = "fast" },
+                                n = { type = "integer", default = 3 },
+                            },
+                        } } } },
+                },
+            },
+        },
+    },
+
+    -- parses as JSON and is not an OpenAPI document
+    ["/notaspec.json"] = { this = "is not an openapi document" },
+
     -- query parameters that are an object and an array
     ["/objq.json"] = {
         openapi = "3.0.0",
diff --git a/t/plugin/openapi-to-mcp-e2e-sse.t 
b/t/plugin/openapi-to-mcp-e2e-sse.t
index ed1ac52a5a..00bd8d00ec 100644
--- a/t/plugin/openapi-to-mcp-e2e-sse.t
+++ b/t/plugin/openapi-to-mcp-e2e-sse.t
@@ -80,3 +80,109 @@ post status: 202
 protocolVersion: 2024-11-05
 serverInfo: openapi2mcp-sse 0.0.1
 unknown session status: 404
+
+
+
+=== TEST 3: an sse route whose header carries a request variable
+--- config
+    location /t {
+        content_by_lua_block {
+            local ok = require("lib.openapi_to_mcp_fixture").put_routes({
+                { 1, "/mcp", {
+                    transport = "sse",
+                    base_url = "http://127.0.0.1:11460";,
+                    openapi_url = "http://127.0.0.1:11460/openapi.json";,
+                    headers = { Authorization = "Bearer ${http_x_user}" },
+                } },
+            })
+            if ok then ngx.say("passed") end
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 4: the value resolved when the stream opened is the one used
+--- exec
+python3 t/plugin/openapi_to_mcp_sse_frozen_vars.py /mcp 2>&1
+--- response_body
+post status: 202
+upstream saw: Bearer alice
+
+
+
+=== TEST 5: the same route with the variable written without braces
+--- 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";,
+                    -- resolve_var takes this form too, so the stream has to
+                    -- freeze what it resolved here as well
+                    headers = { Authorization = "Bearer $http_x_user" },
+                } },
+            })
+            if ok then ngx.say("passed") end
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 6: a brace-less variable is frozen just the same
+--- exec
+python3 t/plugin/openapi_to_mcp_sse_frozen_vars.py /mcp 2>&1
+--- response_body
+post status: 202
+upstream saw: Bearer alice
+
+
+
+=== TEST 7: a route whose configuration holds no variable
+--- config
+    location /t {
+        content_by_lua_block {
+            local ok = require("lib.openapi_to_mcp_fixture").put_routes({
+                { 1, "/mcp", {
+                    transport = "sse",
+                    base_url = "http://127.0.0.1:11460";,
+                    headers = { Authorization = "fixed-credential" },
+                    openapi_url = "http://127.0.0.1:11460/openapi.json";,
+                } },
+            })
+            if ok then ngx.say("passed") end
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 8: its session record keeps no copy of the resolved headers
+--- config
+    location /t {
+        content_by_lua_block {
+            local session = require("apisix.plugins.openapi-to-mcp.session")
+            local dict = ngx.shared["mcp-session"]
+
+            -- what handle_get stores when nothing needs re-resolving
+            local plain = assert(session.create(nil))
+            ngx.say("plain context: ", tostring(session.context(plain)))
+
+            -- and what it stores when the configuration holds a variable
+            local frozen = assert(session.create({ headers = { Authorization = 
"Bearer t" } }))
+            ngx.say("frozen context: ", 
session.context(frozen).headers.Authorization)
+            ngx.say("in the dict: ",
+                    tostring(string.find(tostring(dict:get(plain .. ":alive")),
+                                         "Bearer", 1, true) ~= nil))
+        }
+    }
+--- response_body
+plain context: nil
+frozen context: Bearer t
+in the dict: false
diff --git a/t/plugin/openapi-to-mcp-openapi-loader.t 
b/t/plugin/openapi-to-mcp-openapi-loader.t
index 6569ee7757..1a3241d5fb 100644
--- a/t/plugin/openapi-to-mcp-openapi-loader.t
+++ b/t/plugin/openapi-to-mcp-openapi-loader.t
@@ -199,3 +199,148 @@ true
 --- response_body
 true
 1,2,3
+
+
+
+=== TEST 9: validate accepts an openapi document
+--- config
+    location /t {
+        content_by_lua_block {
+            local loader = 
require("apisix.plugins.openapi-to-mcp.openapi.loader")
+            local spec = 
loader.parse('{"openapi":"3.0.0","paths":{"/a":{"get":{}}}}')
+            local ok, err = loader.validate(spec)
+            ngx.say(ok, " ", err)
+        }
+    }
+--- response_body
+true nil
+
+
+
+=== TEST 10: validate accepts a swagger 2.0 document
+--- config
+    location /t {
+        content_by_lua_block {
+            local loader = 
require("apisix.plugins.openapi-to-mcp.openapi.loader")
+            local spec = 
loader.parse('{"swagger":"2.0","paths":{"/a":{"get":{}}}}')
+            local ok, err = loader.validate(spec)
+            ngx.say(ok, " ", err)
+        }
+    }
+--- response_body
+true nil
+
+
+
+=== TEST 11: validate rejects a document that carries no version
+--- config
+    location /t {
+        content_by_lua_block {
+            local loader = 
require("apisix.plugins.openapi-to-mcp.openapi.loader")
+            local spec = loader.parse('{"this":"is not an openapi document"}')
+            local ok, err = loader.validate(spec)
+            ngx.say(ok, " ", err)
+        }
+    }
+--- response_body
+nil not an openapi document: no openapi or swagger version
+
+
+
+=== TEST 12: validate rejects a document that declares no paths
+--- config
+    location /t {
+        content_by_lua_block {
+            local loader = 
require("apisix.plugins.openapi-to-mcp.openapi.loader")
+            local spec = 
loader.parse('{"openapi":"3.0.0","components":{"schemas":{}}}')
+            local ok, err = loader.validate(spec)
+            ngx.say(ok, " ", err)
+        }
+    }
+--- response_body
+nil openapi document declares no paths
+
+
+
+=== TEST 13: an external $ref document is not held to the document check
+--- config
+    location /t {
+        content_by_lua_block {
+            -- a fragment pulled in by $ref has neither openapi nor paths, and
+            -- parse() must keep accepting it
+            local loader = 
require("apisix.plugins.openapi-to-mcp.openapi.loader")
+            local spec, _, err = 
loader.parse('{"components":{"schemas":{"A":{"type":"string"}}}}')
+            ngx.say(err == nil)
+            ngx.say(spec.components.schemas.A.type)
+        }
+    }
+--- response_body
+true
+string
+
+
+
+=== TEST 14: a YAML document whose version is not quoted is still an openapi 
document
+--- config
+    location /t {
+        content_by_lua_block {
+            local loader = 
require("apisix.plugins.openapi-to-mcp.openapi.loader")
+            -- YAML reads an unquoted "2.0" or "3.1" as a number; only a 
version
+            -- with two dots, such as 3.0.0, comes back as a string
+            local cases = {
+                "swagger: 2.0\npaths:\n  /a:\n    get: {}\n",
+                "openapi: 3.1\npaths:\n  /a:\n    get: {}\n",
+                "openapi: 3.0.0\npaths:\n  /a:\n    get: {}\n",
+                'swagger: "2.0"\npaths:\n  /a:\n    get: {}\n',
+            }
+            for _, body in ipairs(cases) do
+                local spec = loader.parse(body)
+                local version = spec and (spec.openapi or spec.swagger)
+                local ok, err = loader.validate(spec)
+                ngx.say(type(version), " ", tostring(ok), " ", tostring(err))
+            end
+        }
+    }
+--- response_body
+number true nil
+number true nil
+string true nil
+string true nil
+
+
+
+=== TEST 15: a YAML document that is not an openapi document is still rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local loader = 
require("apisix.plugins.openapi-to-mcp.openapi.loader")
+            local spec = loader.parse("title: an index\nitems:\n  - one\n")
+            local ok, err = loader.validate(spec)
+            ngx.say(tostring(ok), " ", tostring(err))
+        }
+    }
+--- response_body
+nil not an openapi document: no openapi or swagger version
+
+
+
+=== TEST 16: a 3.1 document with webhooks and no paths is an openapi document
+--- config
+    location /t {
+        content_by_lua_block {
+            local loader = 
require("apisix.plugins.openapi-to-mcp.openapi.loader")
+            local cases = {
+                '{"openapi":"3.1.0","webhooks":{"newItem":{"post":{}}}}',
+                '{"openapi":"3.1.0","components":{"schemas":{}}}',
+                '{"this":"is not an openapi document"}',
+            }
+            for _, body in ipairs(cases) do
+                local ok, err = loader.validate(loader.parse(body))
+                ngx.say(tostring(ok), " ", tostring(err))
+            end
+        }
+    }
+--- response_body
+nil openapi document declares no paths
+nil openapi document declares no paths
+nil not an openapi document: no openapi or swagger version
diff --git a/t/plugin/openapi-to-mcp.t b/t/plugin/openapi-to-mcp.t
index 536aaf1534..6d98086f96 100644
--- a/t/plugin/openapi-to-mcp.t
+++ b/t/plugin/openapi-to-mcp.t
@@ -132,7 +132,7 @@ 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
+timeout 5 curl -X GET -N -sS http://localhost:1984/mcp 2>&1 | cat
 --- response_body_like
 event:\s*endpoint
 data:\s*/mcp\?sessionId=.*
@@ -141,7 +141,7 @@ 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
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp 2>&1 | cat
 --- response_body eval
 qr/Missing or invalid sessionId parameter/
 
@@ -170,7 +170,7 @@ passed
 === TEST 7: tools/list is answered in-process
 --- max_size: 2048000
 --- exec
-timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
     -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
@@ -203,7 +203,7 @@ 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 \
+timeout 5 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" \
@@ -236,7 +236,7 @@ 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 \
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
     -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
@@ -267,7 +267,7 @@ 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 \
+timeout 5 curl -X GET -N -sS http://localhost:1984/mcp \
     -H "variable_host: 127.0.0.1:11460" \
     2>&1 | cat
 --- response_body_like
@@ -322,7 +322,7 @@ 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
+timeout 5 curl -X GET -N -sS http://localhost:1984/mcp 2>&1 | cat
 --- response_body_like
 event:\s*endpoint
 data:\s*/mcp\?sessionId=.*
@@ -352,7 +352,7 @@ 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 \
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
     -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
@@ -385,7 +385,7 @@ passed
 === TEST 20: nested parameters are grouped under queryParameters
 --- max_size: 2048000
 --- exec
-timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
     -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
@@ -397,7 +397,7 @@ qr/queryParameters/
 
 === TEST 21: verify mcp tools call works
 --- exec
-timeout 1 curl -X POST -N -sS http://localhost:1984/mcp \
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
     -d '{
     "jsonrpc": "2.0",
     "method": "tools/call",
@@ -422,7 +422,7 @@ 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 \
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
     -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
@@ -435,7 +435,7 @@ 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 \
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
     -d '{
     "jsonrpc": "2.0",
     "method": "tools/call",
@@ -480,7 +480,7 @@ 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 \
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
     -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
@@ -493,7 +493,7 @@ 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 \
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
     -d '{
     "jsonrpc": "2.0",
     "method": "tools/call",
@@ -538,7 +538,7 @@ 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 \
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
     -d '{
     "jsonrpc": "2.0",
     "method": "tools/call",
@@ -589,7 +589,7 @@ 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 \
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
     -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
@@ -622,9 +622,133 @@ 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
+timeout 5 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
+
+
+
+=== TEST 33: a route whose document has defaults on required members
+--- config
+    location /t {
+        content_by_lua_block {
+            local ok = require("lib.openapi_to_mcp_fixture").put_routes({
+                { 1, "/mcp", {
+                    transport = "streamable_http",
+                    base_url = "http://127.0.0.1:11460";,
+                    openapi_url = "http://127.0.0.1:11460/defaults.json";,
+                } },
+            })
+            if ok then ngx.say("passed") end
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 34: a required query parameter that has a default may be omitted
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+    -d 
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"listItems","arguments":{"queryParameters":{}}}}'
 \
+    -H "Content-Type: application/json" \
+    -H "Accept: application/json, text/event-stream" \
+    2>&1 | cat
+--- response_body eval
+qr/seen_path.*items\?limit=10&status=available/
+
+
+
+=== TEST 35: a required body property that has a default may be omitted
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+    -d 
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"createItem","arguments":{"requestBody":{}}}}'
 \
+    -H "Content-Type: application/json" \
+    -H "Accept: application/json, text/event-stream" \
+    2>&1 | cat
+--- response_body eval
+qr/seen_body.*mode.*fast/
+
+
+
+=== TEST 36: the whole container may be left out, not just its members
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+    -d 
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"listItems","arguments":{}}}'
 \
+    -H "Content-Type: application/json" \
+    -H "Accept: application/json, text/event-stream" \
+    2>&1 | cat
+--- response_body eval
+qr/seen_path.*items\?limit=10&status=available/
+
+
+
+=== TEST 37: a request body that is required and all-default may be left out 
too
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+    -d 
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"createItem","arguments":{}}}'
 \
+    -H "Content-Type: application/json" \
+    -H "Accept: application/json, text/event-stream" \
+    2>&1 | cat
+--- response_body eval
+qr/seen_body.*mode.*fast/
+
+
+
+=== TEST 38: a required member without a default is still an error
+--- 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":{}}}'
 \
+    -H "Content-Type: application/json" \
+    -H "Accept: application/json, text/event-stream" \
+    2>&1 | cat
+--- response_body eval
+qr/property .*pathParameters.* is required/
+
+
+
+=== TEST 39: an argument that is sent still wins over the default
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+    -d 
'{"method":"tools/call","jsonrpc":"2.0","id":1,"params":{"name":"listItems","arguments":{"queryParameters":{"status":"sold"}}}}'
 \
+    -H "Content-Type: application/json" \
+    -H "Accept: application/json, text/event-stream" \
+    2>&1 | cat
+--- response_body eval
+qr/seen_path.*items\?limit=10&status=sold/
+
+
+
+=== TEST 40: a route pointed at a document that is not an OpenAPI document
+--- config
+    location /t {
+        content_by_lua_block {
+            local ok = require("lib.openapi_to_mcp_fixture").put_routes({
+                { 1, "/mcp", {
+                    transport = "streamable_http",
+                    base_url = "http://127.0.0.1:11460";,
+                    openapi_url = "http://127.0.0.1:11460/notaspec.json";,
+                } },
+            })
+            if ok then ngx.say("passed") end
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 41: it is reported as an error instead of an empty tool list
+--- exec
+timeout 5 curl -X POST -N -sS http://localhost:1984/mcp \
+    -d '{"method":"tools/list","jsonrpc":"2.0","id":1}' \
+    -H "Content-Type: application/json" \
+    -H "Accept: application/json, text/event-stream" \
+    2>&1 | cat
+--- response_body eval
+qr/(?s)(?=.*"code":-32603)(?=.*not an openapi document: no openapi or swagger 
version)/
+--- error_log
+not an openapi document
diff --git a/t/plugin/openapi_to_mcp_sse_frozen_vars.py 
b/t/plugin/openapi_to_mcp_sse_frozen_vars.py
new file mode 100644
index 0000000000..541b67288a
--- /dev/null
+++ b/t/plugin/openapi_to_mcp_sse_frozen_vars.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""What a ${...} in base_url or in a header resolves to for an SSE session.
+
+The GET that opens the stream carries the variable's source -- here the
+X-User request header. The message POST that follows carries only the session
+id, so a value resolved at that point would be empty: the stream has to hand
+its own resolution to every message on the session.
+"""
+import json
+import sys
+
+import openapi_to_mcp_harness as h
+
+
+def main():
+    route = sys.argv[1]
+    stream = h.SseStream(h.GATEWAY, route, {"X-User": "alice"})
+    if not stream.open(timeout=4):
+        print("FAIL no endpoint event (%s)" % stream.error)
+        return
+
+    # no X-User on this request: the endpoint the client was handed carries
+    # nothing but the session id
+    status, _, _ = h.post_json(h.GATEWAY, stream.endpoint, {
+        "jsonrpc": "2.0", "id": 1, "method": "tools/call",
+        "params": {"name": "getPet", "arguments": {"pathParameters": {"petId": 
1}}},
+    })
+    print("post status:", status)
+
+    event = stream.wait_for("event: message", timeout=4)
+    if not event:
+        print("FAIL no message pushed back")
+        return
+
+    result = json.loads(event.split("data: ", 1)[1])["result"]
+    upstream = json.loads(result["content"][0]["text"])
+    print("upstream saw:", upstream["data"]["seen_auth"])
+    stream.close()
+
+
+if __name__ == "__main__":
+    h.run(main)

Reply via email to