Skip to main content

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.

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 ID access policy layer,
  • And you accept that off-the-shelf MCP clients and agent frameworks will not work for these calls.

For most teams, use App ID access policies and bearer middleware 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.

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.

PhaseRunsTypical use
beforeListToolsBefore dapr.internal.mcp.<server>.ListTools returns the cached tool listAllow/deny of the listing as a whole
afterListToolsAfter tool discovery returns, before the list reaches the callerCatalog filtering (drop tools the caller isn't entitled to see)
beforeCallToolBefore dapr.internal.mcp.<server>.CallTool.<tool> invokes the MCP serverArgument-level authorization, rate limiting, PII redaction
afterCallToolAfter the tool call returns, before the result reaches the callerAudit logging, response filtering, response gating

Hook input shapes

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

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 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:

{
"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

PhaseIf a hook returns an error
beforeListToolsThe workflow fails. The caller cannot proceed with the listing.
afterListToolsThe 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.
beforeCallToolThe 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.
afterCallToolThe 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.

Phasemutate: true replaces
beforeListToolsNot available — mutate is not a field on beforeListTools hooks; setting it is rejected by the CRD validator
afterListToolsThe result (tool list) passed to the next hook and back to the caller
beforeCallToolThe arguments passed to the next hook and to the tool call
afterCallToolThe 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:

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 App ID is referenced in middleware.<phase>.workflow.appID (or in the calling agent's app when appID is omitted).

Argument-level authorization (beforeCallTool)

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)

@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)

@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)

@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

PatternPhasemutateSketch
Argument RBACbeforeCallToolfalseInspect arguments, return error to deny
Rate limitingbeforeCallToolfalseLook up budget keyed by toolName; error when exhausted
PII redaction (request)beforeCallTooltrueTransform arguments, return the cleaned shape
Audit loggingafterCallToolfalseEmit {toolName, arguments, result.isError} to a sink
Response filteringafterCallTooltrueStrip or mask fields in result.content, return updated response
Catalog filteringafterListToolstrueDrop tools the caller isn't entitled to discover

Observability

In the Catalyst observability console, 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.