Claude Media
C# MCPサーバーの作り方 — .NET公式SDKでstdioとHTTPを立てる

C# MCPサーバーの作り方 — .NET公式SDKでstdioとHTTPを立てる

C#のMCPサーバーは公式のModelContextProtocol NuGetパッケージで作れます。stdioとHTTPの立て方、Tool定義、C#クライアント、例外の届き方、ホスト名検証まで。

C#MCPサーバーとは何を作ることになるのか

C#でMCPサーバーを作るとは、Claude CodeやClaude Desktopのようなホストアプリケーションから呼び出せるツール・リソース・プロンプトを、.NET上に公開することです。MCP(Model Context Protocol)の公式SDK一覧では、C#はTypeScript・Python・Go・Rust・Rubyと並ぶTier 1に分類されています。

TypeScript SDKやデコレータでツールを定義するPython SDKと比べると、C#は属性(アトリビュート)でToolを宣言します。宣言したToolを Microsoft.Extensions.Hosting のDIコンテナに載せる点も特徴です。社内の.NET資産をMCP化したいチームは、この書き方がそのまま既存のホスティングモデルに乗ります。同じ.NET環境でClaude Codeを使うなら、Claude Codeで.NET MAUIアプリを開発するも参考になります。

結論から言うと、C#(.NET)のMCPサーバーもクライアントも、公式C# SDKの ModelContextProtocol パッケージ1つで書けます。サーバーは AddMcpServer() と WithStdioServerTransport() を呼び、Toolに [McpServerTool] を付けるだけです。HTTPで公開するなら ModelContextProtocol.AspNetCore を足します。クライアントは McpClient.CreateAsync で接続します。

この記事のコードは、C# SDKのv1系ドキュメント(ラベルはv1.4.1)に沿っています。NuGetには2.0.0以降も公開されていて、最新安定版は2.2.0です。ドキュメントのバージョン切り替えで、v2版も読めます。

dotnet add package でバージョンを省くと入るのは2.2.0です。v2.0.0からHTTPトランスポートの既定がステートレスに変わっています。v2系でサンプルコードを実際に動かして比べてはいません。コードを写す前に、使うパッケージのバージョンと、対応するドキュメントの版を揃えてください。

作る前に決めるのは、パッケージとトランスポート

前提は3つです。.NET SDK(dotnet コマンド)、NuGetを引けるネットワーク、動作確認用のMCPホスト(Claude CodeやMCP Inspectorなど)があれば足ります。Node.js(npx)があると、後述の参照実装サーバーにも接続できます。

公式SDKは3つのNuGetパッケージに分かれています。

パッケージ向いている用途
ModelContextProtocol.Core向いている用途クライアントまたは低レベルサーバーAPIのみが必要で、依存を最小限にしたい場合
ModelContextProtocol向いている用途stdioベースのサーバーやクライアントで、ホスティング・DI・属性による自動検出を使う場合(Core を参照)
ModelContextProtocol.AspNetCore向いている用途ASP.NET Core上のHTTPサーバーを作る場合(ModelContextProtocol を参照)

迷ったら ModelContextProtocol から始め、HTTPが必要になった時点で ModelContextProtocol.AspNetCore を足す、というのがドキュメントのTipです。新規サーバーの雛形を作るMCP Serverプロジェクトテンプレートもあります。拡張用には ModelContextProtocol.Extensions.Apps(MCP Apps)と ModelContextProtocol.Extensions.Tasks(長時間のTool実行)もNuGetに公開されています。

stdioサーバーを最短で立てる

コンソールアプリを新規作成し、パッケージを追加します。

dotnet new console
dotnet add package ModelContextProtocol
dotnet add package Microsoft.Extensions.Hosting

Program.cs を次の内容に置き換えると、stdio経由で1つのToolを公開する最小構成のMCPサーバーになります。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
using System.ComponentModel;
 
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole(consoleLogOptions =>
{
    // ログはすべて標準エラーへ流す
    consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
});
 
builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();
 
await builder.Build().RunAsync();
 
[McpServerToolType]
public static class EchoTool
{
    [McpServerTool, Description("Echoes the message back to the client.")]
    public static string Echo(string message) => $"hello {message}";
}

核心は WithToolsFromAssembly() です。アセンブリ内で [McpServerToolType] を付けたクラスを走査し、[McpServerTool] を付けたメソッドをToolとして登録します。PromptとResourceも [McpServerPromptType] / [McpServerResourceType] で同じ流れです。個別に登録したいときは、WithTools<MyTools>() のように型を指定する方法もあります。

stdioは、Claude CodeやClaude Desktopがサブプロセスとして起動するローカルMCPサーバーの形です。標準入出力をそのままプロトコル通信に使うので、ログの出力先が成否を分けます(後述)。

stdioサーバーをClaude Codeに登録して動かす

作ったサーバーは、claude mcp add で登録します。v2.1.287の claude mcp add --help には、stdioサーバーの登録例として次の形が載っています。

  # Add stdio server with subprocess flags:
  claude mcp add my-server -- my-command --some-flag arg1

-- より後ろが、サーバーを起動するコマンドとその引数です。C#のプロジェクトなら、dotnet run --project でプロジェクトを指す形が候補になります。

claude mcp add --transport stdio echo-server -- dotnet run --project /path/to/EchoServer
手順

登録から確認までの流れ

  1. 1

    ビルドが通ることを先に確かめる

    プロジェクトのフォルダで dotnet build を実行します。ビルドの失敗は、登録後だとホスト側の接続エラーとしてしか見えません。

  2. 2

    `--` の後ろにコマンドを書いて登録する

    -- を省くと、dotnet run に続く --project などのフラグをClaude Code自身のオプションとして解釈しようとします。Claude Codeのドキュメントにも同じ注意があります。

  3. 3

    Claude Codeで接続状態を見る

    claude mcp get echo-server を実行するか、claude mcp list でサーバーが ✔ Connected と表示されるか確かめます。Claude Codeのドキュメントもこの確認手順を案内しています。セッション内なら /mcp パネルでも接続状態を見られます。

手元のMacには dotnet が入っておらず、この記事のC#コードはビルドして動かしてはいません。claude mcp add の書式は、v2.1.287のヘルプ出力とドキュメントの記述で確かめました。

社内ネットワークの自己ホスト環境へ配布する場合の経路は、Claude Codeセルフホスト環境へMCPサーバーを届ける3つの経路で扱っている内容がそのまま当てはまります。

HTTPサーバーへ切り替える

リモートから複数クライアントを受け付けたい場合は、ASP.NET Coreベースの構成に切り替えます。

dotnet new web
dotnet add package ModelContextProtocol.AspNetCore
using ModelContextProtocol.Server;
using System.ComponentModel;
 
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer()
    .WithHttpTransport(options =>
    {
        // サーバーからクライアントへのリクエスト(sampling / elicitation)が
        // 不要な場合は Stateless モードを推奨
        options.Stateless = true;
    })
    .WithToolsFromAssembly();
 
var app = builder.Build();
app.MapMcp();
app.Run("http://localhost:3001");
 
[McpServerToolType]
public static class EchoTool
{
    [McpServerTool, Description("Echoes the message back to the client.")]
    public static string Echo(string message) => $"hello {message}";
}

Tool側のコードはstdio版と同じです。変わるのはトランスポートの登録部分だけなので、stdioサーバーをHTTP化してもTool実装は書き直しになりません。

Statelessにするかどうかで、できることが変わる

サンプルの options.Stateless = true は、サーバーがセッションを持たない設定です。v2.0.0から既定がステートレスに変わりました。v1系の既定はステートフルで、サーバーがクライアントごとに Mcp-Session-Id を割り当て、状態をメモリに持ちます。どちらの版でも明示的に設定しておけば、挙動が揃います。v2のドキュメントは同じ設定を options.SessionMode = HttpServerSessionMode.Stateless と書きます。

くらべる

HTTPトランスポートの2つのモード

多くのサーバーで推奨

Stateless = true

セッション固定なしで水平スケールでき、Mcp-Session-Id を送らないクライアントとも問題が起きません。代わりにGETエンドポイントが作られないため、サーバー起点の通知は送れません。

サーバー起点の通信が要るとき

Stateless = false

長時間のGETで、サーバーからの通知やサーバーからクライアントへのリクエストを受け取れます。Toolの一覧が実行中に変わる場合の通知も、ステートフルかstdioのときだけ送れます。セッションはメモリ上に持つので、インスタンスを増やす構成では扱いが重くなります。

v2ではMCPの2026-07-28リビジョンでセッションが廃止されました。ステートフル専用のオプションは [Obsolete] の警告付きになり、旧来のinitializeハンドシェイクで接続するクライアントにだけ効きます。

旧来のSSEトランスポート(/sse と /message)は既定で無効で、EnableLegacySse には [Obsolete](診断ID MCP9004)が付いています。SSEはステートフルモードが必須で、Stateless = true のときはSSEのエンドポイント自体が割り当てられません。

ホスト名検証を必ず設定する。ASP.NET CoreのKestrelは既定で Host ヘッダーを検証しません。ローカル用途では AllowedHosts をループバック値に限定し、本番ではプロキシ・ロードバランサ側でも検証します。これでDNSリバインディング(攻撃者が制御するDNS名でローカルサーバーに到達される攻撃)を防ぎます。AllowedHosts に "*" を置いたままにしないよう指示されています。CORSはホスト名検証の代わりにならず、ブラウザからのクロスオリジンアクセスが必要なときだけ、最も制限的なポリシーで有効にします。

Toolが投げた例外は、クライアントにどう届くか

自作サーバーで最初に迷うのは、Toolの中で例外が出たときの扱いです。Toolsドキュメントによると、Toolの失敗はプロトコルエラーではなく、結果の CallToolResult の中の IsError = true として返ります。LLMが失敗を見て、自分で立て直せるようにするための設計です。

くらべる

例外の種類で、返り方が分かれる

Tool結果として返る

通常の例外

ArgumentException など McpException でない例外は、一般的な文言に置き換わります。内部情報を漏らさないためで、たとえば「An error occurred invoking 'divide'.」のように届きます。McpException の派生なら、そのメッセージがエラー文に入ります。

JSON-RPCエラーとして返る

McpProtocolException

不正な引数や未知のToolなど、プロトコルレベルの異常を知らせるときに投げます。McpErrorCode.InvalidParams を指定すると、コード -32602のエラー応答になります。キャンセル用のトークンが発火した場合の OperationCanceledException も、同じく投げ直されます。

つまり、throw new ArgumentException("Cannot divide by zero") と書いても、LLMに届くのは具体的な原因ではなく一般的な文言です。原因をLLMに伝えて立て直させたいなら、メッセージを McpException に載せるか、結果の文字列として返す設計にします。クライアント側は、呼び出しの結果で IsError を調べます。

Toolの引数・戻り値と、DIで受け取れるもの

[McpServerTool] のメソッドは、クライアントが渡す通常の引数に加えて、特別な引数も受け取れます。対象は McpServer / IProgress<ProgressNotificationValue> / ClaimsPrincipal と、DIコンテナへ登録済みの任意のサービスです。これらはSDKが自動で解決する特別な引数で、クライアントが値を渡すものではありません。

入力スキーマはメソッドのシグネチャから自動生成され、JSON Schema 2020-12になります。対応は、string がstring、int / long がinteger、float / double がnumberです。bool はboolean、複合型はpropertiesを持つobjectになります。[Description] を引数に付けると、スキーマの description に入り、既定値もスキーマに出ます。

戻り値は、文字列なら自動で TextContentBlock に包まれます。画像は ImageContentBlock.FromBytes(bytes, "image/png")、音声は AudioContentBlock を返します。ドキュメントを埋め込む場合は EmbeddedResourceBlock です。複数を返したいときは IEnumerable<ContentBlock> にします。ブロックには Annotations で宛先(Role.Assistant など)と優先度(0.0〜1.0)を添えられます。

C#でMCPクライアントを書いて動作確認する

サーバー単体では動作確認しづらいので、公式SDKでクライアントも書けます。参照実装サーバー(@modelcontextprotocol/server-everything)に接続する例です。

dotnet new console
dotnet add package ModelContextProtocol
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
 
var clientTransport = new StdioClientTransport(new StdioClientTransportOptions
{
    Name = "Everything",
    Command = "npx",
    Arguments = ["-y", "@modelcontextprotocol/server-everything"],
});
 
var client = await McpClient.CreateAsync(clientTransport);
 
// 利用可能なToolの一覧を表示
foreach (var tool in await client.ListToolsAsync())
{
    Console.WriteLine($"{tool.Name} ({tool.Description})");
}
 
// Toolを実行(通常はLLMのtool呼び出しから駆動される)
var result = await client.CallToolAsync(
    "echo",
    new Dictionary<string, object?>() { ["message"] = "Hello MCP!" },
    cancellationToken: CancellationToken.None);
 
Console.WriteLine(result.Content.OfType<TextContentBlock>().First().Text);

このクライアントはC# SDKで作ったサーバーに限らず、任意のMCPサーバーに接続できます。McpClientTool は AIFunction を継承しているため、ListToolsAsync() で取得したToolはそのまま IChatClient に渡せます。

IList<McpClientTool> tools = await client.ListToolsAsync();
 
IChatClient chatClient = ...;
var response = await chatClient.GetResponseAsync(
    "your prompt here",
    new() { Tools = [.. tools] });

.NETのチャットクライアント抽象化にMCPのToolを直結できるのは、McpClientTool が Microsoft.Extensions.AI の AIFunction を継承しているからです。

よくあるつまずき

最初に起きやすいのはパッケージの取り違えです。HTTPサーバーを作るつもりで ModelContextProtocol だけを追加すると、WithHttpTransport が見つかりません。HTTPには ModelContextProtocol.AspNetCore が必須です。

ログを標準出力に書いてしまうミスも起きがちです。stdioトランスポートは標準入出力をプロトコル通信に使うため、ロガーの出力先を標準エラーに向け忘れると、ログ文字列がJSON-RPCメッセージに混入してクライアント側のパースが壊れます。サンプルで LogToStandardErrorThreshold = LogLevel.Trace を明示しているのはこのためです。

DIサービスを引数に書けることに気づかない。上で触れたとおり、属性ベースの検出のまま、明示的なTool登録APIへ切り替えずにサービスを受け取れます。

dotnet add package でバージョンを省くと、その時点の最新安定版が入ります。NuGetにはv2系が公開済みで、HTTPの既定もv2.0.0から変わっています。v1のドキュメントを見ながらコードを書くと、既定値や型の細部が食い違う可能性があります。再現性が要るプロジェクトでは、バージョンを明示して固定し、更新時に変更履歴を確認する運用が安全です。

Description属性を省略する。[Description] はToolの説明として公開され、引数に付ければスキーマの description に入ります。ドキュメントはこれが、LLMが各引数に何を渡せばよいか理解する助けになると説明しています。

C#MCPサーバーは他言語のSDKとどう違うか

公式のSDK一覧では、TypeScript・Python・C#・Go・Rust・RubyがTier 1、JavaがTier 2、Swift・PHP・KotlinがTier 3です。Java向けにはSpring AIとの統合が用意されています。SDKのTier区分の詳細は、機能完全性・プロトコル対応・保守コミットメントで決まります。

C#の特徴は、Microsoft.Extensions.Hosting のホスティングモデルと Microsoft.Extensions.AI の IChatClient に直結する点です。属性でToolを宣言する書き方は、同じ発想がUnity Editor内蔵のMCPブリッジの [McpTool] にもあり、Unity公式MCPサーバーの使い方で実装例を確認できます。

クライアント実装の詳しい書き方はMCPクライアント自作ガイド、サーバーとクライアントそれぞれの内部構造はMCPクライアントの仕組みで扱っています。

公開するならMCP Registryに載せる

作ったサーバーをNuGetで配布し、MCP Registry経由で見つけてもらう場合は、server.json によるメタデータ登録が必要です。C#製サーバーはNuGetパッケージとして登録でき、server.json の registryType は "nuget" です。

Registryが対応するNuGetの配布元は、nuget.org(https://api.nuget.org/v3/index.json)だけです。所有者の確認は、パッケージのREADMEに mcp-name: $SERVER_NAME という文字列があるかで行われます。この文字列はHTMLコメントの中に隠してもよい一方、$SERVER_NAME の部分は server.json のサーバー名と一致させる必要があります。パッケージ種別ごとの違いはMCP Registryのパッケージ種別ガイドで扱っています。

ResourceとPromptを属性で公開する

Toolだけでなく、URIで参照できるデータをResourceとして、定型プロンプトをPromptとして公開すると、サーバーの用途が広がります。

[McpServerResourceType]
public static class DocsResource
{
    [McpServerResource(UriTemplate = "docs://{path}"), Description("ドキュメントの内容を返す")]
    public static string GetDoc(string path) => File.ReadAllText($"./docs/{path}");
}
 
[McpServerPromptType]
public static class ReviewPrompt
{
    [McpServerPrompt, Description("コードレビュー用のプロンプトを生成する")]
    public static string CodeReview(string language) =>
        $"次の{language}コードをレビューし、バグとスタイルの問題を指摘してください。";
}

Resourceは UriTemplate でクライアント側の参照方法(docs://xxx のようなURI)を宣言します。ToolはLLMの判断で呼び出される実行可能な処理です。これに対しResourceはクライアントが明示的にURIを指定して読みにいくデータで、Promptは引数を渡してテキストを組み立てるテンプレートです。

このサンプルの GetDoc は、path をそのままファイルパスに連結しています。../ を含む値が渡されれば ./docs の外も読めてしまうので、実運用では許可するディレクトリを限定する検証が要ります。

まとめ

stdioで始めるなら、ModelContextProtocol を追加して [McpServerToolType] / [McpServerTool] のクラスを1つ書きます。あとは claude mcp add -- dotnet run --project ... で登録すれば最短で動きます。複数クライアントを受けるHTTPでは、ステートレスにするか(サーバー起点の通知を諦めるか)を最初に決め、AllowedHosts でホスト名を限定します。

この記事を共有:XはてブLinkedIn
MCP をもっと見る →