用代理模式给官方 MCP Server 注入自定义工具(以 run_command 为例)

不重写文件系统逻辑,而是把官方 @modelcontextprotocol/server-filesystem 当子进程拉起、自己在 ChatGPT 与子进程之间做代理,在 tools/list 回程注入自定义工具、在 tools/call 请求上截下自定义工具自己执行。含 OpenAI 连接器静默丢弃缺字段工具的坑。

#type / howto #status / growing #tech / ai #tech / dev #resource / mcp #resource / openai #resource / chatgpt

[!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 注入自定义工具

目标

在不修改官方 @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_commandwhoami,应回 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 任何路径。是否加、加在哪个作用域,是授权决策,不是技术默认。

创建于 2026/8/15 更新于 2026/8/15