Start the Server — and Write Nothing to stdout
5 minThe course starts here. Over six pieces one server grows. Each piece adds one thing, and every step is a complete server you can run on its own.
The first piece builds a server that does nothing. No tools, no resources. And without this step everything after it falls over.
All of it
import 'dart:async';
import 'dart:io';
import 'package:mcp_server/mcp_server.dart';
void main(List<String> args) async {
const config = McpServerConfig(
name: 'Course',
version: '1.0.0',
capabilities: ServerCapabilities(),
);
final server = McpServer.createServer(config);
final transport = McpServer.createStdioTransport().get();
server.connect(transport);
stderr.writeln('course step1: up, nothing registered');
await Completer<void>().future;
}ServerCapabilities() is empty. That is the server saying it has nothing to offer yet. From the next piece on, this fills in.
The Completer<void>().future on the last line never completes. When main returns the process ends, so this holds it open.
Connect and ask
You can poke it by hand, with no client at all. It speaks over stdio, so pushing a line in is enough.
{ printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}\n'
sleep 2; } | dart run bin/step1.dartWhat came back.
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","serverInfo":{"name":"Course","version":"1.0.0"},"capabilities":{}}}serverInfo carries the name we wrote. That is everything this first piece confirms. The server is alive and introduces itself by the contract.
stdout is reserved for the protocol
Notice that the diagnostic line above goes to stderr. That is not taste.
In stdio mode stdout is a channel where only JSON-RPC flows. Use print once and that line joins the stream, and the client dies trying to read it as a message.
print('server started'); // this one line breaks the protocol
stderr.writeln('server started'); // this one is safeThis is not the kind of mistake you fix later, because the symptom is "parsing fails sometimes." It works or does not depending on when the log happens to be written. So it gets set in the first piece.
It is the same place in embedded work. A board printing human-readable logs onto the same UART makes the client choke — that is why the real-board piece had to filter them in the bridge.
Verification
The course sample carries a check per step. Step 1's is this.
ask step1 > captures/s1.txt
grep -q 'Course' captures/s1.txt || die "step1: no serverInfo"You must not write input while dart run is still compiling, and you must not close stdin right after writing either — the server takes EOF as a disconnect and leaves before answering. So the probe waits on both sides.
sleep 3 # until compilation finishes and main is up
printf '%s\n' "$INIT"
sleep 2 # keep stdin open until the answer comesA day went into not having those two lines. The response was empty while the server was printing its normal startup log, so the time went into suspecting the server.
Run it yourself
cd content/sample/course-server
dart pub get
dart run bin/step1.dart # connect and poke by hand
bash verify.sh # check all six stepsWhat to take
- The minimum shape —
createServer→createStdioTransport→connect→ keep the process alive - stderr only —
printis forbidden in a stdio server - A hand probe — confirm the round trip with
printf | dart run, no client package
Next
Register one tool. And why a tool's description matters more than the code under it.
Run the sample
cd course-server dart pub get dart run bin/step1.dart