跳到正文
曾亮懿 ZengLiangYi Blog

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

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

曾亮懿

曾亮懿

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

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 接入。

分享:

相关文章

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

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

曾亮懿 曾亮懿
阅读约 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、开发者工具和日常学习记录,欢迎交流实现细节。