Skip to content

mcp-server: per-tool authorization and filtered tools/list #4916

Description

@a-palamarchuk

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:

  1. tools/call: check dataplane_mcpserver_tools_call on <server>/tools/<name>. An empty tool name is denied.
  2. tools/list: still requires dataplane_mcpserver_tools_list on the server; the response then only includes tools the principal may call.
  3. 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

  1. 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?
  2. List visibility. Should a tool be listed when the principal may call it (proposed), or should there be a separate per-tool visibility permission?
  3. 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.
  4. 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.

Activity

  1. Grantslip commented on Oct 8, 2026

    @Grantslip

    On question 2, I'd keep listing tied to call permission, as proposed. A separate visibility grant creates a state where an agent sees a tool it can't call, and models do try those, which mostly produces denials and retries. If someone needs 'see but not call' for an admin view, that can be added later.

    Two edge cases that might be worth a test in #4917:

    • Wildcards and new tools. With read_*, a tool someone adds later, say read_and_delete_orders, is granted automatically. That's probably what people expect, but it deserves a line in the docs, since it's the one way a binding widens without anyone editing it.
    • Clients that cache tools/list. If a binding is revoked mid-session, a client can keep offering the tool until it lists again. The call-time check keeps it safe, but sending notifications/tools/list_changed when policy reloads (where the server supports it) would stop the model from retrying something it's now denied.

    On the denial log, I'd leave tool arguments out (they can carry customer data or secrets) and log a request ID instead, so the denial can be matched to a trace.

    Disclosure: I work on Grantslip, which issues short-lived, per-tool passes to agents. There's a free two-week hosted trial at https://grantslip.com if it's ever useful for comparison.

  2. a-palamarchuk commented on Oct 8, 2026

    @a-palamarchuk
    Author

    Thanks for the careful read. On question 2, agreed: tying visibility to call permission avoids offering the model tools it can only be denied. I'll keep it as proposed unless the maintainers see it differently.

    On wildcards, good point. Tools are loaded from the server's repository at startup, so a binding like tools/read_* widens when someone deploys a new matching tool, without any policy change. I'll add a test that makes this explicit and call it out in the code and the PR description.

    On revocation and cached tool lists, agreed that the call-time check is what enforces it. Sending notifications/tools/list_changed on policy reload would be a good follow-up. It needs a reload hook in the policy watcher, which doesn't exist yet, and the MCP Go SDK version Connect uses only sends that notification when tools are added or removed. I'll note it as a follow-up rather than add it to this PR.

    On the denial log, it already excludes tool arguments; I'll add a test to keep it that way, and log the trace ID from the incoming request when present, so a denial can be matched to its trace.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions