Enforcing MCP tool annotation policies with Cedar

The MCP specification gives servers a vocabulary to declare what their tools actually do: whether they modify state, reach external systems, or make irreversible changes. We wrote about why that vocabulary matters, and this post is the practical follow-up. Stacklok’s Cedar authorization engine, part of Stacklok Enterprise and our open source ToolHive platform, enforces policy against those declarations in every request, letting your platform team govern tool access by behavior rather than by name.

Here’s what you’ll learn in this post:

  • What the four MCP tool annotation hints are and how ToolHive exposes them as Cedar resource attributes
  • Why the has operator is required for safe annotation-based policies
  • How to create an authorization policy ConfigMap and reference it from an MCPServer, MCPRemoteProxy, or VirtualMCPServer in Kubernetes
  • Three ready-to-use policy profiles you can apply today

What are tool annotations?

The MCP specification lets servers describe the behavioral properties of their tools using annotations. Four hints are available today:

AnnotationWhat true means
readOnlyHintThe tool only reads data; it does not modify anything
destructiveHintThe tool may perform irreversible or destructive operations
idempotentHintCalling the tool multiple times with the same arguments is safe
openWorldHintThe tool interacts with external systems outside the server’s domain

Annotations are hints, not contracts. A server can claim readOnlyHint: true and behave otherwise. We’ll address the trust angle at the end. First, here’s how to write policies that act on them.

How ToolHive exposes annotations

When a client calls a tool, ToolHive evaluates Cedar authorization policies before the request reaches the MCP server. All four annotation hints are available as boolean attributes on the Tool:: resource entity, but only when the MCP server explicitly sets them. ToolHive caches their values from the server’s tools/list response, so they’re ready at call time without an extra round-trip.

Annotations also come from the server, not from the client’s tools/call request. A malicious client can’t set readOnlyHint: true on a destructive tool to bypass annotation-based policies.

The has operator: non-optional

Not every MCP server annotates every tool. If your policy accesses an annotation attribute without first confirming it exists, Cedar throws an evaluation error, and ToolHive treats evaluation errors as denies.

Always guard annotation access with Cedar’s has operator:

// Safe: checks for the attribute before reading it
permit(
  principal,
  action == Action::"call_tool",
  resource
) when {
  resource has readOnlyHint && resource.readOnlyHint == true
};
// Unsafe: throws an evaluation error if readOnlyHint is absent
permit(
  principal,
  action == Action::"call_tool",
  resource
) when {
  resource.readOnlyHint == true
};

A missing annotation is not false; it simply doesn’t exist on the entity. The has check is the difference between a policy that evaluates correctly and one that silently denies every unannotated tool call.

Setting up annotation-based authorization in Kubernetes

You’ll need:

Authorization in the ToolHive Operator is a separate concern from authentication. You can add it to any existing MCPServer or MCPRemoteProxy by adding an authzConfig block to its spec.

Step 1: Create the authorization ConfigMap

Store your Cedar policies in a Kubernetes ConfigMap. This lets you reuse the same policy set across multiple MCPServer or MCPRemoteProxy resources, and update policies without redeploying servers.

Save the following to authz-configmap.yaml:

apiVersion: v1
kind: ConfigMap
metadata:
  name: annotation-authz
  namespace: toolhive-system
data:
  authz-config.json: |
    {
      "version": "1.0",
      "type": "cedarv1",
      "cedar": {
        "policies": [
          "permit(principal, action == Action::\"get_prompt\", resource);",
          "permit(principal, action == Action::\"read_resource\", resource);",
          "permit(principal, action == Action::\"call_tool\", resource) when { resource has readOnlyHint && resource.readOnlyHint == true };"
        ],
        "entities_json": "[]"
      }
    }

Apply it:

kubectl apply -f authz-configmap.yaml

This policy allows all authenticated clients to call any tool that explicitly declares readOnlyHint: true. Cedar denies everything else by default: nothing gets through unless a policy explicitly permits it.

Step 2: Reference the ConfigMap from an MCPServer or MCPRemoteProxy

Add the authzConfig block to your MCPServer spec. The field sits at the top level of spec, alongside your oidcConfigRef:

apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPServer
metadata:
  name: my-mcp-server
  namespace: toolhive-system
spec:
  image: ghcr.io/example/my-mcp-server:latest
  transport: streamable-http
  proxyPort: 8080
  permissionProfile:
    type: builtin
    name: network
  oidcConfigRef:
    name: my-oidc-config
    audience: my-mcp-server
  authzConfig:
    type: configMap
    configMap:
      name: annotation-authz
      key: authz-config.json
kubectl apply -f mcp-server-authz.yaml

MCPRemoteProxy uses the same top-level spec.authzConfig field. If you’re proxying a remote MCP server, add the identical block:

apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPRemoteProxy
metadata:
  name: my-remote-proxy
  namespace: toolhive-system
spec:
  remoteUrl: https://mcp.example.com
  transport: streamable-http
  proxyPort: 8080
  oidcConfigRef:
    name: my-oidc-config
    audience: my-remote-proxy
  authzConfig:
    type: configMap
    configMap:
      name: annotation-authz
      key: authz-config.json

Step 3: Reference the ConfigMap from a Virtual MCP Server gateway

For a VirtualMCPServer gateway, the authorization configuration is nested inside spec.incomingAuth rather than at the top level. Authorization is part of the incoming auth boundary that governs how the gateway handles client requests:

apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPServer
metadata:
  name: my-vmcp
  namespace: toolhive-system
spec:
  groupRef:
    name: my-group
  incomingAuth:
    type: oidc
    oidcConfigRef:
      name: my-oidc-config
      audience: my-vmcp
    authzConfig:
      type: configMap
      configMap:
        name: annotation-authz
        key: authz-config.json
kubectl apply -f vmcp-authz.yaml

All three resource types support both configMap and inline as the authzConfig.type. The inline type embeds Cedar policies directly in the manifest, which is useful for simple cases or when you want to keep everything in a single file:

# Inline example (MCPServer shown; same inline structure for MCPRemoteProxy
# and VirtualMCPServer spec.incomingAuth.authzConfig)
authzConfig:
  type: inline
  inline:
    policies:
      - |
        permit(principal, action == Action::"get_prompt", resource);
      - |
        permit(principal, action == Action::"read_resource", resource);
      - |
        permit(
          principal,
          action == Action::"call_tool",
          resource
        ) when {
          resource has readOnlyHint && resource.readOnlyHint == true
        };

For policies shared across multiple workloads, the ConfigMap approach is preferable: your policies live in one place, and updating them means touching a single resource rather than every server spec.

Step 4: Verify the policy is working

When a client calls a tool, ToolHive checks the cached annotations and evaluates your policies. Tools without readOnlyHint or with readOnlyHint: false return 403 Forbidden.

ToolHive also filters the tools/list response automatically: clients only see the tools they’re permitted to call. Under this configuration, unannotated tools won’t appear in the list at all.

To verify the operator picked up the new configuration:

# For MCPServer or MCPRemoteProxy
kubectl get mcpserver my-mcp-server -n toolhive-system
kubectl describe mcpserver my-mcp-server -n toolhive-system

# For VirtualMCPServer
kubectl get virtualmcpserver my-vmcp -n toolhive-system
kubectl describe virtualmcpserver my-vmcp -n toolhive-system

Policy profiles

These three profiles represent common authorization postures, from most restrictive to least restrictive. Create a ConfigMap for the one that fits your use case and reference it from your workloads.

Observe profile (read-only, no tool calls)

Allow prompts and resources, but block all tool calls:

apiVersion: v1
kind: ConfigMap
metadata:
  name: authz-observe
  namespace: toolhive-system
data:
  authz-config.json: |
    {
      "version": "1.0",
      "type": "cedarv1",
      "cedar": {
        "policies": [
          "permit(principal, action == Action::\"get_prompt\", resource);",
          "permit(principal, action == Action::\"read_resource\", resource);"
        ],
        "entities_json": "[]"
      }
    }

Useful for monitoring or auditing scenarios where clients need access to data and prompts without being able to execute tools. Because no call_tool policy exists, tools won’t appear in tools/list responses either.

Safe tools profile (annotation-gated tool calls)

Extend the observe profile to allow tool calls, but only for tools annotated as safe. This permits read-only tools and non-destructive closed-world tools, while blocking everything else:

apiVersion: v1
kind: ConfigMap
metadata:
  name: authz-safe-tools
  namespace: toolhive-system
data:
  authz-config.json: |
    {
      "version": "1.0",
      "type": "cedarv1",
      "cedar": {
        "policies": [
          "permit(principal, action == Action::\"get_prompt\", resource);",
          "permit(principal, action == Action::\"read_resource\", resource);",
          "permit(principal, action == Action::\"call_tool\", resource) when { resource has readOnlyHint && resource.readOnlyHint == true };",
          "permit(principal, action == Action::\"call_tool\", resource) when { resource has destructiveHint && resource.destructiveHint == false && resource has openWorldHint && resource.openWorldHint == false };"
        ],
        "entities_json": "[]"
      }
    }

Cedar denies tools without any annotations under this profile. Only tools that explicitly declare safe annotations get through. It’s a conservative default that forces servers to opt in to permissive access rather than inheriting it.

RBAC with annotation guardrails

Combine role-based access with annotation checks. Admins get full access to all tools; everyone else is restricted to tools annotated as read-only:

apiVersion: v1
kind: ConfigMap
metadata:
  name: authz-rbac-annotations
  namespace: toolhive-system
data:
  authz-config.json: |
    {
      "version": "1.0",
      "type": "cedarv1",
      "cedar": {
        "policies": [
          "permit(principal, action == Action::\"get_prompt\", resource);",
          "permit(principal, action == Action::\"read_resource\", resource);",
          "permit(principal, action == Action::\"call_tool\", resource) when { principal.claim_roles.contains(\"admin\") };",
          "permit(principal, action == Action::\"call_tool\", resource) when { resource has readOnlyHint && resource.readOnlyHint == true };"
        ],
        "entities_json": "[]"
      }
    }

This is a practical starting point for enterprise deployments: broad read access for all authenticated users, and unrestricted tool access for trusted administrators.

To apply any of these profiles across multiple servers, each server references the same ConfigMap:

authzConfig:
  type: configMap
  configMap:
    name: authz-rbac-annotations
    key: authz-config.json

When you need to update your policies, you change one ConfigMap rather than editing and reapplying every server manifest individually.

Beyond allowlists

The standard approach to tool authorization is an allowlist: explicitly name the tools each user or group can call. That works, but it doesn’t scale across large MCP deployments and requires you to know every tool name up front.

Annotation-based policies invert the model. Instead of naming every permitted tool, you describe the class of tools that are safe to run. Any tool meeting the behavioral criteria passes; Cedar blocks everything else. As your MCP server library grows, safe tools are automatically permitted without policy changes.

That’s the value of a structured metadata vocabulary combined with a policy engine that evaluates it at request time. Stacklok’s authorization layer enforces that metadata consistently across every MCPServer and MCPRemoteProxy in your cluster. As annotation coverage improves across the MCP ecosystem, the policies you write today will apply to tools that don’t exist yet.

A note on trust

Annotation-based enforcement is only as trustworthy as the server providing the annotations. A server sourced from an untrusted location could claim readOnlyHint: true and still write to disk.

Stacklok addresses this at the registry level. When we evaluate an MCP server for inclusion in the registry, we verify cryptographic provenance through Sigstore and GitHub Attestations. We’re extending that process to validate annotation accuracy against source code, turning the registry into a trust broker for annotations rather than just a source of packages. Organizations running servers from the Stacklok registry can treat annotation values as genuine policy inputs, not unverified hints.


Want to see what Stacklok can do for your organization? Book a demo or get started right away with ToolHive, our open source project. Join the conversation and engage directly with our team on Discord.

May 07, 2026

Last modified on May 05, 2026

How-To

Dan Barr

Dan Barr

Senior Technical Marketing Engineer

Dan Barr is a Senior Technical Marketing Engineer and the primary architect of Stacklok's top-notch docs, tutorials, how-to guides and more that help customers and users navigate all things MCP.

More by Dan Barr