services/mctp: add mctp server, echo handler, and server tests
diff --git a/services/mctp/echo/BUILD.bazel b/services/mctp/echo/BUILD.bazel
index 4cfe7a4..2a280d1 100644
--- a/services/mctp/echo/BUILD.bazel
+++ b/services/mctp/echo/BUILD.bazel
@@ -11,6 +11,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//services/mctp/api:mctp_api",
+        "@pigweed//pw_log/rust:pw_log",
     ],
 )
 
diff --git a/services/mctp/echo/src/lib.rs b/services/mctp/echo/src/lib.rs
index 6fde1bf..27951df 100644
--- a/services/mctp/echo/src/lib.rs
+++ b/services/mctp/echo/src/lib.rs
@@ -13,7 +13,9 @@
 
 #![no_std]
 
-use openprot_mctp_api::{MctpClient, MctpError, MctpRespChannel, Stack, StackListener};
+use openprot_mctp_api::{
+    MctpClient, MctpError, MctpReqChannel, MctpRespChannel, Stack, StackListener,
+};
 
 /// Default MCTP message type used by the echo app.
 pub const ECHO_MSG_TYPE: u8 = 1;
@@ -40,12 +42,113 @@
 }
 
 /// Process one echo receive/send cycle.
-///
-/// Returns an error from either receive or response send.
-pub fn echo_once<L>(listener: &mut L, buf: &mut [u8]) -> Result<(), MctpError>
+pub fn echo_once<L>(listener: &mut L, buf: &mut [u8]) -> Result<(), openprot_mctp_api::MctpError>
 where
     L: openprot_mctp_api::MctpListener,
 {
     let (_meta, msg, mut resp) = listener.recv(buf)?;
     resp.send(msg)
 }
+
+/// Run the echo loop forever, echoing received messages.
+pub fn run<L>(listener: &mut L) -> !
+where
+    L: openprot_mctp_api::MctpListener,
+{
+    let mut buf = [0u8; 255];
+    loop {
+        match listener.recv(&mut buf) {
+            Ok((_meta, msg, mut resp)) => {
+                let _ = resp.send(msg);
+            }
+            Err(e) => {
+                // Timeouts are expected noise from the short recv deadline; suppress them.
+                if !e.is_timeout() {
+                    pw_log::error!("echo recv failed: code={}", e.code as u32);
+                }
+            }
+        }
+    }
+}
+
+/// Run the echo loop with periodic sends to a peer endpoint, forever.
+pub fn run_with_peer<C: MctpClient, L: openprot_mctp_api::MctpListener>(
+    stack: &Stack<C>,
+    peer_eid: u8,
+    listener: &mut L,
+) -> ! {
+    loop {
+        run_with_peer_round_trip_limit(stack, peer_eid, listener, u32::MAX);
+    }
+}
+
+/// Run the echo loop with periodic sends to a peer endpoint, returning after
+/// `max_round_trips` successful request/response exchanges.
+pub fn run_with_peer_round_trip_limit<C: MctpClient, L: openprot_mctp_api::MctpListener>(
+    stack: &Stack<C>,
+    peer_eid: u8,
+    listener: &mut L,
+    max_round_trips: u32,
+) {
+    let mut buf = [0u8; 255];
+    let mut iteration: u32 = 0;
+    // 10 iterations between sends keeps the channel active without flooding it.
+    let send_interval = 10;
+
+    // Keep retrying until the first successful round-trip so a slow-booting
+    // peer (>1 min) does not cause us to give up before it is ready.
+    loop {
+        match listener.recv(&mut buf) {
+            Ok((_meta, msg, mut resp)) => {
+                let _ = resp.send(msg);
+            }
+            Err(e) => {
+                // Timeouts are expected noise from the short recv deadline; suppress them.
+                if !e.is_timeout() {
+                    pw_log::error!("echo recv failed: code={}", e.code as u32);
+                }
+            }
+        }
+
+        if let Ok(mut req) = stack.req(peer_eid, 10000) {
+            let test_msg = b"echo_test";
+            let _ = req.send(ECHO_MSG_TYPE, test_msg);
+            if req.recv(&mut buf).is_ok() {
+                break;
+            }
+        }
+    }
+
+    // First round-trip succeeded; count it and continue with periodic sends.
+    let mut completed_round_trips: u32 = 1;
+
+    loop {
+        iteration = iteration.wrapping_add(1);
+
+        // Periodically try to send a test message to the peer.
+        if iteration % send_interval == 0
+            && completed_round_trips < max_round_trips
+            && let Ok(mut req) = stack.req(peer_eid, 100)
+        {
+            let test_msg = b"echo_test";
+            let _ = req.send(ECHO_MSG_TYPE, test_msg);
+            // Try to receive response with a short timeout.
+            if req.recv(&mut buf).is_ok() {
+                completed_round_trips = completed_round_trips.saturating_add(1);
+            }
+        }
+
+        // Listen for incoming messages and echo them back.
+        match listener.recv(&mut buf) {
+            Ok((_meta, msg, mut resp)) => {
+                let _ = resp.send(msg);
+            }
+            Err(e) => {
+                // Timeouts are expected noise from the short recv deadline; suppress them.
+                if !e.is_timeout() {
+                    pw_log::error!("echo recv failed: code={}", e.code as u32);
+                }
+            }
+        }
+    }
+}
diff --git a/target/ast10x0/defs.bzl b/target/ast10x0/defs.bzl
index dd10172..7e2e827 100644
--- a/target/ast10x0/defs.bzl
+++ b/target/ast10x0/defs.bzl
@@ -10,17 +10,24 @@
 })
 
 def _system_image_test_impl(ctx):
-    master_elf = ctx.attr.image[SystemImageInfo].elf
+    image_info = ctx.attr.image[SystemImageInfo]
     executable_symlink = ctx.actions.declare_file(ctx.label.name)
-    ctx.actions.symlink(output = executable_symlink, target_file = master_elf)
+    ctx.actions.symlink(output = executable_symlink, target_file = image_info.elf)
 
-    runfiles = ctx.attr.image[DefaultInfo].default_runfiles
+    bin_symlink = ctx.actions.declare_file(ctx.label.name + ".bin")
+    ctx.actions.symlink(output = bin_symlink, target_file = image_info.bin)
+
+    runfiles = ctx.runfiles(files = [bin_symlink]).merge(
+        ctx.attr.image[DefaultInfo].default_runfiles,
+    )
 
     if ctx.attr.slave_image:
-        slave_elf = ctx.attr.slave_image[SystemImageInfo].elf
-        slave_symlink = ctx.actions.declare_file(ctx.label.name + ".slave.elf")
-        ctx.actions.symlink(output = slave_symlink, target_file = slave_elf)
-        runfiles = ctx.runfiles(files = [slave_symlink]).merge(
+        slave_info = ctx.attr.slave_image[SystemImageInfo]
+        slave_elf_symlink = ctx.actions.declare_file(ctx.label.name + ".slave.elf")
+        ctx.actions.symlink(output = slave_elf_symlink, target_file = slave_info.elf)
+        slave_bin_symlink = ctx.actions.declare_file(ctx.label.name + ".slave.bin")
+        ctx.actions.symlink(output = slave_bin_symlink, target_file = slave_info.bin)
+        runfiles = ctx.runfiles(files = [slave_elf_symlink, slave_bin_symlink]).merge(
             runfiles.merge(ctx.attr.slave_image[DefaultInfo].default_runfiles),
         )
 
diff --git a/target/ast10x0/harness/test_runner.py b/target/ast10x0/harness/test_runner.py
index a49188d..ec36b64 100644
--- a/target/ast10x0/harness/test_runner.py
+++ b/target/ast10x0/harness/test_runner.py
@@ -94,24 +94,59 @@
 def _acquire_lock(host: str, timeout: int = _LOCK_ACQUIRE_TIMEOUT) -> bool:
     """Atomically create lock file on Pi; retry until timeout."""
     deadline = time.time() + timeout
-    create = f"set -o noclobber && echo $$ > {_LOCK_PATH}"
+    local_user = os.environ.get("USER", "unknown")
+    # On noclobber failure, cat outputs the lock file contents so we get
+    # owner info from the same SSH call without a second round-trip.
+    create = (
+        f"set -o noclobber && "
+        f"printf '%s\\t%s\\n' '{local_user}' \"$(date +%s)\" > {_LOCK_PATH} || "
+        f"{{ cat {_LOCK_PATH} 2>/dev/null; false; }}"
+    )
     stale_check = (
         f"mtime=$(stat -c %Y {_LOCK_PATH} 2>/dev/null) && "
         f"now=$(date +%s) && "
         f"[ $(( now - mtime )) -gt {_LOCK_STALE_THRESHOLD} ] && "
         f"rm -f {_LOCK_PATH}"
     )
+    last_printed = 0.0
     while time.time() < deadline:
         r = _ssh(host, create, capture_output=True)
         if r.returncode == 0:
             return True
-        if r.stderr.strip():
-            print(r.stderr.decode(errors="replace").strip(), file=sys.stderr)
-            return False
+        if not r.stdout.strip():
+            if msg := r.stderr.decode(errors="replace").strip():
+                print(f"RUNNER: {msg}", file=sys.stderr)
+                return False
+            continue
+        parts = r.stdout.decode(errors="replace").split()
+        now = time.time()
+        if len(parts) == 2 and now - last_printed >= 30:
+            lock_user, acq_ts = parts[0], int(parts[1])
+            held_s = int(now) - acq_ts
+            mins, secs = divmod(held_s, 60)
+            held = f"{mins}m {secs}s" if mins else f"{secs}s"
+            stat_r = _ssh(
+                host,
+                f"now=$(date +%s); mtime=$(stat -c %Y {_LOCK_PATH} 2>/dev/null); echo $((now - mtime))",
+                capture_output=True,
+            )
+            touch_ago = int(stat_r.stdout.decode().strip() or -1)
+            if touch_ago > _LOCK_STALE_THRESHOLD:
+                print(
+                    f"RUNNER: removing stale lock held by {lock_user} {held} ago, last touched {touch_ago}s ago...",
+                    file=sys.stderr,
+                )
+            else:
+                print(
+                    f"RUNNER: Pi locked by {lock_user} {held} ago, last touched {touch_ago}s ago; waiting...",
+                    file=sys.stderr,
+                )
+            last_printed = now
         _ssh(host, stale_check, capture_output=True)
         time.sleep(2)
     print(
-        f"Timeout acquiring Pi lock after {timeout}s (Pi may be busy)", file=sys.stderr
+        f"RUNNER: timeout acquiring Pi lock after {timeout}s (Pi may be busy)",
+        file=sys.stderr,
     )
     return False
 
@@ -242,6 +277,7 @@
 
     if not _acquire_lock(host):
         return False
+    print("RUNNER: lock acquired, starting test", file=sys.stderr)
 
     stop_touch = threading.Event()
     touch_thread = threading.Thread(
@@ -383,13 +419,14 @@
     if not image.suffix:
         slave_symlink = image.parent / (image.name + ".slave.elf")
         if slave_symlink.exists():
-            slave_elf = slave_symlink.resolve()
-            slave_elf_path = slave_elf
-            args.slave_firmware = str(slave_elf.with_suffix(".bin"))
+            slave_elf_path = slave_symlink.resolve()
+            args.slave_firmware = str(image.parent / (image.name + ".slave.bin"))
+        args.firmware = str(image.parent / (image.name + ".bin"))
         image = image.resolve()
+    else:
+        args.firmware = str(image.with_suffix(".bin"))
 
     elf_path = image.with_suffix(".elf")
-    args.firmware = str(image.with_suffix(".bin"))
     if not elf_path.exists():
         print(f"Error: ELF not found at {elf_path}", file=sys.stderr)
         return 1
diff --git a/target/ast10x0/peripherals/i2c/slave.rs b/target/ast10x0/peripherals/i2c/slave.rs
index b603df6..8b118e8 100644
--- a/target/ast10x0/peripherals/i2c/slave.rs
+++ b/target/ast10x0/peripherals/i2c/slave.rs
@@ -174,6 +174,14 @@
 
     /// Configure the controller for slave mode
     pub fn configure_slave(&mut self, config: &SlaveConfig) -> Result<(), I2cError> {
+        // Disable master mode while the slave registers are programmed so the
+        // shared controller is quiescent during setup; its prior state is
+        // restored at the end so slave-only callers keep master off.
+        let master_was_enabled = self.regs().i2cc00().read().enbl_master_fn().bit();
+        self.regs()
+            .i2cc00()
+            .modify(|_, w| w.enbl_master_fn().clear_bit());
+
         // Set slave address
         self.regs().i2cs40().write(|w| unsafe {
             w.slave_dev_addr1()
@@ -245,6 +253,14 @@
         // Enable slave interrupts
         self.enable_slave_interrupts();
 
+        // Restore master mode to its prior state: dual master+slave callers
+        // (e.g. MCTP) get it back on; slave-only callers keep it off.
+        if master_was_enabled {
+            self.regs()
+                .i2cc00()
+                .modify(|_, w| w.enbl_master_fn().set_bit());
+        }
+
         Ok(())
     }
 
diff --git a/target/ast10x0/tests/mctp/ipc_client/BUILD.bazel b/target/ast10x0/tests/mctp/ipc_client/BUILD.bazel
index 2ac7521..56dbc70 100644
--- a/target/ast10x0/tests/mctp/ipc_client/BUILD.bazel
+++ b/target/ast10x0/tests/mctp/ipc_client/BUILD.bazel
@@ -112,6 +112,7 @@
 system_image_test(
     name = "ipc_client_qemu_test",
     image = ":ipc_client_image",
+    tags = ["qemu_only"],
     target_compatible_with = TARGET_COMPATIBLE_WITH,
 )
 
diff --git a/target/ast10x0/tests/mctp/server/BUILD.bazel b/target/ast10x0/tests/mctp/server/BUILD.bazel
new file mode 100644
index 0000000..f6614d8
--- /dev/null
+++ b/target/ast10x0/tests/mctp/server/BUILD.bazel
@@ -0,0 +1,223 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@pigweed//pw_kernel/tooling:rust_app.bzl", "rust_app")
+load("@pigweed//pw_kernel/tooling:system_image.bzl", "system_image")
+load("@pigweed//pw_kernel/tooling:target_codegen.bzl", "target_codegen")
+load("@pigweed//pw_kernel/tooling:target_linker_script.bzl", "target_linker_script")
+load("@rules_rust//rust:defs.bzl", "rust_binary")
+load("//target/ast10x0:defs.bzl", "TARGET_COMPATIBLE_WITH", "system_image_test")
+
+filegroup(
+    name = "system_config",
+    srcs = ["system.json5"],
+    visibility = ["//visibility:public"],
+)
+
+filegroup(
+    name = "peer_system_config",
+    srcs = ["peer_system.json5"],
+    visibility = ["//visibility:public"],
+)
+
+target_codegen(
+    name = "codegen",
+    arch = "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+    system_config = ":system_config",
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+target_linker_script(
+    name = "linker_script",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    template = "//target/ast10x0:linker_script_template",
+)
+
+rust_binary(
+    name = "target",
+    srcs = ["target.rs"],
+    edition = "2024",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [
+        ":codegen",
+        ":linker_script",
+        "//target/ast10x0:entry",
+        "//target/ast10x0/board:ast10x0_board",
+        "//target/ast10x0/peripherals",
+        "@ast1060_pac",
+        "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+        "@pigweed//pw_kernel/kernel",
+        "@pigweed//pw_kernel/subsys/console:console_backend",
+        "@pigweed//pw_kernel/target:target_common",
+        "@pigweed//pw_kernel/userspace",
+    ],
+)
+
+rust_app(
+    name = "mctp_server",
+    srcs = ["main.rs"],
+    codegen_crate_name = "app_mctp_server",
+    edition = "2024",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/i2c/api:i2c_api",
+        "//services/i2c/client:i2c_client",
+        "//services/i2c/client-ipc:i2c_client_ipc",
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/server:mctp_server_lib",
+        "//services/mctp/transport-i2c:mctp_transport_i2c",
+        "@pigweed//pw_kernel/syscall:syscall_user",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+        "@pigweed//pw_status/rust:pw_status",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
+
+rust_app(
+    name = "mctp_server_peer",
+    srcs = ["main_peer.rs"],
+    codegen_crate_name = "app_mctp_server_peer",
+    edition = "2024",
+    system_config = ":peer_system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/i2c/api:i2c_api",
+        "//services/i2c/client:i2c_client",
+        "//services/i2c/client-ipc:i2c_client_ipc",
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/server:mctp_server_lib",
+        "//services/mctp/transport-i2c:mctp_transport_i2c",
+        "@pigweed//pw_kernel/syscall:syscall_user",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+        "@pigweed//pw_status/rust:pw_status",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
+
+rust_app(
+    name = "i2c_server_peer",
+    srcs = ["i2c_server_main_peer.rs"],
+    codegen_crate_name = "app_i2c_server_peer",
+    edition = "2024",
+    system_config = ":peer_system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/i2c/server-runtime:i2c_server_runtime",
+        "//target/ast10x0/backend/i2c:i2c_backend_ast10x0",
+        "//target/ast10x0/peripherals",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+    ],
+)
+
+rust_app(
+    name = "mctp_echo_client_peer",
+    srcs = ["echo_main_peer.rs"],
+    codegen_crate_name = "app_mctp_echo_client_peer",
+    edition = "2024",
+    system_config = ":peer_system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/client-ipc:mctp_client_ipc",
+        "//services/mctp/echo:mctp_echo",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+    ],
+)
+
+rust_app(
+    name = "i2c_server",
+    srcs = ["i2c_server_main.rs"],
+    codegen_crate_name = "app_i2c_server",
+    edition = "2024",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/i2c/server-runtime:i2c_server_runtime",
+        "//target/ast10x0/backend/i2c:i2c_backend_ast10x0",
+        "//target/ast10x0/peripherals",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+    ],
+)
+
+rust_app(
+    name = "mctp_echo_client",
+    srcs = ["echo_main.rs"],
+    codegen_crate_name = "app_mctp_echo_client",
+    edition = "2024",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/client-ipc:mctp_client_ipc",
+        "//services/mctp/echo:mctp_echo",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+    ],
+)
+
+system_image(
+    name = "mctp_server_image",
+    apps = [
+        ":i2c_server",
+        ":mctp_server",
+        ":mctp_echo_client",
+    ],
+    kernel = ":target",
+    platform = "//target/ast10x0",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+)
+
+system_image(
+    name = "mctp_server_peer_image",
+    apps = [
+        ":i2c_server_peer",
+        ":mctp_server_peer",
+        ":mctp_echo_client_peer",
+    ],
+    kernel = ":target",
+    platform = "//target/ast10x0",
+    system_config = ":peer_system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+)
+
+system_image_test(
+    name = "mctp_server_test",
+    image = ":mctp_server_image",
+    slave_image = ":mctp_server_peer_image",
+    tags = [
+        "hardware",
+        "manual",
+    ],
+    target_compatible_with = select({
+        "//target/ast10x0:qemu_enabled": ["@platforms//:incompatible"],
+        "//conditions:default": [],
+    }),
+)
diff --git a/target/ast10x0/tests/mctp/server/echo_main.rs b/target/ast10x0/tests/mctp/server/echo_main.rs
new file mode 100644
index 0000000..ef697af
--- /dev/null
+++ b/target/ast10x0/tests/mctp/server/echo_main.rs
@@ -0,0 +1,34 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+#![no_main]
+#![no_std]
+
+use openprot_mctp_api::Stack;
+use openprot_mctp_client_ipc::IpcMctpClient;
+use openprot_mctp_echo::{prepare_listener_with_eid_and_timeout, run_with_peer};
+use userspace::{entry, syscall};
+
+// Primary echo endpoint at EID 8, sends to peer at EID 9.
+const ECHO_EID: u8 = 8;
+const PEER_EID: u8 = 9;
+const LISTEN_TIMEOUT_MS: u32 = 100;
+#[entry]
+fn entry() {
+    let stack = Stack::new(IpcMctpClient::new(app_mctp_echo_client::handle::MCTP));
+    let mut listener =
+        match prepare_listener_with_eid_and_timeout(&stack, ECHO_EID, LISTEN_TIMEOUT_MS) {
+            Ok(listener) => listener,
+            Err(e) => {
+                pw_log::error!("echo setup failed: code={}", e.code as u32);
+                syscall::process_exit(1);
+            }
+        };
+
+    run_with_peer(&stack, PEER_EID, &mut listener);
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast10x0/tests/mctp/server/echo_main_peer.rs b/target/ast10x0/tests/mctp/server/echo_main_peer.rs
new file mode 100644
index 0000000..b8f6b54
--- /dev/null
+++ b/target/ast10x0/tests/mctp/server/echo_main_peer.rs
@@ -0,0 +1,36 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+#![no_main]
+#![no_std]
+
+use openprot_mctp_api::Stack;
+use openprot_mctp_client_ipc::IpcMctpClient;
+use openprot_mctp_echo::{prepare_listener_with_eid_and_timeout, run_with_peer_round_trip_limit};
+use userspace::{entry, syscall};
+
+// Peer echo endpoint at EID 9, sends to primary at EID 8.
+const ECHO_EID: u8 = 9;
+const PEER_EID: u8 = 8;
+const LISTEN_TIMEOUT_MS: u32 = 100;
+const MAX_ROUND_TRIPS: u32 = 64;
+
+#[entry]
+fn entry() {
+    let stack = Stack::new(IpcMctpClient::new(app_mctp_echo_client_peer::handle::MCTP));
+    let mut listener =
+        match prepare_listener_with_eid_and_timeout(&stack, ECHO_EID, LISTEN_TIMEOUT_MS) {
+            Ok(listener) => listener,
+            Err(e) => {
+                pw_log::error!("echo setup failed: code={}", e.code as u32);
+                syscall::process_exit(1);
+            }
+        };
+
+    run_with_peer_round_trip_limit(&stack, PEER_EID, &mut listener, MAX_ROUND_TRIPS);
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast10x0/tests/mctp/server/i2c_server_main.rs b/target/ast10x0/tests/mctp/server/i2c_server_main.rs
new file mode 100644
index 0000000..40f295d
--- /dev/null
+++ b/target/ast10x0/tests/mctp/server/i2c_server_main.rs
@@ -0,0 +1,52 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! I2C server app: board init + open Bus 2 + run the server-runtime loop.
+
+#![no_main]
+#![no_std]
+
+use app_i2c_server::{handle, signals};
+use ast10x0_peripherals::i2c::{ClockConfig, I2cConfig, I2cSpeed, I2cXferMode};
+use i2c_server_runtime::{run, Bus};
+use userspace::entry;
+
+const SLAVE_CFG: I2cConfig = I2cConfig {
+    speed: I2cSpeed::Standard,
+    xfer_mode: I2cXferMode::DmaMode,
+    multi_master: false,
+    smbus_timeout: false,
+    smbus_alert: false,
+    clock_config: ClockConfig::ast1060_default(),
+};
+
+#[unsafe(link_section = ".ram_nc")]
+static mut MASTER_DMA_BUF: [u8; 4096] = [0u8; 4096];
+#[unsafe(link_section = ".ram_nc")]
+static mut SLAVE_DMA_BUF: [u8; 256] = [0u8; 256];
+
+#[entry]
+fn entry() {
+    // SAFETY: board init ran init_bus(2) in the kernel; buffers are non-cached and owned here.
+    let master_dma_buf: &'static mut [u8] =
+        unsafe { &mut *core::ptr::addr_of_mut!(MASTER_DMA_BUF) };
+    let slave_dma_buf: &'static mut [u8] = unsafe { &mut *core::ptr::addr_of_mut!(SLAVE_DMA_BUF) };
+    let driver =
+        match unsafe { i2c_backend::open_bus_dma(2, &SLAVE_CFG, master_dma_buf, slave_dma_buf) } {
+            Ok(d) => d,
+            Err(_) => {
+                pw_log::error!("open_bus_dma(2) failed");
+                loop {}
+            }
+        };
+
+    pw_log::info!("I2C server ready on Bus 2");
+
+    let mut buses = [Bus::new(handle::I2C, handle::I2C2_IRQ, driver)];
+    run(handle::WG, signals::I2C2, &mut buses);
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast10x0/tests/mctp/server/i2c_server_main_peer.rs b/target/ast10x0/tests/mctp/server/i2c_server_main_peer.rs
new file mode 100644
index 0000000..f2bba75
--- /dev/null
+++ b/target/ast10x0/tests/mctp/server/i2c_server_main_peer.rs
@@ -0,0 +1,52 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! I2C server app: board init + open Bus 2 + run the server-runtime loop.
+
+#![no_main]
+#![no_std]
+
+use app_i2c_server_peer::{handle, signals};
+use ast10x0_peripherals::i2c::{ClockConfig, I2cConfig, I2cSpeed, I2cXferMode};
+use i2c_server_runtime::{run, Bus};
+use userspace::entry;
+
+const SLAVE_CFG: I2cConfig = I2cConfig {
+    speed: I2cSpeed::Standard,
+    xfer_mode: I2cXferMode::DmaMode,
+    multi_master: false,
+    smbus_timeout: false,
+    smbus_alert: false,
+    clock_config: ClockConfig::ast1060_default(),
+};
+
+#[unsafe(link_section = ".ram_nc")]
+static mut MASTER_DMA_BUF: [u8; 4096] = [0u8; 4096];
+#[unsafe(link_section = ".ram_nc")]
+static mut SLAVE_DMA_BUF: [u8; 256] = [0u8; 256];
+
+#[entry]
+fn entry() {
+    // SAFETY: board init ran init_bus(2) in the kernel; buffers are non-cached and owned here.
+    let master_dma_buf: &'static mut [u8] =
+        unsafe { &mut *core::ptr::addr_of_mut!(MASTER_DMA_BUF) };
+    let slave_dma_buf: &'static mut [u8] = unsafe { &mut *core::ptr::addr_of_mut!(SLAVE_DMA_BUF) };
+    let driver =
+        match unsafe { i2c_backend::open_bus_dma(2, &SLAVE_CFG, master_dma_buf, slave_dma_buf) } {
+            Ok(d) => d,
+            Err(_) => {
+                pw_log::error!("open_bus_dma(2) failed");
+                loop {}
+            }
+        };
+
+    pw_log::info!("I2C server peer ready on Bus 2");
+
+    let mut buses = [Bus::new(handle::I2C, handle::I2C2_IRQ, driver)];
+    run(handle::WG, signals::I2C2, &mut buses);
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast10x0/tests/mctp/server/main.rs b/target/ast10x0/tests/mctp/server/main.rs
new file mode 100644
index 0000000..577ffdf
--- /dev/null
+++ b/target/ast10x0/tests/mctp/server/main.rs
@@ -0,0 +1,317 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! MCTP Server — IPC Dispatch Loop
+//!
+//! Userspace service that receives MCTP requests over a Pigweed IPC channel,
+//! dispatches them to the MCTP server core, and responds with results.
+//!
+//! # Architecture
+//!
+//! ```text
+//! ┌─ Client ──────────────────────────┐
+//! │ channel_transact(request)         │
+//! └──────────────┬────────────────────┘
+//!                │ IPC channel
+//!                ▼
+//! ┌─ This Server ─────────────────────┐
+//! │ object_wait(READABLE)             │
+//! │ channel_read → MctpRequestHeader  │
+//! │ dispatch_mctp_op(op, server)      │
+//! │ channel_respond ← MctpRespHeader  │
+//! └──────────────┬────────────────────┘
+//!                │ mctp-stack Router
+//!                ▼
+//! ┌─ I2C Transport ──────────────────┐
+//! │ I2cSender → I2C Server IPC      │
+//! └──────────────────────────────────┘
+//! ```
+//!
+//! # IPC Pattern
+//!
+//! Follows the same loop as `services/i2c/server/src/main.rs`:
+//!
+//! 1. `object_wait(handle, READABLE)` — block until a client sends a request
+//! 2. `channel_read(handle)` — read the raw request bytes
+//! 3. Parse `MctpRequestHeader`, dispatch via `dispatch_mctp_op`
+//! 4. `channel_respond(handle)` — send response header + data
+//!
+//! # Handle Binding
+//!
+//! The IPC handles are provided by the `app_package` Bazel rule, which
+//! generates `app_mctp_server::handle::*` from the system configuration.
+
+#![no_main]
+#![no_std]
+
+use i2c_api::SlaveEvent;
+use i2c_client::I2cClient;
+use i2c_client_ipc::IpcTransport;
+use openprot_mctp_api::wire::{
+    self, MctpOp, MctpRequestHeader, MAX_PAYLOAD_SIZE, MAX_REQUEST_SIZE, MAX_RESPONSE_SIZE,
+};
+use openprot_mctp_api::{Handle, ResponseCode};
+use openprot_mctp_server::dispatch::{self, DispatchOutcome};
+use openprot_mctp_transport_i2c::{I2cSender, MctpI2cReceiver};
+
+use pw_status::Error;
+use pw_status::Result;
+use userspace::entry;
+use userspace::syscall::{self, Signals};
+use userspace::time::{Clock, Duration, Instant, SystemClock};
+
+use app_mctp_server::handle;
+
+const OWN_EID: u8 = 8;
+const OWN_I2C_ADDR: u8 = 0x10;
+const REMOTE_I2C_ADDR: u8 = 0x42;
+const I2C_RX_MAX: usize = MAX_PAYLOAD_SIZE;
+
+// ---------------------------------------------------------------------------
+// Server loop
+// ---------------------------------------------------------------------------
+
+fn mctp_server_loop() -> Result<()> {
+    pw_log::info!("MCTP server starting");
+    let sender = I2cSender::new(
+        I2cClient::new(IpcTransport::new(handle::I2C)),
+        OWN_I2C_ADDR,
+        REMOTE_I2C_ADDR,
+    );
+    let mut i2c_rx_client = I2cClient::new(IpcTransport::new(handle::I2C));
+    let i2c_receiver = MctpI2cReceiver::new(OWN_I2C_ADDR);
+
+    i2c_rx_client.configure_slave(OWN_I2C_ADDR).map_err(|_| {
+        pw_log::error!("configure_slave failed");
+        Error::Internal
+    })?;
+
+    i2c_rx_client.enable_slave().map_err(|_| {
+        pw_log::error!("enable_slave failed");
+        Error::Internal
+    })?;
+
+    i2c_rx_client.enable_notification().map_err(|_| {
+        pw_log::error!("enable_notification failed");
+        Error::Internal
+    })?;
+
+    let mut server = openprot_mctp_server::Server::<_, 16>::new(mctp::Eid(OWN_EID), 0, sender);
+
+    let mut request_buf = [0u8; MAX_REQUEST_SIZE];
+    let mut response_buf = [0u8; MAX_RESPONSE_SIZE];
+    let mut recv_buf = [0u8; MAX_PAYLOAD_SIZE];
+    let mut i2c_rx_buf = [0u8; I2C_RX_MAX];
+    struct PendingRecv {
+        handle: Handle,
+        deadline: Instant,
+    }
+
+    // Pending blocking-recv: holds the MCTP handle whose IPC reply is deferred
+    // until I2C data arrives, or until its timeout deadline expires.
+    let mut pending_recv: Option<PendingRecv> = None;
+
+    // Register event sources with the WaitGroup.
+    // user_data=0 → IPC from a client  (MCTP channel READABLE)
+    // user_data=1 → I2C USER signal (slave data latched by i2c server)
+    syscall::wait_group_add(handle::WG, handle::MCTP, Signals::READABLE, 0usize)?;
+    syscall::wait_group_add(handle::WG, handle::I2C, Signals::USER, 1usize)?;
+
+    loop {
+        let wait_deadline = pending_recv
+            .as_ref()
+            .map(|pending| pending.deadline)
+            .unwrap_or(Instant::MAX);
+        let ev = match syscall::object_wait(
+            handle::WG,
+            Signals::READABLE | Signals::USER,
+            wait_deadline,
+        ) {
+            Ok(ev) => ev,
+            Err(pw_status::Error::DeadlineExceeded) => {
+                if pending_recv.take().is_some() {
+                    let resp =
+                        openprot_mctp_api::wire::MctpResponseHeader::error(ResponseCode::TimedOut);
+                    response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE]
+                        .copy_from_slice(&resp.to_bytes());
+                    let _ = syscall::channel_respond(
+                        handle::MCTP,
+                        &response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE],
+                    );
+                    let _ = syscall::wait_group_add(
+                        handle::WG,
+                        handle::MCTP,
+                        Signals::READABLE,
+                        0usize,
+                    );
+                }
+                continue;
+            }
+            Err(err) => return Err(err),
+        };
+
+        if ev.user_data == 1 {
+            // Inbound i2c data: fetch latched payload + metadata and feed to router.
+            match i2c_rx_client.slave_receive(&mut i2c_rx_buf) {
+                Ok(event) => {
+                    if event.kind == SlaveEvent::DataReceived && event.data_len > 0 {
+                        if let Ok((pkt, _)) = i2c_receiver.decode(&i2c_rx_buf[..event.data_len]) {
+                            let _ = server.inbound(pkt);
+                        } else {
+                            pw_log::error!("i2c frame decode failed");
+                        }
+                    }
+                }
+                Err(_) => {
+                    pw_log::error!("slave_receive failed");
+                }
+            }
+            // Satisfy any deferred blocking-recv now that inbound data was processed.
+            if let Some(pending) = pending_recv.as_ref() {
+                if let Some(meta) = server.try_recv(pending.handle, &mut recv_buf) {
+                    let payload = &recv_buf[..meta.payload_size];
+                    let response_len = openprot_mctp_api::wire::encode_recv_response(
+                        &mut response_buf,
+                        meta.msg_type,
+                        meta.msg_ic,
+                        meta.remote_eid,
+                        meta.msg_tag,
+                        payload,
+                    )
+                    .unwrap_or_else(|_| {
+                        openprot_mctp_api::wire::encode_error_response(
+                            &mut response_buf,
+                            ResponseCode::InternalError,
+                        )
+                        .unwrap_or(0)
+                    });
+                    syscall::channel_respond(handle::MCTP, &response_buf[..response_len])?;
+                    pending_recv = None;
+                    syscall::wait_group_add(handle::WG, handle::MCTP, Signals::READABLE, 0usize)?;
+                }
+            }
+        } else {
+            // IPC from a client — channel_read is non-blocking here because
+            // the WaitGroup only fires after READABLE is set.
+            let len = syscall::channel_read(handle::MCTP, 0, &mut request_buf)?;
+            if pending_recv.is_some() {
+                // A blocking recv is in flight; reject the new caller so the
+                // kernel clears READABLE and stops re-firing this branch.
+                let resp =
+                    openprot_mctp_api::wire::MctpResponseHeader::error(ResponseCode::InternalError);
+                response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE]
+                    .copy_from_slice(&resp.to_bytes());
+                syscall::channel_respond(
+                    handle::MCTP,
+                    &response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE],
+                )?;
+                continue;
+            }
+
+            if len < MctpRequestHeader::SIZE {
+                // Truncated request — respond with error
+                let resp =
+                    openprot_mctp_api::wire::MctpResponseHeader::error(ResponseCode::BadArgument);
+                response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE]
+                    .copy_from_slice(&resp.to_bytes());
+                syscall::channel_respond(
+                    handle::MCTP,
+                    &response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE],
+                )?;
+                continue;
+            }
+
+            // Recv op: try immediately; defer response if no message is ready.
+            if MctpRequestHeader::from_bytes(&request_buf[..len])
+                .and_then(|h| h.operation())
+                .map_or(false, |op| matches!(op, MctpOp::Recv))
+            {
+                let header = MctpRequestHeader::from_bytes(&request_buf[..len]).unwrap();
+                let recv_handle = Handle(header.handle);
+                let payload = wire::get_request_payload(&request_buf[..len]);
+                if payload.len() < 4 {
+                    let resp = openprot_mctp_api::wire::MctpResponseHeader::error(
+                        ResponseCode::BadArgument,
+                    );
+                    response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE]
+                        .copy_from_slice(&resp.to_bytes());
+                    syscall::channel_respond(
+                        handle::MCTP,
+                        &response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE],
+                    )?;
+                    continue;
+                }
+
+                let timeout_millis = u32::from_le_bytes(payload[..4].try_into().unwrap());
+                match server.try_recv(recv_handle, &mut recv_buf) {
+                    Some(meta) => {
+                        let payload = &recv_buf[..meta.payload_size];
+                        let response_len = openprot_mctp_api::wire::encode_recv_response(
+                            &mut response_buf,
+                            meta.msg_type,
+                            meta.msg_ic,
+                            meta.remote_eid,
+                            meta.msg_tag,
+                            payload,
+                        )
+                        .unwrap_or_else(|_| {
+                            openprot_mctp_api::wire::encode_error_response(
+                                &mut response_buf,
+                                ResponseCode::InternalError,
+                            )
+                            .unwrap_or(0)
+                        });
+                        syscall::channel_respond(handle::MCTP, &response_buf[..response_len])?;
+                    }
+                    None => {
+                        let deadline = if timeout_millis == 0 {
+                            Instant::MAX
+                        } else {
+                            SystemClock::now()
+                                .checked_add_duration(Duration::from_millis(timeout_millis as i64))
+                                .unwrap_or(Instant::MAX)
+                        };
+                        // No message yet — remove MCTP from WaitGroup so the fast-path cannot
+                        // re-fire while READABLE stays asserted on the open transaction.
+                        pending_recv = Some(PendingRecv {
+                            handle: recv_handle,
+                            deadline,
+                        });
+                        let _ = syscall::wait_group_remove(handle::WG, handle::MCTP);
+                    }
+                }
+            } else {
+                // All other ops: dispatch and respond immediately.
+                let response_len = match dispatch::dispatch_mctp_op(
+                    &request_buf[..len],
+                    &mut response_buf,
+                    &mut server,
+                    &mut recv_buf,
+                    0,
+                ) {
+                    DispatchOutcome::Reply(n) => n,
+                    DispatchOutcome::Pending { .. } => unreachable!("Recv handled above"),
+                };
+                syscall::channel_respond(handle::MCTP, &response_buf[..response_len])?;
+            }
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+#[entry]
+fn entry() {
+    if let Err(e) = mctp_server_loop() {
+        pw_log::error!("mctp_server exiting with error");
+        let _ = syscall::process_exit(e as u32);
+    }
+    loop {}
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast10x0/tests/mctp/server/main_peer.rs b/target/ast10x0/tests/mctp/server/main_peer.rs
new file mode 100644
index 0000000..8de3de4
--- /dev/null
+++ b/target/ast10x0/tests/mctp/server/main_peer.rs
@@ -0,0 +1,257 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! MCTP Server — IPC Dispatch Loop (peer role)
+//!
+//! Same service behavior as `main.rs`, but with complementary I2C addressing
+//! for the second board in dual-EVB tests.
+
+#![no_main]
+#![no_std]
+
+use i2c_api::SlaveEvent;
+use i2c_client::I2cClient;
+use i2c_client_ipc::IpcTransport;
+use openprot_mctp_api::wire::{
+    self, MctpOp, MctpRequestHeader, MAX_PAYLOAD_SIZE, MAX_REQUEST_SIZE, MAX_RESPONSE_SIZE,
+};
+use openprot_mctp_api::{Handle, ResponseCode};
+use openprot_mctp_server::dispatch::{self, DispatchOutcome};
+use openprot_mctp_transport_i2c::{I2cSender, MctpI2cReceiver};
+
+use pw_status::Error;
+use pw_status::Result;
+use userspace::entry;
+use userspace::syscall::{self, Signals};
+use userspace::time::{Clock, Duration, Instant, SystemClock};
+
+use app_mctp_server_peer::handle;
+
+const OWN_EID: u8 = 9;
+const OWN_I2C_ADDR: u8 = 0x42;
+const REMOTE_I2C_ADDR: u8 = 0x10;
+const I2C_RX_MAX: usize = MAX_PAYLOAD_SIZE;
+
+fn mctp_server_loop() -> Result<()> {
+    pw_log::info!("MCTP server peer starting");
+    let sender = I2cSender::new(
+        I2cClient::new(IpcTransport::new(handle::I2C)),
+        OWN_I2C_ADDR,
+        REMOTE_I2C_ADDR,
+    );
+    let mut i2c_rx_client = I2cClient::new(IpcTransport::new(handle::I2C));
+    let i2c_receiver = MctpI2cReceiver::new(OWN_I2C_ADDR);
+
+    if i2c_rx_client.configure_slave(OWN_I2C_ADDR).is_err() {
+        pw_log::error!("configure_slave failed");
+        return Err(Error::Internal);
+    }
+    if i2c_rx_client.enable_slave().is_err() {
+        pw_log::error!("enable_slave failed");
+        return Err(Error::Internal);
+    }
+    if i2c_rx_client.enable_notification().is_err() {
+        pw_log::error!("enable_notification failed");
+        return Err(Error::Internal);
+    }
+
+    let mut server = openprot_mctp_server::Server::<_, 16>::new(mctp::Eid(OWN_EID), 0, sender);
+
+    let mut request_buf = [0u8; MAX_REQUEST_SIZE];
+    let mut response_buf = [0u8; MAX_RESPONSE_SIZE];
+    let mut recv_buf = [0u8; MAX_PAYLOAD_SIZE];
+    let mut i2c_rx_buf = [0u8; I2C_RX_MAX];
+
+    syscall::wait_group_add(handle::WG, handle::MCTP, Signals::READABLE, 0usize)?;
+    syscall::wait_group_add(handle::WG, handle::I2C, Signals::USER, 1usize)?;
+
+    struct PendingRecv {
+        handle: Handle,
+        deadline: Instant,
+    }
+
+    let mut pending_recv: Option<PendingRecv> = None;
+
+    loop {
+        let wait_deadline = pending_recv
+            .as_ref()
+            .map(|pending| pending.deadline)
+            .unwrap_or(Instant::MAX);
+        let ev = match syscall::object_wait(
+            handle::WG,
+            Signals::READABLE | Signals::USER,
+            wait_deadline,
+        ) {
+            Ok(ev) => ev,
+            Err(pw_status::Error::DeadlineExceeded) => {
+                if pending_recv.take().is_some() {
+                    let resp =
+                        openprot_mctp_api::wire::MctpResponseHeader::error(ResponseCode::TimedOut);
+                    response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE]
+                        .copy_from_slice(&resp.to_bytes());
+                    let _ = syscall::channel_respond(
+                        handle::MCTP,
+                        &response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE],
+                    );
+                    let _ = syscall::wait_group_add(
+                        handle::WG,
+                        handle::MCTP,
+                        Signals::READABLE,
+                        0usize,
+                    );
+                }
+                continue;
+            }
+            Err(err) => return Err(err),
+        };
+
+        if ev.user_data == 1 {
+            match i2c_rx_client.slave_receive(&mut i2c_rx_buf) {
+                Ok(event) => {
+                    if event.kind == SlaveEvent::DataReceived && event.data_len > 0 {
+                        if let Ok((pkt, _)) = i2c_receiver.decode(&i2c_rx_buf[..event.data_len]) {
+                            let _ = server.inbound(pkt);
+                        } else {
+                            pw_log::error!("i2c frame decode failed");
+                        }
+                    }
+                }
+                Err(_) => {
+                    pw_log::error!("slave_receive failed");
+                }
+            }
+            if let Some(pending) = pending_recv.as_ref() {
+                if let Some(meta) = server.try_recv(pending.handle, &mut recv_buf) {
+                    let payload = &recv_buf[..meta.payload_size];
+                    let response_len = openprot_mctp_api::wire::encode_recv_response(
+                        &mut response_buf,
+                        meta.msg_type,
+                        meta.msg_ic,
+                        meta.remote_eid,
+                        meta.msg_tag,
+                        payload,
+                    )
+                    .unwrap_or_else(|_| {
+                        openprot_mctp_api::wire::encode_error_response(
+                            &mut response_buf,
+                            ResponseCode::InternalError,
+                        )
+                        .unwrap_or(0)
+                    });
+                    syscall::channel_respond(handle::MCTP, &response_buf[..response_len])?;
+                    pending_recv = None;
+                    syscall::wait_group_add(handle::WG, handle::MCTP, Signals::READABLE, 0usize)?;
+                }
+            }
+        } else {
+            let len = syscall::channel_read(handle::MCTP, 0, &mut request_buf)?;
+            if pending_recv.is_some() {
+                let resp =
+                    openprot_mctp_api::wire::MctpResponseHeader::error(ResponseCode::InternalError);
+                response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE]
+                    .copy_from_slice(&resp.to_bytes());
+                syscall::channel_respond(
+                    handle::MCTP,
+                    &response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE],
+                )?;
+                continue;
+            }
+
+            if len < MctpRequestHeader::SIZE {
+                let resp =
+                    openprot_mctp_api::wire::MctpResponseHeader::error(ResponseCode::BadArgument);
+                response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE]
+                    .copy_from_slice(&resp.to_bytes());
+                syscall::channel_respond(
+                    handle::MCTP,
+                    &response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE],
+                )?;
+                continue;
+            }
+
+            if MctpRequestHeader::from_bytes(&request_buf[..len])
+                .and_then(|h| h.operation())
+                .map_or(false, |op| matches!(op, MctpOp::Recv))
+            {
+                let header = MctpRequestHeader::from_bytes(&request_buf[..len]).unwrap();
+                let recv_handle = Handle(header.handle);
+                let payload = wire::get_request_payload(&request_buf[..len]);
+                if payload.len() < 4 {
+                    let resp = openprot_mctp_api::wire::MctpResponseHeader::error(
+                        ResponseCode::BadArgument,
+                    );
+                    response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE]
+                        .copy_from_slice(&resp.to_bytes());
+                    syscall::channel_respond(
+                        handle::MCTP,
+                        &response_buf[..openprot_mctp_api::wire::MctpResponseHeader::SIZE],
+                    )?;
+                    continue;
+                }
+
+                let timeout_millis = u32::from_le_bytes(payload[..4].try_into().unwrap());
+                match server.try_recv(recv_handle, &mut recv_buf) {
+                    Some(meta) => {
+                        let payload = &recv_buf[..meta.payload_size];
+                        let response_len = openprot_mctp_api::wire::encode_recv_response(
+                            &mut response_buf,
+                            meta.msg_type,
+                            meta.msg_ic,
+                            meta.remote_eid,
+                            meta.msg_tag,
+                            payload,
+                        )
+                        .unwrap_or_else(|_| {
+                            openprot_mctp_api::wire::encode_error_response(
+                                &mut response_buf,
+                                ResponseCode::InternalError,
+                            )
+                            .unwrap_or(0)
+                        });
+                        syscall::channel_respond(handle::MCTP, &response_buf[..response_len])?;
+                    }
+                    None => {
+                        let deadline = if timeout_millis == 0 {
+                            Instant::MAX
+                        } else {
+                            SystemClock::now()
+                                .checked_add_duration(Duration::from_millis(timeout_millis as i64))
+                                .unwrap_or(Instant::MAX)
+                        };
+                        pending_recv = Some(PendingRecv {
+                            handle: recv_handle,
+                            deadline,
+                        });
+                        let _ = syscall::wait_group_remove(handle::WG, handle::MCTP);
+                    }
+                }
+            } else {
+                let response_len = match dispatch::dispatch_mctp_op(
+                    &request_buf[..len],
+                    &mut response_buf,
+                    &mut server,
+                    &mut recv_buf,
+                    0,
+                ) {
+                    DispatchOutcome::Reply(n) => n,
+                    DispatchOutcome::Pending { .. } => unreachable!("Recv handled above"),
+                };
+                syscall::channel_respond(handle::MCTP, &response_buf[..response_len])?;
+            }
+        }
+    }
+}
+
+#[entry]
+fn entry() {
+    if let Err(e) = mctp_server_loop() {
+        pw_log::error!("mctp_server peer exiting with error");
+        let _ = syscall::process_exit(e as u32);
+    }
+    loop {}
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast10x0/tests/mctp/server/peer_system.json5 b/target/ast10x0/tests/mctp/server/peer_system.json5
new file mode 100644
index 0000000..4a2cc20
--- /dev/null
+++ b/target/ast10x0/tests/mctp/server/peer_system.json5
@@ -0,0 +1,116 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+// AST10x0 MCTP Server Configuration (peer image)
+{
+    arch: {
+        type: "armv7m",
+        vector_table_start_address: 0x00000000,
+        vector_table_size_bytes: 1792,
+    },
+    kernel: {
+        flash_start_address: 0x00000700,
+        flash_size_bytes: 129280,
+        ram_start_address: 0x00060000,
+        ram_size_bytes: 131072,
+    },
+    apps: [
+        {
+            name: "i2c_server_peer",
+            flash_size_bytes: 65536,
+            processes: [
+                {
+                    name: "i2c_server_peer_process",
+                    ram_size_bytes: 65536,
+                    objects: [
+                        {
+                            name: "wg",
+                            type: "wait_group",
+                        },
+                        {
+                            name: "i2c",
+                            type: "channel_handler",
+                        },
+                        {
+                            name: "i2c2_irq",
+                            type: "interrupt",
+                            irqs: [
+                                {
+                                    name: "i2c2",
+                                    number: 112,
+                                },
+                            ],
+                        },
+                        {
+                            type: "thread",
+                            name: "i2c_server_peer_thread",
+                            kernel_stack_size_bytes: 4096,
+                        },
+                    ],
+                    memory_mappings: [
+                        {
+                            name: "i2c_regs",
+                            type: "device",
+                            start_address: 0x7e7b0000,
+                            size_bytes: 0x4000,
+                        },
+                    ],
+                },
+            ],
+        },
+        {
+            name: "mctp_server_peer",
+            flash_size_bytes: 131072,
+            processes: [
+                {
+                    name: "mctp_server_peer_process",
+                    ram_size_bytes: 65536,
+                    objects: [
+                        {
+                            name: "wg",
+                            type: "wait_group",
+                        },
+                        {
+                            name: "mctp",
+                            type: "channel_handler",
+                        },
+                        {
+                            name: "i2c",
+                            type: "channel_initiator",
+                            handler_process: "i2c_server_peer_process",
+                            handler_object_name: "i2c",
+                        },
+                        {
+                            type: "thread",
+                            name: "mctp_server_peer_thread",
+                            kernel_stack_size_bytes: 4096,
+                        },
+                    ],
+                },
+            ],
+        },
+        {
+            name: "mctp_echo_client_peer",
+            flash_size_bytes: 16384,
+            processes: [
+                {
+                    name: "mctp_echo_client_peer_process",
+                    ram_size_bytes: 32768,
+                    objects: [
+                        {
+                            name: "mctp",
+                            type: "channel_initiator",
+                            handler_process: "mctp_server_peer_process",
+                            handler_object_name: "mctp",
+                        },
+                        {
+                            type: "thread",
+                            name: "mctp_echo_client_peer_thread",
+                            kernel_stack_size_bytes: 2048,
+                        },
+                    ],
+                },
+            ],
+        },
+    ],
+}
diff --git a/target/ast10x0/tests/mctp/server/system.json5 b/target/ast10x0/tests/mctp/server/system.json5
new file mode 100644
index 0000000..61387b2
--- /dev/null
+++ b/target/ast10x0/tests/mctp/server/system.json5
@@ -0,0 +1,120 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+// AST10x0 MCTP Server Configuration
+//
+// Multi-app configuration:
+// - i2c_server: owns i2c hardware and runs i2c_server_runtime
+// - mctp_server: uses i2c transport over IPC + serves MCTP control channel
+{
+    arch: {
+        type: "armv7m",
+        vector_table_start_address: 0x00000000,
+        vector_table_size_bytes: 1792,
+    },
+    kernel: {
+        flash_start_address: 0x00000700,
+        flash_size_bytes: 129280,
+        ram_start_address: 0x00060000,
+        ram_size_bytes: 131072,
+    },
+    apps: [
+        {
+            name: "i2c_server",
+            flash_size_bytes: 65536,
+            processes: [
+                {
+                    name: "i2c_server_process",
+                    ram_size_bytes: 65536,
+                    objects: [
+                        {
+                            name: "wg",
+                            type: "wait_group",
+                        },
+                        {
+                            name: "i2c",
+                            type: "channel_handler",
+                        },
+                        {
+                            name: "i2c2_irq",
+                            type: "interrupt",
+                            irqs: [
+                                {
+                                    name: "i2c2",
+                                    number: 112,
+                                },
+                            ],
+                        },
+                        {
+                            type: "thread",
+                            name: "i2c_server_thread",
+                            kernel_stack_size_bytes: 4096,
+                        },
+                    ],
+                    memory_mappings: [
+                        {
+                            name: "i2c_regs",
+                            type: "device",
+                            start_address: 0x7e7b0000,
+                            size_bytes: 0x4000,
+                        },
+                    ],
+                },
+            ],
+        },
+        {
+            name: "mctp_server",
+            flash_size_bytes: 131072,
+            processes: [
+                {
+                    name: "mctp_server_process",
+                    ram_size_bytes: 65536,
+                    objects: [
+                        {
+                            name: "wg",
+                            type: "wait_group",
+                        },
+                        {
+                            name: "mctp",
+                            type: "channel_handler",
+                        },
+                        {
+                            name: "i2c",
+                            type: "channel_initiator",
+                            handler_process: "i2c_server_process",
+                            handler_object_name: "i2c",
+                        },
+                        {
+                            type: "thread",
+                            name: "mctp_server_thread",
+                            kernel_stack_size_bytes: 4096,
+                        },
+                    ],
+                },
+            ],
+        },
+        {
+            name: "mctp_echo_client",
+            flash_size_bytes: 16384,
+            processes: [
+                {
+                    name: "mctp_echo_client_process",
+                    ram_size_bytes: 32768,
+                    objects: [
+                        {
+                            name: "mctp",
+                            type: "channel_initiator",
+                            handler_process: "mctp_server_process",
+                            handler_object_name: "mctp",
+                        },
+                        {
+                            type: "thread",
+                            name: "mctp_echo_client_thread",
+                            kernel_stack_size_bytes: 2048,
+                        },
+                    ],
+                },
+            ],
+        },
+    ],
+}
diff --git a/target/ast10x0/tests/mctp/server/target.rs b/target/ast10x0/tests/mctp/server/target.rs
new file mode 100644
index 0000000..ed4f034
--- /dev/null
+++ b/target/ast10x0/tests/mctp/server/target.rs
@@ -0,0 +1,64 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+#![no_std]
+#![no_main]
+
+use ast10x0_board::{Ast10x0Board, Ast10x0BoardDescriptor, I2cBusCfg};
+use ast10x0_peripherals::i2c::{ClockConfig, I2cConfig, I2cSpeed, I2cXferMode};
+use ast10x0_peripherals::scu::pinctrl;
+use console_backend::console_backend_write_all;
+use entry as _;
+use target_common::{declare_target, TargetInterface};
+
+pub struct Target;
+
+const I2C2_CFG: I2cConfig = I2cConfig {
+    speed: I2cSpeed::Standard,
+    xfer_mode: I2cXferMode::DmaMode,
+    multi_master: false,
+    smbus_timeout: false,
+    smbus_alert: false,
+    clock_config: ClockConfig::ast1060_default(),
+};
+
+static PINCTRL_GROUPS: [&[ast10x0_peripherals::scu::PinctrlPin]; 1] = [pinctrl::PINCTRL_I2C2];
+static I2C_BUSES: [I2cBusCfg; 1] = [I2cBusCfg {
+    bus: 2,
+    config: I2C2_CFG,
+}];
+
+impl TargetInterface for Target {
+    const NAME: &'static str = "AST10x0 MCTP Server";
+
+    fn main() -> ! {
+        // SAFETY: kernel main() runs once with exclusive hardware ownership.
+        if unsafe {
+            Ast10x0Board::new(Ast10x0BoardDescriptor {
+                pinctrl_groups: &PINCTRL_GROUPS,
+                i2c_buses: &I2C_BUSES,
+            })
+            .init()
+        }
+        .is_err()
+        {
+            loop {}
+        }
+
+        codegen::start();
+        loop {}
+    }
+
+    fn shutdown(code: u32) -> ! {
+        let sentinel: &[u8] = if code == 0 {
+            b"TEST_RESULT:PASS\n"
+        } else {
+            b"TEST_RESULT:FAIL\n"
+        };
+        let _ = console_backend_write_all(sentinel);
+        #[expect(clippy::empty_loop)]
+        loop {}
+    }
+}
+
+declare_target!(Target);
diff --git a/third_party/crates_io/Cargo.toml b/third_party/crates_io/Cargo.toml
index a83b7b6..b30765d 100644
--- a/third_party/crates_io/Cargo.toml
+++ b/third_party/crates_io/Cargo.toml
@@ -51,7 +51,7 @@
 fugit = "0.3.7"
 openprot-hal-blocking = { git = "https://github.com/rusty1968/openprot.git", rev = "c6cd23a" }
 heapless = { version = "0.9", default-features = false }
-mctp = { git = "https://github.com/CodeConstruct/mctp-rs.git" }
+mctp = { git = "https://github.com/CodeConstruct/mctp-rs.git", default-features = false }
 mctp-lib = { git = "https://github.com/OpenPRoT/mctp-lib.git", branch = "9e-buildup" }
 spdm-lib = { git = "https://github.com/OpenPRoT/spdm-lib.git", branch = "main" }
 # Pin to match Hubris ecosystem