.NETで同じ処理をCLIにもMCPにも出したくなったので、source generatorで全部生成することにした
はじめに
MCP が出てきて盛り上がったと思ったら、最近では CLI 回帰の流れも強くなってきました。結局のところ、ローカルで手早く叩ける CLI と、エディタや AI エージェントから呼べる MCP、どちらも必要になる場面は多いです。
そこで、1 つの定義から CLI も MCP も両方生成できる .NET 基盤を作ってみました。
ただ作るだけじゃ面白くないので、自分なりに極限まで処理速度にこだわっています。もちろん Zero Allocation です。
この記事では、Manifold というこのライブラリの設計と使い方に加え、どのようにパフォーマンスを詰めていったかを具体的に書きます。
なぜ作ったのか
実際のプロダクトでは、同じ operation を複数の公開先から叩きたくなります。
- ローカルでは CLI から叩く
- エディタや agent からは MCP tool として呼ぶ
- 将来的には別の公開先にも出したい
このとき素朴に実装すると、以下が 別々に 増えていきます。
- operation 本体
- CLI の command / option / argument 定義
- MCP の tool 名 / parameter schema / metadata
- dispatch / invoker
- registry
つまり、本質的には同じ機能なのに、公開先ごとにつなぎ込みが重複するわけです。
そうすると何が起きるかというと、
- 片方だけ更新漏れする
- metadata がずれる
- リネーム時の追従漏れが出る
- パフォーマンスの都合で特殊対応が増え、さらに複雑になる
Manifold は、この問題を 「operation の定義を唯一の定義元にして、公開先は generator で出す」 形に寄せることで解決します。
Manifold の位置づけ
具体的には、次のような用途に向いています。
- internal tooling 用の CLI
- MCP host の tool 層
- 同じ機能を CLI と MCP の両方に出したいプロダクト
- source generator で繰り返しのつなぎ込みを消したい .NET ライブラリ
逆に、以下を直接提供するものではありません。
- stdio loop そのもの
- Streamable HTTP サーバーそのもの
- 完成済みの product host
パッケージは 4 つに分かれています。
| パッケージ | 役割 |
|---|---|
Manifold |
コア契約、descriptor、attribute、binding 基盤 |
Manifold.Generators |
source generator(registry / invoker を生成) |
Manifold.Cli |
CLI 向けの実行・binding |
Manifold.Mcp |
MCP 向けの実行・binding |
CLI だけ使う人に MCP の runtime を持ち込ませたくないし、その逆も同じなので、この分割にしています。
使い方
パッケージの組み合わせ
| 用途 | パッケージ |
|---|---|
| operation 定義だけ |
Manifold + Manifold.Generators
|
| CLI アプリ |
Manifold + Manifold.Generators + Manifold.Cli
|
| MCP host |
Manifold + Manifold.Generators + Manifold.Mcp
|
| CLI + MCP 両方 |
Manifold + Manifold.Generators + Manifold.Cli + Manifold.Mcp
|
<ItemGroup>
<PackageReference Include="Manifold" Version="1.0.0" />
<PackageReference Include="Manifold.Generators" Version="1.0.0" PrivateAssets="all" />
<PackageReference Include="Manifold.Cli" Version="1.0.0" />
<PackageReference Include="Manifold.Mcp" Version="1.0.0" />
</ItemGroup>
static method で operation を定義する
最小構成はこの形です。static method の場合、DI 登録は不要です。
using Manifold;
public static class SampleOperations
{
[Operation("math.add", Description = "Add two integers.",
Summary = "Returns the sum of x and y.")]
[CliCommand("math", "add")]
[McpTool("math_add")]
public static int Add(
[Argument(0, Name = "x", Description = "Left operand")] int x,
[Argument(1, Name = "y", Description = "Right operand")] int y)
{
return x + y;
}
}
手書きするのは operation と parameter の定義だけです。[CliCommand] と [McpTool] で「どの公開先に出すか」を attribute で宣言します。
class-based operation
DI と組み合わせたい場合は、IOperation<TRequest, TResult> を実装するクラス形式を使います。この場合は DI コンテナへの登録が必要 です。
[Operation("weather.preview", Description = "Return a pretend weather summary.")]
[CliCommand("weather", "preview")]
[McpTool("weather_preview")]
public sealed class WeatherPreviewOperation
: IOperation<WeatherPreviewOperation.Request, string>
{
public ValueTask<string> ExecuteAsync(Request request, OperationContext context)
{
int days = request.Days <= 0 ? 1 : request.Days;
string city = string.IsNullOrWhiteSpace(request.City)
? "unknown" : request.City.Trim();
return ValueTask.FromResult(
$"Forecast for {city}: mild for the next {days} day(s).");
}
public sealed class Request
{
[Option("city", Description = "Target city")]
public string City { get; init; } = string.Empty;
[Option("days", Description = "Number of forecast days")]
public int Days { get; init; } = 3;
}
}
generator が出すもの
上記の定義から、Manifold.Generators が以下を自動生成します。
| 生成物 | 役割 |
|---|---|
GeneratedOperationRegistry |
全 operation のメタデータ一覧 |
GeneratedCliInvoker |
CLI 用の高速 dispatch コード |
GeneratedMcpCatalog |
MCP tools/list 用のメタデータ |
GeneratedMcpInvoker |
MCP tools/call 用の高速 dispatch コード |
たとえば math.add に対して、generator は以下のような CLI invoker を生成します。
// <auto-generated/>
public sealed class GeneratedCliInvoker : global::Manifold.Cli.ICliInvoker, global::Manifold.Cli.IFastCliInvoker
{
public bool TryInvoke(
string operationId,
global::System.Collections.Generic.IReadOnlyDictionary<string, string> options,
global::System.Collections.Generic.IReadOnlyList<string> arguments,
global::System.IServiceProvider? services,
bool jsonRequested,
global::System.Threading.CancellationToken cancellationToken,
out global::System.Threading.Tasks.ValueTask<global::Manifold.Cli.CliInvocationResult> invocation)
{
if (global::System.String.Equals(operationId, "math.add", global::System.StringComparison.Ordinal))
{
invocation = InvokeMathAddAsync(options, arguments, services, cancellationToken);
return true;
}
// ... 他の operation も同様に分岐
invocation = default;
return false;
}
private static async global::System.Threading.Tasks.ValueTask<global::Manifold.Cli.CliInvocationResult> InvokeMathAddAsync(
global::System.Collections.Generic.IReadOnlyDictionary<string, string> options,
global::System.Collections.Generic.IReadOnlyList<string> arguments,
global::System.IServiceProvider? services,
global::System.Threading.CancellationToken cancellationToken)
{
int __uops_x = (int)global::Manifold.Cli.CliBinding.ConvertValue(
typeof(int),
global::Manifold.Cli.CliBinding.GetRequiredArgument(arguments, 0, "x"), "x")!;
int __uops_y = (int)global::Manifold.Cli.CliBinding.ConvertValue(
typeof(int),
global::Manifold.Cli.CliBinding.GetRequiredArgument(arguments, 1, "y"), "y")!;
int result = global::SampleOperations.Add(__uops_x, __uops_y);
string? text = global::Manifold.Cli.CliBinding.FormatDefaultText(result);
return new global::Manifold.Cli.CliInvocationResult(result, typeof(int), text);
}
}
引数の取り出し・型変換・operation の呼び出し・結果のフォーマットまで、すべて generator が手書き不要のコードとして出します。class-based operation の場合は、DI コンテナから operation インスタンスを取得し、Request を組み立てて ExecuteAsync を呼ぶコードが生成されます。
CLI ホストの構築
生成されたコードを使って CLI アプリを組み立てるのは数行です。
using Manifold.Cli;
using Manifold.Generated;
using Microsoft.Extensions.DependencyInjection;
ServiceCollection services = new();
services.AddTransient<WeatherPreviewOperation>(); // class-based operation は DI 登録が必要
IServiceProvider serviceProvider = services.BuildServiceProvider();
CliApplication application = new(
GeneratedOperationRegistry.Operations, // 生成済みの operation 一覧
new GeneratedCliInvoker(), // 生成済みの invoker
serviceProvider);
return await application.ExecuteAsync(args, Console.Out, Console.Error);
これで math add 1 2 や weather preview --city Tokyo のようにコマンドを実行できます。
MCP ホストの構築
MCP 側もほぼ同じ operation から構築できます。
using Manifold.Generated;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<WeatherPreviewOperation>(); // 同じ operation を登録
builder.Services.AddGeneratedMcpServer() // generator が出す拡張メソッド
.WithStdioServerTransport();
IHost host = builder.Build();
await host.RunAsync();
AddGeneratedMcpServer() は generator が自動生成する拡張メソッドで、生成済みの tool 定義を MCP サーバーに登録します。これだけで、同じ math.add が MCP tool math_add として公開されます。
全体の流れ
operation 定義から CLI / MCP の呼び出しまでの流れをまとめます。
手書きの binding や registry を増やす必要はありません。
パフォーマンス
Manifold では、「便利だけど遅い」で終わらないことをかなり重視しました。generator を使うからこそ、不必要な実行時コストを払わない設計にできます。
現時点の benchmark はこうなっています。
CLI
| シナリオ | Manifold | ConsoleAppFramework | System.CommandLine |
|---|---|---|---|
| Positional command | 22.61 ns / 0 B | 26.57 ns / 0 B | 1,730 ns / 4,688 B |
| Option-heavy command | 28.89 ns / 0 B | 24.76 ns / 0 B | 2,110 ns / 5,632 B |
MCP(往復処理)
| シナリオ | Manifold | ModelContextProtocol | McpToolkit | mcpdotnet |
|---|---|---|---|---|
tools/list 相当 |
756.3 ns / 0 B | 754.6 ns / 0 B | 635.4 ns / 0 B | 816.2 ns / 0 B |
tools/call 相当 |
47.16 ns / 0 B | 68.56 ns / 0 B | 146.34 ns / 96 B | 93.20 ns / 256 B |
CLI は ConsoleAppFramework と同レンジで、System.CommandLine よりかなり軽量です。MCP では tools/call で 0 B allocation を達成しています。
ここからは、このパフォーマンスをどのように詰めたかを具体的に書きます。
パフォーマンスチューニングの過程
1. 最初の問題:汎用的な実行時パスが支配的だった
最初の Manifold は設計としてはきれいでしたが、かなり runtime 寄りでした。
- operation の metadata を実行時に読む
- command path や tool name を実行時に解決する
- parse した結果を汎用的な binder で request に流す
- result も汎用的な入れ物に入れて扱う
柔軟で実装しやすい反面、頻繁に通るパス(ホットパス)では毎回の候補探索・コレクション生成・boxing・async state machine というコストが残ります。
最初の問題設定は 「API の柔軟性はあるが、ホットパスが汎用的すぎる」 でした。
CLI 側のチューニング
2. 前計算できる状態はすべて起動時に寄せた
最初に効いたのは、CLI 側の「毎回作り直している状態」を減らすことです。
実際のコードでは、CliApplication のコンストラクタで以下を行っています。
// 先頭トークンごとの候補を FrozenDictionary で保持
private readonly FrozenDictionary<string, CliCommandCandidate[]> commandCandidatesByFirstToken;
// コンストラクタで事前構築
(visibleCliOperations, commandCandidatesByFirstToken) = BuildCliState(operations);
BuildCliState では、operation 一覧から先頭トークンごとに候補を集約し、FrozenDictionary[1] に固めています。これで math add 1 2 が来たとき、"math" で O(1) lookup して候補を絞れます。
消したもの:
- 毎回の LINQ・sort・
HashSet/Dictionary構築
派手な最適化ではないですが、「毎回同じものを作っている」コストを消したのが大きかったです。
3. 汎用 binder から生成済みの高速経路へ
次に効いたのは、「実行時に賢く bind する」から 「generator が最短経路を作る」 方向への転換です。
ここでいう「高速経路」とは、典型的なケースを最短で処理するために generator が事前に出しておく専用のコードパスのことです。汎用的に何でも処理できるパスと対になる概念です。
実際の CliApplication.ExecuteAsync では、まず高速経路を試し、対応できない場合だけ汎用パスに落ちます。
public Task<int> ExecuteAsync(string[] args, TextWriter output, TextWriter error,
CancellationToken cancellationToken = default)
{
// まず高速経路を試す
if (TryExecuteArrayFastPath(args, output, error, cancellationToken, out Task<int>? fastPathTask))
return fastPathTask!;
// 対応できなければ汎用パスへ
return ExecuteSlowPathAsync(args, output, error, cancellationToken);
}
TryExecuteArrayFastPath 内では、生成済みの invoker に command token をそのまま渡して直接 dispatch します。汎用パスのように「parse → dictionary に詰める → operationId で lookup → invoke」という手順を踏みません。
4. 同期パスから async の overhead を外した
CLI の同期 command で余計な async の仕組みを使わない方向に詰めました。
IFastSyncCliInvoker と FastCliInvocationResult を導入し、
- 同期完了する command で
Taskを作らない -
ValueTaskすら通らないパスを持つ -
objectへ詰めない(boxing しない)
ことを実現しました。
FastCliInvocationResult は StructLayout(LayoutKind.Explicit) を使った value union[2] で、結果の型ごとに boxing なしで値を保持します。
// TryExecuteArrayFastPath 内での同期高速経路
if (fastSyncCliInvoker is not null &&
fastSyncCliInvoker.TryInvokeFastSync(commandTokens, services, cancellationToken,
out FastCliInvocationResult syncInvocation))
{
// Task も ValueTask も作らずに結果を書き出す
execution = WriteFastResult(output, syncInvocation);
return true;
}
WriteFastResult では FastCliInvocationResult.Kind で分岐し、int なら ISpanFormattable 経由で stackalloc バッファに直接書き出します。object への boxing は一切起きません。
5. 高速経路が実際には有効になっていなかった
これは重要な発見でした。
設計上は高速経路があるのに、benchmark の改善が鈍い時期がありました。原因を詰めると、generator の高速経路が primitive alias をうまく拾えていなかったことが分かりました。
int・bool・string のような典型的なケースが、想定していた高速経路に乗らず、裏で汎用パスを通っていたのです。
この修正が入ったタイミングで CLI は一気に改善しました。
6. option が多いパスの高速化
positional なパスは比較的早く詰まりましたが、option が多いパスはしばらく改善が残りました。
原因は option の処理が汎用的すぎたこと。--name value と --name=value を同じ汎用 parser で扱い、いったんコレクションに集めてから解釈していました。
generator 側で、option 名の先頭文字をビット演算で小文字化し、switch で振り分けるコードを生成するようにしました。
// 生成コード(イメージ): option の先頭文字で候補を絞る
char optionDiscriminator = current.Length > 2
? (char)(current[2] | (char)0x20) // ビット OR で小文字化
: '\0';
switch (optionDiscriminator)
{
case 'c': // --city など
// ...
case 'd': // --days など
// ...
}
全 option を順に比較するのではなく、先頭文字ベースで候補を絞ることで、option が多いパスも 0 B のままかなり短くなりました。
MCP 側のチューニング
7. MCP の探索を生成済み catalog に寄せた
MCP 側で最初に効いたのは tool の探索です。
tool catalog を実行時に組み立てるのをやめ、generator から GeneratedMcpCatalog を出す形にしました。
- metadata の探索を静的化
- reflection 的な lookup を排除
- allocation をなくす
MCP では tools/list の metadata パスが地味に効くので、ここを生成済みにした意義は大きいです。
8. MCP invocation の生成済み dispatch
MCP の invocation で次のボトルネックになったのは、「tool name と args から operation へ辿るまで」の汎用処理でした。
generator が switch(toolName) で直接振り分けるコードを出すようにしました。
// 生成コード: tool name での直接 switch
switch (toolName)
{
case "math_add":
invocation = InvokeMathAddFastSync(arguments, services, cancellationToken);
return true;
case "weather_preview":
invocation = InvokeWeatherPreviewFastSync(arguments, services, cancellationToken);
return true;
default:
invocation = default;
return false;
}
string.Equals の直列比較から switch に変えたことで、tool 数が増えてもスケールします。JsonElement からの引数取り出しも 1 回だけで済むよう改善しました。
9. MCP の結果型から boxing を排除した
結果を汎用的に object で扱うパスが残っていると allocation を消し切れません。
CLI 側と同じ設計で FastMcpInvocationResult を導入し、union 風の struct で結果を保持します。MCP 側には Structured(任意の型 + Type 情報)が追加されており、複雑な戻り値にも対応しています。
10. レスポンス構築を raw JSON writer に寄せた
MCP では invocation が速くても、レスポンス構築で無駄を出すと意味がありません。
McpTextContentResponseWriter では、MCP レスポンスの固定部分を UTF-8 literal として持ち、IBufferWriter<byte> に直接書き込みます。
// レスポンスの固定部分を UTF-8 literal で事前定義
private static ReadOnlySpan<byte> ResponsePrefix
=> "{\"isError\":false,\"content\":[{\"type\":\"text\",\"text\":\""u8;
private static ReadOnlySpan<byte> ResponseSuffix
=> "\"}]}"u8;
// int 結果の書き込み: Utf8Formatter で stackalloc バッファに直接フォーマット
private static void WriteInt32(IBufferWriter<byte> writer, int value)
{
WriteUtf8(writer, ResponsePrefix);
Span<byte> destination = writer.GetSpan(11);
Utf8Formatter.TryFormat(value, destination, out int written);
writer.Advance(written);
WriteUtf8(writer, ResponseSuffix);
}
JsonDocument.Parse も Utf8JsonWriter も通らず、文字列の allocation もゼロ。結果として tools/call 相当の往復処理 benchmark で 47.16 ns / 0 B まで下がりました。
11. 試して捨てた最適化
今回のチューニングでは、「速そうに見えるが実際は性能劣化する」案がいくつもありました。
試して捨てた最適化の一覧
-
EnumerateObject()一発で全部拾う binder - primitive parser の無理なインライン展開
- 生成メソッドへの過剰な
AggressiveInlining - raw UTF-8 実験
- null writer 最適化
これらは一時的には筋が良さそうに見えても、
- コードが複雑になる
- 分岐予測が悪くなる
- JIT が期待通りに最適化しない
- 実測で逆に遅くなる
というケースがありました。
チューニングの全体像
パフォーマンスチューニングの流れを図にまとめます。
- 汎用パス → 生成済みの高速経路 — runtime の汎用処理を generator が出す最短経路に置き換えた
-
同期パスから async/boxing を除去 — 同期 command で不必要な
Taskやobjectを排除した - 探索・invocation・レスポンス構築を個別に詰めた — ボトルネックごとに対処した
- 性能劣化する最適化は捨てた — 実測で勝ったものだけ残した
抽象化を入れれば便利になるが、抽象化のコストを増やさないことが基盤としては最も重要です。
まとめ
- Manifold は、1 つの operation 定義を唯一の定義元にして CLI と MCP を生成する .NET 基盤
- 手書きするのは operation と parameter の定義だけ。binding / registry / invoker は generator が出す
- パフォーマンスでは CLI
22.61 ns / 0 B、MCPtools/call47.16 ns / 0 Bを達成 - その裏には、汎用パス → 生成済み高速経路への切り替え、同期パスの最適化、レスポンス構築の改善がある
- 「便利だけど遅い」で終わらない抽象化を目指した
興味があれば、ぜひリポジトリを見てフィードバックをもらえると嬉しいです。
参考
Discussion