For the complete documentation index, see llms.txt. Markdown versions of all docs pages are available by appending .md to any docs URL.
Set up MCP guardrails
Verified Code examples on this page have been automatically tested and verified.Gate and mutate MCP method calls with an external ExtMCP policy server or in-process CEL expressions.
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.
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
Install theagentgateway binary.Set up MCP guardrails
Start a sample ExtMCP policy server. This example uses a prebuilt gRPC server that denies
tools/callwhen the tool name containsforbidden, and appends[extmcp]to every tool description intools/listresponses. The server listens on port9001.docker run --rm -p 9001:9001 gcr.io/solo-public/docs/testbox:latestNote
Build your own ExtMCP server: The sample server is for demonstration only. To build your own, implement the
ExtMcpgRPC service from the ExtMCP protocol definition. The service has two methods:CheckRequest: Called in the request phase, before the call reaches the MCP backend. Return the request unchanged, return mutatedparams, or return anAuthorizationErrorto deny the call.CheckResponse: Called in the response phase, after the MCP backend returns a result. Return the response unchanged, return a mutatedresult, or return anAuthorizationErrorto deny the call.
Generate gRPC bindings from the proto file in your language, implement the two methods, and serve them over cleartext HTTP/2 (h2c) on the port that agentgateway connects to. For more information about the request and response messages, outcomes, and error codes, see About MCP guardrails.
In another terminal, create a
config.yamlfile. The MCP backend exposes a local stdio MCP server, and themcpGuardrailspolicy on the route sends selected MCP methods through the ExtMCP server.# 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 backends: - mcp: targets: - name: everything stdio: cmd: npx args: ["@modelcontextprotocol/server-everything"]Review the following table to understand the
mcpGuardrailspolicy.Setting Description kind: remoteUse a remote gRPC ExtMCP server to enforce this processor. hostThe address of the ExtMCP policy server. This example points to the sample server from the previous step. failureMode: failClosedDeny requests if the policy server is unreachable or returns an error. To allow requests instead, set failOpen.methodsThe MCP methods to route through the policy server, and the phase for each. tools/call: requestsends each tool call to the server before it reaches the MCP backend, so the server can allow, mutate, or deny the call.tools/list: responsesends the tool listing to the server after the backend returns it, so the server can filter or annotate the list. For the full list of phases and method matching, see About MCP guardrails.Run agentgateway with the configuration file.
agentgateway -f config.yaml
Verify the guardrails
Verify that the policy server gates tools/call and mutates tools/list responses.
Initialize an MCP session and save the session ID.
export MCP_SESSION_ID=$(curl -s -D - http://localhost:3000/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "MCP-Protocol-Version: 2025-03-26" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}' \ | grep -i "mcp-session-id:" | sed 's/.*: //' | tr -d '\r') echo $MCP_SESSION_IDSend the
notifications/initializednotification to complete the MCP handshake. The MCP server does not answer other requests, such astools/list, until initialization is complete.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","method":"notifications/initialized"}'List the available tools. Verify that each tool description ends with
[extmcp], which the policy server added in the response phase.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":2,"method":"tools/list","params":{}}'Example output:
data: {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","description":"Echoes back the input [extmcp]",...}]}}Call a tool whose name contains
forbidden. Verify that the policy server denies the call with a JSON-RPC error.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":3,"method":"tools/call","params":{"name":"forbidden-tool","arguments":{}}}'Example output:
{"jsonrpc":"2.0","id":3,"error":{"code":-32001,"message":"tool forbidden-tool is not allowed"}}Call an allowed tool, such as
echo. Verify that the call succeeds.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":4,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello"}}}'
Add CEL 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.
Update the
config.yamlfile to add threeexpressionprocessors after theremoteprocessor. The first processor rejectsechocalls whose message containsDO_NOT_SEND. The second processor masks email addresses in theechomessage before the call reaches the MCP backend. The third processor masksdemo-secret-tokens in the text content of tool results.# 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
expressionsettings. For the variables that an expression can read, see CEL variables.Setting Description kind: expressionRun the processor as a CEL expression inside agentgateway instead of calling a remote ExtMCP server. Expression processors have no failureMode.conditionOptional. A CEL expression that must return truefor the action to run. If you omitcondition, the action runs for every method that the processor matches. Check optional fields withhas()first because an expression that fails to evaluate rejects the call with a JSON-RPC internal error.rejectThe message to return when the processor rejects a call. When the processor rejects a tools/callrequest in the request phase, the client receives a tool result withisError: trueand this message as its text. Other rejections return a JSON-RPC error with code-32001. Set exactly one ofrejectortransform.transformA 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.paramsvariable and the value that the expression returns becomes the new request parameters. In the response phase, the expression reads the current response result from themcp.resultvariable and the value that the expression returns becomes the new response result. To change one field and keep the others, usemergeon the variable, as in the example. To leave the message unchanged, returnnull. If the output is not a valid message for the method, agentgateway rejects the call.methodsThe MCP methods and phases that the processor runs for, the same as for a remoteprocessor.Stop agentgateway with
Ctrl+Cand run it again with the updated configuration file.agentgateway -f config.yamlStart a new MCP session. Repeat steps 1 and 2 in Verify the guardrails.
Call the
echotool with a message that containsDO_NOT_SEND. Verify that the response is a tool result withisError: trueand the textMessages containing DO_NOT_SEND may not be sent upstream. The call does not reach the MCP backend.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"}}}'Call the
echotool with a message that contains an email address and a demo secret. Verify that the result text isEcho: Contact [EMAIL] with [SECRET]. The second processor masked the email address in the request, and the third processor masked the secret in the response.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
You can remove the resources that you created in this guide.- Stop the agentgateway process with
Ctrl+C. - Stop the sample ExtMCP server with
Ctrl+Cin its terminal. - Delete the configuration file.
rm config.yaml