-
Notifications
You must be signed in to change notification settings - Fork 3k
feat: add openapi-to-mcp plugin #13942
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
0a66c87
feat: add openapi-to-mcp plugin
AlinsRan 870c559
test: make the openapi-to-mcp CLI test executable
AlinsRan 0684655
ci: build the debian-dev image on Debian 12
AlinsRan 1d4a43e
chore(openapi-to-mcp): say where the SSE message body was decoded
AlinsRan a6b4674
fix(openapi-to-mcp): refresh expired tool lists and build requests as…
AlinsRan 8607b02
docs(openapi-to-mcp): add the Chinese documentation
AlinsRan fd596f1
refactor(openapi-to-mcp): bypass the nginx upstream instead of settin…
AlinsRan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| -- | ||
| -- Licensed to the Apache Software Foundation (ASF) under one or more | ||
| -- contributor license agreements. See the NOTICE file distributed with | ||
| -- this work for additional information regarding copyright ownership. | ||
| -- The ASF licenses this file to You under the Apache License, Version 2.0 | ||
| -- (the "License"); you may not use this file except in compliance with | ||
| -- the License. You may obtain a copy of the License at | ||
| -- | ||
| -- http://www.apache.org/licenses/LICENSE-2.0 | ||
| -- | ||
| -- Unless required by applicable law or agreed to in writing, software | ||
| -- distributed under the License is distributed on an "AS IS" BASIS, | ||
| -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| -- See the License for the specific language governing permissions and | ||
| -- limitations under the License. | ||
| -- | ||
| local core = require("apisix.core") | ||
| local streamable_http = require("apisix.plugins.openapi-to-mcp.transport.streamable_http") | ||
| local mcp_sse = require("apisix.plugins.openapi-to-mcp.transport.sse") | ||
| local ngx = ngx | ||
| local pairs = pairs | ||
|
|
||
| local schema = { | ||
| type = "object", | ||
| properties = { | ||
| transport = { | ||
| description = "The transport mechanisms for client-server communication", | ||
| type = "string", | ||
| default = "sse", | ||
| enum = {"sse", "streamable_http"}, | ||
| }, | ||
| openapi_url = { | ||
| description = "URL of the OpenAPI specification document", | ||
| type = "string", | ||
| minLength = 1, | ||
| }, | ||
| base_url = { | ||
| description = "Base URL of the external service", | ||
| type = "string", | ||
| minLength = 1, | ||
| }, | ||
| headers = { | ||
| description = "Headers to include in requests to the external service", | ||
| type = "object", | ||
| minProperties = 0, | ||
| patternProperties = { | ||
| ["^[^:]+$"] = { | ||
| oneOf = { | ||
| { type = "string" } | ||
| } | ||
| } | ||
| }, | ||
| }, | ||
| flatten_parameters = { | ||
| description = "Whether to flatten query and path parameters " .. | ||
| "in the tool inputSchema. When false (default), " .. | ||
| "parameters are nested under queryParameters or pathParameters. " .. | ||
| "When true, parameters are placed directly in properties.", | ||
| type = "boolean", | ||
| default = false, | ||
| }, | ||
| }, | ||
| required = { "openapi_url", "base_url" }, | ||
| } | ||
|
|
||
| local plugin_name = "openapi-to-mcp" | ||
|
|
||
| local _M = { | ||
| version = 0.1, | ||
| priority = 540, | ||
| name = plugin_name, | ||
| schema = schema, | ||
| } | ||
|
|
||
|
|
||
| function _M.check_schema(conf) | ||
| return core.schema.check(schema, conf) | ||
| end | ||
|
|
||
|
|
||
| -- Resolve base_url and headers against request variables. | ||
| local function resolve_conf(conf, ctx) | ||
| local base_url, err = core.utils.resolve_var(conf.base_url, ctx.var) | ||
| if err then | ||
| core.log.error("failed to resolve variable for base_url: ", | ||
| conf.base_url, ", error: ", err) | ||
| base_url = conf.base_url | ||
| end | ||
|
|
||
| local headers = {} | ||
| for key, value in pairs(conf.headers or {}) do | ||
| local resolved_value, herr = core.utils.resolve_var(value, ctx.var) | ||
| if herr then | ||
| core.log.error("failed to resolve variable for header, key: ", key, | ||
| ", error: ", herr) | ||
| resolved_value = value | ||
| end | ||
| headers[key] = resolved_value | ||
| end | ||
|
|
||
| return base_url, headers | ||
| end | ||
|
|
||
|
|
||
| function _M.access(conf, ctx) | ||
| if conf.transport == "streamable_http" then | ||
| local base_url, headers = resolve_conf(conf, ctx) | ||
|
|
||
| -- Defer the answer to before_proxy. Exiting here would skip every | ||
| -- plugin with a lower priority that still has to run in access, such | ||
| -- as an authorization check on the tool being called. | ||
| ctx.mcp_inprocess_opts = { | ||
| conf = conf, | ||
| base_url = base_url, | ||
| headers = headers, | ||
| transport = "streamable_http", | ||
| } | ||
| -- The answer is produced in before_proxy, so the request never reaches | ||
| -- an upstream; handle_upstream() runs before_proxy and returns. | ||
| ctx.bypass_nginx_upstream = true | ||
| return | ||
| end | ||
|
|
||
| if conf.transport ~= "sse" then | ||
| core.log.error("Invalid MCP transport: ", conf.transport) | ||
| return 500, { message = "Invalid MCP transport"} | ||
| end | ||
|
|
||
| local base_url, headers = resolve_conf(conf, ctx) | ||
|
|
||
| -- The client is told to POST its messages to the path the route matched. | ||
| local message_path = ctx.curr_req_matched and ctx.curr_req_matched._path | ||
|
|
||
| ngx.ctx.disable_proxy_buffering = true | ||
| ctx.mcp_inprocess_opts = { | ||
| conf = conf, | ||
| base_url = base_url, | ||
| headers = headers, | ||
| transport = "sse", | ||
| message_path = message_path, | ||
| } | ||
| ctx.bypass_nginx_upstream = true | ||
| end | ||
|
|
||
|
|
||
| function _M.before_proxy(conf, ctx) | ||
| local opts = ctx.mcp_inprocess_opts | ||
| if not opts then | ||
| return | ||
| end | ||
|
|
||
| -- Every body the transports produce is JSON, and core.response.exit() sets | ||
| -- no content type of its own, so without this they would all go out as | ||
| -- text/plain. Set once here so no rejection path can miss it; the two that | ||
| -- stream override it with text/event-stream on their way out. | ||
| core.response.set_header("Content-Type", "application/json") | ||
|
|
||
| if opts.transport == "sse" then | ||
| return mcp_sse.handle(ctx, opts) | ||
| end | ||
| return streamable_http.handle(ctx, opts) | ||
| end | ||
|
|
||
|
|
||
| return _M |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| -- | ||
| -- Licensed to the Apache Software Foundation (ASF) under one or more | ||
| -- contributor license agreements. See the NOTICE file distributed with | ||
| -- this work for additional information regarding copyright ownership. | ||
| -- The ASF licenses this file to You under the Apache License, Version 2.0 | ||
| -- (the "License"); you may not use this file except in compliance with | ||
| -- the License. You may obtain a copy of the License at | ||
| -- | ||
| -- http://www.apache.org/licenses/LICENSE-2.0 | ||
| -- | ||
| -- Unless required by applicable law or agreed to in writing, software | ||
| -- distributed under the License is distributed on an "AS IS" BASIS, | ||
| -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| -- See the License for the specific language governing permissions and | ||
| -- limitations under the License. | ||
| -- | ||
| local core = require("apisix.core") | ||
| local loader = require("apisix.plugins.openapi-to-mcp.openapi.loader") | ||
| local ref = require("apisix.plugins.openapi-to-mcp.openapi.ref") | ||
| local generator = require("apisix.plugins.openapi-to-mcp.tools.generator") | ||
| local tostring = tostring | ||
|
|
||
| local _M = {} | ||
|
|
||
| -- A generated tool list is kept for an hour, for up to 100 documents. | ||
| local SPEC_TTL = 3600 | ||
| local SPEC_COUNT = 100 | ||
|
|
||
| -- A failed fetch is cached only briefly. Without neg_ttl core.lrucache caches | ||
| -- nothing on failure, which would let an unreachable spec host be re-dialed on | ||
| -- every single request; a long negative TTL would instead keep the route broken | ||
| -- long after the host recovers. | ||
| local NEG_TTL = 5 | ||
| local NEG_COUNT = 32 | ||
|
|
||
| local CACHE_VERSION = "1" | ||
|
|
||
| -- invalid_stale: without it core.lrucache hands an expired entry back and | ||
| -- re-arms its TTL whenever the version still matches, and the version here | ||
| -- never changes, so a document updated at the same URL would never be fetched | ||
| -- again. | ||
| local lru = core.lrucache.new({ | ||
| ttl = SPEC_TTL, | ||
| count = SPEC_COUNT, | ||
| invalid_stale = true, | ||
| neg_ttl = NEG_TTL, | ||
| neg_count = NEG_COUNT, | ||
| }) | ||
|
|
||
|
|
||
| local function build_tools(openapi_url, flatten_parameters) | ||
| local spec, path_order, err = loader.fetch(openapi_url) | ||
| if not spec then | ||
| return nil, err | ||
| end | ||
|
|
||
| local resolved = ref.resolve(spec) | ||
| return generator.generate(resolved, path_order, { | ||
| flatten_parameters = flatten_parameters, | ||
| }) | ||
| end | ||
|
|
||
|
|
||
| -- Returns the tool list for a plugin conf, building it on first use. | ||
| -- base_url and headers do not take part in the key: they affect how a tool is | ||
| -- invoked, never how it is generated. | ||
| function _M.get_tools(conf) | ||
| local flatten_parameters = conf.flatten_parameters == true | ||
| local key = conf.openapi_url .. "#" .. tostring(flatten_parameters) | ||
| return lru(key, CACHE_VERSION, build_tools, conf.openapi_url, flatten_parameters) | ||
| end | ||
|
|
||
|
|
||
| return _M | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| -- | ||
| -- Licensed to the Apache Software Foundation (ASF) under one or more | ||
| -- contributor license agreements. See the NOTICE file distributed with | ||
| -- this work for additional information regarding copyright ownership. | ||
| -- The ASF licenses this file to You under the Apache License, Version 2.0 | ||
| -- (the "License"); you may not use this file except in compliance with | ||
| -- the License. You may obtain a copy of the License at | ||
| -- | ||
| -- http://www.apache.org/licenses/LICENSE-2.0 | ||
| -- | ||
| -- Unless required by applicable law or agreed to in writing, software | ||
| -- distributed under the License is distributed on an "AS IS" BASIS, | ||
| -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| -- See the License for the specific language governing permissions and | ||
| -- limitations under the License. | ||
| -- | ||
| local core = require("apisix.core") | ||
| local str_sub = string.sub | ||
| local str_rep = string.rep | ||
| local table_concat = table.concat | ||
|
|
||
| local _M = {} | ||
|
|
||
| local INDENT_UNIT = " " | ||
|
|
||
|
|
||
| -- cjson has no pretty printer, so re-flow its compact output instead of | ||
| -- re-implementing value encoding and string escaping. Matches | ||
| -- JSON.stringify(value, null, 2): two-space indent, ": " after keys, and | ||
| -- empty containers kept on one line. | ||
| -- | ||
| -- Object key order still comes from the Lua table, so the result is not | ||
| -- byte-identical to JSON.stringify for nested upstream payloads. That is a | ||
| -- known, semantically irrelevant difference. | ||
| function _M.encode(value) | ||
| local compact, err = core.json.encode(value) | ||
| if not compact then | ||
| return nil, err | ||
| end | ||
|
|
||
| local out = {} | ||
| local indent = 0 | ||
| local in_string = false | ||
| local escaped = false | ||
| local index = 1 | ||
| local length = #compact | ||
|
|
||
| while index <= length do | ||
| local char = str_sub(compact, index, index) | ||
|
|
||
| if in_string then | ||
| out[#out + 1] = char | ||
| if escaped then | ||
| escaped = false | ||
| elseif char == "\\" then | ||
| escaped = true | ||
| elseif char == '"' then | ||
| in_string = false | ||
| end | ||
|
|
||
| elseif char == '"' then | ||
| in_string = true | ||
| out[#out + 1] = char | ||
|
|
||
| elseif char == "{" or char == "[" then | ||
| local next_char = str_sub(compact, index + 1, index + 1) | ||
| if (char == "{" and next_char == "}") or (char == "[" and next_char == "]") then | ||
| out[#out + 1] = char .. next_char | ||
| index = index + 1 | ||
| else | ||
| indent = indent + 1 | ||
| out[#out + 1] = char .. "\n" .. str_rep(INDENT_UNIT, indent) | ||
| end | ||
|
|
||
| elseif char == "}" or char == "]" then | ||
| indent = indent - 1 | ||
| out[#out + 1] = "\n" .. str_rep(INDENT_UNIT, indent) .. char | ||
|
|
||
| elseif char == "," then | ||
| out[#out + 1] = ",\n" .. str_rep(INDENT_UNIT, indent) | ||
|
|
||
| elseif char == ":" then | ||
| out[#out + 1] = ": " | ||
|
|
||
| else | ||
| out[#out + 1] = char | ||
| end | ||
|
|
||
| index = index + 1 | ||
| end | ||
|
|
||
| return table_concat(out) | ||
| end | ||
|
|
||
|
|
||
| return _M |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-blocking: cache expiry behavior
With a constant
CACHE_VERSIONandinvalid_staleunset,core.lrucacherevives expired entries whose version still matches instead of invokingbuild_toolsagain. In a targeted probe using the existing cache wrapper with a mocked clock and loader, calls after 3,601 and 7,202 seconds still returned the original tools, with only one fetch. A document updated at the same URL can therefore stay stale beyond the advertised one-hour TTL, until eviction or worker restart.This does not block merging the PR. Please double-check the intended refresh policy and decide whether to fix this behavior, for example by invalidating expired entries and adding a same-URL refresh regression test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in a6b4674.
core.lrucachere-arms an expired entry whose version still matches unlessinvalid_staleis set, andCACHE_VERSIONnever changes, so a document updated at the same URL was never fetched again. The cache now setsinvalid_stale = true.openapi-to-mcp-cache.tTEST 6 loads the cache with a one-second TTL, serves a document that changes on every fetch, and checks the tool list is rebuilt once after expiry (it fails without the fix).