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 拼任意路径,也需要处理路径穿越和符号链接等情况。
模型选择哪个工具,与工具服务是否正确是两件事。直接客户端调用可以独立检查服务;接入模型后,还要观察它是否选择了正确工具、参数是否合理,以及工具返回的外部文本是否被错误地当成指令执行。