2026年9月30日
MCPAgent Gateway

Notebook

agentgatewayのMCP guardrailsをExtMCPで試す

agentgatewayとMCP Inspectorを使い、ExtMCPのCheckRequest・CheckResponseでtools/listとtools/callを検査・書き換える方法についてまとめます

MCPagentgatewayRustMCP Inspector
English

Guide

目次

  1. 今回の構成
  2. ExtMCP の CheckRequest と CheckResponse
  3. agentgateway の設定
  4. ExtMCP に渡される値を確認する
  5. tools/list は複数 backend を対象にする
  6. tools/call は対象 backend と params を受け取る
  7. CheckResponse は backend の result を受け取る
  8. guardrails の実装と検証
  9. tools/list のリクエストを検査する
  10. tools/list のレスポンスを絞り込む
  11. tools/call のリクエストを拒否する
  12. tools/call のレスポンスを書き換える
  13. request と response の両方を設定する
  14. まとめ
  15. 付録:今回使用した ExtMCP サーバーの全コード

社内で複数のMCPサーバーに接続する必要がある場合に、事前に確認されたMCPサーバのみへの接続を許可したいケースや、許可されたMCPサーバの中でも一部のツールのみを許可したいケース、ツール実行前の入力検査が必要になることがあります。
これを実現するのには agentgateway が利用できます。

今回は agentgateway の MCP guardrails を使って次の制御を試しました。

  • tools/list で許可するバックエンドとツールを制限する
  • tools/call の echo に forbidden が含まれていたら拒否する
  • 正常な tools/call のレスポンス末尾へ checked by ExtMCP を追加する

ExtMCP サーバーは Rust で実装し、MCP Inspector から http://localhost:3000/mcp へ接続して動作を確認しています。

この記事で使った agentgateway は v1.6.0-alpha.2 です。

MCP guardrails の概要と対応範囲は、agentgateway 公式ドキュメントを参照してください。

今回の構成

今回の通信経路は次の通りです。

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

agentgateway は MCP リクエストを受け取ると、設定に応じて ExtMCP の CheckRequest を呼び出します。ExtMCP は許可、拒否、またはリクエストの書き換えを返せます。

バックエンドからレスポンスが返った後は、CheckResponse が呼び出されます。こちらではレスポンスをそのまま通すか、書き換えるか、エラーにするかを決められます。

ExtMCP の CheckRequest と CheckResponse

agentgateway の ExtMCP プロトコルでは、次の2つの RPC が定義されています。

RPC呼び出されるタイミング今回の用途
CheckRequestMCP バックエンドへ転送する前バックエンド名と tools/call の引数を検査する
CheckResponseMCP バックエンドから結果が返った後ツール一覧を絞り込み、ツール実行結果を追記する

CheckRequest は McpRequest を受け取り、Pass、Mutated、AuthorizationError のいずれかを返します。

CheckResponse は McpResponse を受け取り、同様に通過・書き換え・拒否を返せます。

今回の実装では、tools/list と tools/call の両方を対象にしています。

agentgateway の設定

MCP guardrails は full を指定し、request と response の両方で ExtMCP を呼び出すようにしました。

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

failureMode: failClosed は、ExtMCP サーバーへ接続できない場合に処理を通さずエラーにする設定です。入力の検査を guardrails に任せる場合は、意図しない通過を避けるために重要です。

今回のバックエンドは次の2つです。

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 "

github には GitHub Personal Access Token を GITHUB_PAT として設定しています。

ExtMCP に渡される値を確認する

ExtMCP の CheckRequest には、MCP メソッド名、対象 backend 名、MCP の params、agentgateway が転送したリクエストヘッダーが渡されます。

一方で、agentgateway が GitHub MCP backend への通信時に付与する ${GITHUB_PAT} は、ExtMCP には渡りません。ExtMCP が受け取るのは、MCP Inspector から agentgateway へ届いた受信リクエストの情報です。

tools/list は複数 backend を対象にする

tools/list は configured backend 全体に対する呼び出しです。今回のログでは、everything と github が同時に渡されました。

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

tools/list は params を持たないため、params=None になります。

tools/call は対象 backend と params を受け取る

GitHub の get_me を呼び出した場合は、対象 backend が github だけになり、params にツール名と arguments が渡されました。

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\":{}}")

このログには authorization がありません。MCP Inspector から agentgateway へ送られたリクエストに Authorization ヘッダーがなく、GitHub 用の ${GITHUB_PAT} も ExtMCP には転送されていないことを確認できました。

CheckResponse は backend の result を受け取る

CheckResponse には、backend が返した JSON-RPC の result が渡されます。GitHub の get_me 呼び出しでは、次のようなログになりました。

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

実際の結果には GitHub のプロフィール情報が含まれていましたが詳細は割愛しています。

guardrails の実装と検証

ここからは、MCP guardrails で代表的な制御を実装・確認します。tools/list では公開対象を絞り込み、tools/call では入力の拒否とレスポンスの書き換えを試しました。

tools/list のリクエストを検査する

tools/list は複数バックエンドに対する fan-out 呼び出しです。ExtMCP 側には、対象バックエンド名が service_names として渡されます。

今回のルールでは、everything と 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}"
    ))));
}

最初は time バックエンドも設定していました。実際の設定は次の通りです。

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 "

この状態で MCP Inspector から tools/list を実行すると、次のエラーになりました。

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

これは想定どおりの結果です。tools/list は time、everything、github のすべてを対象にし、ExtMCP はその一覧を受け取ります。今回の allowlist に time が含まれていないため、リクエスト全体を拒否しました。

time を削除し、許可対象を everything と github だけにすると、tools/list を続行できます。

tools/list のレスポンスを絞り込む

CheckResponse では、返却されるツール一覧を絞り込みました。

  • everything は echo だけを許可する
  • github は get_me だけを許可する

複数バックエンドを multiplex する場合、ツール名はバックエンド名付きになります。今回の結果では、echo は everything_echo、GitHub の get_me は github_get_me になりました。

MCP Inspector で確認できた tools/list の結果は次の通りです。

{
  "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."
      }
    ]
  }
}

読みやすさのため、実際のレスポンスに含まれる inputSchema、annotations、アイコン情報などを省略しています。

MCP Inspector では、許可した everything_echo と github_get_me だけがツール一覧に表示されます。

MCP Inspector に表示された everything_echo と github_get_me

tools/call のリクエストを拒否する

次に、everything_echo の引数内に forbidden が含まれる場合は拒否するようにしました。

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

ここで注意したいのは、複数のバックエンドを設定している場合、ツール名の echo と、バックエンド名の everything がマージされた everything_echo でツールが呼び出されることです。

最初の実装では echo だけを判定していました。そのため、MCP Inspector から渡された everything_echo は検査対象から漏れ、forbidden を含むリクエストが通過していました。

修正後は、両方の名前を echo として扱います。

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

実行したリクエストは次の通りです。

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

修正後、MCP Inspector では次の結果になりました。

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

MCP Inspector では、ツール実行エラーとして echo arguments contain forbidden が表示されます。

forbidden value を含む everything_echo の実行が拒否された MCP Inspector の画面

tools/call の拒否は JSON-RPC のトップレベル error ではなく、MCP のツール実行結果として isError: true で返すようにしました。

このため、MCP クライアントはツール呼び出し自体は受け取りつつ、ツール実行が失敗したことと、失敗理由を認識できます。

tools/call のレスポンスを書き換える

通常の everything_echo 呼び出しでは、CheckResponse が結果のテキストへ checked by ExtMCP を追加します。

リクエストは次の通りです。

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

レスポンスは次のようになりました。

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

ExtMCP はバックエンドの Echo: Hello という結果を受け取り、クライアントへ返す直前にテキストを変更しています。

今回のような文字列追加は単純な例ですが、実際には次のような用途に応用できます。

  • 機密情報らしい文字列をマスキングする
  • ツール結果に利用上の注意を追加する
  • 出力形式をクライアント向けに正規化する
  • 許可していない情報をレスポンスから取り除く

request と response の両方を設定する

今回の確認で、設定上の注意点もありました。

次のように methods の同じキーを重複して書くと、request と response の両方を有効にはできません。

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

YAML のマップではキーが重複しているため、実質的に片方だけが有効になります。その場合、レスポンス書き換えだけが動き、リクエスト拒否が動かない、といった分かりにくい状態になります。

今回は次のように full を使い、両フェーズを有効にしました。

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

request と response で別々の processor を定義する方法でも実現可能です。

まとめ

今回の検証では、ExtMCP を使って MCP のリクエストとレスポンスを制御しました。

対象フェーズ実装した制御
tools/listrequesteverything と github 以外のバックエンドを拒否
tools/listresponseeverything_echo と github_get_me だけを公開
tools/callrequesteverything_echo の引数に forbidden があれば拒否
tools/callresponseテキスト結果の末尾に checked by ExtMCP を追加

MCP guardrails では、単に特定ツールを拒否するだけでなく、複数の MCP バックエンドを束ねたときに公開するツールを制限できます。

また、agentgatewayで返却されるツール名には everything_echo のようなバックエンド名のプレフィックスが付きます。ExtMCP 側でリクエストを判定する場合は、実際に届くツール名を MCP Inspector などで確認することが重要です。

付録:今回使用した ExtMCP サーバーの全コード

今回の ExtMCP サーバで利用した main.rs の全コードは次の通りです。

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

あわせて読みたいノート