From f968487e7f8d26737d2396fbe1fe3c453697f29c Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Thu, 7 May 2026 19:35:54 -0500 Subject: [PATCH 1/8] docs(mcp): init docs for mcpserver resource Signed-off-by: Samantha Coyle --- daprdocs/content/en/concepts/terminology.md | 1 + .../content/en/developing-ai/mcp/_index.md | 21 +- .../developing-ai/mcp/howto-use-mcpserver.md | 149 +++++++++ .../developing-ai/mcp/mcp-server-resource.md | 284 ++++++++++++++++++ .../resource-specs/mcpserver-schema.md | 204 +++++++++++++ 5 files changed, 655 insertions(+), 4 deletions(-) create mode 100644 daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md create mode 100644 daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md create mode 100644 daprdocs/content/en/reference/resource-specs/mcpserver-schema.md diff --git a/daprdocs/content/en/concepts/terminology.md b/daprdocs/content/en/concepts/terminology.md index 46a4dffe2d6..33d0c5d6ff0 100644 --- a/daprdocs/content/en/concepts/terminology.md +++ b/daprdocs/content/en/concepts/terminology.md @@ -20,6 +20,7 @@ This page details all of the common terms you may come across in the Dapr docs. | Dapr control plane | A collection of services that are part of a Dapr installation on a hosting platform such as a Kubernetes cluster. This allows Dapr-enabled applications to run on the platform and handles Dapr capabilities such as actor placement, Dapr sidecar injection, or certificate issuance/rollover. | [Self-hosted overview]({{% ref self-hosted-overview %}})
[Kubernetes overview]({{% ref kubernetes-overview %}}) | Dapr Workflows | A Dapr building block for authoring code-first workflows with durable execution that survive crashes, support long-running processes, and enable human-in-the-loop interactions. | [Workflow overview]({{% ref workflow-overview %}}) | HTTPEndpoint | HTTPEndpoint is a Dapr resource use to identify non-Dapr endpoints to invoke via the service invocation API. | [Service invocation API]({{% ref service_invocation_api %}}) +| MCPServer | A Dapr resource that declares a connection to an MCP (Model Context Protocol) server for durable tool execution via built-in workflow orchestrations. | [MCPServer resource]({{% ref mcp-server-resource.md %}}) | Namespacing | Namespacing in Dapr provides isolation, and thus provides multi-tenancy. | Learn more about namespacing [components]({{% ref component-scopes %}}), [service invocation]({{% ref service-invocation-namespaces %}}), [pub/sub]({{% ref pubsub-namespaces %}}), and [actors]({{% ref namespaced-actors %}}) | Self-hosted | Windows/macOS/Linux machine(s) where you can run your applications with Dapr. Dapr provides the capability to run on machines in "self-hosted" mode. | [Self-hosted mode]({{% ref self-hosted-overview %}}) | Service | A running application or binary. This can refer to your application or to a Dapr application. diff --git a/daprdocs/content/en/developing-ai/mcp/_index.md b/daprdocs/content/en/developing-ai/mcp/_index.md index 013fc43339d..9b541edc69a 100644 --- a/daprdocs/content/en/developing-ai/mcp/_index.md +++ b/daprdocs/content/en/developing-ai/mcp/_index.md @@ -3,10 +3,23 @@ type: docs title: "MCP" linkTitle: "MCP" weight: 25 -description: "Dapr helps developers run secure and reliable Model Context Protocol (MCP) servers" +description: "Dapr helps developers run secure, reliable, and durable Model Context Protocol (MCP) server integrations" --- -### What does Dapr do for MCP servers? +### What does Dapr do for MCP servers? -Using Dapr, developers can interact securely with MCP servers and enable fine-grained ACLs with built-in tracing and metrics, as well as resiliency policies to handle situations where an MCP server might be down or unresponsive. - \ No newline at end of file +The **[MCPServer resource]({{% ref mcp-server-resource.md %}})** turns MCP integration into a deploy-time concern instead of an application-code concern. Declare a YAML resource and Dapr takes over: + +- **No MCP SDK in your app** — Dapr speaks MCP to the server. Your code starts a Dapr workflow by name. +- **Durable tool calls** — backed by Dapr Workflows + Scheduler reminders. A sidecar restart mid-call doesn't drop the request; the workflow resumes on the new instance. +- **Per-tool observability** — each tool gets its own workflow (`dapr.internal.mcp..CallTool.`), so traces, metrics, and audit logs are sliced per-tool out of the box. +- **Declarative auth** — OAuth2 client credentials, SPIFFE workload identity, or static headers configured in YAML. Dapr fetches and refreshes tokens; secrets stay out of application code. +- **Governance pipelines** — order-preserving `beforeCallTool` / `afterCallTool` (and ListTools equivalents) hooks for RBAC, rate limiting, PII redaction, audit logging, and argument transformation. Hooks are themselves Dapr workflows that can run locally or on a remote app. +- **Scoping, multi-tenancy, hot reload** — namespaced like other Dapr resources, restricted via `scopes`, and reloaded without sidecar restart. + +### Get started + +- [MCPServer resource overview]({{% ref mcp-server-resource.md %}}) +- [How-To: Use MCPServer resources]({{% ref howto-use-mcpserver.md %}}) +- [MCPServer spec reference]({{% ref mcpserver-schema %}}) +- [Authenticating an MCP server (HTTPEndpoint approach)]({{% ref mcp-authentication.md %}}) diff --git a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md new file mode 100644 index 00000000000..5e61406fd62 --- /dev/null +++ b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md @@ -0,0 +1,149 @@ +--- +type: docs +title: "How-To: Use MCPServer resources" +linkTitle: "How-To: Use MCPServer" +weight: 15 +description: "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: + +```yaml +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: + +```bash +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: +```json +{"instanceID": "abc123"} +``` + +Poll for the result: + +```bash +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 `input_schema` is the raw JSON Schema for its arguments: + +```json +{ + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + } + } + ] +} +``` + +## Step 3: Call a tool + +Each MCP tool gets its own workflow named `dapr.internal.mcp..CallTool.`. The tool name is in the workflow name, so the input only carries the arguments: + +```bash +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 a `CallMCPToolResponse` proto serialized as JSON. Each entry in `content` is a oneof — text, image, audio, resource_link, or embedded_resource: + +```json +{ + "is_error": false, + "content": [ + {"text": {"text": "Weather in Seattle: sunny, 72°F"}} + ] +} +``` + +If the tool call fails at the MCP level (e.g. unknown tool, auth error), `is_error` is `true` and the error is in `content`. The workflow itself completes successfully — `is_error` is not a workflow failure. + +## Step 4 (optional): Add authentication + +Add OAuth2 client credentials to authenticate with the MCP server: + +```yaml +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 + +Add a `beforeCallTool` hook for RBAC: + +```yaml +spec: + middleware: + beforeCallTool: + - workflow: + workflowName: rbac-check +``` + +Register a workflow named `rbac-check` in your application. It receives `{mcpServerName, toolName, arguments}` as input. Return an error to deny the call; return nil to allow it. + +Add a mutating `beforeCallTool` hook to redact arguments before the tool call: + +```yaml +spec: + middleware: + beforeCallTool: + - workflow: + workflowName: redact-pii + mutate: true +``` + +When `mutate: true`, the hook's return value replaces the arguments flowing to the tool call. The hook receives and returns a `{mcpServerName, toolName, arguments}` payload — modify the `arguments` map to redact, transform, or inject defaults. + +## Related links + +- [MCPServer resource overview]({{% ref mcp-server-resource.md %}}) +- [MCPServer spec reference]({{% ref mcpserver-schema %}}) +- [Workflow API reference]({{% ref workflow_api %}}) diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md new file mode 100644 index 00000000000..a50d21453f7 --- /dev/null +++ b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md @@ -0,0 +1,284 @@ +--- +type: docs +title: "MCPServer resource" +linkTitle: "MCPServer resource" +weight: 10 +description: "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. + +## 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. +- **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. +- **Per-tool observability.** Each tool gets its own workflow name (`dapr.internal.mcp..CallTool.`), 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. +- **Pluggable governance pipelines.** Order-preserving `beforeCallTool` / `afterCallTool` / `beforeListTools` / `afterListTools` hooks can run RBAC, rate limiting, PII redaction, audit logging, or argument transformation as Dapr workflows — locally or on a remote 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 MCPServer | With MCPServer | +|---|---| +| Application manages MCP connections, retries, and credentials | Declare YAML, Dapr handles the rest | +| Sidecar crash mid-call = lost call | Scheduler reminder re-delivers the activity, workflow resumes | +| Per-tool tracing/metrics requires custom instrumentation | One workflow per tool — built-in observability slicing | +| Each app hardcodes its own MCP connection logic | Single resource, shared across apps via `scopes` | +| Auth, PII redaction, RBAC scattered through app code | Declarative middleware hooks per operation | + +## How it works + +For each loaded MCPServer named ``, 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..ListTools` — returns the cached tool list. + - `dapr.internal.mcp..CallTool.` — 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]({{% ref workflow_api %}}). Dapr Workflows takes care of scheduling, retries on transient failures, and resuming after sidecar restarts. + +### Calling a tool + +Start a `CallTool.` workflow with just the arguments — the tool name is encoded in the workflow name itself: + +``` +POST /v1.0-beta1/workflows/dapr/dapr.internal.mcp..CallTool./start +Content-Type: application/json + +{ + "arguments": {"city": "Seattle"} +} +``` + +Poll for the result with `GET /v1.0-beta1/workflows/dapr/`. The workflow output is a `CallMCPToolResponse` proto serialized as JSON. Each entry in `content` is a oneof — text, image, audio, resource_link, or embedded_resource: + +```json +{ + "is_error": false, + "content": [ + {"text": {"text": "Weather in Seattle: sunny, 72°F"}} + ] +} +``` + +For binary content the shape is `{"image": {"mime_type": "image/png", "data": ""}}` (likewise for `audio`); for resource references it is `{"resource_link": {"resource": ""}}` or `{"embedded_resource": {...}}`. + +If the tool call fails at the MCP level (unknown tool, validation failure, server-side auth error), `is_error` 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..ListTools/start +Content-Type: application/json + +{} +``` + +Output: + +```json +{ + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + } + } + ] +} +``` + +Tool definitions are cached at MCPServer load time and refreshed on hot-reload. + +## Transports + +MCPServer supports three wire transports. Exactly one must be configured under `spec.endpoint`. + +### Streamable HTTP + +The recommended transport for production use. + +```yaml +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. + +```yaml +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. + +```yaml +apiVersion: dapr.io/v1alpha1 +kind: MCPServer +metadata: + name: local-tools +spec: + endpoint: + stdio: + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem"] +``` + +## 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`. + +```yaml +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 `secretKeyRef`s anywhere under this `auth` block (and for static-header `secretKeyRef`s on the same transport). It defaults to `kubernetes`. + +```yaml +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. + +```yaml +spec: + endpoint: + streamableHTTP: + url: https://payments.internal/mcp + auth: + spiffe: + jwt: + header: Authorization + headerValuePrefix: "Bearer " + audience: mcp://payments +``` + +## Middleware pipelines + +Optional workflow hooks can be invoked before and after tool calls and tool listing. Hooks execute in array order. + +- **Before hooks**: if any hook returns an error, the chain stops and the operation is aborted. +- **After hooks**: errors **fail the workflow** — after-hooks can act as authz gates that block the response from reaching 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). + +```yaml +spec: + middleware: + beforeCallTool: + - workflow: + workflowName: rate-limiter + - workflow: + workflowName: redact-pii + appID: auth-service # Run on a remote Dapr app + mutate: true # Hook's return value replaces the arguments + afterCallTool: + - workflow: + workflowName: audit-logger + - workflow: + workflowName: response-filter + mutate: true # Hook's return value replaces the tool result +``` + +See [MCPServer spec]({{% ref mcpserver-schema %}}) for the full middleware field reference. + +## App scoping + +Restrict which Dapr applications can use an MCPServer with `scopes`: + +```yaml +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: + +```yaml +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..*` return `ERR_WORKFLOW_NAME_RESERVED` until the server loads successfully (e.g. via hot-reload). + +## Related links + +- [MCPServer spec reference]({{% ref mcpserver-schema %}}) +- [How-To: Use MCPServer resources]({{% ref howto-use-mcpserver.md %}}) +- [Workflow API reference]({{% ref workflow_api %}}) +- [How-To: Enable preview features]({{% ref preview-features %}}) diff --git a/daprdocs/content/en/reference/resource-specs/mcpserver-schema.md b/daprdocs/content/en/reference/resource-specs/mcpserver-schema.md new file mode 100644 index 00000000000..cfc39ff4911 --- /dev/null +++ b/daprdocs/content/en/reference/resource-specs/mcpserver-schema.md @@ -0,0 +1,204 @@ +--- +type: docs +title: "MCPServer spec" +linkTitle: "MCPServer" +description: "The basic spec for a Dapr MCPServer resource" +weight: 3500 +--- + +The `MCPServer` is a Dapr resource that declares a connection to an MCP (Model Context Protocol) server. Dapr loads these at startup, discovers the server's tools, and registers built-in durable workflow orchestrations for each one: `dapr.internal.mcp..ListTools` for tool discovery and `dapr.internal.mcp..CallTool.` per discovered tool for durable tool execution. Callers invoke them through the standard [Dapr Workflow API]({{% ref workflow_api %}}). + +{{% alert title="Note" color="primary" %}} +Any MCPServer resource can be restricted to a particular [namespace]({{% ref isolation-concept.md %}}) and restricted access through scopes to any particular set of applications. +{{% /alert %}} + +## Format + +Exactly one of `streamableHTTP`, `sse`, or `stdio` must be set under `endpoint`. + +### Streamable HTTP transport + +```yaml +apiVersion: dapr.io/v1alpha1 +kind: MCPServer +metadata: + name: +spec: + endpoint: + streamableHTTP: + url: # Required. The endpoint URL of the MCP server. + protocolVersion: # Optional. MCP spec version (e.g. "2025-06-18"). + timeout: # Optional. Per-call deadline (e.g. "30s"). + headers: # Optional + - name: + value: + - name: + secretKeyRef: + name: + key: + auth: # Optional + secretStore: + oauth2: + issuer: + audience: + scopes: + - + secretKeyRef: + name: + key: + spiffe: + jwt: + header: + headerValuePrefix: + audience: + middleware: # Optional + beforeCallTool: + - workflow: + workflowName: + appID: # Optional. Remote app. + afterCallTool: + - workflow: + workflowName: + beforeListTools: + - workflow: + workflowName: + afterListTools: + - workflow: + workflowName: + catalog: # Optional. Informational only. + displayName: + description: + owner: + team: + contact: + tags: + - + links: + docs: +scopes: # Optional +- +``` + +### SSE transport + +```yaml +apiVersion: dapr.io/v1alpha1 +kind: MCPServer +metadata: + name: +spec: + endpoint: + sse: + url: + protocolVersion: # Optional + timeout: # Optional + headers: # Optional. Same format as streamableHTTP. + - name: + value: + auth: # Optional. Same format as streamableHTTP. + secretStore: +``` + +### Stdio transport + +This is not supported in Kubernetes-hosted modes. + +```yaml +apiVersion: dapr.io/v1alpha1 +kind: MCPServer +metadata: + name: +spec: + endpoint: + stdio: + command: # Required. + args: # Optional + - + env: # Optional + - name: + value: + - name: + secretKeyRef: + name: + key: +``` + +## Spec fields + +### Endpoint + +| Field | Required | Details | Example | +|-------|:--------:|---------|---------| +| endpoint.streamableHTTP | N* | Configuration for the streamable_http transport. | See format above | +| endpoint.sse | N* | Configuration for the legacy SSE transport. | See format above | +| endpoint.stdio | N* | Configuration for the stdio subprocess transport. | See format above | + +\* Exactly one of `streamableHTTP`, `sse`, or `stdio` must be set. + +### Streamable HTTP / SSE fields + +| Field | Required | Details | Example | +|-------|:--------:|---------|---------| +| url | Y | The endpoint URL of the MCP server. | `"https://mcp.example.com/"` | +| protocolVersion | N | MCP spec version in date format. When unset, the SDK negotiates automatically. | `"2025-06-18"` | +| timeout | N | Per-call deadline for MCP requests. | `"30s"` | +| headers | N | HTTP headers injected on all outbound requests. Supports `value`, `secretKeyRef`, and `envRef`. | `name: "Authorization"` `secretKeyRef.name: "my-secret"` `secretKeyRef.key: "token"` | +| auth | N | Authentication configuration. See auth fields below. | | + +### Auth fields + +| Field | Required | Details | Example | +|-------|:--------:|---------|---------| +| auth.secretStore | N | Dapr secret store for resolving `secretKeyRef` entries in headers. Defaults to `"kubernetes"`. | `"my-secret-store"` | +| auth.oauth2.issuer | Y (if oauth2) | Token endpoint of the authorization server. | `"https://auth.example.com/token"` | +| auth.oauth2.audience | N | Audience claim for the token request. | `"mcp://payments"` | +| auth.oauth2.scopes | N | Scopes requested in the token. | `["read", "write"]` | +| auth.oauth2.secretKeyRef | N | Reference to the client secret in the secret store. | `name: "oauth-secret"` `key: "clientSecret"` | +| auth.spiffe.jwt.header | Y (if spiffe) | HTTP header name to inject the JWT into. | `"Authorization"` | +| auth.spiffe.jwt.headerValuePrefix | N | String prepended to the JWT value. | `"Bearer "` | +| auth.spiffe.jwt.audience | Y (if spiffe) | Intended audience for the JWT. | `"mcp://payments"` | + +### Stdio fields + +| Field | Required | Details | Example | +|-------|:--------:|---------|---------| +| stdio.command | Y | The executable to run. | `"npx"` | +| stdio.args | N | Command-line arguments. | `["-y", "@modelcontextprotocol/server-filesystem"]` | +| stdio.env | N | Environment variables for the subprocess. Supports `value`, `secretKeyRef`, and `envRef`. | `name: "API_KEY"` `value: "secret"` | + +### Middleware fields + +Middleware hooks are executed in array order. "Before" hooks abort on error; "after" hooks log errors without affecting the result. + +| Field | Required | Details | Example | +|-------|:--------:|---------|---------| +| middleware.beforeCallTool | N | Hooks invoked before each CallTool. | See format above | +| middleware.afterCallTool | N | Hooks invoked after each CallTool. | See format above | +| middleware.beforeListTools | N | Hooks invoked before each ListTools. | See format above | +| middleware.afterListTools | N | Hooks invoked after each ListTools. | See format above | + +Each hook entry: + +| Field | Required | Details | Example | +|-------|:--------:|---------|---------| +| workflow.workflowName | Y | Name of the workflow to invoke. | `"rbac-check"` | +| workflow.appID | N | Target a remote Dapr app. When unset, runs locally. | `"auth-service"` | + +### Catalog fields + +Catalog fields are purely informational and have no effect on runtime behavior. + +| Field | Required | Details | Example | +|-------|:--------:|---------|---------| +| catalog.displayName | N | Human-readable display name. | `"Payments MCP"` | +| catalog.description | N | Description of the MCP server. | `"Payment processing tools"` | +| catalog.owner.team | N | Team responsible for the MCP server. | `"platform-team"` | +| catalog.owner.contact | N | Contact information. | `"platform@example.com"` | +| catalog.tags | N | Tags for categorization. | `["payments", "production"]` | +| catalog.links | N | Named URLs (docs, runbook, dashboard). | `docs: "https://..."` | + +## Related links + +- [MCPServer resource overview]({{% ref mcp-server-resource.md %}}) +- [How-To: Use MCPServer resources]({{% ref howto-use-mcpserver.md %}}) +- [How-To: Enable preview features]({{% ref preview-features %}}) From 751193bb204167483e5bffedd1a8ec4c636fa1f4 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Fri, 8 May 2026 14:06:57 -0500 Subject: [PATCH 2/8] feat: add more indepth details everywhere Signed-off-by: Samantha Coyle --- .../developing-ai/mcp/howto-use-mcpserver.md | 15 ++++ .../developing-ai/mcp/mcp-server-resource.md | 74 ++++++++++++++++++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md index 5e61406fd62..b74eaff5f3b 100644 --- a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md +++ b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md @@ -89,6 +89,8 @@ Poll for the result as in Step 2. The output is a `CallMCPToolResponse` proto se If the tool call fails at the MCP level (e.g. unknown tool, auth error), `is_error` is `true` and the error is in `content`. The workflow itself completes successfully — `is_error` is not a workflow failure. +If your call is missing a required argument, you get the same `is_error: 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: @@ -142,6 +144,19 @@ spec: When `mutate: true`, the hook's return value replaces the arguments flowing to the tool call. The hook receives and returns a `{mcpServerName, toolName, arguments}` payload — modify the `arguments` map to redact, transform, or inject defaults. +To run the hook on a different Dapr app instead of locally, add `appID`: + +```yaml +spec: + middleware: + beforeCallTool: + - workflow: + workflowName: rbac-check + appID: policy-service # runs on the Dapr app named "policy-service" +``` + +This lets a single shared policy app (RBAC, audit, PII redaction) govern many agent apps without each app embedding the policy. Operators update the central app once; every MCPServer that references it picks up the change. + ## Related links - [MCPServer resource overview]({{% ref mcp-server-resource.md %}}) diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md index a50d21453f7..e453b553d1c 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md @@ -15,7 +15,8 @@ The `MCPServer` resource lets you declare MCP (Model Context Protocol) server co 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. -- **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. +- **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 `CallMCPToolResponse{is_error: 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..CallTool.`), 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. - **Pluggable governance pipelines.** Order-preserving `beforeCallTool` / `afterCallTool` / `beforeListTools` / `afterListTools` hooks can run RBAC, rate limiting, PII redaction, audit logging, or argument transformation as Dapr workflows — locally or on a remote app. @@ -30,6 +31,19 @@ MCPServer turns MCP integration into a deploy-time concern instead of an applica | Each app hardcodes its own MCP connection logic | Single resource, shared across apps via `scopes` | | Auth, PII redaction, RBAC scattered through app code | Declarative middleware hooks per operation | +### What if I just write my own workflow that calls an MCP server? + +You can — but then you own all of this: + +- **Connection / session lifecycle.** MCP sessions are stateful (handshake, capability negotiation, persistent SSE channel for the streamable HTTP transport). Your workflow code would need to open, hold, and tear down that session, with retry logic if the server drops you mid-call. With `MCPServer`, the sidecar maintains one connection per resource, runs keep-alive pings, and auto-reconnects on connection-closed errors — transparently to the workflow. +- **Credential plumbing.** OAuth2 token fetch + refresh, SPIFFE SVID minting per request, secret-store resolution for headers — all of that runs in your workflow code, with credentials reachable from every place your workflow runs. With `MCPServer`, credentials never leave the sidecar; tokens refresh in the background, SVIDs mint per-request from Sentry, and your workflow code sees nothing. +- **Tool discovery.** Listing tools is itself a stateful MCP call. A homegrown workflow has to call `tools/list` every cold start (or build its own caching, with cache-invalidation on hot-reload). With `MCPServer`, daprd does this once at load time and serves subsequent `ListTools` workflow calls from cache — zero upstream round-trips. +- **Per-tool observability.** A homegrown workflow gives you one workflow name across all tools — every span and metric is bucketed together. `MCPServer` registers `dapr.internal.mcp..CallTool.` per tool, so traces, metrics, and audit logs are sliced per-tool out of the box. +- **Governance hooks.** RBAC, rate limiting, PII redaction, audit logging, etc. all become hand-rolled middleware in your workflow. With `MCPServer`, the same hooks are declared in YAML (`spec.middleware.beforeCallTool` / `afterCallTool` / `beforeListTools` / `afterListTools`) and can run locally or be routed to a central policy app via `appID`. +- **Hot reload.** Updating credentials, headers, or the endpoint URL requires redeploying or reloading your code. `MCPServer` resources are watched and reloaded by daprd without a sidecar restart. + +The `MCPServer` workflows are themselves Dapr Workflows — you get all the durable-execution properties (Scheduler reminder re-delivery on daprd restart, replay determinism, future support for `wait_for_external_event`-based MCP elicitation / sampling) without writing them. + ## How it works For each loaded MCPServer named ``, daprd: @@ -42,6 +56,8 @@ For each loaded MCPServer named ``, daprd: Callers start these workflows through the standard [Dapr Workflow API]({{% ref 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.` workflow with just the arguments — the tool name is encoded in the workflow name itself: @@ -99,7 +115,7 @@ Output: } ``` -Tool definitions are cached at MCPServer load time and refreshed on hot-reload. +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 @@ -152,6 +168,16 @@ spec: 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. @@ -197,7 +223,7 @@ spec: ### SPIFFE workload identity -Dapr injects a SPIFFE JWT SVID per request. No secrets needed — Sentry issues the SVID automatically. +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. ```yaml spec: @@ -238,8 +264,26 @@ spec: mutate: true # Hook's return value replaces the tool result ``` +When a hook sets `appID: `, that hook workflow runs on the named remote Dapr app via service invocation rather than locally. This is how a single shared policy app — a centralized RBAC service, audit logger, or PII redactor — can govern many agent apps without each app embedding the policy. Operators update the central app once; every MCPServer that references it picks up the change without redeploying its callers. + See [MCPServer spec]({{% ref mcpserver-schema %}}) for the full middleware field reference. +## Observability and access control + +Because each MCP tool gets its own workflow name (`dapr.internal.mcp..CallTool.`), 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..CallTool.` (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. + +## 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`: @@ -258,6 +302,27 @@ scopes: - agent-app-2 ``` +## Catalog metadata + +`spec.catalog` carries informational fields that don't affect runtime behavior but are useful for service catalogs, internal portals, ownership tracking, and compliance tooling. Populate them when publishing MCPServer resources to a wider org so operators can see at a glance who owns each integration and where to find documentation: + +```yaml +spec: + catalog: + displayName: Payments MCP + description: Tools for charging customers and issuing refunds. + owner: + team: payments-platform + contact: payments-oncall@example.com + tags: ["payments", "production", "pii"] + links: + docs: https://wiki.internal/payments-mcp + runbook: https://wiki.internal/payments-mcp/runbook + dashboard: https://grafana.internal/d/payments-mcp +``` + +See the [MCPServer spec]({{% ref mcpserver-schema %}}) for the full list of catalog fields. + ## 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: @@ -281,4 +346,5 @@ When `ignoreErrors` is `true` and load fails, the MCPServer's workflows are not - [MCPServer spec reference]({{% ref mcpserver-schema %}}) - [How-To: Use MCPServer resources]({{% ref howto-use-mcpserver.md %}}) - [Workflow API reference]({{% ref workflow_api %}}) -- [How-To: Enable preview features]({{% ref preview-features %}}) +- Python SDK: `DaprMCPClient` — framework-agnostic client for invoking MCPServer tools from any agent framework (see the python-sdk docs) +- dapr-agents: zero-config MCPServer tool discovery — `DurableAgent` automatically picks up MCPServer tools from sidecar metadata (see the dapr-agents docs) From daabfcc2f062316589e1606fab53d6254bb58d9e Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Mon, 11 May 2026 08:25:22 -0500 Subject: [PATCH 3/8] feat: add bit more clarity on new resource middleware Signed-off-by: Samantha Coyle --- .../content/en/developing-ai/mcp/_index.md | 2 +- .../developing-ai/mcp/howto-use-mcpserver.md | 56 +++++++- .../developing-ai/mcp/mcp-server-resource.md | 126 ++++++++++++++---- 3 files changed, 153 insertions(+), 31 deletions(-) diff --git a/daprdocs/content/en/developing-ai/mcp/_index.md b/daprdocs/content/en/developing-ai/mcp/_index.md index 9b541edc69a..4df159ad71b 100644 --- a/daprdocs/content/en/developing-ai/mcp/_index.md +++ b/daprdocs/content/en/developing-ai/mcp/_index.md @@ -11,10 +11,10 @@ description: "Dapr helps developers run secure, reliable, and durable Model Cont The **[MCPServer resource]({{% ref mcp-server-resource.md %}})** turns MCP integration into a deploy-time concern instead of an application-code concern. Declare a YAML resource and Dapr takes over: - **No MCP SDK in your app** — Dapr speaks MCP to the server. Your code starts a Dapr workflow by name. +- **Per-tool RBAC, audit, and redaction in YAML** — `beforeCallTool` / `afterCallTool` (and ListTools equivalents) hooks run as Dapr workflows; centralizable across apps via `appID`. - **Durable tool calls** — backed by Dapr Workflows + Scheduler reminders. A sidecar restart mid-call doesn't drop the request; the workflow resumes on the new instance. - **Per-tool observability** — each tool gets its own workflow (`dapr.internal.mcp..CallTool.`), so traces, metrics, and audit logs are sliced per-tool out of the box. - **Declarative auth** — OAuth2 client credentials, SPIFFE workload identity, or static headers configured in YAML. Dapr fetches and refreshes tokens; secrets stay out of application code. -- **Governance pipelines** — order-preserving `beforeCallTool` / `afterCallTool` (and ListTools equivalents) hooks for RBAC, rate limiting, PII redaction, audit logging, and argument transformation. Hooks are themselves Dapr workflows that can run locally or on a remote app. - **Scoping, multi-tenancy, hot reload** — namespaced like other Dapr resources, restricted via `scopes`, and reloaded without sidecar restart. ### Get started diff --git a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md index b74eaff5f3b..6f679ee6010 100644 --- a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md +++ b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md @@ -119,7 +119,9 @@ Dapr fetches a token from the issuer and injects it as a Bearer token on every M ## Step 5 (optional): Add middleware -Add a `beforeCallTool` hook for RBAC: +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) ```yaml spec: @@ -129,9 +131,36 @@ spec: workflowName: rbac-check ``` -Register a workflow named `rbac-check` in your application. It receives `{mcpServerName, toolName, arguments}` as input. Return an error to deny the call; return nil to allow it. +Register a workflow named `rbac-check` in your application. It receives an `MCPBeforeCallToolHookInput`: + +```text +{ name, tool_name, arguments } +``` + +`name` is the MCPServer resource name; `arguments` is the JSON object the caller passed. Return an error to deny; return nil to allow. + +```text +workflow rbac-check(input): + # Argument-level RBAC: inspect the payload and decide. + if input.tool_name == "issue_refund": + if input.arguments["amount"] > 10_000: + return error("rbac: refunds over $10K require manual approval") + + if input.tool_name in DESTRUCTIVE_TOOLS: + if not input.arguments.get("dry_run", false): + return error("rbac: %s requires dry_run=true", + input.tool_name) + + 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`]({{% ref workflow_api %}}) 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. -Add a mutating `beforeCallTool` hook to redact arguments before the tool call: +### 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`: ```yaml spec: @@ -142,9 +171,22 @@ spec: mutate: true ``` -When `mutate: true`, the hook's return value replaces the arguments flowing to the tool call. The hook receives and returns a `{mcpServerName, toolName, arguments}` payload — modify the `arguments` map to redact, transform, or inject defaults. +```text +workflow redact-pii(input): + # input: { name, tool_name, arguments } + args = copy(input.arguments) + if "email" in args: + args["email"] = mask_email(args["email"]) + return { name: input.name, tool_name: input.tool_name, 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]({{% ref "mcp-server-resource.md#examples-common-patterns" %}}) for the full set of patterns. -To run the hook on a different Dapr app instead of locally, add `appID`: +### Step 5.3: Centralize policy on a shared app + +To run the hook on a dedicated policy app instead of locally, add `appID`: ```yaml spec: @@ -155,7 +197,9 @@ spec: appID: policy-service # runs on the Dapr app named "policy-service" ``` -This lets a single shared policy app (RBAC, audit, PII redaction) govern many agent apps without each app embedding the policy. Operators update the central app once; every MCPServer that references it picks up the change. +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]({{% ref "mcp-server-resource.md#examples-common-patterns" %}}) for canonical hook patterns (RBAC, rate limiting, audit, response filtering, tool catalog filtering). ## Related links diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md index e453b553d1c..6fb5d1a349b 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md @@ -15,11 +15,11 @@ The `MCPServer` resource lets you declare MCP (Model Context Protocol) server co 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 `CallMCPToolResponse{is_error: 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..CallTool.`), 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. -- **Pluggable governance pipelines.** Order-preserving `beforeCallTool` / `afterCallTool` / `beforeListTools` / `afterListTools` hooks can run RBAC, rate limiting, PII redaction, audit logging, or argument transformation as Dapr workflows — locally or on a remote 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. @@ -29,20 +29,7 @@ MCPServer turns MCP integration into a deploy-time concern instead of an applica | Sidecar crash mid-call = lost call | Scheduler reminder re-delivers the activity, workflow resumes | | Per-tool tracing/metrics requires custom instrumentation | One workflow per tool — built-in observability slicing | | Each app hardcodes its own MCP connection logic | Single resource, shared across apps via `scopes` | -| Auth, PII redaction, RBAC scattered through app code | Declarative middleware hooks per operation | - -### What if I just write my own workflow that calls an MCP server? - -You can — but then you own all of this: - -- **Connection / session lifecycle.** MCP sessions are stateful (handshake, capability negotiation, persistent SSE channel for the streamable HTTP transport). Your workflow code would need to open, hold, and tear down that session, with retry logic if the server drops you mid-call. With `MCPServer`, the sidecar maintains one connection per resource, runs keep-alive pings, and auto-reconnects on connection-closed errors — transparently to the workflow. -- **Credential plumbing.** OAuth2 token fetch + refresh, SPIFFE SVID minting per request, secret-store resolution for headers — all of that runs in your workflow code, with credentials reachable from every place your workflow runs. With `MCPServer`, credentials never leave the sidecar; tokens refresh in the background, SVIDs mint per-request from Sentry, and your workflow code sees nothing. -- **Tool discovery.** Listing tools is itself a stateful MCP call. A homegrown workflow has to call `tools/list` every cold start (or build its own caching, with cache-invalidation on hot-reload). With `MCPServer`, daprd does this once at load time and serves subsequent `ListTools` workflow calls from cache — zero upstream round-trips. -- **Per-tool observability.** A homegrown workflow gives you one workflow name across all tools — every span and metric is bucketed together. `MCPServer` registers `dapr.internal.mcp..CallTool.` per tool, so traces, metrics, and audit logs are sliced per-tool out of the box. -- **Governance hooks.** RBAC, rate limiting, PII redaction, audit logging, etc. all become hand-rolled middleware in your workflow. With `MCPServer`, the same hooks are declared in YAML (`spec.middleware.beforeCallTool` / `afterCallTool` / `beforeListTools` / `afterListTools`) and can run locally or be routed to a central policy app via `appID`. -- **Hot reload.** Updating credentials, headers, or the endpoint URL requires redeploying or reloading your code. `MCPServer` resources are watched and reloaded by daprd without a sidecar restart. - -The `MCPServer` workflows are themselves Dapr Workflows — you get all the durable-execution properties (Scheduler reminder re-delivery on daprd restart, replay determinism, future support for `wait_for_external_event`-based MCP elicitation / sampling) without writing them. +| Tool-call RBAC and audit logic embedded in agent code | Declared per MCPServer in YAML, enforced as durable workflows, centralizable via `appID` | ## How it works @@ -240,33 +227,122 @@ spec: ## Middleware pipelines -Optional workflow hooks can be invoked before and after tool calls and tool listing. Hooks execute in array order. +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](#examples-common-patterns) below for the canonical patterns. - **Before hooks**: if any hook returns an error, the chain stops and the operation is aborted. - **After hooks**: errors **fail the workflow** — after-hooks can act as authz gates that block the response from reaching 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). +### Hook input shapes + +Each hook is a Dapr workflow that receives a typed input from the runtime. Field names match the proto definitions: + +```text +beforeCallTool input: { name, tool_name, arguments } +afterCallTool input: { name, tool_name, arguments, result } # result is CallMCPToolResponse +beforeListTools input: { name } +afterListTools input: { name, result } # result is ListMCPToolsResponse +``` + +`name` is the MCPServer resource name. `arguments` is the JSON object the caller passed. `result` is whatever the MCP server (or a previous mutating hook) produced. 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`: + ```yaml spec: middleware: beforeCallTool: - workflow: - workflowName: rate-limiter + workflowName: rbac-check + appID: policy-service # optional — see "Centralized policy app" below +``` + +Workflow body (pseudocode — language-neutral): + +```text +workflow rbac-check(input): + # input: { name, tool_name, arguments } + if input.tool_name == "issue_refund": + amount = input.arguments["amount"] + if amount > 10_000: + return error("rbac: refunds over $10K require manual approval") + + if input.tool_name in DESTRUCTIVE_TOOLS: + if not input.arguments.get("dry_run", false): + return error("rbac: %s requires dry_run=true in this environment", + input.tool_name) + + 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](#observability-and-access-control), 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: + +```yaml +spec: + middleware: + afterCallTool: + - workflow: + workflowName: audit-logger +``` + +```text +workflow audit-logger(input): + # input: { name, tool_name, arguments, result } + emit_audit({ + server: input.name, + tool: input.tool_name, + args: redact(input.arguments), + succeeded: not input.result.is_error, + 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: `, 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. + +```yaml +spec: + middleware: + beforeCallTool: + - workflow: + workflowName: rbac-check + appID: policy-service - workflow: workflowName: redact-pii - appID: auth-service # Run on a remote Dapr app - mutate: true # Hook's return value replaces the arguments + appID: policy-service + mutate: true afterCallTool: - workflow: workflowName: audit-logger - - workflow: - workflowName: response-filter - mutate: true # Hook's return value replaces the tool result + appID: policy-service ``` -When a hook sets `appID: `, that hook workflow runs on the named remote Dapr app via service invocation rather than locally. This is how a single shared policy app — a centralized RBAC service, audit logger, or PII redactor — can govern many agent apps without each app embedding the policy. Operators update the central app once; every MCPServer that references it picks up the change without redeploying its callers. +### Examples: common patterns -See [MCPServer spec]({{% ref mcpserver-schema %}}) for the full middleware field reference. +| Pattern | Phase | `mutate` | Sketch | +|---|---|---|---| +| Argument RBAC | `beforeCallTool` | `false` | Inspect `arguments`, return error to deny. | +| Rate limiting | `beforeCallTool` | `false` | Look up budget keyed by `tool_name`; return error when exhausted. | +| PII redaction (request) | `beforeCallTool` | `true` | Transform `arguments`, return the cleaned shape. | +| Audit logging | `afterCallTool` | `false` | Emit `{tool_name, arguments, result.is_error}` to a state store / log sink. | +| Response filtering | `afterCallTool` | `true` | Strip / mask fields in `result.content`, return updated `CallMCPToolResponse`. | +| Tool catalog filtering | `afterListTools` | `true` | Drop tools the caller isn't entitled to discover, return updated `ListMCPToolsResponse`. | + +Each pattern is a single workflow with the input/output shape from [Hook input shapes](#hook-input-shapes) above. See the [MCPServer spec]({{% ref mcpserver-schema %}}) for the full middleware field reference. ## Observability and access control @@ -274,6 +350,8 @@ Because each MCP tool gets its own workflow name (`dapr.internal.mcp..Ca 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..CallTool.` (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](#middleware-pipelines) compose, they don't overlap. `WorkflowAccessPolicy` decides *whether a caller can start `CallTool.` 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. + ## 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: From c75652a1d06dbae51e7ab673e401e2ff3c416e9a Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Mon, 11 May 2026 11:08:09 -0500 Subject: [PATCH 4/8] style: address copilot feedback Signed-off-by: Samantha Coyle --- .../developing-ai/mcp/howto-use-mcpserver.md | 16 ++-- .../developing-ai/mcp/mcp-server-resource.md | 47 +++++------ .../resource-specs/mcpserver-schema.md | 79 ++++++++++++------- 3 files changed, 82 insertions(+), 60 deletions(-) diff --git a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md index 6f679ee6010..3b19e275a51 100644 --- a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md +++ b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md @@ -127,8 +127,8 @@ Middleware hooks let you run authorization, redaction, and audit as Dapr workflo spec: middleware: beforeCallTool: - - workflow: - workflowName: rbac-check + - workflow: + workflowName: rbac-check ``` Register a workflow named `rbac-check` in your application. It receives an `MCPBeforeCallToolHookInput`: @@ -166,9 +166,9 @@ To transform `arguments` before they reach the tool — redact PII, normalize va spec: middleware: beforeCallTool: - - workflow: - workflowName: redact-pii - mutate: true + - workflow: + workflowName: redact-pii + mutate: true ``` ```text @@ -192,9 +192,9 @@ 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" + - 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. diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md index 6fb5d1a349b..c46b91bee42 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md @@ -179,10 +179,10 @@ spec: streamableHTTP: url: https://api.example.com/mcp headers: - - name: Authorization - secretKeyRef: - name: mcp-token - key: token + - name: Authorization + secretKeyRef: + name: mcp-token + key: token auth: secretStore: kubernetes ``` @@ -230,8 +230,9 @@ spec: 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](#examples-common-patterns) below for the canonical patterns. - **Before hooks**: if any hook returns an error, the chain stops and the operation is aborted. -- **After hooks**: errors **fail the workflow** — after-hooks can act as authz gates that block the response from reaching 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). +- **`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 @@ -254,9 +255,9 @@ A common need is "deny this tool call based on what's in `arguments`" — for ex spec: middleware: beforeCallTool: - - workflow: - workflowName: rbac-check - appID: policy-service # optional — see "Centralized policy app" below + - workflow: + workflowName: rbac-check + appID: policy-service # optional — see "Centralized policy app" below ``` Workflow body (pseudocode — language-neutral): @@ -291,8 +292,8 @@ After-hooks observe the result. Wire an `afterCallTool` hook with `mutate: false spec: middleware: afterCallTool: - - workflow: - workflowName: audit-logger + - workflow: + workflowName: audit-logger ``` ```text @@ -318,17 +319,17 @@ When a hook sets `appID: `, the hook workflow runs on the named remot spec: middleware: beforeCallTool: - - workflow: - workflowName: rbac-check - appID: policy-service - - workflow: - workflowName: redact-pii - appID: policy-service - mutate: true + - workflow: + workflowName: rbac-check + appID: policy-service + - workflow: + workflowName: redact-pii + appID: policy-service + mutate: true afterCallTool: - - workflow: - workflowName: audit-logger - appID: policy-service + - workflow: + workflowName: audit-logger + appID: policy-service ``` ### Examples: common patterns @@ -376,8 +377,8 @@ spec: streamableHTTP: url: https://payments.internal/mcp scopes: -- agent-app-1 -- agent-app-2 + - agent-app-1 + - agent-app-2 ``` ## Catalog metadata diff --git a/daprdocs/content/en/reference/resource-specs/mcpserver-schema.md b/daprdocs/content/en/reference/resource-specs/mcpserver-schema.md index cfc39ff4911..b28ea2ec5a0 100644 --- a/daprdocs/content/en/reference/resource-specs/mcpserver-schema.md +++ b/daprdocs/content/en/reference/resource-specs/mcpserver-schema.md @@ -24,25 +24,27 @@ kind: MCPServer metadata: name: spec: + ignoreErrors: # Optional. When true, daprd keeps running if this MCPServer fails to load. endpoint: streamableHTTP: url: # Required. The endpoint URL of the MCP server. protocolVersion: # Optional. MCP spec version (e.g. "2025-06-18"). timeout: # Optional. Per-call deadline (e.g. "30s"). headers: # Optional - - name: - value: - - name: - secretKeyRef: - name: - key: + - name: + value: + - name: + secretKeyRef: + name: + key: auth: # Optional secretStore: oauth2: issuer: + clientID: # Optional. OAuth2 client identifier. audience: scopes: - - + - secretKeyRef: name: key: @@ -53,18 +55,21 @@ spec: audience: middleware: # Optional beforeCallTool: - - workflow: - workflowName: - appID: # Optional. Remote app. + - workflow: + workflowName: + appID: # Optional. Remote app. + mutate: # Optional. When true, hook return value replaces the arguments. afterCallTool: - - workflow: - workflowName: + - workflow: + workflowName: + mutate: # Optional. When true, hook return value replaces the result. beforeListTools: - - workflow: - workflowName: + - workflow: + workflowName: afterListTools: - - workflow: - workflowName: + - workflow: + workflowName: + mutate: # Optional. When true, hook return value replaces the result. catalog: # Optional. Informational only. displayName: description: @@ -72,11 +77,11 @@ spec: team: contact: tags: - - + - links: docs: scopes: # Optional -- + - ``` ### SSE transport @@ -93,8 +98,8 @@ spec: protocolVersion: # Optional timeout: # Optional headers: # Optional. Same format as streamableHTTP. - - name: - value: + - name: + value: auth: # Optional. Same format as streamableHTTP. secretStore: ``` @@ -113,23 +118,32 @@ spec: stdio: command: # Required. args: # Optional - - + - env: # Optional - - name: - value: - - name: - secretKeyRef: - name: - key: + - name: + value: + - name: + secretKeyRef: + name: + key: ``` ## Spec fields +### Top-level + +| Field | Required | Details | Example | +|-------|:--------:|---------|---------| +| ignoreErrors | N | When `true`, daprd keeps running if this MCPServer fails validation or secret resolution. When `false` (default), such failures cause daprd to exit gracefully. | `true` | +| endpoint | Y | The transport and target of the MCP server. See [Endpoint](#endpoint) below. | | +| middleware | N | Optional workflow hooks invoked around tool and list operations. See [Middleware fields](#middleware-fields) below. | | +| catalog | N | Informational governance metadata. See [Catalog fields](#catalog-fields) below. | | + ### Endpoint | Field | Required | Details | Example | |-------|:--------:|---------|---------| -| endpoint.streamableHTTP | N* | Configuration for the streamable_http transport. | See format above | +| endpoint.streamableHTTP | N* | Configuration for the streamable HTTP transport. | See format above | | endpoint.sse | N* | Configuration for the legacy SSE transport. | See format above | | endpoint.stdio | N* | Configuration for the stdio subprocess transport. | See format above | @@ -151,6 +165,7 @@ spec: |-------|:--------:|---------|---------| | auth.secretStore | N | Dapr secret store for resolving `secretKeyRef` entries in headers. Defaults to `"kubernetes"`. | `"my-secret-store"` | | auth.oauth2.issuer | Y (if oauth2) | Token endpoint of the authorization server. | `"https://auth.example.com/token"` | +| auth.oauth2.clientID | N | OAuth2 client identifier sent to the token endpoint. Required by RFC 6749 for standard `client_credentials` flow; may be left empty for non-standard flows. | `"my-client-id"` | | auth.oauth2.audience | N | Audience claim for the token request. | `"mcp://payments"` | | auth.oauth2.scopes | N | Scopes requested in the token. | `["read", "write"]` | | auth.oauth2.secretKeyRef | N | Reference to the client secret in the secret store. | `name: "oauth-secret"` `key: "clientSecret"` | @@ -168,7 +183,12 @@ spec: ### Middleware fields -Middleware hooks are executed in array order. "Before" hooks abort on error; "after" hooks log errors without affecting the result. +Middleware hooks are executed in array order. Error behavior differs by hook type: + +- `beforeCallTool` errors abort the chain; the workflow completes with `CallToolResult{isError: true}` so the caller can self-correct. +- `beforeListTools` errors abort the chain and the error is returned. +- `afterCallTool` errors **fail the workflow** — these hooks can act as authorization gates that block the response. +- `afterListTools` errors are logged but do not affect the result. | Field | Required | Details | Example | |-------|:--------:|---------|---------| @@ -183,6 +203,7 @@ Each hook entry: |-------|:--------:|---------|---------| | workflow.workflowName | Y | Name of the workflow to invoke. | `"rbac-check"` | | workflow.appID | N | Target a remote Dapr app. When unset, runs locally. | `"auth-service"` | +| mutate | N | When `true`, the hook's return value replaces the data flowing through the pipeline (arguments for `beforeCallTool`; result for `afterCallTool` and `afterListTools`). When `false` (default), the hook is observe-only. Not supported on `beforeListTools`. | `true` | ### Catalog fields From 61dadd3a59090431c8a36f0ec9174ae9b01bd561 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Fri, 29 May 2026 10:59:50 -0500 Subject: [PATCH 5/8] refactor: revamp after conversations/feedback Signed-off-by: Samantha Coyle --- .../content/en/developing-ai/mcp/_index.md | 72 +++++++- .../developing-ai/mcp/howto-use-mcpserver.md | 26 +-- .../developing-ai/mcp/mcp-access-control.md | 165 ++++++++++++++++++ .../developing-ai/mcp/mcp-authentication.md | 42 +++++ .../en/developing-ai/mcp/mcp-security.md | 76 ++++++++ .../developing-ai/mcp/mcp-server-resource.md | 64 ++++--- .../mcp/mcp-service-invocation.md | 146 ++++++++++++++++ 7 files changed, 550 insertions(+), 41 deletions(-) create mode 100644 daprdocs/content/en/developing-ai/mcp/mcp-access-control.md create mode 100644 daprdocs/content/en/developing-ai/mcp/mcp-security.md create mode 100644 daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md diff --git a/daprdocs/content/en/developing-ai/mcp/_index.md b/daprdocs/content/en/developing-ai/mcp/_index.md index 4df159ad71b..174133213f4 100644 --- a/daprdocs/content/en/developing-ai/mcp/_index.md +++ b/daprdocs/content/en/developing-ai/mcp/_index.md @@ -6,9 +6,57 @@ weight: 25 description: "Dapr helps developers run secure, reliable, and durable Model Context Protocol (MCP) server integrations" --- -### What does Dapr do for MCP servers? +Dapr governs MCP traffic the same way it governs any other service-to-service call: App ID identity, access policies, HTTP middleware, mTLS, observability, and resiliency. There are two ways to plug MCP into Dapr — pick the one that matches your client and your authorization needs. -The **[MCPServer resource]({{% ref mcp-server-resource.md %}})** turns MCP integration into a deploy-time concern instead of an application-code concern. Declare a YAML resource and Dapr takes over: +## Two integration paths + +### Choosing your path + +| If you… | Use | +|---|---| +| Use an off-the-shelf MCP client or framework (LangGraph, the official MCP SDK, etc.) and want unchanged client code | **[Service invocation path]({{% ref mcp-service-invocation.md %}})** | +| Need argument-level RBAC, audit, or redaction hooks on a per-tool basis | **[`MCPServer` resource path]({{% ref mcp-server-resource.md %}})** | +| Need durable retries that survive a sidecar restart mid-call | **[`MCPServer` resource path]({{% ref mcp-server-resource.md %}})** | +| Want the simplest setup that works with any framework | **[Service invocation path]({{% ref mcp-service-invocation.md %}})** | +| Want per-tool observability slicing (one workflow per tool) | **[`MCPServer` resource path]({{% ref mcp-server-resource.md %}})** | + +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 and if you want durable MCP interactions. + +### Path A — Service invocation (recommended for most teams) + +The agent's existing MCP client points at the local Dapr sidecar (`http://localhost:3500/v1.0/invoke//method/mcp`, or sets `dapr-app-id: `). Dapr resolves the target by App ID, applies the `accessControl` policies and HTTP middleware attached to the MCP server's App ID, and forwards the request. **Off-the-shelf MCP clients and agent frameworks work unchanged.** + +```mermaid +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 +``` + +Get started: + +- [MCP through Dapr service invocation]({{% ref mcp-service-invocation.md %}}) — quickstart and architecture +- [Authenticating an MCP server]({{% ref mcp-authentication.md %}}) — OAuth2 and bearer middleware +- [MCP access control]({{% ref mcp-access-control.md %}}) — `Configuration` `accessControl` for MCP + +### Path B — `MCPServer` resource (workflow-centric) + +The **[`MCPServer` resource]({{% ref mcp-server-resource.md %}})** turns MCP integration into a deploy-time concern instead of an application-code concern. Declare a YAML resource and Dapr takes over: - **No MCP SDK in your app** — Dapr speaks MCP to the server. Your code starts a Dapr workflow by name. - **Per-tool RBAC, audit, and redaction in YAML** — `beforeCallTool` / `afterCallTool` (and ListTools equivalents) hooks run as Dapr workflows; centralizable across apps via `appID`. @@ -17,9 +65,23 @@ The **[MCPServer resource]({{% ref mcp-server-resource.md %}})** turns MCP integ - **Declarative auth** — OAuth2 client credentials, SPIFFE workload identity, or static headers configured in YAML. Dapr fetches and refreshes tokens; secrets stay out of application code. - **Scoping, multi-tenancy, hot reload** — namespaced like other Dapr resources, restricted via `scopes`, and reloaded without sidecar restart. -### Get started +This path requires the [Dapr Workflow]({{% ref workflow-overview %}}) client to invoke tools — off-the-shelf MCP clients and agent frameworks won't drive `MCPServer`-backed tool calls. + +Get started: -- [MCPServer resource overview]({{% ref mcp-server-resource.md %}}) +- [`MCPServer` resource overview]({{% ref mcp-server-resource.md %}}) - [How-To: Use MCPServer resources]({{% ref howto-use-mcpserver.md %}}) - [MCPServer spec reference]({{% ref mcpserver-schema %}}) -- [Authenticating an MCP server (HTTPEndpoint approach)]({{% ref mcp-authentication.md %}}) + +## Security at a glance + +Both paths use the same underlying Dapr security primitives. The three layers compose for defense in depth: + +| Layer | What it controls | Reference | +|---|---|---| +| **mTLS + SPIFFE identity** | Every Dapr-to-Dapr call is mutually authenticated using identities Sentry issues and rotates automatically. On by default. | [Dapr mTLS]({{% ref mtls.md %}}) | +| **`Configuration` `accessControl`** | Which caller App IDs may reach which MCP servers. Default-deny is supported. | [MCP access control]({{% ref mcp-access-control.md %}}) | +| **HTTP middleware (bearer / OAuth2)** | Inbound JWT validation on `appHttpPipeline`; outbound token acquisition on `httpPipeline`. | [Authenticating an MCP server]({{% ref mcp-authentication.md %}}) | +| **(`MCPServer` resource only) Workflow hooks** | Argument-level RBAC, audit, redaction, response filtering — runs as durable workflows around the tool call. | [`MCPServer` resource]({{% ref mcp-server-resource.md %}}) | + +For the threat-model framing, default postures, and what stays your responsibility, see [MCP security posture]({{% ref mcp-security.md %}}). diff --git a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md index 3b19e275a51..1f7cbb50ef7 100644 --- a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md +++ b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md @@ -46,7 +46,7 @@ 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 `input_schema` is the raw JSON Schema for its arguments: +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: ```json { @@ -54,7 +54,7 @@ When `runtimeStatus` is `"COMPLETED"`, the `properties["dapr.workflow.output"]` { "name": "get_weather", "description": "Get current weather for a city", - "input_schema": { + "inputSchema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"] @@ -76,20 +76,20 @@ curl -X POST "http://localhost:3500/v1.0-beta1/workflows/dapr/dapr.internal.mcp. }' ``` -Poll for the result as in Step 2. The output is a `CallMCPToolResponse` proto serialized as JSON. Each entry in `content` is a oneof — text, image, audio, resource_link, or embedded_resource: +Poll for the result as in Step 2. The output is an [MCP `CallToolResult`](https://modelcontextprotocol.io/specification/2024-11-05/server/tools) — byte-for-byte the same shape as the MCP wire spec. Each entry in `content` is a flat tagged union with a `type` discriminator: ```json { - "is_error": false, + "isError": false, "content": [ - {"text": {"text": "Weather in Seattle: sunny, 72°F"}} + {"type": "text", "text": "Weather in Seattle: sunny, 72°F"} ] } ``` -If the tool call fails at the MCP level (e.g. unknown tool, auth error), `is_error` is `true` and the error is in `content`. The workflow itself completes successfully — `is_error` is not a workflow failure. +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 `is_error: 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. +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 @@ -134,7 +134,7 @@ spec: Register a workflow named `rbac-check` in your application. It receives an `MCPBeforeCallToolHookInput`: ```text -{ name, tool_name, arguments } +{ 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. @@ -142,14 +142,14 @@ Register a workflow named `rbac-check` in your application. It receives an `MCPB ```text workflow rbac-check(input): # Argument-level RBAC: inspect the payload and decide. - if input.tool_name == "issue_refund": + if input.toolName == "issue_refund": if input.arguments["amount"] > 10_000: return error("rbac: refunds over $10K require manual approval") - if input.tool_name in DESTRUCTIVE_TOOLS: + if input.toolName in DESTRUCTIVE_TOOLS: if not input.arguments.get("dry_run", false): return error("rbac: %s requires dry_run=true", - input.tool_name) + input.toolName) return ok # nil error so tool call proceeds ``` @@ -173,11 +173,11 @@ spec: ```text workflow redact-pii(input): - # input: { name, tool_name, arguments } + # input: { name, toolName, arguments } args = copy(input.arguments) if "email" in args: args["email"] = mask_email(args["email"]) - return { name: input.name, tool_name: input.tool_name, arguments: args } + 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`. diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md b/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md new file mode 100644 index 00000000000..5fc4c601b8b --- /dev/null +++ b/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md @@ -0,0 +1,165 @@ +--- +type: docs +title: "MCP access control" +linkTitle: "Access control" +weight: 30 +description: "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]({{% ref invoke-allowlist.md %}}). 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]({{% ref mtls.md %}})) and allow or deny calls. The policy supports a `deny` default, so every access must be explicitly granted. + +### Granularity: per App ID, not per tool + +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. + +MCP transports — `streamable-http` and `sse` — route all tool calls through a **single HTTP endpoint**. The tool name lives inside the JSON-RPC body, not in the URL path. That means HTTP-path-based ACL rules do **not** give you per-tool granularity for standard MCP traffic. Today, the unit of authorization at the service-invocation layer is the **MCP server's App ID**. + +To enforce per-tool boundaries today, either: + +- Split tools across separate MCP servers (one App ID per group) and let the App-ID-keyed policy do the work — see [Per-tool granularity through separate MCP servers](#per-tool-granularity-through-separate-mcp-servers). +- Use the [`MCPServer` resource]({{% ref mcp-server-resource.md %}}) middleware hooks (`beforeCallTool` / `afterCallTool`) for argument-level RBAC. + +## 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: + +```yaml +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: + +```bash +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`. + +| Field | Description | +|---|---| +| `defaultAction` (top-level) | Default for any App ID not listed in `policies`. Set to `deny` for a zero-trust posture. | +| `trustDomain` | Trust domain in which the policy applies. `"public"` covers traffic within a single Dapr namespace. | +| `policies[].appId` | The Dapr App ID of the calling agent. | +| `policies[].defaultAction` | `allow` or `deny` for this caller. | +| `policies[].namespace` | The 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: + +```yaml +# 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: + +```yaml +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 granularity through separate MCP servers + +When you need per-tool authorization at the service-invocation layer, split the tools across separate MCP servers (one per group) and gate each one with its own `Configuration`: + +``` +analyst-agent ──► mcp-db-schema (schema introspection only) +analyst-agent ──► mcp-db-query (read queries only) +ops-agent ──► mcp-db-schema +ops-agent ──► mcp-db-query +ops-agent ──► mcp-db-write (write operations) +admin-agent ──► mcp-db-schema +admin-agent ──► mcp-db-query +admin-agent ──► mcp-db-write +admin-agent ──► mcp-db-ddl (destructive DDL operations) +``` + +Each MCP server has its own App ID and its own `Configuration` with a deny-by-default policy listing the App IDs allowed to call it. The policy boundary matches the trust boundary, and an agent cannot reach a server its App ID isn't allow-listed for. + +If splitting servers is not an option, use the [`MCPServer` resource]({{% ref "mcp-server-resource.md#middleware-pipelines" %}}) `beforeCallTool` hook for argument-level RBAC inside a single server. + +## 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]({{% ref mcp-authentication.md %}}) 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: + +```yaml +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 + +- [Authenticating an MCP server]({{% ref mcp-authentication.md %}}) — OAuth 2.0 and bearer middleware setup for MCP. +- [MCP security posture]({{% ref mcp-security.md %}}) — threat model and defense-in-depth narrative. +- [Service invocation access control]({{% ref invoke-allowlist.md %}}) — full `accessControl` policy schema reference. +- [`MCPServer` resource]({{% ref mcp-server-resource.md %}}) — workflow-hook layer for argument-level RBAC inside a single MCP server. diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md b/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md index 5981127f6ce..a55ee04d127 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md @@ -202,3 +202,45 @@ dapr run --app-id mcpclient --resources-path ./components --dapr-http-port 3500 ``` 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`]({{% ref middleware-bearer.md %}}) 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. + +```yaml +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" +``` + +```yaml +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]({{% ref mcp-access-control.md %}}) for defense in depth: `accessControl` decides *which callers* may reach the server; bearer validation insists they present a live, signed token. + +## See also + +- [MCP access control]({{% ref mcp-access-control.md %}}) — App-ID-keyed authorization at the service-invocation boundary. +- [MCP security posture]({{% ref mcp-security.md %}}) — threat model and defense-in-depth narrative. +- [Bearer middleware reference]({{% ref middleware-bearer.md %}}). +- [OAuth2 middleware reference]({{% ref middleware-oauth2.md %}}). diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-security.md b/daprdocs/content/en/developing-ai/mcp/mcp-security.md new file mode 100644 index 00000000000..ba206d587f9 --- /dev/null +++ b/daprdocs/content/en/developing-ai/mcp/mcp-security.md @@ -0,0 +1,76 @@ +--- +type: docs +title: "MCP security and trust posture" +linkTitle: "Security posture" +weight: 35 +description: "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 + +| Question | Dapr 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]({{% ref mtls.md %}}) using these identities. No static API keys or shared service tokens are required between Dapr apps. | +| **What may this agent do?** | A [`Configuration` resource]({{% ref invoke-allowlist.md %}}) 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]({{% ref middleware-bearer.md %}}) 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]({{% ref observability-concept %}}). 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]({{% ref mcp-access-control.md %}}). +- **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]({{% ref secrets-overview %}}) 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 mode | What it looks like | Dapr control | +|---|---|---| +| **Privilege escalation** | A 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 use** | An 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. | +| **Jailbreaking** | A 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 sprawl** | API 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 provenance** | No verifiable record of who did what. | Every service-invocation call is recorded by Dapr's [observability pipeline]({{% ref observability-concept %}}) — 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]({{% ref mcp-server-resource.md %}}) 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]({{% ref mcp-access-control.md %}}) for layering ACL + bearer, and [MCPServer resource]({{% ref mcp-server-resource.md %}}) for the workflow-hook layer. + +## Next steps + +- [MCP access control]({{% ref mcp-access-control.md %}}) — declarative authorization per App ID with `Configuration` `accessControl`. +- [Authenticating an MCP server]({{% ref mcp-authentication.md %}}) — OAuth2 and bearer middleware setup, client-side and server-side. +- [Dapr mTLS]({{% ref mtls.md %}}) — SPIFFE-based mTLS and Sentry-managed identity rotation. +- [Service invocation access control]({{% ref invoke-allowlist.md %}}) — the full `accessControl` schema and HTTP-verb-level controls. diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md index c46b91bee42..6b340c354b1 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md @@ -17,7 +17,7 @@ MCPServer turns MCP integration into a deploy-time concern instead of an applica - **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 `CallMCPToolResponse{is_error: true}` immediately — agents and LLMs get an actionable error without burning a network round-trip. +- **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..CallTool.`), 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. @@ -58,20 +58,20 @@ Content-Type: application/json } ``` -Poll for the result with `GET /v1.0-beta1/workflows/dapr/`. The workflow output is a `CallMCPToolResponse` proto serialized as JSON. Each entry in `content` is a oneof — text, image, audio, resource_link, or embedded_resource: +Poll for the result with `GET /v1.0-beta1/workflows/dapr/`. The workflow output is a [MCP `CallToolResult`](https://modelcontextprotocol.io/specification/2024-11-05/server/tools) — 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): ```json { - "is_error": false, + "isError": false, "content": [ - {"text": {"text": "Weather in Seattle: sunny, 72°F"}} + {"type": "text", "text": "Weather in Seattle: sunny, 72°F"} ] } ``` -For binary content the shape is `{"image": {"mime_type": "image/png", "data": ""}}` (likewise for `audio`); for resource references it is `{"resource_link": {"resource": ""}}` or `{"embedded_resource": {...}}`. +Other content shapes are similarly flat: `{"type": "image", "data": "", "mimeType": "image/png"}` (likewise for `audio`); resource references use `{"type": "resource_link", "uri": "...", "name": "...", "mimeType": "...", "description": "..."}` or `{"type": "resource", "resource": {"uri": "...", "mimeType": "...", "text": "..." | "blob": ""}}`. -If the tool call fails at the MCP level (unknown tool, validation failure, server-side auth error), `is_error` 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 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. @@ -92,7 +92,7 @@ Output: { "name": "get_weather", "description": "Get current weather for a city", - "input_schema": { + "inputSchema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"] @@ -236,16 +236,28 @@ Middleware hooks turn tool-call governance into declarative YAML enforced by Dap ### Hook input shapes -Each hook is a Dapr workflow that receives a typed input from the runtime. Field names match the proto definitions: +Each hook is a Dapr workflow that receives a typed input from the runtime: ```text -beforeCallTool input: { name, tool_name, arguments } -afterCallTool input: { name, tool_name, arguments, result } # result is CallMCPToolResponse +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 is ListMCPToolsResponse +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 whatever the MCP server (or a previous mutating hook) produced. Mutating hooks return the same shape they receive — modify, then return. +`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](https://modelcontextprotocol.io/specification/)). Hook workflows deserialize it with the language's MCP SDK or with plain JSON decoding: + +```python +# 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 @@ -264,16 +276,16 @@ Workflow body (pseudocode — language-neutral): ```text workflow rbac-check(input): - # input: { name, tool_name, arguments } - if input.tool_name == "issue_refund": + # 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.tool_name in DESTRUCTIVE_TOOLS: + 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.tool_name) + input.toolName) return ok # mutate=false → return value is discarded; nil error means allow ``` @@ -298,12 +310,14 @@ spec: ```text workflow audit-logger(input): - # input: { name, tool_name, arguments, result } + # 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.tool_name, + tool: input.toolName, args: redact(input.arguments), - succeeded: not input.result.is_error, + succeeded: not result.isError, at: now(), }) return ok # mutate=false → result reaches the caller unchanged @@ -337,11 +351,11 @@ spec: | Pattern | Phase | `mutate` | Sketch | |---|---|---|---| | Argument RBAC | `beforeCallTool` | `false` | Inspect `arguments`, return error to deny. | -| Rate limiting | `beforeCallTool` | `false` | Look up budget keyed by `tool_name`; return error when exhausted. | +| Rate limiting | `beforeCallTool` | `false` | Look up budget keyed by `toolName`; return error when exhausted. | | PII redaction (request) | `beforeCallTool` | `true` | Transform `arguments`, return the cleaned shape. | -| Audit logging | `afterCallTool` | `false` | Emit `{tool_name, arguments, result.is_error}` to a state store / log sink. | -| Response filtering | `afterCallTool` | `true` | Strip / mask fields in `result.content`, return updated `CallMCPToolResponse`. | -| Tool catalog filtering | `afterListTools` | `true` | Drop tools the caller isn't entitled to discover, return updated `ListMCPToolsResponse`. | +| Audit logging | `afterCallTool` | `false` | Emit `{toolName, arguments, result.isError}` (decode `result` bytes first) to a state store / log sink. | +| Response filtering | `afterCallTool` | `true` | Strip / mask fields inside the decoded `CallToolResult` `content`, then JSON-encode and return. | +| Tool catalog filtering | `afterListTools` | `true` | Drop 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](#hook-input-shapes) above. See the [MCPServer spec]({{% ref mcpserver-schema %}}) for the full middleware field reference. @@ -353,6 +367,8 @@ For access control, MCP workflows participate in `WorkflowAccessPolicy` the same `WorkflowAccessPolicy` and [middleware hooks](#middleware-pipelines) compose, they don't overlap. `WorkflowAccessPolicy` decides *whether a caller can start `CallTool.` 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]({{% ref mcp-service-invocation.md %}}) instead of the workflow client, the equivalent perimeter is `Configuration` `accessControl` attached to the MCP server's App ID — see [MCP access control]({{% ref mcp-access-control.md %}}). + ## 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: @@ -425,5 +441,7 @@ When `ignoreErrors` is `true` and load fails, the MCPServer's workflows are not - [MCPServer spec reference]({{% ref mcpserver-schema %}}) - [How-To: Use MCPServer resources]({{% ref howto-use-mcpserver.md %}}) - [Workflow API reference]({{% ref workflow_api %}}) +- [MCP through Dapr service invocation]({{% ref mcp-service-invocation.md %}}) — for agents that need to keep using off-the-shelf MCP clients +- [MCP access control]({{% ref mcp-access-control.md %}}) — App-ID-keyed `Configuration` `accessControl` for the service-invocation path - Python SDK: `DaprMCPClient` — framework-agnostic client for invoking MCPServer tools from any agent framework (see the python-sdk docs) - dapr-agents: zero-config MCPServer tool discovery — `DurableAgent` automatically picks up MCPServer tools from sidecar metadata (see the dapr-agents docs) diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md b/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md new file mode 100644 index 00000000000..3ee57b8fdcc --- /dev/null +++ b/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md @@ -0,0 +1,146 @@ +--- +type: docs +title: "MCP through Dapr service invocation" +linkTitle: "Service invocation path" +weight: 25 +description: "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)](https://modelcontextprotocol.io/) 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]({{% ref service-invocation-overview.md %}}) 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. + +## 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. + +```mermaid +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]({{% ref mtls.md %}}) with SPIFFE-issued credentials). +- Apply [access control policies]({{% ref mcp-access-control.md %}}) defined for the target MCP server's App ID. +- Apply HTTP middleware on the inbound pipeline, such as [OAuth 2.0 bearer validation]({{% ref middleware-bearer.md %}}). +- 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: + +```python +# 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: + +```bash +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: + +```python +# 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: + +```bash +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]({{% ref service-invocation-overview.md %}}#how-to-invoke-services). + +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]({{% ref middleware-bearer.md %}}) 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]({{% ref mcp-authentication.md %}}). +- **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]({{% ref mcp-access-control.md %}}). + +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]({{% ref mtls.md %}}). + +For the full threat-model framing and what the platform does versus what stays your responsibility, see [MCP security posture]({{% ref mcp-security.md %}}). + +## 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]({{% ref mcp-server-resource.md %}}) 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. + +## Related links + +- [Authenticating an MCP server]({{% ref mcp-authentication.md %}}) +- [MCP access control]({{% ref mcp-access-control.md %}}) +- [MCP security posture]({{% ref mcp-security.md %}}) +- [`MCPServer` resource]({{% ref mcp-server-resource.md %}}) +- [Service invocation overview]({{% ref service-invocation-overview.md %}}) +- [Dapr mTLS]({{% ref mtls.md %}}) From 5cd10f9a788daf9da3e902d4b89191950ee02836 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Mon, 1 Jun 2026 14:25:55 -0500 Subject: [PATCH 6/8] fix: address yael feedback Signed-off-by: Samantha Coyle --- .../content/en/developing-ai/mcp/_index.md | 30 +++++-------------- .../developing-ai/mcp/howto-use-mcpserver.md | 4 +-- .../developing-ai/mcp/mcp-authentication.md | 2 +- .../developing-ai/mcp/mcp-server-resource.md | 29 +++--------------- .../mcp/mcp-service-invocation.md | 2 +- 5 files changed, 15 insertions(+), 52 deletions(-) diff --git a/daprdocs/content/en/developing-ai/mcp/_index.md b/daprdocs/content/en/developing-ai/mcp/_index.md index 174133213f4..0354f1f9221 100644 --- a/daprdocs/content/en/developing-ai/mcp/_index.md +++ b/daprdocs/content/en/developing-ai/mcp/_index.md @@ -24,29 +24,13 @@ The two paths are not exclusive — most MCP traffic can flow through service in ### Path A — Service invocation (recommended for most teams) -The agent's existing MCP client points at the local Dapr sidecar (`http://localhost:3500/v1.0/invoke//method/mcp`, or sets `dapr-app-id: `). Dapr resolves the target by App ID, applies the `accessControl` policies and HTTP middleware attached to the MCP server's App ID, and forwards the request. **Off-the-shelf MCP clients and agent frameworks work unchanged.** - -```mermaid -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 -``` +The agent's existing MCP client points at the local Dapr sidecar (`http://localhost:3500/v1.0/invoke//method/mcp`, or sets `dapr-app-id: `). Dapr resolves the target by App ID, applies the `accessControl` policies and HTTP middleware attached to the MCP server's App ID, and forwards the request: + +- **Off-the-shelf MCP clients and agent frameworks work unchanged** — no Dapr-specific MCP SDK to adopt. +- **App-ID identity and mTLS** — every Dapr-to-Dapr call is mutually authenticated using SPIFFE identities issued and rotated by Sentry. +- **`Configuration` `accessControl`** — coarse-grained, App-ID-keyed allow/deny policies attached to the MCP server's App ID. +- **HTTP middleware** — bearer / OAuth2 token validation on inbound, token acquisition on outbound, configured declaratively. +- **Observability, resiliency, and retries** — the same primitives Dapr already provides for service-to-service traffic apply to MCP traffic. Get started: diff --git a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md index 1f7cbb50ef7..5c084b52aa3 100644 --- a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md +++ b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md @@ -76,7 +76,7 @@ curl -X POST "http://localhost:3500/v1.0-beta1/workflows/dapr/dapr.internal.mcp. }' ``` -Poll for the result as in Step 2. The output is an [MCP `CallToolResult`](https://modelcontextprotocol.io/specification/2024-11-05/server/tools) — byte-for-byte the same shape as the MCP wire spec. Each entry in `content` is a flat tagged union with a `type` discriminator: +Poll for the result as in Step 2. The output is an [MCP `CallToolResult`](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) — byte-for-byte the same shape as the MCP wire spec. Each entry in `content` is a flat tagged union with a `type` discriminator: ```json { @@ -199,7 +199,7 @@ spec: 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]({{% ref "mcp-server-resource.md#examples-common-patterns" %}}) for canonical hook patterns (RBAC, rate limiting, audit, response filtering, tool catalog filtering). +> See the [overview examples]({{% ref "mcp-server-resource.md#examples-common-patterns" %}}) for canonical hook patterns (RBAC, rate limiting, audit, response filtering, tool list filtering). ## Related links diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md b/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md index a55ee04d127..a5b3272532d 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md @@ -1,7 +1,7 @@ --- type: docs title: "Authenticating an MCP server" -linkTitle: "Getting Started" +linkTitle: "Authenticating an MCP server" weight: 20 description: "How to enable MCP client-side and server-side authentication" --- diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md index 6b340c354b1..15f2d3f105d 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md @@ -58,7 +58,7 @@ Content-Type: application/json } ``` -Poll for the result with `GET /v1.0-beta1/workflows/dapr/`. The workflow output is a [MCP `CallToolResult`](https://modelcontextprotocol.io/specification/2024-11-05/server/tools) — 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): +Poll for the result with `GET /v1.0-beta1/workflows/dapr/`. The workflow output is a [MCP `CallToolResult`](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) — 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): ```json { @@ -355,7 +355,7 @@ spec: | PII redaction (request) | `beforeCallTool` | `true` | Transform `arguments`, return the cleaned shape. | | Audit logging | `afterCallTool` | `false` | Emit `{toolName, arguments, result.isError}` (decode `result` bytes first) to a state store / log sink. | | Response filtering | `afterCallTool` | `true` | Strip / mask fields inside the decoded `CallToolResult` `content`, then JSON-encode and return. | -| Tool catalog filtering | `afterListTools` | `true` | Drop tools the caller isn't entitled to discover, return the updated `ListToolsResult` as JSON bytes. | +| Tool list filtering | `afterListTools` | `true` | Drop 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](#hook-input-shapes) above. See the [MCPServer spec]({{% ref mcpserver-schema %}}) for the full middleware field reference. @@ -397,27 +397,6 @@ scopes: - agent-app-2 ``` -## Catalog metadata - -`spec.catalog` carries informational fields that don't affect runtime behavior but are useful for service catalogs, internal portals, ownership tracking, and compliance tooling. Populate them when publishing MCPServer resources to a wider org so operators can see at a glance who owns each integration and where to find documentation: - -```yaml -spec: - catalog: - displayName: Payments MCP - description: Tools for charging customers and issuing refunds. - owner: - team: payments-platform - contact: payments-oncall@example.com - tags: ["payments", "production", "pii"] - links: - docs: https://wiki.internal/payments-mcp - runbook: https://wiki.internal/payments-mcp/runbook - dashboard: https://grafana.internal/d/payments-mcp -``` - -See the [MCPServer spec]({{% ref mcpserver-schema %}}) for the full list of catalog fields. - ## 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: @@ -443,5 +422,5 @@ When `ignoreErrors` is `true` and load fails, the MCPServer's workflows are not - [Workflow API reference]({{% ref workflow_api %}}) - [MCP through Dapr service invocation]({{% ref mcp-service-invocation.md %}}) — for agents that need to keep using off-the-shelf MCP clients - [MCP access control]({{% ref mcp-access-control.md %}}) — App-ID-keyed `Configuration` `accessControl` for the service-invocation path -- Python SDK: `DaprMCPClient` — framework-agnostic client for invoking MCPServer tools from any agent framework (see the python-sdk docs) -- dapr-agents: zero-config MCPServer tool discovery — `DurableAgent` automatically picks up MCPServer tools from sidecar metadata (see the dapr-agents docs) +- [Python SDK MCP example](https://github.com/dapr/python-sdk/tree/main/examples/mcp) — `DaprMCPClient`, a framework-agnostic client for invoking MCPServer tools from any agent framework +- [dapr-agents MCPServer example](https://github.com/dapr/dapr-agents/tree/main/examples/10-mcpserver) — zero-config MCPServer tool discovery; `DurableAgent` automatically picks up MCPServer tools from sidecar metadata diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md b/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md index 3ee57b8fdcc..58943e830af 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md @@ -2,7 +2,7 @@ type: docs title: "MCP through Dapr service invocation" linkTitle: "Service invocation path" -weight: 25 +weight: 5 description: "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" --- From 3e19bf1e8f2abef3ffe7799ef5e913d73281dcd1 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Wed, 3 Jun 2026 09:36:49 -0500 Subject: [PATCH 7/8] fix: address final yael feedback Signed-off-by: Samantha Coyle --- .../content/en/developing-ai/mcp/_index.md | 76 +++++------ .../developing-ai/mcp/howto-use-mcpserver.md | 4 +- .../developing-ai/mcp/mcp-access-control.md | 125 ++++++++++++++++-- .../developing-ai/mcp/mcp-authentication.md | 2 +- .../en/developing-ai/mcp/mcp-security.md | 2 +- .../developing-ai/mcp/mcp-server-resource.md | 24 +++- .../mcp/mcp-service-invocation.md | 19 +++ 7 files changed, 191 insertions(+), 61 deletions(-) diff --git a/daprdocs/content/en/developing-ai/mcp/_index.md b/daprdocs/content/en/developing-ai/mcp/_index.md index 0354f1f9221..c4a6b0490bd 100644 --- a/daprdocs/content/en/developing-ai/mcp/_index.md +++ b/daprdocs/content/en/developing-ai/mcp/_index.md @@ -6,66 +6,50 @@ weight: 25 description: "Dapr helps developers run secure, reliable, and durable Model Context Protocol (MCP) server integrations" --- -Dapr governs MCP traffic the same way it governs any other service-to-service call: App ID identity, access policies, HTTP middleware, mTLS, observability, and resiliency. There are two ways to plug MCP into Dapr — pick the one that matches your client and your authorization needs. +Dapr supports MCP by using its [service invocation API]({{% ref service-invocation-overview.md %}}). Off-the-shelf [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) 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. -## Two integration paths +## How it works -### Choosing your path +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. -| If you… | Use | -|---|---| -| Use an off-the-shelf MCP client or framework (LangGraph, the official MCP SDK, etc.) and want unchanged client code | **[Service invocation path]({{% ref mcp-service-invocation.md %}})** | -| Need argument-level RBAC, audit, or redaction hooks on a per-tool basis | **[`MCPServer` resource path]({{% ref mcp-server-resource.md %}})** | -| Need durable retries that survive a sidecar restart mid-call | **[`MCPServer` resource path]({{% ref mcp-server-resource.md %}})** | -| Want the simplest setup that works with any framework | **[Service invocation path]({{% ref mcp-service-invocation.md %}})** | -| Want per-tool observability slicing (one workflow per tool) | **[`MCPServer` resource path]({{% ref mcp-server-resource.md %}})** | +For each call, Dapr can: -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 and if you want durable MCP interactions. +- **Route the request** from the calling app to the target app by App ID. +- **Authenticate the caller's workload identity** using [mTLS]({{% ref mtls.md %}}) 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]({{% ref mcp-access-control.md %}}). +- **Apply HTTP middleware** on the inbound pipeline, such as [OAuth 2.0 bearer validation]({{% ref middleware-bearer.md %}}). +- **Capture observability** — logs, metrics, and traces for the call, sliced by caller and target App ID. -### Path A — Service invocation (recommended for most teams) +Off-the-shelf MCP clients work unchanged — there is no Dapr-specific MCP SDK to adopt for this path. -The agent's existing MCP client points at the local Dapr sidecar (`http://localhost:3500/v1.0/invoke//method/mcp`, or sets `dapr-app-id: `). Dapr resolves the target by App ID, applies the `accessControl` policies and HTTP middleware attached to the MCP server's App ID, and forwards the request: +## Get started -- **Off-the-shelf MCP clients and agent frameworks work unchanged** — no Dapr-specific MCP SDK to adopt. -- **App-ID identity and mTLS** — every Dapr-to-Dapr call is mutually authenticated using SPIFFE identities issued and rotated by Sentry. -- **`Configuration` `accessControl`** — coarse-grained, App-ID-keyed allow/deny policies attached to the MCP server's App ID. -- **HTTP middleware** — bearer / OAuth2 token validation on inbound, token acquisition on outbound, configured declaratively. -- **Observability, resiliency, and retries** — the same primitives Dapr already provides for service-to-service traffic apply to MCP traffic. - -Get started: - -- [MCP through Dapr service invocation]({{% ref mcp-service-invocation.md %}}) — quickstart and architecture -- [Authenticating an MCP server]({{% ref mcp-authentication.md %}}) — OAuth2 and bearer middleware -- [MCP access control]({{% ref mcp-access-control.md %}}) — `Configuration` `accessControl` for MCP - -### Path B — `MCPServer` resource (workflow-centric) - -The **[`MCPServer` resource]({{% ref mcp-server-resource.md %}})** turns MCP integration into a deploy-time concern instead of an application-code concern. Declare a YAML resource and Dapr takes over: - -- **No MCP SDK in your app** — Dapr speaks MCP to the server. Your code starts a Dapr workflow by name. -- **Per-tool RBAC, audit, and redaction in YAML** — `beforeCallTool` / `afterCallTool` (and ListTools equivalents) hooks run as Dapr workflows; centralizable across apps via `appID`. -- **Durable tool calls** — backed by Dapr Workflows + Scheduler reminders. A sidecar restart mid-call doesn't drop the request; the workflow resumes on the new instance. -- **Per-tool observability** — each tool gets its own workflow (`dapr.internal.mcp..CallTool.`), so traces, metrics, and audit logs are sliced per-tool out of the box. -- **Declarative auth** — OAuth2 client credentials, SPIFFE workload identity, or static headers configured in YAML. Dapr fetches and refreshes tokens; secrets stay out of application code. -- **Scoping, multi-tenancy, hot reload** — namespaced like other Dapr resources, restricted via `scopes`, and reloaded without sidecar restart. - -This path requires the [Dapr Workflow]({{% ref workflow-overview %}}) client to invoke tools — off-the-shelf MCP clients and agent frameworks won't drive `MCPServer`-backed tool calls. - -Get started: - -- [`MCPServer` resource overview]({{% ref mcp-server-resource.md %}}) -- [How-To: Use MCPServer resources]({{% ref howto-use-mcpserver.md %}}) -- [MCPServer spec reference]({{% ref mcpserver-schema %}}) +- **[MCP through Dapr service invocation]({{% ref mcp-service-invocation.md %}})** — quickstart and architecture +- **[Authenticating an MCP server]({{% ref mcp-authentication.md %}})** — OAuth 2.0 and bearer middleware +- **[MCP access control]({{% ref mcp-access-control.md %}})** — `Configuration` `accessControl` and OPA for MCP +- **[MCP security posture]({{% ref mcp-security.md %}})** — threat model and defense-in-depth narrative ## Security at a glance -Both paths use the same underlying Dapr security primitives. The three layers compose for defense in depth: - | Layer | What it controls | Reference | |---|---|---| | **mTLS + SPIFFE identity** | Every Dapr-to-Dapr call is mutually authenticated using identities Sentry issues and rotates automatically. On by default. | [Dapr mTLS]({{% ref mtls.md %}}) | | **`Configuration` `accessControl`** | Which caller App IDs may reach which MCP servers. Default-deny is supported. | [MCP access control]({{% ref mcp-access-control.md %}}) | | **HTTP middleware (bearer / OAuth2)** | Inbound JWT validation on `appHttpPipeline`; outbound token acquisition on `httpPipeline`. | [Authenticating an MCP server]({{% ref mcp-authentication.md %}}) | -| **(`MCPServer` resource only) Workflow hooks** | Argument-level RBAC, audit, redaction, response filtering — runs as durable workflows around the tool call. | [`MCPServer` resource]({{% ref mcp-server-resource.md %}}) | +| **OPA per-tool policies** | Argument- and tool-aware authorization that inspects the MCP JSON-RPC body. | [MCP access control]({{% ref mcp-access-control.md %}}) | For the threat-model framing, default postures, and what stays your responsibility, see [MCP security posture]({{% ref mcp-security.md %}}). + +## Alternative: the `MCPServer` resource (workflow-centric path) + +There is a second way to use MCP with Dapr — the [`MCPServer` resource]({{% ref mcp-server-resource.md %}}). 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." + +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. + +In exchange, you adopt the [Dapr Workflow]({{% ref workflow-overview %}}) client to invoke tools — off-the-shelf MCP clients won't drive `MCPServer`-backed tool calls. + +See the [`MCPServer` resource page]({{% ref mcp-server-resource.md %}}) for the full comparison with the service invocation path and a step-by-step guide. diff --git a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md index 5c084b52aa3..a1fb8e31c83 100644 --- a/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md +++ b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md @@ -2,7 +2,7 @@ type: docs title: "How-To: Use MCPServer resources" linkTitle: "How-To: Use MCPServer" -weight: 15 +weight: 30 description: "Use MCPServer resources to discover and call tools on MCP servers" --- @@ -76,7 +76,7 @@ curl -X POST "http://localhost:3500/v1.0-beta1/workflows/dapr/dapr.internal.mcp. }' ``` -Poll for the result as in Step 2. The output is an [MCP `CallToolResult`](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) — byte-for-byte the same shape as the MCP wire spec. Each entry in `content` is a flat tagged union with a `type` discriminator: +Poll for the result as in Step 2. The output is an [MCP `CallToolResult`](https://modelcontextprotocol.io/specification/2025-11-25/schema#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: ```json { diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md b/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md index 5fc4c601b8b..9d755ea92b2 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md @@ -2,7 +2,7 @@ type: docs title: "MCP access control" linkTitle: "Access control" -weight: 30 +weight: 15 description: "Define per-agent access control policies for MCP servers using Configuration accessControl rules" --- @@ -16,16 +16,13 @@ In a multi-agent system, different agents should have different levels of access 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]({{% ref mtls.md %}})) and allow or deny calls. The policy supports a `deny` default, so every access must be explicitly granted. -### Granularity: per App ID, not per tool +### 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. +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, not in the URL path. That means HTTP-path-based ACL rules do **not** give you per-tool granularity for standard MCP traffic. Today, the unit of authorization at the service-invocation layer is the **MCP server's App ID**. +MCP transports — `streamable-http` and `sse` — route all tool calls through a **single HTTP endpoint**. The tool name lives inside the [JSON-RPC](https://www.jsonrpc.org/specification) 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](#per-tool-authorization-with-opa) 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)`. -To enforce per-tool boundaries today, either: - -- Split tools across separate MCP servers (one App ID per group) and let the App-ID-keyed policy do the work — see [Per-tool granularity through separate MCP servers](#per-tool-granularity-through-separate-mcp-servers). -- Use the [`MCPServer` resource]({{% ref mcp-server-resource.md %}}) middleware hooks (`beforeCallTool` / `afterCallTool`) for argument-level RBAC. +You can also split tools across separate MCP servers (one App ID per group) and let the App-ID policy do the work — see [Per-tool granularity through separate MCP servers](#per-tool-granularity-through-separate-mcp-servers). For workflow-centric, argument-level RBAC inside a single server, see the [`MCPServer` resource]({{% ref "mcp-server-resource.md#middleware-pipelines" %}}) middleware hooks. ## How it works @@ -110,6 +107,115 @@ spec: `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]({{% ref middleware-opa.md %}}) 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 + +```mermaid +flowchart LR + AGENT(Agent / MCP client) + subgraph DAPR[Dapr sidecar - MCP server side] + ACL{accessControl
App-ID gate}:::decision + OPA{OPA middleware
tool-level gate}:::decision + end + SERVER(MCP server) + + AGENT -- POST /method/mcp
+ 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`: + +```yaml +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: + +```yaml +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]({{% ref workflow-overview %}}) client, use the [`MCPServer` resource]({{% ref "mcp-server-resource.md#middleware-pipelines" %}}) middleware hooks instead. + ## Per-tool granularity through separate MCP servers When you need per-tool authorization at the service-invocation layer, split the tools across separate MCP servers (one per group) and gate each one with its own `Configuration`: @@ -128,7 +234,7 @@ admin-agent ──► mcp-db-ddl (destructive DDL operations) Each MCP server has its own App ID and its own `Configuration` with a deny-by-default policy listing the App IDs allowed to call it. The policy boundary matches the trust boundary, and an agent cannot reach a server its App ID isn't allow-listed for. -If splitting servers is not an option, use the [`MCPServer` resource]({{% ref "mcp-server-resource.md#middleware-pipelines" %}}) `beforeCallTool` hook for argument-level RBAC inside a single server. +If splitting servers is not an option, use [OPA](#per-tool-authorization-with-opa) on the MCP server's inbound pipeline for `(caller, tool)` decisions, or the [`MCPServer` resource]({{% ref "mcp-server-resource.md#middleware-pipelines" %}}) `beforeCallTool` hook for argument-level RBAC inside a single server. ## Combining ACLs with OAuth 2.0 bearer middleware @@ -162,4 +268,5 @@ Don't attach a `Configuration` resource with `accessControl` to the MCP server. - [Authenticating an MCP server]({{% ref mcp-authentication.md %}}) — OAuth 2.0 and bearer middleware setup for MCP. - [MCP security posture]({{% ref mcp-security.md %}}) — threat model and defense-in-depth narrative. - [Service invocation access control]({{% ref invoke-allowlist.md %}}) — full `accessControl` policy schema reference. +- [OPA middleware]({{% ref middleware-opa.md %}}) — reference for the `middleware.http.opa` component used above. - [`MCPServer` resource]({{% ref mcp-server-resource.md %}}) — workflow-hook layer for argument-level RBAC inside a single MCP server. diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md b/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md index a5b3272532d..af4c1cfb5c6 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md @@ -2,7 +2,7 @@ type: docs title: "Authenticating an MCP server" linkTitle: "Authenticating an MCP server" -weight: 20 +weight: 10 description: "How to enable MCP client-side and server-side authentication" --- diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-security.md b/daprdocs/content/en/developing-ai/mcp/mcp-security.md index ba206d587f9..a820e691dd0 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-security.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-security.md @@ -2,7 +2,7 @@ type: docs title: "MCP security and trust posture" linkTitle: "Security posture" -weight: 35 +weight: 20 description: "How Dapr enforces agent identity, authorization, and auditability across agents and MCP servers, and what stays your responsibility" --- diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md index 15f2d3f105d..3675e1e8580 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md @@ -2,7 +2,7 @@ type: docs title: "MCPServer resource" linkTitle: "MCPServer resource" -weight: 10 +weight: 25 description: "Declare MCP server connections as first-class Dapr resources for durable tool execution" --- @@ -10,6 +10,26 @@ description: "Declare MCP server connections as first-class Dapr resources for d 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. +{{% alert title="When to use this path" color="primary" %}} +The `MCPServer` resource is **not the default MCP integration in Dapr** — most teams should start with the [service invocation path]({{% ref mcp-service-invocation.md %}}), which keeps existing MCP clients and agent frameworks unchanged. + +`MCPServer` is the right choice when you specifically need argument-level RBAC, audit, redaction, durable retries that survive a sidecar restart mid-call, or per-tool observability slicing. In exchange, you adopt the [Dapr Workflow]({{% ref workflow-overview %}}) client to invoke tools — off-the-shelf MCP clients won't drive `MCPServer`-backed tool calls. +{{% /alert %}} + +## Choosing between `MCPServer` and the service invocation path + +Dapr offers two integration paths for MCP. The [service invocation path]({{% ref mcp-service-invocation.md %}}) 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 code | **[Service invocation path]({{% ref mcp-service-invocation.md %}})** | +| Want the simplest setup that works with any framework | **[Service invocation path]({{% ref mcp-service-invocation.md %}})** | +| Need argument-level RBAC, audit, or redaction hooks on a per-tool basis | **`MCPServer` resource** (this page) | +| Need durable retries that survive a sidecar restart mid-call | **`MCPServer` 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: @@ -58,7 +78,7 @@ Content-Type: application/json } ``` -Poll for the result with `GET /v1.0-beta1/workflows/dapr/`. The workflow output is a [MCP `CallToolResult`](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) — 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): +Poll for the result with `GET /v1.0-beta1/workflows/dapr/`. The workflow output is an [MCP `CallToolResult`](https://modelcontextprotocol.io/specification/2025-11-25/schema#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): ```json { diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md b/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md index 58943e830af..52c6e292839 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md @@ -10,6 +10,25 @@ Dapr lets you run [Model Context Protocol (MCP)](https://modelcontextprotocol.io Because [service invocation]({{% ref service-invocation-overview.md %}}) 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]({{% ref mtls.md %}}). 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`]({{% ref mcp-access-control.md %}}) 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]({{% ref mcp-access-control.md %}}) 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]({{% ref mcp-authentication.md %}}) 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]({{% ref policies.md %}}). MCP calls inherit Dapr's resiliency primitives the same way other service-invocation calls do. + +| Without Dapr service invocation | With Dapr service invocation | +|---|---| +| Each agent embeds an MCP client and a separate identity / authz layer | One identity stack for all service traffic, MCP included | +| Per-server bearer-token plumbing in the application | Declarative OAuth 2.0 / bearer middleware | +| Per-tool RBAC requires forking the MCP client | OPA reads the JSON-RPC body and applies per-tool policy | +| Observability bolted onto MCP traffic separately | Same 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. From 06045929c62481139366c73bc07c8f679d8cf299 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Wed, 3 Jun 2026 13:57:52 -0500 Subject: [PATCH 8/8] fix: address final yael feedback Signed-off-by: Samantha Coyle --- .../content/en/developing-ai/mcp/_index.md | 11 ++++++++-- .../developing-ai/mcp/mcp-access-control.md | 22 +------------------ 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/daprdocs/content/en/developing-ai/mcp/_index.md b/daprdocs/content/en/developing-ai/mcp/_index.md index c4a6b0490bd..ad9ed2a06a4 100644 --- a/daprdocs/content/en/developing-ai/mcp/_index.md +++ b/daprdocs/content/en/developing-ai/mcp/_index.md @@ -44,12 +44,19 @@ For the threat-model framing, default postures, and what stays your responsibili There is a second way to use MCP with Dapr — the [`MCPServer` resource]({{% ref mcp-server-resource.md %}}). 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]({{% ref workflow-overview %}}) 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. - -In exchange, you adopt the [Dapr Workflow]({{% ref workflow-overview %}}) client to invoke tools — off-the-shelf MCP clients won't drive `MCPServer`-backed tool calls. +- 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]({{% ref mcp-server-resource.md %}}) for the full comparison with the service invocation path and a step-by-step guide. diff --git a/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md b/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md index 9d755ea92b2..f3d5637b04d 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md @@ -22,7 +22,7 @@ Dapr access control evaluates **caller App ID → target App ID** at the service MCP transports — `streamable-http` and `sse` — route all tool calls through a **single HTTP endpoint**. The tool name lives inside the [JSON-RPC](https://www.jsonrpc.org/specification) 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](#per-tool-authorization-with-opa) 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)`. -You can also split tools across separate MCP servers (one App ID per group) and let the App-ID policy do the work — see [Per-tool granularity through separate MCP servers](#per-tool-granularity-through-separate-mcp-servers). For workflow-centric, argument-level RBAC inside a single server, see the [`MCPServer` resource]({{% ref "mcp-server-resource.md#middleware-pipelines" %}}) middleware hooks. +For workflow-centric, argument-level RBAC inside a single server, see the [`MCPServer` resource]({{% ref "mcp-server-resource.md#middleware-pipelines" %}}) middleware hooks. ## How it works @@ -216,26 +216,6 @@ Restart the MCP server's sidecar with the updated `Configuration`. Requests for - **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]({{% ref workflow-overview %}}) client, use the [`MCPServer` resource]({{% ref "mcp-server-resource.md#middleware-pipelines" %}}) middleware hooks instead. -## Per-tool granularity through separate MCP servers - -When you need per-tool authorization at the service-invocation layer, split the tools across separate MCP servers (one per group) and gate each one with its own `Configuration`: - -``` -analyst-agent ──► mcp-db-schema (schema introspection only) -analyst-agent ──► mcp-db-query (read queries only) -ops-agent ──► mcp-db-schema -ops-agent ──► mcp-db-query -ops-agent ──► mcp-db-write (write operations) -admin-agent ──► mcp-db-schema -admin-agent ──► mcp-db-query -admin-agent ──► mcp-db-write -admin-agent ──► mcp-db-ddl (destructive DDL operations) -``` - -Each MCP server has its own App ID and its own `Configuration` with a deny-by-default policy listing the App IDs allowed to call it. The policy boundary matches the trust boundary, and an agent cannot reach a server its App ID isn't allow-listed for. - -If splitting servers is not an option, use [OPA](#per-tool-authorization-with-opa) on the MCP server's inbound pipeline for `(caller, tool)` decisions, or the [`MCPServer` resource]({{% ref "mcp-server-resource.md#middleware-pipelines" %}}) `beforeCallTool` hook for argument-level RBAC inside a single server. - ## 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: