Skip to main content

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/call blocking rejects calls to denied tools with an HTTP error response.
  • tools/list filtering removes denied tools from the list returned to the client, so disallowed tools are invisible to MCP clients. This applies to both JSON responses and Server-Sent Events (SSE) 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 matching rule is applied, and the remaining rules are skipped.

The plugin was introduced in API7 Enterprise 3.9.8 and 3.10.0.

Examples​

Each example enables openapi-to-mcp and key-auth on the same route. Without an authenticated consumer, mcp-tools-acl passes all traffic unchanged.

Complete Prepare API7 Gateway before continuing so the required OpenAPI-to-MCP service is available.

Configure mcp-tools-acl on the consumer for per-consumer access control. If the plugin is configured on both the consumer and route, the consumer configuration takes precedence. The route configuration applies only when the authenticated consumer has no mcp-tools-acl configuration.

For the Admin API tabs, set the Dashboard address and gateway group, then enter a Dashboard token:

export API7_ADMIN_URL="https://localhost:7443"
export API7_GATEWAY_GROUP_ID="default"
printf "Dashboard token: "
read -rs API7_ADMIN_KEY && export API7_ADMIN_KEY && echo

The -k flag in these examples accepts the local Dashboard's self-signed certificate. Remove it when using a certificate trusted by the client.

For the ADC tabs, select the api7ee backend and configure the corresponding Dashboard address, token, and gateway group.

The examples use version 1.0.27 of the official Swagger Petstore image. The fixture serves both the OpenAPI document and the API. Its manifest digest is pinned so the access-control results remain reproducible.

Start Petstore​

Start the fixture for the deployment type used by the gateway:

Set GATEWAY_CONTAINER to the running API7 Gateway container, then place the gateway and Petstore on a dedicated network. The OpenAPI-to-MCP service must share the gateway's network namespace as described in Prepare API7 Gateway.

export GATEWAY_CONTAINER=replace-with-gateway-container-name

docker network inspect gateway-petstore-net > /dev/null 2>&1 || \
docker network create gateway-petstore-net

if ! docker inspect "$GATEWAY_CONTAINER" \
--format '{{json .NetworkSettings.Networks}}' \
| grep -q '"gateway-petstore-net"'; then
docker network connect gateway-petstore-net "$GATEWAY_CONTAINER"
fi

Create a Compose file for the pinned Petstore fixture on the shared network:

docker-compose.petstore.yaml
services:
petstore:
image: swaggerapi/petstore3:1.0.27@sha256:749432676c31cd05256f0d9e878302164debb94daa1c1223e193747e1b34e7d1
platform: linux/amd64
networks:
- gateway-petstore-net
ports:
- "18080:8080"

networks:
gateway-petstore-net:
external: true

Start or reconcile the fixture with a fixed Compose project name so the command can be rerun across these guides:

docker compose -p api7-docs-petstore \
-f docker-compose.petstore.yaml up -d

Once the container has started, confirm that the OpenAPI document is available:

curl -fsS "http://127.0.0.1:18080/api/v3/openapi.json" > /dev/null

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.

Create a consumer alice:

curl -k "${API7_ADMIN_URL}/apisix/admin/consumers/alice?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"username": "alice"
}'

Create a key-auth credential for alice:

curl -k "${API7_ADMIN_URL}/apisix/admin/consumers/alice/credentials/cred-alice-key-auth?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "cred-alice-key-auth",
"plugins": {
"key-auth": {
"key": "alice-key"
}
}
}'

Configure the mcp-tools-acl plugin on consumer alice:

curl -k "${API7_ADMIN_URL}/apisix/admin/consumers/alice?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-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.

Create a service with the Petstore API as its upstream:

curl -k "${API7_ADMIN_URL}/apisix/admin/services/mcp-tools-acl-service?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "mcp-tools-acl-service",
"upstream": {
"type": "roundrobin",
"scheme": "http",
"pass_host": "node",
"nodes": [
{
"host": "petstore",
"port": 8080,
"weight": 1
}
]
}
}'

Create a route in the service with openapi-to-mcp and key-auth enabled:

curl -k "${API7_ADMIN_URL}/apisix/admin/routes/mcp-tools-acl-route?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "mcp-tools-acl-route",
"service_id": "mcp-tools-acl-service",
"paths": ["/mcp"],
"methods": ["POST"],
"plugins": {
"openapi-to-mcp": {
"transport": "streamable_http",
"openapi_url": "http://petstore:8080/api/v3/openapi.json",
"base_url": "http://petstore:8080/api/v3",
"headers": {
"Authorization": "special-key"
}
},
"key-auth": {}
}
}'

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.

Restrict Tool Access with a Denylist​

The following example demonstrates how to block a specific tool while leaving all other tools accessible.

Building on the previous example, create consumer bob with a denylist:

curl -k "${API7_ADMIN_URL}/apisix/admin/consumers/bob?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-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 -k "${API7_ADMIN_URL}/apisix/admin/consumers/bob/credentials/cred-bob-key-auth?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "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.

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.

Create a consumer grace with conditional rules:

curl -k "${API7_ADMIN_URL}/apisix/admin/consumers/grace?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-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 is a catch-all rule without 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 -k "${API7_ADMIN_URL}/apisix/admin/consumers/grace/credentials/cred-grace-key-auth?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "cred-grace-key-auth",
"plugins": {
"key-auth": {
"key": "grace-key"
}
}
}'

Create a service with the Petstore API as its upstream, or reuse the one from the first example:

curl -k "${API7_ADMIN_URL}/apisix/admin/services/mcp-tools-acl-service?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "mcp-tools-acl-service",
"upstream": {
"type": "roundrobin",
"scheme": "http",
"pass_host": "node",
"nodes": [
{
"host": "petstore",
"port": 8080,
"weight": 1
}
]
}
}'

The route route-pets below uses the /mcp path. If you created mcp-tools-acl-route in the previous examples, delete it first, because the Dashboard rejects a route that matches the same path as another route in the service:

curl -k "${API7_ADMIN_URL}/apisix/admin/routes/mcp-tools-acl-route?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X DELETE \
-H "X-API-KEY: ${API7_ADMIN_KEY}"

Create two routes in the service with openapi-to-mcp and key-auth:

curl -k "${API7_ADMIN_URL}/apisix/admin/routes/route-pets?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "route-pets",
"service_id": "mcp-tools-acl-service",
"paths": ["/mcp"],
"methods": ["POST"],
"plugins": {
"openapi-to-mcp": {
"transport": "streamable_http",
"openapi_url": "http://petstore:8080/api/v3/openapi.json",
"base_url": "http://petstore:8080/api/v3",
"headers": { "Authorization": "special-key" }
},
"key-auth": {}
}
}'
curl -k "${API7_ADMIN_URL}/apisix/admin/routes/route-inventory?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "route-inventory",
"service_id": "mcp-tools-acl-service",
"paths": ["/mcp2"],
"methods": ["POST"],
"plugins": {
"openapi-to-mcp": {
"transport": "streamable_http",
"openapi_url": "http://petstore:8080/api/v3/openapi.json",
"base_url": "http://petstore:8080/api/v3",
"headers": { "Authorization": "special-key" }
},
"key-auth": {}
}
}'

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 because the route_id is different. The catch-all Rule 2 applies, so 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 because all rules have expr conditions that evaluate to false, the plugin does not enforce access control; all tools are passed through.

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.