用代理模式给官方 MCP Server 注入自定义工具(以 run_command 为例)
不重写文件系统逻辑,而是把官方 @modelcontextprotocol/server-filesystem 当子进程拉起、自己在 ChatGPT 与子进程之间做代理,在 tools/list 回程注入自定义工具、在 tools/call 请求上截下自定义工具自己执行。含 OpenAI 连接器静默丢弃缺字段工具的坑。
[!abstract] 一句话 当你想给一个已有的官方 MCP server(比如 filesystem)加一个它本来没有的工具(比如
run_command),最省事的做法不是 fork 它的源码,而是写一个极薄的代理进程:它 spawn 官方 server 当子进程,自己坐在 ChatGPT 和子进程中间,改tools/list的回程、截tools/call的请求。oracle3 的combined-mcp.mjs、oracle2 的同源副本都是这个模式,整份代码约 99 行。
[!info] related notes
- 前置概念: 本地 MCP Server(stdio / JSON-RPC / 安全边界)
- 对照实现(从零造一个语义化 server,不是代理模式): 构建 Thought Forest 本地知识库 MCP Server
- 部署落地(这个 server 怎么跑在 VPS 上、接进 ChatGPT): 通过 Secure MCP Tunnel 让 ChatGPT 读写 oracle3
- 接通后怎么当 agent 用: 把 ChatGPT 网页端变成 Coding Agent Harness
- Tunnel 对象: OpenAI Secure MCP Tunnel
- 入口: MCP MOC
用代理模式给官方 MCP Server 注入自定义工具
目标
在不修改官方 @modelcontextprotocol/server-filesystem 源码的前提下,产出一个 MCP server,使 ChatGPT 经它同时获得:
- 官方 filesystem 的全部文件能力(列/读/写/改,作用域由启动参数决定);
- 一个官方没有的
run_command工具(在宿主机上执行任意 shell)。
两者合并在同一个 tools/list 里返回,ChatGPT 一次就能看到全部工具。
为什么用代理模式,而不是从零写
官方 filesystem server 已经把文件读写、路径边界、权限模型都实现对了。从零重写一遍是重复造轮子,而且容易把边界写错。代理模式的本质是”复用官方子进程 + 只补自己要的那一点”:
- 文件操作 → 全部转发给官方子进程处理;
- 只有
run_command这种官方没有的活 → 自己接住、自己执行。
代价是引入一层进程间转发,但代码量极小(约 99 行),且对 ChatGPT 完全透明。
架构:两个拦截点
ChatGPT ──JSON-RPC(stdin/stdout)──▶ [ 我的代理进程 combined-mcp.mjs ]
│ ① tools/list 回程:删 read_file + 注入 run_command
│ ② tools/call:run_command 自己 spawn bash,其余转发
└──JSON-RPC(stdin/stdout)──▶ 官方 filesystem 子进程
- 拦截点①在「子进程 → 我」的回程上:子进程回的
tools/list被我改掉清单(去掉官方废弃的read_file,再 push 我自己的run_command),再转给 ChatGPT。 - 拦截点②在「ChatGPT → 我」的请求上:如果是
tools/call且名字是run_command,我自己执行、自己回结果,不往下透传;其他请求(文件操作)原样转发给子进程。
代码骨架(忠实于 oracle3 的 combined-mcp.mjs)
1. 拉起官方子进程 + 接出两边行流
MCP stdio 传输就是「每行一个 JSON-RPC 消息」,所以用 readline 把 ChatGPT→我、子进程→我两条行流分别接出来:
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
const FS_BIN = '/home/ubuntu/.../server-filesystem/dist/index.js';
const FS_ARGS = ['/home/ubuntu']; // filesystem 作用域根
const DEFAULT_CWD = '/home/ubuntu';
// 把官方 server 当子进程拉起,stdio 三根管子都接 pipe
const child = spawn('node', [FS_BIN, ...FS_ARGS], { stdio: ['pipe', 'pipe', 'pipe'] });
child.stderr.on('data', d => process.stderr.write('[fs-child] ' + d));
// 两条 readline:一条读 ChatGPT 发来的,一条读子进程回的
const rlClient = createInterface({ input: process.stdin });
const rlChild = createInterface({ input: child.stdout });
const sendToClient = o => process.stdout.write(JSON.stringify(o) + '\n');
const sendToChild = o => child.stdin.write(JSON.stringify(o) + '\n');
2. 拦截点①:改 tools/list 回程
rlChild.on('line', (line) => {
let msg; try { msg = JSON.parse(line); } catch { return; }
// 只针对「带 tools 数组的 tools/list 响应」动手
if (msg.id !== undefined && msg.result && Array.isArray(msg.result.tools)) {
const tools = msg.result.tools
.filter(x => x.name !== 'read_file' && x.name !== 'run_command'); // 去废弃项、防重复
tools.push(RUN_COMMAND_TOOL); // 注入自定义工具
msg.result.tools = tools;
}
sendToClient(msg); // 改完再转给 ChatGPT
});
3. 拦截点②:截下 run_command 自己执行
rlClient.on('line', (line) => {
let msg; try { msg = JSON.parse(line); } catch { return; }
if (msg.method === 'tools/call' && msg.params?.name === 'run_command') {
runCommand(msg.params.arguments || {}).then(r =>
sendToClient({ jsonrpc: '2.0', id: msg.id, result: r })); // 自己回,不透传
return;
}
sendToChild(msg); // 其余(文件操作)照常转发给官方子进程
});
4. run_command 的实现(spawn bash)
function runCommand(args) {
return new Promise((resolve) => {
const workdir = (args.cwd && args.cwd.startsWith('/')) ? args.cwd : DEFAULT_CWD;
const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : 120000;
const proc = spawn('bash', ['-lc', args.command || ''], { cwd: workdir, env: process.env });
let out = '', err = '';
const finish = (code, signal) => {
const text = `exit=${code ?? signal}\ncwd=${workdir}\n--- stdout ---\n${out}\n--- stderr ---\n${err}`;
resolve({ content: [{ type: 'text', text }], isError: code !== 0 });
};
proc.stdout.on('data', d => out += d);
proc.stderr.on('data', d => err += d);
proc.on('close', (code, signal) => finish(code, signal));
// 超时则 SIGKILL,避免卡死
setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} finish(null, 'timeout'); }, timeout);
});
}
关键坑:工具 schema 必须带「完整形状」,否则被 OpenAI 静默丢弃
这是整个方案里唯一靠试错摸出来的点,官方文档一个字没提:OpenAI 的 ChatGPT 连接器在接入 MCP 时会逐个校验工具 schema,缺字段就静默丢弃该工具——不报错、只是 ChatGPT 里看不到。
被丢弃前,run_command 只写了 name / description / inputSchema,结果在 ChatGPT 里根本不出现。必须补齐下面这套形状(照着官方 filesystem 工具的字段抄)才能过关:
const RUN_COMMAND_TOOL = {
name: 'run_command',
title: 'Run Command', // ← 缺了会被丢
description: 'Execute an arbitrary shell command ...',
inputSchema: {
$schema: 'http://json-schema.org/draft-07/schema#', // ← 缺了会被丢
type: 'object',
properties: {
command: { type: 'string' },
cwd: { type: 'string' },
timeout_ms: { type: 'number' }
},
required: ['command']
},
annotations: { // ← 缺了会被丢
readOnlyHint: false, idempotentHint: false,
destructiveHint: true, openWorldHint: true
},
execution: { taskSupport: 'forbidden' }, // ← 缺了会被丢
outputSchema: { // ← 缺了会被丢
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object', properties: { content: { type: 'string' } },
required: ['content'], additionalProperties: false
}
};
验证手段:当 ChatGPT 里少了一个工具时,直接拿
npx起官方inspector或自己写个 stdio 探针(向 server 发tools/list、打印返回),比对「server 实际暴露的工具」和「ChatGPT 显示的工具」差异,缺的就是被静默丢弃的。
验证
tools/list应返回 13 个 filesystem 工具 + 1 个run_command(共 14,且不含read_file);- 调
run_command传whoami,应回ubuntu、exit=0; - 在 ChatGPT 侧确认能看到
run_command并能执行(若看不到,先怀疑上面的 schema 形状,不是转发逻辑)。
变体:oracle2 同构复制
oracle2 的桥是同一份代码的副本,只把作用域根从 /home/ubuntu 换成 /workspace + /opt/data + /home/ubuntu,其余(双拦截点、run_command 实现、schema 形状)完全一致。详见 oracle3 ↔ ChatGPT runbook 的 oracle2 专节。
常见问题
- ChatGPT 里看不到 run_command:几乎都是 schema 形状不全被静默丢弃,照「关键坑」补齐字段即可;不是转发逻辑问题。
- run_command 执行了但 ChatGPT 报超时:检查 server 进程是否被 systemd 的
TimeoutStartSec/日志截断影响;命令本身用bash -lc跑,cwd 默认/home/ubuntu,可用参数覆盖。 - 想加第二个自定义工具:在
RUN_COMMAND_TOOL之外再定义一个*_TOOL,在拦截点① push 进去、在拦截点②加一个if分支接住即可,转发逻辑不用动。
安全提醒
代理模式把「官方 server 的边界」和「你自己加的工具」合并到同一授权面。一旦你加了 run_command(任意 shell),就等于把宿主机命令行交给了连上这个 MCP 的 ChatGPT 工作区——文件黑名单、作用域根都拦不住它 cat 任何路径。是否加、加在哪个作用域,是授权决策,不是技术默认。