From 7469cd504172e8770a9efd8918454e396a2b7649 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 17 Sep 2026 16:12:45 +0800 Subject: [PATCH] feat(mcp-tools-acl): restrict the MCP tools a consumer may see and call openapi-to-mcp turns every operation of an OpenAPI document into a tool, and a route that serves it hands the whole set to whoever connects. That is rarely what a given client should get: a read-only agent has no business calling deletePet, and it should not even be told the tool exists. mcp-tools-acl runs at priority 539, below openapi-to-mcp (540) and below the auth plugins, so the consumer is known by the time a rule is matched. It refuses a tools/call for a tool the matched rule does not allow, and removes the same tools from the tools/list answer -- over both transports, which means filtering the JSON body of a streamable_http answer and the SSE events of an sse one, buffering an incomplete event across body_filter chunks. Rules are picked per request with lua-resty-expr, so one route can serve a different set of tools to each consumer. A rule without an expression always matches; when no rule matches, nothing is enforced. openapi-to-mcp gains one line, `ctx.openapi_to_mcp_active = true`. The ACL plugin has no other way to tell an MCP conversation from an ordinary route -- the request body alone looks the same -- and on a route without openapi-to-mcp it now stands aside with a warning instead of trying to police plain JSON. The plugin is the one API7 Enterprise has been shipping; this is that code, with its tests rewritten against the in-process openapi-to-mcp implementation this repository has rather than the sidecar the enterprise build used. --- apisix/cli/config.lua | 1 + apisix/plugins/mcp-tools-acl.lua | 408 ++++++++++++++++++++ apisix/plugins/openapi-to-mcp.lua | 7 + conf/config.yaml.example | 1 + docs/en/latest/config.json | 3 +- docs/en/latest/plugins/mcp-tools-acl.md | 179 +++++++++ docs/zh/latest/config.json | 3 +- docs/zh/latest/plugins/mcp-tools-acl.md | 179 +++++++++ t/admin/plugins.t | 1 + t/plugin/mcp-tools-acl.t | 470 ++++++++++++++++++++++++ 10 files changed, 1250 insertions(+), 2 deletions(-) create mode 100644 apisix/plugins/mcp-tools-acl.lua create mode 100644 docs/en/latest/plugins/mcp-tools-acl.md create mode 100644 docs/zh/latest/plugins/mcp-tools-acl.md create mode 100644 t/plugin/mcp-tools-acl.t diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua index 2063eb8d717f..ed027e2522a3 100644 --- a/apisix/cli/config.lua +++ b/apisix/cli/config.lua @@ -271,6 +271,7 @@ local _M = { "redirect", "response-rewrite", "openapi-to-mcp", + "mcp-tools-acl", "oas-validator", "mcp-bridge", "degraphql", diff --git a/apisix/plugins/mcp-tools-acl.lua b/apisix/plugins/mcp-tools-acl.lua new file mode 100644 index 000000000000..18caf05dbb16 --- /dev/null +++ b/apisix/plugins/mcp-tools-acl.lua @@ -0,0 +1,408 @@ +-- +-- 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 sse = require("apisix.plugins.ai-transport.sse") +local expr = require("resty.expr.v1") +local ngx = ngx +local ngx_arg = ngx.arg +local ipairs = ipairs +local str_find = string.find +local str_byte = string.byte +local type = type +local setmetatable = setmetatable +local table = table + +local lrucache = core.lrucache.new({ + type = "plugin", +}) + +local rules_schema = { + type = "array", + items = { + type = "object", + properties = { + allow_tools = { + type = "array", + items = { type = "string", minLength = 1 }, + description = "Allowed MCP tool names (exact match, case-sensitive). " .. + "Empty array denies all tools.", + }, + deny_tools = { + type = "array", + items = { type = "string", minLength = 1 }, + description = "Denied MCP tool names (exact match, case-sensitive).", + }, + rejected_code = { + type = "integer", + minimum = 200, + maximum = 599, + default = 403, + description = "HTTP status code returned when a tool is denied (default 403).", + }, + rejected_msg = { + type = "string", + minLength = 1, + description = "Message included in the response body when a tool is denied." + }, + expr = { + type = "array", + description = "an array of variable expressions; " .. + "MCP tools are filtered only when all expressions evaluate to true", + }, + }, + oneOf = { + {required = {"allow_tools"}}, + {required = {"deny_tools"}}, + }, + }, + minItems = 1, +} + +local schema = { + type = "object", + properties = { + rules = rules_schema, + max_resp_body_size = { + type = "integer", + minimum = 1, + default = 67108864, + description = "maximum response body size in bytes buffered into " + .. "memory for tool filtering; larger responses are " + .. "truncated", + }, + }, + required = {"rules"}, +} + +local _M = { + version = 0.1, + priority = 539, + name = "mcp-tools-acl", + schema = schema, +} + + +function _M.check_schema(conf) + local ok, err = core.schema.check(schema, conf) + if not ok then + return false, err + end + + for _, item in ipairs(conf.rules) do + if item.expr then + local vars_ok, vars_err = expr.new(item.expr) + if not vars_ok then + return false, "failed to validate the 'expr' expression: " .. vars_err + end + end + end + + return true +end + + +local function is_tool_in_list(tools, tool_name) + for _, name in ipairs(tools) do + if name == tool_name then + return true + end + end + return false +end + + +local function reject(conf) + local code = conf.rejected_code or 403 + local msg = conf.rejected_msg or "MCP tool is not allowed" + return code, { message = msg } +end + + +-- Parse the request body as JSON and enforce the given conf's allow/deny rules. +-- Sets ctx.mcp_tools_acl_filter for tools/list, +-- and returns a rejection response for disallowed tools/call requests. +-- Returns nil on pass-through, or (code, body) to short-circuit. +-- data is the already-decoded JSON-RPC request table. +local function enforce(conf, ctx, data) + local method = data.method + if not method then + return + end + + if method == "tools/list" then + -- Mark this request for response filtering in body_filter. + ctx.mcp_tools_acl_filter = true + + elseif method == "tools/call" then + local params = data.params + if type(params) ~= "table" or type(params.name) ~= "string" then + return 400, { message = "Invalid MCP tools/call request" } + end + + if conf.deny_tools and is_tool_in_list(conf.deny_tools, params.name) then + return reject(conf) + end + + if conf.allow_tools and not is_tool_in_list(conf.allow_tools, params.name) then + return reject(conf) + end + + -- else: other MCP methods (initialize, ping, etc.) — pass through unchanged. + end +end + + +-- Compiles the expr field of each conf item into a resty.expr object. +-- Items without an expr field are stored as false (unconditional match). +-- Returns the compiled exprs array, or nil + err on failure. +local function create_exprs(conf) + local exprs = {} + for index, item in ipairs(conf) do + if item.expr then + local e, err = expr.new(item.expr) + if not e then + return nil, "failed to create expression for rule #" .. index .. ": " + .. (err or "unknown error") + end + core.table.insert(exprs, e) + else + core.table.insert(exprs, false) + end + end + return exprs +end + + +local function match(exprs, conf, ctx) + for index, item in ipairs(conf) do + local e = exprs[index] + if not e then + -- no expr on this item: always matches + return item + end + local matched, err = e:eval(ctx.var) + if err then + core.log.error("mcp-tools-acl: failed to evaluate expr for rule #", index, + ": ", err) + return nil, err + end + if matched then + return item + end + end + return nil +end + + +function _M.access(conf, ctx) + -- openapi-to-mcp (priority 540) sets ctx.openapi_to_mcp_active = true + -- during its access() phase as a coordination signal. + if not ctx.openapi_to_mcp_active then + core.log.warn("mcp-tools-acl: openapi-to-mcp plugin is not active on this route, " .. + "skipping enforcement") + return + end + + if not ctx.consumer then + return + end + + local exprs, err = core.lrucache.plugin_ctx(lrucache, ctx, nil, create_exprs, conf.rules) + if not exprs then + core.log.error("failed to create expressions: ", err) + return 500 + end + + local matched, match_err = match(exprs, conf.rules, ctx) + if match_err then + -- Expression evaluation failed; fail closed to avoid bypassing ACL. + return 500 + end + if not matched then + -- No rule matched (all items have expr and none evaluated true); skip ACL. + return + end + + -- Store the matched rule item so body_filter can use it for response filtering. + ctx.mcp_tools_acl_conf = matched + + -- SSE transport GET /sse: the long-lived connection that carries tool list responses. + -- key-auth (or any other auth plugin) runs before this plugin at its higher priority, + -- so ctx.consumer and conf are already correctly merged from the consumer's config. + -- We simply mark the context to enable SSE stream filtering in body_filter. + -- There is no request body to inspect on a GET; tools/call enforcement is handled + -- on the companion POST requests. + if core.request.get_method() == "GET" then + ctx.mcp_tools_acl_filter = true + return + end + + -- get_json_request_body_table() hits ctx._request_body_table when + -- openapi-to-mcp (priority 540) already decoded the body in its access phase. + local data, err = core.request.get_json_request_body_table() + if err then + core.log.warn("mcp-tools-acl: failed to get request body: ", err.message) + return + end + if type(data) ~= "table" then + return + end + + return enforce(matched, ctx, data) +end + + +function _M.header_filter(conf, ctx) + if not ctx.mcp_tools_acl_filter then + return + end + -- Nil out Content-Length / Content-Encoding so nginx does not send + -- the original length after we rewrite the body in body_filter. + core.response.clear_header_as_body_modified() +end + + +local function filter_tools_in_json(body, allow_tools, deny_tools) + local data, err = core.json.decode(body) + if not data or type(data) ~= "table" then + core.log.info("mcp-tools-acl: failed to decode response body: ", err) + return body + end + + local result = data.result + if not result or type(result.tools) ~= "table" then + return body + end + + local filtered = setmetatable({}, core.json.array_mt) + for _, tool in ipairs(result.tools) do + if type(tool) == "table" + and type(tool.name) == "string" + then + if not (deny_tools and is_tool_in_list(deny_tools, tool.name)) + and (not allow_tools or is_tool_in_list(allow_tools, tool.name)) then + filtered[#filtered + 1] = tool + end + end + end + result.tools = filtered + + local new_body, encode_err = core.json.encode(data) + if not new_body then + core.log.warn("mcp-tools-acl: failed to encode filtered body: ", encode_err) + return body + end + + return new_body +end + + +local _SSE_BUF_LIMIT = 1024 * 1024 -- 1 MB guard against oversized incomplete events +local LBRACE = string.byte("{") + +local function decode_and_filter(raw, allow_tools, deny_tools) + local events = sse.decode(raw) + if #events == 0 then + return nil + end + + local out = {} + for _, event in ipairs(events) do + local data = event.data + if data and data ~= "" and str_byte(data, 1) == LBRACE then + event.data = filter_tools_in_json(data, allow_tools, deny_tools) + end + out[#out + 1] = sse.encode(event) + end + return table.concat(out) +end + + +local function filter_sse_events(chunk, allow_tools, deny_tools, ctx) + if ctx._mcp_sse_passthrough then + return chunk + end + + local prev_buf = ctx._mcp_sse_buf or "" + local buf = prev_buf .. chunk + + local complete, remainder = sse.split_buf(buf) + + if #remainder > _SSE_BUF_LIMIT then + core.log.warn("mcp-tools-acl: SSE remainder exceeded 1 MB limit, ", + "passing through unfiltered") + ctx._mcp_sse_buf = nil + ctx._mcp_sse_passthrough = true + return buf + end + + ctx._mcp_sse_buf = remainder ~= "" and remainder or nil + + if complete == "" then + return "" + end + + return decode_and_filter(complete, allow_tools, deny_tools) or "" +end + + +function _M.body_filter(conf, ctx) + if not ctx.mcp_tools_acl_filter then + return + end + + local acl_conf = ctx.mcp_tools_acl_conf + local allow_tools = acl_conf and acl_conf.allow_tools + local deny_tools = acl_conf and acl_conf.deny_tools + + local content_type = ngx.header["Content-Type"] or "" + + if str_find(content_type, "text/event-stream", 1, true) then + local chunk = ngx_arg[1] + local eof = ngx_arg[2] + + if chunk and chunk ~= "" then + ngx_arg[1] = filter_sse_events(chunk, allow_tools, deny_tools, ctx) + end + + if eof and ctx._mcp_sse_buf and ctx._mcp_sse_buf ~= "" then + local remainder = ctx._mcp_sse_buf + ctx._mcp_sse_buf = nil + local filtered = decode_and_filter(remainder, allow_tools, deny_tools) + ngx_arg[1] = (ngx_arg[1] or "") .. (filtered or remainder) + end + + return + end + + if str_find(content_type, "application/json", 1, true) then + -- Single-body response: buffer the whole body, then filter. + local body = core.response.hold_body_chunk(ctx, false, conf.max_resp_body_size) + if not body then + return + end + ngx_arg[1] = filter_tools_in_json(body, allow_tools, deny_tools) + ngx_arg[2] = true + return + end + + -- Unknown content type: pass through unchanged. +end + + +return _M diff --git a/apisix/plugins/openapi-to-mcp.lua b/apisix/plugins/openapi-to-mcp.lua index c0fc454408de..1fbe91e50e27 100644 --- a/apisix/plugins/openapi-to-mcp.lua +++ b/apisix/plugins/openapi-to-mcp.lua @@ -103,6 +103,13 @@ end function _M.access(conf, ctx) + -- The coordination signal for the MCP plugins that run after this one. + -- A plugin such as mcp-tools-acl (priority 539) only has a JSON-RPC + -- conversation to police when this plugin is on the route, and it has no + -- other way to tell: the request body alone looks the same on a route that + -- proxies plain JSON. + ctx.openapi_to_mcp_active = true + if conf.transport == "streamable_http" then local base_url, headers = resolve_conf(conf, ctx) diff --git a/conf/config.yaml.example b/conf/config.yaml.example index 86510feb5ca1..c248ee552a61 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -618,6 +618,7 @@ plugins: # plugin list (sorted by priority) - redirect # priority: 900 - response-rewrite # priority: 899 - openapi-to-mcp # priority: 540 + - mcp-tools-acl # priority: 539 - oas-validator # priority: 512 - mcp-bridge # priority: 510 - degraphql # priority: 509 diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json index c2a816aecfe2..20056d13081a 100644 --- a/docs/en/latest/config.json +++ b/docs/en/latest/config.json @@ -256,7 +256,8 @@ "plugins/kafka-proxy", "plugins/http-dubbo", "plugins/mcp-bridge", - "plugins/openapi-to-mcp" + "plugins/openapi-to-mcp", + "plugins/mcp-tools-acl" ] } ] diff --git a/docs/en/latest/plugins/mcp-tools-acl.md b/docs/en/latest/plugins/mcp-tools-acl.md new file mode 100644 index 000000000000..41c06fee99d8 --- /dev/null +++ b/docs/en/latest/plugins/mcp-tools-acl.md @@ -0,0 +1,179 @@ +--- +title: mcp-tools-acl +keywords: + - Apache APISIX + - API Gateway + - Plugin + - MCP + - ACL + - mcp-tools-acl +description: This document contains information about the Apache APISIX mcp-tools-acl Plugin, which restricts the MCP tools a client may see and call on a Route served by openapi-to-mcp. +--- + + + +## Description + +The `mcp-tools-acl` Plugin restricts which MCP tools a client may use on a Route that [`openapi-to-mcp`](./openapi-to-mcp.md) serves. + +`openapi-to-mcp` turns every operation of an OpenAPI document into a tool, which is rarely what a given client should be given. This Plugin narrows that set per Consumer: + +* A `tools/call` for a tool the matched rule does not allow is refused, and the API is never called. +* The same tools are removed from the `tools/list` answer, so a client is not told they exist. + +The Plugin runs at priority 539, just below `openapi-to-mcp` (540) and below the authentication Plugins, so the Consumer is already known when a rule is matched. + +## Attributes + +| Name | Type | Required | Default | Valid values | Description | +|----------------------|---------|----------|------------|--------------|-------------| +| rules | array | True | | | List of rules. The first rule whose `expr` matches is applied; a rule without `expr` always matches. | +| rules[].allow_tools | array | False | | | Tool names the rule allows, matched exactly and case-sensitively. When set, every other tool is denied. An empty array denies all tools. | +| rules[].deny_tools | array | False | | | Tool names the rule denies, matched exactly and case-sensitively. | +| rules[].rejected_code| integer | False | `403` | [200, 599] | HTTP status code returned when a tool is denied. | +| rules[].rejected_msg | string | False | | | Message returned in the response body when a tool is denied. | +| rules[].expr | array | False | | | [lua-resty-expr](https://github.com/api7/lua-resty-expr) expression selecting the requests the rule applies to. | +| max_resp_body_size | integer | False | `67108864` | >= 1 | Largest response body, in bytes, buffered in memory to filter `tools/list`. A larger body is truncated. | + +Each rule must set `allow_tools` or `deny_tools`. When both are set, `deny_tools` is checked first. + +The Plugin only acts when `openapi-to-mcp` is configured on the same Route and a Consumer has been identified. On any other Route it logs a warning and stands aside, so adding it to a Route that does not serve MCP changes nothing. + +## Example usage + +The examples below use a Route with the ID `mcp`. An [admin key](../admin-api.md) is required for the Admin API calls: + +```shell +admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"//g') +``` + +### Deny a tool to every Consumer + +Create a Consumer and give it a key: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/consumers" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "username": "reader", + "plugins": { + "key-auth": { + "key": "reader-key" + } + } + }' +``` + +Create a Route that serves the Swagger Petstore API as MCP tools, with `deletePet` denied: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "mcp", + "uri": "/mcp", + "plugins": { + "key-auth": {}, + "openapi-to-mcp": { + "transport": "streamable_http", + "openapi_url": "https://petstore3.swagger.io/api/v3/openapi.json", + "base_url": "https://petstore3.swagger.io/api/v3" + }, + "mcp-tools-acl": { + "rules": [ + { + "deny_tools": ["deletePet"], + "rejected_code": 403, + "rejected_msg": "deletePet is not allowed" + } + ] + } + } + }' +``` + +Call the denied tool: + +```shell +curl -i "http://127.0.0.1:9080/mcp" -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "apikey: reader-key" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deletePet","arguments":{"pathParameters":{"petId":1}}}}' +``` + +You should receive an `HTTP/1.1 403 Forbidden` response with the following body: + +```json +{"message":"deletePet is not allowed"} +``` + +List the tools: + +```shell +curl "http://127.0.0.1:9080/mcp" -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "apikey: reader-key" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +`deletePet` is absent from the result. + +### Give each Consumer its own set of tools + +Use `expr` to pick a rule per Consumer: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/mcp" -X PATCH \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "plugins": { + "mcp-tools-acl": { + "rules": [ + { + "expr": [["consumer_name", "==", "reader"]], + "allow_tools": ["getPetById", "findPetsByStatus"] + }, + { + "expr": [["consumer_name", "==", "editor"]], + "deny_tools": ["deletePet"] + } + ] + } + } + }' +``` + +`reader` now sees and may call only `getPetById` and `findPetsByStatus`, while `editor` may use every tool except `deletePet`. A Consumer that matches no rule is not restricted. + +## Delete Plugin + +To remove the Plugin, delete the corresponding JSON configuration from the Plugin configuration. APISIX will automatically reload and you do not have to restart for this to take effect: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/mcp" -X PATCH \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "plugins": { + "mcp-tools-acl": null + } + }' +``` diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json index 4217ba555249..6582cf35f61b 100644 --- a/docs/zh/latest/config.json +++ b/docs/zh/latest/config.json @@ -240,7 +240,8 @@ "plugins/dubbo-proxy", "plugins/mqtt-proxy", "plugins/http-dubbo", - "plugins/openapi-to-mcp" + "plugins/openapi-to-mcp", + "plugins/mcp-tools-acl" ] } ] diff --git a/docs/zh/latest/plugins/mcp-tools-acl.md b/docs/zh/latest/plugins/mcp-tools-acl.md new file mode 100644 index 000000000000..9f90ceb52ffb --- /dev/null +++ b/docs/zh/latest/plugins/mcp-tools-acl.md @@ -0,0 +1,179 @@ +--- +title: mcp-tools-acl +keywords: + - Apache APISIX + - API 网关 + - 插件 + - MCP + - ACL + - mcp-tools-acl +description: 本文介绍了 Apache APISIX mcp-tools-acl 插件的相关操作,你可以使用此插件限制客户端在 openapi-to-mcp 提供服务的路由上可以看到和调用的 MCP 工具。 +--- + + + +## 描述 + +`mcp-tools-acl` 插件用于限制客户端在由 [`openapi-to-mcp`](./openapi-to-mcp.md) 提供服务的路由上可以使用哪些 MCP 工具。 + +`openapi-to-mcp` 会把 OpenAPI 文档中的每一个操作都转换成一个工具,而某个具体的客户端通常不应该拿到全部工具。该插件按消费者收窄这个集合: + +* 对匹配规则不允许的工具发起 `tools/call` 时请求被拒绝,后端 API 不会被调用。 +* 同样的工具会从 `tools/list` 的响应中移除,客户端不会知道它们的存在。 + +该插件的优先级为 539,低于 `openapi-to-mcp`(540),也低于各认证插件,因此在匹配规则时消费者已经确定。 + +## 属性 + +| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | +|-----------------------|---------|--------|-------------|--------------|------| +| rules | array | 是 | | | 规则列表。第一个 `expr` 匹配成功的规则生效;未设置 `expr` 的规则总是匹配。 | +| rules[].allow_tools | array | 否 | | | 允许的工具名称,精确匹配且区分大小写。设置后其他工具全部被拒绝,空数组表示拒绝所有工具。 | +| rules[].deny_tools | array | 否 | | | 拒绝的工具名称,精确匹配且区分大小写。 | +| rules[].rejected_code | integer | 否 | `403` | [200, 599] | 工具被拒绝时返回的 HTTP 状态码。 | +| rules[].rejected_msg | string | 否 | | | 工具被拒绝时响应体中返回的消息。 | +| rules[].expr | array | 否 | | | [lua-resty-expr](https://github.com/api7/lua-resty-expr) 表达式,用于筛选该规则适用的请求。 | +| max_resp_body_size | integer | 否 | `67108864` | >= 1 | 为过滤 `tools/list` 而缓存在内存中的响应体的最大字节数,超出部分会被截断。 | + +每条规则必须设置 `allow_tools` 或 `deny_tools`。两者同时设置时先判断 `deny_tools`。 + +只有当同一条路由上配置了 `openapi-to-mcp` 并且已经识别出消费者时,该插件才会生效。在其他路由上它只会记录一条警告日志并放行,因此把它配置到不提供 MCP 服务的路由上不会产生任何影响。 + +## 使用示例 + +以下示例使用 ID 为 `mcp` 的路由。调用 Admin API 需要 [admin key](../admin-api.md): + +```shell +admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"//g') +``` + +### 对所有消费者拒绝某个工具 + +创建一个消费者并为其配置密钥: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/consumers" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "username": "reader", + "plugins": { + "key-auth": { + "key": "reader-key" + } + } + }' +``` + +创建一条把 Swagger Petstore API 作为 MCP 工具提供服务的路由,并拒绝 `deletePet`: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "mcp", + "uri": "/mcp", + "plugins": { + "key-auth": {}, + "openapi-to-mcp": { + "transport": "streamable_http", + "openapi_url": "https://petstore3.swagger.io/api/v3/openapi.json", + "base_url": "https://petstore3.swagger.io/api/v3" + }, + "mcp-tools-acl": { + "rules": [ + { + "deny_tools": ["deletePet"], + "rejected_code": 403, + "rejected_msg": "deletePet is not allowed" + } + ] + } + } + }' +``` + +调用被拒绝的工具: + +```shell +curl -i "http://127.0.0.1:9080/mcp" -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "apikey: reader-key" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deletePet","arguments":{"pathParameters":{"petId":1}}}}' +``` + +将会收到 `HTTP/1.1 403 Forbidden` 响应,响应体如下: + +```json +{"message":"deletePet is not allowed"} +``` + +列出工具: + +```shell +curl "http://127.0.0.1:9080/mcp" -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "apikey: reader-key" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +结果中不会出现 `deletePet`。 + +### 为每个消费者配置各自的工具集合 + +使用 `expr` 为不同消费者选择不同的规则: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/mcp" -X PATCH \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "plugins": { + "mcp-tools-acl": { + "rules": [ + { + "expr": [["consumer_name", "==", "reader"]], + "allow_tools": ["getPetById", "findPetsByStatus"] + }, + { + "expr": [["consumer_name", "==", "editor"]], + "deny_tools": ["deletePet"] + } + ] + } + } + }' +``` + +此时 `reader` 只能看到并调用 `getPetById` 和 `findPetsByStatus`,而 `editor` 除 `deletePet` 外可以使用所有工具。没有匹配到任何规则的消费者不受限制。 + +## 删除插件 + +当你需要禁用该插件时,可以从插件配置中删除对应的 JSON 配置,APISIX 会自动重新加载,无需重启服务: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/mcp" -X PATCH \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "plugins": { + "mcp-tools-acl": null + } + }' +``` diff --git a/t/admin/plugins.t b/t/admin/plugins.t index 5bd48edbeb27..56fed4564aba 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -128,6 +128,7 @@ traffic-split redirect response-rewrite openapi-to-mcp +mcp-tools-acl oas-validator mcp-bridge degraphql diff --git a/t/plugin/mcp-tools-acl.t b/t/plugin/mcp-tools-acl.t new file mode 100644 index 000000000000..15f866b610fe --- /dev/null +++ b/t/plugin/mcp-tools-acl.t @@ -0,0 +1,470 @@ +# +# 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. +# +# mcp-tools-acl narrows what an authenticated client may do over an MCP +# conversation openapi-to-mcp is serving: it refuses a tools/call for a tool the +# matched rule does not allow, and removes those tools from the tools/list +# answer so a client never learns they exist. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request && !$block->exec) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } + + my $http_config = $block->http_config // <<_EOC_; + server { + listen 11460; + + location / { + content_by_lua_block { + require("lib.openapi_to_mcp_fixture").serve() + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: sanity +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.mcp-tools-acl") + local ok, err = plugin.check_schema({ + rules = { + { allow_tools = {"getPetById"}, expr = {{"consumer_name", "==", "alice"}} }, + { deny_tools = {"deletePet"}, rejected_code = 401, + rejected_msg = "no" }, + }, + }) + if not ok then + ngx.say(err) + return + end + ngx.say("done") + } + } +--- response_body +done + + + +=== TEST 2: rules is required +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.mcp-tools-acl") + local ok, err = plugin.check_schema({}) + ngx.say(err) + } + } +--- response_body +property "rules" is required + + + +=== TEST 3: a malformed expr is rejected by check_schema +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.mcp-tools-acl") + local ok, err = plugin.check_schema({ + rules = {{ allow_tools = {"getPetById"}, + expr = {{"consumer_name", "!!!", "alice"}} }}, + }) + ngx.say(ok, " ", err) + } + } +--- response_body_like +^false failed to validate the 'expr' expression: .* + + + +=== TEST 4: two consumers, so a rule can pick between them +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + for _, c in ipairs({{"alice", "alice-key"}, {"bob", "bob-key"}}) do + local code, body = t('/apisix/admin/consumers', ngx.HTTP_PUT, + string.format( + [[{"username":"%s","plugins":{"key-auth":{"key":"%s"}}}]], + c[1], c[2])) + if code >= 300 then + ngx.say(body) + return + end + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 5: a route that denies deletePet to everyone who authenticates +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp-acl", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/petstore.json", + }, { + ["key-auth"] = {}, + ["mcp-tools-acl"] = { + rules = {{ + deny_tools = { "deletePet" }, + rejected_code = 403, + rejected_msg = "deletePet is not allowed", + }}, + }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 6: a denied tools/call never reaches the MCP server +--- request +POST /mcp-acl +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deletePet","arguments":{"petId":1}}} +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +apikey: alice-key +--- error_code: 403 +--- response_body +{"message":"deletePet is not allowed"} + + + +=== TEST 7: a tool the rule says nothing about is called as usual +--- request +POST /mcp-acl +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getPetById","arguments":{"pathParameters":{"petId":1}}}} +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +apikey: alice-key +--- error_code: 200 +--- response_body_like +.*seen_path.* + + + +=== TEST 8: the denied tool is gone from tools/list as well +--- request +POST /mcp-acl +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +apikey: alice-key +--- error_code: 200 +--- response_body_like +.*"name":"getPetById".* +--- response_body_unlike +.*"name":"deletePet".* + + + +=== TEST 9: an allow list leaves only what it names +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp-acl", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/petstore.json", + }, { + ["key-auth"] = {}, + ["mcp-tools-acl"] = { + rules = {{ allow_tools = { "getPetById" } }}, + }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 10: a tool outside the allow list is refused with the default code +--- request +POST /mcp-acl +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"addPet","arguments":{}}} +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +apikey: alice-key +--- error_code: 403 +--- response_body +{"message":"MCP tool is not allowed"} + + + +=== TEST 11: tools/list keeps only the allowed tool +--- request +POST /mcp-acl +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +apikey: alice-key +--- error_code: 200 +--- response_body_like +.*"name":"getPetById".* +--- response_body_unlike +.*"name":"addPet".* + + + +=== TEST 12: expr picks a different rule per consumer +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp-acl", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/petstore.json", + }, { + ["key-auth"] = {}, + ["mcp-tools-acl"] = { + rules = { + { expr = {{"consumer_name", "==", "alice"}}, + allow_tools = { "getPetById" } }, + { expr = {{"consumer_name", "==", "bob"}}, + allow_tools = { "addPet" } }, + }, + }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 13: alice may call getPetById, bob may not +--- pipelined_requests eval +["POST /mcp-acl\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"getPetById\",\"arguments\":{\"pathParameters\":{\"petId\":1}}}}", + "POST /mcp-acl\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"addPet\",\"arguments\":{}}}"] +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +apikey: alice-key +--- error_code eval +[200, 403] + + + +=== TEST 14: and the other way round for bob +--- pipelined_requests eval +["POST /mcp-acl\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"getPetById\",\"arguments\":{\"pathParameters\":{\"petId\":1}}}}", + "POST /mcp-acl\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"addPet\",\"arguments\":{}}}"] +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +apikey: bob-key +--- error_code eval +[403, 200] + + + +=== TEST 15: no rule matches, so nothing is enforced +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 1, "/mcp-acl", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/petstore.json", + }, { + ["key-auth"] = {}, + ["mcp-tools-acl"] = { + rules = {{ expr = {{"consumer_name", "==", "nobody"}}, + allow_tools = { "getPetById" } }}, + }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 16: a tool no rule covers goes through +--- request +POST /mcp-acl +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"addPet","arguments":{}}} +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +apikey: alice-key +--- error_code: 200 + + + +=== TEST 17: without a consumer the ACL has nobody to police +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 2, "/mcp-acl-anon", { + transport = "streamable_http", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/petstore.json", + }, { + ["mcp-tools-acl"] = { + rules = {{ deny_tools = { "deletePet" } }}, + }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 18: so the denied tool is callable on that route +--- request +POST /mcp-acl-anon +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deletePet","arguments":{"petId":1}}} +--- more_headers +Content-Type: application/json +Accept: application/json, text/event-stream +--- error_code: 200 + + + +=== TEST 19: a route without openapi-to-mcp is not an MCP conversation +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/3', ngx.HTTP_PUT, [[{ + "uri": "/no-mcp", + "plugins": { + "key-auth": {}, + "mcp-tools-acl": { + "rules": [{"deny_tools": ["deletePet"]}] + } + }, + "upstream": { + "nodes": {"127.0.0.1:11460": 1}, + "type": "roundrobin" + } + }]]) + if code >= 300 then + ngx.say(body) + return + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 20: so the plugin stands aside and says why +--- request +POST /no-mcp +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deletePet","arguments":{"petId":1}}} +--- more_headers +Content-Type: application/json +apikey: alice-key +--- error_code: 200 +--- error_log +openapi-to-mcp plugin is not active on this route + + + +=== TEST 21: the same filtering over the SSE transport +--- config + location /t { + content_by_lua_block { + local ok = require("lib.openapi_to_mcp_fixture").put_routes({ + { 4, "/mcp-acl-sse", { + transport = "sse", + base_url = "http://127.0.0.1:11460", + openapi_url = "http://127.0.0.1:11460/petstore.json", + }, { + ["key-auth"] = {}, + ["mcp-tools-acl"] = { + rules = {{ allow_tools = { "getPetById" } }}, + }, + } }, + }) + if ok then ngx.say("passed") end + } + } +--- response_body +passed + + + +=== TEST 22: clean up +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + for _, id in ipairs({1, 2, 3, 4}) do + t('/apisix/admin/routes/' .. id, ngx.HTTP_DELETE) + end + t('/apisix/admin/consumers/alice', ngx.HTTP_DELETE) + t('/apisix/admin/consumers/bob', ngx.HTTP_DELETE) + ngx.say("cleaned") + } + } +--- response_body +cleaned