它能做什么 — 把清单写下来,客户端就只属于那一个服务端

5 分钟
目标MCP 客户端课程第二篇。去问工具和资源的清单。两行就完了,而有没有这两行,决定了它是「那台服务端专用的客户端」还是「站到任何服务端面前都行的客户端」。

第一篇接上了。现在去问。

两行

final tools = await client.listTools();
stdout.writeln('tools: ${tools.map((t) => t.name).join(", ")}');
for (final t in tools) {
  stdout.writeln('  ${t.name} — ${t.description}');
}

final resources = await client.listResources();
stdout.writeln('resources: ${resources.map((r) => r.uri).join(", ")}');
tools: desk.admit
  desk.admit — Admit a number of people from the queue
resources: ui://desk, desk://waiting

写下来,它就成了专用的

也可以这么写:

// 这台服务端有 desk.admit
await client.callTool('desk.admit', {'count': 1});

能跑。而 就在那一刻,这个客户端成了那台服务端专用的。 只要有一个工具名嵌进代码,它就没法站到别的服务端面前。

问来的就不一样。清单是运行时到的,所以客户端 不需要知道自己接的是什么。 接开发板就来 led.set,接柜台就来 desk.admit。同一份代码。

说明文在这里被用上

listTools() 给的不只是名字。说明文和输入模式会一起来。

desk.admit — Admit a number of people from the queue

给人用的客户端拿它做按钮文案和提示。模型则读它来决定调什么。服务端第二篇说的「说明文不是注释,是会跑的代码」,在这里被消费掉。

资源是以 uri 来的

resources: ui://desk, desk://waiting

ui:// 是画面,desk:// 是值。这不是契约规定的,而是 服务端自己定的命名规矩。 客户端不需要知道,第四篇读画面时直接用清单里的 uri 就行。

校验 —— 硬编码就失败

这一篇的主张是「客户端不是被告知,而是去问」。所以检查会去看客户端的代码。

grep -q 'tools: .*desk.admit' captures/s2.txt || die "tools not discovered"
grep -q 'resources: .*ui://desk' captures/s2.txt || die "resources not discovered"

# The claim is that the client was not told; it asked.
grep -q "'desk.admit'" bin/step2.dart && die "the tool name is hardcoded in the client"

bin/step2.dart 里出现一次工具名就失败。

自己跑一遍

dart run bin/step2.dart
bash verify.sh

可以带走的

  1. listTools() / listResources() — 表面在运行时才到
  2. 不要写下名字 — 写了就成了专用客户端
  3. 禁止硬编码的检查 — 防止这个主张被悄悄破掉

下一篇

调用工具。以及服务端拒绝时,那并不是异常。

运行示例

makemind-academy/course_client/
git clone https://github.com/makemind-academy/course_client
cd course_client
dart pub get
(cd course-server && dart pub get)
dart run bin/step2.dart
在 GitHub 上打开 →