3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Microsoft.Extensions.AI でMCPサーバーと連携するChatClientの実装を試す

3
Last updated at Posted at 2025-09-19

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の種類やベンダーに依存しないコードを書くことができます。

  1. 各ベンダー・LLM用のクライアントを生成する
  2. 拡張メソッド、AsIChatClient()を利用してIChatClientに変換する
  3. ChatClientBuilderを利用してIChatClientを生成する
  4. 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": ""
    }

シーケンス図

このログを元にシーケンス図を作成してみます。

  1. HostApp(ホストアプリ)がChatClientに対してプロンプトを送信します。
  2. ChatClientはLLM(大規模言語モデル)にユーザーの入力を渡します。
  3. LLMはツール呼び出し(functionCall)として「get_random_number(min: 1, max: 51)」を提案します。
  4. ChatClientはMcpClientを通じてMcpServerにツール呼び出しリクエストを送信します。
  5. McpServerはランダムな数値(例: 23)を生成し、McpClient経由でChatClientに返します。
  6. ChatClientはツール呼び出しの結果をLLMに返却します。
  7. LLMは最終的な応答文(「1から50までの数値は23です。」など)を分割してChatClientに返します。
  8. ChatClientは分割された応答を順次HostAppに返却します。

この流れにより、LLMが自力で数値を生成するのではなく、MCPサーバーのツール呼び出し結果を利用して応答していることが分かります。

おわりに

Microsoft.Extensions.AI を用いることで、ベンダー固有 SDK 依存のコード量を最小化しつつ、ミドルウェアパイプライン(ログ出力 / FunctionInvocation など)を差し替え可能な形で構築できることが確認できました。

加えて MCP 最小サーバーを組み合わせることで、LLM の応答が外部ツール(今回は乱数生成)に基づいていることをログからも確認できました。

ツールの呼び出し対応した ChatClient の実装は非常にシンプルで、Microsoft.Extensions.AI の抽象化レイヤーを活用することで、LLM ベンダーの切り替えも容易に行える点が大きな利点と感じました。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?