工場設備の前に立つ整備士が知りたいのはたいてい数点だ。この機械は整備周期を過ぎているか。振動は限界内か。手を入れる前に何を確認すべきか。
この情報はすでに工場のどこかに全部ある。整備履歴の DB に、センサー収集システムに、安全点検表の文書に。ただ、機械の前に立つ人に問う手段が無い。
だから LLM をつなげばいい、という発想が自然に出てくる。そしてそこから本当の問題が始まる。「コンベア 3 号は整備周期を過ぎています」と言う機械と、そう言いながら何も照会していない機械を区別する方法が無ければ、その助言を信じて機械の中に手を入れる人が危険になる。
この記事はその区別を画面に載せるところまで作ってみた記録だ。答えの横に、その答えを作った道具の呼び出しが一緒に出る。 そして道具がひとつも呼ばれなかった答えは、そうと表示される。
まず結果
機械について問うた。答えの下に その答えを作った道具の呼び出しが一行付いている。

点検表を問うた。工場のエンジニアが書いた順序のまま出てくる。

そしてこれがこの記事でいちばん重要な画面だ。工場が答えられない質問を投げた。

道具が 0 件 呼ばれ、画面がそう言っている。前の二つの答えと同じ顔をしていない。
全体像
plant_server (mcp_server) assistant (client + server) tablet
equipment.list ◀──MCP── a client of the plant ──MCP──▶ ui://assistant
equipment.read a server to the screen answer + call record
checklist.get the model in the middle
真ん中の部品の正体を先に明かしておく。このサンプルのモデルの席には決定論的なスタブが入っている。 API キー無しで誰でも回して確かめられなければならないからだ。そしてそれがこの記事でいちばん重要でない部分である — 読む値打ちがあるのは その両脇の配線であり、その配線は真ん中がスタブでも Claude でも同じだ。差し替え地点は下にそのまま見える。
① 設備サーバーは判断しない
まず道具の側。ここで意図的にひとつやらない — 「この機械は大丈夫/危険」をサーバーが言わない。
handler: (args) async {
final id = (args['id'] as String?)?.toUpperCase();
final m = _machines[id];
if (m == null) { /* ... */ }
// The server states facts, and how those facts compare to their limits.
// It does not say the machine is "fine" — that word belongs to the person
// holding the checklist.
final overdue = (m['runHours'] as int) > (m['serviceEveryHours'] as int);
final vibrationOver =
(m['vibrationMm'] as num) > (m['vibrationLimitMm'] as num);
return _json({
'id': id,
...m,
'serviceOverdue': overdue,
'vibrationOverLimit': vibrationOver,
});
}
serviceOverdue: true は事実だ。safe: false は判断だ。サーバーは前者だけを出す。
点検表も同じだ。道具の説明文に 「この手順は工場のエンジニアが定めたもので、言い換えてはならない」を入れた。道具の説明文はモデルが実際に読むテキストである。
server.addTool(
name: 'checklist.get',
description:
'Get the plant safety checklist for a machine type (press, conveyor, welder). '
'These steps are set by the plant engineer and must not be paraphrased.',
/* ... */
);
② 配線 — 道具がモデルに届く場所
ここがこの記事の本論だ。三つをつなぐ。
final llm = McpLlm()..registerProvider('bench', BenchProviderFactory(bench));
final client = await llm.createClient(
providerName: 'bench',
config: LlmConfiguration(model: 'bench-1'),
mcpClient: mcpClient,
systemPrompt: assistantSystemPrompt,
);
// 3. Ask.
for (final q in questions) {
stdout.writeln('\n> $q');
final response = await client.chat(q, enableTools: true);
stdout.writeln(response.text.trim());
}
// 4. What the plant was actually asked. An assistant's answer is only worth
// what the record behind it is worth.
final audit = await mcpClient.callTool('audit.log', const {});
final first = audit.content.first;
if (first is TextContent) {
final calls = (jsonDecode(first.text) as Map<String, dynamic>)['calls'] as List;
stdout.writeln('\n# tool calls the plant actually received (${calls.length}):');
mcpClient: の一行が配線の全部だ。chat(enableTools: true) が道具の一覧をモデルに渡し、モデルが道具を呼べば MCP で実行し、結果を付けてもう一度問うて最終の答えを受け取る。
実際のモデルに変えるのもこの場所だ。 サンプルにコメントで残してある。
// llm.registerProvider('claude', ClaudeProviderFactory());
// final client = await llm.createClient(
// providerName: 'claude',
// config: LlmConfiguration(apiKey: Platform.environment['ANTHROPIC_API_KEY'],
// model: 'claude-sonnet-5'),
// mcpClient: mcpClient,
// systemPrompt: systemPrompt,
// );
//
// Nothing below this point changes.
二行だ。その下は一文字も変わらない。
③ システムプロンプト — 文ごとに理由がある
短く書いた。各文がそこにある理由は、その文を抜くと特定の悪い答えが出るからだ。
You help a maintenance technician standing in front of a machine.
Rules:
- Every number you state must have come from a tool result in this conversation.
If you do not have it, call the tool. Never estimate a reading.
- Safety checklist steps are the plant engineer's. Quote them in order and do
not paraphrase, shorten or reorder them.
- You do not decide whether a machine is safe to work on. You report what the
readings are, how they compare to their limits, and what the checklist says.
- If the plant has no tool that answers the question, say so.
- 一行目を抜けば でっち上げの数値が出る。もっともらしい振動値は実際の振動値と区別が付かない。
- 二行目を抜けば 要約された安全手順が出る。4 段階を 3 段階に縮めた点検表は点検表ではない。
- 三行目を抜けば 判断が出る。「作業して構いません」はこのシステムが言う言葉ではない。
- 四行目を抜けば 知らないことを知ったふりをする。
ただしプロンプトは依頼であって保証ではない。だから次の節が要る。
④ 根拠を数える場所
プロンプトで「道具を使え」と言っておいて実際に使ったかを見なければ、使わなかった答えと使った答えが画面で同じ顔をする。だから 質問ひとつが誘発した道具呼び出しだけを正確に切り出す。
? 'No tool was called. Treat this as the assistant talking about '
'itself, not about the plant.'
: '';
return _state();
},
);
server.addTool(
name: 'assistant.state',
description: 'Current question, answer and the tool calls behind it',
inputSchema: const {'type': 'object', 'properties': {}},
そして数える側は自分の呼び出しを除かねばならない。
// audit.log is itself a tool call, but it is ours, not the assistant's —
// counting it would inflate every answer's evidence by one.