はじめに
Blazor + Microsoft.Extensions.AI(MEAI)で作っている社内向けAI Agentアプリで、Bedrock AgentCore Gateway経由でWeb Searchを呼び出す機能を追加しました。この記事では、その実装のポイントをまとめます。
AgentCore Gatewayの概要
Amazon Bedrock AgentCore Gatewayは、エージェント・ツール・LLMをつなぐフルマネージドのAIゲートウェイです。API・Lambda関数・既存のMCPサーバー・他のエージェントなどを「ターゲット」として登録すると、それらをまとめて単一のMCPエンドポイントとして公開してくれます。
自前でMCPサーバーを実装しなくても、AWSが用意している「コネクタ」型のターゲットを追加するだけで使えるツールもあり、Web Searchはその代表例です。認証方式はOAuth(クライアントクレデンシャル/認可コード)・IAM(SigV4署名)・APIキーに対応しています。
Web Search用ターゲットの設定方法(概要)
- AgentCore Gatewayを作成する
- ターゲットプロトコルは「MCP target」を選び、ターゲット名に
web-searchなどの任意の名前をつける - ターゲットタイプは「Connectors」タイプを選び、
Pre-configured targetsからツールとしてWeb Search toolを選ぶ - Gateway呼び出し元(今回で言えばアプリのIAM実行ロール)に
bedrock-agentcore:InvokeGateway権限を付与する - 必要に応じて追加設定の
Excude domainで検索範囲を制限する
ここで注意するのは、AgentCore Gatewayのツール命名規則です。Gateway配下のツールは {ターゲット名}___{ツール名} という形式で公開されます。Web Searchターゲットをweb-searchという名前で追加した場合、実際にtools/callで指定する名前は web-search___WebSearch になります。
また、ゲートウェイリソース URL は後続のMCP呼び出し時に必要になるのでメモしておきましょう。ゲートウェイリソース URL は https://xxxxxx.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp のような形式です。
設定の詳細な手順や制限値(クエリの文字数上限など)は変更される可能性があるため、実際に構築する際は必ずAWS公式ドキュメントの最新情報を確認してください。
WebSearchを直接呼び出してみる
まずは単純に Gateway の Web Search を直接呼び出してみます。
Terminal
│ dotnet run --file websearch.cs -- "検索クエリ"
▼
app.cs (single file)
└─ MCP Client (Streamable HTTP)
└─ AgentCore Gateway
└─ web-search___WebSearch
作業ディレクトリで websearch.cs を作り、次の内容を貼り付けます。
GatewayUrl は、前述の「ゲートウェイリソース URL」を設定してください。
#:package ModelContextProtocol@2.0.0-preview.2
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
const string ToolName = "web-search___WebSearch";
const string GatewayUrl = "https://xxxxxx.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp";
// 1) クエリは引数で受け取る
var input = args.Length > 0 ? string.Join(" ", args) : string.Empty;
if (string.IsNullOrWhiteSpace(input))
{
Console.WriteLine("Usage: dotnet run --file app.cs -- <query>");
return;
}
// 2) Streamable HTTP で Gateway に接続
await using var client = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(GatewayUrl),
TransportMode = HttpTransportMode.StreamableHttp
}));
// 3) Web Search ツールを実行
var result = await client.CallToolAsync(
ToolName,
new Dictionary<string, object?>
{
["query"] = input,
["maxResults"] = 5
});
foreach (var block in result.Content.OfType<TextContentBlock>())
{
if (!string.IsNullOrWhiteSpace(block.Text))
{
Console.WriteLine(block.Text);
}
}
次のコマンドで実行できます。
dotnet run --file websearch.cs -- "最新のAWS Bedrock AgentCore Gatewayの情報を教えて"
次のようなJSONが返却されます(長いので整形して一部省略しています)。
{
"id": "ef5aada3",
"results": [
{
"publishedDate": "unknown",
"text": "Amazon Bedrock AgentCore Gateway: A secure AI gate...",
"title": "Amazon Bedrock AgentCore Gateway: A secure AI gat...",
"url": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html"
},
{
"publishedDate": "04:00PM, Monday, February 23 2026, PST",
"text": "Amazon Bedrock now supports server-side tool exec...",
"title": "Amazon Bedrock now supports server-side tool exe...",
"url": "https://aws.amazon.com/about-aws/whats-new/2026/02/amazon-bedrock-server-side-tool-execution-agentcore-gateway/"
},
{
"publishedDate": "unknown",
"text": "Core concepts for Amazon Bedrock AgentCore Gatewa...",
"title": "Core concepts for Amazon Bedrock AgentCore Gatew...",
"url": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-core-concepts.html"
},
{
"publishedDate": "unknown",
"text": "Connect to your knowledge base through AgentCore ...",
"title": "Connect to your knowledge base through AgentCore...",
"url": "https://docs.aws.amazon.com/bedrock/latest/userguide/kb-gateway-target.html"
},
{
"publishedDate": "06:26PM, Wednesday, July 15 2026, PDT",
"text": "| participant_name | Amazon Bedrock Agents | | pa...",
"title": "aws.amazon.com",
"url": null
}
]
}
Microsoft.Extensions.AI から呼び出す
次にMEAIへ組み込みます。長くなるので、ここでは重要な部分のみを抜粋します。
プログラムの完全なサンプルは文末の「付録: Microsoft.Extensions.AI版フルサンプル」を参照してください。
注: 本文の抜粋ではシンプルさを優先し
WebSearchTool1クラスに処理をまとめています。付録のフルサンプルでは責務を分割し、ツール定義(ToolAIFunction)・Bedrock接続(BedrockChatClient)・MCP接続(AgentCoreWebSearchClient)の3クラス構成にしています。
1) ツール本体を Microsoft.Extensions.AI 用に定義する
「MEAIから見えるツール」を1つ登録します。
最初の例では client.CallToolAsync(...) を直接呼んでいましたが、MEAI経由にする場合は、LLMが「どんなツールを、どんな引数で呼べるのか」を事前に知る必要があります。
そこで AIFunction を継承したクラスを1つ作り、次の4点を定義します。
-
Name: LLM から見えるツール名です。今回はWebSearchとしています -
Description: そのツールをいつ使うかを LLM に伝える説明です -
JsonSchema: ツール呼び出し時に必要な引数の定義です。今回は必須のqueryのみです -
InvokeCoreAsync: 実際にツールが呼ばれたときの処理本体です。最初の例と同じCallToolAsyncをここで実行します
public sealed class WebSearchTool(string gatewayUrl) : AIFunction
{
public override string Name => "WebSearch";
public override string Description => "キーワードで情報を検索します。";
// ツール引数の定義(今回は query のみ)
public override JsonElement JsonSchema { get; } = JsonDocument.Parse(
"""
{ "type": "object", "properties": { "query": { "type": "string" } }, "required": ["query"] }
""").RootElement.Clone();
// LLM がこのツールを選んだときに呼ばれる本体
protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments, CancellationToken cancellationToken)
{
var query = arguments.TryGetValue("query", out var q) ? q?.ToString() : null;
if (string.IsNullOrWhiteSpace(query)) return "query が必要です。";
// Streamable HTTP で Gateway に接続してツールを実行
await using var client = await McpClient.CreateAsync(
new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(gatewayUrl),
TransportMode = HttpTransportMode.StreamableHttp
}),
cancellationToken: cancellationToken);
var result = await client.CallToolAsync(
"web-search___WebSearch",
new Dictionary<string, object?> { ["query"] = query, ["maxResults"] = 5 },
cancellationToken: cancellationToken);
// テキスト結果を連結して LLM に返す
return string.Join("\n", result.Content
.OfType<ModelContextProtocol.Protocol.TextContentBlock>()
.Select(x => x.Text));
}
}
2) ChatOptions.Tools に登録する
次に「この会話で LLM が使ってよいツールの一覧」を ChatOptions に設定し、チャット呼び出し時に渡します。
作成したツールは、ChatOptions.Tools に登録して初めて、「この会話では Web Search ツールを呼んでよい」とLLM側に伝わります。
今回はシンプルに、先ほど作った WebSearchTool を1つだけ登録しています。ツールが複数ある場合は、このリストに並べて追加します。
複数のツールがある場合、LLMがどのツールを呼ぶか判断するために、ツール名や説明文が重要になります。
var chatOptions = new ChatOptions { Tools = [new WebSearchTool(GatewayUrl)] };
var response = await chatClient.GetResponseAsync(
[new ChatMessage(ChatRole.User, userInput)], chatOptions);
3) ストリーミング中の進捗表示
GetResponseAsync は回答が完成してから一括で返すため、応答に数秒かかるとその間は何も表示されません。GetStreamingResponseAsync に切り替えると、LLMが生成したテキストをチャンク単位で受け取り、逐次表示できます。
await foreach (var update in chatClient.GetStreamingResponseAsync(
[new ChatMessage(ChatRole.User, userInput)], chatOptions))
{
Console.Write(update.Text);
}
Console.WriteLine();
ただし、LLMがツールを呼び出すと決めた瞬間にテキスト出力が一時停止します。Web Searchの結果が返るまでの間、ユーザーからは画面が止まって見えます。
もっとも手軽な対処は、InvokeCoreAsync の先頭と末尾に進捗メッセージを書くことです。
protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments, CancellationToken cancellationToken)
{
var query = arguments.TryGetValue("query", out var q) ? q?.ToString() : null;
if (string.IsNullOrWhiteSpace(query)) return "query が必要です。";
Console.WriteLine($"\n[Web Search: {query}]"); // 開始を通知
// ... Gateway 呼び出し ...
Console.WriteLine("[Web Search 完了]\n"); // 完了を通知
return result;
}
上記の修正で「LLMが検索中である」ことが画面に伝わります。より凝った進捗表示(スピナー・フェーズ管理など)が必要な場合は、コールバックインターフェイスを定義してツールから呼び出す形に発展させていくことができます。
付録: Microsoft.Extensions.AI版フルサンプル
以下は app.cs 全体です。このファイル1つで動作します。
本文の抜粋とは異なり、ToolAIFunction(ツール定義)・BedrockChatClient(Bedrock接続)・AgentCoreWebSearchClient(MCP接続)の3クラス構成で責務を分割しています。
Microsoft.Extensions.AI版フルサンプルを開く
#:package AWSSDK.BedrockRuntime@4.0.100.3
#:package AWSSDK.SSO@4.0.100.3
#:package AWSSDK.SSOOIDC@4.0.100.3
#:package Microsoft.Extensions.AI@10.7.0
#:package ModelContextProtocol@2.0.0-preview.2
using System.Text.Json;
using Amazon;
using Amazon.BedrockRuntime;
using Amazon.BedrockRuntime.Model;
using Amazon.Runtime.Documents;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
// ── ① 設定(自分の環境に合わせて書き換える)──
const string AwsProfile = "ct-lab";
const string ModelId = "global.anthropic.claude-sonnet-5";
const string GatewayUrl = "https://xxxxxxxx.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp";
// ── ② クエリはコマンドライン引数で受け取る ──
var userInput = args.Length > 0 ? string.Join(" ", args) : string.Empty;
if (string.IsNullOrWhiteSpace(userInput))
{
Console.WriteLine("Usage: dotnet run --file app.cs -- <query>");
return;
}
// ── ③ Bedrock 用の IChatClient を作成 ──
Environment.SetEnvironmentVariable("AWS_PROFILE", AwsProfile);
using var bedrock = new AmazonBedrockRuntimeClient(RegionEndpoint.USEast1);
using IChatClient chatClient = new BedrockChatClient(bedrock, ModelId);
// ── ④ LLM から呼べる Web Search ツールを定義 ──
var webSearchClient = new AgentCoreWebSearchClient(GatewayUrl);
var webSearchTool = new ToolAIFunction(
name: "WebSearch",
description: "キーワードで情報を検索します。",
jsonSchema: JsonDocument.Parse(
"""
{
"type": "object",
"properties": {
"query": { "type": "string" },
"maxResults": { "type": "integer" }
},
"required": ["query"]
}
""").RootElement.Clone(),
executeFunc: async (parameters, ct) =>
{
var query = parameters.TryGetValue("query", out var q) ? q?.ToString() : null;
if (string.IsNullOrWhiteSpace(query)) return "query が必要です。";
var maxResults = parameters.TryGetValue("maxResults", out var mr)
&& int.TryParse(mr?.ToString(), out var parsed) ? parsed : 5;
Console.WriteLine("[tool] calling web-search___WebSearch ...");
var text = await webSearchClient.SearchAsync(query, maxResults, ct);
Console.WriteLine("[tool] done");
return text;
});
// ── ⑤ ツールを登録してチャットで呼び出し ──
var chatOptions = new ChatOptions { Tools = new List<AITool> { webSearchTool } };
Console.Write("Assistant> ");
await foreach (var update in chatClient.GetStreamingResponseAsync(
new List<ChatMessage> { new(ChatRole.User, userInput) },
chatOptions))
{
Console.Write(update.Text);
}
Console.WriteLine();
// ══ ツール定義用クラス══
public sealed class ToolAIFunction(
string name,
string description,
JsonElement jsonSchema,
Func<IDictionary<string, object?>, CancellationToken, Task<string>> executeFunc) : AIFunction
{
public override string Name => name;
public override string Description => description;
public override JsonElement JsonSchema => jsonSchema;
protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments, CancellationToken cancellationToken)
=> await executeFunc(arguments, cancellationToken);
}
// ══ Bedrock Converse API を IChatClient として使う最小実装 ══
public sealed class BedrockChatClient(IAmazonBedrockRuntime bedrock, string modelId) : IChatClient
{
private const int MaxToolCalls = 5;
public async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> chatMessages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var tools = options?.Tools?.OfType<AIFunction>().ToList() ?? new List<AIFunction>();
var toolConfig = BuildToolConfig(tools);
// 1) 会話メッセージを Bedrock 形式に変換
var messages = chatMessages
.Where(m => !string.IsNullOrEmpty(m.Text))
.Select(m => new Message
{
Role = m.Role == ChatRole.User ? ConversationRole.User : ConversationRole.Assistant,
Content = new List<ContentBlock> { new() { Text = m.Text } }
})
.ToList();
var response = await bedrock.ConverseAsync(
new ConverseRequest { ModelId = modelId, Messages = messages, ToolConfig = toolConfig },
cancellationToken);
// 2) LLM が「ツールを使いたい」と言う間はツールを実行して結果を返す
for (var i = 0; i < MaxToolCalls && response.StopReason == StopReason.Tool_use; i++)
{
messages.Add(response.Output.Message);
var toolResults = new List<ContentBlock>();
foreach (var block in response.Output.Message.Content)
{
if (block.ToolUse is null) continue;
var tool = tools.FirstOrDefault(t => t.Name == block.ToolUse.Name);
var resultText = tool is null
? $"Error: tool '{block.ToolUse.Name}' not found"
: (await tool.InvokeAsync(
new AIFunctionArguments(ToArguments(block.ToolUse.Input)),
cancellationToken))?.ToString() ?? string.Empty;
toolResults.Add(new ContentBlock
{
ToolResult = new ToolResultBlock
{
ToolUseId = block.ToolUse.ToolUseId,
Content = new List<ToolResultContentBlock> { new() { Text = resultText } }
}
});
}
messages.Add(new Message { Role = ConversationRole.User, Content = toolResults });
response = await bedrock.ConverseAsync(
new ConverseRequest { ModelId = modelId, Messages = messages, ToolConfig = toolConfig },
cancellationToken);
}
// 3) 最終回答テキストを取り出す
var text = string.Concat(response.Output.Message.Content
.Where(c => c.Text is not null)
.Select(c => c.Text));
return new ChatResponse(new ChatMessage(ChatRole.Assistant, text));
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> chatMessages,
ChatOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var tools = options?.Tools?.OfType<AIFunction>().ToList() ?? new List<AIFunction>();
var toolConfig = BuildToolConfig(tools);
var messages = chatMessages
.Where(m => !string.IsNullOrEmpty(m.Text))
.Select(m => new Message
{
Role = m.Role == ChatRole.User ? ConversationRole.User : ConversationRole.Assistant,
Content = new List<ContentBlock> { new() { Text = m.Text } }
})
.ToList();
var continueLoop = true;
while (continueLoop)
{
var streamResponse = await bedrock.ConverseStreamAsync(
new ConverseStreamRequest { ModelId = modelId, Messages = messages, ToolConfig = toolConfig },
cancellationToken);
var assistantBlocks = new List<ContentBlock>();
var currentToolUseId = string.Empty;
var currentToolName = string.Empty;
var currentToolInputJson = new System.Text.StringBuilder();
var stopReason = string.Empty;
await foreach (var evt in streamResponse.Stream)
{
if (evt is ContentBlockStartEvent startEvt && startEvt.Start?.ToolUse != null)
{
currentToolUseId = startEvt.Start.ToolUse.ToolUseId;
currentToolName = startEvt.Start.ToolUse.Name;
currentToolInputJson.Clear();
}
else if (evt is ContentBlockDeltaEvent deltaEvt)
{
if (deltaEvt.Delta?.Text != null)
{
// テキストチャンクをその都度 yield — これが本来のストリーミング
yield return new ChatResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [new TextContent(deltaEvt.Delta.Text)]
};
}
else if (deltaEvt.Delta?.ToolUse != null)
{
currentToolInputJson.Append(deltaEvt.Delta.ToolUse.Input);
}
}
else if (evt is ContentBlockStopEvent && !string.IsNullOrEmpty(currentToolUseId))
{
Document inputDoc;
try { inputDoc = ToDocument(JsonDocument.Parse(currentToolInputJson.ToString()).RootElement); }
catch { inputDoc = new Document(new Dictionary<string, Document>()); }
assistantBlocks.Add(new ContentBlock
{
ToolUse = new ToolUseBlock
{
ToolUseId = currentToolUseId,
Name = currentToolName,
Input = inputDoc
}
});
currentToolUseId = string.Empty;
currentToolName = string.Empty;
currentToolInputJson.Clear();
}
else if (evt is MessageStopEvent stopEvt)
{
stopReason = stopEvt.StopReason?.Value ?? string.Empty;
}
}
if (stopReason == "tool_use" && assistantBlocks.Count > 0)
{
// アシスタントのツール使用ブロックを会話に追加
messages.Add(new Message { Role = ConversationRole.Assistant, Content = assistantBlocks });
// ツールを実行して結果を会話に追加
var toolResults = new List<ContentBlock>();
foreach (var block in assistantBlocks)
{
if (block.ToolUse is null) continue;
var tool = tools.FirstOrDefault(t => t.Name == block.ToolUse.Name);
var resultText = tool is null
? $"Error: tool '{block.ToolUse.Name}' not found"
: (await tool.InvokeAsync(
new AIFunctionArguments(ToArguments(block.ToolUse.Input)),
cancellationToken))?.ToString() ?? string.Empty;
toolResults.Add(new ContentBlock
{
ToolResult = new ToolResultBlock
{
ToolUseId = block.ToolUse.ToolUseId,
Content = new List<ToolResultContentBlock> { new() { Text = resultText } }
}
});
}
messages.Add(new Message { Role = ConversationRole.User, Content = toolResults });
// ループを継続してツール結果を踏まえた回答をストリーミング
}
else
{
continueLoop = false;
}
}
}
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose()
{
}
// ツール定義(名前・説明・スキーマ)を Bedrock に渡す形へ変換
private static ToolConfiguration? BuildToolConfig(List<AIFunction> tools) =>
tools.Count == 0 ? null : new ToolConfiguration
{
Tools = tools.Select(t => new Tool
{
ToolSpec = new ToolSpecification
{
Name = t.Name,
Description = t.Description,
InputSchema = new ToolInputSchema { Json = ToDocument(t.JsonSchema) }
}
}).ToList()
};
// JsonElement → Bedrock Document の明示的な変換
private static Document ToDocument(JsonElement element) => element.ValueKind switch
{
JsonValueKind.Object => new Document(element.EnumerateObject()
.ToDictionary(p => p.Name, p => ToDocument(p.Value))),
JsonValueKind.Array => new Document(element.EnumerateArray().Select(ToDocument).ToList()),
JsonValueKind.String => new Document(element.GetString()),
JsonValueKind.Number => new Document(element.GetInt32()),
JsonValueKind.True => new Document(true),
JsonValueKind.False => new Document(false),
_ => new Document()
};
// LLM が指定したツール引数(Document)を辞書に変換
private static Dictionary<string, object?> ToArguments(Document input)
{
var arguments = new Dictionary<string, object?>();
if (!input.IsDictionary()) return arguments;
foreach (var kvp in input.AsDictionary())
{
arguments[kvp.Key] =
kvp.Value.IsString() ? kvp.Value.AsString() :
kvp.Value.IsInt() ? kvp.Value.AsInt() :
kvp.Value.ToString();
}
return arguments;
}
}
// ══ AgentCore Gateway(MCP) の Web Search を呼ぶ薄いクライアント ══
public sealed class AgentCoreWebSearchClient(string gatewayUrl)
{
public async Task<string> SearchAsync(string query, int maxResults, CancellationToken ct = default)
{
// Streamable HTTP で Gateway に接続
await using var client = await McpClient.CreateAsync(
new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(gatewayUrl),
TransportMode = HttpTransportMode.StreamableHttp
}),
cancellationToken: ct);
// Gateway 配下の Web Search ツールを実行
var result = await client.CallToolAsync(
"web-search___WebSearch",
new Dictionary<string, object?> { ["query"] = query, ["maxResults"] = maxResults },
cancellationToken: ct);
// テキスト結果を連結して返す
return string.Join("\n", result.Content
.OfType<ModelContextProtocol.Protocol.TextContentBlock>()
.Select(x => x.Text ?? string.Empty));
}
}
おわりに
これまでAIエージェントで検索エンジンの結果を利用しようとすると、TaviryやBingSearchを利用する必要がありました。
Agentcore Gateway の WebSearch を利用することで個別の契約やら請求やらを考えずに利用できるので、新しくAIエージェントを作成する場合はうれしいかもしれませんね。
とはいえ、AIエージェントのツールのコールバックは理解しづらい部分です。まずは次のように分割し、段階的に理解していくのが良いと思います。
- 「MCP直呼び最小サンプル」で Gateway 接続とツール実行を確認する
- その次にMEAIへ組み込み、
ChatOptions.ToolsでLLMと接続する - 最後に責務分割(Tool/Client/DI)を進めて、実運用向けに育てる
追記
日本語を対象にした時の詳しい評価記事がありました。
実際のサービスに組み込む場合は、情報の鮮度などで課題がありそうですね。

