# MCP guardrails

MCP guardrails let you insert authorization, audit, and argument-rewriting logic before and after MCP tool calls, by routing the call through a Dapr Workflow that Catalyst manages. Each "guardrail" is itself a workflow that runs around the tool call, so its execution is durable and replay-safe.

:::warning Read before adopting

**This is a workflow-only integration path, not a general MCP integration.** It carries significant tradeoffs:

- **Requires the Dapr Workflow client.** You must invoke MCP tools through Catalyst's 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.
- **Catalyst — not your app — is the MCP client.** Tool calls are routed through Catalyst, which acts as the protocol-level MCP client. Your application no longer speaks MCP directly.
- **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.** A future Catalyst release is expected to let off-the-shelf MCP clients drive the same flow without the workflow SDK.

Use this pattern **only** when:

- Your application already uses Dapr Workflows for the rest of its execution model,
- You need per-tool, per-argument authorization or audit hooks that can't be expressed at the [app access policy](https://docs.diagrid.io/develop/mcp/mcp-access-policies) layer,
- And you accept that off-the-shelf MCP clients and agent frameworks will not work for these calls.

For most teams, **use [app access policies](https://docs.diagrid.io/develop/mcp/mcp-access-policies) and [bearer middleware](https://docs.diagrid.io/develop/mcp/mcp-authentication) instead.** They work with any MCP client and any agent framework.

:::

## How it works

When an agent invokes a tool through the workflow client, Catalyst starts a workflow that wraps the tool call. The workflow runs an ordered list of hook workflows before the tool call, dispatches the tool call to the MCP server, then runs another ordered list of hook workflows after the result returns. Each phase is configured on an `MCPServer` resource and resolved when the call workflow starts.

```mermaid
flowchart LR
  AGENT(Agent / workflow client)
  subgraph Catalyst Workflow
    BEFORE[beforeCallTool hooks]
    CALL[CallTool activity]:::decision
    AFTER[afterCallTool hooks]
  end
  SERVER(MCP server)

  AGENT-->BEFORE
  BEFORE-->CALL
  CALL-->SERVER
  SERVER-->AFTER
  AFTER-->AGENT

  classDef decision stroke:#ed8936
```

Because the wrapping work is a workflow, every hook execution, every retry, and every input/output is recorded and viewable in the Catalyst observability console.

## The four hook phases

Each `MCPServer` can attach an ordered list of workflows to each phase. Within a phase, hooks run in array order — every hook sees the (possibly mutated) output of the previous one.

| Phase | Runs | Typical use |
|---|---|---|
| `beforeListTools` | Before `dapr.internal.mcp.<server>.ListTools` returns the cached tool list | Allow/deny of the listing as a whole |
| `afterListTools` | After tool discovery returns, before the list reaches the caller | Catalog filtering (drop tools the caller isn't entitled to see) |
| `beforeCallTool` | Before `dapr.internal.mcp.<server>.CallTool.<tool>` invokes the MCP server | Argument-level authorization, rate limiting, PII redaction |
| `afterCallTool` | After the tool call returns, before the result reaches the caller | Audit logging, response filtering, response gating |

## Hook input shapes

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

```text
beforeListTools  input: { name }
afterListTools   input: { name, result }                          # result is the JSON-encoded MCP ListToolsResult
beforeCallTool   input: { name, tool_name, arguments }
afterCallTool    input: { name, tool_name, arguments, result }    # result is the JSON-encoded MCP CallToolResult
```

`name` is the MCPServer resource name. `toolName` is the tool being called. `arguments` is the JSON object the caller passed. `result` is opaque to Dapr — it is the MCP server's response (or a previous mutating hook's return value) serialized exactly as the [MCP specification](https://modelcontextprotocol.io/specification/2024-11-05/server/tools#tool-result) defines it: a `CallToolResult` for `afterCallTool`, a `ListToolsResult` for `afterListTools`. Mutating hooks return the same shape, byte-for-byte, and the runtime feeds it back through `json.Unmarshal` into the upstream Go SDK's types.

For a quick example, a `CallToolResult` with a text content block looks like:

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

Content blocks are a flat tagged union discriminated by `"type"` — `"text"`, `"image"`, `"audio"`, `"resource_link"`, `"embedded_resource"`. For binary content the shape is `{"type": "image", "data": "<base64>", "mimeType": "image/png"}` (and likewise `"audio"`). See the MCP spec for the full set.

## Error semantics

| Phase | If a hook returns an error |
|---|---|
| `beforeListTools` | The workflow **fails**. The caller cannot proceed with the listing. |
| `afterListTools` | The workflow **fails**, exactly like `afterCallTool`. Every after-hook is also a gate — there is no purely observational after-phase. For audit-only behavior, write the hook so it never raises. |
| `beforeCallTool` | The chain stops and the tool call is aborted (`afterCallTool` does **not** run for the denial). The workflow completes with `isError: true` and the hook's error message in `content`. The caller's LLM can reason about the denial and decide to retry, escalate, or surface to the user. |
| `afterCallTool` | The workflow **fails**. Use this when you need to gate the response — for example, refuse to return a result that contains a forbidden field. The failure surfaces to the caller as a workflow failure, not as a tool result. |

`afterCallTool` does run when the tool call *itself* fails: the runtime invokes it with `isError: true` injected into `result`, so after-hooks observe runtime failures. They are skipped only for `beforeCallTool` policy denials, which short-circuit before the tool call.

## Mutating hooks

By default a hook is observational — its return value is discarded. Set `mutate: true` to make the hook's return value replace the data flowing through the pipeline. The next hook (or the tool call, or the caller) sees the mutated value.

| Phase | `mutate: true` replaces |
|---|---|
| `beforeListTools` | Not available — `mutate` is not a field on `beforeListTools` hooks; setting it is rejected by the CRD validator |
| `afterListTools` | The `result` (tool list) passed to the next hook and back to the caller |
| `beforeCallTool` | The `arguments` passed to the next hook and to the tool call |
| `afterCallTool` | The `result` passed to the next hook and back to the caller |

A mutating hook returns the same shape it receives — modify the relevant field, then return the whole input.

## Centralizing hooks in a policy app

When a hook sets `appID: <other-app>`, the hook workflow runs on the named remote Catalyst 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.

## Configure hooks

Reference each hook by workflow name (and optional `appID` and `mutate`) in the `MCPServer` spec:

```yaml
apiVersion: dapr.io/v1alpha1
kind: MCPServer
metadata:
  name: payments-mcp
spec:
  endpoint:
    streamableHTTP:
      url: https://payments.internal/mcp
  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
scopes:
  - my-agent-app
```

Workflow names must match workflows registered by the app at `appID` (or, when `appID` is omitted, the same app that runs the calling agent). Catalyst does not validate the names at save time; if a workflow is missing at call time, the call aborts and the failure surfaces as an `isError` tool result.

## Implement a hook

Hooks are durable workflows you write with the Dapr workflow SDK in your language (`dapr.ext.workflow` for Python, equivalents in Go, .NET, Java). Register them on the app whose ID is referenced in `middleware.<phase>.workflow.appID` (or in the calling agent's app when `appID` is omitted).

### Argument-level authorization (`beforeCallTool`)

```python
from dapr.ext.workflow import WorkflowRuntime, DaprWorkflowContext

wfr = WorkflowRuntime()

@wfr.workflow(name="rbac-check")
def authz(ctx: DaprWorkflowContext, payload: dict):
    tool = payload["tool_name"]
    args = payload["arguments"]

    # Block refunds over $1000 for the basic agent role.
    if tool == "refund_payment" and args.get("amount", 0) > 1000:
        raise PermissionError(
            f"refund amount {args['amount']} exceeds policy limit"
        )

    return None   # mutate=false → return value is discarded; no exception raised → allow
```

Raising any exception aborts the call. The exception message is surfaced in the `content` of the resulting tool error — keep it human-readable so the calling agent can reason about it.

### Argument rewriting (`beforeCallTool` with `mutate: true`)

```python
@wfr.workflow(name="redact-pii")
def redact(ctx: DaprWorkflowContext, payload: dict):
    args = dict(payload["arguments"])
    if "email" in args:
        args["email"] = "<redacted>"
    return {
        "name": payload["name"],
        "tool_name": payload["tool_name"],
        "arguments": args,
    }
```

The downstream tool call (and any subsequent hooks) sees the redacted arguments.

### Audit (`afterCallTool`)

```python
@wfr.workflow(name="audit-logger")
def audit(ctx: DaprWorkflowContext, payload: dict):
    yield ctx.call_activity(
        write_audit_record,
        input={
            "timestamp": ctx.current_utc_datetime.isoformat(),
            "mcpServer": payload["name"],
            "tool": payload["tool_name"],
            "arguments": payload["arguments"],
            "isError": payload["result"]["isError"],
        },
    )
    return None   # mutate=false → result reaches the caller unchanged
```

Because the audit hook is itself a workflow, its activities are durably retried.

### Catalog filtering (`afterListTools` with `mutate: true`)

```python
@wfr.workflow(name="filter-tools")
def filter_tools(ctx: DaprWorkflowContext, payload: dict):
    result = payload["result"]
    result["tools"] = [t for t in result["tools"] if not t["name"].startswith("admin_")]
    return {"name": payload["name"], "result": result}
```

The caller sees a reduced tool list.

## 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`; error when exhausted |
| PII redaction (request) | `beforeCallTool` | `true` | Transform `arguments`, return the cleaned shape |
| Audit logging | `afterCallTool` | `false` | Emit `{toolName, arguments, result.isError}` to a sink |
| Response filtering | `afterCallTool` | `true` | Strip or mask fields in `result.content`, return updated response |
| Catalog filtering | `afterListTools` | `true` | Drop tools the caller isn't entitled to discover |

## Observability

In the [Catalyst observability console](https://docs.diagrid.io/operate/project-operations/observability), hook executions appear as nested child workflows attached to the `dapr.internal.mcp.<server>.CallTool.<tool>` and `dapr.internal.mcp.<server>.ListTools` orchestrations. You can drill into a single tool call to see which hooks ran, how long each took, and whether they aborted or completed.

Hooks emit traces and logs through Catalyst's standard observability pipeline; nothing additional is required to surface them.

## Limits

- Hooks run synchronously relative to the tool call — slow hooks add latency directly to every tool invocation.
- Hooks are not the right place for long human-in-the-loop pauses today. The MCP `elicitation` and `sampling` primitives that pause a tool call mid-execution are tracked for a follow-on release.
- Every tool call spawns a child workflow and writes to the workflow state store. Evaluate the storage and throughput impact before adopting at scale.

## Troubleshooting

### Every tool call is denied with "no such workflow"

The workflow named in one of `middleware.beforeCallTool`, `middleware.afterCallTool`, `middleware.beforeListTools`, or `middleware.afterListTools` is not registered by the targeted app.

Fix:

- Confirm the workflow is registered in an application that runs in your project.
- If the hook sets `appID: <other-app>`, confirm that app is running and registers the workflow under the same name.
- If `appID` is omitted, the workflow must be registered by the same app that calls the tool.

### `beforeCallTool` aborts calls unexpectedly

A hook raised an exception, so the call was aborted with an `isError` tool result. Inspect the workflow execution: the hook's exception message becomes the `content` text in the result. Adjust the workflow's logic or relax the policy.

### `afterCallTool` failure surfaces as a workflow failure (not a tool result)

This is intentional. `afterCallTool` errors **fail the workflow** so the hook can act as a response gate — for example, refusing to return a result that contains a forbidden field. The failure surfaces to the caller's code as a workflow failure, not as a tool result to the LLM. View the failed workflow in the observability console to see which hook caused it.

If you want the result to flow through unchanged, write the hook so it never raises — log the issue and return without error. Every after-hook fails the workflow when it raises, so there is no phase that silently swallows errors.

### Local runtime refuses to start with an MCPServer present

The workflow runtime is required. The Dapr CLI's slim mode does not include it — if you're running locally with `dapr init --slim`, reinitialize without the slim flag.
