Skip to main content

MCP access control

How to define per-agent access control policies for MCP servers in Catalyst.

For a runnable walkthrough, follow the MCP access policies tutorial. This page is the reference: the YAML shape, the patterns, and the trade-offs.

Overview

In a multi-agent system, different agents should have different levels of access to MCP servers. An analysis agent might be allowed to read data from one server but not reach a server that performs writes. An operations agent might call write servers but not delete ones. Without explicit policies, any agent in your project could call any MCP server — a serious attack surface.

Catalyst lets you enforce this using access control lists (ACLs), defined as part of a Catalyst Configuration resource. ACLs identify callers by their Catalyst App ID (which is cryptographically authenticated by service invocation) and allow or deny calls. The policy supports a deny default, so every access must be explicitly granted.

Granularity: per App ID, not per tool

Catalyst access control evaluates caller App ID → target App ID at the service-invocation boundary. It is the same mechanism Catalyst uses for any other service-to-service traffic.

MCP transports — streamable-http and sse — route all tool calls through a single HTTP endpoint. The tool name lives inside the JSON-RPC body, not in the URL path. That means HTTP-path-based ACL rules do not give you per-tool granularity for standard MCP traffic. Today, the unit of authorization for MCP on Catalyst is the MCP server's App ID.

To enforce per-tool boundaries today, split tools across separate MCP servers — one App ID per group — and let the App-ID-keyed policy do the work. See Per-tool granularity through separate MCP servers.

How it works

When an MCP client invokes a tool, the request travels through Catalyst's service invocation layer to the MCP server. The ACL policy is evaluated before the request reaches the application. If the calling App ID is not permitted, Catalyst returns a 403 Forbidden and the call never executes.

The access control policy is attached to the MCP server's App ID via a Configuration resource applied to your Catalyst project.

Defining a policy

The simplest pattern — and the one used in the tutorial and the catalyst-quickstarts repo — uses Configuration accessControl with a default action and per-caller overrides:

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
name: mcp-server-deny
spec:
accessControl:
defaultAction: deny # callers not listed below are denied
trustDomain: "public"
policies:
- appId: mcp-client
defaultAction: deny # this caller is explicitly denied
namespace: "default"

Equivalently, you can create the same Configuration from the CLI:

diagrid configuration create mcp-server-deny \
--default-action deny \
--policy mcp-client:deny

Attach the Configuration to the MCP server's App ID so the rules take effect on its inbound traffic:

diagrid appid update mcp-server --app-config mcp-server-deny
FieldDescription
defaultAction (top-level)Default for any App ID not listed in policies. Set to deny for a zero-trust posture.
trustDomainTrust domain in which the policy applies. "public" covers project-internal traffic.
policies[].appIdThe Catalyst App ID of the calling agent.
policies[].defaultActionallow or deny for this caller.
policies[].namespaceDapr namespace (typically "default").

ACL changes take 10–15 seconds to propagate after diagrid apply.

Deny-all baseline

Start from a deny-all posture and grant access incrementally:

# config/deny-all.yaml
apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
name: mcp-policy
spec:
accessControl:
defaultAction: deny
trustDomain: "public"
diagrid apply -f config/deny-all.yaml
diagrid appid update mcp-server --app-config mcp-policy

Then layer in allow rules by updating the same Configuration resource and re-applying it. The name: mcp-policy is what binds the resource to the running configuration — re-applying replaces the existing policy.

Allowing specific callers

To allow a specific agent App ID while keeping everything else denied:

spec:
accessControl:
defaultAction: deny
trustDomain: "public"
policies:
- appId: analyst-agent
defaultAction: allow
namespace: "default"

The analyst-agent can invoke this MCP server; all other callers are denied.

Per-tool granularity through separate MCP servers

When you need per-tool authorization today, split the tools across separate MCP servers (one per group) and gate each one with its own Configuration:

analyst-agent ──► mcp-db-schema (schema introspection only)
analyst-agent ──► mcp-db-query (read queries only)
ops-agent ──► mcp-db-schema
ops-agent ──► mcp-db-query
ops-agent ──► mcp-db-write (write operations)
admin-agent ──► mcp-db-schema
admin-agent ──► mcp-db-query
admin-agent ──► mcp-db-write
admin-agent ──► mcp-db-ddl (destructive DDL operations)

Each MCP server has its own App ID and its own Configuration with a deny-by-default policy listing the App IDs allowed to call it. The policy boundary matches the trust boundary, and an agent cannot reach a server its App ID isn't allow-listed for.

Combining ACLs with OAuth 2.0

ACL policies and OAuth 2.0 bearer middleware are independent enforcement layers — apply both to the MCP server for defense in depth:

  1. ACL — controls which agent App IDs are allowed to call which MCP servers (enforced by Catalyst's service invocation layer).
  2. Bearer middleware — validates that the caller presents a live, signed JWT from a trusted identity provider (enforced at the HTTP pipeline level, independent of App ID).

An attacker would need to defeat both layers: forge or steal a valid App ID and obtain a valid signed token. See MCP OAuth2 for bearer middleware setup.

Troubleshooting

My agent gets 403 even though I added a policy for its App ID. Catalyst ACL changes take 10–15 seconds to propagate after diagrid apply. Wait and retry. Verify the App ID in the policy exactly matches the --app-id the agent was started with (case-sensitive).

I want to allow all operations for a specific agent. Set defaultAction: allow at the policies[].defaultAction level for that App ID:

policies:
- appId: admin-agent
defaultAction: allow
namespace: "default"

I want to test with no access control first. Don't attach a Configuration resource to the MCP server. Without one, Catalyst allows calls from any App ID in your project.

See also