mcp-tools-acl
The mcp-tools-acl plugin controls which MCP tools each consumer can call or discover on a route powered by openapi-to-mcp. It enforces two kinds of restrictions:
tools/callblocking — rejects calls to denied tools with an HTTP error response.tools/listfiltering — strips denied tools from the list returned to the client, so disallowed tools are invisible to MCP clients. This applies to both JSON responses and SSE (Server-Sent Events) streaming responses.
The plugin uses a rules-based configuration where each rule specifies either an allowlist or a denylist, and optionally an expression condition. Rules are evaluated in order — the first rule whose conditions match is applied and the rest are skipped.
This plugin is available in API7 Enterprise from version 3.9.8.
Examples
Prerequisites
Before using this plugin, ensure that:
- The
openapi-to-mcpplugin is enabled on the same route. - An authentication plugin (e.g.,
key-auth) is configured on the route. Without an authenticated consumer on the request,mcp-tools-aclpasses all traffic unchanged.
The examples below demonstrate how you can use the mcp-tools-acl plugin for different scenarios.
Restrict Tool Access with an Allowlist
The following example demonstrates how to configure an allowlist so that a consumer can only call specific MCP tools.
- Admin API
- ADC
- Ingress Controller
Create a consumer alice:
curl "http://127.0.0.1:9180/apisix/admin/consumers" -X PUT \
-H "X-API-KEY: ${ADMIN_API_KEY}" \
-d '{
"username": "alice"
}'
Create a key-auth credential for alice:
curl "http://127.0.0.1:9180/apisix/admin/consumers/alice/credentials" -X PUT \
-H "X-API-KEY: ${ADMIN_API_KEY}" \
-d '{
"id": "cred-alice-key-auth",
"plugins": {
"key-auth": {
"key": "alice-key"
}
}
}'
Configure the mcp-tools-acl plugin on consumer alice:
curl "http://127.0.0.1:9180/apisix/admin/consumers" -X PUT \
-H "X-API-KEY: ${ADMIN_API_KEY}" \
-d '{
"username": "alice",
"plugins": {
"mcp-tools-acl": {
"rules": [
{
"allow_tools": ["getPetById", "getUserByName"]
}
]
}
}
}'
❶ Allow consumer alice to call only getPetById and getUserByName. All other tools are blocked and will not appear in the tool listing.
Configure mcp-tools-acl on the consumer (or consumer group) to enable per-consumer tool access control. The route only needs openapi-to-mcp and an auth plugin.
When the plugin is configured on both the consumer and the route, the consumer configuration takes priority and the route-level configuration is ignored for that consumer. If a consumer has no mcp-tools-acl configuration, the route-level configuration applies as a fallback.
Create a route with openapi-to-mcp and key-auth enabled:
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${ADMIN_API_KEY}" \
-d '{
"id": "mcp-tools-acl-route",
"uri": "/mcp",
"methods": ["GET", "POST"],
"plugins": {
"openapi-to-mcp": {
"transport": "streamable_http",
"openapi_url": "https://petstore3.swagger.io/api/v3/openapi.json",
"base_url": "https://petstore3.swagger.io/api/v3",
"headers": {
"Authorization": "special-key"
}
},
"key-auth": {}
},
"upstream": {
"type": "roundrobin",
"scheme": "https",
"pass_host": "node",
"nodes": {
"petstore3.swagger.io:443": 1
}
}
}'
Send a tools/list request as consumer alice:
curl -s "http://127.0.0.1:9080/mcp" \
-H "apikey: alice-key" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}'
You should see only getPetById and getUserByName in the response — all other tools have been filtered out.
Call an allowed tool:
curl -i "http://127.0.0.1:9080/mcp" \
-H "apikey: alice-key" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "getPetById",
"arguments": {
"pathParameters": {
"petId": 1
}
}
}
}'
You should see an HTTP/1.1 200 OK response with the Petstore pet payload.
Call a tool that is not on the allowlist:
curl -i "http://127.0.0.1:9080/mcp" \
-H "apikey: alice-key" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "deletePet",
"arguments": {
"pathParameters": {
"petId": 1
}
}
}
}'
You should see an HTTP/1.1 403 Forbidden response, indicating the tool call was rejected.
Create a consumer with key-auth credential and mcp-tools-acl configured, and create a route with openapi-to-mcp and key-auth enabled:
consumers:
- username: alice
credentials:
- name: cred-alice-key-auth
type: key-auth
config:
key: alice-key
plugins:
mcp-tools-acl:
rules:
- allow_tools:
- getPetById
- getUserByName
services:
- name: mcp-tools-acl-service
upstream:
type: roundrobin
scheme: https
pass_host: node
nodes:
- host: petstore3.swagger.io
port: 443
weight: 1
routes:
- name: mcp-tools-acl-route
uris:
- /mcp
methods:
- GET
- POST
plugins:
key-auth:
header: apikey
openapi-to-mcp:
transport: streamable_http
base_url: https://petstore3.swagger.io/api/v3
headers:
Authorization: special-key
openapi_url: https://petstore3.swagger.io/api/v3/openapi.json
❶ Allow consumer alice to call only getPetById and getUserByName. All other tools are blocked and will not appear in the tool listing.
Synchronize the configuration to the gateway:
adc sync -f adc.yaml
- Gateway API
- APISIX CRD
Gateway API currently has a bug where the upstream scheme is not correctly configured. As a result, requests are forwarded over HTTP instead of HTTPS, which leads to the error The plain HTTP request was sent to HTTPS port. Until this behavior is fixed, this example cannot be completed using Gateway API.
Create a consumer and a route with openapi-to-mcp, key-auth, and mcp-tools-acl configured as such:
apiVersion: apisix.apache.org/v2
kind: ApisixConsumer
metadata:
namespace: aic
name: alice
spec:
ingressClassName: apisix
authParameter:
keyAuth:
value:
key: alice-key
---
apiVersion: apisix.apache.org/v2
kind: ApisixUpstream
metadata:
namespace: aic
name: petstore-external-domain
spec:
ingressClassName: apisix
scheme: https
passHost: node
externalNodes:
- type: Domain
name: petstore3.swagger.io
port: 443
---
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
namespace: aic
name: mcp-tools-acl-route
spec:
ingressClassName: apisix
http:
- name: mcp-tools-acl-route
match:
paths:
- /mcp
methods:
- GET
- POST
upstreams:
- name: petstore-external-domain
plugins:
- name: key-auth
enable: true
config:
header: apikey
- name: openapi-to-mcp
enable: true
config:
transport: streamable_http
base_url: https://petstore3.swagger.io/api/v3
headers:
Authorization: special-key
openapi_url: https://petstore3.swagger.io/api/v3/openapi.json
- name: mcp-tools-acl
enable: true
config:
rules:
- allow_tools:
- getPetById
- getUserByName
❶ Allow only getPetById and getUserByName on this route.
Apply the configuration to your cluster:
kubectl apply -f mcp-tools-acl-ic.yaml
Restrict Tool Access with a Denylist
The following example demonstrates how to block a specific tool while leaving all other tools accessible.
- Admin API
- ADC
- Ingress Controller
Building on the previous example, create consumer bob with a denylist:
curl "http://127.0.0.1:9180/apisix/admin/consumers" -X PUT \
-H "X-API-KEY: ${ADMIN_API_KEY}" \
-d '{
"username": "bob",
"plugins": {
"mcp-tools-acl": {
"rules": [
{
"deny_tools": ["deletePet"]
}
]
}
}
}'
❶ Deny consumer bob from calling deletePet. All other tools remain accessible.
Create a key-auth credential for bob:
curl "http://127.0.0.1:9180/apisix/admin/consumers/bob/credentials" -X PUT \
-H "X-API-KEY: ${ADMIN_API_KEY}" \
-d '{
"id": "cred-bob-key-auth",
"plugins": {
"key-auth": {
"key": "bob-key"
}
}
}'
Send a tools/list request as consumer bob:
curl -s "http://127.0.0.1:9080/mcp" \
-H "apikey: bob-key" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}'
You should see all tools listed except deletePet.
Call the denied tool:
curl -i "http://127.0.0.1:9080/mcp" \
-H "apikey: bob-key" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "deletePet",
"arguments": {
"pathParameters": {
"petId": 1
}
}
}
}'
You should see an HTTP/1.1 403 Forbidden response.
Update the consumer configuration in adc.yaml to change the ACL rule:
consumers:
- username: bob
credentials:
- name: cred-bob-key-auth
type: key-auth
config:
key: bob-key
plugins:
mcp-tools-acl:
rules:
- deny_tools:
- deletePet
services:
- name: mcp-tools-acl-service
upstream:
type: roundrobin
scheme: https
pass_host: node
nodes:
- host: petstore3.swagger.io
port: 443
weight: 1
routes:
- name: mcp-tools-acl-route
uris:
- /mcp
methods:
- GET
- POST
plugins:
key-auth:
header: apikey
openapi-to-mcp:
transport: streamable_http
base_url: https://petstore3.swagger.io/api/v3
headers:
Authorization: special-key
openapi_url: https://petstore3.swagger.io/api/v3/openapi.json
❶ Deny consumer bob from calling deletePet. All other tools remain accessible.
Synchronize the configuration to the gateway:
adc sync -f adc.yaml
- Gateway API
- APISIX CRD
Gateway API currently has a bug where the upstream scheme is not correctly configured. As a result, requests are forwarded over HTTP instead of HTTPS, which leads to the error The plain HTTP request was sent to HTTPS port. Until this behavior is fixed, this example cannot be completed using Gateway API.
Update the ACL rule in your consumer or route configuration:
# Other Configs
# ---
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
namespace: aic
name: mcp-tools-acl-route
spec:
ingressClassName: apisix
http:
- name: mcp-tools-acl-route
match:
paths:
- /mcp
methods:
- GET
- POST
upstreams:
- name: petstore-external-domain
plugins:
- name: key-auth
enable: true
config:
header: apikey
- name: openapi-to-mcp
enable: true
config:
transport: streamable_http
base_url: https://petstore3.swagger.io/api/v3
headers:
Authorization: special-key
openapi_url: https://petstore3.swagger.io/api/v3/openapi.json
- name: mcp-tools-acl
enable: true
config:
rules:
- deny_tools:
- deletePet
❶ Deny calls to deletePet while allowing other tools on the route.
Apply the configuration to your cluster:
kubectl apply -f mcp-tools-acl-ic.yaml
Apply Different Rules Based on Route Conditions
The following example demonstrates how to use expression conditions (expr) to apply different ACL rules depending on request context, such as the route being accessed.
- Admin API
- ADC
- Ingress Controller
This example applies to API7 Enterprise version 3.9.8 and later.
Create a consumer grace with conditional rules:
curl "http://127.0.0.1:9180/apisix/admin/consumers" -X PUT \
-H "X-API-KEY: ${ADMIN_API_KEY}" \
-d '{
"username": "grace",
"plugins": {
"mcp-tools-acl": {
"rules": [
{
"expr": [["route_id", "==", "route-pets"]],
"allow_tools": ["getPetById"]
},
{
"allow_tools": ["getUserByName"]
}
]
}
}
}'
❶ Rule 1: When the request hits the route route-pets, only getPetById is allowed.
❷ Rule 2: A catch-all rule (no expr) — for all other routes, only getUserByName is allowed. This rule is reached only if Rule 1 does not match.
Create a key-auth credential for grace:
curl "http://127.0.0.1:9180/apisix/admin/consumers/grace/credentials" -X PUT \
-H "X-API-KEY: ${ADMIN_API_KEY}" \
-d '{
"id": "cred-grace-key-auth",
"plugins": {
"key-auth": {
"key": "grace-key"
}
}
}'
Create two routes with openapi-to-mcp and key-auth:
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${ADMIN_API_KEY}" \
-d '{
"id": "route-pets",
"uri": "/mcp",
"methods": ["GET", "POST"],
"plugins": {
"openapi-to-mcp": {
"transport": "streamable_http",
"openapi_url": "https://petstore3.swagger.io/api/v3/openapi.json",
"base_url": "https://petstore3.swagger.io/api/v3",
"headers": { "Authorization": "special-key" }
},
"key-auth": {}
},
"upstream": { "type": "roundrobin", "scheme": "https", "pass_host": "node", "nodes": { "petstore3.swagger.io:443": 1 } }
}'
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${ADMIN_API_KEY}" \
-d '{
"id": "route-inventory",
"uri": "/mcp2",
"methods": ["GET", "POST"],
"plugins": {
"openapi-to-mcp": {
"transport": "streamable_http",
"openapi_url": "https://petstore3.swagger.io/api/v3/openapi.json",
"base_url": "https://petstore3.swagger.io/api/v3",
"headers": { "Authorization": "special-key" }
},
"key-auth": {}
},
"upstream": { "type": "roundrobin", "scheme": "https", "pass_host": "node", "nodes": { "petstore3.swagger.io:443": 1 } }
}'
When grace calls route-pets, Rule 1 matches and only getPetById is allowed:
curl -i "http://127.0.0.1:9080/mcp" \
-H "apikey: grace-key" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "getPetById",
"arguments": {
"pathParameters": {
"petId": 1
}
}
}
}'
You should see an HTTP/1.1 200 OK response with the Petstore pet payload.
When grace calls route-inventory, Rule 1 does not match (wrong route_id), so the catch-all Rule 2 applies — only getUserByName is allowed:
curl -i "http://127.0.0.1:9080/mcp2" \
-H "apikey: grace-key" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "getUserByName",
"arguments": {
"pathParameters": {
"username": "user1"
}
}
}
}'
You should see an HTTP/1.1 200 OK response with the Petstore user payload.
Rules are evaluated top to bottom. The first matching rule takes effect and all subsequent rules are skipped. Place more specific rules (with expr) before broader catch-all rules (without expr).
If no rule matches (all rules have expr conditions and none evaluate to true), the plugin does not enforce any access control — all tools are passed through.
Create a consumer with conditional rules and two routes:
consumers:
- username: grace
credentials:
- name: cred-grace-key-auth
type: key-auth
config:
key: grace-key
plugins:
mcp-tools-acl:
rules:
- expr:
-
- route_name
- ==
- pets-route
allow_tools:
- getPetById
- allow_tools:
- getUserByName
services:
- name: pets-mcp-service
upstream:
type: roundrobin
scheme: https
pass_host: node
nodes:
- host: petstore3.swagger.io
port: 443
weight: 1
routes:
- name: pets-route
uris:
- /mcp
methods:
- GET
- POST
plugins:
key-auth:
header: apikey
openapi-to-mcp:
transport: streamable_http
base_url: https://petstore3.swagger.io/api/v3
headers:
Authorization: special-key
openapi_url: https://petstore3.swagger.io/api/v3/openapi.json
- name: inventory-mcp-service
upstream:
type: roundrobin
scheme: https
pass_host: node
nodes:
- host: petstore3.swagger.io
port: 443
weight: 1
routes:
- name: inventory-route
uris:
- /mcp2
methods:
- GET
- POST
plugins:
key-auth:
header: apikey
openapi-to-mcp:
transport: streamable_http
base_url: https://petstore3.swagger.io/api/v3
headers:
Authorization: special-key
openapi_url: https://petstore3.swagger.io/api/v3/openapi.json
❶ When the request hits pets-route, only getPetById is allowed.
❷ A catch-all rule for all other routes. Here only getUserByName is allowed.
Synchronize the configuration to the gateway:
adc sync -f adc.yaml
- Gateway API
- APISIX CRD
Gateway API currently has a bug where the upstream scheme is not correctly configured. As a result, requests are forwarded over HTTP instead of HTTPS, which leads to the error The plain HTTP request was sent to HTTPS port. Until this behavior is fixed, this example cannot be completed using Gateway API.
Configure conditional rules with separate MCP routes:
apiVersion: apisix.apache.org/v2
kind: ApisixConsumer
metadata:
namespace: aic
name: grace
spec:
ingressClassName: apisix
authParameter:
keyAuth:
value:
key: grace-key
---
apiVersion: apisix.apache.org/v2
kind: ApisixUpstream
metadata:
namespace: aic
name: petstore-external-domain
spec:
ingressClassName: apisix
scheme: https
passHost: node
externalNodes:
- type: Domain
name: petstore3.swagger.io
port: 443
---
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
namespace: aic
name: mcp-tools-acl-routes
spec:
ingressClassName: apisix
http:
- name: pets-route
match:
paths:
- /mcp
methods:
- GET
- POST
upstreams:
- name: petstore-external-domain
plugins:
- name: key-auth
enable: true
config:
header: apikey
- name: openapi-to-mcp
enable: true
config:
transport: streamable_http
base_url: https://petstore3.swagger.io/api/v3
headers:
Authorization: special-key
openapi_url: https://petstore3.swagger.io/api/v3/openapi.json
- name: mcp-tools-acl
enable: true
config:
rules:
- expr:
-
- uri
- ==
- /mcp
allow_tools:
- getPetById
- allow_tools:
- getUserByName
- name: inventory-route
match:
paths:
- /mcp2
methods:
- GET
- POST
upstreams:
- name: petstore-external-domain
plugins:
- name: key-auth
enable: true
config:
header: apikey
- name: openapi-to-mcp
enable: true
config:
transport: streamable_http
base_url: https://petstore3.swagger.io/api/v3
headers:
Authorization: special-key
openapi_url: https://petstore3.swagger.io/api/v3/openapi.json
- name: mcp-tools-acl
enable: true
config:
rules:
- expr:
-
- uri
- ==
- /mcp
allow_tools:
- getPetById
- allow_tools:
- getUserByName
❶ Apply getPetById only when the request URI is /mcp.
❷ Use the catch-all rule to allow only getUserByName for the /mcp2 route.
Apply the configuration to your cluster:
kubectl apply -f mcp-tools-acl-ic.yaml
Troubleshooting
Plugin has no effect
Check that openapi-to-mcp is enabled on the same route and that an authentication plugin is configured. Without an authenticated consumer on the request, mcp-tools-acl passes all traffic unchanged by design.
tools/call returns HTTP 400
The request body is valid JSON but params is missing or params.name is not a string. The plugin returns {"message": "Invalid MCP tools/call request"} with HTTP 400. This is distinct from rejected_code (which applies to denied tools) and indicates a malformed request from the MCP client.
allow_tools: [] blocks all tools
An empty allowlist is schema-valid and denies all tools. Every tools/call will be rejected and tools/list will return an empty list. If you want consumers to access any tools, ensure the allow_tools array is non-empty.