---
title: Loopback Testing
description: Test serial communication without any hardware using loop:// virtual ports.
---

import { Steps, Tabs, TabItem, Aside } from '@astrojs/starlight/components';

pyserial provides a built-in `loop://` URL scheme that creates a virtual serial port. Everything written to it is immediately available to read back -- no cables, no adapters, no hardware at all. This makes it a great way to verify that mcserial is working in your MCP client, practice serial workflows, validate encodings, and run automated checks in CI/CD.

<Aside type="note" title="MCP tool calls">
  The tool calls shown below are what the assistant executes when you ask it to perform serial operations. You interact in natural language -- for example, "open a loopback port" or "write AT+VERSION to the serial port."
</Aside>

## Why use loopback

- **No hardware required** -- test serial workflows on any machine, including headless CI runners
- **Deterministic** -- what you write is exactly what you read, making assertions straightforward
- **Fast** -- no real baud rate delays; data transfers are instantaneous in memory
- **Safe** -- no risk of misconfiguring or locking a real device

## Basic round-trip

The simplest test: ask the assistant to write a string and read it back.

<Steps>

1. **Ask the assistant to open the loopback port**

   ```
   open_serial_port(port="loop://", baudrate=9600)
   ```

   ```json
   {
     "success": true,
     "port": "loop://",
     "mode": "rs232",
     "baudrate": 9600,
     "bytesize": 8,
     "parity": "N",
     "stopbits": 1,
     "resource_uri": "serial://loop:///data",
     "url_scheme": "loop"
   }
   ```

   The `baudrate` parameter is accepted but has no effect on loopback -- data moves at memory speed regardless of the value. Setting it still validates that the parameter is handled correctly throughout the tool chain.

2. **Send a message**

   The assistant writes your data to the port:

   ```
   write_serial(port="loop://", data="AT+VERSION\r\n")
   ```

   ```json
   {
     "success": true,
     "bytes_written": 12,
     "port": "loop://"
   }
   ```

3. **Read it back**

   Ask the assistant to read from the port:

   ```
   read_serial(port="loop://")
   ```

   ```json
   {
     "success": true,
     "data": "AT+VERSION\r\n",
     "bytes_read": 12,
     "raw_hex": "41542b56455253494f4e0d0a",
     "port": "loop://"
   }
   ```

   The `raw_hex` field confirms the exact bytes on the wire -- useful for verifying that line endings and control characters are correct.

4. **Close the port**

   ```
   close_serial_port(port="loop://")
   ```

</Steps>

## Testing line-oriented reads

Many serial devices send responses as lines terminated with `\r\n` or `\n`. Use `read_serial_line` and `read_serial_lines` to handle these.

<Steps>

1. **Open and write multi-line data**

   Ask the assistant to open a loopback port and send multi-line data:

   ```
   open_serial_port(port="loop://", baudrate=115200)
   ```

   ```
   write_serial(port="loop://", data="OK\r\nERROR 42\r\nDONE\r\n")
   ```

2. **Read one line at a time**

   The assistant calls `read_serial_line` to get a single line:

   ```
   read_serial_line(port="loop://")
   ```

   ```json
   {
     "success": true,
     "line": "OK",
     "bytes_read": 4,
     "port": "loop://"
   }
   ```

   The `line` field strips trailing `\r\n` automatically. The `bytes_read` count includes the line ending bytes.

3. **Drain remaining lines in one call**

   Ask the assistant to read all remaining lines:

   ```
   read_serial_lines(port="loop://", max_lines=10)
   ```

   ```json
   {
     "success": true,
     "lines": ["ERROR 42", "DONE"],
     "count": 2,
     "bytes_read": 16,
     "port": "loop://"
   }
   ```

   `read_serial_lines` stops early when no more data is available, so setting `max_lines` higher than expected is fine.

4. **Close the port**

   ```
   close_serial_port(port="loop://")
   ```

</Steps>

## Testing custom terminators

Not every protocol uses newlines. Some devices use `>` as a prompt, `\x00` as a null terminator, or multi-byte sequences. Use `read_until` for these.

<Steps>

1. **Open and write prompt-terminated data**

   Ask the assistant to open a loopback port and write data with a `>` prompt at the end:

   ```
   open_serial_port(port="loop://", baudrate=9600)
   ```

   ```
   write_serial(port="loop://", data="ELM327 v1.5>")
   ```

2. **Read until the `>` prompt**

   The assistant calls `read_until` with a custom terminator:

   ```
   read_until(port="loop://", terminator=">")
   ```

   ```json
   {
     "success": true,
     "data": "ELM327 v1.5>",
     "bytes_read": 12,
     "raw_hex": "454c4d33323720312e353e",
     "port": "loop://",
     "terminator_found": true
   }
   ```

   The `terminator_found` field confirms whether the terminator was actually received or whether the read timed out first.

3. **Close the port**

   ```
   close_serial_port(port="loop://")
   ```

</Steps>

## Testing encodings

Serial devices don't always speak UTF-8. Industrial equipment, legacy systems, and some sensors use Latin-1 or other single-byte encodings. mcserial's `encoding` parameter handles the translation. Tell the assistant which encoding to use, and it passes the value through to the read/write calls.

<Steps>

1. **Open the port**

   ```
   open_serial_port(port="loop://", baudrate=9600)
   ```

2. **Write with Latin-1 encoding**

   Latin-1 maps bytes 0x00--0xFF directly, which makes it useful for binary-safe text. The assistant calls:

   ```
   write_serial(port="loop://", data="Temp: 23\xb0C", encoding="latin-1")
   ```

   The `\xb0` byte is the degree symbol in Latin-1.

3. **Read with matching encoding**

   ```
   read_serial(port="loop://", encoding="latin-1")
   ```

   ```json
   {
     "success": true,
     "data": "Temp: 23\u00b0C",
     "bytes_read": 10,
     "raw_hex": "54656d703a203233b043",
     "port": "loop://"
   }
   ```

   The `b0` byte in `raw_hex` confirms the degree symbol was sent correctly.

4. **Close the port**

   ```
   close_serial_port(port="loop://")
   ```

</Steps>

<Aside type="note">
  If you see garbled characters, the most common cause is a read/write encoding mismatch. Always use the same `encoding` value for both `write_serial` and `read_serial` on a given port.
</Aside>

## Testing raw byte writes

For binary protocols (Modbus RTU, custom framing, firmware uploads), the assistant uses `write_serial_bytes` to send exact byte sequences:

<Steps>

1. **Open the port**

   ```
   open_serial_port(port="loop://", baudrate=9600)
   ```

2. **Write raw bytes**

   Send a Modbus-style query (address 0x01, function 0x03, register 0x0000, count 0x0001):

   ```
   write_serial_bytes(port="loop://", data=[1, 3, 0, 0, 0, 1])
   ```

   ```json
   {
     "success": true,
     "bytes_written": 6,
     "port": "loop://"
   }
   ```

3. **Read back and verify hex**

   ```
   read_serial(port="loop://", encoding="latin-1")
   ```

   ```json
   {
     "success": true,
     "data": "\u0001\u0003\u0000\u0000\u0000\u0001",
     "bytes_read": 6,
     "raw_hex": "010300000001",
     "port": "loop://"
   }
   ```

   The `raw_hex` field is the most reliable way to verify binary data -- it shows the exact bytes without encoding ambiguity.

4. **Close the port**

   ```
   close_serial_port(port="loop://")
   ```

</Steps>

## Testing port configuration changes

The assistant can reconfigure an open port without closing and reopening it. This is useful for testing parameter changes or simulating baud rate switching:

<Steps>

1. **Open at 9600 baud**

   ```
   open_serial_port(port="loop://", baudrate=9600)
   ```

2. **Change to 115200 baud with hardware flow control**

   ```
   configure_serial(port="loop://", baudrate=115200, rtscts=true)
   ```

   ```json
   {
     "success": true,
     "port": "loop://",
     "baudrate": 115200,
     "timeout": 1.0,
     "write_timeout": null,
     "inter_byte_timeout": null,
     "xonxoff": false,
     "rtscts": true,
     "dsrdtr": false,
     "rts": true,
     "dtr": true
   }
   ```

   The response shows the full port state after the change, making it easy to confirm the new settings.

3. **Verify the connection status**

   ```
   get_connection_status()
   ```

   This returns all open connections with their current settings, including the updated baud rate and flow control flags.

4. **Close the port**

   ```
   close_serial_port(port="loop://")
   ```

</Steps>

## Testing buffer management

When working with protocol handlers, you sometimes need to clear stale data before sending a new command. Ask the assistant to flush the buffers:

```
flush_serial(port="loop://", input_buffer=true, output_buffer=true)
```

This discards any unread data in the input buffer and any unsent data in the output buffer. Call it before sending a command when you want a clean slate.

<Aside type="tip" title="Debugging real connections with spy://">
  When you move from loopback testing to real hardware and things don't work, wrap your device path with `spy://` to log all traffic to stderr:

  ```
  open_serial_port(port="spy:///dev/ttyUSB0", baudrate=9600)
  ```

  Every byte sent and received will be printed to the server's stderr output, which shows up in your MCP client's server logs. This is invaluable for diagnosing timing issues, encoding mismatches, or unexpected device responses.

  `spy://` works with any local device path -- just prefix the full path after the scheme. On Windows: `spy://COM3`.
</Aside>

## Using loopback in CI/CD

Since `loop://` requires no hardware or special permissions, it works in any CI environment. A typical test pattern:

1. Start mcserial as a subprocess
2. Connect via MCP client
3. Open `loop://`
4. Write known data, read it back, assert equality
5. Test edge cases: empty writes, large payloads, encoding boundaries
6. Close the port

This validates that your MCP tool chain works end-to-end without needing a physical device in the test runner.
