•
阅读时长 3 分钟

用 Node.js 实现并调用一个只读 MCP 工具

目录

AI 应用需要查询数据库、读取文档或调用接口时,可以把这些操作提供成工具。MCP 约定了客户端和工具服务之间怎样交换能力说明、参数和结果,应用不必为每个服务重新设计一套通信格式。

工具本身仍然是普通业务代码。是否有权限读取、数据怎么查、结果是否正确,都要由实现负责。下面做一个查询固定文档的只读工具,用客户端直接调用,不依赖模型选择工具。

安装依赖

新建目录,安装固定版本的 SDK 和 Zod:

npm init -y
npm install @modelcontextprotocol/sdk@1.12.1 zod@3.24.2

示例使用 .mjs 文件,通过 stdio 传输。客户端启动服务端子进程,使用它的 stdin 和 stdout 通信。

服务端定义工具

创建 server.mjs:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const notes = [
  { id: 'stream', title: '流式接口', text: '按行解析 NDJSON,支持取消。' },
  { id: 'cookie', title: 'Cookie', text: 'HttpOnly Cookie 不被 document.cookie 读取。' },
];

const server = new McpServer({ name: 'readonly-notes', version: '1.0.0' });
server.tool(
  'search_notes',
  '查找固定的公开示例文档',
  { query: z.string().min(1).max(80) },
  async ({ query }) => {
    const result = notes.filter(note =>
      (note.title + note.text).toLowerCase().includes(query.toLowerCase())
    );
    return { content: [{ type: 'text', text: JSON.stringify(result) }] };
  },
);
await server.connect(new StdioServerTransport());

工具名是 search_notes,描述说明它的用途,参数限制 query 长度为 1~80。SDK 将这些信息提供给客户端,客户端可以列出工具,也可以检查调用需要哪些参数。

真正的查询是字符串包含匹配,不是语义搜索。两条固定数据足够检查通信过程,同时避免示例意外读取本机其他文件。

客户端发现和调用工具

创建 client.mjs,放在 server.mjs 同一目录:

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';

const transport = new StdioClientTransport({
  command: process.execPath,
  args: [fileURLToPath(new URL('./server.mjs', import.meta.url))],
});
const client = new Client({ name: 'notes-client', version: '1.0.0' });

try {
  await client.connect(transport);
  const { tools } = await client.listTools();
  console.log('工具:', tools.map(tool => tool.name));

  const result = await client.callTool({
    name: 'search_notes', arguments: { query: 'Cookie' },
  });
  const text = result.content.find(item => item.type === 'text')?.text;
  if (!text) throw new Error('工具没有返回文本结果');
  const matches = JSON.parse(text);
  assert.equal(matches[0].id, 'cookie');
  console.log('查询结果:', matches);

  await assert.rejects(
    client.callTool({ name: 'search_notes', arguments: { query: '' } }),
    error => error.code === -32602,
  );
  await assert.rejects(
    client.callTool({ name: 'missing', arguments: {} }),
    error => error.code === -32602,
  );
  console.log('非法参数和未知工具检查通过');
} finally {
  await client.close();
}

执行 node client.mjs,客户端会启动 server.mjs,完成连接、工具列表查询和调用,最后关闭连接。没有匹配项时,工具返回空数组;参数为空或工具名不存在时,示例固定版本 SDK 返回协议错误,客户端调用会拒绝。

stdio 的日志放在哪

服务端 stdout 是协议通道,不能随意加入普通日志,否则客户端可能把日志当成协议消息解析。服务端调试信息使用 console.error 写到 stderr,协议消息交给 SDK 发送。

客户端自己的 stdout 可以打印结果,因为这里它不是提供给另一个 MCP 客户端的协议服务。两个进程的输出用途需要区分。

调用失败有不同的位置

失败位置例子客户端处理
连接或进程服务端启动失败、连接关闭捕获连接或调用异常
协议与参数未知工具、query 不符合约束示例中 Promise 拒绝,错误码 -32602
工具业务查询服务暂时不可用可返回标记 isError 的工具结果
返回内容工具声称成功,但结果格式不对应用检查内容类型及业务数据

不能把所有失败都当成 result.isError。有些调用根本没有返回普通结果,而是抛出错误。工具结果和错误约定见 MCP Tools 规范。

接真实数据时的限制

只读不代表可以读任何内容。如果把固定数组换成目录或数据库,要限制允许访问的数据范围、返回长度和查询耗时。读取文件时不能直接用 query 拼任意路径,也需要处理路径穿越和符号链接等情况。

模型选择哪个工具,与工具服务是否正确是两件事。直接客户端调用可以独立检查服务;接入模型后,还要观察它是否选择了正确工具、参数是否合理,以及工具返回的外部文本是否被错误地当成指令执行。