MCP 本地服务:把 AI 的工具调用关进可审计边界
一次看似无害的代码检索,曾让团队在本地 AI 客户端里得到一连串“工具不可用”的提示。服务进程明明还在,终端里也没有报错;排查很久才发现,开发者在 stdout 打了一行启动横幅。对人来说那只是一句日志,对 stdio 传输而言却是一条不是 JSON-RPC 的协议帧。客户端的第一条 initialize 没能获得合法响应,于是工具列表、资源和提示模板都像是“随机消失”。更危险的变体不是崩溃,而是服务在工作区之外读取了同名目录、把完整文件内容塞进日志、或把一个原本只读的查询接口悄悄扩成可写命令。MCP 的难处从来不在“让模型能调用函数”,而在于每一次能力暴露都能说清楚协议是否成立、数据从哪里来、动作由谁批准、失败后如何收束。
MCP 是用于上下文交换的协议,不规定宿主怎样选择模型,也不替应用决定交互界面。一个 host 为每个 server 维持对应的 client 连接;本地 server 常由 host 以子进程方式启动,远端 server 则可由多个 client 通过 Streamable HTTP 访问。MCP 架构说明 将数据层与传输层分开:数据层是 JSON-RPC 生命周期、能力和原语,传输层处理进程管道或 HTTP 连接、认证与消息承载。把这两层混在一起设计,往往会把“网络能通”误判成“工具可以安全执行”。
协议版本、SDK 版本和 server 版本是三条不同的兼容轴。这里按协议版本标识 2025-11-25 建立边界,TypeScript 实现采用官方 SDK 的稳定发布线;预发布 SDK 即使已经提供新包名或新 API,也不应直接替换生产基线。升级时要同时固定依赖锁文件、记录 initialize 协商结果,并用同一组正反实验验证行为,不能只凭 TypeScript 编译通过判断兼容。
从 stdout 污染事故恢复协议边界
stdio 适合一个本地 client 启动一个受控子进程:client 写入 server 的 stdin,server 只在 stdout 输出以换行分隔、UTF-8 编码的 JSON-RPC 消息,单条消息内部不能出现原始换行;日志可以写到 stderr。协议明确禁止 server 向 stdout 输出其他内容,client 也不得把非协议文本写入 stdin。传输规范 中这条约束很短,却决定了服务能否被可靠地调试。先把日志与协议隔离,启动脚本才有资格被接入客户端。
先用官方 TypeScript SDK 建立一个没有文件权限、没有网络出口的最小 server。下面固定稳定的 v1 主版本、单包 @modelcontextprotocol/sdk 和 Zod 4,避免预发布的拆包 API 混入生产基线;未来切换稳定主版本时,应先按迁移指南更新 import,再重新做握手和 schema 实验。
New-Item -ItemType Directory -Force .\tools\mcp-repo-readonly\src | Out-Null
Set-Location .\tools\mcp-repo-readonly
npm init -y
npm install "@modelcontextprotocol/sdk@^1" "zod@^4"src/index.mjs 先只暴露一个受约束的诊断工具。.mjs 让 Node 直接按 ESM 加载,避免最小实验还没开始就卡在 TypeScript 构建配置上;正式工程仍应接入自己的编译、测试和依赖锁定流程。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "repo-readonly", version: "0.1.0" });
server.registerTool(
"normalize_symbol",
{
description: "Normalize one symbol name without reading files or using the network",
inputSchema: { symbol: z.string().trim().min(1).max(128) },
},
async ({ symbol }) => ({
content: [{ type: "text", text: symbol.toLowerCase() }],
}),
);
console.error("repo-readonly MCP server starting");
await server.connect(new StdioServerTransport());用 node src/index.mjs 直接运行时,进程会等待 stdin 中的协议消息,这不是卡死;真正的连通验证交给 Inspector 或 host 启动。若启动后 stdout 出现横幅,或传入空字符串仍执行 handler,说明日志隔离或 schema 校验没有成立。
下面的最小启动脚本刻意把诊断输出留在 stderr。目录使用项目内相对位置,示例中的名字只用于演示,不包含真实工作区、账号或令牌。
{
"mcpServers": {
"repo-readonly": {
"command": "node",
"args": ["./tools/mcp-repo-readonly/src/index.mjs"],
"env": {
"MCP_LOG_LEVEL": "info",
"REPO_ROOT": "./"
}
}
}
}这个配置只描述“由谁启动什么进程”,并不自动授予整个磁盘访问权。server 自己仍要对 REPO_ROOT、根目录清单和每个参数做真实校验。不要把 token 放进 JSON 配置、代码仓库、日志或工具返回内容;有认证需要时,让运行环境以短期凭据或受控 secret 注入。把凭据写进 args 还会使它出现在进程列表、诊断包或 shell 历史里。
一次正向连通实验应当记录的是协议证据,而不是“界面看到了工具”。可以用官方维护的 MCP Inspector 启动本地命令,检查握手、能力与每次调用。命令中只给出示意路径;实际项目把它替换为受控的构建产物。
npx @modelcontextprotocol/inspector node ./tools/mcp-repo-readonly/src/index.mjs预期证据是:Inspector 显示 server 信息;initialize 有匹配的协议版本;随后 client 发送 notifications/initialized;再请求 tools/list 和 resources/list。若在 server 的 stdout 加入 console.log("started"),负向实验的预期不是“工具少一个”,而是会话在握手前解析失败,或 client 报 JSON-RPC 帧无效。修复是删除 stdout 日志、保留 stderr 结构化日志,并重新抓取握手记录。不要通过吞掉解析异常来“修好”它,那只会让非法输出在下一次升级时重新爆炸。
initialize 不是装饰性的欢迎消息
MCP 连接具有明确的生命周期状态,但这不等于所有传输都必须保存服务端会话。初始化必须是 client 与 server 的首次交互:client 发送 initialize,声明支持的协议版本、client capabilities 与实现信息;server 返回选定版本、server capabilities 与 server 信息;成功后 client 发送 notifications/initialized,才进入普通操作阶段。生命周期规范 还规定,在初始化完成前双方不应随意发送其他请求。Streamable HTTP 可以采用有会话或无会话实现,二者都不能跳过能力协商;跳过初始化会让后续的 roots/list、sampling 或工具更新通知看上去偶发失效。
下列报文是一次缩减后的握手样本。字段不应照抄到业务服务:它的价值在于让测试明确断言版本、能力和顺序。
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"local-host","version":"0.1.0"}}}
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true},"resources":{},"prompts":{},"logging":{}},"serverInfo":{"name":"repo-readonly","version":"0.1.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}协议版本不是服务端单方面写死的字符串。server 若支持 client 请求的版本,应返回同一版本;否则返回自己支持的版本,client 若不支持该结果就应断开。HTTP 连接在后续请求里还要携带 MCP-Protocol-Version。因此,升级 SDK 或 host 时,先在隔离环境抓一次完整的 initialize 交换,比只看依赖锁文件更可靠。能力也不是权限:tools 表示 server 能提供工具,不能推出 host 会无提示自动调用,更不能推出工具可访问任何路径。
正向实验可断言 tools、resources 与 logging 只出现在 server 实际实现的能力集合中。负向实验则让 server 宣告 resources,但故意不实现 resources/list;正确现象是 client 获得协议错误而不是静默退化。另一个常见错误是 server 在没有协商 listChanged 时发送 notifications/tools/list_changed。客户端应把它视为不可信通知,服务端测试也应阻止这条路径。能力声明与实现分离时,最怕的是“为了兼容”声明一切,这会扩大攻击面并制造无法复核的行为。
tools、resources 与 prompts 的职责不能互换
server 向 client 暴露的三类核心原语分别是 tools、resources 与 prompts。tool 是可被调用的动作;resource 是可读取的上下文数据;prompt 是可复用的交互模板。把“读一个文件”设计成 tool,会使模型把查询和动作混为一谈;把“刷新索引”伪装成 resource,又会把有副作用的计算藏进读取路径。协议的 */list 用于发现,resources/read 用于读取,prompts/get 用于取模板,tools/call 才进入动作执行。MCP 的原语说明 给出了这条区分。
只读仓库服务可以将项目元数据作为 resource,把检索作为 tool,把“如何解释一次检索结果”的固定指导作为 prompt。下面的设计故意不提供 write_file、run_shell 或泛化 SQL。能力少不是缺功能,而是把授权拆成可复核的小块。
resources/list -> repo://manifest -> 受控的项目清单
resources/read -> repo://manifest -> 返回脱敏后的文本
prompts/list -> explain-match -> 查询结果解读模板
prompts/get -> explain-match -> 只填入已获得的摘要
tools/list -> search_symbols -> 按 schema 查询索引
tools/call -> search_symbols -> 只读、限结果数、可审计tools/list 中的 inputSchema 是 API 合同,不是给模型看的文案。工具名需稳定且唯一;schema 应给出对象类型、必填字段、枚举、长度和 additionalProperties 限制。当前规范在没有 $schema 时按 JSON Schema 方言版本标识 2020-12 解释 schema;无参数工具也应该显式给出只接受空对象的 schema。工具规范 同时提醒:来自 server 的 annotations 不应被 client 当成可信权限依据。真正的授权必须由 host、server 的访问控制和用户确认共同完成。
{
"name": "search_symbols",
"description": "在已批准根目录的索引中查找符号,不读取工作区外文件。",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"query": {"type": "string", "minLength": 1, "maxLength": 120},
"limit": {"type": "integer", "minimum": 1, "maximum": 20}
},
"required": ["query"]
},
"outputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {"matches": {"type": "array"}},
"required": ["matches"]
}
}正向调用使用 {"query":"parseConfig","limit":5},预期返回有限数量的相对路径、行号和截断片段。声明 outputSchema 后,结构化结果还应放入 structuredContent,并由 server 与 client 校验;为兼容只读取文本内容的 client,可以同时返回序列化后的文本块。负向调用加入未声明字段、超长 query 或负数 limit,预期在进入索引前被 schema 校验拒绝,并产生不含原始敏感内容的审计事件。输入不合法属于工具执行错误,应让模型获得可修正的反馈,而不是伪装成 transport 已损坏的协议错误。若服务把参数直接拼成 shell、正则或数据库查询,schema 验证仍不足够;还要使用参数化 API、超时、结果大小上限、字符集处理和资源配额。schema 管住输入形状,不能替代执行器的安全语义。
roots 只是工作区边界提示
当 client 在初始化时协商了 roots capability,server 才能请求 roots/list 获取 client 建议的文件系统边界。root 的 uri 是 file:// URI,server 必须把每个待访问路径规范化后验证仍位于获批根之内;client 也应仅暴露用户同意的根,并在根变化时通过通知让 server 刷新。Roots 规范 明确 roots 只是协作与定位信息,不是操作系统权限控制,也不保证 server 真的无法越界。生产边界还要依靠最小系统账号、容器挂载、文件 ACL 或 sandbox;resources URI 则是 server 暴露的应用数据标识,不能反向证明它位于某个 root 内。
路径穿越事故往往从“只拼一个子目录”开始:工具接收 ../、符号链接、盘符大小写差异或 URL 编码路径,字符串前缀检查误以为它仍在根内。正确策略是对根与目标执行同一种真实路径解析,拒绝不可访问的目标,并在解析后做包含关系比较。对于符号链接是否允许跨出 root,要由服务策略明确决定;默认拒绝更便于审计。根目录改变后,以旧缓存继续读文件是另一种绕过,因此通知到来时应使缓存失效。
const root = await fs.realpath(approvedRoot);
const target = await fs.realpath(path.resolve(root, requestedPath));
const relative = path.relative(root, target);
if (relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative))) {
return readBoundedText(target);
}
throw new Error("requested path is outside the approved root");这段代码仍要配合平台测试:一个普通子文件是正例;../secret.txt、指向 root 外部的符号链接和不存在文件是反例。预期反例都返回经过分类的拒绝结果,记录请求标识、根标识和规则编号,而不记录文件正文。实际产品还应考虑 Windows 大小写与网络共享目录的语义,不能把单机 Unix 测试当作跨平台结论。
根不是唯一上下文来源。资源 URI、tool 返回的嵌入资源、prompt 参数、client 日志以及 host 的会话内容都可能进入模型上下文。每一个入口都应有最大长度、MIME 类型、脱敏和允许列表。最稳妥的只读 server 只返回所需摘要,不返回整个工作区;必要时让 client 再明确读取一个 resource,而不是由 tool 递归附带任意文件。
stdio 与 Streamable HTTP 的选择信号
本地开发助手需要一个针对当前工作区的单 client server 时,stdio 的进程边界简单、没有网络监听端口,生命周期也自然绑定到 host。它并不意味着安全:子进程继承的环境变量、当前目录、文件描述符和系统代理仍可能泄露能力。启动器应设置明确的工作目录、最小环境、资源限制和 stderr 采集规则,并在 host 退出后回收子进程。
Streamable HTTP 适合独立进程与多 client 的场景。协议使用 HTTP POST 传 client 到 server 的消息,并可使用 GET/SSE 接收流式消息、通知或 server 发起的请求;它替代了旧式 HTTP+SSE 传输。官方传输规范 还要求认真处理会话、连接恢复、Origin 校验与认证。把本地 service 直接监听在所有网卡上,等同于把原本的桌面工具能力变成局域网 API。
下面的部署片段演示最小化的网络意图:宿主只向 loopback 发布端口,容器内进程监听容器网卡,反向代理再承担 TLS 和身份层。真实认证类型取决于组织的身份系统;不要把示例中的占位符替换成长期共享 token 后提交到仓库。
services:
mcp-readonly:
image: example/mcp-readonly:stable
environment:
MCP_TRANSPORT: streamable-http
MCP_BIND_HOST: 0.0.0.0
MCP_BIND_PORT: "8787"
MCP_ALLOWED_ORIGINS: https://assistant.example.invalid
ports:
- "127.0.0.1:8787:8787"
read_only: true
tmpfs:
- /tmp容器内进程监听 0.0.0.0 才能接收 Docker 端口转发,真正限制暴露面的是宿主发布地址 127.0.0.1:8787:8787;把容器内进程也绑定到 127.0.0.1,宿主映射通常无法连接。若改为原生进程运行,则应直接监听主机 loopback。两种运行方式的绑定语义不同,不能复制同一个地址就声称边界等价。
选择 HTTP 后要做两组不同实验。正例:经过身份层的 client 完成 initialize,后续请求携带协商的 MCP-Protocol-Version,并只收到该身份允许的工具。反例:移除认证、伪造 Origin、复用失效会话或在未经协商时发送 server request;预期是边界层拒绝或 server 关闭会话,不是降级成匿名只读。SSE 断线重连也应验证重复投递是否会造成重复动作;只读查询可以重试,带副作用的调用需要幂等键或明确禁止自动重放。
只读 server 也存在正反方向调用
“只读”描述的是 server 对业务数据和外部系统的副作用,不代表协议只会从 client 到 server。MCP 允许 server 在已协商能力下向 client 请求 sampling、elicitation、roots,并发送 logging;这就是反向调用。server 请求模型生成内容时,模型的选择、上下文和用户政策仍属于 host;server 不应把它当作绕开审批的免费推理通道。server 请求用户输入时,不能用“确认”文本掩盖未经说明的权限升级。
一个安全的只读服务通常禁用不需要的反向能力:不声明或不调用 sampling;不把 elicitation 用作收集凭据;只在需要根目录时请求 roots;只把不含正文和密钥的等级化日志送给 client。若 host 没有宣告相应能力,server 必须走本地的明确失败分支,而不是猜测 host 支持。协商成功也不等于持续可用:连接、根目录和用户授权可以在任务中途发生变化。
{"jsonrpc":"2.0","id":8,"method":"roots/list"}
{"jsonrpc":"2.0","id":8,"result":{"roots":[{"uri":"file:///workspace/demo","name":"demo"}]}}
{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","logger":"repo-readonly","data":{"event":"search.completed","resultCount":3}}}这组报文的正例是 server 先确认 client 宣告了 roots,再读取 root;日志没有出现绝对工作区路径、查询全文、认证头或文件正文。反例是 client 未声明 roots 时仍发送 roots/list,或 server 收到 -32601 后改用进程当前目录。预期是工具返回“无可用工作区”的可诊断错误并停止读取。这样做会比“自动找一个目录继续执行”少一些便利,却能阻止一个只读服务因错误默认值读到不相关仓库。
凭据、账号与敏感上下文要分层处理
本地 stdio server 通常依靠 host 启动进程并从环境注入所需凭据,不应把面向 HTTP 的 MCP OAuth 流程照搬进子进程。它仍要限制环境变量、授权根和子进程继承能力;一旦要访问远端 API、issue 系统或内部索引,身份与协议会同时进入风险面。优先使用平台提供的短期访问令牌或工作负载身份,将权限限定为资源级和动作级,设置到期时间,并能够单独撤销。
远端 Streamable HTTP server 支持授权时,应把自己视为受保护资源,而不是同时假装成任意上游系统的令牌代理。MCP 授权规范 要求受保护 server 发布 OAuth Protected Resource Metadata,client 通过 WWW-Authenticate 或 well-known URI 发现授权服务器,并在授权请求与令牌请求中用 resource 绑定目标 MCP server。每个 HTTP 请求都要携带并校验 bearer token、签发方、有效期、audience 与所需 scope,令牌不能放在查询字符串中;过期或无效令牌返回 401,权限不足与 scope 提升则走明确的挑战和授权流程。MCP server 调用上游 API 时必须使用面向上游的独立令牌,不能把 client 交来的 token 原样透传。组织仍需决定身份提供方、scope 设计、同意页面与审计归属,不能把一个共享 Authorization header 当成永久通行证。
服务配置只保存凭据引用,运行时再从受控 secret store 解析。日志记录“使用了哪个凭据别名、请求了哪个权限类、结果是否成功”,不记录 token、authorization header、cookie、完整 URL 查询串和原始响应。工具返回内容同样要脱敏:模型上下文会被转交给 host 的模型策略、会话记录和可能的插件,不能把“给模型看”当成安全存储。
权限评估应拆成四项:谁能注册或启用 server;谁能修改 server 配置和二进制;哪个身份能调用哪个 tool;调用后数据会进入哪些宿主、日志和缓存。只有最后一项看似是数据问题,但前三项任何一个失控都能让攻击者替换 server,借原有信任边界读取或输出内容。将 server 二进制、依赖锁文件和配置作为代码审查对象,和审查应用依赖没有区别。
把实验结果、diff 和测试留成证据
一条可审计的工具调用记录至少需要关联:server 构建标识、配置摘要、协商的版本和能力、请求 ID、工具名、参数摘要、root 标识、授权决策、耗时、响应大小、错误类别。它不需要保存每个提示词、每个文件或每个 token。日志应区分协议诊断与业务审计:前者帮助修复帧、超时和会话;后者回答是谁在何种授权下访问了哪类资源。两者都要有保留周期和访问控制。
把下面的命令放在变更评审前执行,记录退出码和报告位置。命令只是项目骨架;不同 SDK 的 test runner 可以替换,但“握手、schema、路径边界、无凭据泄露”四类断言不应缺席。
npm run build
npm test -- mcp-handshake mcp-schema mcp-root-boundary
git diff --check
git diff -- tools/mcp-repo-readonly正向测试应验证:纯协议 stdout、合法 initialize 顺序、只列出宣告工具、参数校验在执行前完成、正常 root 内文件可读取、日志经过脱敏。负向测试应验证:stdout 污染导致会话失败、能力未协商就拒绝反向调用、路径穿越被阻断、过大响应被截断、失效凭据不会重试为匿名访问。测试名称不是证据,退出码、断言和需要人工复核的差异才是。没有执行的命令应明确标作未执行,不能用“预期通过”替代真实结果。
在这份文章的示例中,协议报文、预期成功与预期失败均用于说明测试设计;未连接真实 host、未启动真实服务、未使用真实账号或生产数据。接入项目时,应把实际执行的命令、退出码、server 版本和人工审查结论写入该项目的变更记录。这样,读者能区分可复制的测试构造与已发生的运行事实,而不会把文档中的输出误当成生产证明。
资源成本不只是一条 API 账单
每个 server 都会消耗启动时间、内存、索引空间、文件句柄、网络连接、日志存储和模型上下文。一个“搜索整个仓库”的 tool 若把数百个文件完整返回,即使没有远程调用,也会造成 token 成本、延迟和错误关联;HTTP server 若保存每个会话的资源订阅而不回收,会逐步吃掉连接与内存;频繁 tools/list_changed 则可能触发 client 重建工具注册表。成本治理的第一步是观察请求数、拒绝数、响应字节、索引耗时、并发会话、错误类别与缓存命中,而不是为每个请求编造固定单价。
给 tool 定义预算:最大调用时长、最大并行数、最大读取字节、最大匹配数、最大单次返回大小和取消点。预算触发时返回明确的受限结果,并留下可归因的事件。不要在超时后让后台工作继续读取文件或继续占用远端凭据。长任务若使用协议支持的任务能力,也要确保 client 和 server 都协商了它,并定义取消后清理什么临时状态。
依赖与供应链成本同样重要。SDK、server 包、容器基础镜像和本地辅助程序需要来源审查、版本升级测试、漏洞响应与许可证核对。没有任何一个“官方 SDK”会自动保证第三方 server 的安全性;官方协议和 SDK 解决互操作,server 的业务授权、实现质量和数据处理仍由维护者负责。
停用与回滚要能切断真实能力
发现异常时,第一动作不是删日志或重装客户端,而是停止新增调用:在 host 中禁用该 server,撤销对应身份与网络访问,终止 stdio 子进程或下线 HTTP 实例,保留最小必要审计证据。只删掉一个配置文件不够,因为同一命令可能被多个 host、用户配置或 CI 环境引用;只轮换 token 也不够,因为被污染的 server 二进制仍能读取本地文件。回滚需要同时处理执行入口、凭据、部署实体、缓存和订阅。
# 先停止服务入口并撤销凭据,再检查目标仍在当前仓库内。
$repoRoot = (Resolve-Path .).Path
$targets = @(
(Join-Path $repoRoot "config\mcp\repo-readonly.json"),
(Join-Path $repoRoot "var\mcp\repo-readonly-cache")
)
foreach ($target in $targets) {
$parent = Split-Path -Parent $target
if (-not $parent.StartsWith($repoRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "refuse to remove path outside repository: $target"
}
Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue
}
git revert <reviewed-change>命令中的路径和提交标识是占位符。真实回滚前先确认目标绝对路径在预期工作区内,避免把清理脚本扩大到共享目录;对于受集中配置管理的 host,应通过配置系统撤销,而不是只改一个开发机文件。回滚后重新启动 client,验证 tools/list 不再出现该 server 的工具、网络监控不再看到对应连接、凭据审计不再记录调用。若业务需要恢复,使用经过修复和重新评审的构建重新启用,而不是把旧配置原样打开。
长期治理把 server 当作一个小型集成系统:维护所有者、用途、允许 roots、工具清单、数据分类、身份来源、网络出口、日志位置、资源预算、升级路径与停用负责人。每次新增 tool 都复查 schema、执行副作用、审计字段和回滚动作;每次升级 host、SDK 或协议支持也重做握手与权限反例。MCP 的价值在于让 AI 客户端能够接上外部能力,工程上的价值则取决于这些能力是否仍被限制在看得见、测得到、撤得掉的边界内。
