A maintenance technician standing in front of a machine usually wants a few things. Is this machine past its service interval? Is vibration within limits? What do I check before I touch it?
All of this already exists somewhere in the plant. In the maintenance history database, in the sensor collection system, in the safety checklist document. What is missing is a way for the person standing in front of the machine to ask.
So the thought of attaching an LLM comes naturally. And that is where the real problem starts. If there is no way to tell apart a machine that says "conveyor 3 is past its service interval" from a machine that says so having queried nothing, then the person who trusts that advice and puts a hand inside the machine is in danger.
This article is a record of building that distinction onto the screen. Beside the answer sits the tool call that produced it. And an answer that called no tool is marked as such.
The result first
Asked about a machine. Below the answer sits one line: the tool call that produced it.

Asked for the checklist. It comes out in the order the plant engineer wrote it.

And this is the most important screen in the article. A question the plant cannot answer.

Zero tools were called, and the screen says so. It does not wear the same face as the two answers before it.
The whole picture
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
Let me state what the middle piece is up front. The model slot in this sample holds a deterministic stub. Anyone has to be able to run and check this without an API key. And that is the least important part of this article — what is worth reading is the wiring on either side of it, and that wiring is the same whether the middle is a stub or Claude. The swap point is shown below as it is.
① The plant server does not judge
The tool side first. One thing is deliberately not done here — the server does not say "this machine is fine / dangerous."
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 is a fact. safe: false is a judgement. The server emits only the former.
Same for the checklist. The tool description carries "these steps are set by the plant engineer and must not be paraphrased." A tool description is text the model actually reads.
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.',
/* ... */
);
② The wiring — where the tools reach the model
This is the substance of the article. Three things joined.
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}):');
The mcpClient: line is the whole of the wiring. chat(enableTools: true) hands the tool list to the model, executes tool calls over MCP when the model calls them, attaches the results and asks once more for the final answer.
Swapping in a real model happens right here too. Left in the sample as a comment.
// 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.
Two lines. Below that, not a character changes.
③ The system prompt — every sentence has a reason
Written short. Each sentence is there because removing it produces a specific bad answer.
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.
- Remove the first and you get invented readings. A plausible vibration value is indistinguishable from a real one.
- Remove the second and you get a summarised safety procedure. A four-step checklist shortened to three is not a checklist.
- Remove the third and you get a judgement. "You may work on it" is not this system's to say.
- Remove the fourth and it pretends to know what it does not.
But a prompt is a request, not a guarantee. Which is why the next section is needed.
④ Where the grounds get counted
Say "use the tools" in a prompt and never look at whether they were used, and an ungrounded answer looks exactly like a grounded one on screen. So only the tool calls that one question triggered are cut out precisely.
? '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': {}},
And the counting side has to exclude its own calls.
// 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.
This content requires Developer or above
Sign in and upgrade your plan to continue reading.
View Plans