Skip to content

Loopback Testing

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.

  • 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

The simplest test: send a command and read the response in a single call using transact.

  1. Open the loopback port

    open_serial_port(port="loop://", baudrate=9600)
    {
    "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 and receive in one call

    transact writes your data, flushes, and reads back the response:

    transact(port="loop://", data="AT+VERSION\r\n", response_timeout=0.5)
    {
    "success": true,
    "port": "loop://",
    "bytes_sent": 12,
    "data_sent": "AT+VERSION\r\n",
    "response": "AT+VERSION\r\n",
    "response_bytes": 12,
    "response_hex": "41542b56455253494f4e0d0a"
    }

    The response_hex field confirms the exact bytes — useful for verifying line endings and control characters.

  3. Test echo stripping

    Many devices echo back the command before the response. strip_echo removes it:

    transact(port="loop://", data="AT\r\n", strip_echo=true, response_timeout=0.5)
    {
    "success": true,
    "response": "",
    "response_bytes": 0
    }

    On a loopback port the entire response is the echo, so stripping it returns empty. On a real device that echoes AT\r\n followed by OK\r\n, strip_echo would return just OK\r\n.

  4. Close the port

    close_serial_port(port="loop://")

Most serial devices expect a line ending after each command. Instead of appending \r\n to every call, set it once with configure_serial:

  1. Open and configure

    open_serial_port(port="loop://", baudrate=9600)
    configure_serial(port="loop://", line_ending="crlf")
    {
    "success": true,
    "port": "loop://",
    "baudrate": 9600,
    "line_ending": "\r\n"
    }

    Now every write_serial and transact call auto-appends \r\n.

  2. Write without manual line ending

    write_serial(port="loop://", data="AT")
    {
    "success": true,
    "bytes_written": 4,
    "port": "loop://"
    }

    4 bytes: AT (2) + \r\n (2). The line ending was appended automatically.

  3. Verify with read

    read_serial(port="loop://")
    {
    "success": true,
    "data": "AT\r\n",
    "bytes_read": 4,
    "raw_hex": "41540d0a"
    }
  4. Override for a single transact call

    Pass line_ending directly to override the port default:

    transact(port="loop://", data="RAW", line_ending="none", response_timeout=0.5)

    This sends RAW without any line ending, regardless of the port default.

  5. Clear the default

    configure_serial(port="loop://", line_ending="none")
  6. Close the port

    close_serial_port(port="loop://")

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

  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://")
    {
    "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)
    {
    "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://")

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.

  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=">")
    {
    "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://")

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.

  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")
    {
    "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://")

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

  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])
    {
    "success": true,
    "bytes_written": 6,
    "port": "loop://"
    }
  3. Read back and verify hex

    read_serial(port="loop://", encoding="latin-1")
    {
    "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://")

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

  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)
    {
    "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://")

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.

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.