Skip to content

feat(openapi-to-mcp): advertise outputSchema and restrict base_url with allowed_base_url_hosts - #13957

Draft
AlinsRan wants to merge 9 commits into
apache:masterfrom
AlinsRan:feat/openapi-to-mcp-output-schema
Draft

AlinsRan wants to merge 9 commits into
apache:masterfrom
AlinsRan:feat/openapi-to-mcp-output-schema

Conversation

@AlinsRan

@AlinsRan AlinsRan commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Two independent changes to openapi-to-mcp, both found by driving the plugin with the official MCP SDK client and comparing the result against what the OpenAPI document promises. They are unrelated and can be evaluated separately.

This branch was stacked on #13956. That PR and the YAML version fix are now on master, so the branch has been merged with master and carries only the two changes below (git diff master... shows just them).


1. outputSchema and structuredContent

An operation whose success response is described as a JSON object currently tells an MCP client nothing about the shape of what it will get back, and the answer always arrives as an opaque text block.

The generated tool now advertises that schema as outputSchema, and a call whose answer satisfies it returns the API body as structuredContent, with the same body as the text block.

Which response the schema comes from, most specific first: 200, 201, any other explicit 2xx, then 2XX. Only an application/json schema that is an object with properties qualifies: an array, a default response, a bare type: object and a composition the document leaves unmerged (allOf) advertise nothing, since a client could not bind to them.

The error path deserves attention in review. MCP requires a tool that declares an outputSchema to return structured content unless the result is an error, so an answer that cannot satisfy the schema (a status outside 2xx, a body that is not a JSON object, or one that does not validate) is returned as an error result carrying the usual {status, statusText, headers, data} envelope. Returning that envelope on its own leaves an SDK client with MCP error -32600: Tool ... has an output schema but did not return structured content. Tools that advertise no outputSchema are untouched.

The body is validated the way the client will validate it: the MCP SDKs do not assert format, so the check schema drops the format keyword. It is built once per tool when the tool list is generated, so core.schema.check compiles it once instead of on every call, and a property that happens to be named format is kept.

Tests:

  • t/plugin/openapi-to-mcp-tools-generator.t: response selection, the cases that qualify or do not, and the check schema.
  • t/plugin/openapi-to-mcp.t: only qualifying operations advertise a schema, the advertised schema is the declared one, a matching answer comes back as structured content, a 404 comes back as an error result.
  • t/plugin/openapi-to-mcp-interop.spec.ts: the official SDK client accepts the structured content and gets an error result for the 404. Returning the envelope without isError makes this case fail with the -32600 above.

2. allowed_base_url_hosts

base_url accepts variables, so a route can be written whose upstream host comes from the request. The security section of the docs warns against that, but nothing constrained what it resolved to, so such a route makes the gateway a usable relay to any host it can reach.

The new allowed_base_url_hosts attribute uses the same entry format as allowed_ref_hosts and the same matcher (openapi/ref.lua): a hostname or *.example.com wildcard, optionally followed by :port; without a port it matches any port. When it is set, the resolved base_url is checked in before_proxy, after the Origin check, so lower-priority access plugins still run first. A host outside the list, or a base_url that is not an http(s) URL, is answered with 400. Neither the URL nor the host appears in the response or in the log line, because a resolved base_url can carry values taken from the request. Leaving the attribute unset keeps today's behaviour.

Tests: a host outside the list, or on another port, rejected; a host:port entry served; a wildcard entry matching a sub-domain but not the bare domain; a non-http URL rejected without being logged; the schema rejecting an empty list.


Both changes are documented in docs/en/latest/plugins/openapi-to-mcp.md and the Chinese page.

…penAPI documents

Three fixes to the contract the plugin documents, found by driving it with the
official MCP SDK client.

Defaults declared in the document were applied after the arguments had been
validated, so a parameter or a body property that is both `required` and has a
`default` was rejected as missing and the call never reached the API. They are
now filled in before validation, and an argument the client sent is never
replaced.

With the SSE transport, `${...}` in `base_url` or in `headers` was resolved
again on every message POST. That request carries only the session id -- the
endpoint handed to the client is `<path>?sessionId=<uuid>` -- so a header such
as `Bearer ${http_x_api_token}` resolved to `Bearer ` and a variable base_url
failed to resolve at all. The values are now resolved once, on the request that
opens the stream, stored with the session and used for every message on it.

Any JSON or YAML document parsed, so a route pointed at the wrong URL -- an
error page, an index document, a spec that failed to render -- served an empty
tool list and looked healthy. The route's own document must now declare an
`openapi` or `swagger` version and a `paths` object; a document pulled in by an
external `$ref` is a fragment and is still accepted as before.

Adds tests for each, and documents the default handling, the SSE resolution
point and the query serialization the plugin performs.
…tent

When the document describes an operation's success response as a JSON object,
the generated tool now advertises that schema as `outputSchema`, and a call
whose answer satisfies it returns the API body as `structuredContent` with the
same body as the text block. The response is taken from `200`, then `201`, then
any other explicit `2xx`, then `2XX`; only an `application/json` object schema
with properties qualifies, so an array, a `default` response and an unmerged
composition advertise nothing.

An answer that cannot satisfy the schema -- a status outside `2xx`, a body that
is not a JSON object, or one that does not validate -- is returned as an error
result carrying the usual envelope. MCP requires a tool that declares an
`outputSchema` to produce structured content unless the result is an error, so
returning the envelope on its own would leave a client that enforces the schema
with a protocol error instead of a readable failure.

The body is validated the way the client will validate it: the MCP SDKs check
structured content with a validator that does not assert `format`, so asserting
it here would reject bodies the client would have accepted.
…y point

`base_url` accepts variables, so a route can be written whose upstream host
comes from the request -- `http://${http_x_backend}` is a documented
configuration. Nothing constrained what that resolved to, which makes the
gateway a usable relay to any host reachable from it.

`allowed_hosts` takes exact host names and `*.example.com` wildcards matching
one or more leading labels. When it is set, the host the `base_url` resolves to
is checked before the document is fetched and before the API is called, and a
`base_url` that is not an `http` or `https` URL is rejected as well; both
answer 400. Neither the URL nor the host appears in the rejection or in the log
line, because a resolved `base_url` can carry values taken from the request.

Leaving the attribute unset keeps the previous behaviour.
YAML reads an unquoted version as a number: `swagger: 2.0` and `openapi: 3.1`
both come back as one, and only a version with two dots such as `3.0.0` is a
string. The document check accepted a string alone, so it turned away the
form Swagger 2.0 documents are usually written in. JSON documents were never
affected, since JSON quotes the value.

The check now takes either, and the loader tests cover all four shapes.
…tent

When the document describes an operation's success response as a JSON object,
the generated tool now advertises that schema as `outputSchema`, and a call
whose answer satisfies it returns the API body as `structuredContent` with the
same body as the text block. The response is taken from `200`, then `201`, then
any other explicit `2xx`, then `2XX`; only an `application/json` object schema
with properties qualifies, so an array, a `default` response and an unmerged
composition advertise nothing.

An answer that cannot satisfy the schema -- a status outside `2xx`, a body that
is not a JSON object, or one that does not validate -- is returned as an error
result carrying the usual envelope. MCP requires a tool that declares an
`outputSchema` to produce structured content unless the result is an error, so
returning the envelope on its own would leave a client that enforces the schema
with a protocol error instead of a readable failure.

The body is validated the way the client will validate it: the MCP SDKs check
structured content with a validator that does not assert `format`, so asserting
it here would reject bodies the client would have accepted.
…se_url may point

`base_url` accepts variables, so a route can be written whose upstream host
comes from the request. Nothing constrained what that resolved to, which makes
the gateway a usable relay to any host reachable from it.

`allowed_base_url_hosts` takes entries in the same form as `allowed_ref_hosts`
and is matched with the same code: a hostname or `*.example.com` wildcard,
optionally followed by `:port`. When it is set, the resolved `base_url` is
checked in before_proxy, after the Origin check, and a request whose host is
outside the list, or whose `base_url` is not an http(s) URL, is answered with
400. Neither the URL nor the host appears in the rejection or in the log line.

Leaving the attribute unset keeps the previous behaviour.
The first commit (apache#13956) and the YAML version fix were merged to master
separately; this keeps only the output schema and base_url host work on top
of master. allowed_hosts is renamed to allowed_base_url_hosts and shares the
entry format and matcher of allowed_ref_hosts.
@AlinsRan AlinsRan changed the title feat(openapi-to-mcp): advertise outputSchema and restrict base_url with allowed_hosts feat(openapi-to-mcp): advertise outputSchema and restrict base_url with allowed_base_url_hosts Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant