启动一个进程并不等于完成 MCP 握手,工具出现在列表里也不证明参数校验生效。本文编写一个不访问模型、网络或知识库的加法工具,由真实 SDK 客户端经 stdio 验证初始化、发现和调用。之后再对照 ChatCrystal,理解协议适配层负责哪些事情。
固定练习版本和通信边界
ChatCrystal 固定提交的 依赖清单与锁文件解析到 @modelcontextprotocol/sdk 1.30.0。下文使用这个版本及 Zod 4.6.2,在 Node 24+ 上运行。SDK 包名与 API 会变化,不要把这个练习与其他大版本的导入写法混用。
MCP 的宿主组织 AI 应用,客户端负责连接某个服务端,服务端提供工具等能力;transport 是通信通道。stdio 通过子进程的 stdin/stdout 传递按行分隔的 JSON-RPC 消息。这里没有 HTTP 监听端口,但并不意味着服务端处理函数不能访问网络。协议边界见stdio 传输规范。
stdio 的 stdout 留给协议消息,诊断信息使用 stderr。server.connect 安装传输与处理能力,初始化仍需要客户端发送请求;单独启动服务端后安静等待,并不是启动失败。
编写只有一个工具的服务端
在新临时目录安装教学依赖:
npm init -y
npm install --ignore-scripts --no-audit --no-fund @modelcontextprotocol/sdk@1.30.0 zod@4.6.2
保存 server.mjs。这是独立教学服务端,不是 ChatCrystal 的入口,处理函数只做加法。
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({ name: 'lesson-add', version: '1.0.0' });
server.registerTool(
'add',
{
description: 'Add two finite numbers in this offline lesson.',
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => ({ content: [{ type: 'text', text: String(a + b) }] })
);
await server.connect(new StdioServerTransport());
console.error('lesson server connected to stdio');
工具名用于调用,description 解释用途,inputSchema 定义输入,处理函数返回 content。描述有助于阅读和选用工具,但不是参数校验生效的必需条件,也没有可据此保证调用准确率的测量。
用客户端证明握手和参数错误
在同一目录保存 client.mjs,然后运行 node client.mjs。客户端会启动刚才的服务端子进程;不需要同时手动启动另一个实例。
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const client = new Client({ name: 'lesson-client', version: '1.0.0' });
const transport = new StdioClientTransport({
command: process.execPath,
args: [fileURLToPath(new URL('./server.mjs', import.meta.url))],
});
try {
await client.connect(transport);
const { tools } = await client.listTools();
assert.deepEqual(
tools.map((tool) => tool.name),
['add']
);
const good = await client.callTool({ name: 'add', arguments: { a: 4, b: 3 } });
assert.equal(good.content[0].text, '7');
const bad = await client.callTool({ name: 'add', arguments: { a: 'four', b: 3 } });
assert.equal(bad.isError, true);
console.log('initialize, list, valid call, and invalid arguments pass');
} finally {
await client.close();
await transport.close();
}
把结果期望从 '7' 改为 '8',程序会失败;恢复后通过。错误输入验证的是字符串不能通过数值 schema,并不是语言模型“自动改正了参数”。这里只对 SDK 客户端与教学服务端做了协议检查,没有测试任何 AI 宿主的安装路径、授权界面或自动选工具行为。
ChatCrystal 的 MCP 层是 HTTP 适配层
当前 MCP 实现先创建 CrystalClient 并调用 status,再注册七个工具:search_knowledge、get_note、list_notes、get_relations、recall_for_task、validate_task_memory、write_task_memory。服务端版本读取 CLI 包版本,不是固定写成早期版本号。
工具处理函数通过 HTTP 客户端委托已有 API,并把结果序列化为 text content。记忆相关工具复用独立请求 shape。MCP 进程没有直接持有知识库数据库,但客户端还有连接检查等进程内状态;不能把重启协议进程理解成任意在途写入都绝不会丢失或重复。
crystal mcp 的 命令入口解析 base URL 和 token,连接目标才决定工具访问哪个知识库。名称相同的 MCP 工具不代表相同实例;真实接入与连接配置见MCP 使用指南。本文没有运行这个命令,避免让教学检查访问真实数据或触发后台服务。
扩展工具时先定义失败结果
增加一个工具之前,写清输入、读取或写入范围、成功输出和失败类型。输入 schema 不等于业务授权;工具描述也不构成调用者权限。若处理函数调用 HTTP API,还需区分连接失败、认证拒绝、业务错误和协议层错误,不要把它们都包装成成功文本。
本练习的完成条件是初始化成功、工具列表正确、有效输入得到预期结果、无效输入被拒绝、关闭客户端后子进程收尾。模型能否在合适时机选择工具,应由另一个包含具体任务和宿主的评估来验证。