👌

.NET で MCP サーバー・クライアントを試してみよう

2025/03/31に公開

更新履歴

2025/04/02

本文

ここ最近、色々なところで目にする Model Context Protocol (MCP) ですが、最初は何で Microsoft の資格試験が X のトレンドに入っているんだろう?と思ってましたが全然違いました。

MCP 自体の解説については以下のリンク先がとても詳しいので、そちらを参照してください。

端的に言うと、これからの AI を扱うアプリケーションはサーバーにしてもクライアントにしても MCP に対応しておけば間違いなさそうな将来有望な奴という感じです。

.NET で MCP のサーバーを作ってみよう

ASP.NET Core で MCP サーバー作ってみようと思います。「ASP.NET Core Web API」のプロジェクトテンプレートを使ってプロジェクトを作成します。
ここでは MCPServerLab という名前のプロジェクトを作成します。

「コントローラーを使用する」のチェックを外しておきます。これで Minimal API のプロジェクトが作成されます。
Program.cs からテンプレートに含まれている WeatherForecast 関連のコードを削除します。

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.UseHttpsRedirection();

app.Run();

そして NuGet パッケージマネージャーから以下のパッケージをインストールします。

  • ModelContextProtocol v0.1.0-preview.2

ここから簡単な Tool を追加したりして動作確認!と思っていたのですが、現状 sse の実装がないので MCP の C# SDK のリポジトリのサンプルから McpEndpointRouteBuilderExtensions.cs をコピーしてきます。

そして 3 時間前にあったコミットによって RunAsync メソッドの呼び出しに書き換えられているのですが、現状の NuGet パッケージでは StartAsync メソッドになるので、そこだけ書き換えます。書き換えた結果のコードは以下のようになります。

McpEndpointRouteBuilderExtensions.cs
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils.Json;

namespace MCPServerLab;

public static class McpEndpointRouteBuilderExtensions
{
    public static IEndpointConventionBuilder MapMcpSse(this IEndpointRouteBuilder endpoints)
    {
        SseResponseStreamTransport? transport = null;
        var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
        var mcpServerOptions = endpoints.ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>();

        var routeGroup = endpoints.MapGroup("");

        routeGroup.MapGet("/sse", async (HttpResponse response, CancellationToken requestAborted) =>
        {
            response.Headers.ContentType = "text/event-stream";
            response.Headers.CacheControl = "no-cache";

            await using var localTransport = transport = new SseResponseStreamTransport(response.Body);
            await using var server = McpServerFactory.Create(transport, mcpServerOptions.Value, loggerFactory, endpoints.ServiceProvider);

            try
            {
                var transportTask = transport.RunAsync(cancellationToken: requestAborted);
                await server.StartAsync(cancellationToken: requestAborted);
                await transportTask;
            }
            catch (OperationCanceledException) when (requestAborted.IsCancellationRequested)
            {
                // RequestAborted always triggers when the client disconnects before a complete response body is written,
                // but this is how SSE connections are typically closed.
            }
        });

        routeGroup.MapPost("/message", async context =>
        {
            if (transport is null)
            {
                await Results.BadRequest("Connect to the /sse endpoint before sending messages.").ExecuteAsync(context);
                return;
            }

            var message = await context.Request.ReadFromJsonAsync<IJsonRpcMessage>(McpJsonUtilities.DefaultOptions, context.RequestAborted);
            if (message is null)
            {
                await Results.BadRequest("No message in request body.").ExecuteAsync(context);
                return;
            }

            await transport.OnMessageReceivedAsync(message, context.RequestAborted);
            context.Response.StatusCode = StatusCodes.Status202Accepted;
            await context.Response.WriteAsync("Accepted");
        });

        return routeGroup;
    }
}

そして Program.cs に MCP に対応するコードを追加します。

using ModelContextProtocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using MCPServerLab;

var builder = WebApplication.CreateBuilder(args);
// MCP 関連のサービスの追加と EchoTool の追加
builder.Services.AddMcpServer().WithTools<WeatherForecastTool>();

var app = builder.Build();

app.UseHttpsRedirection();

app.MapGet("/", () => "My sample MCP server.");
// 先ほど定義した MapmcpSse メソッドを呼び出す
app.MapMcpSse();
app.Run();

// 天気予報を取得するツール
[McpServerToolType, Description("天気予報を取得するツール")]
class WeatherForecastTool
{
    [McpServerTool, Description("指定した場所の天気予報を返します。")]
    public static string GetWeatherForecast(
        [Description("天気を取得したい場所の名前")]
        string location) => location switch
    {
        "東京" => "晴れ",
        "大阪" => "曇り",
        "札幌" => "雪",
        _ => "空からカエルが降る異常気象",
    };
}

これで MCP サーバーの実装は完了です。次にクライアントを実装していきます。

MCP クライアントを実装してみよう

MCP のサーバーが出来たのでクライアントを作って呼び出してみます。
クライアント機能も ModelContextProtocol の NuGet パッケージを使います。コンソールアプリを作成して、NuGet パッケージを追加して以下のコードを追加します。

Program.cs
using ModelContextProtocol.Client;
using ModelContextProtocol.Configuration;
using ModelContextProtocol.Protocol.Transport;

await using var client = await McpClientFactory.CreateAsync(new McpServerConfig
{
    Id = "MCPServerLab",
    Name = "天気予報",
    // sse のエンドポイントを指定
    TransportType = TransportTypes.Sse,
    Location = "https://localhost:7087/sse",
});

// ツールの一覧を取得
var tools = await client.ListToolsAsync();
foreach (var tool in tools)
{
    Console.WriteLine($"{tool.Name}: {tool.JsonSchema}");
}

// GetWeatherForecast ツールを取得
var getWeatherForecastTool = tools.FirstOrDefault(t => t.Name == "GetWeatherForecast")
    ?? throw new InvalidOperationException();

// GetWeatherForecast ツールを実行
var response = await client.CallToolAsync(getWeatherForecastTool.Name, new Dictionary<string, object?>
{
    ["location"] = "東京",
});

// レスポンスを表示
foreach (var content in response.Content)
{
    Console.WriteLine($"{content.Type}, {content.Text}");
}

Console.ReadKey();

使い方は直観的だと思います。McpClientFactory でクライアントを作成します。サーバーの指定は McpServerConfig で行います。IdName は適当な値を指定します。TransportTypeSse を指定します。Location にはサーバーの URL を指定します。これで先ほどのサーバーに接続することができます。

ListToolsAsync メソッドでサーバーに登録されているツールの一覧を取得します。ツールの名前は Name プロパティで取得できます。CallToolAsync メソッドでツールを実行します。引数にはツールの名前と引数を指定します。引数は Dictionary<string, object?> で指定します。戻り値は CallToolResponse 型で、レスポンスの内容は Content プロパティに格納されています。

Content プロパティは Content という型で Type でレスポンスの種類が取得できて Text でレスポンスの内容が取得できます。ツールから返ってくるデータが画像のようなものの場合は Data プロパティに base-64 でエンコードされたデータが格納されます。

サーバーを起動して、クライアントを実行すると以下のような結果が得られます。

GetWeatherForecast: {"title":"GetWeatherForecast","description":"\u6307\u5B9A\u3057\u305F\u5834\u6240\u306E\u5929\u6C17\u4E88\u5831\u3092\u8FD4\u3057\u307E\u3059\u3002","type":"object","properties":{"location":{"description":"\u5929\u6C17\u3092\u53D6\u5F97\u3057\u305F\u3044\u5834\u6240\u306E\u540D\u524D","type":"string"}},"required":["location"]}
text, 晴れ

ちゃんとツールが取得できて結果が帰ってきていることが確認できました。description がエンコードされてしまいますが、ちゃんと値が入っていることが確認できました。

ちなみにサーバー側のツールを以下のように書き換えてツールの戻り値をオブジェクトにしてみます。

// 天気予報を取得するツール
[McpServerToolType, Description("天気予報を取得するツール")]
class WeatherForecastTool
{
    [McpServerTool, Description("指定した場所の天気予報を返します。")]
    public static WeatherForecast GetWeatherForecast(
        [Description("天気を取得したい場所の名前")]
        string location) => location switch
    {
        "東京" => new(location, "晴れ"),
        "大阪" => new(location, "曇り"),
        "札幌" => new(location, "雪"),
        _ => new(location, "空からカエルが降る異常気象"),
    };
}

record WeatherForecast(
    [property: Description("場所")]
    string Location,
    [property: Description("天気予報")]
    string Forecast);

そうすると、クライアントの実行結果は以下のようになります。

GetWeatherForecast: {"title":"GetWeatherForecast","description":"\u6307\u5B9A\u3057\u305F\u5834\u6240\u306E\u5929\u6C17\u4E88\u5831\u3092\u8FD4\u3057\u307E\u3059\u3002","type":"object","properties":{"location":{"description":"\u5929\u6C17\u3092\u53D6\u5F97\u3057\u305F\u3044\u5834\u6240\u306E\u540D\u524D","type":"string"}},"required":["location"]}
text, {"location":"\u6771\u4EAC","forecast":"\u6674\u308C"}

ちゃんと戻り値が object になっていて object のプロパティにも description が入っていることが確認できます。そしてツール呼び出しの結果も JSON 形式になっています。日本語がエンコードされてしまうので、ちょっとクライアントのコードをいじって日本語をデコードするようにしてみます。

ツールを呼び出した結果を表示する部分のコードに少し手を入れて以下のように変更します。

// レスポンスを表示
foreach (var content in response.Content)
{
    var r = JsonSerializer.Deserialize<Dictionary<string, object?>>(content.Text ?? "{}")!;
    foreach (var (key, value) in r)
    {
        Console.WriteLine($"{key}: {value}");
    }
}

実行すると以下のように表示されます。

GetWeatherForecast: {"title":"GetWeatherForecast","description":"\u6307\u5B9A\u3057\u305F\u5834\u6240\u306E\u5929\u6C17\u4E88\u5831\u3092\u8FD4\u3057\u307E\u3059\u3002","type":"object","properties":{"location":{"description":"\u5929\u6C17\u3092\u53D6\u5F97\u3057\u305F\u3044\u5834\u6240\u306E\u540D\u524D","type":"string"}},"required":["location"]}
location: 東京
forecast: 晴れ

ちゃんと日本語が表示されるようになりました。これで MCP のサーバー・クライアントの実装が出来ました。

AI っぽさを足そう

MCP のサーバー・クライアントを実装してみましたが、これだけだとただの Web API なので、AI っぽさを足してみます。今回は Azure OpenAI Service 上にデプロイした gpt-4o モデルを使って MCP Server のツールを呼び出すようなコードをクライアントに実装してみようと思います。

ModelContextProtocol の NuGet パッケージは Microsoft.Extensions.AI を参照しているので素直に使うのであれば、これを使って LLM のモデルを呼び出すのが簡単です。やってみましょう。クライアントアプリに Microsoft.Extensions.AI.OpenAIAzure.AI.OpenAIAzure.Identity の NuGet パッケージを追加して Azure OpenAI Service に繋ぐための処理を追加します。

そしてクライアントのコードを以下のように変更します。コメントにあるように Azure OpenAI Service には自分の Azure CLI でログインしているアカウントに Cognitive Service OpenAI ユーザーの権限を付与していて、gpt-4o という名前のモデルをデプロイしている想定です。

Program.cs
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Configuration;
using ModelContextProtocol.Protocol.Transport;
using System.Text.Json;

await using var client = await McpClientFactory.CreateAsync(new McpServerConfig
{
    Id = "MCPServerLab",
    Name = "天気予報",
    // sse のエンドポイントを指定
    TransportType = TransportTypes.Sse,
    Location = "https://localhost:7087/sse",
});

var mcpTools = await client.ListToolsAsync();

// ローカルの Azure CLI でログインしているアカウントに Cognitive Service OpenAI ユーザーの権限を付与している想定
var openAIClient = new AzureOpenAIClient(
    new("https://<<AOAI のリソース名>>.openai.azure.com/"),
    new AzureCliCredential());

// gpt-4o という名前でデプロイされているモデルを使用して自動で関数呼び出しをする ChatClient を作成
var chatClient = openAIClient.AsChatClient("gpt-4o")
    .AsBuilder()
    .UseFunctionInvocation()
    .Build();

// 品川の天気を聞いてツールを呼び出してもらう
var aiResponse1 = await chatClient.GetResponseAsync(
    new ChatMessage(ChatRole.User, "今日の品川の天気は?"),
    new ChatOptions
    {
        ToolMode = ChatToolMode.Auto,
        Tools = [.. mcpTools]
    });

// AI からの返答を表示
Console.WriteLine(aiResponse1.Text);

// 東京の天気を聞いてツールを呼び出してもらう
var aiResponse2 = await chatClient.GetResponseAsync(
    new ChatMessage(ChatRole.User, "今日の東京の天気は?"),
    new ChatOptions
    {
        ToolMode = ChatToolMode.Auto,
        Tools = [.. mcpTools]
    });
// AI からの返答を表示
Console.WriteLine(aiResponse2.Text);

Console.ReadKey();

最初の AI の呼び出しでは品川の天気を聞いているので MCP サーバーにある GetWeatherForecast のツールに「品川」を指定して呼び出すので「空からカエルが降る異常気象」という天気に対するコメントが返ってくることを期待しています。2回目の呼び出しでは「東京」を指定しているので、東京の天気を取得して「晴れ」という天気に対するコメントが返ってくることを期待しています。

実際に実行すると以下のようになりました。

品川の今日の天気予報は「空からカエルが降る異常気象」です。ちょっと奇妙ですね!外出の際には注意してください。
今日の東京の天気は「晴れ」です!

ちゃんと品川と東京の天気を聞いて MCP Server のツールを呼び出して結果を返してくれました。

今後に期待することと懸念点

今回の MCP のサーバーの /sse エンドポイントの実装はサンプルからコピペしましたが ASP.NET Core なのか ModelContextProtocol のどちらかに組み込んでいてほしいなぁと思います。また、Azure の App Service や Container Apps に MCP サーバーをデプロイする際には HTTP の通信のタイムアウトの 240 秒がブロッカーになる気がしてます…。

Azure Container Apps だと、ここらへんの Issue ですかね…。

現状、この制限を回避するためには AKS か VM にデプロイしないといけないはずなので、MCP も WebSocket とかに対応してくれたら嬉しいなぁと思います。もしくは Azure App Service などで 240 秒の壁が緩和されてほしいなぁと思います。

まとめ

MCP のサーバー・クライアントを実装してみました。ModelContextProtocol の NuGet パッケージを使うことで簡単に実装できました。ただ GitHub のリポジトリを見てみると結構活発に開発が進められているので、今日書いたコードはすぐに破壊的変更で動かなくなると思います。そのため雰囲気をつかむ程度くらいに参考にしてください。

実際に使う際には以下のリポジトリを確認するのがいいと思います。

また、以下のブログにあるように Microsoft.Extensions.AI と Semantic Kernel は相互運用可能なので Semantic Kernel でも MCP Server のツールを呼ぶようなことが出来ます。そちらについては Semantic Kernel のブログの以下の記事に記載がありますので参考にしてください。

次の記事

.NET で MCP サーバー・クライアントを試してみよう その 2

Microsoft (有志)

Discussion