It Connects — the Transport Is a Command to Run, Not a Setting

5 min
GoalFirst in the mcp_client course. The client attaches to the server. What to notice is not the connection code but what the transport turns out to be — not a socket to open but a command to launch, which is why changing transports later becomes changing one line.

Six pieces of the server course stood a server up. This track is the other side — the code that attaches to it.

All of the connecting code

Future<Client> connect(String step) async {
  final r = await McpClient.createAndConnect(
    config: McpClient.simpleConfig(
        name: 'Course Client', version: '1.0.0', enableDebugLogging: false),
    transportConfig: TransportConfig.stdio(
      command: 'dart',
      arguments: ['run', 'bin/$step.dart'],
      workingDirectory: '../course-server',
    ),
  );
  return r.get();
}

config is who we are; transportConfig is how we reach across.

The transport is a command

Look at the arguments to TransportConfig.stdio. No host, no port. A command and arguments.

The client starts the server process itself and speaks over that process's stdin and stdout. There is no socket to configure and no service to wait for.

It looks minor and it pays later, because changing transports shrinks to which program you launch.

// to a serial device
command: '../serial_bridge/serial_bridge',
arguments: ['/dev/cu.usbmodem1234', '115200'],

// to a network device
command: '../tcp_bridge/tcp_bridge',
arguments: ['mcp-esp32.local', '6270'],

The client code does not change by a character. That structure is why the real-board piece could attach to an STM32 and an ESP32 with the same code.

Once attached, it knows who it is talking to

stdout.writeln('connected to ${client.serverInfo?["name"]} '
    '${client.serverInfo?["version"]}');
connected to Course 1.0.0

That came from the initialize round trip. Without asking for anything else, who the other side is and what it can do (capabilities) is already in hand.

What is .get()

createAndConnect returns not a Client but a result carrying success or failure. .get() unwraps it on success and throws on failure.

Where failure has to be handled, split it instead.

r.fold(
  (client) => /* attached */,
  (error)  => /* the server did not start, the path is wrong, no permission */,
);

The course sample assumes it attaches, so it uses .get(). In a real app this is the fork for the first screen — attached and not attached show a person different things.

Verification

run step1 > captures/s1.txt || die "step1: did not connect"
grep -q 'connected to Course' captures/s1.txt || die "step1: no serverInfo"
step1  connected to Course 1.0.0

It starts a real server and attaches. Not a mock — the server built in the server course. So every run also checks that the two tracks still fit together.

Run it yourself

cd content/sample/course-client
dart pub get
dart run bin/step1.dart
bash verify.sh

What to take

  1. createAndConnect plus .get() — the minimum shape
  2. Transport as command — command and arguments, not host and port
  3. serverInfo — you know the other side before asking anything

Next

Ask what it can do. And why that list must not be written into the client.

Run the sample

cd course-client
dart pub get
dart run bin/step1.dart