11 KiB
MCP SDK 自研实现方案
1. 为什么自研
社区目前没有成熟的 .NET MCP SDK。检索 NuGet.org 和相关 GitHub 仓库后,现状如下:
- 微软官方尚未发布
Microsoft.Extensions.Mcp正式包(截至 2026 年初仍在 preview 阶段,API 不稳定) - 社区的几个实验性实现覆盖场景有限,停留在 MCP 协议的早期版本,不支持 Streamable HTTP Transport
- 没有任何包支持原生 AOT 编译,大量依赖运行时反射
自研的核心收益:
- 协议版本精确对齐,与 TypeScript SDK 的行为保持一致,减少互操作问题
- 传输层可插拔,在测试环境使用 InProcessTransport,生产环境使用 StdioTransport 或 SseTransport
- AOT 友好,所有序列化走
JsonSerializerContext,无运行时反射 - 依赖最小化,不引入不受控的第三方包,安全审查更简单
2. JSON-RPC 2.0 协议基础
MCP 基于 JSON-RPC 2.0 协议。三种消息类型构成所有交互:
请求 (Request)
客户端发起,期待服务端响应。必须携带 id,方法名决定语义:
{
"jsonrpc": "2.0",
"id": "req-001",
"method": "tools/call",
"params": {
"name": "read_file",
"arguments": { "path": "/tmp/foo.txt" }
}
}
响应 (Response)
服务端对请求的回复,id 必须与请求一致。成功用 result,失败用 error:
{
"jsonrpc": "2.0",
"id": "req-001",
"result": {
"content": [{ "type": "text", "text": "file contents here" }]
}
}
通知 (Notification)
单向消息,不携带 id,无需响应:
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
}
对应的 C# 模型:
public record JsonRpcRequest(
string JsonRpc,
string Id,
string Method,
JsonElement? Params
);
public record JsonRpcResponse(
string JsonRpc,
string Id,
JsonElement? Result,
JsonRpcError? Error
);
public record JsonRpcNotification(
string JsonRpc,
string Method,
JsonElement? Params
);
public record JsonRpcError(int Code, string Message, JsonElement? Data);
3. 传输层实现
传输层负责消息的发送和接收,与协议逻辑解耦。所有传输层实现同一接口:
public interface ITransport : IAsyncDisposable
{
Task<JsonRpcMessage> ReadMessageAsync(CancellationToken ct);
Task SendMessageAsync(JsonRpcMessage message, CancellationToken ct);
Task ConnectAsync(CancellationToken ct);
Task CloseAsync(CancellationToken ct);
}
StdioTransport
通过子进程的 stdin/stdout 通信,是 MCP 最常见的本地工具接入方式:
public sealed class StdioTransport : ITransport
{
private readonly Process _process;
private readonly Channel<JsonRpcMessage> _inbound =
Channel.CreateUnbounded<JsonRpcMessage>();
public async Task ConnectAsync(CancellationToken ct)
{
_process.Start();
// 启动后台读取循环,将 stdout 行解析为 JsonRpcMessage 写入 Channel
_ = Task.Run(() => ReadLoopAsync(ct), ct);
}
public async Task<JsonRpcMessage> ReadMessageAsync(CancellationToken ct)
=> await _inbound.Reader.ReadAsync(ct);
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken ct)
{
var json = JsonSerializer.Serialize(message, FreeCodeJsonContext.Default.JsonRpcMessage);
await _process.StandardInput.WriteLineAsync(json.AsMemory(), ct);
await _process.StandardInput.FlushAsync(ct);
}
}
每行 JSON 对应一条消息,严格遵循 MCP 规范的 newline-delimited JSON 格式。
SseTransport
服务端通过 HTTP Server-Sent Events 推送消息,客户端通过 HTTP POST 发送:
public sealed class SseTransport : ITransport
{
private readonly HttpClient _http;
private readonly Uri _sseEndpoint;
private readonly Uri _postEndpoint;
public async Task ConnectAsync(CancellationToken ct)
{
var response = await _http.GetAsync(
_sseEndpoint,
HttpCompletionOption.ResponseHeadersRead,
ct);
// 以流方式持续读取 SSE 事件行
_ = Task.Run(() => ReadSseLoopAsync(response, ct), ct);
}
}
StreamableHttpTransport
MCP 协议 2025-03-26 版本引入的新传输方式,支持单次 HTTP 请求内的双向流式通信:
public sealed class StreamableHttpTransport : ITransport
{
// 使用 HttpClient 发送请求,响应体作为 NDJSON 流持续读取
// 请求体也可以是 NDJSON 流(客户端到服务端)
// 通过 Content-Type: application/jsonl 区分
}
WebSocketTransport
适用于需要长连接和低延迟的场景:
public sealed class WebSocketTransport : ITransport
{
private readonly ClientWebSocket _ws = new();
public async Task ConnectAsync(CancellationToken ct)
{
await _ws.ConnectAsync(_uri, ct);
_ = Task.Run(() => ReceiveLoopAsync(ct), ct);
}
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken ct)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(
message, FreeCodeJsonContext.Default.JsonRpcMessage);
await _ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
}
}
InProcessTransport
测试和嵌入场景专用,通过一对 Channel<JsonRpcMessage> 模拟双向通信,无网络开销:
public sealed class InProcessTransport : ITransport
{
private readonly Channel<JsonRpcMessage> _clientToServer;
private readonly Channel<JsonRpcMessage> _serverToClient;
// 客户端和服务端各持有一端的 Reader/Writer
// 完全同步,deterministic,适合单元测试
}
4. 客户端生命周期
MCP 客户端从连接到关闭经历五个阶段:
connect → initialize → initialized → [tool calls / resource reads] → shutdown
阶段一:connect
传输层建立物理连接(进程启动、HTTP 连接、WebSocket 握手)。
阶段二:initialize
客户端发送 initialize 请求,声明协议版本和能力:
{
"jsonrpc": "2.0",
"id": "init-1",
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"clientInfo": { "name": "FreeCode", "version": "0.1.0" },
"capabilities": { "roots": { "listChanged": true } }
}
}
阶段三:initialized
服务端响应 initialize,返回其支持的能力和协议版本后,客户端发送 notifications/initialized 通知,握手完成。
阶段四:工具调用
握手完成后,客户端可以发起任意 tools/call、resources/read、prompts/get 等请求。McpClient 内部维护一个 pending request 字典,通过 id 匹配请求和响应:
public async Task<CallToolResult> CallToolAsync(
string toolName,
IReadOnlyDictionary<string, JsonElement> arguments,
CancellationToken ct)
{
var id = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<JsonRpcResponse>();
_pending[id] = tcs;
await _transport.SendMessageAsync(new JsonRpcRequest(
"2.0", id, "tools/call",
BuildParams(toolName, arguments)), ct);
var response = await tcs.Task.WaitAsync(ct);
return ParseCallToolResult(response);
}
阶段五:shutdown
客户端发送 shutdown 请求,等待服务端 null 响应后发送 exit 通知,最后关闭传输层。
5. MCP 工具适配为 ITool 接口
McpToolWrapper 将 MCP 服务端暴露的工具包装为 FreeCode 的 ITool 接口,使得 MCP 工具和本地工具在 Engine 层完全透明:
public sealed class McpToolWrapper : ITool
{
private readonly McpClient _client;
private readonly McpToolDefinition _definition;
public string Name => $"mcp_{_client.ServerId}_{_definition.Name}";
public string Description => _definition.Description ?? string.Empty;
public JsonElement InputSchema => _definition.InputSchema;
public async Task<ToolResult> ExecuteAsync(
JsonElement input,
CancellationToken ct)
{
var args = ParseArguments(input);
var result = await _client.CallToolAsync(_definition.Name, args, ct);
return new ToolResult(
Success: !result.IsError,
Content: result.Content.Select(MapContent).ToList());
}
}
McpManager 在启动时连接所有配置的 MCP 服务器,拉取工具列表,为每个工具创建 McpToolWrapper 并注册到 ToolRegistry。Engine 层通过 ToolRegistry 统一调度,无需感知来源。
6. 与 TypeScript SDK 对比
| 特性 | @modelcontextprotocol/sdk (TS) | FreeCode.Mcp (C# 自研) |
|---|---|---|
| 传输层 | Stdio、SSE、StreamableHttp | Stdio、SSE、StreamableHttp、WebSocket、InProcess |
| 序列化 | Zod 运行时验证 | System.Text.Json + Source Generator |
| AOT 支持 | 不适用 | 完全支持 |
| 异步模型 | Promise / async-await | ValueTask / Channel<T> |
| 协议版本 | 跟随官方 | 对齐 2025-03-26 |
| 测试支持 | Jest mock | InProcessTransport |
| 服务端实现 | 完整 | 计划中(v0.3) |
TypeScript SDK 使用 Zod schema 自动验证所有传入和传出消息。C# 自研版本通过 JsonSerializerContext 反序列化时的类型约束和 FluentValidation 检查实现等效的安全性,且无运行时反射开销。
7. 关键设计决策
Channel<T> 用于异步消息传递
传输层的读取循环与业务调用层之间通过 Channel<JsonRpcMessage> 解耦。读取循环是一个长期运行的后台任务,不阻塞调用线程。Channel<T> 支持背压(有界通道)和取消,比 BlockingCollection<T> 更适合全异步场景。
不可变 Record 表示协议状态
所有 JSON-RPC 消息和 MCP 协议模型都定义为 C# record,天然不可变。消息一旦构建就不会被修改,消除了多线程读写竞争的可能,也使测试断言更简单。
id 用字符串而不是整数
JSON-RPC 2.0 允许 id 为字符串或整数。FreeCode.Mcp 统一使用 Guid.NewGuid().ToString("N") 生成字符串 id,避免高并发下的整数碰撞,也方便日志追踪。
传输层不感知协议
ITransport 只负责原始 JsonRpcMessage 的收发,不理解 initialize、tools/call 等语义。协议逻辑完全在 McpClient 和 McpServer 中,传输层可以随时替换,不影响上层逻辑。这个边界在集成测试中尤其有价值:用 InProcessTransport 替换 StdioTransport,测试速度从秒级降到毫秒级。