347 lines
11 KiB
Markdown
347 lines
11 KiB
Markdown
# MCP SDK 自研实现方案
|
||
|
||
> 关联文档:[总体概述](../总体概述与技术选型.md) | [MCP协议集成](../../基础设施设计/基础设施设计-MCP协议集成.md)
|
||
|
||
---
|
||
|
||
## 1. 为什么自研
|
||
|
||
社区目前没有成熟的 .NET MCP SDK。检索 NuGet.org 和相关 GitHub 仓库后,现状如下:
|
||
|
||
- 微软官方尚未发布 `Microsoft.Extensions.Mcp` 正式包(截至 2026 年初仍在 preview 阶段,API 不稳定)
|
||
- 社区的几个实验性实现覆盖场景有限,停留在 MCP 协议的早期版本,不支持 Streamable HTTP Transport
|
||
- 没有任何包支持原生 AOT 编译,大量依赖运行时反射
|
||
|
||
自研的核心收益:
|
||
1. **协议版本精确对齐**,与 TypeScript SDK 的行为保持一致,减少互操作问题
|
||
2. **传输层可插拔**,在测试环境使用 InProcessTransport,生产环境使用 StdioTransport 或 SseTransport
|
||
3. **AOT 友好**,所有序列化走 `JsonSerializerContext`,无运行时反射
|
||
4. **依赖最小化**,不引入不受控的第三方包,安全审查更简单
|
||
|
||
---
|
||
|
||
## 2. JSON-RPC 2.0 协议基础
|
||
|
||
MCP 基于 JSON-RPC 2.0 协议。三种消息类型构成所有交互:
|
||
|
||
**请求 (Request)**
|
||
|
||
客户端发起,期待服务端响应。必须携带 `id`,方法名决定语义:
|
||
|
||
```json
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": "req-001",
|
||
"method": "tools/call",
|
||
"params": {
|
||
"name": "read_file",
|
||
"arguments": { "path": "/tmp/foo.txt" }
|
||
}
|
||
}
|
||
```
|
||
|
||
**响应 (Response)**
|
||
|
||
服务端对请求的回复,`id` 必须与请求一致。成功用 `result`,失败用 `error`:
|
||
|
||
```json
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": "req-001",
|
||
"result": {
|
||
"content": [{ "type": "text", "text": "file contents here" }]
|
||
}
|
||
}
|
||
```
|
||
|
||
**通知 (Notification)**
|
||
|
||
单向消息,不携带 `id`,无需响应:
|
||
|
||
```json
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"method": "notifications/tools/list_changed"
|
||
}
|
||
```
|
||
|
||
对应的 C# 模型:
|
||
|
||
```csharp
|
||
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. 传输层实现
|
||
|
||
传输层负责消息的发送和接收,与协议逻辑解耦。所有传输层实现同一接口:
|
||
|
||
```csharp
|
||
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 最常见的本地工具接入方式:
|
||
|
||
```csharp
|
||
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 发送:
|
||
|
||
```csharp
|
||
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 请求内的双向流式通信:
|
||
|
||
```csharp
|
||
public sealed class StreamableHttpTransport : ITransport
|
||
{
|
||
// 使用 HttpClient 发送请求,响应体作为 NDJSON 流持续读取
|
||
// 请求体也可以是 NDJSON 流(客户端到服务端)
|
||
// 通过 Content-Type: application/jsonl 区分
|
||
}
|
||
```
|
||
|
||
### WebSocketTransport
|
||
|
||
适用于需要长连接和低延迟的场景:
|
||
|
||
```csharp
|
||
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>` 模拟双向通信,无网络开销:
|
||
|
||
```csharp
|
||
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` 请求,声明协议版本和能力:
|
||
|
||
```json
|
||
{
|
||
"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` 匹配请求和响应:
|
||
|
||
```csharp
|
||
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 层完全透明:
|
||
|
||
```csharp
|
||
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,测试速度从秒级降到毫秒级。
|