MiniMcp 实现记录:MCP、Cloudflare Access 与三个工具
让 ChatGPT 读取本机的 Program.cs、修改一个方法,再运行 dotnet build,需要把模型的工具调用转换成文件和进程操作。MiniMcp 使用 .NET 10、ASP.NET Core 和 MCP C# SDK 实现这条链路,对外提供 read_file、apply_patch、bash 三个工具;Cloudflare Tunnel 提供公网入口,Cloudflare Access Managed OAuth 处理登录与访问授权。
这个项目同时涉及网络、协议、C# 和操作系统。理解它们的关系,可以沿着一次请求追问:请求怎样到达内网电脑,后端怎样确认访问身份,MCP 消息怎样成为 C# 方法调用,文件修改和命令执行又能提供哪些保证。本文按这个顺序展开,再讨论运行环境、验证与故障定位。
以下以 2026-10-19 的提交 73b380e 为代码快照,使用 MCP C# SDK 2.2.0。协议按 2026-07-28 修订说明,同时保留 2025-11-25 的兼容说明。安装、Dashboard 配置和自启动命令见 MiniMcp 仓库 README,本文侧重实现原理。
1 一次工具调用的完整路线
1.1 用三个工具组合开发操作
MiniMcp 公开三个工具:
| 工具 | 主要参数 | 职责 |
|---|---|---|
read_file | path、start_line、end_line | 读取 UTF-8 文本的指定行,返回行范围、总行数和截断信息 |
apply_patch | patch、dry_run | 按上下文新增、更新、移动或删除文件,支持预演与失败恢复 |
bash | command、workdir、timeout_seconds | 启动一次性 Bash,复用搜索、Git、构建等命令行能力 |
例如,修改一个 C# 方法时,模型先用 read_file 获取原文,再用 apply_patch 表达修改,最后通过 bash 执行 git diff 或 dotnet build。目录浏览可以使用 ls,文本搜索可以使用 rg,服务无需为每一种命令另写一个工具。这些能力仍依赖本机安装的程序及服务进程的 PATH。
整个流程中,模型提交 {"path":"Program.cs"} 这样的工具参数,MiniMcp 校验后调用文件与进程接口,操作系统完成实际的磁盘读写和进程调度。MiniMcp 本身不调用模型 API,无需 OpenAI API Key。
1.2 请求经过哪些层
以读取 Program.cs 为例,业务请求依次经过以下组件:
图中箭头表示业务请求流向。文件内容封装为工具结果后,沿原请求返回客户端,再交给模型处理。隧道连接由本机 cloudflared 主动向 Cloudflare 建立,连接建立方向与外部请求流向不同。
可以把这条链路分成六层:
| 层次 | 组件 | 要解决的问题 |
|---|---|---|
| 模型与工具选择 | ChatGPT | 选择什么工具,传入什么参数,怎样处理结果 |
| 公网与传输 | DNS、HTTPS、Tunnel | 请求怎样找到入口并到达内网服务,传输怎样加密 |
| 身份与准入 | OAuth、Access Policy、JWT | 客户端代表谁访问,是否允许访问,身份断言是否可信 |
| 协议与框架 | HTTP、Kestrel、ASP.NET Core、MCP SDK | 怎样接收消息并分发到 C# 方法 |
| 应用逻辑 | Workspace、Patch、Bash 服务 | 怎样校验路径、修改文本、管理命令执行 |
| 操作系统 | 文件系统、进程、账号权限 | 实际允许访问什么文件、创建和终止什么进程 |
Tunnel 建立网络通道后,请求还要经过身份校验、协议解析和业务参数检查,才能执行工具。
1.3 从请求链路定位源码
服务注册、协议入口和业务逻辑分别位于以下文件:
| 文件 | 职责 |
|---|---|
Program.cs | 监听地址、依赖注入、认证和 MCP 路由 |
Configuration/MiniMcpOptions.cs | 读取配置并检查必填项 |
Security/CloudflareAccessAuthentication.cs | 提取 Access JWT、获取公钥并配置令牌校验 |
Tools/WorkspaceTools.cs | 三个工具的名称、参数、描述与调用入口 |
Services/ProjectWorkspace.cs | 路径检查、文本快照、按行读取和共享修改锁 |
Services/PatchService.cs | 补丁解析、匹配、预演、提交和异常恢复 |
Services/BashRunner.cs | 启动 Bash、读取输出、处理超时与取消 |
Services/ProcessLifetime.cs | Linux 进程组和 Windows Job Object 清理 |
WorkspaceTools 是协议入口,文件与进程逻辑放在独立服务中,因此本地检查可以直接调用业务服务。公网请求则先经过 Cloudflare 的网络入口与身份校验。
2 请求如何到达本机并通过认证
2.1 URL、DNS 与 Tunnel 路由
以下使用示例地址:
https://mcp.example.com/mcp| 部分 | 含义 |
|---|---|
https | 通过 TLS 保护 HTTP 通信 |
mcp.example.com | 需要 DNS 解析的公网主机名 |
隐含的 443 | HTTPS 默认端口 |
/mcp | 交给应用匹配的 HTTP 路径 |
客户端通过 DNS 解析主机名,找到公网入口并建立连接,再通过 HTTP 发送 /mcp 路径。MiniMcp 使用以下代码注册对应的应用路由:
app.MapMcp("/mcp");修改路由后,客户端 URL 的路径也要同步修改。文件工具使用的根目录由 MCP_ROOT 单独指定。
把 Nameserver 改成 Cloudflare 的名称服务器,完成的是 DNS 委派。常见记录中,A 对应 IPv4,AAAA 对应 IPv6,CNAME 表达别名,NS 指定权威名称服务器,TXT 可用于域名验证。
Tunnel 的域名关联通常表现为:
mcp.example.com. CNAME <Tunnel-UUID>.cfargotunnel.com.这条记录把主机名关联到隧道入口,无需公布本机 IP。启用代理后,外部查询通常返回 Cloudflare 边缘地址;连接器是否在线,还要另外检查。Proxied(橙云)表示流量经过代理,DNS Only(灰云)只提供解析,DNS 托管与 Tunnel 可以分别配置。参见 Cloudflare 的 Tunnel DNS 说明。
Dashboard 中各项配置的职责如下:
| 配置 | 控制什么 |
|---|---|
| Nameservers | 域名的权威 DNS 委派 |
| Tunnel | 一个逻辑隧道及其身份 |
| Connector | 正在运行、主动连接 Cloudflare 的 cloudflared 实例 |
| Tunnel Token | Connector 加入指定 Tunnel 的凭据 |
| Published Application | 公网 hostname 到本地源站(origin)的转发关系 |
| Zero Trust Team | 身份与访问策略的配置空间 |
| Identity Provider | 用户通过什么方式登录 |
| Access Policy | 哪些身份可以访问应用 |
| Managed OAuth | 非浏览器客户端怎样获得用户授权的访问令牌 |
2.2 NAT、出站隧道与回环监听
内网电脑主动连接外部服务时,NAT 设备记录地址和端口的转换关系,允许匹配的返回流量进入。公网机器直接连接路由器上的某个端口时,通常没有现成的转发关系;直接部署服务还需要公网可达地址、路由、防火墙与端口转发配合。运营商级 NAT 可能让用户无法控制最外层映射。
Tunnel 让 cloudflared 先主动连接 Cloudflare,再通过已建立的通道传送外部请求:
Cloudflare Tunnel 使用 UDP 7844 上的 QUIC 或 TCP 7844 上的 HTTP/2。具体网络要求见 Tunnel with firewall。
本地 Kestrel 的监听代码是:
options.Listen(IPAddress.Loopback, settings.Port);默认得到 127.0.0.1:8000。处于同一网络环境的 cloudflared 可以访问它,其他电脑无法通过这台机器的局域网 IP 直接访问该端点。容器、WSL 和虚拟机可能有不同的网络环境。
整条链路的传输保护分为三段:
| 网络段 | 传输 | 依赖的保护 |
|---|---|---|
| MCP 客户端 → Cloudflare | HTTPS / TLS | 域名证书验证、传输加密与完整性 |
| Cloudflare → cloudflared | 加密 Tunnel | 隧道传输保护 |
| cloudflared → MiniMcp | 同机回环 HTTP | 回环可达范围与后端认证 |
公网 TLS 在 Cloudflare 边缘终止,本地 Kestrel 因此不必配置这个域名的 HTTPS 证书。Cloudflare 需要处理 HTTP、认证和转发,能够接触应用流量,是这套部署中的受信任中间方。
回环监听只限制网络可达范围。本机进程仍可能连接这个端口,cloudflared 也能把外部请求送进去,所以后端还需要验证访问身份。
2.3 Managed OAuth:让客户端获得访问授权
Authentication 确认身份,Authorization 判断该身份可以访问什么。身份提供方帮助用户登录,Access Policy 根据邮箱等条件决定是否允许进入 MiniMcp。
浏览器可以处理登录跳转,但 MCP 客户端需要机器可识别的授权流程。Managed OAuth 为非浏览器客户端返回 401 和 OAuth 发现信息,用户在浏览器完成登录与授权后,客户端再用访问令牌发起请求。MiniMcp 无需自己实现 OAuth 授权服务器。参见 Cloudflare Managed OAuth。
图中合并了回调与令牌交换步骤。授权码、回调 URI、PKCE、资源标识和令牌更新由相应协议及实现处理;浏览器完成用户登录,MCP 客户端负责后续工具请求。
Access 按策略控制应用准入,所有获准身份共用三个工具和同一个 MCP_ROOT。
2.4 两种令牌与后端 JWT 校验
Managed OAuth 发给客户端的是不透明访问令牌,客户端在请求 Cloudflare 时携带:
Authorization: Bearer <OAuth access token>Cloudflare 将该令牌解析为对应身份,再向源站转发签名断言:
Cf-Access-Jwt-Assertion: <Access signed JWT>客户端持有的访问令牌供 Cloudflare 识别授权,源站收到的 JWT 供 MiniMcp 校验身份。令牌格式见 Managed OAuth 文档。
本项目在 AddJwtBearer 的 OnMessageReceived 中,从请求头提取 Access JWT:
var assertion = context.Request.Headers["Cf-Access-Jwt-Assertion"];
if (assertion.Count != 1 || string.IsNullOrWhiteSpace(assertion[0]))
context.NoResult();
else
context.Token = assertion[0];签名 JWT 通常表现为 Header.Payload.Signature,其声明可以通过 Base64URL 解码读取。要确认 email 等声明可信,后端需要验证签名和以下约束:
| 检查项 | 当前实现 | 解决的问题 |
|---|---|---|
| 签名与算法 | 使用可信公钥,限制为 RS256 | 确认签发方可信、内容未被修改 |
iss(Issuer) | 匹配配置的团队签发者 | 拒绝其他签发者的声明 |
aud(Audience) | 匹配当前 Access 应用 | 拒绝签给其他应用的令牌 |
| 有效期 | 要求过期时间,允许 30 秒时钟偏差 | 限制令牌的时间适用范围 |
CF_ACCESS_TEAM 用来构造 https://<TEAM>.cloudflareaccess.com,CF_ACCESS_AUD 标识预期的 Access 应用,分别用于定位签发者和检查令牌受众。
Cloudflare 用私钥签名,MiniMcp 从团队的 /cdn-cgi/access/certs 获取公钥集合(JWKS),根据 JWT 中的密钥标识 kid 选择对应公钥。实现使用 ConfigurationManager 缓存公钥配置,设置自动与受限刷新,并允许未知 kid 触发更新。验证机制参见 Cloudflare Validate JWTs。
网络入口解决了可达性,Access 和后端校验解决了准入。请求通过这些检查以后,MCP SDK 才能把消息交给具体工具。
3 MCP 请求如何成为 C# 方法调用
3.1 HTTP、JSON-RPC 与 MCP 的分工
到达 http://127.0.0.1:8000/mcp 的请求,仍需经过协议解析。HTTP、JSON-RPC 和 MCP 在这里负责不同的事:
| 概念 | 职责 |
|---|---|
| HTTP | 在客户端和服务器之间传送请求与响应 |
| JSON-RPC 2.0 | 规定方法名、参数、请求 ID、结果与错误的消息格式 |
| MCP | 规定工具发现、调用和上下文交换等交互语义 |
MCP 使用 JSON-RPC 表达消息,本项目通过 HTTP 传输。JSON-RPC 可以用于其他远程调用场景,MCP 也有 HTTP 以外的传输方式。
客户端通过 tools/list 获取工具名称、描述、输入 schema 和行为提示,再通过 tools/call 调用某个工具。以读取 Program.cs 为例,一次 2026-07-28 请求的正文可以是:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "read_file",
"arguments": {
"path": "Program.cs",
"start_line": 1,
"end_line": 35
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}id 用于把响应对应到这次请求。method 是 MCP 协议方法 tools/call,业务工具名位于 params.name,实际参数放在 arguments 中。所有工具共用 /mcp 端点。
_meta 携带协议上下文。通过 HTTP 发送上述正文时,还需要匹配的头:
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: read_file升级请求格式时,要同时处理协议日期、逐请求元数据和 HTTP 头。使用 SDK 客户端时,这些协议字段由 SDK 处理,模型只提供业务参数。具体格式见 MCP Streamable HTTP。
3.2 协议修订与旧版兼容
上面的请求直接携带了版本和客户端能力,这是 2026-07-28 修订的重要变化:
2025-11-25 及以前 | 2026-07-28 | |
|---|---|---|
| 初始化 | initialize 和初始化完成通知 | 移除该握手 |
| 版本与客户端能力 | 在初始化中交换 | 在每个请求的元数据中表达 |
| HTTP 协议会话 | 可以使用 session ID | 移除 MCP-Session-Id 机制 |
| 版本与能力查询 | 通过旧版初始化流程 | 可以先调用 server/discover |
server/discover 是可选查询,新版允许客户端直接发送工具请求。服务器根据每个请求独立接受或拒绝其协议版本。每次请求必须在 _meta 中包含 io.modelcontextprotocol/protocolVersion 和 io.modelcontextprotocol/clientCapabilities,客户端名称与版本可另由 clientInfo 表达。参见 Versioning and Compatibility 与 Base Protocol。
C# SDK 2.x 同时保留旧版兼容路径,仍能处理旧客户端的初始化流程。SDK 的兼容约定见 C# SDK v2.2.0:Versioning。
还要区分三套独立的版本号:
| 版本 | 表示什么 |
|---|---|
JSON-RPC 2.0 | 消息格式版本 |
MCP 2026-07-28 | 协议修订版本 |
MCP C# SDK 2.2.0 | NuGet 包版本 |
3.3 Program.cs:组装框架和业务服务
协议消息到达后,Kestrel 接收 HTTP,ASP.NET Core 提供路由、认证、日志和依赖注入,MCP C# SDK 再解析消息、绑定参数并调用工具。
Program.cs 的启动过程可以概括为:WebApplication.CreateBuilder(args) 准备配置和主机环境,builder.Services 收集服务注册信息,Build() 构造应用,再映射路由,最后由 Run() 启动 Web 主机。以下是服务注册部分:
builder.Services.AddSingleton(settings);
builder.Services.AddSingleton<ProjectWorkspace>();
builder.Services.AddSingleton<PatchService>();
builder.Services.AddSingleton<BashRunner>();
builder.Services.AddMcpServer()
.WithHttpTransport(options =>
options.SessionMode = HttpServerSessionMode.Stateless)
.WithTools<WorkspaceTools>();WorkspaceTools 的构造函数接收 ProjectWorkspace、PatchService 和 BashRunner。DI(Dependency Injection,依赖注入)容器根据注册关系创建并传入依赖;当 PatchService 也需要 ProjectWorkspace 时,容器继续解析它的构造函数。
AddSingleton 让同一个容器复用一个实例,所以补丁与 Bash 使用同一个 ProjectWorkspace,通过其中的修改锁协调并发(见 4.8 节)。服务生命周期的定义见 Microsoft 文档。
构建应用后,程序立即解析工作区与 Bash 服务:
_ = app.Services.GetRequiredService<ProjectWorkspace>();
_ = app.Services.GetRequiredService<BashRunner>();这会提前检查根目录、Bash 路径和 Linux 的 setsid 依赖。配置无效时终止启动,避免等到第一次工具调用才暴露问题。
工具类与方法上的 Attribute 为 SDK 提供元数据。WorkspaceTools 使用 McpServerToolType 标记,读取方法则声明:
[McpServerTool(Name = "read_file", ReadOnly = true,
Destructive = false, OpenWorld = false, UseStructuredContent = true)]
public Task<ReadResult> ReadFile(
string path,
CancellationToken cancellationToken,
int start_line = 1,
int? end_line = null) =>
Run(() => workspace.ReadFileAsync(
path, start_line, end_line, cancellationToken));ReadFile 是 C# 方法名,read_file 是协议中公开的工具名。SDK 根据方法签名、描述和 Attribute 建立工具定义及参数 schema:path 是字符串,start_line 是可选整数;CancellationToken 由请求执行环境提供,模型不用填写。
Schema 描述输入结构,读取文件和检查路径仍由 ProjectWorkspace 完成。WorkspaceTools.Run 将工作区、权限和 I/O 异常转换为 MCP 工具错误,使协议入口与业务逻辑分开。
ReadOnly、Destructive、OpenWorld 等 annotations 是客户端行为提示,分别描述只读、破坏性影响和外部交互等特征。bash 被标记为可能产生破坏性影响且属于 OpenWorld,因为它能执行命令、访问网络。工具提示与实际权限的关系见 4.8 节。
3.4 无状态请求与状态的生命周期
注册代码使用了 HttpServerSessionMode.Stateless,协议上下文由每次请求显式携带,具体字段见 3.2 节。
应用仍可持有 Singleton 对象、公钥缓存和信号量,磁盘文件也会持续存在。不同状态由不同组件管理:
| 状态 | 生命周期与管理者 |
|---|---|
| HTTP 连接 | 承担传输与连接复用 |
| MCP 请求上下文 | 当前请求携带的版本、能力与元数据 |
| OAuth 授权状态 | 由令牌及授权关系管理 |
| ChatGPT 对话 | 保存历史消息与客户端已知的工具信息 |
| 应用进程 | 持有 DI 服务、公钥缓存和并发协调对象 |
| Bash 子进程 | 保存本次命令的变量、当前目录和进程状态 |
| 磁盘文件 | 在请求和子进程结束后继续存在 |
需要跨请求关联的操作,应使用显式参数或句柄。HTTP 响应可以是 JSON,也可以是当前请求范围内的 SSE 流。新版还通过 MRTR(多轮往返请求)表达需要客户端补充输入的交互,用 subscriptions/listen 承载订阅通知,当前三个工具主要使用普通请求与结果。
客户端可能缓存 tools/list 返回的工具定义。服务端工具列表变化后,旧对话仍可能调用已下线的工具并收到 Unknown tool。此时需要刷新工具列表,必要时开启新对话,具体步骤见 README。
MCP SDK 将参数绑定到工具方法后,由业务服务检查路径、修改文件并管理命令进程。
4 文件与进程操作怎样实现
4.1 read_file:先确认路径指向哪里
模型传入 Program.cs 时,服务需要先把相对路径转换为根目录下的实际路径。假设 MCP_ROOT 是 /home/user/project,Tools/../Program.cs 规范化后仍在项目内,../secret.txt 则会越界。
当前实现先求完整路径,再检查它相对根目录的位置。以下节选省略了前置参数检查和后续链接检查:
var full = Path.GetFullPath(Path.Combine(Root, path));
var relative = Path.GetRelativePath(Root, full);
if (relative == ".." ||
relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
Path.IsPathRooted(relative))
throw new WorkspaceException("The path is outside MCP_ROOT.");计算相对路径是为了避免字符串前缀误判。只检查 full.StartsWith(Root),可能把 /home/user/project2 也当作 /home/user/project 内部的路径。
接口统一使用 / 分隔的相对路径,拒绝绝对路径、冒号、反斜杠和 NUL。实现还逐级检查路径分量及根目录祖先,拒绝符号链接与 Windows junction。例如,项目内的 linked -> ~/.ssh 也会因链接检查被拒绝。
文件接口还过滤 .git、.env* 和部分凭据、私钥及配置目录。.gitignore 不决定文件工具是否允许访问。
这些检查作用于文件接口。检查与打开之间,其他进程仍可能替换路径;硬链接等情况也需要额外防护。服务账号的访问范围见 4.8 节。
4.2 read_file:从字节快照到按行文本
路径合法以后,服务还要判断文件能否按文本读取。磁盘保存的是字节,C# string 使用 UTF-16,模型 token 又有独立的计量方式:
| 字符 | UTF-8 字节数 | UTF-16 代码单元数 |
|---|---|---|
A | 1 | 1 |
中 | 3 | 1 |
😀 | 4 | 2 |
string.Length 统计 UTF-16 代码单元,MiniMcp 的单文件大小限制则按字节计算,上限为 256 KiB,即 262144 字节。SnapshotAsync 最多读取 256 KiB + 1 字节,多读一个字节,就能区分文件恰好达到上限还是已经超限。
之后,服务检查 NUL、识别可选 UTF-8 BOM,并严格解码 UTF-8。new UTF8Encoding(false, true) 的第二个参数表示遇到非法 UTF-8 时抛出异常;第一个参数控制编码器是否生成 BOM。读取时是否识别已有 BOM,需要由读取逻辑单独处理。UTF-8 BOM 是开头的 EF BB BF 三个字节,补丁快照会记录它,以便更新时保留。
读取限制分别作用于文件和输出:
| 限制或单位 | 当前含义 |
|---|---|
| 单文件 256 KiB | 按文件字节计算,包含 BOM |
| 单次输出 16000 | 按 UTF-16 代码单元计算,避免截断代理对 |
| 行号 | 从 1 开始,包含结束行 |
| 模型 token | 由分词方式决定 |
文件大小检查在截取行范围之前完成,因此只请求第一行也会拒绝超过 256 KiB 的文件。
假设文件内容为 alpha\nbeta\n,读取第二行得到的业务数据是:
{
"path": "sample.txt",
"startLine": 2,
"endLine": 2,
"totalLines": 2,
"truncated": false,
"content": "beta\n"
}content 不额外添加行号,保留所读部分原有的 LF 或 CRLF,便于后续作为补丁上下文。结束行超过文件末尾时,按实际末行返回,非法范围会报错;空文件的 totalLines 为 0,末尾换行不会凭空增加一行。
truncated=true 表示所选内容受到输出上限截断,最后一行可能只有一部分。继续读取时可以缩小行范围;超长单行或大文件则可通过 Bash 显式提取片段。
SDK 再把业务对象包装成 MCP 工具结果,新版成功完成的结果在协议外层带有 resultType="complete"。拿到原文后,模型才有依据生成符合当前文件的补丁。
4.3 apply_patch:把修改意图与当前原文匹配
修改一个方法时,补丁只需要描述原文、上下文和替换内容,避免让模型重新输出整份文件。下面把 hello 改成 Hello,并要求下一行仍为 world:
*** Begin Patch
*** Update File: notes/example.txt
@@
-hello
+Hello
world
*** End Patch空格前缀表示保留的上下文,- 表示删除,+ 表示插入;空白上下文行也必须带前缀空格。@@ 开始一个修改块。
引擎把删除行和上下文行组合成“预期旧文本”,再在原文件中查找连续匹配。这个例子查找的是 hello、world 两行:
| 匹配结果 | 行为 |
|---|---|
| 没有找到 | 拒绝修改,要求重新读取当前内容 |
| 找到多处 | 拒绝修改,要求补充上下文以消除歧义 |
| 恰好一处 | 按该位置生成新内容 |
匹配要求行内容精确一致,包括空格与大小写。实现还使用前缀匹配算法处理重复文本搜索,避免大量重复行导致低效的逐位置比较。
多个修改块按原文件中的顺序匹配,范围不能重叠;精确锚点可以缩小后续搜索范围,*** End of File 可以限定末尾匹配。补丁语法参考 OpenAI Codex 的 apply-patch parser(Apache-2.0),由 C# 独立实现,不接受标准 git diff 的 diff --git、---、+++ 和带行号的 @@ 头。
支持的文件操作及约束如下:
| 操作 | 表达方式 | 主要条件 |
|---|---|---|
| 新增 | *** Add File: path,正文每行带 + | 目标必须不存在 |
| 更新 | *** Update File: path 与修改块 | 原文需符合上下文 |
| 移动 | Update 后加 *** Move to: path | 目标必须不存在,可同时更新 |
| 删除 | *** Delete File: path | 已有文件符合文本与大小约束 |
一份补丁最多 1 MiB、64 个文件操作,单文件最多 256 KiB;不支持任意二进制操作。同一规范化路径只能涉及一次,因此一份补丁不处理复杂移动链。
4.4 apply_patch:预演、提交与异常恢复
解析和匹配完成后,PatchService 先形成变更计划,再决定是否落盘。dry_run=true 会检查路径、上下文及内容大小,返回 dryRun 与文件级 changes,包括操作类型和修改前后字节数,不创建目录或写入目标。
预演与正式提交是两次独立调用,期间另一个编辑器可能修改文件,所以正式执行仍会重新读取并验证。
实际提交先完成整批校验并暂存新内容,再复核原文字节与目标路径,随后按文件执行备份和安装。快照记录 BOM、原换行与 Unix 模式;未改行保留换行,新插入行采用原文第一个换行样式,末尾换行也按实现规则保留。新文件使用无 BOM 的 UTF-8 和 LF。Unix 文件属性保留模式位,所有者、ACL 和扩展属性不在保留范围内。
已有目标的提交包含两个重命名步骤:先把旧文件移到备份路径,再把暂存文件移到目标路径。两步之间,目标可能短暂不存在;多个文件也按顺序逐个替换,期间可能只有部分修改完成。
发生异常时,程序尝试逆序恢复之前的修改。回滚失败会报错并保留 .minimcp-*.bak。恢复与清理在进程内执行,突然断电或强制结束进程可能打断这些步骤。
read_file 读取时不获取修改锁,因此可能看到部分文件已提交的中间状态。外部编辑器也不参与这把锁,提交前的字节复核可以发现部分冲突,但复核后仍可能发生变化。
4.5 bash:创建一次性进程
补丁工具提供结构明确的文本修改,Bash 则复用本机命令行能力。其一次性 Shell 接口参考 DeepSeek Harness 的 tool-bash 文档(MIT),执行逻辑用 .NET Process 独立实现。
一次调用可以是:
{
"command": "git status --short; git diff -- Program.cs",
"workdir": ".",
"timeout_seconds": 30
}每次调用创建全新的 Bash。前一次的 cd、export、函数和别名不会延续到下一次,因为子进程对自身环境与工作目录的修改不会反向修改 MiniMcp 父进程;已经写入磁盘的文件则会保留。需要共享 Shell 状态的连续操作,应放在同一个 command 中。
UseShellExecute=false 让 .NET 直接启动指定程序,随后命令正文仍交给 bash -c 解释。ArgumentList 保留外层参数边界,命令里的管道、变量、重定向和 && 仍按 Bash 语法执行。
Linux 先运行 /usr/bin/setsid,Windows 直接启动配置的 Bash。服务在运行期间持续收集输出,等待命令结束并清理相关进程。
4.6 bash:同时读取 stdout 与 stderr
stdout 和 stderr 是两条独立且有容量限制的管道。如果子进程写满 stderr,而父进程只等 stdout 或进程退出,子进程可能因管道无法继续写入而阻塞,父进程又在等子进程完成,形成相互等待。
BashRunner 因此同时异步排空两条流,各保留前 16000 个 UTF-16 代码单元。达到保留上限后继续读取并丢弃多余内容,避免管道堵塞。
两条流分开返回,没有保存精确的交错时序。outputTruncated 除了表示达到字符上限,还可能表示清理后仍未读到管道 EOF、等待超过 5 秒后停止收集;此时即使保留内容不足 16000,也可能不完整。
输出统一按 UTF-8 解码。Windows 本机程序使用其他代码页时,需要处理编码差异。
4.7 bash:取消、超时与后代进程清理
CancellationToken 传递协作式取消信号。WaitForExitAsync 响应取消后结束等待,操作系统中的 Bash 及其后代则由后续清理流程处理。
正常退出也需要清理。对于 sleep 300 & exit 0,主 Bash 的退出码是 0,后台 sleep 仍可能继续运行,甚至持有管道写入端,使读取端无法得到 EOF。一次性工具会在命令结束后清理这类后台子进程。
Linux 用 setsid 创建新会话和进程组,结合进程树与进程组清理;Windows 使用带 KILL_ON_JOB_CLOSE 的 Job Object。两种机制分别见 setsid 手册 和 Microsoft Job Objects。
Windows 需要先把 Bash 加入 Job Object,再执行可能派生子进程的用户命令。为此,初始 Bash 等待 stdin 上的 run 信号,直到 ProcessLifetime 初始化完成后才开始执行。
BashRunner.RunAsync 在启动进程后执行:
outputTask = stdout.ReadAsync(process.StandardOutput, reading.Token);
errorTask = stderr.ReadAsync(process.StandardError, reading.Token);
lifetime = new ProcessLifetime(process);
deadline.Token.ThrowIfCancellationRequested();
await process.StandardInput.WriteLineAsync("run".AsMemory(), deadline.Token);
process.StandardInput.Close();
await process.WaitForExitAsync(deadline.Token);
exitCode = process.ExitCode;两条输出流在放行前就开始读取,退出后的清理位于外围 finally 中。内部信号发送后关闭 stdin,用户命令得到 EOF。当前工具没有 PTY、持久终端会话或 write_stdin 接口。
命令超时范围为 1~600 秒,默认 30 秒。计时在获取修改锁并准备进程后开始;整次调用还包括锁的排队、进程清理和输出收集时间,网络与客户端也可能另有超时。
返回字段包括 exitCode、stdout、stderr、timedOut 和 outputTruncated:
| 结束方式 | 当前结果语义 |
|---|---|
| 命令正常结束 | 返回退出码,包括 exit 7 这样的非零值 |
| 执行超时 | exitCode=null,timedOut=true |
| 客户端取消 | 清理后传播取消,不承诺返回完整业务结果 |
超时、取消和正常结束都执行进程清理,命令已写入磁盘的修改会保留。Linux 上主动脱离原进程组的程序仍可能避开这套清理逻辑。
4.8 共享修改锁与实际权限
apply_patch 和 bash 都可能修改项目。如果一个在提交补丁,另一个同时运行 sed 修改同一文件,就可能产生覆盖或竞态。因此,二者共用 ProjectWorkspace 中的 SemaphoreSlim(1,1),即 Mutations:
await workspace.Mutations.WaitAsync(cancellationToken);
try
{
// Apply a patch or run a Bash command.
}
finally
{
workspace.Mutations.Release();
}WaitAsync 允许异步等待进入,finally 在普通异常或取消时仍释放信号量。这把锁只协调当前 MiniMcp 进程中的补丁与 Bash;读取、外部编辑器和另一个 MiniMcp 进程不共享它。即使 Bash 只运行只读命令,当前实现也会串行执行。
并发协调之外,还要区分工具提示、路径限制和操作系统权限:
| 层次 | 当前控制方式 | 实际影响 |
|---|---|---|
| 工具提示 | annotations 与客户端确认设置 | 描述行为,帮助客户端决定交互 |
| 文件接口 | 相对路径、根目录与私有路径检查 | 约束 read_file、apply_patch 和 Bash 的起始 workdir |
| 操作系统执行 | 服务账号、文件权限、systemd 等限制 | 决定 Bash 真正能访问和执行什么 |
bash 的 workdir 通过 ProjectWorkspace.Resolve 检查,必须是项目内部的已有目录。它设置的是起始工作目录,命令启动后仍可以 cd、使用绝对路径,访问服务账号有权读取的文件和网络。例如,文件工具拒绝读取 .env,Bash 仍可能通过 cat .env 读取它。
子进程只继承 PATH、用户目录、语言、临时目录、部分系统目录和代理等基础变量,并使用 --noprofile --norc,不自动继承整份应用配置或 BASH_ENV。若要进一步限制命令的文件访问,需要单独的低权限账号、容器或系统沙箱等操作系统层隔离。
服务以哪个账号运行、从哪里加载配置,会影响 Bash 能找到哪些命令、访问哪些文件。
5 运行环境、验证与故障定位
5.1 配置怎样进入 .NET 进程
.NET SDK 提供还原、构建和发布工具,运行时负责执行应用,ASP.NET Core 共享框架提供 Web 组件。本项目目标为 net10.0,开发时可以使用 dotnet run,发布后可以直接执行 dotnet MiniMcp.dll。
手动启动时,项目的 .env 由 Shell 读取并导出为环境变量:
set -a
source .env
set +a
dotnet run --no-launch-profilesource 在当前 Shell 中执行文件,set -a 使赋值自动导出,随后启动的 dotnet 继承这些环境变量。MiniMcpOptions.Load 在启动时读取配置,因此修改 .env 后需要重新导入变量并重启进程。
主要配置项及用途如下:
| 配置 | 用途 |
|---|---|
MCP_PUBLIC_URL | 公网 origin,用于配置允许的 Host;不含 /mcp |
MCP_ROOT | 文件工具根目录和 Bash 默认工作目录 |
MCP_BASH_PATH | Bash 可执行文件路径 |
CF_ACCESS_TEAM | Access 团队域名前缀,用于定位签发者与公钥 |
CF_ACCESS_AUD | 后端预期的 Access 应用 Audience |
应用进程配置与 Dashboard 中的 DNS、Access、Tunnel 配置分别维护。更换公网入口时,需要同步相关配置并重启应用。
5.2 前台、systemd 与 Windows 的环境差异
后台服务可能使用不同的账号、PATH、工作目录和配置来源。systemd 模板从 EnvironmentFile 读取配置,WorkingDirectory 指向发布目录,%h 由 systemd 展开为用户 home。服务环境文件与项目 .env 分别维护。
UMask=0077、NoNewPrivileges=true、PrivateTmp=true 也会影响 Bash 子进程。服务不会自动加载交互式 Shell 的别名、函数和环境,前台可用的命令在后台可能因 PATH 不同而无法找到。
Wants 和 After 表达拉起关系及启动顺序。enable 配置自启动关系,start 立即启动服务;用户管理器要在无人登录时运行,还涉及 linger。命令语义见 systemctl 与 loginctl,安装和验收步骤见仓库 README。
Windows 使用 Git for Windows 的 Bash,由 MCP_BASH_PATH 指定路径。工具文件参数继续使用 / 分隔的相对路径,command 使用 Bash 语法。Windows 路径转换、输出编码及 Job Object 清理需要在实机上验证。
5.3 按层次理解验证结果
验证业务服务、协议接口和公网部署,需要经过不同的组件。仓库提供两组本地检查:
dotnet run --project tests/MiniMcp.Checks
dotnet run --project tests/MiniMcp.ProtocolChecks第一组直接调用业务服务,在临时工作区测试路径、文本读取、补丁和真实 Bash 进程,已有记录为 27 项通过。第二组使用 MCP SDK 的内存流客户端,检查工具发现、参数 schema、结构化结果、工具错误及协议版本兼容。
各层验证能说明的范围如下:
| 验证方式 | 能提供的证据 | 没有覆盖的部分 |
|---|---|---|
| 直接调用业务服务 | 路径、补丁算法、输出收集和进程清理等实现行为 | MCP 参数绑定、HTTP、OAuth、Tunnel |
| SDK 内存流客户端 | 工具发现、协议消息、schema 与结果包装 | Kestrel、Cloudflare 和公网认证 |
| 本机 HTTP 与健康路由 | 应用启动、监听与相应路由可达 | 完整公网链路、全部授权和工具行为 |
| ChatGPT 远程调用 | 当次客户端、授权、转发与工具调用成功 | 未执行的失败分支、其他平台和自启动场景 |
5.4 从错误来源反推故障位置
第 1 部分的分层也可以用于排障。先确定错误由哪一层返回,再检查该层的输入与依赖:
| 观察 | 优先检查 |
|---|---|
| 域名无法解析 | DNS 委派、记录和 hostname |
| Cloudflare 1033 | Tunnel 是否存在可用连接器 |
| 502 Bad Gateway | cloudflared 到本地 origin 的可达性、监听地址和端口 |
| Tunnel 已注册,公网仍不可用 | 公网 hostname 路由、连接器所在机器和后端地址 |
Provided Tunnel token is not valid | 是否误填 Tunnel UUID、整条安装命令或带 BOM 的文本 |
| OAuth 登录失败 | Access 应用、身份提供方、回调 URI 与访问策略 |
| 401 / 403 | 先确认来自 Cloudflare 还是后端,再检查 OAuth、Access JWT、TEAM、AUD 和时钟 |
/healthz 成功,工具仍失败 | 继续检查认证、MCP 请求格式与业务参数 |
Unknown tool: write_file | 客户端是否仍缓存已删除工具,刷新工具列表 |
read_file 拒绝路径 | 根目录、相对路径和私有路径规则 |
| 补丁上下文不匹配或歧义 | 重新读取文件,使用当前内容补充精确上下文 |
| Bash 返回非零退出码 | stderr、命令依赖、PATH 与工作目录 |
| Bash 超时 | 命令耗时、后代进程、清理和相关超时设置 |
| 前台成功、后台失败 | 配置副本、服务账号、环境、工作目录和网络依赖 |
IsError=False 表示这次调用没有作为 MCP 工具错误返回。判断执行结果时,apply_patch 要检查 dryRun 并回读实际文件,bash 要检查 exitCode、timedOut 和输出。例如 exitCode=7 可以被正常封装为工具结果,但命令自身并未成功。
检查修改时,git diff 不显示未跟踪文件的内容。新增文件应通过 read_file 回读。
5.5 更换公网入口时需要替换什么
更换公网入口时,文件、补丁和 Bash 服务可以复用;公网可达性、TLS、认证和转发需要按新入口配置。
例如,Caddy 的局部反向代理配置可以写成:
mcp.example.com {
reverse_proxy 127.0.0.1:8000
}Caddy 可以通过 ACME 管理证书。HTTP-01、TLS-ALPN-01 和 DNS-01 分别通过 HTTP、TLS 或 DNS TXT 证明域名控制权;DNS-01 可以在没有公网入站访问的环境申请证书,但后端的网络可达性仍需另外解决。参见 Caddy Automatic HTTPS。
CloudflareAccessAuthentication 依赖 Cf-Access-Jwt-Assertion,因此迁移时需要替换这部分认证实现,并验证新的授权方案。Local 模式会关闭现有认证,只用于本机联调。
结语
MiniMcp 把模型生成的工具参数逐步转换为本机文件和进程操作:DNS 与 Tunnel 提供网络入口,Managed OAuth 与 Access JWT 完成准入,ASP.NET Core 和 MCP SDK 分发请求,业务服务负责执行。文件接口检查路径,补丁通过提交前验证和尽力回滚处理失败,进程管理负责清理后代进程,Bash 的访问范围由服务账号权限决定。
精读源码时,可以从 Program.cs 进入 WorkspaceTools.cs,再依次看 ProjectWorkspace.cs、PatchService.cs、BashRunner.cs 和 ProcessLifetime.cs,最后回看认证代码与配置。PatchService 适合研究上下文匹配和文件提交的失败恢复,BashRunner 与 ProcessLifetime 则把异步 I/O、取消机制及跨平台进程管理连接起来。
附录:阅读源码时常见的术语
C# 与 .NET
| 术语 | 在项目中怎样理解 |
|---|---|
| Primary Constructor | 在类声明中接收构造参数,例如 WorkspaceTools(...),这里用于接收依赖 |
| Dependency Injection | 容器根据注册和构造函数关系创建、组装对象 |
| Singleton | 同一容器复用一个实例,线程同步仍需单独实现 |
| Attribute | 附着在类或方法上的元数据,供 SDK 建立工具定义 |
| Record | 适合表达结果数据,支持值相等,不自动保证深度不可变 |
Task<T> | 表示可能成功、失败或取消的异步操作 |
async / await | 异步等待并恢复执行 |
CancellationToken | 传递取消意图,操作需要响应它 |
SemaphoreSlim | 限制并发进入者数量,支持异步等待 |
FileStream | 从 .NET 访问文件字节流 |
Span / Memory | 表示连续数据及其切片,减少不必要的复制 |
| LINQ | 用 Where、Select 等表达集合查询和转换 |
| Lambda | 将行为作为值传递,例如 () => DoWork() |
Func<Task<T>> | 调用后返回异步结果的委托 |
| P/Invoke | 从 C# 调用操作系统或原生库的非托管函数 |
IDisposable | 显式释放资源,通常配合 using |
IAsyncDisposable | 异步释放资源,通常配合 await using |
网络、协议与进程
| 术语 | 在本文中的含义 |
|---|---|
| Origin | 实际提供 HTTP 服务的源站,本例是本机 Kestrel |
| Reverse Proxy | 代表后端接收并转发客户端请求 |
| Tunnel | 通过已建立的通道,把外部请求送到原本无法直接入站访问的服务 |
| OAuth | 客户端获得访问授权的框架 |
| JWT | 携带声明的令牌格式,本文使用签名 JWT |
| JWKS | JSON Web Key 集合,本例用于提供验证签名的公钥 |
| Issuer / Audience | 分别标识签发者与令牌的预期使用对象 |
| JSON-RPC | 用结构化消息表达远程方法调用 |
| MCP Tool | 供 AI 客户端发现和调用的功能接口 |
| Stateless | 请求携带必要协议上下文,应用与磁盘仍可保存状态 |
| Process Group / Job Object | 操作系统用于协调或管理相关进程的机制 |
| PTY | 提供交互终端语义的伪终端,当前 MiniMcp 未提供 |
参考资料
参考
- MiniMcp:源码与部署指南
- Cloudflare:Tunnel DNS records
- Cloudflare:Tunnel with firewall
- Cloudflare:Managed OAuth
- MCP 2026-07-28:Authorization
- Cloudflare:Validate JWTs
- MCP 2026-07-28:Versioning and Compatibility
- MCP C# SDK v2.2.0:Versioning
- MCP 2026-07-28:Streamable HTTP
- MCP 2026-07-28:Base Protocol
- OpenAI:Connect and test your plugin / Refresh metadata
- Microsoft:Service lifetimes
- Linux 手册:setsid
- Microsoft:Job Objects
- systemd:systemctl
- systemd:loginctl
- Caddy:Automatic HTTPS
- OpenAI Codex:apply-patch parser
- DeepSeek Harness:tool-bash