A3. 単一 exe にする

Node.js を入れられない相手へ配る。C# + csc.exe

📅 作成: 2026-09-07 / 更新: 2026-09-07

この資料の内容

  1. なぜ exe にするか
  2. 書く
  3. ビルドする
  4. 動かす
  5. Node 版との違い

なぜ exe にするか

Node.js 版は、渡す相手にも Node.js が要ります。それが難しい場面があります。

場面事情
社内の共有 PCランタイムを勝手に入れられない
相手が開発者でないnpm install を頼めない
版がばらばら入っている Node.js の版が揃わない
起動を速くしたい多数のプロセスが立つとき

.NET Framework 4.x は Windows 10・11 に最初から入っています。そのコンパイラ(csc.exe)も同梱されているので、追加で何も入れずに exe を作れます。

Node.js 版 server.mjs + package.json + node_modules(数十 MB) + Node.js 本体 相手側に用意が要る C# 版 mcp-server.exe 7,680 バイト(実測) これ 1 つ .NET Framework は OS 同梱
この章のサンプルをビルドすると、実測 7,680 バイトの exe が 1 つできる。

MCP は言語に依りません。やることは「標準入出力で改行区切りの JSON をやりとりする」だけです。02 章で手書きしたものを、そのまま C# に移すだけで動きます。

書く

全文は samples/A3-csharp/Server.cs にあります。要点だけ見ます。

文字コードを間違えない

// BOM を付けない UTF-8 にする。BOM を出すと相手が JSON として読めない
var utf8 = new UTF8Encoding(false);
var input = new StreamReader(Console.OpenStandardInput(), utf8);
_out = new StreamWriter(Console.OpenStandardOutput(), utf8);
_out.AutoFlush = true;

ここが最大の落とし穴です。new UTF8Encoding(true) や既定の設定だと、最初の出力に BOM(EF BB BF)が付きます。相手はそれを JSON の一部として読もうとして失敗します。しかも 1 通目だけが壊れるので、原因に気づきにくくなります。

AutoFlush = true も要ります。付けないと応答が溜まったまま送られず、相手は待ち続けます。

JSON を扱う

.NET Framework には JavaScriptSerializer が入っています。参照を 1 つ足すだけで使えます。

using System.Web.Script.Serialization;

static readonly JavaScriptSerializer Json = new JavaScriptSerializer();

// 読む: Dictionary<string, object> になる
var request = (Dictionary<string, object>)Json.DeserializeObject(line);

// 書く
_out.Write(Json.Serialize(message));

受け取って返す

string line;
while ((line = input.ReadLine()) != null)
{
	if (line.Trim().Length == 0) continue;
	Log("受信: " + line);

	Dictionary<string, object> request;
	try
	{
		request = (Dictionary<string, object>)Json.DeserializeObject(line);
	}
	catch
	{
		Log("JSON として読めなかった行を捨てた");
		continue;
	}

	// id が無いものは通知。応答してはいけない
	if (!request.ContainsKey("id"))
	{
		Log("通知: " + Get(request, "method"));
		continue;
	}

	Handle(request);
}

ログは stderr へ出します。Node 版と同じ約束です。

// stdout は JSON-RPC 専用。ログは stderr へ出す
static void Log(string message)
{
	Console.Error.WriteLine("[csharp] " + message);
}
JavaScript rl.on('line', ...) JSON.parse(line) process.stdout.write(...) console.error(...) C# input.ReadLine() Json.DeserializeObject(line) _out.Write(...) Console.Error.WriteLine(...)
1 対 1 で移せる。プロトコルの側に言語の都合は無い。

ビルドする

"%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe" /nologo /optimize /utf8output ^
	/out:mcp-server.exe /r:System.Web.Extensions.dll Server.cs
オプション意味
/optimize最適化する
/utf8outputコンパイラのメッセージを UTF-8 で出す。日本語のエラーが化けない
/out:できる exe の名前
/r:参照する DLL。JavaScriptSerializer に要る

ダブルクリックで作れるよう build.cmd を置いてあります。64 ビット版が無ければ 32 ビット版を探すようにしています。

set CSC=%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe
if not exist "%CSC%" set CSC=%WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe
if not exist "%CSC%" (
	echo csc.exe が見つかりません。.NET Framework 4.x が入っているか確認してください
	pause
	exit /b 1
)

実行するとこうなります。

できた: mcp-server.exe (7,680 バイト)
Server.cs 1 ファイル csc.exe OS に最初から入っている 追加のインストール不要 mcp-server.exe 7,680 バイト ビルド環境の用意が要らないのが、この方法の値打ち
SDK も NuGet もパッケージマネージャも使わない。ソース 1 枚とコンパイラだけ。

動かす

@(
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"手","version":"1.0"}}}'
'{"jsonrpc":"2.0","method":"notifications/initialized"}'
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}'
) -join "`n" | ./mcp-server.exe

実際の出力です。

[csharp] 起動した
[csharp] 受信: {"jsonrpc":"2.0","id":1,"method":"initialize",...}
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"csharp","version":"1.0.0"}}}
[csharp] 受信: {"jsonrpc":"2.0","method":"notifications/initialized"}
[csharp] 通知: notifications/initialized
[csharp] 受信: {"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"add","description":"2 つの数を足す",...}]}}
[csharp] 受信: {"jsonrpc":"2.0","id":3,"method":"tools/call",...}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"5"}]}}

登録する

claude mcp add csharp -- docs/samples/A3-csharp/mcp-server.exe

コマンドが exe になるだけで、あとは Node 版と同じです。

テストも同じ道具で書ける

クライアント側は Node のままでよいので、Node 版と同じテストの仕組みが使えます。

test('C# 版でも日本語が壊れない', { skip }, async () => {
	const client = await connect(EXE, { command: EXE });

	try {
		// BOM を付けずに UTF-8 で書き出しているかの確認でもある
		const { tools } = await client.listTools();
		assert.equal(tools[0].description, '2 つの数を足す');

		const result = await client.callTool({ name: 'add', arguments: { a: 'あ', b: 3 } });
		assert.equal(result.isError, true);
		assert.match(textOf(result), /数値を渡してください/);
	} finally {
		await client.close();
	}
});
✔ C# 版でもツールの一覧と実行が通る (128.4249ms)
✔ C# 版でも日本語が壊れない (104.3658ms)
✔ C# 版でも小数を扱える (101.7517ms)
テスト(Node) 1 つで両方を検証できる server.mjs(Node) mcp-server.exe(C#) 同じ応答を返すことを、同じテストで確かめられる
2 つの実装を並行して持つときも、検証は 1 か所で済む。

Node 版との違い

項目Node + SDKC# + csc.exe
配るものソース + node_modules + ランタイムexe 1 つ
大きさ数十 MB7,680 バイト(実測)
入力の検証SDK が zod で行う自分で書く
JSON-RPC の組み立てSDK が行う自分で書く
HTTP に載せる差し替えるだけ自分で書く
タスク・通知SDK に用意がある自分で書く
仕様の追随SDK の更新に乗れる自分で追う
直しやすさ再起動だけビルドし直して配り直す
配りやすさ 作りやすさ Node + SDK 機能は全部使える。配るのが重い C# + csc.exe 配るのは楽。全部自分で書く
どちらが上ではなく、何を優先するかの選択。

選び方

状況お勧め
自分と同僚が使うNode + SDK。作るのが速い
開発者でない相手に配るC# + csc.exe
ツールが 1〜2 個だけC# でも負担は小さい
通知やタスクを使うNode + SDK。自前で書くと大仕事になる
両方要る中身を分けておき、入り口だけ 2 つ作る

まず Node + SDK で作り、必要になってから移すのが現実的です。プロトコルは同じなので、動くものができてからの移植は難しくありません。最初から C# で書くと、仕様の理解と実装の両方を同時にやることになります。