🙆

Microsoft Agent Framework をローカルLLMで試してみる その14(MCPクライアント)

に公開

シリーズ一覧

一覧

はじめに

Microsoft Agent FrameworkをローカルLLMで試してみるその14では、外部のMCPサーバーをAgentから呼び出す方法を確認します。

その13ではミドルウェアの動作を確認しました。
その14では、AgentにMCPクライアント機能を追加し、Microsoft Learn Docs MCP Serverのツールを呼ぶ流れを見ていきます。

今回は次の点を確認します。

  • Streamable HTTP の MCP サーバーへ .NET の MCP クライアントで接続する流れ
  • 固定チャット → MCP ツール → ツール実行結果 → 最終レスポンス のシーケンス

環境情報

今回確認した主な環境情報は次のとおりです。

項目 バージョン
Target Framework .NET 10 (net10.0)
Microsoft Agent Framework Microsoft.Agents.AI 1.6.1 / Microsoft.Agents.AI.OpenAI 1.6.1
MCP C# SDK ModelContextProtocol 1.3.0
LM Studio のモデル openai/gpt-oss-20b

確認する動作

下記のシナリオでMCPの動作を確認します。

  • チャットは固定の1回のみ
  • MCP サーバーは Microsoft Learn Docs MCP Server
  • 使用するツールは microsoft_docs_search
  • ツールの呼び出し結果をコンソールで確認
  • 最終レスポンスもコンソールで確認

MCP クライアントの接続について

最初に Microsoft Learn Docs MCP Server へ接続します。(https://learn.microsoft.com/api/mcp)

HttpClientTransport を直接渡し、Streamable HTTP を明示して接続します。

await using var mcpClient = await McpClient.CreateAsync(
    new HttpClientTransport(new HttpClientTransportOptions
    {
        Endpoint = new Uri(settings.McpServerUrl),
        Name = "MicrosoftLearnDocs",
        TransportMode = HttpTransportMode.StreamableHttp
    }));

IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync().ConfigureAwait(false);

McpClientTool selectedTool = mcpTools
    .SingleOrDefault(tool => string.Equals(tool.Name, settings.ToolName, StringComparison.Ordinal))
    ?? throw new InvalidOperationException("対象の MCP ツールが見つかりませんでした。");

今回は接続方式を固定したいので HttpTransportMode.StreamableHttp を明示しています。(既定値は AutoDetect )

McpClient で使える主な機能

McpClientListToolsAsync() だけのための型ではなく、MCP サーバーの公開機能をまとめて扱うクラスです。主なメソッドを用途別にまとめると下記になります。

分類 主な メソッド / プロパティ できること 今回のサンプル
接続と再開 CreateAsync(...), ResumeSessionAsync(...) 新しい接続を開始し、必要なら既存セッションを再利用する CreateAsync(...) を使用
サーバー情報 ServerInfo, ServerCapabilities, ServerInstructions, Completion サーバー名、対応機能、利用上の補足、接続終了理由を取得する ServerInstructionsInstructions に加えている
疎通確認 PingAsync(...) サーバーに応答できるかを確認する 未使用
ツール ListToolsAsync(...), CallToolAsync(...), CallToolAsTaskAsync(...) ツール一覧の取得、ツール実行、長時間ツールのタスク実行を行う ListToolsAsync()microsoft_docs_search を選択
プロンプト ListPromptsAsync(...), GetPromptAsync(...), CompleteAsync(...) サーバー公開プロンプトの列挙、取得、引数補完を行う 未使用
リソース ListResourcesAsync(...), ListResourceTemplatesAsync(...), ReadResourceAsync(...) 公開リソースとリソーステンプレートを列挙し、中身を読む 未使用
更新通知 SubscribeToResourceAsync(...), UnsubscribeFromResourceAsync(...) リソースの更新通知を購読する 未使用
タスク管理 ListTasksAsync(...), GetTaskAsync(...), GetTaskResultAsync(...), PollTaskUntilCompleteAsync(...), CancelTaskAsync(...) 長時間実行の状態確認、結果取得、キャンセルを行う 未使用
サーバーログ SetLoggingLevelAsync(...) サーバーから受けるログの粒度を調整する 未使用

どの API が実際に使えるかは、接続先サーバーが返す ServerCapabilities に依存します。

MCP の接続方式をどう選ぶか

MCP で標準として押さえる接続方式は、標準入出力接続 (stdio) と HTTP ストリーム接続 (Streamable HTTP) です。C# SDK の HTTP transport では、互換用途として HTTP + SSE 接続も扱えます。

接続方式 概要 サーバー起動 向いている用途 C# SDK での主な型
標準入出力接続 (stdio) クライアントが子プロセスとして MCP サーバーを起動し、標準入力と標準出力で JSON-RPC をやり取りする クライアント側が CommandArguments を指定して起動する ローカル CLI ツール、ローカル検証、同一端末内の連携 StdioClientTransport, StdioClientTransportOptions
HTTP ストリーム接続 (Streamable HTTP) 常駐中の HTTP エンドポイントへ接続し、サーバー側からのストリーム通知も扱う 別プロセス、別コンテナー、別ホストとしてサーバーを起動しておく リモート MCP サーバー、認証付きサービス、セッション再開を使いたい場合 HttpClientTransport, HttpClientTransportOptions, HttpTransportMode.StreamableHttp
HTTP + SSE 接続 旧来の HTTP transport。C# SDK では互換目的で利用でき、AutoDetect ではこの方式へフォールバックできる HTTP サーバーを別途起動しておく 既存サーバー互換、段階的移行 HttpClientTransport, HttpTransportMode.Sse, HttpTransportMode.AutoDetect
独自 transport アプリ側で IClientTransport を実装して独自の接続層を差し込む 実装に依存する 既存ミドルウェアや特殊なランタイム制約がある場合 IClientTransport 実装

今回は、Microsoft Learn Docs MCP Server が HTTP エンドポイントを公開しているため、HttpClientTransport を使った Streamable HTTP 接続を選択しています。

Agent の構成

Agent は使うツールを1個に固定し、チャットも1回だけで確認します。

AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
    Name = "LmStudioMcpAgent",
    Description = "LM Studio から MCP ツールを呼び出す最小サンプルです。",
    ChatOptions = new ChatOptions
    {
        Instructions =
            "あなたは Microsoft Learn の内容だけを根拠に回答するエージェントです。Microsoft または Azure に関する質問では、回答前に必ず microsoft_docs_search を使って確認してください。回答は日本語で簡潔にまとめ、最後に参照したドキュメントのタイトルと URL を含めてください。",
        Tools = [selectedTool],
        MaxOutputTokens = settings.MaxOutputTokens
    }
});

今回のポイントは次の 2 つです。

  • Tools = [selectedTool] で MCP ツールをそのまま渡している
  • Instructions で Microsoft / Azure 系の質問ではツールを必ず使うように寄せている

この設定でLM Studio 側のモデルがツール呼び出しを返した時に、Agent がそのまま MCP サーバーへ中継してくれるように促します。

固定チャットと実行結果の確認

実行に使うチャットは固定です。

FixedPrompt:
"Azure AI Agent Framework で MCP Tool Calling を使う方法を 3 点で要約してください。回答する前に必ず Microsoft Learn の検索ツールで確認し、最後に参照したドキュメントのタイトルと URL を付けてください。"

実行後は、AgentResponse のメッセージから FunctionCallContentFunctionResultContent を抜き出して表示しています。

List<FunctionCallContent> calls = response.Messages
    .SelectMany(message => message.Contents)
    .OfType<FunctionCallContent>()
    .ToList();

List<FunctionResultContent> results = response.Messages
    .SelectMany(message => message.Contents)
    .OfType<FunctionResultContent>()
    .ToList();

これにより、コンソールでは下記の順で確認できます。

  • Fixed Chat: LM Studio に渡した固定チャット
  • MCP Tool: 実際に Agent に渡した MCP ツール
  • Tool Call Summary: モデルが要求したツール名と引数、実行結果
  • Agent Response: ツール結果を踏まえた最終レスポンス

もしモデルがツールを呼ばなかった場合は例外で止めています。

if (calls.Count == 0)
{
    throw new InvalidOperationException("モデルが MCP ツールを呼び出しませんでした。tool calling に対応したモデルを LM Studio で読み込み、固定チャットを確認して再実行してください。");
}

実際のコンソール出力

今回の環境では下記の出力になりました。

[Tool Call Summary]
call[0] microsoft_docs_search: query=Azure AI Agent Framework MCP Tool Calling
result[0] {"results":[{"title":"Integrate MCP Tools with Azure AI Agents - Training","content":"# Integrate MCP Tools with Azure AI Agents\r\n\r\n- Module\r\n- 6 Units\r\n\r\nIntermediate\r\n\r\nAI Engineer\r\n\r\nDeveloper\r\n..."}]}

[Agent Response]
**Azure AI Agent FrameworkでMCP Tool Callingを利用する手順(3点)**

1. **MCPサーバーへの接続**
2. **ツール取得と変換**
3. **エージェントの作成と実行**

> **参照ドキュメント**
> 「Using MCP tools with Agents (programming-language-csharp)」

ポイントは次の 3 つです。

  • call[0] が出ているので、LM Studio 側のモデルが実際に microsoft_docs_search を呼んでいる
  • result[0] が出ているので、MCP サーバーの検索結果が Agent へ戻り、その結果を使って最終レスポンスを作れている
  • 検索結果は JSON 文字列として返るため、本文中の改行は \r\n の形で残る

まとめ

今回はAgentにMCPクライアント機能を実装し、外部のMCPサーバーを呼び出す方法を確認しました。

確認できたポイントは下記の通りです。

  • MCP サーバーには HttpClientTransport + McpClient.CreateAsync(...) で接続できる
  • ListToolsAsync() で取った McpClientTool をそのまま Agent のツールへ渡せる
  • 固定チャットからのツール呼び出しとツール結果を使った最終レスポンスをコンソールで確認できる

実際の運用ではMCPサーバーからのToolの取得とAgentへのチャットは別のタイミングで行うことが多いと思います。(Toolの選択をユーザーに任せたり、AIに絞り込ませたり等)

ソース

Program.cs
using System.ClientModel;
using System.Text;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using OpenAI;

Console.OutputEncoding = Encoding.UTF8;

SampleSettings settings = SampleSettings.Load();

try
{
    Console.WriteLine("=== MCP Client Sample with LM Studio ===");
    Console.WriteLine($"lm_studio={settings.LmStudioUrl}");
    Console.WriteLine($"model={settings.ModelName}");
    Console.WriteLine($"mcp_server={settings.McpServerUrl}");
    Console.WriteLine($"tool={settings.ToolName}");
    Console.WriteLine();

    var clientOptions = new OpenAIClientOptions
    {
        Endpoint = new Uri(settings.LmStudioUrl)
    };

    var openAIClient = new OpenAIClient(
        new ApiKeyCredential(settings.ApiKey),
        clientOptions);

    IChatClient chatClient = openAIClient
        .GetChatClient(settings.ModelName)
        .AsIChatClient();

    await using var mcpClient = await McpClient.CreateAsync(
        new HttpClientTransport(new HttpClientTransportOptions
        {
            Endpoint = new Uri(settings.McpServerUrl),
            Name = "MicrosoftLearnDocs",
            TransportMode = HttpTransportMode.StreamableHttp
        }));

    IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync().ConfigureAwait(false);

    McpClientTool selectedTool = mcpTools
        .SingleOrDefault(tool => string.Equals(tool.Name, settings.ToolName, StringComparison.Ordinal))
        ?? throw new InvalidOperationException($"MCP ツール '{settings.ToolName}' が見つかりませんでした。利用可能: {string.Join(", ", mcpTools.Select(tool => tool.Name))}");

    selectedTool = selectedTool.WithDescription(
        "Search official Microsoft Learn and Azure documentation. Use this tool before answering Microsoft or Azure questions.");

    Console.WriteLine("[Fixed Chat]");
    Console.WriteLine(settings.FixedPrompt);
    Console.WriteLine();
    Console.WriteLine("[MCP Tool]");
    Console.WriteLine($"name={selectedTool.Name}");
    Console.WriteLine($"description={selectedTool.Description}");
    Console.WriteLine();

    string serverInstructions = string.IsNullOrWhiteSpace(mcpClient.ServerInstructions)
        ? string.Empty
        : $"\nサーバーから渡された補足: {mcpClient.ServerInstructions}";

    AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
    {
        Name = "LmStudioMcpAgent",
        Description = "LM Studio から MCP ツールを呼び出す最小サンプルです。",
        ChatOptions = new ChatOptions
        {
            Instructions =
                "あなたは Microsoft Learn の内容だけを根拠に回答するエージェントです。Microsoft または Azure に関する質問では、回答前に必ず microsoft_docs_search を使って確認してください。回答は日本語で簡潔にまとめ、最後に参照したドキュメントのタイトルと URL を含めてください。"
                + serverInstructions,
            Tools = [selectedTool],
            MaxOutputTokens = settings.MaxOutputTokens
        }
    });

    AgentSession session = await agent.CreateSessionAsync();
    AgentResponse response = await agent.RunAsync(settings.FixedPrompt, session);

    PrintToolSummary(response);

    Console.WriteLine();
    Console.WriteLine("[Agent Response]");
    Console.WriteLine(response);
}
catch (Exception ex)
{
    Console.WriteLine();
    Console.WriteLine("LM Studio または MCP サーバーへの接続に失敗しました。LM Studio を既定の endpoint で起動し、再実行してください。");
    Console.WriteLine(ex.Message);
}

static void PrintToolSummary(AgentResponse response)
{
    List<FunctionCallContent> calls = response.Messages
        .SelectMany(message => message.Contents)
        .OfType<FunctionCallContent>()
        .ToList();

    List<FunctionResultContent> results = response.Messages
        .SelectMany(message => message.Contents)
        .OfType<FunctionResultContent>()
        .ToList();

    Console.WriteLine("[Tool Call Summary]");

    if (calls.Count == 0)
    {
        throw new InvalidOperationException("モデルが MCP ツールを呼び出しませんでした。tool calling に対応したモデルを LM Studio で読み込み、固定チャットを確認して再実行してください。");
    }

    for (int index = 0; index < calls.Count; index++)
    {
        FunctionCallContent call = calls[index];
        Console.WriteLine($"call[{index}] {call.Name}{FormatFunctionCallArguments(call.Arguments)}");
    }

    for (int index = 0; index < results.Count; index++)
    {
        FunctionResultContent result = results[index];
        Console.WriteLine($"result[{index}] {Preview(result.Result)}");
    }
}

static string FormatFunctionCallArguments(IDictionary<string, object?>? arguments)
{
    if (arguments is null || arguments.Count == 0) return string.Empty;
    string formatted = string.Join(", ", arguments.Select(argument => $"{argument.Key}={Preview(argument.Value)}"));
    return $": {formatted}";
}

static string Preview(object? value, int maxLength = 240)
{
    if (value is null) return "<null>";
    string text = value.ToString()?.Replace("\r", " ").Replace("\n", " ").Trim() ?? string.Empty;
    if (string.IsNullOrWhiteSpace(text)) return "<empty>";
    return text.Length <= maxLength
        ? text
        : text[..maxLength] + "...";
}

sealed record SampleSettings(
    string LmStudioUrl,
    string ApiKey,
    string ModelName,
    string McpServerUrl,
    string ToolName,
    string FixedPrompt,
    int MaxOutputTokens)
{
    public static SampleSettings Load()
    {
        return new SampleSettings(
            LmStudioUrl: Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? "http://localhost:1234/v1",
            ApiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "sk-dummy",
            ModelName: Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "openai/gpt-oss-20b",
            McpServerUrl: Environment.GetEnvironmentVariable("MCP_SERVER_URL") ?? "https://learn.microsoft.com/api/mcp",
            ToolName: Environment.GetEnvironmentVariable("MCP_TOOL_NAME") ?? "microsoft_docs_search",
            FixedPrompt: Environment.GetEnvironmentVariable("MCP_FIXED_PROMPT")
                ?? "Azure AI Agent Framework で MCP Tool Calling を使う方法を 3 点で要約してください。回答する前に必ず Microsoft Learn の検索ツールで確認し、最後に参照したドキュメントのタイトルと URL を付けてください。",
            MaxOutputTokens: ReadInt32EnvironmentVariable("MAX_OUTPUT_TOKENS", 2000));
    }

    private static int ReadInt32EnvironmentVariable(string variableName, int defaultValue)
    {
        string? value = Environment.GetEnvironmentVariable(variableName);
        return int.TryParse(value, out int parsed)
            ? parsed
            : defaultValue;
    }
}

Discussion