Skip to content
4 changes: 4 additions & 0 deletions content/docs/kubernetes/main/reference/cel/variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Each policy execution consistently gets the current view of the request and resp

For the full list of fields and types on every top-level object, see the [Interactive CEL reference]({{< link-hextra path="/reference/cel/cel-context-interactive" >}}) page.

When an agent session can be identified, the session is available as `request.agent.session`. The session is detected from agent headers such as `x-claude-code-session-id` and `x-session-id`.

When a policy can run against both directly addressed and Service backends, check `has(backend.endpoint)` before reading `backend.endpoint`. The field is populated only for directly addressed backends, such as static hostnames. Service backends leave `backend.endpoint` unset because workload endpoints are selected separately.

> [!NOTE]
Expand All @@ -36,6 +38,8 @@ Depending on the policy, different fields are accessible based on when in the re
|HTTP Authorization| `source`, `request`, `jwt`, `mcp`, `backend` |
|External Authorization| `source`, `request`, `jwt`, `mcp`, `backend` |
|MCP Authorization| `source`, `request`, `jwt`, `mcp`, `backend` — `mcp.methodName` distinguishes methods such as `tools/list` and `tools/call`. For list methods, rules run once per listed item. `mcp.sessionId` and `mcp.tool.arguments` aren't set. |
|MCP guardrails (CEL request processor)| `source`, `request`, `jwt`, `mcp`, `backend` — `mcp.params` contains the current MCP request parameters. |
|MCP guardrails (CEL response processor)| Same as the request processor, except that `mcp.result` replaces `mcp.params`. `mcp.result` contains the current MCP response result. |
|Logging| `source`, `request`, `jwt`, `mcp`, `backend`, `extauthz`, `response`, `llm`|
|Tracing| `source`, `request`, `jwt`, `mcp`, `backend`, `extauthz`, `response`, `llm`|
|Metrics| `source`, `request`, `jwt`, `mcp`, `backend`, `extauthz`, `response`, `llm`|
Expand Down
7 changes: 7 additions & 0 deletions content/docs/kubernetes/main/release-notes/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,19 @@ Review the release notes for agentgateway on Kubernetes.
<!-- ref: https://github.com/agentgateway/agentgateway/pull/3763 -->

- **Optional chart-created RBAC**: Set `rbac.create` to `false` in the controller Helm chart to skip the ClusterRoles, Role, ClusterRoleBindings, and RoleBindings that the chart creates, for clusters that manage role-based access control (RBAC) outside of Helm. For more information, see [Chart-created RBAC resources]({{< link-hextra path="/documentation/install/advanced/#rbac-create" >}}).

### MCP {#v17-mcp}

<!-- ref: https://github.com/agentgateway/agentgateway/pull/3824 -->

- **Conditional virtual MCP targets**: Set `spec.mcp.targets[].condition` on an {{< reuse "agw-docs/snippets/backend.md" >}} to a CEL expression to select which targets take part in a request before agentgateway initializes or contacts them. A backend whose only target is a `static` target cannot set a condition. For more information, see [Select targets with conditions]({{< link-hextra path="/documentation/mcp/virtual/#target-conditions" >}}).

### Observability {#v17-observability}

<!-- ref: https://github.com/agentgateway/agentgateway/pull/3850 -->

- **Agent session detection**: CEL expressions can read the agent session of a request as `request.agent.session`. The session is detected from agent headers such as `x-claude-code-session-id`.

## 🐛 Fixes {#v17-fixes}

<!-- ref: https://github.com/agentgateway/agentgateway/pull/3744 -->
Expand Down
4 changes: 4 additions & 0 deletions content/docs/standalone/main/documentation/llm/playground.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ Use the built-in LLM playground in the agentgateway UI to send a chat completion
{{< reuse-image-light src="img/ui-llm-playground.png" >}}
{{< reuse-image-dark srcDark="img/ui-llm-playground-dark.png" >}}

## Authentication behavior

The playground sends the request from your browser, but does not forward browser credentials, such as cookies or HTTP basic authentication, to the LLM gateway. If the gateway requires a bearer token, select a saved key in the **Virtual API key** list. Or, select **Raw value** and enter the token in the **Raw API key** field. The playground sends the key in an `Authorization: Bearer` header. The MCP playground also omits browser credentials and sends the value of its **Bearer token** field instead.

## Next steps

- [Observe LLM traffic]({{< link-hextra path="/documentation/llm/observability/" >}}) with metrics, logs, and traces.
Expand Down
120 changes: 117 additions & 3 deletions content/docs/standalone/main/documentation/mcp/guardrails/setup.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
title: Set up MCP guardrails
weight: 20
description: Gate and mutate MCP method calls with an external ExtMCP policy server.
description: Gate and mutate MCP method calls with an external ExtMCP policy server or in-process CEL expressions.
test:
mcp-guardrails:
- path: mcp-guardrails
---

Gate and mutate Model Context Protocol (MCP) method calls with an external policy server. For more information about how MCP guardrails work, see [About MCP guardrails]({{< link-hextra path="/documentation/mcp/guardrails/about" >}}).
Gate and mutate Model Context Protocol (MCP) method calls with an external policy server or in-process Common Expression Language (CEL) expressions. For more information about how MCP guardrails work, see [About MCP guardrails]({{< link-hextra path="/documentation/mcp/guardrails/about" >}}).

In this guide, you route `tools/call` and `tools/list` requests through a sample ExtMCP server that denies any tool whose name contains `forbidden` and annotates each tool description in `tools/list` responses.
In this guide, you route `tools/call` and `tools/list` requests through a sample ExtMCP server that denies any tool whose name contains `forbidden` and annotates each tool description in `tools/list` responses. Then, you add CEL expression processors that reject and mask tool calls without a policy server.

## Before you begin

Expand Down Expand Up @@ -249,6 +249,120 @@ EC=$(curl -s http://localhost:3000/mcp \
echo "$EC" | grep -q 'Echo: hello' && echo "✓ allowed tool call (echo) succeeded" || { echo "FAIL: echo call did not succeed: $EC"; exit 1; }
{{< /doc-test >}}

## Add CEL expression processors {#expression-processors}

You can also run a processor as a CEL expression inside agentgateway, without a policy server. Set `kind: expression` instead of `kind: remote`. One `mcpGuardrails` policy can mix both kinds. Processors run in list order, each processor sees the changes from earlier processors, and the first rejection stops the chain.

1. Update the `config.yaml` file to add three `expression` processors after the `remote` processor. The first processor rejects `echo` calls whose message contains `DO_NOT_SEND`. The second processor masks email addresses in the `echo` message before the call reaches the MCP backend. The third processor masks `demo-secret-` tokens in the text content of tool results.
```yaml
# yaml-language-server: $schema=https://agentgateway.dev/schema/config
gateways:
default:
port: 3000
routes:
- policies:
cors:
allowOrigins:
- "*"
allowHeaders:
- mcp-protocol-version
- content-type
- mcp-session-id
exposeHeaders:
- "Mcp-Session-Id"
mcpGuardrails:
processors:
- kind: remote
host: "localhost:9001"
failureMode: failClosed
methods:
tools/call: request
tools/list: response
# Reject before masking, so this processor sees the original message.
- kind: expression
methods: {"tools/call": request}
condition: >-
mcp.params.name == 'echo' &&
has(mcp.params.arguments) &&
has(mcp.params.arguments.message) &&
mcp.params.arguments.message.contains('DO_NOT_SEND')
reject: "Messages containing DO_NOT_SEND may not be sent upstream"
# Mask every email-like match in the echo message.
- kind: expression
methods: {"tools/call": request}
condition: >-
mcp.params.name == 'echo' &&
has(mcp.params.arguments) &&
has(mcp.params.arguments.message)
transform: >-
mcp.params.with(p, p.merge({
'arguments': p.arguments.merge({
'message': p.arguments.message.regexReplaceAll(
'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+[.][A-Za-z]{2,}',
'[EMAIL]'
)
})
}))
# Mask demo secrets in all text blocks that tools return.
- kind: expression
methods: {"tools/call": response}
condition: has(mcp.result.content)
transform: >-
mcp.result.with(r, r.merge({
'content': r.content.map(item,
item.type == 'text'
? item.merge({'text': item.text.regexReplaceAll(
'demo-secret-[A-Za-z0-9]+', '[SECRET]'
)})
: item
)
}))
backends:
- mcp:
targets:
- name: everything
stdio:
cmd: npx
args: ["@modelcontextprotocol/server-everything"]
```

Review the following table to understand the `expression` settings. For the variables that an expression can read, see [CEL variables]({{< link-hextra path="/reference/cel/variables" >}}).

| Setting | Description |
|---------|-------------|
| `kind: expression` | Run the processor as a CEL expression inside agentgateway instead of calling a remote ExtMCP server. Expression processors have no `failureMode`. |
| `condition` | Optional. A CEL expression that must return `true` for the action to run. If you omit `condition`, the action runs for every method that the processor matches. Check optional fields with `has()` first because an expression that fails to evaluate rejects the call with a JSON-RPC internal error. |
| `reject` | The message to return when the processor rejects a call. When the processor rejects a `tools/call` request in the request phase, the client receives a tool result with `isError: true` and this message as its text. Other rejections return a JSON-RPC error with code `-32001`. Set exactly one of `reject` or `transform`. |
| `transform` | A CEL expression that returns a new version of the MCP message. In the request phase, the expression reads the current request parameters from the `mcp.params` variable and the value that the expression returns becomes the new request parameters. In the response phase, the expression reads the current response result from the `mcp.result` variable and the value that the expression returns becomes the new response result. To change one field and keep the others, use `merge` on the variable, as in the example. To leave the message unchanged, return `null`. If the output is not a valid message for the method, agentgateway rejects the call. |
| `methods` | The MCP methods and phases that the processor runs for, the same as for a `remote` processor. |

2. Stop agentgateway with `Ctrl+C` and run it again with the updated configuration file.
```sh
agentgateway -f config.yaml
```

3. Start a new MCP session. Repeat steps 1 and 2 in [Verify the guardrails](#verify-the-guardrails).

4. Call the `echo` tool with a message that contains `DO_NOT_SEND`. Verify that the response is a tool result with `isError: true` and the text `Messages containing DO_NOT_SEND may not be sent upstream`. The call does not reach the MCP backend.
```sh
curl -s http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-03-26" \
-H "mcp-session-id: $MCP_SESSION_ID" \
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"echo","arguments":{"message":"DO_NOT_SEND this"}}}'
```

5. Call the `echo` tool with a message that contains an email address and a demo secret. Verify that the result text is `Echo: Contact [EMAIL] with [SECRET]`. The second processor masked the email address in the request, and the third processor masked the secret in the response.
```sh
curl -s http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-03-26" \
-H "mcp-session-id: $MCP_SESSION_ID" \
-d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"echo","arguments":{"message":"Contact alice@example.com with demo-secret-abc123"}}}'
```

## Cleanup

{{< reuse "agw-docs/snippets/cleanup.md" >}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ frontendPolicies:
cost: 'string(llm.cost)'
```

For requests from agent harnesses, you can also log the detected agent session. The `request.agent.session` value comes from common agent headers such as `x-claude-code-session-id` and `x-session-id`, unless you override that value with `config.standardAttributes.session`.

```yaml
# yaml-language-server: $schema=https://agentgateway.dev/schema/config
frontendPolicies:
accessLog:
add:
agent_session: 'request.agent.session'
```

For the full list of available fields, see the [CEL variables reference]({{< link-hextra path="/reference/cel/variables/" >}}).

### Log the selected backend endpoint {#backend-endpoint}
Expand Down
4 changes: 4 additions & 0 deletions content/docs/standalone/main/reference/cel/variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Each policy execution consistently gets the current view of the request and resp

For the full list of fields and types on every top-level object, see the [CEL reference]({{< link-hextra path="/reference/cel/cel-context" >}}) page. It is generated from the [agentgateway CEL schema](https://github.com/agentgateway/agentgateway/blob/main/schema/cel.md) and is the source of truth for nested fields (for example, `source.address` or `llm.inputTokens`).

When an agent session can be identified, the session is available as `request.agent.session`. By default, the session is detected from agent headers such as `x-claude-code-session-id` and `x-session-id`. To set the session yourself, set `config.standardAttributes.session` to a CEL expression that returns a string. Return `null` or an empty string to mark a request as having no agent session. If the expression fails or returns another type, the detected session is used.

When a policy can run against both directly addressed and Service backends, check `has(backend.endpoint)` before reading `backend.endpoint`. The field is populated only for directly addressed backends, such as static hostnames. Service backends leave `backend.endpoint` unset because workload endpoints are selected separately.

> [!NOTE]
Expand All @@ -36,6 +38,8 @@ Depending on the policy, different top-level variables are bound when CEL runs.
| Network authorization | `env`, `source` [^3] |
| External Authorization | `request`, `response`, `env`, `jwt`, `apiKey`, `basicAuth`, `llm`, `source`, `mcp`, `backend`, `extauthz`, `extproc`, `metadata` — some expressions run after the authorization service returns and can read `response`. [^4] |
| MCP Authorization | `request`, `env`, `jwt`, `apiKey`, `basicAuth`, `llm`, `source`, `mcp`, `backend`, `extauthz`, `extproc`, `metadata` — `mcp.methodName` distinguishes methods such as `tools/list` and `tools/call`. For list methods, rules run once per listed item. `mcp.sessionId` and `mcp.tool.arguments` aren't set. |
| MCP guardrails (CEL request processor) | `request`, `env`, `jwt`, `apiKey`, `basicAuth`, `llm`, `source`, `mcp`, `backend`, `extauthz`, `extproc`, `metadata` — `mcp.params` contains the current MCP request parameters. |
| MCP guardrails (CEL response processor) | Same as the request processor, except that `mcp.result` replaces `mcp.params`. `mcp.result` contains the current MCP response result. |
| External processing (ExtProc) | Request-phase rules: same as Transformation (request). Response-phase rules: same as Transformation (response). |
| LLM policy | `request`, `env`, `jwt`, `apiKey`, `basicAuth`, `llm`, `llmRequest`, `source`, `backend`, `extauthz`, `extproc`, `metadata` — `llmRequest` is the raw JSON body during LLM request handling (not `mcp`). [^5] |
| Logging | `request`, `response`, `env`, `jwt`, `apiKey`, `basicAuth`, `llm`, `source`, `mcp`, `backend`, `extauthz`, `extproc`, `metadata` [^6] |
Expand Down
12 changes: 12 additions & 0 deletions content/docs/standalone/main/release-notes/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,24 @@ Review the release notes for agentgateway standalone.

## 🌟 New features {#v17-new-features}

### MCP {#v17-mcp}

<!-- ref: https://github.com/agentgateway/agentgateway/pull/3711 -->

- **CEL MCP guardrails**: MCP guardrail processors can now use `kind: expression` to reject matching MCP operations, or to transform the request `mcp.params` or response `mcp.result` body with CEL, without a remote ExtMCP server. For more information, see [Set up MCP guardrails]({{< link-hextra path="/documentation/mcp/guardrails/setup/#expression-processors" >}}).

### Traffic management and operations {#v17-operations}

<!-- ref: https://github.com/agentgateway/agentgateway/pull/3842 -->

- **Standalone Helm session key Secret**: Set `session.secretName` to load the AES-256-GCM session key from an existing Kubernetes Secret. The chart passes the Secret's `key` entry to the proxy as the `SESSION_KEY` environment variable, which takes precedence over `config.session.key`. For more information, see [Use an existing session key Secret]({{< link-hextra path="/documentation/setup/install/helm/#helm-session-secret" >}}).

### Observability {#v17-observability}

<!-- ref: https://github.com/agentgateway/agentgateway/pull/3850 -->

- **Agent session detection**: CEL expressions can read the agent session of a request as `request.agent.session`. The session is detected from agent headers such as `x-claude-code-session-id`, or set by a CEL expression in `config.standardAttributes.session`.

## 🐛 Fixes {#v17-fixes}

<!-- ref: https://github.com/agentgateway/agentgateway/pull/3744 -->
Expand Down
Loading