mctp: add IpcMctpClient crate and QEMU IPC wire-protocol test)
diff --git a/services/mctp/BUILD.bazel b/services/mctp/BUILD.bazel
index 688e62e..9c01d3e 100644
--- a/services/mctp/BUILD.bazel
+++ b/services/mctp/BUILD.bazel
@@ -8,6 +8,8 @@
     name = "mctp_embedded_all",
     srcs = [
         "//services/mctp/api:mctp_api",
+        # Kernel-only crate (depends on pw_kernel userspace; not host-testable).
+        "//services/mctp/client-ipc:mctp_client_ipc",
         "//services/mctp/echo:mctp_echo",
         "//services/mctp/server:mctp_server_lib",
     ],
diff --git a/services/mctp/client-ipc/BUILD.bazel b/services/mctp/client-ipc/BUILD.bazel
new file mode 100644
index 0000000..06f23f7
--- /dev/null
+++ b/services/mctp/client-ipc/BUILD.bazel
@@ -0,0 +1,17 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@rules_rust//rust:defs.bzl", "rust_library")
+
+rust_library(
+    name = "mctp_client_ipc",
+    srcs = ["src/lib.rs"],
+    crate_name = "openprot_mctp_client_ipc",
+    edition = "2024",
+    tags = ["kernel"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/mctp/api:mctp_api",
+        "@pigweed//pw_kernel/userspace",
+    ],
+)
diff --git a/services/mctp/client-ipc/src/lib.rs b/services/mctp/client-ipc/src/lib.rs
new file mode 100644
index 0000000..cadc388
--- /dev/null
+++ b/services/mctp/client-ipc/src/lib.rs
@@ -0,0 +1,217 @@
+// Licensed under the Apache-2.0 license
+
+//! MCTP IPC Client
+//!
+//! Provides an `MctpClient` implementation that communicates with the MCTP
+//! server over a Pigweed IPC channel, using the wire protocol from
+//! `openprot-mctp-api`.
+//!
+//! ## Usage
+//!
+//! ```rust,ignore
+//! use openprot_mctp_client_ipc::IpcMctpClient;
+//! use openprot_mctp_api::MctpClient;
+//!
+//! let client = IpcMctpClient::new(handle::MCTP);
+//!
+//! client.set_eid(8).unwrap();
+//! let listener = client.listener(1).unwrap();
+//! let meta = client.recv(listener, 0, &mut buf).unwrap();
+//! ```
+
+#![no_std]
+#![warn(missing_docs)]
+
+use core::cell::RefCell;
+
+use openprot_mctp_api::wire::{self, MctpResponseHeader, MAX_REQUEST_SIZE, MAX_RESPONSE_SIZE};
+use openprot_mctp_api::{Handle, MctpClient, MctpError, RecvMetadata, ResponseCode};
+
+/// Internal mutable state for the IPC client.
+struct ClientBuffers {
+    request_buf: [u8; MAX_REQUEST_SIZE],
+    response_buf: [u8; MAX_RESPONSE_SIZE],
+}
+
+/// MCTP client that communicates with the MCTP server over Pigweed IPC.
+///
+/// Uses `RefCell` for interior mutability so that `MctpClient` trait
+/// methods (which take `&self`) can mutate the internal IPC buffers.
+/// This keeps the caller-facing API simple while enabling efficient
+/// buffer reuse across synchronous IPC transactions.
+pub struct IpcMctpClient {
+    handle: u32,
+    inner: RefCell<ClientBuffers>,
+}
+
+impl IpcMctpClient {
+    /// Create a new IPC MCTP client bound to the given channel handle.
+    ///
+    /// The handle comes from the application's `app_package`-generated
+    /// handle module (e.g., `handle::MCTP`).
+    pub fn new(handle: u32) -> Self {
+        Self {
+            handle,
+            inner: RefCell::new(ClientBuffers {
+                request_buf: [0u8; MAX_REQUEST_SIZE],
+                response_buf: [0u8; MAX_RESPONSE_SIZE],
+            }),
+        }
+    }
+
+    /// Get the IPC channel handle.
+    pub fn channel_handle(&self) -> u32 {
+        self.handle
+    }
+
+    /// Encode, send, and decode a transaction.
+    fn transact(&self, req_len: usize) -> Result<(MctpResponseHeader, usize), MctpError> {
+        let resp_len = self.send_recv(req_len)?;
+        let inner = self.inner.borrow();
+
+        if resp_len < MctpResponseHeader::SIZE || resp_len > MAX_RESPONSE_SIZE {
+            return Err(MctpError::from_code(ResponseCode::InternalError));
+        }
+
+        let header = wire::decode_response_header(&inner.response_buf[..resp_len])
+            .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?;
+
+        if !header.is_success() {
+            return Err(MctpError::from_code(header.response_code()));
+        }
+
+        Ok((header, resp_len))
+    }
+
+    fn send_recv(&self, req_len: usize) -> Result<usize, MctpError> {
+        if req_len > MAX_REQUEST_SIZE {
+            return Err(MctpError::from_code(ResponseCode::InternalError));
+        }
+        let req_copy = {
+            let inner = self.inner.borrow();
+            let mut buf = [0u8; MAX_REQUEST_SIZE];
+            buf[..req_len].copy_from_slice(&inner.request_buf[..req_len]);
+            buf
+        };
+        let mut inner = self.inner.borrow_mut();
+        userspace::syscall::channel_transact(
+            self.handle,
+            &req_copy[..req_len],
+            &mut inner.response_buf,
+            userspace::time::Instant::MAX,
+        )
+        .map_err(|_| MctpError::from_code(ResponseCode::InternalError))
+    }
+}
+
+impl MctpClient for IpcMctpClient {
+    fn req(&self, eid: u8) -> Result<Handle, MctpError> {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            wire::encode_req(&mut inner.request_buf, eid)
+                .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?
+        };
+        let (header, _) = self.transact(req_len)?;
+        Ok(Handle(header.handle))
+    }
+
+    fn listener(&self, msg_type: u8) -> Result<Handle, MctpError> {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            wire::encode_listener(&mut inner.request_buf, msg_type)
+                .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?
+        };
+        let (header, _) = self.transact(req_len)?;
+        Ok(Handle(header.handle))
+    }
+
+    fn get_eid(&self) -> u8 {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            match wire::encode_get_eid(&mut inner.request_buf) {
+                Ok(len) => len,
+                Err(_) => return 0,
+            }
+        };
+        match self.transact(req_len) {
+            Ok((header, _)) => header.eid,
+            Err(_) => 0,
+        }
+    }
+
+    fn set_eid(&self, eid: u8) -> Result<(), MctpError> {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            wire::encode_set_eid(&mut inner.request_buf, eid)
+                .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?
+        };
+        self.transact(req_len)?;
+        Ok(())
+    }
+
+    fn recv(
+        &self,
+        handle: Handle,
+        timeout_millis: u32,
+        buf: &mut [u8],
+    ) -> Result<RecvMetadata, MctpError> {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            wire::encode_recv(&mut inner.request_buf, handle.0, timeout_millis)
+                .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?
+        };
+        let (header, resp_len) = self.transact(req_len)?;
+
+        let inner = self.inner.borrow();
+        let payload = wire::get_response_payload(&inner.response_buf[..resp_len], &header)
+            .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?;
+
+        let copy_len = core::cmp::min(payload.len(), buf.len());
+        buf[..copy_len].copy_from_slice(&payload[..copy_len]);
+
+        Ok(RecvMetadata {
+            msg_type: header.msg_type,
+            msg_ic: header.flags & wire::flags::IC != 0,
+            msg_tag: header.tag,
+            remote_eid: header.eid,
+            payload_size: payload.len(),
+        })
+    }
+
+    fn send(
+        &self,
+        handle: Option<Handle>,
+        msg_type: u8,
+        eid: Option<u8>,
+        tag: Option<u8>,
+        integrity_check: bool,
+        buf: &[u8],
+    ) -> Result<u8, MctpError> {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            wire::encode_send(
+                &mut inner.request_buf,
+                handle.map(|h| h.0),
+                msg_type,
+                eid,
+                tag,
+                integrity_check,
+                buf,
+            )
+            .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?
+        };
+        let (header, _) = self.transact(req_len)?;
+        Ok(header.tag)
+    }
+
+    fn drop_handle(&self, handle: Handle) {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            match wire::encode_unbind(&mut inner.request_buf, handle.0) {
+                Ok(len) => len,
+                Err(_) => return,
+            }
+        };
+        let _ = self.transact(req_len);
+    }
+}
diff --git a/target/ast10x0/tests/mctp/ipc_client/BUILD.bazel b/target/ast10x0/tests/mctp/ipc_client/BUILD.bazel
new file mode 100644
index 0000000..2ac7521
--- /dev/null
+++ b/target/ast10x0/tests/mctp/ipc_client/BUILD.bazel
@@ -0,0 +1,122 @@
+# 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", "system_image_test")
+load("@pigweed//pw_kernel/tooling:target_codegen.bzl", "target_codegen")
+load("@pigweed//pw_kernel/tooling:target_linker_script.bzl", "target_linker_script")
+load("@pigweed//pw_kernel/tooling/panic_detector:rust_binary_no_panics_test.bzl", "rust_binary_no_panics_test")
+load("@rules_rust//rust:defs.bzl", "rust_binary")
+load("//target/ast10x0:defs.bzl", "TARGET_COMPATIBLE_WITH")
+
+# ── System configuration ───────────────────────────────────────────────────────
+
+filegroup(
+    name = "system_config",
+    srcs = ["system.json5"],
+)
+
+# ── Kernel image ───────────────────────────────────────────────────────────────
+
+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",
+        "@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",
+        "@pigweed//pw_log/rust:pw_log",
+    ],
+)
+
+# ── Fake MCTP server ───────────────────────────────────────────────────────────
+# Speaks the openprot_mctp_api::wire protocol.  No transport dependency.
+
+rust_app(
+    name = "fake_server",
+    srcs = ["fake_server_main.rs"],
+    codegen_crate_name = "app_fake_server",
+    edition = "2024",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [
+        "//services/mctp/api:mctp_api",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+    ],
+)
+
+# ── Client test app ────────────────────────────────────────────────────────────
+# Exercises IpcMctpClient; calls debug_shutdown(Ok|Err) to report result.
+
+rust_app(
+    name = "client_test",
+    srcs = ["client_main.rs"],
+    codegen_crate_name = "app_client_test",
+    edition = "2024",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/client-ipc:mctp_client_ipc",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+        "@pigweed//pw_status/rust:pw_status",
+    ],
+)
+
+# ── System image ───────────────────────────────────────────────────────────────
+
+system_image(
+    name = "ipc_client_image",
+    apps = [
+        ":fake_server",
+        ":client_test",
+    ],
+    kernel = ":target",
+    platform = "//target/ast10x0",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+)
+
+# ── Test target ────────────────────────────────────────────────────────────────
+# Run with: bazel test --config=virt_ast10x0 //target/ast10x0/tests/mctp/ipc_client:ipc_client_qemu_test
+
+system_image_test(
+    name = "ipc_client_qemu_test",
+    image = ":ipc_client_image",
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+rust_binary_no_panics_test(
+    name = "no_panics_test",
+    binary = ":ipc_client_image",
+    tags = ["kernel"],
+)
diff --git a/target/ast10x0/tests/mctp/ipc_client/client_main.rs b/target/ast10x0/tests/mctp/ipc_client/client_main.rs
new file mode 100644
index 0000000..ebfa00c
--- /dev/null
+++ b/target/ast10x0/tests/mctp/ipc_client/client_main.rs
@@ -0,0 +1,176 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! MCTP IPC-client exerciser for the QEMU test.
+//!
+//! Runs ten test cases against `fake_server` via `IpcMctpClient` and calls
+//! `debug_shutdown(Ok(()))` on full pass or `debug_shutdown(Err(_))` on the
+//! first failure.  The kernel target writes `TEST_RESULT:PASS/FAIL` to UART.
+//!
+//! ## Test cases
+//!
+//! | TC   | Method          | Expected result                          |
+//! |------|-----------------|------------------------------------------|
+//! | TC-01 | set_eid(8)     | Ok(())                                   |
+//! | TC-02 | get_eid()      | returns 8                                |
+//! | TC-03 | listener(5)    | Ok(Handle(42))                           |
+//! | TC-04 | req(8)         | Ok(Handle(43))                           |
+//! | TC-05 | recv(h42, …)   | Ok(meta); msg_type=5, eid=8,             |
+//! |       |                | payload=[DE AD BE EF]                    |
+//! | TC-06 | send(…, b"hi") | Ok(1)  (tag)                             |
+//! | TC-07 | drop_handle(h43)| no panic                                |
+//! | TC-08 | listener(0xFE) | Err(BadArgument)                         |
+//! | TC-09 | recv(h0, …)    | Err(TimedOut)                            |
+//! | TC-10 | recv(h0xFEFE,…)| Err(InternalError)  (truncated response) |
+
+#![no_main]
+#![no_std]
+
+use openprot_mctp_api::{Handle, MctpClient, ResponseCode};
+use openprot_mctp_client_ipc::IpcMctpClient;
+use pw_status::Error;
+use userspace::{entry, syscall};
+
+use app_client_test::handle;
+
+#[entry]
+fn entry() {
+    match run() {
+        Ok(()) => {
+            pw_log::info!("All test cases PASSED");
+            let _ = syscall::debug_shutdown(Ok(()));
+        }
+        Err(()) => {
+            let _ = syscall::debug_shutdown(Err(Error::Internal));
+        }
+    }
+    loop {}
+}
+
+fn run() -> Result<(), ()> {
+    let client = IpcMctpClient::new(handle::MCTP);
+
+    // ── TC-01: set_eid ────────────────────────────────────────────────────────
+    pw_log::info!("TC-01: set_eid(8)");
+    client.set_eid(8).map_err(|e| {
+        pw_log::error!("TC-01 FAIL: code={}", e.code as u32);
+    })?;
+
+    // ── TC-02: get_eid ────────────────────────────────────────────────────────
+    pw_log::info!("TC-02: get_eid()");
+    let eid = client.get_eid();
+    if eid != 8 {
+        pw_log::error!("TC-02 FAIL: expected 8, got {}", eid as u32);
+        return Err(());
+    }
+
+    // ── TC-03: listener ───────────────────────────────────────────────────────
+    pw_log::info!("TC-03: listener(5)");
+    let listener_handle = client.listener(5).map_err(|e| {
+        pw_log::error!("TC-03 FAIL: code={}", e.code as u32);
+    })?;
+    if listener_handle.0 != 42 {
+        pw_log::error!("TC-03 FAIL: expected handle 42, got {}", listener_handle.0 as u32);
+        return Err(());
+    }
+
+    // ── TC-04: req ────────────────────────────────────────────────────────────
+    pw_log::info!("TC-04: req(8)");
+    let req_handle = client.req(8).map_err(|e| {
+        pw_log::error!("TC-04 FAIL: code={}", e.code as u32);
+    })?;
+    if req_handle.0 != 43 {
+        pw_log::error!("TC-04 FAIL: expected handle 43, got {}", req_handle.0 as u32);
+        return Err(());
+    }
+
+    // ── TC-05: recv — success with payload ───────────────────────────────────
+    pw_log::info!("TC-05: recv(listener_handle)");
+    let mut recv_buf = [0u8; 16];
+    let meta = client
+        .recv(listener_handle, 0, &mut recv_buf)
+        .map_err(|e| {
+            pw_log::error!("TC-05 FAIL: code={}", e.code as u32);
+        })?;
+    if meta.msg_type != 5 {
+        pw_log::error!("TC-05 FAIL: msg_type expected 5, got {}", meta.msg_type as u32);
+        return Err(());
+    }
+    if meta.remote_eid != 8 {
+        pw_log::error!("TC-05 FAIL: remote_eid expected 8, got {}", meta.remote_eid as u32);
+        return Err(());
+    }
+    if meta.payload_size != 4 {
+        pw_log::error!("TC-05 FAIL: payload_size expected 4, got {}", meta.payload_size as u32);
+        return Err(());
+    }
+    if recv_buf[..4] != [0xDE, 0xAD, 0xBE, 0xEF] {
+        pw_log::error!("TC-05 FAIL: payload mismatch");
+        return Err(());
+    }
+
+    // ── TC-06: send ───────────────────────────────────────────────────────────
+    pw_log::info!("TC-06: send()");
+    let tag = client
+        .send(None, 5, Some(8), None, false, b"hi")
+        .map_err(|e| {
+            pw_log::error!("TC-06 FAIL: code={}", e.code as u32);
+        })?;
+    if tag != 1 {
+        pw_log::error!("TC-06 FAIL: expected tag 1, got {}", tag as u32);
+        return Err(());
+    }
+
+    // ── TC-07: drop_handle ───────────────────────────────────────────────────
+    pw_log::info!("TC-07: drop_handle(req_handle)");
+    client.drop_handle(req_handle);
+
+    // ── TC-08: error response — BadArgument ──────────────────────────────────
+    pw_log::info!("TC-08: listener(0xFE) should return BadArgument");
+    match client.listener(0xFE) {
+        Err(e) if e.code == ResponseCode::BadArgument => {}
+        Err(e) => {
+            pw_log::error!("TC-08 FAIL: expected BadArgument, got {}", e.code as u32);
+            return Err(());
+        }
+        Ok(_) => {
+            pw_log::error!("TC-08 FAIL: expected Err, got Ok");
+            return Err(());
+        }
+    }
+
+    // ── TC-09: TimedOut response ─────────────────────────────────────────────
+    pw_log::info!("TC-09: recv(Handle(0)) should return TimedOut");
+    match client.recv(Handle(0), 1000, &mut recv_buf) {
+        Err(e) if e.code == ResponseCode::TimedOut => {}
+        Err(e) => {
+            pw_log::error!("TC-09 FAIL: expected TimedOut, got {}", e.code as u32);
+            return Err(());
+        }
+        Ok(_) => {
+            pw_log::error!("TC-09 FAIL: expected Err, got Ok");
+            return Err(());
+        }
+    }
+
+    // ── TC-10: short (truncated) response — InternalError ────────────────────
+    pw_log::info!("TC-10: recv(Handle(0xFEFE)) should return InternalError");
+    match client.recv(Handle(0xFEFE), 1000, &mut recv_buf) {
+        Err(e) if e.code == ResponseCode::InternalError => {}
+        Err(e) => {
+            pw_log::error!("TC-10 FAIL: expected InternalError, got {}", e.code as u32);
+            return Err(());
+        }
+        Ok(_) => {
+            pw_log::error!("TC-10 FAIL: expected Err, got Ok");
+            return Err(());
+        }
+    }
+
+    Ok(())
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast10x0/tests/mctp/ipc_client/fake_server_main.rs b/target/ast10x0/tests/mctp/ipc_client/fake_server_main.rs
new file mode 100644
index 0000000..64c0f46
--- /dev/null
+++ b/target/ast10x0/tests/mctp/ipc_client/fake_server_main.rs
@@ -0,0 +1,129 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Fake MCTP server for the IPC-client QEMU test.
+//!
+//! Speaks the `openprot_mctp_api::wire` protocol over a Pigweed IPC channel.
+//! No I2C, no serial — just a response table keyed on op code and selected
+//! request fields.
+//!
+//! ## Response table
+//!
+//! | Op            | Trigger                  | Response                                 |
+//! |---------------|--------------------------|------------------------------------------|
+//! | SetEid        | any                      | Success                                  |
+//! | GetEid        | any                      | Success, eid = 8                         |
+//! | Listener      | msg_type != 0xFE         | Success, handle = 42                     |
+//! | Listener      | msg_type == 0xFE (TC-08) | Error: BadArgument                       |
+//! | Req           | any                      | Success, handle = 43                     |
+//! | Recv          | handle == 42             | Success, msg_type=5, eid=8, tag=0,       |
+//! |               |                          | payload = [0xDE, 0xAD, 0xBE, 0xEF]      |
+//! | Recv          | handle == 0    (TC-09)   | Error: TimedOut                          |
+//! | Recv          | handle == 0xFEFE (TC-10) | 4-byte short response (no full header)   |
+//! | Send          | any                      | Success, tag = 1                         |
+//! | Unbind        | any                      | Success                                  |
+
+#![no_main]
+#![no_std]
+
+use openprot_mctp_api::wire::{
+    self, MctpOp, MctpRequestHeader, MAX_REQUEST_SIZE, MAX_RESPONSE_SIZE,
+};
+use openprot_mctp_api::ResponseCode;
+use userspace::{entry, syscall};
+use userspace::syscall::Signals;
+use userspace::time::Instant;
+
+use app_fake_server::handle;
+
+// Handle value used by the client to trigger a short (truncated) response.
+const TC10_HANDLE: u32 = 0xFEFE;
+// Handle value used by the client to trigger a TimedOut error response.
+const TC09_HANDLE: u32 = 0;
+
+#[entry]
+fn entry() {
+    let mut req_buf = [0u8; MAX_REQUEST_SIZE];
+    let mut resp_buf = [0u8; MAX_RESPONSE_SIZE];
+
+    if syscall::wait_group_add(handle::WG, handle::MCTP, Signals::READABLE, 0usize).is_err() {
+        loop {}
+    }
+
+    loop {
+        let _ = syscall::object_wait(handle::WG, Signals::READABLE, Instant::MAX);
+
+        let len = match syscall::channel_read(handle::MCTP, 0, &mut req_buf) {
+            Ok(n) => n,
+            Err(_) => continue,
+        };
+
+        // Parse the request header once; handle TC-10 (short response) before
+        // the generic dispatch so we can bypass the full response encoder.
+        let header = match MctpRequestHeader::from_bytes(&req_buf[..len]) {
+            Some(h) => h,
+            None => {
+                let rlen =
+                    wire::encode_error_response(&mut resp_buf, ResponseCode::BadArgument)
+                        .unwrap_or(0);
+                let _ = syscall::channel_respond(handle::MCTP, &resp_buf[..rlen]);
+                continue;
+            }
+        };
+
+        // TC-10: respond with 4 bytes only so the client sees a truncated header.
+        if matches!(header.operation(), Some(MctpOp::Recv)) && header.handle == TC10_HANDLE {
+            let _ = syscall::channel_respond(handle::MCTP, &[0u8; 4]);
+            continue;
+        }
+
+        let resp_len = dispatch(&header, &mut resp_buf);
+        let _ = syscall::channel_respond(handle::MCTP, &resp_buf[..resp_len]);
+    }
+}
+
+/// Build a response for the given decoded header.
+fn dispatch(header: &MctpRequestHeader, resp: &mut [u8]) -> usize {
+    let op = match header.operation() {
+        Some(op) => op,
+        None => {
+            return wire::encode_error_response(resp, ResponseCode::BadArgument).unwrap_or(0)
+        }
+    };
+
+    match op {
+        MctpOp::SetEid => wire::encode_success_response(resp).unwrap_or(0),
+
+        MctpOp::GetEid => wire::encode_get_eid_response(resp, 8).unwrap_or(0),
+
+        MctpOp::Listener => {
+            // TC-08: msg_type 0xFE triggers a BadArgument error response.
+            if header.msg_type == 0xFE {
+                wire::encode_error_response(resp, ResponseCode::BadArgument).unwrap_or(0)
+            } else {
+                wire::encode_handle_response(resp, 42).unwrap_or(0)
+            }
+        }
+
+        MctpOp::Req => wire::encode_handle_response(resp, 43).unwrap_or(0),
+
+        MctpOp::Recv => {
+            // TC-09: handle 0 triggers a TimedOut error response.
+            if header.handle == TC09_HANDLE {
+                wire::encode_error_response(resp, ResponseCode::TimedOut).unwrap_or(0)
+            } else {
+                let payload: [u8; 4] = [0xDE, 0xAD, 0xBE, 0xEF];
+                wire::encode_recv_response(resp, 5, false, 8, 0, &payload).unwrap_or(0)
+            }
+        }
+
+        MctpOp::Send => wire::encode_send_response(resp, 1).unwrap_or(0),
+
+        MctpOp::Unbind => wire::encode_success_response(resp).unwrap_or(0),
+    }
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast10x0/tests/mctp/ipc_client/system.json5 b/target/ast10x0/tests/mctp/ipc_client/system.json5
new file mode 100644
index 0000000..331e17d
--- /dev/null
+++ b/target/ast10x0/tests/mctp/ipc_client/system.json5
@@ -0,0 +1,81 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+// AST10x0 MCTP IPC-client unit-exercise system image.
+//
+// Two-process layout:
+//
+//   fake_server   — channel_handler that returns deterministic wire responses
+//   client_test   — IpcMctpClient exerciser; calls debug_shutdown(Ok|Err)
+//
+// Memory map (AST10x0: 768 KB SRAM, no XIP):
+//   0x00000000 - 0x00000500  vector table (1280 B)
+//   0x00000500 - 0x00020500  kernel flash (~128 KB)
+//   0x00020500 - 0x00060500  app flash (256 KB total; 128 KB per app)
+//   0x00060000 - 0x00080000  kernel RAM (128 KB)
+//   0x00080000 - 0x000C0000  app RAM (256 KB total; 32 KB + 64 KB per process)
+{
+    arch: {
+        type: "armv7m",
+        vector_table_start_address: 0x00000000,
+        vector_table_size_bytes: 1280,
+    },
+    kernel: {
+        flash_start_address: 0x00000500,
+        flash_size_bytes: 129792,
+        ram_start_address: 0x00060000,
+        ram_size_bytes: 131072,
+    },
+    apps: [
+        {
+            name: "fake_server",
+            flash_size_bytes: 131072,
+            processes: [
+                {
+                    name: "fake_server_process",
+                    ram_size_bytes: 32768,
+                    objects: [
+                        {
+                            name: "wg",
+                            type: "wait_group",
+                        },
+                        {
+                            name: "mctp",
+                            type: "channel_handler",
+                        },
+                    ],
+                    threads: [
+                        {
+                            name: "fake_server_thread",
+                            kernel_stack_size_bytes: 4096,
+                        },
+                    ],
+                },
+            ],
+        },
+        {
+            name: "client_test",
+            flash_size_bytes: 131072,
+            processes: [
+                {
+                    name: "client_test_process",
+                    ram_size_bytes: 65536,
+                    objects: [
+                        {
+                            name: "mctp",
+                            type: "channel_initiator",
+                            handler_process: "fake_server_process",
+                            handler_object_name: "mctp",
+                        },
+                    ],
+                    threads: [
+                        {
+                            name: "client_test_thread",
+                            kernel_stack_size_bytes: 4096,
+                        },
+                    ],
+                },
+            ],
+        },
+    ],
+}
diff --git a/target/ast10x0/tests/mctp/ipc_client/target.rs b/target/ast10x0/tests/mctp/ipc_client/target.rs
new file mode 100644
index 0000000..2c20eec
--- /dev/null
+++ b/target/ast10x0/tests/mctp/ipc_client/target.rs
@@ -0,0 +1,40 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Kernel target for the MCTP IPC-client QEMU test.
+//!
+//! Pass/fail is communicated by `client_test` calling
+//! `syscall::debug_shutdown(Ok(()) | Err(...))`, which lands here
+//! and writes the UART sentinel picked up by qemu_runner.py.
+
+#![no_std]
+#![no_main]
+
+use console_backend::console_backend_write_all;
+use target_common::{TargetInterface, declare_target};
+use entry as _;
+
+pub struct Target {}
+
+impl TargetInterface for Target {
+    const NAME: &'static str = "AST10x0 MCTP IPC-client test";
+
+    fn main() -> ! {
+        codegen::start();
+        #[expect(clippy::empty_loop)]
+        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);