跳到正文
曾亮懿 ZengLiangYi Blog

实现最小 MCP Server:注册工具与验证 stdio 调用

用固定版本 TypeScript SDK 编写一个离线加法工具,从客户端完成初始化、发现、调用与参数错误检查,再对照 ChatCrystal 的适配边界。

曾亮懿

曾亮懿

阅读约 4 分钟
目录 展开 收起

启动一个进程并不等于完成 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,还需区分连接失败、认证拒绝、业务错误和协议层错误,不要把它们都包装成成功文本。

本练习的完成条件是初始化成功、工具列表正确、有效输入得到预期结果、无效输入被拒绝、关闭客户端后子进程收尾。模型能否在合适时机选择工具,应由另一个包含具体任务和宿主的评估来验证。

分享:

相关文章

MCP 协议的边界:发现、实例身份与调用权限

对照 ChatCrystal 的七个 MCP 工具,用固定 SDK 的内存传输验证同名工具可指向不同实例,并区分参数 schema、行为提示与应用权限。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #MCP #Node.js

AI 对话格式差异:事件、快照与跨库消息定位

对照五个固定适配器,区分 JSONL 容器、事件语义与 SQLite 键布局,用两个内存数据库观察消息索引、孤立数据和未知版本。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #SQLite

AI 对话管理方案:四种存储路径与迁移验收

比较 Claude Code、Cursor、Copilot、Trae 的固定适配器输入,区分原始副本、可读导出与知识库,并用合成清单验证身份和信息保留。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #SQLite

从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录,欢迎交流实现细节。