跳转到正文

MCP:连接外部工具服务 ​

MCP 接入分成三层:应用建立 SDK 连接;中立适配器符合 Ditto 的 McpClient;Graph 调用 INTERACTION.ACT.MCP。Ditto 不捆绑 MCP SDK,也不根据 server 名称自动启动进程。

1. 安装应用依赖 ​

在自己的 npm 应用中:

sh
npm install @codesoul-co/ditto @modelcontextprotocol/sdk @modelcontextprotocol/server-filesystem
mkdir -p workspace
printf 'Hello from MCP\n' > workspace/hello.txt
node examples/handbook/mcp.mjs ./workspace hello.txt .

最后一个参数是安装可选 SDK 的应用目录。可直接运行下面的完整 .mjs 文件;它会启动本地 filesystem server,发现工具,实际读取文件,再转换为 Observation。

若在源码仓库中,不要把这两个 SDK 加入框架的 runtime dependencies;单独创建依赖目录,安装后将该目录作为最后一个参数传入。

2. 完整连接和 Graph ​

js
import { resolve } from 'node:path';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { createDitto, graph, createInteractionWorker } from '@codesoul-co/ditto';

/** dependencies is an application directory with the official SDK and filesystem server installed. */
export async function runMcp(workspace, filename, dependencies = process.cwd()) {
  const requireSdk = createRequire(resolve(dependencies, 'package.json'));
  const { Client } = requireSdk('@modelcontextprotocol/sdk/client/index.js');
  const { StdioClientTransport } = requireSdk('@modelcontextprotocol/sdk/client/stdio.js');
  const server = requireSdk.resolve('@modelcontextprotocol/server-filesystem/dist/index.js');
  const client = new Client({ name: 'ditto-handbook', version: '1.0.0' });
  const transport = new StdioClientTransport({ command: process.execPath, args: [server, resolve(workspace)], stderr: 'inherit' });
  let runtime;
  try {
    await client.connect(transport);
    const adapter = {
      listTools: (params, options) => client.listTools(params, options),
      async callTool(params, options) {
        const result = await client.callTool(params, undefined, options);
        return { content: result.content,
          ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }),
          ...(result.isError === undefined ? {} : { isError: result.isError }) };
      },
    };
    runtime = createDitto({ sandbox: { mcp: ['files'] },
      workers: [createInteractionWorker({ mcp: { files: adapter } })] });
    const plan = graph('read-via-mcp')
      .node('discovery', 'INTERACTION.ACT.MCP', [], () => ({ operation: 'discover', server: 'files' }))
      .node('read', 'INTERACTION.ACT.MCP', ['discovery'], (path, { discovery }) => {
        if (discovery.operation !== 'discover' || !discovery.capabilities.some(tool => tool.name === 'read_text_file')) throw new Error('Required MCP tool missing');
        return { operation: 'invoke', server: 'files', call: { id: 'read-1', name: 'read_text_file', arguments: { path } } };
      })
      .node('observation', 'INTERACTION.OBSERVE', ['read'], (_input, { read }) => {
        if (read.operation !== 'invoke') throw new Error('Expected invocation');
        return { result: read.result };
      });
    const output = await runtime.run(plan, resolve(workspace, filename), { signal: AbortSignal.timeout(15_000) });
    if (output.observation.status !== 'success') throw new Error(output.observation.error?.message ?? 'MCP read failed');
    return output.observation;
  } finally {
    try { await runtime?.close(); } finally { try { await client.close(); } finally { await transport.close(); } }
  }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  if (!process.argv[2] || !process.argv[3]) throw new Error('Usage: node mcp.mjs WORKSPACE RELATIVE_FILE [DEPENDENCY_DIRECTORY]');
  console.log(JSON.stringify(await runMcp(process.argv[2], process.argv[3], process.argv[4]), null, 2));
}

成功结果保留 source: "files:read_text_file" 与原始 callId,message 中包含实际文件内容。文件不存在时程序失败,不伪装为成功的空文本。

3. 为什么需要适配器 ​

接口Ditto 期望应用需要处理
listTools(params, options)工具列表与可选 nextCursor保留 SDK 的 this,传递 signal,映射 inputSchema
callTool(params, options)content / structuredContent / references / isError官方 SDK 的第三参数才是调用 options;第二参数是结果 schema
close不属于 McpClient由应用在 Runtime 排空后关闭客户端与 transport

官方 SDK 的内容块可能含特定多模态类型。文本/JSON/引用之外的内容应明确归一化或保留为应用允许的引用,不能把不兼容的联合类型直接强制断言后塞入通用 Message。

4. discover 和 invoke 的返回值不同 ​

{operation:"discover",server:"files"} 返回 capabilities;{operation:"invoke",server:"files",call:{...}} 返回 result。先检查 discriminant operation,再访问字段。result 是 ExternalResult,不是 NodeResult。

discover 会处理分页。McpRegistry 默认限制总页数与总工具数,拒绝重复 cursor 和非法 schema。大型 server 可显式设定 maxDiscoveryPages/maxCapabilities,仍应保持有界。

5. 权限和边界 ​

Sandbox 的 mcp: ["files"] 控制允许的 server,不会自动限制该 server 内每个工具。示例的文件根目录由 filesystem server 自己限制。若只允许读工具,需要在适配器的 listTools/callTool 中过滤并拒绝其他名称。

远端 HTTP MCP 的认证、TLS、session 续期与连接配置由对应 SDK transport 管理。主机地址和 token 由可信控制器配置,不从模型动作参数直接采用。HTTP transport 接好后仍可复用相同 Graph 和中立适配器。

6. 加入 Agent 循环 ​

第一次加载或 server 能力变化时 discover,将允许动作映射为模型的 action descriptors。INFER 返回的动作只包含调用意图;校验 server/name/schema 后由 MCP Graph 执行。OBSERVE 结果写入 Context,然后 Loop 决定继续、返回或升级人工。

一个 callId 应关联一次逻辑调用;有副作用时另外提供服务支持的幂等标识。MCP transport 超时不保证 server 回滚,恢复时仍需核对效果。

逐接口 MCP API · 真实 MCP 测试脚本 · ReAct

Ditto · @codesoul-co/ditto · Node.js 24+