1. はじめに
この記事では、Microsoft.Extensions.AI が提供する抽象化レイヤー(IChatClient など)を活用して、Model Context Protocol(MCP)サーバーと連携するChatクライアントの実装を試してみたいと思います。
2. Microsoft.Extensions.AIとは
Microsoft.Extensions.AIは、.NETアプリケーションでAIモデルを簡単に利用できるようにするためのライブラリです。これにより、OpenAIやAzure OpenAIなどのAIサービスとシームレスに統合できます。
3. サンプルコード
今回、下記のドキュメントに記載されているサンプルコードをベースに、MCPサーバーのツール呼び出しに対応したChatClientを実装を試しました。
本記事のために作成したソースコードはGitHubで公開しています。こちらも併せてご確認ください。
MCPサーバー
ChatClientと連携するMCPサーバーは、上記MPCサーバーのドキュメントに記載されている、最小限のMCPサーバーのテンプレートをそのまま利用しています。
実装されているツールは、下記の1つだけです。
internal class RandomNumberTools
{
[McpServerTool]
[Description("指定した最小値以上、最大値未満のランダムな数値を生成します。")]
public int GetRandomNumber(
[Description("最小値(含む)")] int min = 0,
[Description("最大値(未満)")] int max = 100)
{
return Random.Shared.Next(min, max);
}
}
Chat Client
Chat Clientは上記MCPクライアントのドキュメントに記載されているサンプルコードをベースに実装しています。
Microsoft.Extensions.AIのIChatClientインターフェースを利用します。
- サンプルでは接続先LLMとしてAzure OpenAIを利用していますが、今回はGitHub Modelsを利用するよう変更します
- MCPサーバーの起動や、ツールの呼び出し処理はサンプルコードをほぼそのまま利用します
4. プログラムの実装
4.1 GitHub Modelsに接続してChatClientを構築する
GitHub Modelsから接続情報を取得
パーソナルアクセストークンの取得
GitHub ModelsのLLMに接続するには、GitHubのパーソナルアクセストークンが必要です。個人用アクセス トークンを管理する: GitHub Docsを参考に、パーソナルアクセストークンを作成します。
付与する権限は「Models」の「read-only」のみでOKです。
モデル選択とエンドポイントの確認
- https://github.com/marketplace/models へ移動します
- 左上の「Model: Select a Model」ドロップダウンから、使いたいモデルを選択します。今回は"OpenAI GPT-4.1 mini" を選びました
- 右上の「Use this model」ボタンをクリックすると表示される、サンプルコードに記載されているendpointとmodelを控えておきます
- endpoint = "https://models.github.ai/inference";
- model = "openai/gpt-4.1-mini";
IChatClientの実装
MCPクライアントのサンプルコードでは、Azure OpenAIを利用するコードになっています。これをGitHub Modelsに接続するコードに変更します。
Nugetパッケージのインストール
GitHub Modelsで選択した openai/gpt-4.1-mini は、OpenAIのモデルのため、OpenAIクライアント用のパッケージをインストールします。
(記事執筆時点ではPreview版のため --preleaseをつけてインストールしています)
> dotnet add package Microsoft.Extensions.AI.OpenAI --prelease
IChatClientの実装をGitHub Models(OpenAI)用に変更する
MCPクライアントのサンプルコードでは、Azure OpenAIを利用するコードになっています。これをGitHub Modelsに接続するコードへ変更します。
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;
//中略...
// Create an IChatClient using Azure OpenAI.
IChatClient client =
new ChatClientBuilder(
new AzureOpenAIClient(
new Uri("<your-azure-openai-endpoint>"),
new DefaultAzureCredential())
.GetChatClient("gpt-4o")
.AsIChatClient())
.UseFunctionInvocation()
.Build();
Microsoft.Extensions.AIでは、ChatClientの各機能を抽象化したIChatClientインターフェースを提供しています。
Chat Clientを生成する手順としては下記の様になります。これにより、LLMの種類やベンダーに依存しないコードを書くことができます。
- 各ベンダー・LLM用のクライアントを生成する
- 拡張メソッド、AsIChatClient()を利用してIChatClientに変換する
- ChatClientBuilderを利用してIChatClientを生成する
- MCPサーバーのツール呼び出しに対応するため、ChatClientBuilderの拡張メソッド、UseFunctionInvocation()を追加
GitHub Models様に変更したコードは下記の様になります。APIキーの取得方法等が異なるだけで、ほとんど同じようなコードで実装できることがわかります。
- APIキーは環境変数"GH_MODELS_TOKEN"から取得するようにしています
- モデル名、エンドポイントはGitHub Modelsのサイトから控えておいたものを利用しています
using Microsoft.Extensions.AI;
//中略...
var model = "openai/gpt-4.1-mini";
var apiKey = Environment.GetEnvironmentVariable("GH_MODELS_TOKEN", EnvironmentVariableTarget.User)
?? throw new InvalidOperationException("Please set the GH_MODELS_TOKEN environment variable.");
var key = new ApiKeyCredential(apiKey);
var options = new OpenAI.OpenAIClientOptions { Endpoint = new Uri("https://models.github.ai/inference") };
return new ChatClientBuilder(
new OpenAI.OpenAIClient(key, options)
.GetChatClient(model)
.AsIChatClient()) //IChatClientに変換
.UseFunctionInvocation() //ツール呼び出しに対応
.Build();
4.2 IMcpClientを利用してMCPサーバーに接続する
こちらは、MCPクライアントのサンプルコードほぼそのままですが、サンプルではMCPサーバーのプロジェクトをdotnet runコマンドでビルド&起動する設定となっていましたが
今回、MCPサーバーのプロジェクトを事前にビルドしておき、dotnetコマンドで直接DLLを実行するように変更しています。
// MCPサーバーに接続するMCPクライアントを生成
IMcpClient mcpClient = await McpClientFactory.CreateAsync(
new StdioClientTransport(new()
{
Command = "dotnet",
Arguments = [".\\mcp-server\\MyMcpServer.dll"], // 事前にビルドしたMCPサーバーのDLLを指定
Name = "Minimal MCP Server",
}));
// MCPサーバーに登録されているツールの一覧を取得
var tools = await mcpClient.ListToolsAsync();
4.3 Chatセッションの実装
こちらの処理もMPCクライアントのサンプルコードそのままです。
これだけの記述で、ツールの呼び出しに対応したChatセッションが実装できるのは非常に便利です。
//メッセージ履歴保存用
var messages = new List<ChatMessage>();
while (true)
{
//ユーザーからの入力を受け取る
Console.Write("Prompt: ");
//メッセージ履歴にユーザーからの入力を追加
messages.Add(new(ChatRole.User, input));
//Streamingメッセージ取得用バッファ
List<ChatResponseUpdate> updates = new();
try
{
//ChatClientからStreamingで応答を取得
await foreach (var update in _chatClient.GetStreamingResponseAsync(
messages, //これまでのメッセージ履歴を渡す
new() { Tools = [.. _tools] })) //MCPサーバーのツールを渡す
{
Console.Write(update); //応答はリアルタイムでコンソールに表示
updates.Add(update);
}
Console.WriteLine();
}
catch (Exception ex)
{
continue;
}
//応答をメッセージ履歴に追加
messages.AddMessages(updates);
}
5. 動作確認
実行してみます。
Prompt: 1から10までのランダムな数値を生成して
13です。他にも数字が欲しければ教えてください。
Prompt: 5から15までのランダムな数値を2つ生成して
7と11です。他にも数字が欲しければ教えてください。
正しい回答が得られていますが、これだけでは本当にMCPサーバーのツールの呼び出し結果が利用されているのか分かりません。ツールの機能が単純すぎるため、LLMが自力で回答してしまっている可能性もあります。
6. ログを取って処理の流れを確認する
IChatClientでは、ILoggerを利用するミドルウェアを利用してChatClient内の処理のログを取得できます。MCPクライアントに対しても同様にログ取得の仕組みがあります。
こちらを設定して、LLMやMPCサーバーとのやり取りがどの様に行われているかを確認してみます。
6.1 ロギングの設定
LoggerFactoryの生成
ILoggerを利用するには、まずILoggerFactoryを生成します。
個別にログレベルを設定できるように、カテゴリ毎のフィルターを追加しています。
-
ModelContextProtocol.Client.*: IMcpClientのログ -
Microsoft.Extensions.AI.LoggingChatClient: IChatClientのログ
今回は詳細な動作を確認したいので、両方ともLogLevel.Traceに設定しています。
var loggerFactory = LoggerFactory.Create(builder =>
{
builder
.AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; })
.AddFilter("ModelContextProtocol.Client.*", LogLevel.Trace)
.AddFilter("Microsoft.Extensions.AI.LoggingChatClient", LogLevel.Trace);
});
IChatClientの生成コードにロギングを追加する
ChatClientBuilderの拡張メソッド、UseLogging()にILoggerFactoryを渡します。
new ChatClientBuilder(
new OpenAI.OpenAIClient(key, options)
.GetChatClient(model)
.AsIChatClient())
.UseLogging(loggerFactory)
.UseFunctionInvocation()
.Build();
MCPクライアントの生成コードにロギングを追加する
McpClientFactoryのCreateAsync()メソッドにILoggerFactoryを渡します。
IMcpClient mcpClient = await McpClientFactory.CreateAsync(
new StdioClientTransport(new()
{
Command = "dotnet",
Arguments = [".\\mcp-server\\MyMcpServer.dll"],
Name = "Minimal MCP Server",
}, loggerFactory));
6.2 ログの確認
これで、MCPクライアント、ChatClientの両方でログが取得できるようになりました。実行時にどのようなログが出力されるか確認してみましょう。
ログは読みやすいよう、一部カットや改行などの加工をしています。
ChatClientのログ(1)
GetStreamingResponseAsync invoked: [
{
"role": "user",
"contents": [
{
"$type": "text",
"text": "1から50までの数値を1つください"
}
]
}
].
Options: {}.
Metadata: {
"providerName": "openai",
"providerUri": "https://models.github.ai/inference",
"defaultModelId": "openai/gpt-4.1-mini"
}.
GetStreamingResponseAsync received update: { "contents": [], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [], ... }
GetStreamingResponseAsync received update: {
"role": "assistant",
"contents": [
{
"$type": "usage",
"details": {
"inputTokenCount": 103,
"outputTokenCount": 20,
"totalTokenCount": 123,
"additionalCounts": {
"InputTokenDetails.AudioTokenCount": 0,
"InputTokenDetails.CachedTokenCount": 0,
"OutputTokenDetails.ReasoningTokenCount": 0,
"OutputTokenDetails.AudioTokenCount": 0,
"OutputTokenDetails.AcceptedPredictionTokenCount": 0,
"OutputTokenDetails.RejectedPredictionTokenCount": 0
}
}
}
],
"responseId": "chatcmpl-CEWIme6mBj4HFh88ww1VXqZD2zocM",
"messageId": "chatcmpl-CEWIme6mBj4HFh88ww1VXqZD2zocM",
"createdAt": "2025-09-11T07:46:36+00:00",
"finishReason": "tool_calls",
"modelId": ""
}
GetStreamingResponseAsync received update: {
"role": "assistant",
"contents": [
{
"$type": "functionCall",
"callId": "call_5AAH3DYvWbWupdcEZlReO6ou",
"name": "get_random_number",
"arguments": {
"min": 1,
"max": 51
}
}
],
"responseId": "",
"messageId": "",
"createdAt": "1970-01-01T00:00:00+00:00",
"finishReason": "tool_calls",
"modelId": ""
}
GetStreamingResponseAsync completed.
MCPクライアントのログ(1)
Minimal MCP Server received stderr log: 'info: ModelContextProtocol.Server.McpServer[570385771]'.
Minimal MCP Server received stderr log: ' Server (MyMcpServer 1.0.0.0), Client (MyMcpHost 1.0.0.0) method 'tools/call' request handler called.'.
Minimal MCP Server transport received message. Message: '{"result":{"content":[{"type":"text","text":"23"}]},"id":3,"jsonrpc":"2.0"}'.
Minimal MCP Server received stderr log: 'info: ModelContextProtocol.Server.McpServer[1867955179]'.
Minimal MCP Server received stderr log: ' Server (MyMcpServer 1.0.0.0), Client (MyMcpHost 1.0.0.0) method 'tools/call' request handler completed.'.
Minimal MCP Server transport received message with ID '3'.
ChatClientのログ(2)
GetStreamingResponseAsync invoked: [
{
"role": "user",
"contents": [
{
"$type": "text",
"text": "1から50までの数値を1つください"
}
]
},
{
"createdAt": "1970-01-01T00:00:00+00:00",
"role": "assistant",
"contents": [
{
"$type": "functionCall",
"callId": "call_5AAH3DYvWbWupdcEZlReO6ou",
"name": "get_random_number",
"arguments": {
"min": 1,
"max": 51
}
}
],
"messageId": "chatcmpl-CEWIme6mBj4HFh88ww1VXqZD2zocM"
},
{
"role": "tool",
"contents": [
{
"$type": "functionResult",
"callId": "call_5AAH3DYvWbWupdcEZlReO6ou",
"result": {
"content": [
{
"type": "text",
"text": "23"
}
]
}
}
]
}
].
Options: {}.
Metadata: { "providerName": "openai", "providerUri": "https://models.github.ai/inference", "defaultModelId": "openai/gpt-4.1-mini" }.
GetStreamingResponseAsync received update: { "contents": [], "responseId": "", "messageId": "", "createdAt": "1970-01-01T00:00:00+00:00", "modelId": "" }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "1" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "から" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "50" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "まで" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "の" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "数" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "値" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "は" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "23" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "です" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [ { "$type": "text", "text": "。" } ], ... }
GetStreamingResponseAsync received update: { "role": "assistant", "contents": [],
"responseId": "chatcmpl-CEWInSmHzu1wyBn3zeqljicSrFmSe",
"messageId": "chatcmpl-CEWInSmHzu1wyBn3zeqljicSrFmSe",
"createdAt": "2025-09-11T07:46:37+00:00",
"finishReason": "stop",
"modelId": "" }
GetStreamingResponseAsync received update: {
"role": "assistant",
"contents": [
{
"$type": "usage",
"details": {
"inputTokenCount": 158,
"outputTokenCount": 13,
"totalTokenCount": 171,
"additionalCounts": {
"InputTokenDetails.AudioTokenCount": 0,
"InputTokenDetails.CachedTokenCount": 0,
"OutputTokenDetails.ReasoningTokenCount": 0,
"OutputTokenDetails.AudioTokenCount": 0,
"OutputTokenDetails.AcceptedPredictionTokenCount": 0,
"OutputTokenDetails.RejectedPredictionTokenCount": 0
}
}
}
],
"responseId": "chatcmpl-CEWInSmHzu1wyBn3zeqljicSrFmSe",
"messageId": "chatcmpl-CEWInSmHzu1wyBn3zeqljicSrFmSe",
"createdAt": "2025-09-11T07:46:37+00:00",
"finishReason": "stop",
"modelId": ""
}
シーケンス図
このログを元にシーケンス図を作成してみます。
- HostApp(ホストアプリ)がChatClientに対してプロンプトを送信します。
- ChatClientはLLM(大規模言語モデル)にユーザーの入力を渡します。
- LLMはツール呼び出し(functionCall)として「get_random_number(min: 1, max: 51)」を提案します。
- ChatClientはMcpClientを通じてMcpServerにツール呼び出しリクエストを送信します。
- McpServerはランダムな数値(例: 23)を生成し、McpClient経由でChatClientに返します。
- ChatClientはツール呼び出しの結果をLLMに返却します。
- LLMは最終的な応答文(「1から50までの数値は23です。」など)を分割してChatClientに返します。
- ChatClientは分割された応答を順次HostAppに返却します。
この流れにより、LLMが自力で数値を生成するのではなく、MCPサーバーのツール呼び出し結果を利用して応答していることが分かります。
おわりに
Microsoft.Extensions.AI を用いることで、ベンダー固有 SDK 依存のコード量を最小化しつつ、ミドルウェアパイプライン(ログ出力 / FunctionInvocation など)を差し替え可能な形で構築できることが確認できました。
加えて MCP 最小サーバーを組み合わせることで、LLM の応答が外部ツール(今回は乱数生成)に基づいていることをログからも確認できました。
ツールの呼び出し対応した ChatClient の実装は非常にシンプルで、Microsoft.Extensions.AI の抽象化レイヤーを活用することで、LLM ベンダーの切り替えも容易に行える点が大きな利点と感じました。