This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

MCP

Dapr helps developers run secure, reliable, and durable Model Context Protocol (MCP) server integrations

Dapr supports MCP by using its service invocation API. Off-the-shelf Model Context Protocol (MCP) clients and agent frameworks (LangGraph, the official MCP SDK, custom HTTP clients) point at the local Dapr sidecar and reach MCP servers by App ID. Dapr governs the traffic with the same controls it applies to any other service-to-service call: App ID identity, access policies, HTTP middleware, mTLS, observability, and resiliency.

How it works

Both the agent and the MCP server run as Dapr apps, each with its own App ID. The MCP client directs requests to its local sidecar and sets the dapr-app-id header (or uses the full service-invocation URL). Dapr resolves the target by App ID, applies the policies attached to the MCP server’s App ID, and forwards the request.

For each call, Dapr can:

  • Route the request from the calling app to the target app by App ID.
  • Authenticate the caller’s workload identity using mTLS with SPIFFE-issued credentials. On by default.
  • Apply access control policies defined for the target MCP server’s App ID — coarse-grained App-ID gating, plus per-tool authorization via OPA.
  • Apply HTTP middleware on the inbound pipeline, such as OAuth 2.0 bearer validation.
  • Capture observability — logs, metrics, and traces for the call, sliced by caller and target App ID.

Off-the-shelf MCP clients work unchanged — there is no Dapr-specific MCP SDK to adopt for this path.

Get started

Security at a glance

LayerWhat it controlsReference
mTLS + SPIFFE identityEvery Dapr-to-Dapr call is mutually authenticated using identities Sentry issues and rotates automatically. On by default.Dapr mTLS
Configuration accessControlWhich caller App IDs may reach which MCP servers. Default-deny is supported.MCP access control
HTTP middleware (bearer / OAuth2)Inbound JWT validation on appHttpPipeline; outbound token acquisition on httpPipeline.Authenticating an MCP server
OPA per-tool policiesArgument- and tool-aware authorization that inspects the MCP JSON-RPC body.MCP access control

For the threat-model framing, default postures, and what stays your responsibility, see MCP security posture.

Give agents Dapr tools: the Dapr MCP server

The paths on this page connect agents to MCP servers you already run. The Dapr MCP server is a separate, standalone MCP server that runs next to a Dapr sidecar and exposes Dapr’s building blocks (state, pub/sub, bindings, secrets, service invocation, actors, lock, cryptography, and conversation) as MCP tools. Use it when you want an agent to call Dapr APIs directly from any MCP client. The Dapr MCP server quickstarts show it with LangGraph, Dapr Agents, and the OpenAI Agents SDK.

Alternative: the MCPServer resource (workflow-centric path)

There is a second way to use MCP with Dapr — the MCPServer resource. This path turns MCP integration into a deploy-time concern: you declare each MCP server as a YAML resource, and Dapr discovers tools, manages connections, and registers a built-in durable workflow per tool. Calling a tool becomes “start a workflow.”

In exchange, you face the following tradeoffs:

  • Requires the Dapr Workflow client. You must invoke MCP tools through the Dapr Workflow SDK, not through your existing MCP client.
  • Off-the-shelf MCP clients and agent frameworks do not work with this path. If you use LangGraph, the standard MCP Python SDK, or any other framework that speaks the MCP protocol natively, you cannot use these guardrails — you would need to call tools through the workflow SDK and forgo your framework’s MCP integration.
  • Scale considerations. Every tool call spawns a child workflow and writes to the workflow state store. If your agent is already a workflow (for example, a DurableAgent), every tool call multiplies into a child workflow.
  • Workflow-client-only today. Driving MCPServer-backed tool calls requires the Dapr Workflow client; off-the-shelf MCP clients cannot drive these flows in the current release.

Use the MCPServer resource when you specifically need:

  • Argument-level RBAC, audit, or redaction hooks on a per-tool basis (beforeCallTool / afterCallTool / beforeListTools / afterListTools).
  • Durable retries that survive a sidecar restart mid-call (backed by Dapr Workflows + Scheduler reminders).
  • Per-tool observability slicing — one workflow name per tool, so traces, metrics, and audit logs are sliced per-tool out of the box.
  • Your application already uses Dapr Workflows for the rest of its execution model.
  • You accept that off-the-shelf MCP clients and agent frameworks will not work for these calls.

See the MCPServer resource page for the full comparison with the service invocation path and a step-by-step guide.

1 - MCP through Dapr service invocation

Run MCP clients and servers as Dapr apps and govern the traffic between them with App ID identity, access policies, bearer middleware, mTLS, and observability

Dapr lets you run Model Context Protocol (MCP) clients and servers as Dapr apps and govern the traffic between them with the same controls you already use for any other service-to-service call: App ID identity, access policies, bearer middleware, mTLS, observability, and resiliency.

Because service invocation speaks plain HTTP, the agent’s existing MCP client can target the local Dapr sidecar and reach the MCP server by App ID. Off-the-shelf MCP clients and agent frameworks work unchanged — there is no Dapr-specific MCP SDK to adopt on this path.

Why service invocation?

The service invocation path reuses Dapr primitives you almost certainly already operate, so MCP traffic gets enterprise controls without a new programming model:

  • Zero MCP SDK lock-in. Any MCP client or framework (LangGraph, the official MCP SDK, custom JSON-RPC HTTP clients) drives MCP servers through the sidecar unchanged. Adopting Dapr is a deployment-time change, not a code change.
  • App ID identity with mTLS by default. Every Dapr-to-Dapr call is mutually authenticated using SPIFFE identities issued and rotated by Sentry. The MCP server sees the caller’s verified App ID; you don’t need to bolt on a separate identity layer.
  • Coarse-grained App-ID access control. A Configuration accessControl attached to the MCP server’s App ID gates which agent App IDs may reach it, with deny as the default action so untrusted callers cannot reach an MCP server by accident.
  • Per-tool authorization via OPA. When App-ID gating isn’t fine-grained enough, an OPA middleware on the MCP server’s inbound pipeline inspects the JSON-RPC body, extracts the tool name (and arguments, if needed), and applies a Rego policy keyed by (caller App ID, tool name). This brings per-tool authz to off-the-shelf MCP clients without an SDK change.
  • Declarative OAuth 2.0 / bearer auth. A bearer middleware on the inbound pipeline validates JWTs against the issuer’s JWKS, iss, and aud claims. Outbound, a separate middleware acquires tokens for upstream MCP servers. All declarative, no code in the MCP server.
  • Built-in observability. Service invocation generates traces, metrics, and logs sliced by caller and target App ID — the same telemetry you already use for non-MCP traffic.
  • Resiliency policies. Retries, timeouts, and circuit breakers attach to the MCP server’s App ID via a Resiliency resource. MCP calls inherit Dapr’s resiliency primitives the same way other service-invocation calls do.
Without Dapr service invocationWith Dapr service invocation
Each agent embeds an MCP client and a separate identity / authz layerOne identity stack for all service traffic, MCP included
Per-server bearer-token plumbing in the applicationDeclarative OAuth 2.0 / bearer middleware
Per-tool RBAC requires forking the MCP clientOPA reads the JSON-RPC body and applies per-tool policy
Observability bolted onto MCP traffic separatelySame traces / metrics / logs as the rest of the system

How it works

Both the agent and the MCP server run as Dapr apps, each with its own App ID. The MCP client directs requests to its local sidecar and sets the dapr-app-id header (or uses the full service-invocation URL). Dapr resolves the target by App ID, applies the policies attached to the MCP server’s App ID, and forwards the request.

flowchart LR
  CLIENT(Agent / MCP client)
  subgraph Dapr
    CID(mcp-client App ID)
    POLICY{Access policy}:::decision
    BEARER{Bearer middleware}:::decision
    SID(mcp-server App ID)
  end
  SERVER(MCP server)

  CLIENT-->CID
  CID-->POLICY
  POLICY-- allow -->BEARER
  POLICY-. deny .->CID
  BEARER-- valid JWT -->SID
  BEARER-. 401 .->CID
  SID-->SERVER

  classDef decision stroke:#ed8936

For each call, Dapr can:

  • Route the request from the calling app to the target app by App ID.
  • Authenticate the caller’s workload identity (mTLS with SPIFFE-issued credentials).
  • Apply access control policies defined for the target MCP server’s App ID.
  • Apply HTTP middleware on the inbound pipeline, such as OAuth 2.0 bearer validation.
  • Capture logs, metrics, and traces for the call.

These features apply to MCP calls just like any other service-to-service call, with no changes to MCP client or server code.

Quickstart

Step 1: Run an MCP server as a Dapr app

A minimal MCP server using the Python mcp library:

# server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("my-mcp-server")

@mcp.tool()
def get_inventory(product_id: str) -> dict:
    """Look up inventory for a product."""
    return {"product_id": product_id, "stock": 42}

if __name__ == "__main__":
    mcp.run(transport="streamable-http")

Run it as a Dapr app:

dapr run \
  --app-id mcp-server \
  --app-port 8000 \
  -- python server.py

Step 2: Connect the agent (MCP client) through the Dapr sidecar

The agent’s MCP client targets its local Dapr sidecar’s service-invocation endpoint:

# agent.py
import os
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

DAPR_HTTP_ENDPOINT = os.getenv("DAPR_HTTP_ENDPOINT", "http://localhost:3500")
MCP_URL = f"{DAPR_HTTP_ENDPOINT}/v1.0/invoke/mcp-server/method/mcp"

async def main():
    async with streamablehttp_client(url=MCP_URL) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            print("Available tools:", tools)

Run the agent as its own Dapr app:

dapr run \
  --app-id my-agent \
  -- python agent.py

Alternative: set the dapr-app-id header on the MCP client transport instead of using the explicit /v1.0/invoke/... URL. Both forms work — see the service invocation overview.

Because both apps run on the same Dapr control plane, service invocation routes my-agent’s requests to mcp-server by App ID. No additional networking configuration is required.

Apply security controls

MCP tool calls flow through Dapr’s service invocation layer, so you can layer two independent security mechanisms:

  • OAuth 2.0 authentication — a bearer middleware on the MCP server validates inbound JWTs against the issuer’s JWKS, iss, and aud claims. Requests without a valid token are rejected with 401 Unauthorized before reaching MCP server code. See Authenticating an MCP server.
  • Access policies (ACLs) — a Configuration resource attached to the MCP server’s App ID defines which agent App IDs may invoke it, with a deny-by-default posture. See MCP access control.

These mechanisms can be used independently or layered together for defense in depth. mTLS using SPIFFE-issued workload identity is on by default for all Dapr-to-Dapr traffic — see Dapr mTLS.

For the full threat-model framing and what the platform does versus what stays your responsibility, see MCP security posture.

When to use this path vs the MCPServer resource

This path is the right fit when:

  • You use an off-the-shelf MCP client or agent framework (LangGraph, the official MCP SDK, etc.) and want to keep that integration unchanged.
  • App-ID-level access control and HTTP-pipeline middleware are enough — you don’t need per-argument RBAC or hooks that observe the tool result body.
  • You don’t already use Dapr Workflows, or you don’t want to introduce them just to call MCP tools.

Use the MCPServer resource instead when:

  • You need argument-level RBAC, audit, redaction, or response filtering on a per-tool basis (the beforeCallTool / afterCallTool / beforeListTools / afterListTools hooks).
  • You need durable retries that survive a sidecar restart mid-call.
  • You want per-tool observability slicing (one workflow name per tool).

The two paths are not exclusive — you can use service invocation for most MCP traffic and switch a specific server to the MCPServer resource when its policy needs become argument-aware.

2 - Authenticating an MCP server

How to enable MCP client-side and server-side authentication

Overview

The MCP specification does not mandate any form of authentication between an MCP client and server. The security model is left to the user to plan and implement. This creates a maintenance burden on developers and opens up MCP servers to various attack surfaces.

While MCP servers lack identity, OAuth2 is a well established standard that can be used to properly authenticate MCP clients to MCP servers.

OAuth2 becomes essential when MCP servers are:

  • Multi-tenant
  • Remote
  • Cloud-hosted
  • Connected to confidential systems
  • Performing privileged actions on behalf of a user
  • Exposing tools that must be permission-gated

Dapr enables OAuth2 authentication between MCP clients and servers using middleware components.

Types of authentication

Dapr supports two critical authentication mechanisms for production grade deployments of MCP servers - Client-side and Server-side.

Client-side Authentication

The client initiates OAuth2 to obtain an access token and includes it when connecting to the MCP server. This proves the user’s identity and permissions and is required for remote, sensitive, or multi-tenant MCP servers. It ensures the server can trust who is calling and what scopes the client is allowed to use.

Server-side Authentication

The server validates the client’s token or, if missing or insufficient, triggers an OAuth2 login or scope upgrade. This is needed for cloud-hosted or shared MCP servers, tenant-aware systems, and integrations that require user-specific authorization. It enforces access control, isolates users, and protects privileged tools and data.

How to enable Client-side Authentication

Define the MCP Server as an HTTPEndpoint

Dapr allows developers and operators to model remote HTTP services as resources that can be governed and invoked using the Dapr Service Invocation API. Create this HTTPEndpoint resource to represent the MCP server:

apiVersion: dapr.io/v1alpha1
kind: HTTPEndpoint
metadata:
  name: "mcp-server"
spec:
  baseUrl: https://my-mcp-server:443
  headers:
  - name: "Accept"
    value: "text/event-stream"

Define the OAuth2 middleware and configuration components

The following middleware component defines the connection to the OAuth2 provider:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: oauth2
spec:
  type: middleware.http.oauth2
  version: v1
  metadata:
  - name: clientId
    value: "<client-id>"
  - name: clientSecret
    value: "<client-secret>"
  - name: authURL
    value: "<authorization-url>"
  - name: tokenURL
    value: "<token-url>"
  - name: scopes
    value: "<comma-separated scopes>"

Next, create the configuration resource which tells Dapr to use the OAuth2 middleware:

piVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: auth
spec:
  tracing:
    samplingRate: "1"
  httpPipeline:
    handlers:
    - name: oauth2 # reference the oauth component here
      type: middleware.http.oauth2    

Call the MCP server using an MCP client

Copy the following code to a file named mcpclient.py:

import asyncio
from mcp import ClientSession
from mcp.transport.http import HttpClientTransport

async def main():
    # Default address of the Dapr process. Use an environment variable in production
    server_url = "http://localhost:3500/"

    # Create an HTTP/SSE transport with a header to target our HTTPEndpoint defined above
    transport = HttpClientTransport(
        url=server_url,
        headers={
          "dapr-app-id": "mcp-server",
        }
        event_headers={
            "Accept": "text/event-stream",
        },
    )

    # Create an MCP session bound to the transport
    async with ClientSession(transport) as session:
        await session.initialize()

        tools = await session.call("tools/list")
        print("Server Tools:", tools))

        await session.shutdown()

if __name__ == "__main__":
    asyncio.run(main())

Run the MCP client with Dapr

Put the YAML files above into a components directory and run Dapr:

dapr run --app-id mcpclient --resources-path ./components --dapr-http-port 3500 --config ./config.yaml -- python mcpclient.py

The MCP client causes Dapr to start an OAuth2 pipeline before connecting to the MCP server.

How to enable Server-side Authentication

Define the OAuth2 middleware and configuration components

Define a middleware component the same as the client example.

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: oauth2
spec:
  type: middleware.http.oauth2
  version: v1
  metadata:
  - name: clientId
    value: "<client-id>"
  - name: clientSecret
    value: "<client-secret>"
  - name: authURL
    value: "<authorization-url>"
  - name: tokenURL
    value: "<token-url>"
  - name: scopes
    value: "<comma-separated scopes>"

Next, create the configuration component, with the modification of an appHttpPipeline field. This tells Dapr to apply the middleware for incoming calls.

piVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: auth
spec:
  tracing:
    samplingRate: "1"
  appHttpPipeline:
    handlers:
    - name: oauth2 # reference the oauth component here
      type: middleware.http.oauth2    

Run the MCP server with Dapr

Put the YAML files above in components directory and run Dapr:

dapr run --app-id mcpclient --resources-path ./components --dapr-http-port 3500 --config ./config.yaml -- python mcpserver.py

Dapr will start an OAuth2 pipeline when a request for the MCP server arrives.

Alternative: inbound JWT validation with bearer middleware

To require that every inbound request to the MCP server carries a valid OAuth 2.0 token — without driving an OAuth2 flow on the server side — attach middleware.http.bearer to the MCP server’s appHttpPipeline. The middleware validates the token’s signature, issuer, and audience against a JWKS endpoint and rejects requests with missing or invalid tokens (401 Unauthorized) before reaching server code.

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: bearer-validator
spec:
  type: middleware.http.bearer
  version: v1
  metadata:
  - name: jwksURL
    value: "https://auth.example.com/.well-known/jwks.json"
  - name: audience
    value: "mcp-server"
  - name: issuer
    value: "https://auth.example.com"
apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: bearer-server
spec:
  appHttpPipeline:
    handlers:
    - name: bearer-validator
      type: middleware.http.bearer

Combine bearer validation with App-ID-keyed access control for defense in depth: accessControl decides which callers may reach the server; bearer validation insists they present a live, signed token.

See also

3 - MCP access control

Define per-agent access control policies for MCP servers using Configuration accessControl rules

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

For the full accessControl schema and HTTP-verb-level controls, see Service invocation access control. This page applies that mechanism specifically to MCP traffic, with the patterns and trade-offs that matter for agents.

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 destructive ones. Without explicit policies, any agent in your namespace could call any MCP server — a serious attack surface.

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

Two layers: App-ID gating and per-tool authorization

Dapr access control evaluates caller App ID → target App ID at the service-invocation boundary. It is the same mechanism Dapr uses for any other service-to-service traffic, and it gives you coarse-grained gating: which agents may reach which MCP servers at all.

MCP transports — streamable-http and sse — route all tool calls through a single HTTP endpoint. The tool name lives inside the JSON-RPC body (params.name), not in the URL path, so HTTP-path-based ACL rules don’t give you per-tool granularity on their own. For finer-grained authorization, layer an OPA middleware on the MCP server’s inbound pipeline — it reads the JSON-RPC body, extracts the tool name, and applies a Rego policy keyed by (caller App ID, tool name).

For workflow-centric, argument-level RBAC inside a single server, see the MCPServer resource middleware hooks.

How it works

When an MCP client invokes a tool, the request travels through Dapr’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, Dapr 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 the sidecar through --config.

Defining a policy

The simplest pattern uses Configuration accessControl with a default action and per-caller overrides:

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

Apply the Configuration and attach it to the MCP server’s App ID when starting Dapr:

dapr run \
  --app-id mcp-server \
  --app-port 8000 \
  --resources-path ./components \
  --config ./config/mcp-server-policy.yaml \
  -- python server.py

On Kubernetes, set the configuration on the pod by annotating it with dapr.io/config: mcp-server-policy.

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 traffic within a single Dapr namespace.
policies[].appIdThe Dapr App ID of the calling agent.
policies[].defaultActionallow or deny for this caller.
policies[].namespaceThe Dapr namespace the caller runs in (typically "default").

ACL changes take effect after the target Dapr sidecar reloads the configuration — restart the sidecar to 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"

Attach it to the MCP server’s sidecar and verify that no caller can reach it. Then layer in allow rules by extending the same Configuration and re-applying it.

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"

analyst-agent can invoke this MCP server; all other callers are denied at the service-invocation boundary.

Per-tool authorization with OPA

App-ID gating is coarse — it controls whether an agent may reach an MCP server at all, but every tool on that server is equally reachable. For finer-grained (caller App ID, tool name) authorization, layer an Open Policy Agent (OPA) middleware onto the MCP server’s inbound HTTP pipeline. The OPA middleware reads the JSON-RPC request body, your Rego policy extracts method and params.name, and the decision is keyed by the caller’s App ID (propagated by Dapr as the dapr-caller-app-id header).

How OPA gates per-tool MCP traffic

flowchart LR
  AGENT(Agent / MCP client)
  subgraph DAPR[Dapr sidecar - MCP server side]
    ACL{accessControl<br/>App-ID gate}:::decision
    OPA{OPA middleware<br/>tool-level gate}:::decision
  end
  SERVER(MCP server)

  AGENT -- POST /method/mcp<br/>+ dapr-caller-app-id --> ACL
  ACL -- allow --> OPA
  ACL -. 403 .-> AGENT
  OPA -- allow --> SERVER
  OPA -. 403 .-> AGENT

  classDef decision stroke:#ed8936

The two layers compose:

  1. accessControl rejects unauthenticated or disallowed App IDs before any middleware runs.
  2. OPA inspects the JSON-RPC body of the allowed request and applies tool-level rules.

Enable the OPA middleware

OPA’s HTTP middleware ships with Dapr. To inspect the JSON-RPC body, set readBody: "true" and pass the caller App ID through includedHeaders:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: mcp-tool-authz
spec:
  type: middleware.http.opa
  version: v1
  metadata:
    - name: includedHeaders
      value: "dapr-caller-app-id"
    - name: readBody
      value: "true"
    - name: defaultStatus
      value: "403"
    - name: rego
      value: |
        package http

        default allow = false

        # Per-tool authorization for MCP JSON-RPC traffic.
        #
        # `input.request.body` is the raw JSON-RPC payload, e.g.
        #   {"jsonrpc":"2.0","id":1,"method":"tools/call",
        #    "params":{"name":"get_inventory","arguments":{...}}}
        #
        # `input.request.headers["dapr-caller-app-id"]` is the verified caller App ID.
        body := json.unmarshal(input.request.body)
        caller := input.request.headers["dapr-caller-app-id"]

        # Allow MCP handshake / discovery for any allowed caller.
        allow {
          body.method == "initialize"
        }
        allow {
          body.method == "tools/list"
        }

        # Per-tool RBAC on tools/call.
        allow {
          body.method == "tools/call"
          allowed_tools[caller][_] == body.params.name
        }

        # (caller App ID → permitted tool names) policy.
        allowed_tools := {
          "analyst-agent": ["get_inventory", "get_schema"],
          "ops-agent":     ["get_inventory", "get_schema", "update_stock"],
          "admin-agent":   ["get_inventory", "get_schema", "update_stock", "drop_table"],
        }

Attach the middleware to the MCP server’s app HTTP pipeline:

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: mcp-server-policy
spec:
  appHttpPipeline:
    handlers:
      - name: mcp-tool-authz
        type: middleware.http.opa

Restart the MCP server’s sidecar with the updated Configuration. Requests for tools not on the caller’s allow-list now return 403 before the JSON-RPC body reaches the MCP server.

Notes and trade-offs

  • Body shape matters. The Rego policy assumes standard JSON-RPC over streamable-http. Validate the shape your MCP server expects (especially batched requests, which arrive as a JSON array) and adapt the policy.
  • readBody: "true" buffers each request fully in memory. For very large tool argument payloads, factor this into capacity planning.
  • Defense in depth, not a replacement. Keep the App-ID accessControl policy in place — OPA’s job is the tool-level refinement, not the server-level perimeter.
  • Workflow-centric alternative. If you want argument-level RBAC, audit, redaction, or response filtering inside one MCP server and you’re willing to invoke tools through the Dapr Workflow client, use the MCPServer resource middleware hooks instead.

Combining ACLs with OAuth 2.0 bearer middleware

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 Dapr’s service-invocation layer using SPIFFE identity).
  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 Authenticating an MCP server for bearer middleware setup.

Troubleshooting

My agent gets 403 even though I added a policy for its App ID. Verify the App ID in the policy exactly matches the --app-id the agent was started with (case-sensitive). Make sure the MCP server’s sidecar has been restarted to pick up the new configuration. Confirm the namespace field matches the namespace the calling Dapr app runs in.

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 with accessControl to the MCP server. Without one, Dapr allows calls from any App ID in the trust domain.

See also

4 - MCP security and trust posture

How Dapr enforces agent identity, authorization, and auditability across agents and MCP servers, and what stays your responsibility

Running agents in production raises three questions Dapr is built to answer:

  • Who is this agent? Can a downstream service prove that a request really came from a specific agent, and not from impersonated or hijacked credentials?
  • What may this agent do? Are there enforceable limits on which MCP servers the agent can call and which data it can read or modify — limits that the LLM cannot reason its way around?
  • What has this agent done? When something goes wrong, can the platform produce a record of what happened, by which identity, in what order?

Dapr answers each of these at the infrastructure layer, so the answers stay consistent regardless of which agent framework, language, or LLM you use, and without requiring changes to MCP client or server code.

How Dapr answers the three questions

QuestionDapr control
Who is this agent?Every Dapr workload — agent App IDs and any MCP server you run as a Dapr app — receives a SPIFFE-based cryptographic identity that Dapr’s Sentry component issues, attests, and rotates automatically. All service-to-service traffic is mTLS-secured using these identities. No static API keys or shared service tokens are required between Dapr apps.
What may this agent do?A Configuration resource with accessControl rules attached to each App ID decides which callers may reach it. Defaults can be set to deny, so an MCP server is unreachable until a calling App ID is explicitly allow-listed. A bearer middleware layered on the MCP server’s appHttpPipeline adds JWT validation on top — the LLM cannot reason its way around either control.
What has this agent done?Every service-invocation call — MCP calls included — flows through Dapr’s data plane and is captured in logs, metrics, and distributed traces. Standard OpenTelemetry exporters ship the data to your SIEM, log warehouse, or tracing backend.

Default postures

Dapr’s defaults favor refusal over permissiveness. None of the below requires you to “turn on a security mode” — they are how the platform behaves out of the box.

  • No identity is implicit. An MCP server reached through Dapr service invocation is mTLS-authenticated using the caller’s SPIFFE identity. There is no anonymous service-invocation path.
  • Access policies are declarative and explicit. An accessControl block attached to an MCP server’s App ID with defaultAction: deny makes the server unreachable until callers are explicitly allow-listed. See MCP access control.
  • Secrets are never exposed to agent code. Credentials referenced by middleware components (issuer URLs, audiences, signing keys, OAuth client secrets) are stored in your project’s secret store and resolved at request time. The agent receives tool results, not credentials.
  • mTLS is on everywhere. Sentry issues short-lived SVIDs to every workload and rotates them automatically. You don’t configure it per-resource.

Threat model

The failure modes below account for most of the security risk when agents operate in production. Dapr’s controls map directly to each.

Failure modeWhat it looks likeDapr control
Privilege escalationA sub-agent inherits unscoped credentials and acts beyond its principal’s authority.Each agent’s App ID has its own SPIFFE identity and its own accessControl configuration. Authority does not propagate by inheritance; every hop is independently authorized.
Unauthorized tool useAn agent or unknown caller tries to reach an MCP server it isn’t entitled to use.Configuration accessControl rules attached to the MCP server’s App ID enforce per-caller allow/deny at the service-invocation boundary. Denied calls are rejected by Dapr before they reach the MCP server process.
JailbreakingA prompt persuades the LLM to attempt an unauthorized action.The LLM’s decision happens before the platform; Dapr’s authorization checks run after. A jailbroken LLM that tries to reach a forbidden MCP server still hits a deny from accessControl (or a 401 from bearer middleware) before any code on the MCP server runs.
“Agent who?”A downstream service cannot confirm which agent originated a call.SPIFFE workload identity is verified at every hop. The MCP server (if it runs as a Dapr app) or any downstream service the MCP server calls can read the caller’s identity from the mTLS connection or from claims in the validated JWT.
Secret sprawlAPI keys appear in logs, prompts, or downstream agent calls.Credentials used by bearer or OAuth2 middleware are resolved from the secret store at request time and never visible to agent code. SPIFFE SVIDs are short-lived and rotated by Sentry automatically.
No provenanceNo verifiable record of who did what.Every service-invocation call is recorded by Dapr’s observability pipeline — logs, metrics, traces — and shipped to your sinks via OpenTelemetry.

What stays your responsibility

Dapr draws the trust boundary at the platform’s surface. Some risks live outside it.

  • Prompt injection and LLM-layer attacks. Dapr enforces authorization at the service-invocation boundary regardless of what the LLM does, but it does not inspect prompt content. Defense against prompt injection — content filters, allow-listing, output validation — belongs in your agent’s pre-LLM and post-LLM layers.
  • The security of the MCP server itself. When you connect to a third-party MCP server (GitHub, Stripe, an internal tool), Dapr secures the connection, not the server. Vet third-party MCP servers as you would any other dependency.
  • Audit sink durability and integrity. Dapr emits observability data to your sinks; the long-term durability and tamper resistance of those records is governed by the sink you write to (your SIEM, log warehouse, immutable bucket). Choose a sink whose retention and integrity guarantees match your compliance obligations.
  • Tool-level granularity at the service-invocation layer. accessControl today is keyed by caller App ID and target App ID. If a single MCP server exposes both low-risk and high-risk tools and you need to grant access to some but not others, either split the tools across separate MCP servers (one App ID per server) so the policy boundary matches the trust boundary, or use the MCPServer resource middleware hooks for argument-level RBAC.

Identity model in one paragraph

Every Dapr workload — agent App IDs and the MCP server itself if it runs as a Dapr app — receives a SPIFFE-based cryptographic identity that Sentry issues and rotates automatically. mTLS between workloads uses these identities. When an agent invokes an MCP server through Dapr, the caller’s SPIFFE identity is bound to the request; the MCP server’s Configuration accessControl rules decide whether to allow it.

Defense in depth

The strongest production deployments layer multiple controls so that defeating one does not grant access:

  1. mTLS with SPIFFE identity — every call between Dapr workloads is mutually authenticated by default.
  2. Configuration accessControl — App-ID-keyed allow/deny on the service-invocation boundary. Default-deny means new callers can’t reach the MCP server until they’re listed.
  3. Bearer middleware on appHttpPipeline — independent JWT validation against the issuer’s JWKS, iss, and aud claims. An attacker would need to forge or steal a valid App ID and obtain a valid signed token.
  4. (Optional) MCPServer resource middleware hooks — argument-level RBAC, redaction, and audit running as durable workflows. Useful when policy depends on the contents of a tool call, not just the caller.

See MCP access control for layering ACL + bearer, and MCPServer resource for the workflow-hook layer.

Next steps

5 - MCPServer resource

Declare MCP server connections as first-class Dapr resources for durable tool execution

Overview

The MCPServer resource lets you declare MCP (Model Context Protocol) server connections as first-class Dapr resources. When daprd loads an MCPServer, it discovers the server’s tools and registers a built-in durable workflow orchestration per tool. Calling a tool then becomes “start a workflow” — and Dapr handles the connection, retries, credentials, observability, and crash recovery for you. Your application never imports an MCP SDK or holds a long-lived MCP connection.

Choosing between MCPServer and the service invocation path

Dapr offers two integration paths for MCP. The service invocation path is the default; MCPServer is the workflow-centric path. Use this table to decide which fits your needs.

If you…Use
Use an off-the-shelf MCP client or framework (LangGraph, the official MCP SDK, etc.) and want unchanged client codeService invocation path
Want the simplest setup that works with any frameworkService invocation path
Need argument-level RBAC, audit, or redaction hooks on a per-tool basisMCPServer resource (this page)
Need durable retries that survive a sidecar restart mid-callMCPServer resource (this page)
Want per-tool observability slicing (one workflow per tool)MCPServer resource (this page)

The two paths are not exclusive — most MCP traffic can flow through service invocation, with specific servers switched to the MCPServer resource when their policy needs become argument-aware or when you want durable MCP interactions.

Why MCPServer?

MCPServer turns MCP integration into a deploy-time concern instead of an application-code concern. The benefits compound across the system:

  • Zero MCP SDK in your app. Your application starts a Dapr workflow by name. Dapr speaks MCP to the server. Swap MCP servers, change transports, or rotate credentials without touching application code.
  • Per-tool RBAC, audit, and redaction in YAML. Order-preserving beforeCallTool / afterCallTool / beforeListTools / afterListTools hooks run argument-level authorization, rate limiting, PII redaction, audit logging, and response filtering as Dapr workflows. Set appID on a hook to route it to a centralized policy app, so one shared RBAC service governs every agent without each app embedding the policy.
  • Durable execution. Tool calls run as workflow activities backed by Dapr Scheduler reminders. If daprd is restarted mid-call, the scheduler re-delivers the activity to the new instance and the call completes — agents don’t have to implement their own retry/resume logic. Inside a single activity, transient connection drops are absorbed automatically: Dapr keeps one warm session per MCPServer (with keep-alive pings) and reconnects once on ErrConnectionClosed before the workflow ever sees the blip.
  • Fast feedback for callers. Required-field validation runs against the cached JSON Schema before the MCP server is contacted. Missing arguments come back as a structured mcp.CallToolResult{isError: true} immediately — agents and LLMs get an actionable error without burning a network round-trip.
  • Per-tool observability. Each tool gets its own workflow name (dapr.internal.mcp.<server>.CallTool.<tool>), so traces, metrics, and audit logs are sliced per-tool out of the box. You see exactly which tool was called, by whom, with what arguments, and what came back.
  • Declarative authentication. OAuth2 client credentials, SPIFFE workload identity, and static-header auth are all configured in YAML. Dapr fetches and refreshes tokens, caches per-MCPServer HTTP clients, and never exposes raw credentials to your app.
  • Scoping and multi-tenancy. MCPServers are namespaced and scopes-restricted, just like other Dapr resources. One MCP server can be shared across many apps with different access policies.
  • Hot reload. Add, remove, or modify MCPServer resources at runtime — Dapr reloads them without a sidecar restart.
Without MCPServerWith MCPServer
Application manages MCP connections, retries, and credentialsDeclare YAML, Dapr handles the rest
Sidecar crash mid-call = lost callScheduler reminder re-delivers the activity, workflow resumes
Per-tool tracing/metrics requires custom instrumentationOne workflow per tool — built-in observability slicing
Each app hardcodes its own MCP connection logicSingle resource, shared across apps via scopes
Tool-call RBAC and audit logic embedded in agent codeDeclared per MCPServer in YAML, enforced as durable workflows, centralizable via appID

How it works

For each loaded MCPServer named <server>, daprd:

  1. Connects to the MCP server using the configured transport (streamable HTTP, SSE, or stdio).
  2. Discovers the tools the server exposes (one MCP tools/list round-trip).
  3. Registers durable workflow orchestrations:
    • dapr.internal.mcp.<server>.ListTools — returns the cached tool list.
    • dapr.internal.mcp.<server>.CallTool.<tool> — one workflow per discovered tool. Each invokes the tool durably as an activity, with optional middleware hooks before/after.

Callers start these workflows through the standard Dapr Workflow API. Dapr Workflows takes care of scheduling, retries on transient failures, and resuming after sidecar restarts.

You don’t need to enable workflows separately — loading an MCPServer is sufficient. Dapr’s workflow engine activates as soon as any MCPServer resource is present, even if no SDK workflow client ever connects.

Calling a tool

Start a CallTool.<tool> workflow with just the arguments — the tool name is encoded in the workflow name itself:

POST /v1.0-beta1/workflows/dapr/dapr.internal.mcp.<server>.CallTool.<tool>/start
Content-Type: application/json

{
  "arguments": {"city": "Seattle"}
}

Poll for the result with GET /v1.0-beta1/workflows/dapr/<instanceID>. The workflow output is an MCP CallToolResult — byte-for-byte the same shape as the MCP wire spec. Each entry in content is a flat tagged union (type discriminator + per-variant fields):

{
  "isError": false,
  "content": [
    {"type": "text", "text": "Weather in Seattle: sunny, 72°F"}
  ]
}

Other content shapes are similarly flat: {"type": "image", "data": "<base64>", "mimeType": "image/png"} (likewise for audio); resource references use {"type": "resource_link", "uri": "...", "name": "...", "mimeType": "...", "description": "..."} or {"type": "resource", "resource": {"uri": "...", "mimeType": "...", "text": "..." | "blob": "<base64>"}}.

If the tool call fails at the MCP level (unknown tool, validation failure, server-side auth error), isError is true and the failure is described in content — the workflow itself completes successfully so the calling agent or LLM receives a structured error it can act on (retry, pick a different tool, or surface to the user).

If daprd restarts while the tool call is in flight, Dapr Scheduler re-delivers the pending activity to the new daprd instance and the workflow resumes — no application-side retry logic required.

Listing tools

POST /v1.0-beta1/workflows/dapr/dapr.internal.mcp.<server>.ListTools/start
Content-Type: application/json

{}

Output:

{
  "tools": [
    {
      "name": "get_weather",
      "description": "Get current weather for a city",
      "inputSchema": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"]
      }
    }
  ]
}

Tool definitions are cached at MCPServer load time and refreshed on hot-reload. Subsequent ListTools workflow calls return instantly from the cache — no upstream tools/list round-trip — so agents that call ListTools repeatedly pay zero MCP-server latency after the initial load.

Transports

MCPServer supports three wire transports. Exactly one must be configured under spec.endpoint.

Streamable HTTP

The recommended transport for production use.

apiVersion: dapr.io/v1alpha1
kind: MCPServer
metadata:
  name: payments-mcp
spec:
  endpoint:
    streamableHTTP:
      url: https://payments.internal/mcp
      timeout: 30s

SSE (legacy)

For MCP servers that only support the legacy SSE transport.

apiVersion: dapr.io/v1alpha1
kind: MCPServer
metadata:
  name: legacy-mcp
spec:
  endpoint:
    sse:
      url: https://legacy.internal/sse

Stdio

For local MCP server subprocesses in development.

apiVersion: dapr.io/v1alpha1
kind: MCPServer
metadata:
  name: local-tools
spec:
  endpoint:
    stdio:
      command: npx
      args: ["-y", "@modelcontextprotocol/server-filesystem"]

Built-in limits

Dapr applies a few hard limits to MCP server interactions so that a misbehaving or hostile MCP server can’t exhaust sidecar resources:

  • Tool list pagination: at most 500 pages per tools/list round-trip. A server that returns more is rejected at load time rather than silently truncated.
  • Schema cache: per MCPServer, at most 500 cached tool schemas, each capped at 1 MB.
  • HTTP response-headers timeout: 5 seconds time-to-first-byte on every outbound request. SSE streams remain unaffected because the timeout only bounds initial header receipt.

These are intentionally not user-tunable — they’re sized for typical production MCP servers and ensure the sidecar stays bounded under adversarial input.

Authentication

HTTP transports (streamableHTTP, sse) support three authentication mechanisms. These are configured under the transport’s auth field.

Static headers

Inject headers on every outbound request. Supports value, secretKeyRef, and envRef.

spec:
  endpoint:
    streamableHTTP:
      url: https://api.example.com/mcp
      headers:
        - name: Authorization
          secretKeyRef:
            name: mcp-token
            key: token
      auth:
        secretStore: kubernetes

OAuth2 client credentials

Dapr fetches an access token from the authorization server and injects it automatically. HTTP clients are cached per MCPServer for efficiency. auth.secretStore controls which secret store is used to resolve secretKeyRefs anywhere under this auth block (and for static-header secretKeyRefs on the same transport). It defaults to kubernetes.

spec:
  endpoint:
    streamableHTTP:
      url: https://payments.internal/mcp
      auth:
        secretStore: my-vault   # optional; defaults to "kubernetes"
        oauth2:
          issuer: https://auth.company.com/token
          clientID: my-client-id
          audience: mcp://payments
          scopes: [payments.read]
          secretKeyRef:
            name: payments-oauth
            key: clientSecret

SPIFFE workload identity

Dapr injects a SPIFFE JWT SVID per request. No secrets needed — Sentry issues the SVID automatically. The SVID is fetched fresh on every outbound request rather than cached in-process, so there’s no in-memory token cache, no refresh races, and no stale-credential window.

spec:
  endpoint:
    streamableHTTP:
      url: https://payments.internal/mcp
      auth:
        spiffe:
          jwt:
            header: Authorization
            headerValuePrefix: "Bearer "
            audience: mcp://payments

Middleware pipelines

Middleware hooks turn tool-call governance into declarative YAML enforced by Dapr Workflows. Optional hooks run in array order before and after tool calls and tool listing. See the examples below for the canonical patterns.

  • Before hooks: if any hook returns an error, the chain stops and the operation is aborted.
  • afterCallTool hooks: errors fail the workflow — these hooks can act as authz gates that block the response from reaching the caller.
  • afterListTools hooks: errors are logged but do not affect the result returned to the caller.
  • Mutating hooks: set mutate: true to make the hook’s return value replace the data flowing through the pipeline (arguments before the tool call, result after it). Default is false (observe-only — the hook validates or audits but its output is discarded). mutate is not supported on beforeListTools.

Hook input shapes

Each hook is a Dapr workflow that receives a typed input from the runtime:

beforeCallTool input:  { name, toolName, arguments }
afterCallTool  input:  { name, toolName, arguments, result }   # result: bytes — JSON-encoded MCP CallToolResult
beforeListTools input: { name }
afterListTools  input: { name, result }                         # result: bytes — JSON-encoded MCP ListToolsResult

name is the MCPServer resource name. arguments is the JSON object the caller passed. result is the JSON-encoded MCP-spec result (camelCase wire shape, byte-compatible with the MCP specification). Hook workflows deserialize it with the language’s MCP SDK or with plain JSON decoding:

# Python hook example
import json
def after_call_tool(ctx, input):
    result = json.loads(input["result"])
    is_error = result["isError"]
    text = result["content"][0]["text"] if result["content"] else ""
    ...

Mutating hooks return the same shape they receive — modify, then return.

Worked example: argument-level RBAC

A common need is “deny this tool call based on what’s in arguments” — for example, refuse refunds above a threshold, block tools that touch a tenant the request doesn’t belong to, or reject calls whose payload matches a denylist. Wire a beforeCallTool hook with mutate: false:

spec:
  middleware:
    beforeCallTool:
      - workflow:
          workflowName: rbac-check
          appID: policy-service   # optional — see "Centralized policy app" below

Workflow body (pseudocode — language-neutral):

workflow rbac-check(input):
  # input: { name, toolName, arguments }
  if input.toolName == "issue_refund":
    amount = input.arguments["amount"]
    if amount > 10_000:
      return error("rbac: refunds over $10K require manual approval")

  if input.toolName in DESTRUCTIVE_TOOLS:
    if not input.arguments.get("dry_run", false):
      return error("rbac: %s requires dry_run=true in this environment",
                   input.toolName)

  return ok   # mutate=false → return value is discarded; nil error means allow

A few choices worth naming:

  • mutate: false because the hook only decides allow/deny — it never reshapes arguments. (For PII redaction, you’d flip to mutate: true and return the cleaned arguments.)
  • beforeCallTool because denial should run before the MCP server sees the request. An equivalent afterCallTool hook can also gate (after-hook errors fail the workflow), but you’ve already paid for the upstream call.
  • Caller-keyed RBAC (“who can call which tool”) belongs at the policy layer, not the hook — the hook input doesn’t carry caller appID.

Worked example: audit logging

After-hooks observe the result. Wire an afterCallTool hook with mutate: false to write an audit record without altering the response:

spec:
  middleware:
    afterCallTool:
      - workflow:
          workflowName: audit-logger
workflow audit-logger(input):
  # input: { name, toolName, arguments, result }
  # `result` is bytes carrying a JSON-encoded MCP CallToolResult; decode first.
  result = json_decode(input.result)
  emit_audit({
    server:    input.name,
    tool:      input.toolName,
    args:      redact(input.arguments),
    succeeded: not result.isError,
    at:        now(),
  })
  return ok   # mutate=false → result reaches the caller unchanged

Because the audit hook is itself a Dapr Workflow, the write is durable: an emitter restart between emit_audit activity start and ack does not drop the record.

Centralized policy app

When a hook sets appID: <other-app>, the hook workflow runs on the named remote Dapr app via service invocation rather than locally. A single shared policy app — RBAC service, audit logger, PII redactor — can govern many agent apps without each app embedding the policy. Update the central workflow once; every MCPServer that references it picks up the change without redeploying its callers.

spec:
  middleware:
    beforeCallTool:
      - workflow:
          workflowName: rbac-check
          appID: policy-service
      - workflow:
          workflowName: redact-pii
          appID: policy-service
        mutate: true
    afterCallTool:
      - workflow:
          workflowName: audit-logger
          appID: policy-service

Examples: common patterns

PatternPhasemutateSketch
Argument RBACbeforeCallToolfalseInspect arguments, return error to deny.
Rate limitingbeforeCallToolfalseLook up budget keyed by toolName; return error when exhausted.
PII redaction (request)beforeCallTooltrueTransform arguments, return the cleaned shape.
Audit loggingafterCallToolfalseEmit {toolName, arguments, result.isError} (decode result bytes first) to a state store / log sink.
Response filteringafterCallTooltrueStrip / mask fields inside the decoded CallToolResult content, then JSON-encode and return.
Tool list filteringafterListToolstrueDrop tools the caller isn’t entitled to discover, return the updated ListToolsResult as JSON bytes.

Each pattern is a single workflow with the input/output shape from Hook input shapes above. See the MCPServer spec for the full middleware field reference.

Observability and access control

Because each MCP tool gets its own workflow name (dapr.internal.mcp.<server>.CallTool.<tool>), every standard Dapr Workflow telemetry surface — instance status, traces, metrics — slices automatically per-tool. No custom instrumentation required. Operators can build per-tool dashboards or alerts using the workflow name as the slicing dimension.

For access control, MCP workflows participate in WorkflowAccessPolicy the same way user workflows do. The policy is an allow-list keyed by workflow name + caller appID, so operators can deny or restrict who is permitted to invoke dapr.internal.mcp.<server>.CallTool.<tool> (or ListTools) from outside the daprd that owns the resource. Self-call exemption (caller appID equals target appID) keeps in-process invocations open by default. This is how a central agent platform restricts which agents can call which tools, even when many agents share a single MCP gateway.

WorkflowAccessPolicy and middleware hooks compose, they don’t overlap. WorkflowAccessPolicy decides whether a caller can start CallTool.<tool> at all — coarse-grained, appID-keyed, enforced at the workflow boundary. Middleware hooks decide what happens once the call is in flight — fine-grained, with full visibility into arguments and result. Use both: the policy as the perimeter, hooks for tool-call-level argument RBAC, redaction, and audit.

For agents that reach MCP servers through the service invocation path instead of the workflow client, the equivalent perimeter is Configuration accessControl attached to the MCP server’s App ID — see MCP access control.

Deployment topologies

Dapr Workflow’s cross-app routing means an MCPServer’s workflows don’t have to live on the same daprd as the calling agent — the workflow actor’s appID determines hosting. Three patterns this enables:

  • MCP gateway — one dedicated daprd app loads many MCPServer resources (payments, github, internal tools, …). All agent apps invoke MCP workflows on this gateway. Centralized credentials, centralized egress, centralized policy, single place to rotate secrets. Combine with WorkflowAccessPolicy to control which agents can reach which tools.
  • One-to-one — each agent app loads only the MCPServers it needs. Tightest tenant isolation, no cross-app dependency. Best fit when teams own their own MCP integrations end-to-end.
  • Mixed — some MCPServers on a shared gateway (common infrastructure), some on individual apps (tenant-specific). Use WorkflowAccessPolicy to gate gateway tools per-app.

MCPServer itself doesn’t add anything for this — it’s the existing Dapr Workflow cross-app routing. The takeaway: pick whichever topology fits your governance and isolation model; you don’t have to flatten everything onto one daprd to use MCPServer.

App scoping

Restrict which Dapr applications can use an MCPServer with scopes:

apiVersion: dapr.io/v1alpha1
kind: MCPServer
metadata:
  name: payments-mcp
spec:
  endpoint:
    streamableHTTP:
      url: https://payments.internal/mcp
scopes:
  - agent-app-1
  - agent-app-2

Tolerating load failures

By default, an MCPServer that fails to load (validation error, unreachable endpoint, bad credentials) causes daprd to exit. Set spec.ignoreErrors: true to keep the sidecar running and log the failure instead — useful when one MCP server is optional or when other resources on the same daprd must remain available:

apiVersion: dapr.io/v1alpha1
kind: MCPServer
metadata:
  name: optional-mcp
spec:
  ignoreErrors: true
  endpoint:
    streamableHTTP:
      url: https://maybe-flaky.internal/mcp

When ignoreErrors is true and load fails, the MCPServer’s workflows are not registered, so calls to dapr.internal.mcp.<server>.* return ERR_WORKFLOW_NAME_RESERVED until the server loads successfully (e.g. via hot-reload).

6 - How-To: Use MCPServer resources

Use MCPServer resources to discover and call tools on MCP servers

This guide walks you through declaring an MCPServer resource, listing its tools, and calling a tool through the Dapr Workflow API. Dapr handles the MCP protocol, transport, authentication, and durable retries — your application just starts workflows by name.

Step 1: Define the MCPServer resource

Create a file mcpserver.yaml in your resources directory:

apiVersion: dapr.io/v1alpha1
kind: MCPServer
metadata:
  name: my-mcp-server
spec:
  endpoint:
    streamableHTTP:
      url: http://localhost:8080

This tells Dapr to connect to an MCP server at http://localhost:8080 using the streamable HTTP transport.

Step 2: List available tools

Start a ListTools workflow using the Dapr Workflow API:

curl -X POST "http://localhost:3500/v1.0-beta1/workflows/dapr/dapr.internal.mcp.my-mcp-server.ListTools/start" \
  -H "Content-Type: application/json" \
  -d '{}'

Response:

{"instanceID": "abc123"}

Poll for the result:

curl "http://localhost:3500/v1.0-beta1/workflows/dapr/abc123"

When runtimeStatus is "COMPLETED", the properties["dapr.workflow.output"] field contains the tool list. Each tool’s inputSchema is the raw JSON Schema for its arguments:

{
  "tools": [
    {
      "name": "get_weather",
      "description": "Get current weather for a city",
      "inputSchema": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"]
      }
    }
  ]
}

Step 3: Call a tool

Each MCP tool gets its own workflow named dapr.internal.mcp.<server>.CallTool.<tool>. The tool name is in the workflow name, so the input only carries the arguments:

curl -X POST "http://localhost:3500/v1.0-beta1/workflows/dapr/dapr.internal.mcp.my-mcp-server.CallTool.get_weather/start" \
  -H "Content-Type: application/json" \
  -d '{
    "arguments": {"city": "Seattle"}
  }'

Poll for the result as in Step 2. The output is an MCP CallToolResult — byte-for-byte the same shape as the MCP wire spec. Each entry in content is a flat tagged union with a type discriminator:

{
  "isError": false,
  "content": [
    {"type": "text", "text": "Weather in Seattle: sunny, 72°F"}
  ]
}

If the tool call fails at the MCP level (e.g. unknown tool, auth error), isError is true and the error is in content. The workflow itself completes successfully — isError is not a workflow failure.

If your call is missing a required argument, you get the same isError: true shape immediately — Dapr validates against the tool’s cached JSON Schema before contacting the MCP server, so agents/LLMs see actionable errors without burning a network round-trip.

Step 4 (optional): Add authentication

Add OAuth2 client credentials to authenticate with the MCP server:

apiVersion: dapr.io/v1alpha1
kind: MCPServer
metadata:
  name: my-mcp-server
spec:
  endpoint:
    streamableHTTP:
      url: https://mcp.example.com
      auth:
        secretStore: kubernetes
        oauth2:
          issuer: https://auth.example.com/token
          clientID: my-client-id
          audience: mcp://my-server
          secretKeyRef:
            name: mcp-oauth-secret
            key: clientSecret

Dapr fetches a token from the issuer and injects it as a Bearer token on every MCP request. HTTP clients are cached per MCPServer for efficiency.

Step 5 (optional): Add middleware

Middleware hooks let you run authorization, redaction, and audit as Dapr workflows on every tool call — no agent code change. Hooks are wired in the MCPServer spec and registered as plain workflows in your application (or in a dedicated policy app via appID).

Step 5.1: Add an RBAC hook (deny on policy violation)

spec:
  middleware:
    beforeCallTool:
      - workflow:
          workflowName: rbac-check

Register a workflow named rbac-check in your application. It receives an MCPBeforeCallToolHookInput:

{ name, toolName, arguments }

name is the MCPServer resource name; arguments is the JSON object the caller passed. Return an error to deny; return nil to allow.

workflow rbac-check(input):
  # Argument-level RBAC: inspect the payload and decide.
  if input.toolName == "issue_refund":
    if input.arguments["amount"] > 10_000:
      return error("rbac: refunds over $10K require manual approval")

  if input.toolName in DESTRUCTIVE_TOOLS:
    if not input.arguments.get("dry_run", false):
      return error("rbac: %s requires dry_run=true",
                   input.toolName)

  return ok   # nil error so tool call proceeds

The hook runs as a durable workflow — if daprd restarts mid-policy-check, Scheduler re-delivers and the decision completes.

Caller-keyed RBAC (“which apps can call which tools”) belongs at the WorkflowAccessPolicy layer, not the hook. The hook input doesn’t carry caller appID; the policy is. Use the policy as the perimeter and hooks for argument-level decisions.

Step 5.2: Add a mutating PII redaction hook

To transform arguments before they reach the tool — redact PII, normalize values, inject defaults — set mutate: true:

spec:
  middleware:
    beforeCallTool:
      - workflow:
          workflowName: redact-pii
        mutate: true
workflow redact-pii(input):
  # input: { name, toolName, arguments }
  args = copy(input.arguments)
  if "email" in args:
    args["email"] = mask_email(args["email"])
  return { name: input.name, toolName: input.toolName, arguments: args }

The hook returns the same shape it receives. The MCP server (and any subsequent hooks in the chain) sees only the transformed arguments.

For after-the-fact response filtering or audit logging, wire the same way under afterCallTool — see the overview examples for the full set of patterns.

Step 5.3: Centralize policy on a shared app

To run the hook on a dedicated policy app instead of locally, add appID:

spec:
  middleware:
    beforeCallTool:
      - workflow:
          workflowName: rbac-check
          appID: policy-service   # runs on the Dapr app named "policy-service"

The same workflow runs on the named app via service invocation. One shared policy app (RBAC, audit, PII redaction) governs many agent apps without each app embedding the policy. Update the central workflow once; every MCPServer that references it picks up the change without redeploying its callers.

See the overview examples for canonical hook patterns (RBAC, rate limiting, audit, response filtering, tool list filtering).

7 - Dapr MCP server

Expose Dapr building blocks to AI agents as Model Context Protocol tools

The Dapr MCP server (dapr/dapr-mcp-server) is a standalone Model Context Protocol (MCP) server, written in Go, that runs next to a Dapr sidecar. It turns the sidecar’s building blocks (state, pub/sub, bindings, secrets, service invocation, actors, distributed lock, cryptography, and conversation) into MCP tools that any MCP client can call: Claude Desktop, Cursor, Claude Code, VS Code, Dapr Agents, or your own agent.

How it differs from the rest of this section

The other pages in this section cover how the Dapr runtime connects agents to MCP servers you already have, either through service invocation or with the MCPServer resource. The Dapr MCP server works the other way round: it is an MCP server, and the tools it serves are Dapr APIs. It is a separate binary with its own release cycle, not a feature of daprd. The two fit together. You can run the Dapr MCP server as a Dapr app and reach it through the service invocation path, or declare it as an MCPServer resource like any other MCP server.

When to use it

Use the Dapr MCP server when you want an agent to:

  • Persist or read data through a Dapr state store, with transactions where the store supports them.
  • Publish events, call other Dapr apps or actors, or reach external systems through output bindings.
  • Coordinate with other workers using a distributed lock.
  • Read secrets, encrypt or decrypt data, or hand a prompt to another LLM through a conversation component.

The agent gets the same component abstraction your services use, so swapping Redis for PostgreSQL or Kafka for RabbitMQ needs no change to the agent.

You don’t need it for plain computation, one-off text generation, or local file and process access. Those don’t need a Dapr sidecar.

How it works

  1. The server starts and connects to the Dapr sidecar over gRPC, using the standard Dapr SDK settings (DAPR_GRPC_PORT, DAPR_GRPC_ENDPOINT). It retries the connection up to five times, then exits.
  2. It reads the sidecar’s metadata and registers tools for each building block that has at least one component loaded. Three tools are always registered: get_components, invoke_service, and invoke_actor_method.
  3. It serves MCP over stdio (the default, for local clients that launch the server themselves) or over streamable HTTP (with --http, for remote and shared deployments). Authentication, health endpoints, and CORS apply to the HTTP transport only.

Tools follow the components loaded in the sidecar, with no restart. When component hot reloading adds the first component of a type (for example the first pub/sub component), the server registers that type’s tools the next time a client lists tools or calls get_components, and notifies connected clients that the tool list changed. Removing the last component of a type removes its tools. Clients that read the tool list only once, as many agent frameworks do, need to reconnect to see the change.

Every tool carries MCP safety annotations (readOnlyHint, destructiveHint, idempotentHint) and a description that tells the model when to use it. The server’s instructions tell the model to call get_components first and never to invent component names, keys, or topics.

Security considerations

The server can do anything its Dapr sidecar can do, and it returns tool results, including secret values from get_secret and get_bulk_secrets, to the model. Limit what the sidecar can reach:

  • Load only the components the agent needs, and use component scopes and secret scopes to narrow access further.
  • Turn on authentication whenever you serve over HTTP to anything other than localhost.
  • Treat tools marked destructive (delete_state, execute_transaction, invoke_service, invoke_actor_method, invoke_output_binding, encrypt_data) as needing human confirmation in your MCP client.

Next steps

7.1 - Getting started with the Dapr MCP server

Install the Dapr MCP server, run it with a Dapr sidecar, and connect an MCP client over stdio or streamable HTTP

This guide installs the Dapr MCP server, runs it next to a Dapr sidecar with two in-memory components, and connects an MCP client to it.

Prerequisites

Step 1: Install the server

Pick one of the following.

Each GitHub release publishes a binary per platform, named dapr-mcp-server-<os>-<arch>, with a checksums.txt file. Builds are available for linux-amd64, linux-arm64, darwin-amd64, darwin-arm64, and windows-amd64 (with an .exe suffix).

Download the binary, check it against checksums.txt, and move it onto your PATH. On Arm, use linux-arm64.

curl -LO https://github.com/dapr/dapr-mcp-server/releases/latest/download/dapr-mcp-server-linux-amd64
curl -LO https://github.com/dapr/dapr-mcp-server/releases/latest/download/checksums.txt
sha256sum --ignore-missing -c checksums.txt
chmod +x dapr-mcp-server-linux-amd64
sudo mv dapr-mcp-server-linux-amd64 /usr/local/bin/dapr-mcp-server

Download the binary, check it against checksums.txt, and move it onto your PATH. On an Intel Mac, use darwin-amd64.

curl -LO https://github.com/dapr/dapr-mcp-server/releases/latest/download/dapr-mcp-server-darwin-arm64
curl -LO https://github.com/dapr/dapr-mcp-server/releases/latest/download/checksums.txt
shasum -a 256 --ignore-missing -c checksums.txt
chmod +x dapr-mcp-server-darwin-arm64
sudo mv dapr-mcp-server-darwin-arm64 /usr/local/bin/dapr-mcp-server

In PowerShell, download the binary to $Env:LOCALAPPDATA\dapr-mcp-server, check it against checksums.txt, and add the folder to your user PATH:

$base = "https://github.com/dapr/dapr-mcp-server/releases/latest/download"
$dir = "$Env:LOCALAPPDATA\dapr-mcp-server"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
Invoke-WebRequest "$base/dapr-mcp-server-windows-amd64.exe" -OutFile "$dir\dapr-mcp-server.exe"
Invoke-WebRequest "$base/checksums.txt" -OutFile "$dir\checksums.txt"
$expected = ((Select-String -Path "$dir\checksums.txt" -Pattern "dapr-mcp-server-windows-amd64.exe").Line -split "\s+")[0]
$actual = (Get-FileHash "$dir\dapr-mcp-server.exe" -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $expected) { throw "Checksum mismatch for dapr-mcp-server.exe" }
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if (($userPath -split ";") -notcontains $dir) {
    [Environment]::SetEnvironmentVariable("Path", (($userPath, $dir) | Where-Object { $_ }) -join ";", "User")
}

Open a new terminal so the updated PATH takes effect.

</div>

Requires Go 1.26.6 or later.

go install github.com/dapr/dapr-mcp-server/cmd/dapr-mcp-server@latest

A binary built this way reports its version as dev, because the version is stamped in only by the release build.

Multi-arch images (linux/amd64, linux/arm64) are published to the GitHub Container Registry, tagged with the release version. Stable releases also update latest.

docker pull ghcr.io/dapr/dapr-mcp-server:latest

The image runs as a non-root user, listens on port 8080 with --http 0.0.0.0:8080 by default, and includes a HEALTHCHECK. See Run on Kubernetes for how to pair it with a sidecar.

Check the install:

dapr-mcp-server --version

Step 2: Define components

The server registers tools only for building blocks that have a component loaded. Create a components folder with an in-memory state store and pub/sub broker:

mkdir -p components

components/statestore.yaml:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.in-memory
  version: v1

components/pubsub.yaml:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: pubsub
spec:
  type: pubsub.in-memory
  version: v1

With these two components the server registers the three core tools plus the state and pub/sub tools. Add components of other types (bindings.*, secretstores.*, lock.*, crypto.*, conversation.*) to expose their tools. See the tool reference for the full mapping.

Step 3: Run and connect

Over stdio (Claude Desktop, Cursor)

With stdio, the MCP client starts the server process itself. Run the sidecar on its own first, on a fixed gRPC port:

dapr run --app-id dapr-mcp-server --dapr-grpc-port 50001 --resources-path ./components

Leave it running, then point your client at the binary. Use the absolute path, because clients don’t always inherit your shell’s PATH. On Windows, that’s the full path to dapr-mcp-server.exe, with each backslash doubled in JSON (for example, C:\\Users\\<you>\\AppData\\Local\\dapr-mcp-server\\dapr-mcp-server.exe).

Edit claude_desktop_config.json (on macOS, ~/Library/Application Support/Claude/claude_desktop_config.json; on Windows, %APPDATA%\Claude\claude_desktop_config.json) and restart Claude Desktop:

{
  "mcpServers": {
    "dapr": {
      "command": "/usr/local/bin/dapr-mcp-server",
      "env": {
        "DAPR_GRPC_PORT": "50001"
      }
    }
  }
}

Add the server to ~/.cursor/mcp.json, or to .cursor/mcp.json in a project:

{
  "mcpServers": {
    "dapr": {
      "command": "/usr/local/bin/dapr-mcp-server",
      "env": {
        "DAPR_GRPC_PORT": "50001"
      }
    }
  }
}
claude mcp add dapr --env DAPR_GRPC_PORT=50001 -- /usr/local/bin/dapr-mcp-server

Ask the assistant to list your Dapr components. It calls get_components and reports statestore and pubsub. From there, try “save the key greeting with value hello to the state store” and “read it back”.

The server writes its logs as JSON to stderr, so they never mix with the MCP messages on stdout. Most clients show stderr in their MCP log view.

Over streamable HTTP

Pass --http with a listen address to serve MCP over streamable HTTP. Here dapr run starts both the sidecar and the server, and sets DAPR_GRPC_PORT for it:

dapr run --app-id dapr-mcp-server --resources-path ./components -- dapr-mcp-server --http localhost:8080

The MCP endpoint is http://localhost:8080/. Check that the server is up and can reach its sidecar:

curl http://localhost:8080/readyz
Invoke-RestMethod http://localhost:8080/readyz

A 200 response with "status":"healthy" means it’s ready. A 503 means the sidecar isn’t reachable; PowerShell reports it as an error.

Connect a client that supports streamable HTTP:

{
  "mcpServers": {
    "dapr": {
      "url": "http://localhost:8080/"
    }
  }
}
claude mcp add --transport http dapr http://localhost:8080/
import asyncio

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client


async def main():
    async with streamablehttp_client("http://localhost:8080/") as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            print([tool.name for tool in tools.tools])
            result = await session.call_tool("get_components", {})
            print(result.structuredContent)


asyncio.run(main())

Run on Kubernetes

On Kubernetes, run the container image with the Dapr sidecar injected. The injector sets DAPR_GRPC_PORT in the server’s container, so no extra configuration is needed to find the sidecar. Point the probes at the server’s health endpoints:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: dapr-mcp-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: dapr-mcp-server
  template:
    metadata:
      labels:
        app: dapr-mcp-server
      annotations:
        dapr.io/enabled: "true"
        dapr.io/app-id: "dapr-mcp-server"
    spec:
      containers:
        - name: dapr-mcp-server
          image: ghcr.io/dapr/dapr-mcp-server:latest
          ports:
            - containerPort: 8080
          startupProbe:
            httpGet:
              path: /startupz
              port: 8080
            failureThreshold: 30
            periodSeconds: 2
          livenessProbe:
            httpGet:
              path: /livez
              port: 8080
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: dapr-mcp-server
spec:
  selector:
    app: dapr-mcp-server
  ports:
    - port: 8080
      targetPort: 8080

Add authentication and telemetry settings as env entries on the container; see the configuration reference.

Streamable HTTP sessions are held in the server’s memory. If you run more than one replica, configure session affinity on whatever routes traffic to them, so that each client keeps reaching the same replica.

Next steps

7.2 - Dapr MCP server configuration reference

Every command-line flag and environment variable the Dapr MCP server reads, with defaults

The Dapr MCP server takes a few command-line flags and reads everything else from environment variables. The server checks authentication settings at startup and exits with an error if they are invalid or incomplete, rather than starting without protection.

Command-line flags

FlagDefaultDescription
--http <addr>Not set (stdio)Serve streamable HTTP on this address, for example localhost:8080 or 0.0.0.0:8080, instead of stdio. The health endpoints are served on the same address.
--versionfalsePrint the version and exit.
--health-checkfalseRequest /livez from a running server and exit with 0 on a 200 response, 1 otherwise. The container image uses this for its HEALTHCHECK.
--health-check-addr <host:port>From --http, else localhost:8080Address that --health-check probes. A wildcard host such as 0.0.0.0 is probed as localhost.

Server and Dapr connection

VariableDefaultDescription
DAPR_GRPC_PORT50001Port of the Dapr sidecar’s gRPC API on 127.0.0.1. dapr run and the Kubernetes sidecar injector set this for you.
DAPR_GRPC_ENDPOINTNot setFull address of the sidecar’s gRPC API. Takes precedence over DAPR_GRPC_PORT.
DAPR_API_TOKENNot setToken sent to the sidecar when it requires API token authentication.
DAPR_CLIENT_TIMEOUT_SECONDS5Timeout for each attempt to connect to the sidecar. The server makes up to five attempts, two seconds apart, then exits.
DAPR_MCP_SERVER_LOG_LEVELinfoLog level: debug, info, warn, or error. Logs are JSON on stderr. At debug, request headers are logged with credential headers redacted.

The DAPR_* connection variables are read by the Dapr Go SDK that the server uses.

HTTP transport

These settings apply only when the server runs with --http.

VariableDefaultDescription
DAPR_MCP_CORS_ORIGINNot setOrigin allowed to call the server from a browser, for example https://app.example.com. When set, the server adds CORS headers to every response and answers OPTIONS preflight requests. When not set, no CORS headers are sent and browsers on other origins are refused. Set it only for a browser-based MCP client on another origin.

The server always serves /livez, /readyz, and /startupz without authentication; see Health endpoints. MCP requests are served on every other path, so http://<host>:<port>/ is the MCP endpoint. The one exception is /dapr/subscribe, which returns an empty subscription list for the Dapr sidecar and also needs no authentication.

Authentication

Authentication applies to the HTTP transport. See Authentication for how the modes work and for examples.

VariableDefaultDescription
AUTH_MODENot set (disabled)oidc, spiffe, dapr-sentry, or hybrid turns authentication on. Not set, or disabled, turns it off.
OIDC_ENABLEDfalseIn hybrid mode, accept OIDC tokens. Ignored in other modes.
OIDC_ISSUER_URLNot setOIDC issuer URL. Required when OIDC is on.
OIDC_CLIENT_IDNot setExpected aud claim. Required when OIDC is on.
OIDC_ALLOWED_ALGORITHMSRS256,ES256Comma-separated signing algorithms to accept.
OIDC_SKIP_ISSUER_CHECKfalseSkip the iss check. For development only.
SPIFFE_ENABLEDfalseIn hybrid mode, accept SPIFFE JWT-SVIDs. Ignored in other modes.
SPIFFE_TRUST_DOMAINNot setTrust domain that callers must belong to. Required when SPIFFE is on.
SPIFFE_SERVER_IDNot setThis server’s SPIFFE ID, which callers’ JWT-SVIDs must use as their audience. Required when SPIFFE is on.
SPIFFE_ENDPOINT_SOCKETNot setAddress of the SPIFFE Workload API, for example unix:///run/spire/sockets/agent.sock.
SPIFFE_ALLOWED_CLIENTSNot set (any workload in the trust domain)Comma-separated SPIFFE IDs allowed to call the server.
DAPR_SENTRY_ENABLEDfalseIn hybrid mode, accept Dapr Sentry JWTs. Ignored in other modes.
DAPR_SENTRY_JWKS_URLNot setURL of the Sentry JWKS. Required when Dapr Sentry is on.
DAPR_SENTRY_TRUST_DOMAINNot setTrust domain of the SPIFFE ID in the token’s sub claim. Required when Dapr Sentry is on.
DAPR_SENTRY_AUDIENCENot setExpected aud claim. Required when Dapr Sentry is on.
DAPR_SENTRY_ISSUERNot setExpected iss claim. Checked only when set.
DAPR_SENTRY_TOKEN_HEADERAuthorizationHeader to read the token from. Despite its name, it applies in every auth mode, not only Dapr Sentry. A custom header carries the raw token, without a Bearer prefix.
DAPR_SENTRY_JWKS_REFRESH_INTERVAL5mHow often to refresh the JWKS, as a Go duration. Minimum 30s.

Telemetry

Telemetry is off until an OTLP endpoint is set. See Observability for what the server emits.

VariableDefaultDescription
OTEL_EXPORTER_OTLP_ENDPOINTNot setOTLP collector endpoint for all signals, for example http://otel-collector:4317. An endpoint without a scheme uses TLS.
OTEL_EXPORTER_OTLP_PROTOCOLgrpcgrpc or http/protobuf. http/json falls back to http/protobuf.
OTEL_EXPORTER_OTLP_INSECUREfalseUse plaintext for an endpoint given without a scheme. An explicit http:// or https:// scheme always wins.
OTEL_EXPORTER_OTLP_HEADERSNot setComma-separated key=value headers sent with every export, such as an API key. Values are percent-decoded.
OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINTNot setPer-signal endpoint, overriding the generic one. For HTTP, used as given unless it has no path, in which case /v1/traces, /v1/metrics, or /v1/logs is added.
OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_PROTOCOLGeneric valuePer-signal protocol.
OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_INSECUREGeneric valuePer-signal plaintext setting.
OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERSNot setPer-signal headers, merged over the generic ones.
OTEL_SERVICE_NAMEdapr-mcp-serverservice.name resource attribute.
OTEL_SERVICE_VERSIONBinary versionservice.version resource attribute.
OTEL_METRIC_EXPORT_INTERVAL10000Metric export interval in milliseconds. Go durations such as 30s are also accepted.
OTEL_LOG_EXPORT_INTERVAL5000Log export interval in milliseconds. Go durations are also accepted.
DAPR_MCP_SERVER_METRICS_ENABLEDtrueSet to false to stop exporting metrics while keeping traces.
DAPR_MCP_SERVER_LOGS_OTEL_ENABLEDtrueSet to false to stop exporting logs over OTLP. Logs are still written to stderr.

7.3 - Authenticating clients of the Dapr MCP server

Require OIDC, SPIFFE, or Dapr Sentry tokens on the Dapr MCP server’s HTTP transport

The Dapr MCP server can require a valid token on every MCP request it serves over streamable HTTP. Authentication is off by default. Set AUTH_MODE to turn it on:

AUTH_MODEAccepts
Not set, or disabledAny request. Use only on localhost.
oidcID or access tokens (JWTs) from an OpenID Connect provider
spiffeSPIFFE JWT-SVIDs, validated with trust bundles from the SPIFFE Workload API
dapr-sentryJWTs issued by Dapr Sentry for a Dapr app
hybridAny combination of the three above

Authentication only applies to the HTTP transport. Over stdio, the MCP client starts the server as a local process, and there is no network listener to protect.

The server checks the settings for the selected mode at startup. If a required variable is missing or a value can’t be parsed, it exits with an error that names the variable. An unrecognized AUTH_MODE value is also rejected at startup.

Sending a token

Clients send the token in the Authorization header, either as Bearer <token> or as the raw token. If DAPR_SENTRY_TOKEN_HEADER names another header, the server checks that header first, in every mode, and expects the raw token there.

A request with no token, or with a token that no enabled method accepts, gets 401 Unauthorized. The server doesn’t say which check failed. Set DAPR_MCP_SERVER_LOG_LEVEL=debug to log the reason on the server.

OIDC

Use oidc for tokens issued to users or applications by an identity provider such as Microsoft Entra ID, Okta, Auth0, Keycloak, or Google.

VariableRequiredDescription
OIDC_ISSUER_URLYesIssuer URL. The server fetches the provider’s discovery document from it at startup, and exits if it can’t.
OIDC_CLIENT_IDYesValue the token’s aud claim must contain.
OIDC_ALLOWED_ALGORITHMSNoSigning algorithms to accept. Default RS256,ES256.
OIDC_SKIP_ISSUER_CHECKNoSkip the iss check. For development only; the server logs a warning when it’s on.
export AUTH_MODE=oidc
export OIDC_ISSUER_URL=https://login.example.com/realms/agents
export OIDC_CLIENT_ID=dapr-mcp-server
dapr run --app-id dapr-mcp-server --resources-path ./components -- dapr-mcp-server --http 0.0.0.0:8080

SPIFFE

Use spiffe for service-to-service calls between workloads that get JWT-SVIDs from a SPIFFE implementation such as SPIRE. The server connects to the Workload API at startup to get trust bundles, waiting up to 10 seconds.

VariableRequiredDescription
SPIFFE_TRUST_DOMAINYesTrust domain, for example example.org. Tokens from other trust domains are rejected.
SPIFFE_SERVER_IDYesThis server’s SPIFFE ID, for example spiffe://example.org/dapr-mcp-server. Callers must request their JWT-SVID with this ID as the audience.
SPIFFE_ENDPOINT_SOCKETNoWorkload API address, for example unix:///run/spire/sockets/agent.sock.
SPIFFE_ALLOWED_CLIENTSNoComma-separated SPIFFE IDs allowed to call the server. Each must be in the trust domain. When not set, any workload in the trust domain is accepted.
export AUTH_MODE=spiffe
export SPIFFE_TRUST_DOMAIN=example.org
export SPIFFE_SERVER_ID=spiffe://example.org/dapr-mcp-server
export SPIFFE_ENDPOINT_SOCKET=unix:///run/spire/sockets/agent.sock
export SPIFFE_ALLOWED_CLIENTS=spiffe://example.org/ns/agents/sa/planner

Dapr Sentry

Use dapr-sentry when the callers are Dapr apps. Dapr Sentry can issue each app a JWT whose subject is the app’s SPIFFE ID, and publish the signing keys as a JWKS. To turn this on in Sentry, enable JWT issuing and the OIDC server, using the Sentry Helm values shown in Authenticating with a Federated Identity Credential. The Microsoft Entra ID steps on that page don’t apply. The JWKS is served at /jwks.json on the Sentry OIDC port. Each calling app requests a token for the audience you set in DAPR_SENTRY_AUDIENCE with the dapr.io/sentry-request-jwt-audiences annotation, or the --sentry-request-jwt-audiences flag in self-hosted mode.

An MCPServer resource with auth.spiffe.jwt sends such a token on every call, so this mode pairs naturally with declaring the Dapr MCP server as an MCPServer.

VariableRequiredDescription
DAPR_SENTRY_JWKS_URLYesURL of the Sentry JWKS.
DAPR_SENTRY_TRUST_DOMAINYesTrust domain of the caller’s SPIFFE ID, for example public. The sub claim must be a SPIFFE ID in this trust domain with a workload path.
DAPR_SENTRY_AUDIENCEYesValue the token’s aud claim must contain. Use the audience your callers request their tokens for.
DAPR_SENTRY_ISSUERNoExpected iss claim. When not set, the issuer isn’t checked.
DAPR_SENTRY_TOKEN_HEADERNoHeader that carries the raw token. Default Authorization. Applies in every mode; see Sending a token.
DAPR_SENTRY_JWKS_REFRESH_INTERVALNoHow often to refresh the JWKS. Default 5m, minimum 30s.
export AUTH_MODE=dapr-sentry
export DAPR_SENTRY_JWKS_URL=https://dapr-sentry.dapr-system.svc.cluster.local:9080/jwks.json
export DAPR_SENTRY_TRUST_DOMAIN=public
export DAPR_SENTRY_AUDIENCE=mcp://dapr-mcp-server
export DAPR_SENTRY_ISSUER=https://sentry.example.com

The server fetches the JWKS at startup and exits if it can’t. Afterwards it refreshes the keys on the configured interval, and also when a token arrives signed with a key it doesn’t have, at most once every 30 seconds. If Sentry can’t be reached, the server keeps using the cached keys for up to 24 hours. Only asymmetric signing algorithms are accepted, the token must have an exp claim, and exp, nbf, and iat are checked with one minute of clock-skew leeway.

Hybrid

hybrid accepts tokens from more than one method. Turn on each method you want with its *_ENABLED variable, and set that method’s required variables. At least one method must be on.

export AUTH_MODE=hybrid

export OIDC_ENABLED=true
export OIDC_ISSUER_URL=https://login.example.com/realms/agents
export OIDC_CLIENT_ID=dapr-mcp-server

export DAPR_SENTRY_ENABLED=true
export DAPR_SENTRY_JWKS_URL=https://dapr-sentry.dapr-system.svc.cluster.local:9080/jwks.json
export DAPR_SENTRY_TRUST_DOMAIN=public
export DAPR_SENTRY_AUDIENCE=mcp://dapr-mcp-server

The server tries the enabled methods in the order OIDC, SPIFFE, Dapr Sentry, and accepts the request as soon as one of them validates the token. The *_ENABLED variables are ignored in single-method modes.

Unauthenticated paths

The health endpoints /livez, /readyz, and /startupz are always served without authentication, as is /dapr/subscribe, which only tells the sidecar the server has no subscriptions. Every other path is MCP and needs a token. There is no setting to exempt more paths.

7.4 - Dapr MCP server tool reference

Every tool the Dapr MCP server exposes, its inputs, its safety properties, and the component type that enables it

The Dapr MCP server exposes 17 tools. Three are always registered. The rest are registered only while the sidecar has at least one component of the matching type, so an agent never sees tools it can’t use.

The schema the server sends when a client connects is the authoritative definition of each tool. This page is a readable summary of it.

Call get_components first

Component-specific tools take a component name as input, such as storeName or pubsubName. An agent should call get_components first to learn the names that exist, rather than guess. The server’s instructions to the model say the same thing, and tell it to pass metadata fields as JSON objects ({}), never as strings ("{}").

Safety properties

Each tool carries the MCP annotations readOnlyHint, destructiveHint, and idempotentHint, which MCP clients can use to decide whether to ask a person before a call. The tables below show them as:

  • Read-only: reads data and changes nothing.
  • Idempotent: calling it again with the same inputs has no further effect, so it is safe to retry.
  • Destructive: may delete or overwrite data, or trigger an action that can’t be undone. Consider asking a person to confirm the first call.
  • Side effect: changes state or triggers an action somewhere.

Every tool except get_components also sets openWorldHint, because it reaches systems outside the server.

Tools at a glance

ToolRegistered when the sidecar hasSafety
get_componentsAlwaysRead-only, idempotent
invoke_serviceAlwaysDestructive, side effect, not idempotent
invoke_actor_methodAlwaysDestructive, side effect, not idempotent
save_stateA state.* componentSide effect, idempotent
get_stateA state.* componentRead-only, idempotent
delete_stateA state.* componentDestructive, idempotent
execute_transactionA state.* componentDestructive, side effect, not idempotent
publish_eventA pubsub.* componentSide effect, not idempotent
publish_event_with_metadataA pubsub.* componentSide effect, not idempotent
invoke_output_bindingA bindings.* componentDestructive, side effect, not idempotent
get_secretA secretstores.* componentRead-only, idempotent
get_bulk_secretsA secretstores.* componentRead-only, idempotent
converse_with_llmA conversation.* componentRead-only, idempotent
encrypt_dataA crypto.* componentDestructive, side effect, not idempotent
decrypt_dataA crypto.* componentRead-only, idempotent
acquire_lockA lock.* componentSide effect, idempotent
release_lockA lock.* componentSide effect, not idempotent

Tools follow the components loaded in the sidecar. When component hot reloading adds the first component of a type, the server registers that type’s tools the next time a client lists tools or calls get_components, and notifies connected clients that the tool list changed. When the last one is removed, its tools are removed too. Clients that read the tool list only once need to reconnect to see the change.

A tool that fails, because an input is missing or the sidecar returns an error, returns a result marked as an error with a message the model can read, rather than failing the MCP request.

Metadata

get_components

Lists the components loaded in the sidecar, with each one’s name, type, version, and capabilities. Only component types the server has tools for are listed. The list is read live from the sidecar on every call.

Inputs: none.

Service invocation

invoke_service

Calls a method on another Dapr app through service invocation and returns the response body.

InputRequiredDescription
appIDYesApp ID of the target app.
methodYesMethod or path to call, for example orders/status.
dataNoRequest body, usually JSON.
httpVerbNoGET, HEAD, POST, PUT, PATCH, DELETE, or OPTIONS. Default POST.
contentTypeNoContent type of data. Default application/json if data is valid JSON, text/plain otherwise.
metadataNoHeaders to send to the target app. Keys starting with dapr-, grpc-, or : are rejected.

Actors

invoke_actor_method

Calls a method on a virtual actor and returns its response.

InputRequiredDescription
actorTypeYesRegistered actor type.
actorIDYesID of the actor instance.
methodYesActor method to call.
dataNoPayload to pass to the method.

State management

save_state

Saves one key-value pair to a state store.

InputRequiredDescription
storeNameYesState store component name.
keyYesKey to save under.
valueYesValue to save, as a string. JSON is stored as given.

get_state

Reads the value of one key. Reports found: false when the key doesn’t exist.

InputRequiredDescription
storeNameYesState store component name.
keyYesKey to read.

delete_state

Deletes one key.

InputRequiredDescription
storeNameYesState store component name.
keyYesKey to delete.

execute_transaction

Runs several saves and deletes as one atomic transaction. The state store must support transactions.

InputRequiredDescription
storeNameYesState store component name.
itemsYesNon-empty list of operations. Each has a key (required), a value to save, and isDelete: true to delete the key instead.

Publish and subscribe

publish_event

Publishes a message to a topic on a pub/sub component.

InputRequiredDescription
pubsubNameYesPub/sub component name.
topicYesTopic to publish to.
messageYesMessage payload, usually JSON.
contentTypeNoContent type of message. Default application/json if message is valid JSON, text/plain otherwise.

publish_event_with_metadata

Same as publish_event, with publish metadata such as ttlInSeconds.

InputRequiredDescription
pubsubNameYesPub/sub component name.
topicYesTopic to publish to.
messageYesMessage payload, usually JSON.
contentTypeNoContent type of message.
metadataNoPublish metadata, for example {"ttlInSeconds": "60"}.

Bindings

invoke_output_binding

Invokes an operation on an output binding, such as an HTTP endpoint, a queue, or object storage, and returns the response.

InputRequiredDescription
bindingNameYesBinding component name.
operationYesOperation supported by the binding, for example create, get, or delete.
dataNoPayload, usually JSON.
metadataNoMetadata the binding needs for this operation, for example key for a storage binding.

Secrets

Secret values are returned to the model in the tool result. Expose only secret stores the agent should read, and narrow them with secret scopes.

get_secret

Reads one secret from a secret store.

InputRequiredDescription
storeNameYesSecret store component name.
secretNameYesName of the secret.
metadataNoStore-specific options, for example version_id.

get_bulk_secrets

Reads every secret the app can access in a secret store. The tool description tells the model to use it only when the user explicitly asks for all secrets.

InputRequiredDescription
storeNameYesSecret store component name.
metadataNoStore-specific options.

Conversation

converse_with_llm

Sends a prompt to another LLM through a conversation component and returns the reply, along with a context_id to continue the conversation.

InputRequiredDescription
nameYesConversation component name.
promptYesPrompt to send.
contextIdNocontext_id from an earlier call, to continue that conversation. A new one is generated when omitted.
temperatureNoSampling temperature from 0.0 to 1.0. Default 0.7.

Cryptography

encrypt_data

Encrypts text with a key held by a cryptography component and returns the cipher text, base64-encoded.

InputRequiredDescription
componentNameYesCryptography component name.
plainTextYesText to encrypt.
keyNameNoKey name, or name/version. Default rsa-private-key.pem.
keyWrapAlgorithmNoKey wrap algorithm, for example RSA, RSA-OAEP-256, or A256KW. Default RSA.

decrypt_data

Decrypts cipher text produced by encrypt_data and returns the plain text.

InputRequiredDescription
componentNameYesCryptography component name.
cipherTextYesBase64-encoded cipher text.
keyNameNoKey name, or name/version. When omitted, the key named in the cipher text header is used.

Distributed lock

Always pair acquire_lock with release_lock, and set an expiry so a lock isn’t held forever if the agent stops.

acquire_lock

Tries to acquire a distributed lock on a resource. If another owner holds it, the call succeeds with lock_acquired: false.

InputRequiredDescription
storeNameYesLock store component name.
resourceIDYesName of the resource to lock.
lockOwnerYesUnique ID of the caller, used again to release the lock.
expiryInSecondsYesLock lifetime in seconds, greater than zero. The lock expires automatically after this time.

release_lock

Releases a lock. Fails if the lock doesn’t exist or belongs to a different owner.

InputRequiredDescription
storeNameYesLock store component name.
resourceIDYesName of the locked resource.
lockOwnerYesThe owner ID used to acquire the lock.

7.5 - Dapr MCP server observability

Traces, metrics, and logs the Dapr MCP server exports over OpenTelemetry, and its health endpoints

The Dapr MCP server exports traces, metrics, and logs over the OpenTelemetry Protocol (OTLP), and serves health endpoints for probes when it runs over HTTP.

Turn on telemetry

Telemetry is off until you set an OTLP endpoint. Point the server at an OpenTelemetry Collector, or any backend that accepts OTLP:

export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_SERVICE_NAME=dapr-mcp-server
dapr run --app-id dapr-mcp-server --resources-path ./components -- dapr-mcp-server --http localhost:8080

An endpoint without a scheme, such as otel-collector:4317, uses TLS. Add OTEL_EXPORTER_OTLP_INSECURE=true to send plaintext to an endpoint like that, or give the scheme explicitly. To use OTLP over HTTP instead of gRPC, set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf and use the HTTP port, usually 4318.

All three signals go to the same endpoint unless you override one with its OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_* variables. See the configuration reference for every setting. If the exporters can’t be set up, the server logs a warning and keeps running without telemetry.

For a collector to receive the data, see Using OpenTelemetry Collector.

Traces

The server creates:

  • A server span for each HTTP request, named after the method and route, for example POST /. It continues the caller’s trace when the request carries a W3C traceparent header.
  • A span for each tool call, named after the tool, for example save_state. It has the attributes mcp.tool.name, mcp.tool.package, and, where the tool targets a component, dapr.component.name, plus details such as the state key or topic. Failed calls are marked with an error status.

The tool span’s trace context is passed to the Dapr sidecar with each gRPC call, so spans from the sidecar, and from any app it calls, join the same trace when Dapr tracing exports to the same backend.

Metrics

Metrics are exported every 10 seconds by default (OTEL_METRIC_EXPORT_INTERVAL). Set DAPR_MCP_SERVER_METRICS_ENABLED=false to turn them off while keeping traces.

MetricTypeUnitDescription
dapr-mcp-server.tool.invocationsCounter{invocation}Tool calls, by outcome.
dapr-mcp-server.tool.errorsCounter{error}Tool calls that failed.
dapr-mcp-server.tool.durationHistogrammsTool call duration.
dapr-mcp-server.tool.in_progressUp-down counter{tool}Tool calls running now.
dapr-mcp-server.http.requests_totalCounter{request}MCP HTTP requests.
dapr-mcp-server.http.request_durationHistogrammsMCP HTTP request duration. Server-sent event streams are counted but not timed, because their duration is the client’s session length.
dapr-mcp-server.http.requests_in_flightUp-down counter{request}MCP HTTP requests being handled now.

Tool metrics carry only bounded attributes: tool.name and tool.package, plus outcome (success or error) on the invocation and duration metrics and error.type on the error counter. Values the agent supplies, such as component names, app IDs, and actor types, are kept off metrics and recorded on the tool’s span instead. HTTP metrics carry http.request.method, http.response.status_code, and http.route. The HTTP metrics cover MCP requests only, not the health endpoints.

Logs

The server writes structured JSON logs to stderr, never stdout, so logs can’t corrupt the stdio transport. Every record includes the service name and version. Set the level with DAPR_MCP_SERVER_LOG_LEVEL (debug, info, warn, or error; default info).

When an OTLP endpoint is set, the same records are also exported as OpenTelemetry logs, correlated with the active trace. Set DAPR_MCP_SERVER_LOGS_OTEL_ENABLED=false to keep logs on stderr only.

At debug level the server logs incoming request headers, with Authorization, Cookie, X-Api-Key, and the configured token header redacted. Each successful tool call is logged at info, and each failure at warn, with its target, such as the store name and key, but never with payloads or secret values.

Health endpoints

With --http, the server serves three endpoints on the same address as MCP. They never require authentication, and each returns JSON with a status of healthy or unhealthy and the server version.

EndpointReturns 200 whenOtherwise
/livezThe process is running.—
/startupzStartup has finished: the server connected to the sidecar and registered its tools.503
/readyzThe server is ready and the Dapr sidecar answers a metadata request within 5 seconds. The response lists each check.503
{
  "status": "healthy",
  "checks": [
    { "status": "healthy", "component": "dapr", "message": "sidecar connected", "latency": "1.2ms" }
  ],
  "version": "v0.0.1"
}

On SIGTERM or SIGINT, /readyz starts returning 503, open server-sent event streams are closed, and in-flight requests get up to 15 seconds to finish before the server exits.

The --health-check flag runs the same liveness check from the command line. It requests /livez on the address from --health-check-addr, or from --http, or localhost:8080, and exits with 0 or 1. The container image uses it as its HEALTHCHECK.

Over stdio there is no HTTP listener, so these endpoints aren’t available.