Skip to main content

MCP Server Access Control

This example demonstrates how to implement fine-grained access control for Model Context Protocol (MCP) servers. MCP enables AI assistants to interact with external tools and resources, making proper access control essential.

Overview

Overview

MCP servers expose several types of operations:

CategoryOperationsDescription
Discoverymcp:tool:list, mcp:prompt:list, mcp:resource:listList available capabilities
Invocationmcp:tool:call, mcp:prompt:get, mcp:resource:readExecute tools or read resources
Subscriptionmcp:resource:subscribe, mcp:resource:unsubscribeReal-time updates
Lifecyclemcp:server:ping, mcp:server:initializeConnection management

This PolicyDomain implements:

  • Server-level access: Control which principals can connect to which MCP servers
  • Tool-level permissions: Fine-grained control over individual tool invocations
  • Resource access: Control which MCP resources a principal can read
  • Discovery restrictions: Limit what capabilities are visible during listing

Design

MCP Resource Naming

We use MRNs to identify MCP entities:

mrn:mcp:<server-id>:tool:<tool-name>
mrn:mcp:<server-id>:prompt:<prompt-name>
mrn:mcp:<server-id>:resource:<resource-uri>

Examples:

  • mrn:mcp:github:tool:create-issue — GitHub server's create-issue tool
  • mrn:mcp:filesystem:resource:file:///home/user/docs — Filesystem resource
  • mrn:mcp:database:tool:query — Database server's query tool

Access Model

Complete PolicyDomain

apiVersion: iamlite.manetu.io/v1alpha4
kind: PolicyDomain
metadata:
name: mcp-server
spec:
# ============================================================
# Policy Libraries
# ============================================================
policy-libraries:
- mrn: &lib-utils "mrn:iam:library:utils"
name: utils
description: "Common utility functions"
rego: |
package utils

import rego.v1

# Check if request has a valid principal (authenticated)
has_principal if {
input.principal != {}
input.principal.sub != ""
}

- mrn: &lib-mcp-helpers "mrn:iam:library:mcp-helpers"
name: mcp-helpers
description: "MCP access control helper functions"
rego: |
package mcp_helpers

import rego.v1

# Extract server ID from an MCP MRN
# mrn:mcp:github:tool:create-issue -> github
extract_server(mrn) := server if {
parts := split(mrn, ":")
parts[0] == "mrn"
parts[1] == "mcp"
server := parts[2]
}

# Extract entity type from an MCP MRN
# mrn:mcp:github:tool:create-issue -> tool
extract_type(mrn) := type if {
parts := split(mrn, ":")
parts[0] == "mrn"
parts[1] == "mcp"
type := parts[3]
}

# Extract entity name from an MCP MRN
# mrn:mcp:github:tool:create-issue -> create-issue
extract_name(mrn) := name if {
parts := split(mrn, ":")
parts[0] == "mrn"
parts[1] == "mcp"
# Join everything after the type
name := concat(":", array.slice(parts, 4, count(parts)))
}

# Check if a principal has access to a specific server
has_server_access(principal, server_id) if {
server_mrn := sprintf("mrn:mcp:server:%s", [server_id])
server_mrn in principal.mannotations.mcp_servers
}

# Check if a principal can use a specific tool
has_tool_access(principal, server_id, tool_name) if {
# Check wildcard access to all tools on this server
wildcard := sprintf("mrn:mcp:%s:tool:*", [server_id])
wildcard in principal.mannotations.mcp_tools
}

has_tool_access(principal, server_id, tool_name) if {
# Check specific tool access
tool_mrn := sprintf("mrn:mcp:%s:tool:%s", [server_id, tool_name])
tool_mrn in principal.mannotations.mcp_tools
}

# Check if operation is an MCP operation
is_mcp_operation(operation) if {
startswith(operation, "mcp:")
}

# Check if operation is discovery (listing)
is_discovery_operation(operation) if {
endswith(operation, ":list")
}

# Check if operation is invocation
is_invocation_operation(operation) if {
operation in {"mcp:tool:call", "mcp:prompt:get", "mcp:resource:read", "mcp:resource:subscribe"}
}

# Check if operation is lifecycle
is_lifecycle_operation(operation) if {
startswith(operation, "mcp:server:")
}

# ============================================================
# Policies
# ============================================================
policies:
# Operation phase - require authentication, allow public server ping
- mrn: &policy-mcp-operation "mrn:iam:policy:mcp-operation"
name: mcp-operation
description: "MCP operation phase policy"
dependencies:
- *lib-utils
rego: |
package authz

import rego.v1
import data.utils

# Tri-level: negative=DENY, 0=GRANT, positive=GRANT Override
# Default deny - only grant if authenticated or health check
default allow = -1

# Helper: check if this is a public health check
is_health_check if {
input.operation == "mcp:server:ping"
}

# Health check bypasses auth (grant-override)
allow = 1 if is_health_check

# Grant authenticated requests
allow = 0 if utils.has_principal

# Identity phase - check principal has MCP access
- mrn: &policy-mcp-user "mrn:iam:policy:mcp-user"
name: mcp-user
description: "Allow principals accessing MCP operations"
rego: |
package authz

import rego.v1

default allow = false

# Helper: check if this is an MCP operation
is_mcp_operation if {
startswith(input.operation, "mcp:")
}

# Allow MCP operations for authenticated users
# (Authentication was verified in operation phase)
allow if is_mcp_operation

# Resource phase - server-level access for discovery
- mrn: &policy-mcp-server-access "mrn:iam:policy:mcp-server-access"
name: mcp-server-access
description: "Check server-level access for MCP discovery operations"
dependencies:
- *lib-mcp-helpers
rego: |
package authz

import rego.v1
import data.mcp_helpers

default allow = false

# Allow if principal has access to this server
allow if {
server_id := mcp_helpers.extract_server(input.resource.id)
mcp_helpers.has_server_access(input.principal, server_id)
}

# Admin can access any server
allow if {
"mrn:iam:role:mcp-admin" in input.principal.mroles
}

# Resource phase - tool invocation access
- mrn: &policy-mcp-tool-access "mrn:iam:policy:mcp-tool-access"
name: mcp-tool-access
description: "Check tool-level access for MCP invocations"
dependencies:
- *lib-mcp-helpers
rego: |
package authz

import rego.v1
import data.mcp_helpers

default allow = false

# Check specific tool access
allow if {
input.operation == "mcp:tool:call"
server_id := mcp_helpers.extract_server(input.resource.id)
tool_name := mcp_helpers.extract_name(input.resource.id)
mcp_helpers.has_tool_access(input.principal, server_id, tool_name)
}

# Check resource access for resource operations
allow if {
input.operation in {"mcp:resource:read", "mcp:resource:subscribe"}
server_id := mcp_helpers.extract_server(input.resource.id)
mcp_helpers.has_server_access(input.principal, server_id)
}

# Prompt access follows server access for now
allow if {
input.operation == "mcp:prompt:get"
server_id := mcp_helpers.extract_server(input.resource.id)
mcp_helpers.has_server_access(input.principal, server_id)
}

# Admin can invoke any tool
allow if {
"mrn:iam:role:mcp-admin" in input.principal.mroles
}

# Resource phase - lifecycle operations
- mrn: &policy-mcp-lifecycle "mrn:iam:policy:mcp-lifecycle"
name: mcp-lifecycle
description: "Check access for MCP lifecycle operations"
dependencies:
- *lib-mcp-helpers
rego: |
package authz

import rego.v1
import data.mcp_helpers

default allow = false

# Allow initialize if principal has server access
allow if {
input.operation == "mcp:server:initialize"
server_id := mcp_helpers.extract_server(input.resource.id)
mcp_helpers.has_server_access(input.principal, server_id)
}

# Allow ping for any authenticated user (checked at operation phase)
allow if {
input.operation == "mcp:server:ping"
}

# Admin can perform any lifecycle operation
allow if {
"mrn:iam:role:mcp-admin" in input.principal.mroles
}

# ============================================================
# Roles
# ============================================================
roles:
# Basic MCP user - can access assigned servers and tools
- mrn: &role-mcp-user "mrn:iam:role:mcp-user"
name: mcp-user
description: "Basic MCP access - servers and tools controlled by annotations"
policy: *policy-mcp-user

# MCP admin - full access to all servers and tools
- mrn: &role-mcp-admin "mrn:iam:role:mcp-admin"
name: mcp-admin
description: "Full MCP administrative access"
policy: *policy-mcp-user

# Read-only MCP user - discovery only
- mrn: &role-mcp-viewer "mrn:iam:role:mcp-viewer"
name: mcp-viewer
description: "Read-only access to MCP servers"
policy: *policy-mcp-user

# ============================================================
# Groups - Organized by team/function
# ============================================================
groups:
# Developers get access to common development tools
- mrn: "mrn:iam:group:developers"
name: developers
description: "Development team with access to dev tools"
roles:
- *role-mcp-user
annotations:
- name: "mcp_servers"
value: '["mrn:mcp:server:github", "mrn:mcp:server:filesystem", "mrn:mcp:server:git"]'
merge: "union"
- name: "mcp_tools"
value: '["mrn:mcp:github:tool:*", "mrn:mcp:filesystem:tool:read_file", "mrn:mcp:filesystem:tool:list_directory", "mrn:mcp:git:tool:*"]'
merge: "union"

# Data analysts get database access
- mrn: "mrn:iam:group:data-analysts"
name: data-analysts
description: "Data team with database access"
roles:
- *role-mcp-user
annotations:
- name: "mcp_servers"
value: '["mrn:mcp:server:database", "mrn:mcp:server:analytics"]'
merge: "union"
- name: "mcp_tools"
value: '["mrn:mcp:database:tool:query", "mrn:mcp:analytics:tool:*"]'
merge: "union"

# Platform team gets full admin access
- mrn: "mrn:iam:group:platform"
name: platform
description: "Platform team with full MCP access"
roles:
- *role-mcp-admin
annotations:
- name: "mcp_servers"
value: '["mrn:mcp:server:*"]'
merge: "union"
- name: "mcp_tools"
value: '["mrn:mcp:*:tool:*"]'
merge: "union"

# Auditors get read-only access
- mrn: "mrn:iam:group:auditors"
name: auditors
description: "Auditors with read-only MCP access"
roles:
- *role-mcp-viewer
annotations:
- name: "mcp_servers"
value: '["mrn:mcp:server:github", "mrn:mcp:server:database"]'
merge: "union"
- name: "mcp_tools"
value: '[]'
merge: "union"

# ============================================================
# Resource Groups - Categorize MCP entities
# ============================================================
resource-groups:
- mrn: "mrn:iam:resource-group:mcp-tools"
name: mcp-tools
description: "MCP tool resources"
policy: *policy-mcp-tool-access

- mrn: "mrn:iam:resource-group:mcp-servers"
name: mcp-servers
description: "MCP server resources"
default: true
policy: *policy-mcp-server-access

- mrn: "mrn:iam:resource-group:mcp-lifecycle"
name: mcp-lifecycle
description: "MCP lifecycle operations"
policy: *policy-mcp-lifecycle

# ============================================================
# Resources - Route MCP entities to resource groups
# ============================================================
resources:
- name: mcp-tools
description: "Route tool resources"
selector:
- "mrn:mcp:.*:tool:.*"
group: "mrn:iam:resource-group:mcp-tools"

- name: mcp-prompts
description: "Route prompt resources"
selector:
- "mrn:mcp:.*:prompt:.*"
group: "mrn:iam:resource-group:mcp-tools"

- name: mcp-resources
description: "Route MCP resources"
selector:
- "mrn:mcp:.*:resource:.*"
group: "mrn:iam:resource-group:mcp-tools"

- name: mcp-lifecycle
description: "Route lifecycle operations"
selector:
- "mrn:mcp:.*:server"
group: "mrn:iam:resource-group:mcp-lifecycle"

# ============================================================
# Operations - Route MCP operations
# ============================================================
operations:
- name: mcp-discovery
selector:
- "mcp:tool:list"
- "mcp:prompt:list"
- "mcp:resource:list"
policy: *policy-mcp-operation

- name: mcp-invocation
selector:
- "mcp:tool:call"
- "mcp:prompt:get"
- "mcp:resource:read"
- "mcp:resource:subscribe"
- "mcp:resource:unsubscribe"
policy: *policy-mcp-operation

- name: mcp-lifecycle
selector:
- "mcp:server:.*"
policy: *policy-mcp-operation

Test Cases

Test 1: Developer Can List GitHub Tools

A developer can discover available tools on the GitHub server:

{
"principal": {
"sub": "dev@example.com",
"mroles": ["mrn:iam:role:mcp-user"],
"mgroups": ["mrn:iam:group:developers"],
"mannotations": {
"mcp_servers": ["mrn:mcp:server:github", "mrn:mcp:server:filesystem"],
"mcp_tools": ["mrn:mcp:github:tool:*", "mrn:mcp:filesystem:tool:read_file"]
}
},
"operation": "mcp:tool:list",
"resource": {
"id": "mrn:mcp:github:server",
"group": "mrn:iam:resource-group:mcp-servers"
}
}

Expected:

GRANT

Test 2: Developer Can Call GitHub Tool

A developer can invoke the create-issue tool:

{
"principal": {
"sub": "dev@example.com",
"mroles": ["mrn:iam:role:mcp-user"],
"mgroups": ["mrn:iam:group:developers"],
"mannotations": {
"mcp_servers": ["mrn:mcp:server:github"],
"mcp_tools": ["mrn:mcp:github:tool:*"]
}
},
"operation": "mcp:tool:call",
"resource": {
"id": "mrn:mcp:github:tool:create-issue",
"group": "mrn:iam:resource-group:mcp-tools"
}
}

Expected:

GRANT
(developer has wildcard tool access)

Test 3: Developer Cannot Access Database Tools

A developer cannot access database tools they're not assigned:

{
"principal": {
"sub": "dev@example.com",
"mroles": ["mrn:iam:role:mcp-user"],
"mgroups": ["mrn:iam:group:developers"],
"mannotations": {
"mcp_servers": ["mrn:mcp:server:github"],
"mcp_tools": ["mrn:mcp:github:tool:*"]
}
},
"operation": "mcp:tool:call",
"resource": {
"id": "mrn:mcp:database:tool:query",
"group": "mrn:iam:resource-group:mcp-tools"
}
}

Expected:

DENY
(developer lacks database server access)

Test 4: Data Analyst Can Query Database

A data analyst can use the database query tool:

{
"principal": {
"sub": "analyst@example.com",
"mroles": ["mrn:iam:role:mcp-user"],
"mgroups": ["mrn:iam:group:data-analysts"],
"mannotations": {
"mcp_servers": ["mrn:mcp:server:database", "mrn:mcp:server:analytics"],
"mcp_tools": ["mrn:mcp:database:tool:query", "mrn:mcp:analytics:tool:*"]
}
},
"operation": "mcp:tool:call",
"resource": {
"id": "mrn:mcp:database:tool:query",
"group": "mrn:iam:resource-group:mcp-tools"
}
}

Expected:

GRANT

Test 5: Analyst Cannot Delete Database Entries

An analyst has query access but not delete access:

{
"principal": {
"sub": "analyst@example.com",
"mroles": ["mrn:iam:role:mcp-user"],
"mgroups": ["mrn:iam:group:data-analysts"],
"mannotations": {
"mcp_servers": ["mrn:mcp:server:database"],
"mcp_tools": ["mrn:mcp:database:tool:query"]
}
},
"operation": "mcp:tool:call",
"resource": {
"id": "mrn:mcp:database:tool:delete",
"group": "mrn:iam:resource-group:mcp-tools"
}
}

Expected:

DENY
(analyst only has query tool access)

Test 6: Admin Can Access Any Tool

A platform admin can access any MCP tool:

{
"principal": {
"sub": "admin@example.com",
"mroles": ["mrn:iam:role:mcp-admin"],
"mgroups": ["mrn:iam:group:platform"],
"mannotations": {
"mcp_servers": ["mrn:mcp:server:*"],
"mcp_tools": ["mrn:mcp:*:tool:*"]
}
},
"operation": "mcp:tool:call",
"resource": {
"id": "mrn:mcp:secret-server:tool:dangerous-operation",
"group": "mrn:iam:resource-group:mcp-tools"
}
}

Expected:

GRANT
(admin role bypasses tool-level checks)

Test 7: Unauthenticated Ping Allowed

Health checks work without authentication:

{
"principal": {},
"operation": "mcp:server:ping",
"resource": {
"id": "mrn:mcp:github:server",
"group": "mrn:iam:resource-group:mcp-lifecycle"
}
}

Expected:

GRANT
(ping uses tri-level override)

Test 8: Unauthenticated Tool Call Denied

Tool calls require authentication:

{
"principal": {},
"operation": "mcp:tool:call",
"resource": {
"id": "mrn:mcp:github:tool:list-repos",
"group": "mrn:iam:resource-group:mcp-tools"
}
}

Expected:

DENY
(no authentication)

Key Concepts Demonstrated

1. Hierarchical Resource Naming

MCP entities follow a clear naming convention that embeds both the server and entity type:

mrn:mcp:<server>:<type>:<name>

This enables pattern-based routing and access checks.

2. Annotation-Based Entitlements with Union Merge

Rather than creating a role for every tool combination, we use annotations to specify which servers and tools a group can access. The merge: "union" strategy ensures that users in multiple groups accumulate access rather than having one group's settings replace another:

annotations:
- name: "mcp_servers"
value: '["mrn:mcp:server:github"]'
merge: "union"
- name: "mcp_tools"
value: '["mrn:mcp:github:tool:*"]'
merge: "union"

With union merging, a user who belongs to both developers and data-analysts groups would have access to servers and tools from both groups combined.

See Annotation Merge Strategies for more details on available merge options.

3. Wildcard Permissions

The policy supports wildcards like mrn:mcp:github:tool:* to grant access to all tools on a server, reducing configuration complexity.

4. Operation-Based Routing

Different MCP operations route to different policies:

  • Discovery operations check server-level access
  • Invocations check tool-level access
  • Lifecycle operations have special rules

5. Tri-Level Operation Policy

The operation phase uses tri-level output to allow unauthenticated health checks while requiring authentication for everything else.

Extending This Example

Adding Resource-Level Filtering

To implement filtered discovery (only show tools the user can access):

visible_tools[tool] if {
some tool in input.context.available_tools
server_id := mcp_helpers.extract_server(tool)
tool_name := mcp_helpers.extract_name(tool)
mcp_helpers.has_tool_access(input.principal, server_id, tool_name)
}

Adding Rate Limiting

Add rate limit annotations to control tool invocation frequency:

annotations:
- name: "mcp_rate_limits"
value: '{"mrn:mcp:database:tool:query": {"requests_per_minute": 60}}'

Adding Approval Workflows

For sensitive tools, require approval:

allow if {
input.operation == "mcp:tool:call"
is_sensitive_tool(input.resource.id)
input.context.approval_token != ""
verify_approval(input.context.approval_token)
}