Have you ever counted the terminals in a café? An order kiosk, a counter POS, a card reader, paper tickets going to the kitchen, and a stock ledger. Plenty of terminals, and they do not speak to each other. So a person joins them up. Staff read the kiosk order and shout it to the kitchen, payment happens on yet another machine, and stock is reconciled by hand at night.
Adding one more terminal cannot be the answer. The payment terminal already in the shop simply does not know how to share a screen.
This article is a record of standing one server up beside that terminal, attaching a tablet, a PC and a kitchen screen — and getting each attached device to become a kiosk, a POS and a kitchen display. No app was built for any of the three devices. And the card authorisation path was not touched by a single line. Every screen below is a capture of pixels really rendered from a real run.
The result first — three screens
A customer orders on the tablet.

At the same moment, those orders are up on the kitchen screen.

Charge at the counter and the terminal returns an authorisation.

The three screens are three different definitions served by the same server. There is no separate tablet app, POS app and kitchen app.
The whole picture
terminal_sim (C) store_server (Dart · mcp_server) clients ×3
terminal endpoint ──serial/usb──▶ owns order and sales state ──MCP──▶ tablet ui://kiosk
answers only with an approval serves 3 screen definitions ──MCP──▶ PC ui://pos
relays requests via the adapter ──MCP──▶ kitchen ui://kds
| Piece | What it does | Who writes it |
|---|---|---|
| Shop server app | Holds order, prep and sales state, and serves the screen definition matching the role that connected | The developer |
| Terminal adapter | Passes an authorisation request to the terminal and takes the answer back. That is all | The developer |
| Clients ×3 | Render the definition the server gave | A few lines of role selection |
| Terminal endpoint | Returns approvals | (simulated in this sample — see below) |
Here, the inside of the payment terminal is not our code. Card data, the approval decision and the VAN link all stay inside the terminal's certified secure area. What we do is ask "please approve this amount" and take an answer. That asymmetry is the whole of this article.
① Where one server serves three screens
This is the centre of the article. A device connects and asks for its screen; it gets that screen.
const screens = <String, (String, String, Map<String, dynamic>)>{
'ui://kiosk': ('Self Order Kiosk', 'Customer-facing order screen', kioskDefinition),
'ui://pos': ('Counter POS', 'Owner-facing sales and payment screen', posDefinition),
'ui://kds': ('Kitchen Display', 'Kitchen order queue', kdsDefinition),
};
screens.forEach((uri, spec) {
final (name, description, definition) = spec;
server.addResource(
uri: uri,
name: name,
description: description,
mimeType: 'application/json',
handler: (requestedUri, params) async => ReadResourceResult(
contents: [
ResourceContentInfo(
uri: requestedUri,
mimeType: 'application/json',
text: jsonEncode(definition),
),
],
),
);
});
The substance of "the device becomes the terminal" is this three-line table. Want another screen — a waiting-number display in the window — add a line here. You do not build an app to install on that device; you add an entry to this table.
The client side is this short. The role is the screen name.
const _role = String.fromEnvironment('ROLE', defaultValue: 'kiosk');
const _screenForRole = <String, String>{
'kiosk': 'ui://kiosk',
'pos': 'ui://pos',
'kds': 'ui://kds',
};
// Attach to the server, take my screen, hand it to the runtime.
final uri = _screenForRole[_role] ?? 'ui://kiosk';
final resource = await client.readResource(uri);
final definition = jsonDecode(resource.contents.first.text!) as Map<String, dynamic>;
final runtime = MCPUIRuntime();
await runtime.initialize(definition);
On the kitchen display it runs with --dart-define=ROLE=kds, on the counter PC with ROLE=pos. The same binary. Nowhere in this file is there code describing what the kitchen screen looks like — the server knows that.
② The terminal adapter — the code most likely to be copied out of this article
Send an authorisation request and wait for the answer. That really is all it does, and that thinness is the argument.
/// Send one request and wait for the answer bearing the same id.
///
/// Matched by id rather than by arrival order, because a link is a stream,
/// not a call stack. A status query slipping in while an authorisation is
/// still in flight must not steal the authorisation's answer.
Future<Map<String, dynamic>> call(
String tool, [
Map<String, dynamic> args = const {},
]) async {
final id = _nextId++;
final completer = Completer<Map<String, dynamic>>();
_pending[id] = completer;
final request = jsonEncode({'id': id, 'tool': tool, 'args': args});
transcript.add('=> $request');
_proc.stdin.writeln(request);
final reply = await completer.future.timeout(
requestTimeout,
onTimeout: () {
_pending.remove(id);
throw TimeoutException('terminal did not answer $tool', requestTimeout);
},
);
if (reply['ok'] != true) {
throw StateError('terminal refused $tool: ${reply['error']}');
}
return (reply['result'] as Map).cast<String, dynamic>();
}
Let me write down why the timeout is three seconds. That is arithmetic, not taste.
/// How long to wait for the terminal.
///
/// The simulator answers in 70–160 ms. A real authorisation is dominated by
/// the VAN round trip, so the order of magnitude is the same. Three seconds
/// is set deliberately far above that distribution. A timeout here must mean
/// "the terminal is gone," not "it was slower than usual." Tighten it to a
/// few hundred milliseconds and a perfectly good authorisation turns into a
/// failure — and a payment reported as failed that actually went through is
/// the worst outcome this adapter can produce.
final Duration requestTimeout;
And the payment handler. What this code does not do matters more.
handler: (args) async {
final unpaid = _orders.where((o) => o['paid'] == false).toList();
if (unpaid.isEmpty) { _notice = 'Nothing to charge'; return _state(); }
final amount = unpaid.fold<int>(0, (sum, o) => sum + (o['price'] as int));
final started = DateTime.now();
try {
final result = await terminal.call('terminal.authorize', {'amount': amount});
final elapsed = DateTime.now().difference(started);
for (final o in unpaid) { o['paid'] = true; }
_salesTotal += amount;
_lastApproval = '${result['approvalCode']} (${result['last4']})';
_lastRoundTripMs = elapsed.inMilliseconds;
} on TimeoutException {
// A timeout is not a decline. We do not know what the terminal did,
// so we say we do not know. We do not decide it failed on our own.
_notice = 'Terminal did not answer — check the receipt before retrying';
}
return _state();
}
It does not read the card. It does not judge whether the customer may pay. It does not connect to the VAN. It counts the amount, asks, and records the answer. Everything that happens between the asking and the answer happens inside certified hardware this sample never opens.
Please note the timeout handling. The most dangerous state in payments is not a decline but not knowing. If the authorisation actually went through and we treat it as a failure, the customer is out of pocket with no order. So when we do not know, we write on the screen that we do not know.
This content requires Developer or above
Sign in and upgrade your plan to continue reading.
View Plans