mctp server test readme
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock
index 09835e3..70ec3ff 100644
--- a/MODULE.bazel.lock
+++ b/MODULE.bazel.lock
@@ -2786,7 +2786,7 @@
         "usagesDigest": "/Nz9QH+EBlDXoVXWiAEH8owsAo1cu+GkXjlRdolG7Sk=",
         "recordedFileInputs": {
           "@@//third_party/crates_io/Cargo.lock": "eac2c7966bf4d855547714d841b236d4a4c245118815682e720a48e9035635fb",
-          "@@//third_party/crates_io/Cargo.toml": "5afe4e14be9b26c3eeb6c90ddf6f80426d5bd7af9d3407c83a7f00d6669fbaee",
+          "@@//third_party/crates_io/Cargo.toml": "31508eeb838cc079810d93cb9801a21cd5a992af58602b15205474862f8280b3",
           "@@caliptra_deps+//crates_io/embedded/Cargo.lock": "d6c0101f48da22f2bc2d339f358de79bad3dd03218c6db29a14099b9f7757691",
           "@@caliptra_deps+//crates_io/embedded/Cargo.toml": "8f9f4ed2721db13476b12fdac045dac2142b38f189a8abb5f4c446dc0c6ac3dd",
           "@@caliptra_deps+//crates_io/host/Cargo.lock": "ae555b01424917ec61d892c15d7a66af88c2f10be4e0e7b0bc2357b702883b89",
@@ -2801,7 +2801,7 @@
           "CARGO_BAZEL_DEBUG": null,
           "CARGO_BAZEL_GENERATOR_SHA256": null,
           "CARGO_BAZEL_GENERATOR_URL": null,
-          "CARGO_BAZEL_ISOLATED": null,
+          "CARGO_BAZEL_ISOLATED": "0",
           "CARGO_BAZEL_REPIN": null,
           "CARGO_BAZEL_REPIN_ONLY": null,
           "CARGO_BAZEL_TIMEOUT": null,
@@ -4331,7 +4331,7 @@
               "patches": [],
               "shallow_since": "",
               "remote": "https://github.com/CodeConstruct/mctp-rs.git",
-              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'openprot'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"mctp\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"default\",\n        \"std\",\n    ],\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=mctp\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:riscv32imc-unknown-none-elf\": [],\n        \"@rules_rust//rust/platform:thumbv7em-none-eabi\": [],\n        \"@rules_rust//rust/platform:x86_64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"0.2.0\",\n)\n",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'openprot'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"mctp\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=mctp\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:riscv32imc-unknown-none-elf\": [],\n        \"@rules_rust//rust/platform:thumbv7em-none-eabi\": [],\n        \"@rules_rust//rust/platform:x86_64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"0.2.0\",\n)\n",
               "strip_prefix": "mctp",
               "commit": "b134e145f93d634dff7eb9f2a01559273c687365"
             }
diff --git a/target/ast10x0/tests/README.md b/target/ast10x0/tests/README.md
index 9b4072b..5874fd6 100644
--- a/target/ast10x0/tests/README.md
+++ b/target/ast10x0/tests/README.md
@@ -48,6 +48,12 @@
 be specified. Omitting `AST1060_EVB_PI_HOST` should default to using a wired
 connection, but currently logs an unimplemented error.
 
+## Hardware Tests
+
+| Test | Description |
+|------|-------------|
+| [`mctp/server`](mctp/server/README.md) | MCTP echo stress test between two AST1060 EVBs over I2C |
+
 ## Test Results (2026-05-12)
 
 | Test | QEMU | Physical board |
diff --git a/target/ast10x0/tests/mctp/server/BUILD.bazel b/target/ast10x0/tests/mctp/server/BUILD.bazel
index f6614d8..e955524 100644
--- a/target/ast10x0/tests/mctp/server/BUILD.bazel
+++ b/target/ast10x0/tests/mctp/server/BUILD.bazel
@@ -210,6 +210,7 @@
 
 system_image_test(
     name = "mctp_server_test",
+    timeout = "eternal",
     image = ":mctp_server_image",
     slave_image = ":mctp_server_peer_image",
     tags = [
diff --git a/target/ast10x0/tests/mctp/server/README.md b/target/ast10x0/tests/mctp/server/README.md
new file mode 100644
index 0000000..e0fd716
--- /dev/null
+++ b/target/ast10x0/tests/mctp/server/README.md
@@ -0,0 +1,191 @@
+# MCTP Server Hardware Test
+
+This test runs the MCTP echo flow between two physical AST1060 EVBs connected
+over I2C. One board acts as the MCTP server + requester; the other acts as the
+peer server + responder. Both boards flash, boot, and exchange echo packets
+simultaneously. The test runs indefinitely or until timeout. There is currently
+no success or failure case other than inspecting the logging behaviour.
+
+## Prerequisites
+
+### Hardware
+
+- Two AST1060 EVBs wired I2C bus-to-bus (Bus 2 on each board)
+- A Raspberry Pi acting as the test fixture, connected to both boards via:
+  - Two USB-to-UART adapters (`/dev/ttyUSB0` for board A, `/dev/ttyUSB1` for board B)
+  - GPIO lines for SRST and FWSPICK on each board (see `evb_config.toml`)
+
+### Pi software
+
+The Pi needs Python 3 and `pyserial`. If it is not already installed:
+
+```
+pip install pyserial
+```
+
+### SSH access from your workstation to the Pi
+
+The test runner connects to the Pi over SSH using **key-based authentication**
+(no passwords). To set this up the first time:
+
+1. **Generate a named SSH key** on your workstation:
+
+   ```
+   ssh-keygen -t ed25519 -f ~/.ssh/ast_pi
+   ```
+
+   You will be prompted for a passphrase.
+
+2. **Copy your public key to the Pi:**
+
+   ```
+   ssh-copy-id -i ~/.ssh/ast_pi.pub <your-username>@<pi-hostname>
+   ```
+
+   You will be prompted for your password associated with your username on
+   the pi host. After this, SSH from your workstation using this key will not
+   require the Pi password.
+
+3. **Configure SSH to use this key for the Pi host.** Add the following to
+   `~/.ssh/config` on your workstation (create the file if it does not exist):
+
+   ```
+   Host <pi-hostname>
+       IdentityFile ~/.ssh/ast_pi
+       IdentitiesOnly yes
+   ```
+
+   This tells SSH to use your named key — and only that key — when connecting
+   to the Pi, regardless of which keys your agent currently has loaded.
+
+4. **Add the key to your SSH agent** so the test runner can use it without
+   prompting for the passphrase mid-run:
+
+   ```
+   ssh-add ~/.ssh/ast_pi
+   ```
+
+   You will be prompted for the passphrase once per login session. The agent
+   holds the unlocked key in memory until you log out or explicitly remove it.
+
+5. **Verify it works:**
+
+   ```
+   ssh <your-username>@<pi-hostname> echo ok
+   ```
+
+   You should see `ok` printed without any prompts.
+
+## Running the test
+
+```
+bazelisk test \
+    --config=k_ast1060_evb \
+    --test_env=AST1060_EVB_PI_HOST=<pi-hostname> \
+    --test_output=streamed \
+    -- //target/ast10x0/tests/mctp/server:mctp_server_test
+```
+
+Replace `<pi-hostname>` with the hostname or IP address of your Pi fixture.
+
+The `--test_output=streamed` flag lets you watch the UART output from both
+boards in real time as the test runs. The test has no pass/fail sentinel and
+runs until you interrupt it with `Ctrl-C` or until Bazel's `eternal` timeout
+(3600 seconds) is reached.
+
+### What happens when you run it
+
+1. Bazel builds the firmware images for both boards.
+2. `test_runner.py` on your workstation acquires an exclusive lock on the Pi
+   (`/tmp/ast1060_evb.lock`) so that concurrent test runs from other users do
+   not interfere.
+3. The firmware binaries and `pi_test_runner.py` are SCP'd to the Pi.
+4. `pi_test_runner.py` sequences the GPIO lines on each board to enter UART
+   bootloader mode, uploads the firmware, then streams raw UART bytes back to
+   your workstation.
+5. UART output is detokenized on the host machine (log messages are decoded
+   from their compact tokenized form) and printed to your terminal.
+6. The test runs until you interrupt it (`Ctrl-C`) or the `eternal` timeout
+   expires.
+
+>Using `Ctrl-C` to stop the test kills the test process without giving it a
+>chance to clean up the lockfile. If the lockfile remains untouched for 60
+>seconds, it is considered stale. Running tests touch the lock file every 10
+>seconds to maintain their lock. As a result of this, you may see a message like
+>
+>```
+>RUNNER: removing stale lock held by jsmith 37m 14s ago, last touched 2089s ago...
+>
+>````
+
+
+## If the Pi is already locked
+
+The test runner prints who holds the lock and how long they have held it:
+
+```
+RUNNER: Pi locked by jsmith 2m 34s ago, last touched 8s ago; waiting...
+```
+
+
+It will wait up to 120 seconds for the lock to become available. If the previous
+test run crashed without releasing the lock, the runner detects that the lock
+file has not been touched in over 60 seconds, removes it automatically, and
+proceeds:
+
+```
+RUNNER: removing stale lock held by jsmith 5m 12s ago, last touched 75s ago...
+```
+
+If you need to clear the lock manually for any reason:
+
+```
+ssh <pi-hostname> rm -f /tmp/ast1060_evb.lock
+```
+
+## Hardware configuration
+
+GPIO pin assignments and serial port paths are defined in
+[`target/ast10x0/harness/evb_config.toml`](../../../harness/evb_config.toml):
+
+```toml
+[gpio]
+srst_pin = 23       # BCM pin connected to board A SRST
+fwspick_pin = 18    # BCM pin connected to board A FWSPICK
+
+[uart]
+serial_port = "/dev/ttyUSB0"   # Board A serial port
+baudrate = 115200
+
+[device_b]
+srst_pin = 25       # BCM pin connected to board B SRST
+fwspick_pin = 24    # BCM pin connected to board B FWSPICK
+serial_port = "/dev/ttyUSB1"   # Board B serial port
+```
+
+If your Pi wiring differs, edit `evb_config.toml` before running the test.
+
+>Different Pi hosts might have different configurations. Currently there is no
+>association between a config and its host built into the harness.
+
+## Troubleshooting
+
+>We are currently aware of an issue where occasionally the spliced UART streams
+>corrupt each other so that one line from each becomes unreadable.
+
+**`ssh: connect to host ... port 22: Connection refused`**
+: The Pi is unreachable. Check that the hostname is correct and the Pi is powered on.
+
+**`Permission denied (publickey)`**
+: Key-based SSH auth is not set up, or the key is not loaded in your agent.
+  Follow the [SSH setup](#ssh-access-from-your-workstation-to-the-pi) steps
+  above, and make sure you have run `ssh-add ~/.ssh/ast_pi` this session.
+
+**`RUNNER: timeout acquiring Pi lock after 120s`**
+: Another test has been running for over 2 minutes whilst touching the lockfile.
+  Wait until the current test is done using the hardware or contact the lock holder shown
+  in the waiting messages.
+
+**`Error: could not open /dev/ttyUSB0`**
+: The USB-to-UART adapter is not detected on the Pi. Check USB connections and
+  verify the device appears with `ls /dev/ttyUSB*` on the Pi.