Notebook
agentgatewayのMCP guardrailsをExtMCPで試す
agentgatewayとMCP Inspectorを使い、ExtMCPのCheckRequest・CheckResponseでtools/listとtools/callを検査・書き換える方法についてまとめます
Guide
目次
- 今回の構成
- ExtMCP の CheckRequest と CheckResponse
- agentgateway の設定
- ExtMCP に渡される値を確認する
- tools/list は複数 backend を対象にする
- tools/call は対象 backend と params を受け取る
- CheckResponse は backend の result を受け取る
- guardrails の実装と検証
- tools/list のリクエストを検査する
- tools/list のレスポンスを絞り込む
- tools/call のリクエストを拒否する
- tools/call のレスポンスを書き換える
- request と response の両方を設定する
- まとめ
- 付録:今回使用した 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 - githubagentgateway は MCP リクエストを受け取ると、設定に応じて ExtMCP の CheckRequest を呼び出します。ExtMCP は許可、拒否、またはリクエストの書き換えを返せます。
バックエンドからレスポンスが返った後は、CheckResponse が呼び出されます。こちらではレスポンスをそのまま通すか、書き換えるか、エラーにするかを決められます。
ExtMCP の CheckRequest と CheckResponse
agentgateway の ExtMCP プロトコルでは、次の2つの RPC が定義されています。
| RPC | 呼び出されるタイミング | 今回の用途 |
|---|---|---|
CheckRequest | MCP バックエンドへ転送する前 | バックエンド名と tools/call の引数を検査する |
CheckResponse | MCP バックエンドから結果が返った後 | ツール一覧を絞り込み、ツール実行結果を追記する |
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: fullfailureMode: 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=Nonetools/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 だけがツール一覧に表示されます。

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 が表示されます。

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: responseYAML のマップではキーが重複しているため、実質的に片方だけが有効になります。その場合、レスポンス書き換えだけが動き、リクエスト拒否が動かない、といった分かりにくい状態になります。
今回は次のように full を使い、両フェーズを有効にしました。
methods:
tools/call: full
tools/list: fullrequest と response で別々の processor を定義する方法でも実現可能です。
まとめ
今回の検証では、ExtMCP を使って MCP のリクエストとレスポンスを制御しました。
| 対象 | フェーズ | 実装した制御 |
|---|---|---|
tools/list | request | everything と github 以外のバックエンドを拒否 |
tools/list | response | everything_echo と github_get_me だけを公開 |
tools/call | request | everything_echo の引数に forbidden があれば拒否 |
tools/call | response | テキスト結果の末尾に 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
あわせて読みたいノート
ClaudeのWeb検索ツールを実際に呼び出して整理する
web_search / web_search_fast / web_fetch を使い、モバイルバッテリーの捨て方を題材にClaudeがページを探索する流れと、2つの検索ツールの出力の違いを確認します。
続きを読む
ChatGPTのWeb Searchツールを実際に呼び出して整理する
fast / slow / open / find / click を使い、モバイルバッテリーの捨て方を題材にWeb Searchがページを探索する流れを確認します。
続きを読む
Amazon Bedrock Guardrailsで入力の一部だけを検査する方法
Amazon Bedrock Guardrails の適用範囲を入力テキストの一部に絞る方法を、InvokeModel の input tags と Converse の guardContent ブロックで整理します。
続きを読む