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..ad9ed2a06a4 100644 --- a/daprdocs/content/en/developing-ai/mcp/_index.md +++ b/daprdocs/content/en/developing-ai/mcp/_index.md @@ -3,10 +3,60 @@ 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? +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. -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 +## How it works + +Both the agent and the MCP server run as Dapr apps, each with its own App ID. The MCP client directs requests to its local sidecar and sets the `dapr-app-id` header (or uses the full service-invocation URL). Dapr resolves the target by App ID, applies the policies attached to the MCP server's App ID, and forwards the request. + +For each call, Dapr can: + +- **Route the request** from the calling app to the target app by App ID. +- **Authenticate the caller's workload identity** using [mTLS]({{% 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. + +Off-the-shelf MCP clients work unchanged — there is no Dapr-specific MCP SDK to adopt for this path. + +## Get started + +- **[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 + +| 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 %}}) | +| **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." + +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. +- 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/howto-use-mcpserver.md b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md new file mode 100644 index 00000000000..a1fb8e31c83 --- /dev/null +++ b/daprdocs/content/en/developing-ai/mcp/howto-use-mcpserver.md @@ -0,0 +1,208 @@ +--- +type: docs +title: "How-To: Use MCPServer resources" +linkTitle: "How-To: Use MCPServer" +weight: 30 +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 `inputSchema` is the raw JSON Schema for its arguments: + +```json +{ + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "inputSchema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + } + } + ] +} +``` + +## Step 3: Call a tool + +Each MCP tool gets its own workflow named `dapr.internal.mcp..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 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 +{ + "isError": false, + "content": [ + {"type": "text", "text": "Weather in Seattle: sunny, 72°F"} + ] +} +``` + +If the tool call fails at the MCP level (e.g. unknown tool, auth error), `isError` is `true` and the error is in `content`. The workflow itself completes successfully — `isError` is not a workflow failure. + +If your call is missing a required argument, you get the same `isError: true` shape immediately — Dapr validates against the tool's cached JSON Schema before contacting the MCP server, so agents/LLMs see actionable errors without burning a network round-trip. + +## Step 4 (optional): Add authentication + +Add OAuth2 client credentials to authenticate with the MCP server: + +```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 + +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: + middleware: + beforeCallTool: + - workflow: + workflowName: rbac-check +``` + +Register a workflow named `rbac-check` in your application. It receives an `MCPBeforeCallToolHookInput`: + +```text +{ 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. + +```text +workflow rbac-check(input): + # Argument-level RBAC: inspect the payload and decide. + if input.toolName == "issue_refund": + if input.arguments["amount"] > 10_000: + return error("rbac: refunds over $10K require manual approval") + + if input.toolName in DESTRUCTIVE_TOOLS: + if not input.arguments.get("dry_run", false): + return error("rbac: %s requires dry_run=true", + input.toolName) + + return ok # nil error so tool call proceeds +``` + +The hook runs as a durable workflow — if daprd restarts mid-policy-check, Scheduler re-delivers and the decision completes. + +> **Caller-keyed RBAC ("which apps can call which tools") belongs at the [`WorkflowAccessPolicy`]({{% 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. + +### 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: + middleware: + beforeCallTool: + - workflow: + workflowName: redact-pii + mutate: true +``` + +```text +workflow redact-pii(input): + # input: { name, toolName, arguments } + args = copy(input.arguments) + if "email" in args: + args["email"] = mask_email(args["email"]) + return { name: input.name, toolName: input.toolName, arguments: args } +``` + +The hook returns the same shape it receives. The MCP server (and any subsequent hooks in the chain) sees only the transformed `arguments`. + +For after-the-fact response filtering or audit logging, wire the same way under `afterCallTool` — see the [overview examples]({{% ref "mcp-server-resource.md#examples-common-patterns" %}}) for the full set of patterns. + +### Step 5.3: Centralize policy on a shared app + +To run the hook on a dedicated policy app instead of locally, add `appID`: + +```yaml +spec: + middleware: + beforeCallTool: + - workflow: + workflowName: rbac-check + appID: policy-service # runs on the Dapr app named "policy-service" +``` + +The same workflow runs on the named app via service invocation. One shared policy app (RBAC, audit, PII redaction) governs many agent apps without each app embedding the policy. Update the central workflow once; every MCPServer that references it picks up the change without redeploying its callers. + +> See the [overview examples]({{% ref "mcp-server-resource.md#examples-common-patterns" %}}) for canonical hook patterns (RBAC, rate limiting, audit, response filtering, tool list filtering). + +## 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-access-control.md b/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md new file mode 100644 index 00000000000..f3d5637b04d --- /dev/null +++ b/daprdocs/content/en/developing-ai/mcp/mcp-access-control.md @@ -0,0 +1,252 @@ +--- +type: docs +title: "MCP access control" +linkTitle: "Access control" +weight: 15 +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. + +### Two layers: App-ID gating and per-tool authorization + +Dapr access control evaluates **caller App ID → target App ID** at the service-invocation boundary. It is the same mechanism Dapr uses for any other service-to-service traffic, and it gives you coarse-grained gating: which agents may reach which MCP servers at all. + +MCP transports — `streamable-http` and `sse` — route all tool calls through a **single HTTP endpoint**. The tool name lives inside the [JSON-RPC](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)`. + +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 + +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 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. + +## 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. +- [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 5981127f6ce..af4c1cfb5c6 100644 --- a/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md +++ b/daprdocs/content/en/developing-ai/mcp/mcp-authentication.md @@ -1,8 +1,8 @@ --- type: docs title: "Authenticating an MCP server" -linkTitle: "Getting Started" -weight: 20 +linkTitle: "Authenticating an MCP server" +weight: 10 description: "How to enable MCP client-side and server-side authentication" --- @@ -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..a820e691dd0 --- /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: 20 +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 new file mode 100644 index 00000000000..3675e1e8580 --- /dev/null +++ b/daprdocs/content/en/developing-ai/mcp/mcp-server-resource.md @@ -0,0 +1,446 @@ +--- +type: docs +title: "MCPServer resource" +linkTitle: "MCPServer resource" +weight: 25 +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. + +{{% 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: + +- **Zero MCP SDK in your app.** Your application starts a Dapr workflow by name. Dapr speaks MCP to the server. Swap MCP servers, change transports, or rotate credentials without touching application code. +- **Per-tool RBAC, audit, and redaction in YAML.** Order-preserving `beforeCallTool` / `afterCallTool` / `beforeListTools` / `afterListTools` hooks run argument-level authorization, rate limiting, PII redaction, audit logging, and response filtering as Dapr workflows. Set `appID` on a hook to route it to a centralized policy app, so one shared RBAC service governs every agent without each app embedding the policy. +- **Durable execution.** Tool calls run as workflow activities backed by Dapr Scheduler reminders. If daprd is restarted mid-call, the scheduler re-delivers the activity to the new instance and the call completes — agents don't have to implement their own retry/resume logic. Inside a single activity, transient connection drops are absorbed automatically: Dapr keeps one warm session per MCPServer (with keep-alive pings) and reconnects once on `ErrConnectionClosed` before the workflow ever sees the blip. +- **Fast feedback for callers.** Required-field validation runs against the cached JSON Schema *before* the MCP server is contacted. Missing arguments come back as a structured `mcp.CallToolResult{isError: true}` immediately — agents and LLMs get an actionable error without burning a network round-trip. +- **Per-tool observability.** Each tool gets its own workflow name (`dapr.internal.mcp..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. +- **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` | +| 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 + +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. + +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: + +``` +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 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 +{ + "isError": false, + "content": [ + {"type": "text", "text": "Weather in Seattle: sunny, 72°F"} + ] +} +``` + +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), `isError` is `true` and the failure is described in `content` — the workflow itself completes successfully so the calling agent or LLM receives a structured error it can act on (retry, pick a different tool, or surface to the user). + +If daprd restarts while the tool call is in flight, Dapr Scheduler re-delivers the pending activity to the new daprd instance and the workflow resumes — no application-side retry logic required. + +### Listing tools + +``` +POST /v1.0-beta1/workflows/dapr/dapr.internal.mcp..ListTools/start +Content-Type: application/json + +{} +``` + +Output: + +```json +{ + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "inputSchema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + } + } + ] +} +``` + +Tool definitions are cached at MCPServer load time and refreshed on hot-reload. Subsequent `ListTools` workflow calls return instantly from the cache — no upstream `tools/list` round-trip — so agents that call `ListTools` repeatedly pay zero MCP-server latency after the initial load. + +## Transports + +MCPServer supports three wire transports. Exactly one must be configured under `spec.endpoint`. + +### Streamable HTTP + +The recommended transport for production use. + +```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"] +``` + +### Built-in limits + +Dapr applies a few hard limits to MCP server interactions so that a misbehaving or hostile MCP server can't exhaust sidecar resources: + +- **Tool list pagination**: at most 500 pages per `tools/list` round-trip. A server that returns more is rejected at load time rather than silently truncated. +- **Schema cache**: per MCPServer, at most 500 cached tool schemas, each capped at 1 MB. +- **HTTP response-headers timeout**: 5 seconds time-to-first-byte on every outbound request. SSE streams remain unaffected because the timeout only bounds initial header receipt. + +These are intentionally not user-tunable — they're sized for typical production MCP servers and ensure the sidecar stays bounded under adversarial input. + +## Authentication + +HTTP transports (`streamableHTTP`, `sse`) support three authentication mechanisms. These are configured under the transport's `auth` field. + +### Static headers + +Inject headers on every outbound request. Supports `value`, `secretKeyRef`, and `envRef`. + +```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. 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: + endpoint: + streamableHTTP: + url: https://payments.internal/mcp + auth: + spiffe: + jwt: + header: Authorization + headerValuePrefix: "Bearer " + audience: mcp://payments +``` + +## Middleware pipelines + +Middleware hooks turn tool-call governance into declarative YAML enforced by Dapr Workflows. Optional hooks run in array order before and after tool calls and tool listing. See the [examples](#examples-common-patterns) below for the canonical patterns. + +- **Before hooks**: if any hook returns an error, the chain stops and the operation is aborted. +- **`afterCallTool` hooks**: errors **fail the workflow** — these hooks can act as authz gates that block the response from reaching the caller. +- **`afterListTools` hooks**: errors are logged but do not affect the result returned to the caller. +- **Mutating hooks**: set `mutate: true` to make the hook's return value replace the data flowing through the pipeline (arguments before the tool call, result after it). Default is `false` (observe-only — the hook validates or audits but its output is discarded). `mutate` is not supported on `beforeListTools`. + +### Hook input shapes + +Each hook is a Dapr workflow that receives a typed input from the runtime: + +```text +beforeCallTool input: { name, toolName, arguments } +afterCallTool input: { name, toolName, arguments, result } # result: bytes — JSON-encoded MCP CallToolResult +beforeListTools input: { name } +afterListTools input: { name, result } # result: bytes — JSON-encoded MCP ListToolsResult +``` + +`name` is the MCPServer resource name. `arguments` is the JSON object the caller passed. `result` is the JSON-encoded MCP-spec result (camelCase wire shape, byte-compatible with the [MCP specification](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 + +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: rbac-check + appID: policy-service # optional — see "Centralized policy app" below +``` + +Workflow body (pseudocode — language-neutral): + +```text +workflow rbac-check(input): + # input: { name, toolName, arguments } + if input.toolName == "issue_refund": + amount = input.arguments["amount"] + if amount > 10_000: + return error("rbac: refunds over $10K require manual approval") + + if input.toolName in DESTRUCTIVE_TOOLS: + if not input.arguments.get("dry_run", false): + return error("rbac: %s requires dry_run=true in this environment", + input.toolName) + + return ok # mutate=false → return value is discarded; nil error means allow +``` + +A few choices worth naming: + +- **`mutate: false`** because the hook only decides allow/deny — it never reshapes arguments. (For PII redaction, you'd flip to `mutate: true` and return the cleaned `arguments`.) +- **`beforeCallTool`** because denial should run *before* the MCP server sees the request. An equivalent `afterCallTool` hook can also gate (after-hook errors fail the workflow), but you've already paid for the upstream call. +- **Caller-keyed RBAC ("who can call which tool") belongs at the [policy layer](#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, toolName, arguments, result } + # `result` is bytes carrying a JSON-encoded MCP CallToolResult; decode first. + result = json_decode(input.result) + emit_audit({ + server: input.name, + tool: input.toolName, + args: redact(input.arguments), + succeeded: not result.isError, + at: now(), + }) + return ok # mutate=false → result reaches the caller unchanged +``` + +Because the audit hook is itself a Dapr Workflow, the write is durable: an emitter restart between `emit_audit` activity start and ack does not drop the record. + +### Centralized policy app + +When a hook sets `appID: `, 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: policy-service + mutate: true + afterCallTool: + - workflow: + workflowName: audit-logger + appID: policy-service +``` + +### Examples: common patterns + +| Pattern | Phase | `mutate` | Sketch | +|---|---|---|---| +| Argument RBAC | `beforeCallTool` | `false` | Inspect `arguments`, return error to deny. | +| 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 `{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 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. + +## 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. + +`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: + +- **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`: + +```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 %}}) +- [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 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 new file mode 100644 index 00000000000..52c6e292839 --- /dev/null +++ b/daprdocs/content/en/developing-ai/mcp/mcp-service-invocation.md @@ -0,0 +1,165 @@ +--- +type: docs +title: "MCP through Dapr service invocation" +linkTitle: "Service invocation path" +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" +--- + +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. + +## 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. + +```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 %}}) 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..b28ea2ec5a0 --- /dev/null +++ b/daprdocs/content/en/reference/resource-specs/mcpserver-schema.md @@ -0,0 +1,225 @@ +--- +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: + 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: + auth: # Optional + secretStore: + oauth2: + issuer: + clientID: # Optional. OAuth2 client identifier. + audience: + scopes: + - + secretKeyRef: + name: + key: + spiffe: + jwt: + header: + headerValuePrefix: + audience: + middleware: # Optional + beforeCallTool: + - workflow: + workflowName: + appID: # Optional. Remote app. + mutate: # Optional. When true, hook return value replaces the arguments. + afterCallTool: + - workflow: + workflowName: + mutate: # Optional. When true, hook return value replaces the result. + beforeListTools: + - workflow: + workflowName: + afterListTools: + - workflow: + workflowName: + mutate: # Optional. When true, hook return value replaces the result. + 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 + +### 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.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.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"` | +| 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. 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 | +|-------|:--------:|---------|---------| +| 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"` | +| 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 + +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 %}})