Start the Server — and Write Nothing to stdout

5 min
GoalFirst in the mcp_server course. No tools, no resources — just a server standing up. There is one thing to confirm here: does a client connect and get an answer to initialize. And one rule that has to be set now or it breaks later.

The 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.dart

What 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 safe

This 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 comes

A 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 steps

What to take

  1. The minimum shape — createServer → createStdioTransport → connect → keep the process alive
  2. stderr only — print is forbidden in a stdio server
  3. 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