MCP 减少了应用之间重复实现工具发现与调用协议的工作。它规定通信方式和能力描述,实际知识库、权限和输出语义仍由应用承担。前篇最小 MCP 服务已经验证 stdio 握手;这篇进一步检查同名工具的实例身份,以及“能发现”与“允许调用”的差别。
Host、Client 与 Server 分工
2025-11-25 架构规范区分 Host、Client 和 Server。Host 管理模型交互、多个连接和调用策略;Client 与一个 Server 通信;Server 暴露工具等能力。本文固定 SDK 1.30.0 的 LATEST_PROTOCOL_VERSION 也是 2025-11-25,因此引用这一修订,不将其称为当前最新规范。
模型的工具调用表达某项操作的请求,应用负责执行;MCP 让发现与调用可跨应用复用。执行器可以访问远程服务,也可以在本地运行。不能用“Function Calling 必在同一进程、MCP 必在远程”判断集成边界。
该修订的标准传输包括 stdio 与 Streamable HTTP,参见传输规范。stdio 不要求 MCP HTTP 监听端口,handler 仍可能访问网络。子进程权限、连接参数、服务端鉴权和业务写入检查不会因此消失。
ChatCrystal 暴露七个工具,背后仍是 HTTP
MCP server.ts先创建 CrystalClient、请求 status,再注册七个工具并连接 StdioServerTransport。版本来自 CLI 包。四个知识读取工具是 search_knowledge、get_note、list_notes、get_relations;另外三个是 recall_for_task、validate_task_memory、write_task_memory。注册名与原稿相符,不能把旧版硬编码版本或“不足150行”当作当前证据。
handler 委托 CrystalClient 并把结果序列化为文本。语义检索、关键词列表和任务记忆回忆是不同入口;validate 的预检也不替代 write 的实际业务校验。协议层没有直接打开知识库数据库。
mcp command调用 resolveConnection解析 base URL 和 token,可能连接本地默认、显式、环境或已保存目标。stdio 连接相同而 HTTP 目标不同,会访问不同实例;显式 loopback 的实例模式检查也与隐式本地默认不同。工具名、成功 status 和某条返回文字都不足以证明连到了预期数据集。本文不执行实际 crystal mcp,避免启动服务或访问真实知识库。
用两个内存 Server 检查边界
独立目录安装 npm install --ignore-scripts --save-exact @modelcontextprotocol/sdk@1.30.0 zod@4.6.2,保存 lesson.mjs,以 Node 24+ 运行。两个真实 SDK Client/Server 通过 InMemoryTransport 通信;只有临时字符串状态,没有 stdio 子进程、HTTP、模型或实际 ChatCrystal。allowedCall 是本篇手写的教学 Host 策略,不能代表某款 AI Host 的权限实现。
import assert from 'node:assert/strict';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { z } from 'zod';
async function peer(instance) {
let value = instance;
let reads = 0;
const server = new McpServer({ name: 'fixture-' + instance, version: '1.0.0' });
server.registerTool(
'read_fixture',
{
description: 'Read one synthetic value in this instance.',
inputSchema: { id: z.number() },
annotations: { readOnlyHint: true },
},
async () => {
reads += 1;
return { content: [{ type: 'text', text: value }] };
}
);
server.registerTool(
'write_fixture',
{
description: 'Replace one temporary in-memory fixture value.',
inputSchema: { value: z.string().min(1) },
annotations: { readOnlyHint: false },
},
async (input) => {
value = input.value;
return { content: [{ type: 'text', text: value }] };
}
);
const client = new Client({ name: 'fixture-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
return {
client,
server,
get reads() {
return reads;
},
};
}
async function allowedCall(client, name, args, allowed) {
if (!allowed.has(name)) throw new Error('blocked by teaching host policy');
return client.callTool({ name, arguments: args });
}
const a = await peer('A');
const b = await peer('B');
try {
const names = (await a.client.listTools()).tools.map((tool) => tool.name).sort();
assert.deepEqual(names, ['read_fixture', 'write_fixture']);
assert.deepEqual((await b.client.listTools()).tools.map((tool) => tool.name).sort(), names);
const allowed = new Set(['read_fixture']);
const first = await allowedCall(a.client, 'read_fixture', { id: 1 }, allowed);
const second = await allowedCall(b.client, 'read_fixture', { id: 1 }, allowed);
assert.equal(first.content[0].text, 'A');
assert.equal(second.content[0].text, 'B');
const before = a.reads;
const bad = await a.client.callTool({ name: 'read_fixture', arguments: { id: 'one' } });
assert.equal(bad.isError, true);
assert.equal(a.reads, before);
await assert.rejects(
() => allowedCall(a.client, 'write_fixture', { value: 'changed' }, allowed),
/blocked by teaching host policy/
);
assert.equal(
(await a.client.callTool({ name: 'read_fixture', arguments: { id: 1 } })).content[0].text,
'A'
);
await a.client.callTool({ name: 'write_fixture', arguments: { value: 'changed' } });
assert.equal(
(await a.client.callTool({ name: 'read_fixture', arguments: { id: 1 } })).content[0].text,
'changed'
);
console.log('instance identity, schema errors, and host policy boundaries pass');
} finally {
await a.client.close();
await b.client.close();
await a.server.close();
await b.server.close();
}
两实例发现相同工具名,却读到 A 和 B。错误 id 触发 isError,read handler 计数不增加。教学策略阻止写入,直接 SDK 调用仍能修改临时状态:行为标注和 schema 不是业务授权。
将第二实例的期望 B 改成 A,实例断言失败;删掉 allowedCall 的名单检查,预期拒绝断言失败。恢复后应通过。它们验证指定样本,没有证明任意 Host 都支持这一 SDK 修订或采用相同策略。
发现之后还要验收业务结果
工具规范把 annotations 作为行为描述,并要求客户端审慎对待不可信来源。即使工具声明只读,也需核查真实 handler、连接权限和返回数据。参数合法不证明查询相关、内容正确或写入有权。
实际集成分别验收协议初始化与版本、工具清单、目标实例、schema 拒绝、鉴权、业务拒绝、输出和退出。对写工具增加重复调用与失败后的状态检查。MCP 统一了可复用接口;可靠性仍需要这些具体证据。需要配置真实连接时,继续读MCP 接入。