1. はじめに
Anthropicが発表したオープン規格 「MCP (Model Context Protocol)」 が急速に注目を集めています。AIモデルに対してデータベース、ファイルシステム、ローカルアプリなどの「手足(ツール)」を標準規格で接続できるプロトコルです。
しかし、世の中のMCP実装例や解説記事を見ると、その9割以上が TypeScript や Python です。
「Windowsネイティブのデスクトップアプリ(C# / .NET)から、標準入出力(stdio)でMCPサーバーと対話し、ローカルアプリを自律操作するAIエージェントを作りたい」と思った時、日本語の実践情報はほぼ皆無でした。
そこで今回、WinUI 3 (C# / .NET 8) アプリケーションから外部のデスクトップツール(表計算、画像編集、ワープロ等)を直接操作するC#版MCPクライアントエンジンを自作しました。
本記事では、C#でMCPクライアントを構築するアーキテクチャ、JSON-RPC 2.0の双方向パイプ処理、そして 「実際に運用して初めて直面した泥臭いハマりどころ(LLMのプロトコル誤認問題)」 とその解決策をコード付きで解説します。
2. MCP (stdio) 通信のアーキテクチャ
MCPにはHTTP/SSE接続と標準入出力(stdio)接続の2種類がありますが、デスクトップ環境でローカルCLIやアプリを安全・高速に叩くには stdio (標準入出力) によるIPC(プロセス間通信) が最も適しています。
プロセスの起動と非同期パイプの確立
C#の ProcessStartInfo を使い、入出力をUTF-8でリダイレクトしてプロセスを起動します。
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
public class McpSession : IDisposable
{
public Process Process { get; set; } = null!;
public StreamWriter Stdin { get; set; } = null!;
public StreamReader Stdout { get; set; } = null!;
public SemaphoreSlim Lock { get; } = new(1, 1);
public int RequestId = 0;
public void Dispose()
{
try { if (!Process.HasExited) Process.Kill(true); } catch { }
Process?.Dispose();
Lock?.Dispose();
}
}
public async Task<McpSession?> LaunchMcpAppAsync(string exePath, string arguments, CancellationToken ct)
{
var startInfo = new ProcessStartInfo
{
FileName = exePath,
Arguments = arguments,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardInputEncoding = new UTF8Encoding(false),
StandardOutputEncoding = new UTF8Encoding(false)
};
var process = new Process { StartInfo = startInfo };
process.Start();
var session = new McpSession
{
Process = process,
Stdin = process.StandardInput,
Stdout = process.StandardOutput
};
// 初期化ハンドシェイク (initialize)
var initRequest = new
{
jsonrpc = "2.0",
id = Interlocked.Increment(ref session.RequestId),
method = "initialize",
@params = new
{
protocolVersion = "2024-11-05",
capabilities = new { },
clientInfo = new { name = "WinAIAgent", version = "1.0.0" }
}
};
string? res = await SendJsonRpcAsync(session, JsonSerializer.Serialize(initRequest), ct);
if (string.IsNullOrEmpty(res))
{
session.Dispose();
return null;
}
return session;
}
双方向の非同期読み書きでは、複数のツール呼び出しが重なった際にリクエストとレスポンスが混ざらないよう、SemaphoreSlim による排他制御とタイムアウト保護を設けておくのがポイントです。
3. 外部アプリの機能をAIツールへ動的変換(tools/list)
MCPの最大の強みは、「アプリ側がどんな機能を持っているか(関数一覧や引数のJSON Schema)」を動的に取得できる点です。
ツール一覧の取得とスキーマのサニタイズ
MCPサーバーから tools/list を呼び出し、Google Gemini や OpenAI API が受け付ける Function Declaration 形式へと動的マッピングします。
public async Task<string> GetAppToolsAsync(McpSession session, CancellationToken ct)
{
var request = new
{
jsonrpc = "2.0",
id = Interlocked.Increment(ref session.RequestId),
method = "tools/list",
@params = new { }
};
return await SendJsonRpcAsync(session, JsonSerializer.Serialize(request), ct);
}
【注意点】Gemini API とのスキーマ非互換トラップ
MCPツールが返す inputSchema(Draft-07等のJSON Schema)には、additionalProperties、$schema、配列型の type: ["string", "null"] などが含まれることが多々あります。
これをそのまま Gemini API や一部の LLM に渡すと 400: Invalid JSON Schema で即死します。
そのため、パース時に不要なプロパティを再帰的に除去するサニタイズ層を挟む必要があります。
private static void CleanJsonNode(JsonNode? node)
{
if (node is JsonObject obj)
{
// Gemini API で未サポートのスキーマプロパティを除去
obj.Remove("additionalProperties");
obj.Remove("$schema");
obj.Remove("$id");
// 型定義が配列の場合、単一型に正規化
if (obj.TryGetPropertyValue("type", out var typeNode) && typeNode is JsonArray typeArr)
{
string chosenType = "string";
foreach (var item in typeArr)
{
string? t = item?.GetValue<string>();
if (!string.IsNullOrEmpty(t) && t != "null") { chosenType = t; break; }
}
obj["type"] = chosenType;
}
foreach (var key in obj.Select(kv => kv.Key).ToList())
{
CleanJsonNode(obj[key]);
}
}
}
4. 複数MCPアプリのオーケストレーション(名前空間の分離)
さらに一歩進めて、「画像加工アプリ(PhotoCraft)でロゴを作って、表計算アプリ(GridCraft)の見積書に貼り付ける」といった複数アプリをまたぐ複合連携を実現しました。
ここでの課題は 「ツール名の衝突」 です(どちらのアプリにも save や export といった同名ツールが存在する)。
プレフィックスによる動的ルーティング
AIにツール一覧を渡す際、先頭に {appId}__ を付与して展開します。
photocraft__crop_image
photocraft__save
gridcraft__write_range
gridcraft__save_workbook
ツールが呼び出された際、プレフィックスを分解して対象のMCPプロセスへ透過ディスパッチします。
private async Task<string> DispatchToolAsync(string functionName, JsonElement args, CancellationToken ct)
{
string targetAppId = _defaultAppId;
string realFuncName = functionName;
int separatorIdx = functionName.IndexOf("__", StringComparison.Ordinal);
if (separatorIdx > 0)
{
targetAppId = functionName.Substring(0, separatorIdx);
realFuncName = functionName.Substring(separatorIdx + 2);
}
// 対象のMCPプロセスへ中継
return await _mcpClient.CallToolAsync(targetAppId, realFuncName, args, ct);
}
アプリ間のデータ受け渡しは、プロジェクトの成果物フォルダーをハブとした「ファイルベース・パイプライン」で行うことで、メモリ共有なしで安全なリレーを実現しています。
5. 完成品をMicrosoft Storeで公開しました
今回解説したC#版MCPクライアント基盤、二段階プランニング自律ループ、OllamaローカルLLM連携、セーフティリミッターなどをすべて統合し、実際に商用アプリとして完成させたものが 「WinAIAgent」 です。
Windows 11 に調和する Mica / Fluent Design で構築しており、現在 Microsoft Store にて公開しています。
【主な機能】
・MCP対応デスクトップアプリ(表計算、画像、ワープロ等)の自律操作
・複数MCPアプリを横断して成果物を仕上げる複合連携(オーケストレーション)
・Google Gemini & 完全オフラインのローカルLLM(Ollama)デュアル対応
・実行の中断・一時停止と、追加入力を添えたシームレスな「続きを実行」
・15RPMレート制限自動回避、履歴圧縮(スライディング窓)、同一エラー検知
WinAIAgent:Windows 用 AIエージェント
https://apps.microsoft.com/detail/9N3G0QRFWS6M
6. まとめ
MCP(Model Context Protocol)はWebやクラウドサービスだけでなく、「ローカルPC上のデスクトップアプリケーションをAIに操作させる」ためのプロトコルとしても圧倒的なポテンシャルを秘めています。
C# / .NET 環境からでも、
-
Processによる双方向 UTF-8 stdio パイプの確立 - JSON Schema の動的サニタイズ
- JSON-RPC 通信ノイズの剥ぎ取り層(
UnpackToolResult) -
{appId}__による名前空間分離ルーティング
を適切に設計することで、非常に堅牢で汎用的な自律AIエージェントを構築できます。
デスクトップ自動化や自作エージェント開発に挑戦している方の参考になれば幸いです!