From f968487e7f8d26737d2396fbe1fe3c453697f29c Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Thu, 7 May 2026 19:35:54 -0500 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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