September 30, 2026
MCPAgent Gateway

Notebook

Trying agentgateway MCP guardrails with ExtMCP

A practical look at inspecting and mutating tools/list and tools/call with ExtMCP CheckRequest and CheckResponse, using agentgateway and MCP Inspector.

MCPagentgatewayRustMCP Inspector
日本語

Guide

Contents

  1. Architecture
  2. ExtMCP's CheckRequest and CheckResponse
  3. agentgateway configuration
  4. Values passed to ExtMCP
  5. tools/list targets multiple backends
  6. tools/call receives its backend and parameters
  7. CheckResponse receives the backend result
  8. Implementing and verifying guardrails
  9. Inspecting a tools/list request
  10. Filtering a tools/list response
  11. Rejecting a tools/call request
  12. Mutating a tools/call response
  13. Enabling both request and response
  14. Summary
  15. Appendix: Full ExtMCP server code

When an organization connects to multiple MCP servers, it may need to allow only approved servers, expose only selected tools from those servers, or inspect tool inputs before execution. agentgateway can be used to implement these controls.

This article uses agentgateway MCP guardrails to try the following:

  • Limit the backends and tools exposed by tools/list
  • Reject tools/call requests whose echo input contains forbidden
  • Append checked by ExtMCP to successful tools/call responses

The ExtMCP server is implemented in Rust, and the behavior is verified by connecting MCP Inspector to http://localhost:3000/mcp.

The agentgateway version used in this article is v1.6.0-alpha.2.

For an overview of MCP guardrails and their supported capabilities, see the agentgateway documentation.

Architecture

The request flow is as follows.

MCP Inspector
  ↓
agentgateway :3000
  ↓                  ↘
ExtMCP :9001           MCP backends
CheckRequest /          - everything
CheckResponse           - github

When agentgateway receives an MCP request, it calls ExtMCP's CheckRequest according to its configuration. ExtMCP can allow, deny, or mutate the request.

After a backend returns a response, agentgateway calls CheckResponse. It can pass the response through, mutate it, or return an error.

ExtMCP's CheckRequest and CheckResponse

agentgateway's ExtMCP protocol defines two RPCs.

RPCWhen it is calledPurpose in this article
CheckRequestBefore forwarding to an MCP backendInspect the backend name and tools/call arguments
CheckResponseAfter a response returns from an MCP backendFilter the tool list and append to tool results

CheckRequest receives an McpRequest and can return Pass, Mutated, or AuthorizationError.

CheckResponse receives an McpResponse and can similarly pass, mutate, or deny it.

This implementation handles both tools/list and tools/call.

agentgateway configuration

Set MCP guardrails to full to call ExtMCP for both requests and responses.

mcpGuardrails:
  processors:
    - kind: remote
      host: "127.0.0.1:9001"
      failureMode: failClosed
      methods:
        tools/call: full
        tools/list: full

With failureMode: failClosed, agentgateway returns an error instead of letting processing continue when it cannot reach the ExtMCP server. This is important when input validation is delegated to guardrails.

The example has these two backends.

backends:
  - mcp:
      targets:
        - name: everything
          stdio:
            cmd: npx
            args:
              - "@modelcontextprotocol/server-everything"
        - name: github
          mcp:
            host: https://api.githubcopilot.com/mcp/
          policies:
            backendAuth:
              key:
                value: ${GITHUB_PAT}
                location:
                  header:
                    name: Authorization
                    prefix: "Bearer "

Configure a GitHub Personal Access Token as GITHUB_PAT for the github backend.

Values passed to ExtMCP

ExtMCP's CheckRequest receives the MCP method name, target backend names, MCP params, and the request headers forwarded by agentgateway.

However, ${GITHUB_PAT}, which agentgateway adds when communicating with the GitHub MCP backend, is not passed to ExtMCP. ExtMCP receives information from the incoming request sent by MCP Inspector to agentgateway.

tools/list targets multiple backends

tools/list is called for all configured backends. In this log, everything and github are passed together.

request: method=tools/list,
backends=["everything", "github"],
headers=[
  ":method=POST",
  ":scheme=http",
  ":authority=localhost:3000",
  ":path=/mcp",
  "mcp-session-id=<session-id>",
  "mcp-protocol-version=2025-11-25",
  "content-type=application/json",
  "user-agent=node"
],
metadata_keys=[],
params=None

Because tools/list has no parameters, params=None is passed.

tools/call receives its backend and parameters

Calling GitHub's get_me targets only the github backend, and the tool name and arguments are passed in params.

request: method=tools/call,
backends=["github"],
headers=[
  ":method=POST",
  ":scheme=http",
  ":authority=localhost:3000",
  ":path=/mcp",
  "mcp-session-id=<session-id>",
  "mcp-protocol-version=2025-11-25",
  "content-type=application/json",
  "user-agent=node"
],
metadata_keys=[],
params=Some("{\"name\":\"get_me\",\"arguments\":{}}")

There is no authorization header in this log. The request from MCP Inspector has no Authorization header, and the GitHub ${GITHUB_PAT} is not forwarded to ExtMCP.

CheckResponse receives the backend result

CheckResponse receives the JSON-RPC result returned by a backend. Calling GitHub's get_me produced the following log.

response: method=tools/call,
backends=["github"],
metadata_keys=[],
result={"content":[{"type":"text","text":"{\"login\":\"...\",\"profile_url\":\"...\",\"details\":{...}}"}]}

The actual result contained GitHub profile data, which is omitted here.

Implementing and verifying guardrails

The following sections implement and verify representative MCP guardrail controls. For tools/list, the exposed surface is filtered. For tools/call, the example rejects input and mutates the response.

Inspecting a tools/list request

tools/list fans out to multiple backends. ExtMCP receives the target backend names in service_names.

This rule allows only everything and github.

if let Some(server) = request
    .service_names
    .iter()
    .find(|server| !matches!(server.as_str(), "everything" | "github"))
{
    return Ok(Response::new(deny(format!(
        "tools/list is not allowed for server {server}"
    ))));
}

Initially, the time backend was also configured.

backends:
  - mcp:
      targets:
        - name: time
          stdio:
            cmd: uvx
            args:
              - --with
              - mcp<2
              - mcp-server-time
        - name: everything
          stdio:
            cmd: npx
            args:
              - "@modelcontextprotocol/server-everything"
        - name: github
          mcp:
            host: https://api.githubcopilot.com/mcp/
          policies:
            backendAuth:
              key:
                value: ${GITHUB_PAT}
                location:
                  header:
                    name: Authorization
                    prefix: "Bearer "

Running tools/list from MCP Inspector then returned the following error.

{
  "jsonrpc": "2.0",
  "id": 8,
  "error": {
    "code": -32001,
    "message": "tools/list is not allowed for server time"
  }
}

This is expected: tools/list targets time, everything, and github, and ExtMCP receives all three names. Since time is not in the allowlist, the entire request is denied.

After removing time and allowing only everything and github, tools/list can proceed.

Filtering a tools/list response

CheckResponse filters the returned tool list.

  • Allow only echo from everything
  • Allow only get_me from github

When multiple backends are multiplexed, tool names include their backend names. Here, echo became everything_echo, and GitHub's get_me became github_get_me.

MCP Inspector returned this tools/list result.

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "everything_echo",
        "title": "Echo Tool",
        "description": "Echoes back the input string"
      },
      {
        "name": "github_get_me",
        "description": "Get details of the authenticated GitHub user."
      }
    ]
  }
}

For readability, this omits fields present in the actual response, including inputSchema, annotations, and icon information.

MCP Inspector displays only the allowed everything_echo and github_get_me tools.

MCP Inspector showing everything_echo and github_get_me

Rejecting a tools/call request

Next, reject a request when the arguments for everything_echo contain forbidden.

if is_everything_echo(name)
    && params.get("arguments").is_some_and(contains_forbidden)
{
    return Ok(Response::new(deny("echo arguments contain forbidden")));
}

With multiple backends, agentgateway calls the tool using everything_echo, which merges the everything backend name with the echo tool name.

The first implementation checked only echo. As a result, everything_echo sent from MCP Inspector was not checked, and requests containing forbidden were allowed through.

The corrected implementation treats both names as echo.

fn is_everything_echo(name: Option<&str>) -> bool {
    matches!(name, Some("echo") | Some("everything_echo"))
}

The request used for the test was:

{
  "name": "everything_echo",
  "arguments": {
    "message": "forbidden value"
  },
  "_meta": {
    "progressToken": 5
  }
}

After the change, MCP Inspector returned:

{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "echo arguments contain forbidden"
      }
    ],
    "isError": true
  }
}

MCP Inspector shows echo arguments contain forbidden as a tool error.

MCP Inspector showing a rejected everything_echo call containing forbidden value

Rather than returning a top-level JSON-RPC error, the rejected tools/call returns isError: true as an MCP tool result. The MCP client can therefore receive the tool call response while recognizing both the failure and its reason.

Mutating a tools/call response

For a normal everything_echo call, CheckResponse adds checked by ExtMCP to the result text.

{
  "name": "everything_echo",
  "arguments": {
    "message": "Hello"
  },
  "_meta": {
    "progressToken": 7
  }
}

The response becomes:

{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Echo: Hello checked by ExtMCP"
      }
    ]
  }
}

ExtMCP receives Echo: Hello from the backend and changes the text immediately before returning it to the client.

Appending a string is a simple example. The same mechanism can be used to:

  • Mask strings that appear to contain sensitive information
  • Add usage notices to tool results
  • Normalize output for a client
  • Remove information that should not be exposed

Enabling both request and response

There is one configuration detail to note. Repeating a key in methods does not enable both request and response.

methods:
  tools/call: request
  tools/call: response

Because YAML maps have duplicate keys, only one value effectively applies. This can leave response mutation working while request rejection does not.

Use full to enable both phases.

methods:
  tools/call: full
  tools/list: full

Alternatively, define separate processors for the request and response paths.

Summary

This experiment used ExtMCP to control MCP requests and responses.

TargetPhaseControl implemented
tools/listrequestReject backends other than everything and github
tools/listresponseExpose only everything_echo and github_get_me
tools/callrequestReject everything_echo when its arguments contain forbidden
tools/callresponseAppend checked by ExtMCP to text results

MCP guardrails can restrict the tools exposed when several MCP backends are combined, not merely reject particular tool calls.

agentgateway tool names can include a backend prefix such as everything_echo. When matching requests in ExtMCP, inspect the tool names that actually arrive, for example with MCP Inspector.

Appendix: Full ExtMCP server code

The complete main.rs used for this ExtMCP server is below.

use std::collections::HashSet;
 
use protos::ext_mcp::authorization_error::Code;
use protos::ext_mcp::ext_mcp_server::{ExtMcp, ExtMcpServer};
use protos::ext_mcp::{
    AuthorizationError, McpRequest, McpRequestResult, McpResponse, McpResponseResult, Pass,
    mcp_request_result, mcp_response_result,
};
use tonic::{Request, Response, Status, transport::Server};
 
const EVERYTHING: &str = "everything";
const GITHUB: &str = "github";
const ECHO: &str = "echo";
const GET_ME: &str = "get_me";
const CHECKED_BY_EXTMCP: &str = " checked by ExtMCP";
 
#[derive(Default)]
struct Guardrails;
 
#[tonic::async_trait]
impl ExtMcp for Guardrails {
    async fn check_request(
        &self,
        request: Request<McpRequest>,
    ) -> Result<Response<McpRequestResult>, Status> {
        let request = request.into_inner();
        eprintln!(
            "request: method={}, backends={:?}",
            request.method, request.service_names
        );
 
        match request.method.as_str() {
            "tools/list" => {
                if let Some(server) = request
                    .service_names
                    .iter()
                    .find(|server| !matches!(server.as_str(), EVERYTHING | GITHUB))
                {
                    return Ok(Response::new(deny(format!(
                        "tools/list is not allowed for server {server}"
                    ))));
                }
                Ok(Response::new(pass_request()))
            }
            "tools/call" => {
                let params = parse_json(&request.mcp_request, "tools/call params")?;
                let name = params.get("name").and_then(serde_json::Value::as_str);
 
                if is_everything_echo(name)
                    && params.get("arguments").is_some_and(contains_forbidden)
                {
                    return Ok(Response::new(deny("echo arguments contain forbidden")));
                }
                Ok(Response::new(pass_request()))
            }
            _ => Ok(Response::new(pass_request())),
        }
    }
 
    async fn check_response(
        &self,
        request: Request<McpResponse>,
    ) -> Result<Response<McpResponseResult>, Status> {
        let request = request.into_inner();
        eprintln!(
            "response: method={}, backends={:?}",
            request.method, request.service_names
        );
 
        match request.method.as_str() {
            "tools/list" => {
                let mut result = parse_response_json(&request.mcp_response, "tools/list result")?;
                filter_tools(&mut result, &request.service_names);
                mutated_response(result)
            }
            "tools/call" => {
                let mut result = parse_response_json(&request.mcp_response, "tools/call result")?;
                append_check_marker(&mut result);
                mutated_response(result)
            }
            _ => Ok(Response::new(pass_response())),
        }
    }
}
 
fn parse_json(body: &Option<bytes::Bytes>, description: &str) -> Result<serde_json::Value, Status> {
    serde_json::from_slice(body.as_deref().unwrap_or_default())
        .map_err(|error| Status::invalid_argument(format!("invalid {description}: {error}")))
}
 
fn parse_response_json(body: &[u8], description: &str) -> Result<serde_json::Value, Status> {
    serde_json::from_slice(body)
        .map_err(|error| Status::invalid_argument(format!("invalid {description}: {error}")))
}
 
fn contains_forbidden(value: &serde_json::Value) -> bool {
    match value {
        serde_json::Value::String(value) => value.contains("forbidden"),
        serde_json::Value::Array(values) => values.iter().any(contains_forbidden),
        serde_json::Value::Object(values) => values.values().any(contains_forbidden),
        _ => false,
    }
}
 
fn is_everything_echo(name: Option<&str>) -> bool {
    matches!(name, Some(ECHO) | Some("everything_echo"))
}
 
fn filter_tools(result: &mut serde_json::Value, service_names: &[String]) {
    let expected_names: HashSet<String> = service_names
        .iter()
        .filter_map(|server| match server.as_str() {
            EVERYTHING => Some(tool_name(service_names, EVERYTHING, ECHO)),
            GITHUB => Some(tool_name(service_names, GITHUB, GET_ME)),
            _ => None,
        })
        .collect();
 
    if let Some(tools) = result
        .get_mut("tools")
        .and_then(serde_json::Value::as_array_mut)
    {
        tools.retain(|tool| {
            tool.get("name")
                .and_then(serde_json::Value::as_str)
                .is_some_and(|name| expected_names.contains(name))
        });
    }
}
 
fn tool_name(service_names: &[String], server: &str, tool: &str) -> String {
    if service_names.len() == 1 {
        tool.to_owned()
    } else {
        format!("{server}_{tool}")
    }
}
 
fn append_check_marker(result: &mut serde_json::Value) {
    if let Some(content) = result
        .get_mut("content")
        .and_then(serde_json::Value::as_array_mut)
    {
        for item in content {
            if item.get("type").and_then(serde_json::Value::as_str) == Some("text") {
                if let Some(serde_json::Value::String(text)) = item.get_mut("text") {
                    text.push_str(CHECKED_BY_EXTMCP);
                }
            }
        }
    }
}
 
fn mutated_response(result: serde_json::Value) -> Result<Response<McpResponseResult>, Status> {
    let body = serde_json::to_vec(&result)
        .map_err(|error| Status::internal(format!("cannot encode mutated result: {error}")))?;
    Ok(Response::new(McpResponseResult {
        result: Some(mcp_response_result::Result::Mutated(body.into())),
    }))
}
 
fn deny(reason: impl Into<String>) -> McpRequestResult {
    McpRequestResult {
        result: Some(mcp_request_result::Result::Error(AuthorizationError {
            code: Code::PermissionDenied as i32,
            reason: reason.into(),
            mcp_error: None,
        })),
        header_mutation: None,
        metadata: None,
    }
}
 
fn pass_request() -> McpRequestResult {
    McpRequestResult {
        result: Some(mcp_request_result::Result::Pass(Pass {})),
        header_mutation: None,
        metadata: None,
    }
}
 
fn pass_response() -> McpResponseResult {
    McpResponseResult {
        result: Some(mcp_response_result::Result::Pass(Pass {})),
    }
}
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let address = std::env::var("EXTMCP_ADDR")
        .unwrap_or_else(|_| "127.0.0.1:9001".into())
        .parse()?;
    eprintln!("ExtMCP server listening on {address}");
    Server::builder()
        .add_service(ExtMcpServer::new(Guardrails))
        .serve(address)
        .await?;
    Ok(())
}

Related notes

Read next