Problem
The MCP server authorizes requests per MCP method (internal/mcp/authz.go). A single dataplane_mcpserver_tools_call grant lets a principal call every tool on the server, and anyone with dataplane_mcpserver_tools_list sees every tool. That makes least privilege impossible for agents:
- You can't let an agent use
read_orders without also letting it use write_orders, or give two agents on the same server different tool sets.
- Every agent sees the server's whole capability surface in
tools/list, including tools it should never use. That widens the blast radius of prompt injection and invites the model to try tools it shouldn't.
- Denials leave no trace. The middleware returns "permission denied" without a log line, metric or span, so operators can't audit what an agent tried and was refused.
Proposal
Treat tools as sub-resources of the MCP server, <server>/tools/<tool name>, and authorize against them with the policy's existing SubResourceAuthorizer:
tools/call: check dataplane_mcpserver_tools_call on <server>/tools/<name>. An empty tool name is denied.
tools/list: still requires dataplane_mcpserver_tools_list on the server; the response then only includes tools the principal may call.
- Denials: log policy denials at Warn with
method, principal, permission, resource_type, resource_id and reason.
This is the model common-go/authz/authzcore already uses for Redpanda's gRPC APIs: SubResourceAuthorizer per request when a resource ID is present, FilterCollection for list responses, and Warn logs with the same keys. Connect already wraps SubResourceAuthorizer in internal/gateway/authz.go, but nothing calls it today.
Policy examples
# Unchanged: a binding on the server covers every tool.
- role: mcp.user
principal: User:alice@example.com
scope: organizations/acme/resourcegroups/default/dataplanes/dp/mcpservers/srv
# New: tools/call on a single tool.
- role: mcp.tool_caller
principal: User:alice@example.com
scope: organizations/acme/resourcegroups/default/dataplanes/dp/mcpservers/srv/tools/read_orders
# New: tools/call on every tool whose name starts with "read_".
- role: mcp.tool_caller
principal: User:alice@example.com
scope: organizations/acme/resourcegroups/default/dataplanes/dp/mcpservers/srv/tools/read_*
Compatibility
- Existing policies keep working. A sub-resource authorizer combines the server-level and tool-level checks, and passes if either does, so server-wide grants still allow every tool.
- Wildcards are scoped correctly. The library indexes wildcard bindings under their parent and prefix-matches them, so
…/tools/read_* matches read_orders but does not grant anything at the server level.
- One behavior change. A principal that may list tools but holds no
tools_call grant anywhere now sees an empty list, rather than tools it can't call. Everyone with a server-wide tools_call grant sees the same list as today.
- Wildcards widen as tools are added.
…/tools/read_* also covers any matching tool deployed later, such as read_and_delete_orders, without a policy change.
- Logging. Methods with no permission mapping, mainly
notifications/initialized, are denied exactly as before but aren't logged, since they aren't policy decisions and would add one warning per session.
Open questions
- Resource type name.
tools follows the plural convention of the library's docs (mcpservers) and the gateway test fixtures. Is that right for the names the policy materializer produces?
- List visibility. Should a tool be listed when the principal may call it (proposed), or should there be a separate per-tool visibility permission?
- Pagination. Filtering happens per page, so a page can come back shorter than the page size. That's valid MCP, but worth confirming for clients.
- Control plane. Tool-scoped bindings only help if the Cloud policy materializer and UI can produce them. Is that on the roadmap, or should this wait for it?
Follow-ups
- Tell clients when the policy changes. Clients that cache
tools/list keep offering a revoked tool until they list again. The call-time check still denies it, but sending notifications/tools/list_changed after a policy reload would stop the model retrying. That needs a reload hook in the policy watcher, which only reports errors today, and a way to send the notification without changing the tool set; MCP Go SDK v1.4.1 only sends it from AddTool/RemoveTools.
Related, not in scope
- The JWT middleware answers a missing token with 400 and sends no
WWW-Authenticate header on 401. The MCP authorization spec expects 401 plus RFC 9728 resource metadata for client discovery.
- The JWT principal is
User:<email>, so a token without an email claim can't authenticate. That matters for machine and agent identities such as client-credentials tokens.
A prototype implementing the proposal, with unit and end-to-end tests, is in #4917.
Problem
The MCP server authorizes requests per MCP method (
internal/mcp/authz.go). A singledataplane_mcpserver_tools_callgrant lets a principal call every tool on the server, and anyone withdataplane_mcpserver_tools_listsees every tool. That makes least privilege impossible for agents:read_orderswithout also letting it usewrite_orders, or give two agents on the same server different tool sets.tools/list, including tools it should never use. That widens the blast radius of prompt injection and invites the model to try tools it shouldn't.Proposal
Treat tools as sub-resources of the MCP server,
<server>/tools/<tool name>, and authorize against them with the policy's existingSubResourceAuthorizer:tools/call: checkdataplane_mcpserver_tools_callon<server>/tools/<name>. An empty tool name is denied.tools/list: still requiresdataplane_mcpserver_tools_liston the server; the response then only includes tools the principal may call.method,principal,permission,resource_type,resource_idandreason.This is the model
common-go/authz/authzcorealready uses for Redpanda's gRPC APIs:SubResourceAuthorizerper request when a resource ID is present,FilterCollectionfor list responses, and Warn logs with the same keys. Connect already wrapsSubResourceAuthorizerininternal/gateway/authz.go, but nothing calls it today.Policy examples
Compatibility
…/tools/read_*matchesread_ordersbut does not grant anything at the server level.tools_callgrant anywhere now sees an empty list, rather than tools it can't call. Everyone with a server-widetools_callgrant sees the same list as today.…/tools/read_*also covers any matching tool deployed later, such asread_and_delete_orders, without a policy change.notifications/initialized, are denied exactly as before but aren't logged, since they aren't policy decisions and would add one warning per session.Open questions
toolsfollows the plural convention of the library's docs (mcpservers) and the gateway test fixtures. Is that right for the names the policy materializer produces?Follow-ups
tools/listkeep offering a revoked tool until they list again. The call-time check still denies it, but sendingnotifications/tools/list_changedafter a policy reload would stop the model retrying. That needs a reload hook in the policy watcher, which only reports errors today, and a way to send the notification without changing the tool set; MCP Go SDK v1.4.1 only sends it fromAddTool/RemoveTools.Related, not in scope
WWW-Authenticateheader on 401. The MCP authorization spec expects 401 plus RFC 9728 resource metadata for client discovery.User:<email>, so a token without an email claim can't authenticate. That matters for machine and agent identities such as client-credentials tokens.A prototype implementing the proposal, with unit and end-to-end tests, is in #4917.