Mctp Loopback Transport

This adds a loopback transport for MCTP allowing MCTP testing and development
on one device without needing a hardware transport.

The proof-of-concept test sets up two MCTP servers, with the test application
opening IPC connections to both and sending packets back and forth.
diff --git a/services/mctp/transport-loopback/BUILD.bazel b/services/mctp/transport-loopback/BUILD.bazel
new file mode 100644
index 0000000..4ae3209
--- /dev/null
+++ b/services/mctp/transport-loopback/BUILD.bazel
@@ -0,0 +1,31 @@
+# Licensed under the Apache-2.0 license
+
+load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")
+
+rust_library(
+    name = "mctp_transport_loopback",
+    srcs = glob(["src/**/*.rs"]),
+    crate_name = "openprot_mctp_transport_loopback",
+    edition = "2024",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/mctp/api:mctp_api",
+        "@rust_crates//:heapless",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
+
+rust_test(
+    name = "mctp_transport_loopback_test",
+    srcs = glob(["tests/**/*.rs"]),
+    crate_root = "tests/loopback.rs",
+    edition = "2024",
+    deps = [
+        ":mctp_transport_loopback",
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/server:mctp_server_lib",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
diff --git a/services/mctp/transport-loopback/Cargo.toml b/services/mctp/transport-loopback/Cargo.toml
new file mode 100644
index 0000000..a5b67dd
--- /dev/null
+++ b/services/mctp/transport-loopback/Cargo.toml
@@ -0,0 +1,17 @@
+# Licensed under the Apache-2.0 license
+
+[package]
+name = "openprot-mctp-transport-loopback"
+version = "0.1.0"
+edition = "2021"
+description = "MCTP loopback transport binding for OpenPRoT"
+license = "Apache-2.0"
+
+[dependencies]
+openprot-mctp-api = { path = "../api" }
+mctp-lib = { git = "https://github.com/9elements/mctp-lib.git", branch = "buildup", package = "mctp-lib" }
+mctp = { git = "https://github.com/OpenPRoT/mctp-rs.git", branch = "sync-features", default-features = false }
+heapless = { workspace = true }
+
+[dev-dependencies]
+openprot-mctp-server = { path = "../server" }
diff --git a/services/mctp/transport-loopback/README.md b/services/mctp/transport-loopback/README.md
new file mode 100644
index 0000000..f962010
--- /dev/null
+++ b/services/mctp/transport-loopback/README.md
@@ -0,0 +1,70 @@
+# openprot-mctp-transport-loopback
+
+Loopback transport binding for the MCTP server, enabling direct server-to-server communication without physical transport.
+
+## Overview
+
+This crate implements MCTP loopback transport for testing and development. It provides a shared buffer mechanism that allows two MCTP servers to communicate directly, replacing the I2C transport layer with an in-memory packet exchange.
+
+This is particularly useful for:
+- Unit testing MCTP protocols without hardware
+- SPDM loopback configurations where requester and responder run on the same device
+- Development and debugging of MCTP applications
+
+## Key Types
+
+- `LoopbackSender` — implements `mctp_lib::Sender` for outbound packets; writes to a shared buffer
+- `LoopbackPair` — manages the bidirectional communication between two endpoints
+
+## Architecture
+
+Unlike I2C transport which requires encoding/decoding with headers and PEC, the loopback transport works with raw MCTP packets:
+
+```
+Server A → LoopbackSender → SharedBuffer → Server B.inbound()
+Server B → LoopbackSender → SharedBuffer → Server A.inbound()
+```
+
+Each endpoint gets a `LoopbackSender` that writes to the peer's receive buffer. The application is responsible for polling and transferring packets between endpoints.
+
+## Usage
+
+```rust
+use std::cell::RefCell;
+use mctp::Eid;
+use openprot_mctp_server::Server;
+use openprot_mctp_transport_loopback::{LoopbackPair, LoopbackSender};
+
+// Create a bidirectional loopback pair
+let pair = LoopbackPair::new();
+
+// Create senders for each endpoint
+let sender_a = LoopbackSender::new(&pair.a_to_b);
+let sender_b = LoopbackSender::new(&pair.b_to_a);
+
+// Create two MCTP servers
+let server_a: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(8), 0, sender_a));
+let server_b: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(42), 0, sender_b));
+
+// Send from A to B (using the MctpClient trait via a wrapper)
+// ...
+
+// Transfer packets A→B
+while let Some(pkt) = pair.pop_a_to_b() {
+    server_b.borrow_mut().inbound(&pkt).unwrap();
+}
+
+// Transfer packets B→A
+while let Some(pkt) = pair.pop_b_to_a() {
+    server_a.borrow_mut().inbound(&pkt).unwrap();
+}
+```
+
+See `tests/loopback.rs` for complete examples including echo roundtrips.
+
+## Dependencies
+
+- `openprot-mctp-api` — API traits
+- `mctp-lib` — `Sender` trait, fragmentation
+- `mctp` — core MCTP types
+- `heapless` — `no_std` collections
diff --git a/services/mctp/transport-loopback/src/lib.rs b/services/mctp/transport-loopback/src/lib.rs
new file mode 100644
index 0000000..1c57064
--- /dev/null
+++ b/services/mctp/transport-loopback/src/lib.rs
@@ -0,0 +1,27 @@
+// Licensed under the Apache-2.0 license
+
+//! # MCTP Loopback Transport Binding
+//!
+//! This crate provides a loopback transport binding for the MCTP server,
+//! enabling direct server-to-server communication without physical transport.
+//!
+//! It implements [`mctp_lib::Sender`] for outbound MCTP packets and provides
+//! a shared buffer mechanism for bidirectional packet exchange between two
+//! MCTP endpoints.
+//!
+//! ## Design
+//!
+//! Unlike I2C transport which requires encoding/decoding with transport headers
+//! and PEC, the loopback transport works with raw MCTP packets. This makes it
+//! simpler and more efficient for in-memory communication.
+//!
+//! The [`LoopbackPair`] manages two unidirectional packet queues (A→B and B→A).
+//! Each endpoint gets a [`LoopbackSender`] that writes to the appropriate queue.
+//! The application polls the pair to transfer packets to the receiving server.
+
+#![no_std]
+#![warn(missing_docs)]
+
+mod sender;
+
+pub use sender::{LoopbackPair, LoopbackSender};
diff --git a/services/mctp/transport-loopback/src/sender.rs b/services/mctp/transport-loopback/src/sender.rs
new file mode 100644
index 0000000..76bce6c
--- /dev/null
+++ b/services/mctp/transport-loopback/src/sender.rs
@@ -0,0 +1,178 @@
+// Licensed under the Apache-2.0 license
+
+//! Loopback MCTP sender — in-memory transport binding.
+//!
+//! Provides a shared buffer mechanism for direct MCTP server-to-server
+//! communication without physical transport encoding.
+
+use core::cell::RefCell;
+use heapless::Vec as HVec;
+use mctp::Result;
+use mctp_lib::fragment::{Fragmenter, SendOutput};
+
+/// Maximum packet size (MCTP baseline MTU).
+const MAX_PACKET_SIZE: usize = 255;
+
+/// Maximum number of packets in flight per direction.
+const MAX_QUEUE_DEPTH: usize = 16;
+
+/// A packet queue for one direction of loopback communication.
+type PacketQueue = HVec<HVec<u8, MAX_PACKET_SIZE>, MAX_QUEUE_DEPTH>;
+
+/// Loopback MCTP sender.
+///
+/// Implements `mctp_lib::Sender` to fragment and send MCTP packets
+/// into a shared in-memory buffer. Each sender writes to a queue
+/// that the peer endpoint reads from.
+///
+/// Unlike I2C transport, this works with raw MCTP packets without
+/// any transport-layer encoding.
+///
+/// This struct uses interior mutability (RefCell) so it can be used
+/// with the `Sender` trait which requires `&mut self`.
+pub struct LoopbackSender<'a> {
+    /// Shared packet queue (packets going to the peer).
+    queue: &'a RefCell<PacketQueue>,
+}
+
+impl<'a> LoopbackSender<'a> {
+    /// Create a new loopback sender writing to the given queue.
+    pub fn new(queue: &'a RefCell<PacketQueue>) -> Self {
+        Self { queue }
+    }
+}
+
+impl mctp_lib::Sender for LoopbackSender<'_> {
+    fn send_vectored(
+        &mut self,
+        mut fragmenter: Fragmenter,
+        payload: &[&[u8]],
+    ) -> Result<mctp::Tag> {
+        loop {
+            let mut pkt = [0u8; MAX_PACKET_SIZE];
+            match fragmenter.fragment_vectored(payload, &mut pkt) {
+                SendOutput::Packet(p) => {
+                    // Push raw MCTP packet to queue
+                    self.queue
+                        .borrow_mut()
+                        .push(HVec::from_slice(p).map_err(|_| mctp::Error::TxFailure)?)
+                        .map_err(|_| mctp::Error::TxFailure)?;
+                }
+                SendOutput::Complete { tag, .. } => return Ok(tag),
+                SendOutput::Error { err, .. } => return Err(err),
+            }
+        }
+    }
+
+    fn get_mtu(&self) -> usize {
+        MAX_PACKET_SIZE
+    }
+}
+
+/// Bidirectional loopback packet manager.
+///
+/// Manages two unidirectional packet queues for communication between
+/// two MCTP endpoints (A and B). This is the shared state that both
+/// senders write to and the application polls from.
+///
+/// # Example
+///
+/// ```ignore
+/// use std::cell::RefCell;
+/// use openprot_mctp_transport_loopback::{LoopbackPair, LoopbackSender};
+///
+/// let a_to_b = RefCell::new(Vec::new());
+/// let b_to_a = RefCell::new(Vec::new());
+///
+/// let sender_a = LoopbackSender::new(&a_to_b);
+/// let sender_b = LoopbackSender::new(&b_to_a);
+///
+/// let mut server_a = Server::new(Eid(8), 0, sender_a);
+/// let mut server_b = Server::new(Eid(42), 0, sender_b);
+///
+/// // A sends to B
+/// server_a.send(...);
+/// while !a_to_b.borrow().is_empty() {
+///     let pkt = a_to_b.borrow_mut().swap_remove(0);
+///     server_b.inbound(&pkt).unwrap();
+/// }
+/// ```
+///
+/// For convenience, `LoopbackPair` provides a higher-level API that
+/// manages both queues together.
+pub struct LoopbackPair {
+    /// Packets from A to B.
+    pub a_to_b: RefCell<PacketQueue>,
+    /// Packets from B to A.
+    pub b_to_a: RefCell<PacketQueue>,
+}
+
+impl LoopbackPair {
+    /// Create a new loopback pair.
+    pub fn new() -> Self {
+        Self {
+            a_to_b: RefCell::new(HVec::new()),
+            b_to_a: RefCell::new(HVec::new()),
+        }
+    }
+
+    /// Pop the next packet from A's send queue (destined for B).
+    ///
+    /// Returns `None` if the queue is empty.
+    pub fn pop_a_to_b(&self) -> Option<HVec<u8, MAX_PACKET_SIZE>> {
+        let mut queue = self.a_to_b.borrow_mut();
+        if queue.is_empty() {
+            None
+        } else {
+            Some(queue.swap_remove(0))
+        }
+    }
+
+    /// Pop the next packet from B's send queue (destined for A).
+    ///
+    /// Returns `None` if the queue is empty.
+    pub fn pop_b_to_a(&self) -> Option<HVec<u8, MAX_PACKET_SIZE>> {
+        let mut queue = self.b_to_a.borrow_mut();
+        if queue.is_empty() {
+            None
+        } else {
+            Some(queue.swap_remove(0))
+        }
+    }
+
+    /// Clear all packets from A→B queue.
+    pub fn clear_a_to_b(&self) {
+        self.a_to_b.borrow_mut().clear();
+    }
+
+    /// Clear all packets from B→A queue.
+    pub fn clear_b_to_a(&self) {
+        self.b_to_a.borrow_mut().clear();
+    }
+
+    /// Get the number of packets pending A→B.
+    pub fn len_a_to_b(&self) -> usize {
+        self.a_to_b.borrow().len()
+    }
+
+    /// Get the number of packets pending B→A.
+    pub fn len_b_to_a(&self) -> usize {
+        self.b_to_a.borrow().len()
+    }
+
+    /// Check if A→B queue is empty.
+    pub fn is_empty_a_to_b(&self) -> bool {
+        self.a_to_b.borrow().is_empty()
+    }
+
+    /// Check if B→A queue is empty.
+    pub fn is_empty_b_to_a(&self) -> bool {
+        self.b_to_a.borrow().is_empty()
+    }
+}
+
+impl Default for LoopbackPair {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/services/mctp/transport-loopback/tests/loopback.rs b/services/mctp/transport-loopback/tests/loopback.rs
new file mode 100644
index 0000000..bb280ef
--- /dev/null
+++ b/services/mctp/transport-loopback/tests/loopback.rs
@@ -0,0 +1,248 @@
+// Licensed under the Apache-2.0 license
+
+//! MCTP loopback transport integration test.
+//!
+//! Verifies that two MCTP servers can communicate through the loopback
+//! transport without any physical transport encoding.
+
+use std::cell::RefCell;
+
+use mctp::Eid;
+use openprot_mctp_api::{Handle, MctpClient, MctpError, RecvMetadata, ResponseCode};
+use openprot_mctp_server::Server;
+use openprot_mctp_transport_loopback::{LoopbackPair, LoopbackSender};
+
+// ---------------------------------------------------------------------------
+// Client-side wrapper (same as echo test)
+// ---------------------------------------------------------------------------
+
+struct DirectClient<'a, S: mctp_lib::Sender, const N: usize> {
+    server: &'a RefCell<Server<S, N>>,
+}
+
+impl<'a, S: mctp_lib::Sender, const N: usize> DirectClient<'a, S, N> {
+    fn new(server: &'a RefCell<Server<S, N>>) -> Self {
+        Self { server }
+    }
+}
+
+impl<S: mctp_lib::Sender, const N: usize> MctpClient for DirectClient<'_, S, N> {
+    fn req(&self, eid: u8) -> Result<Handle, MctpError> {
+        self.server.borrow_mut().req(eid)
+    }
+
+    fn listener(&self, msg_type: u8) -> Result<Handle, MctpError> {
+        self.server.borrow_mut().listener(msg_type)
+    }
+
+    fn get_eid(&self) -> u8 {
+        self.server.borrow().get_eid()
+    }
+
+    fn set_eid(&self, eid: u8) -> Result<(), MctpError> {
+        self.server.borrow_mut().set_eid(eid)
+    }
+
+    fn recv(
+        &self,
+        handle: Handle,
+        _timeout_millis: u32,
+        buf: &mut [u8],
+    ) -> Result<RecvMetadata, MctpError> {
+        self.server
+            .borrow_mut()
+            .try_recv(handle, buf)
+            .ok_or(MctpError::from_code(ResponseCode::TimedOut))
+    }
+
+    fn send(
+        &self,
+        handle: Option<Handle>,
+        msg_type: u8,
+        eid: Option<u8>,
+        tag: Option<u8>,
+        integrity_check: bool,
+        buf: &[u8],
+    ) -> Result<u8, MctpError> {
+        self.server
+            .borrow_mut()
+            .send(handle, msg_type, eid, tag, integrity_check, buf)
+    }
+
+    fn drop_handle(&self, handle: Handle) {
+        let _ = self.server.borrow_mut().unbind(handle);
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Echo application logic (same as echo.rs)
+// ---------------------------------------------------------------------------
+
+fn echo_once(client: &impl MctpClient, listener_handle: Handle) {
+    let mut recv_buf = [0u8; 255];
+    let meta = client
+        .recv(listener_handle, 0, &mut recv_buf)
+        .expect("echo: should receive a message");
+
+    let payload = &recv_buf[..meta.payload_size];
+    client
+        .send(
+            None,
+            meta.msg_type,
+            Some(meta.remote_eid),
+            Some(meta.msg_tag),
+            false,
+            payload,
+        )
+        .expect("echo: should send response");
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+/// Basic loopback: send a message from A to B and verify receipt.
+#[test]
+fn loopback_simple_send() {
+    let pair = LoopbackPair::new();
+    let sender_a = LoopbackSender::new(&pair.a_to_b);
+    let sender_b = LoopbackSender::new(&pair.b_to_a);
+
+    let server_a: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(8), 0, sender_a));
+    let server_b: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(42), 0, sender_b));
+
+    let client_a = DirectClient::new(&server_a);
+    let client_b = DirectClient::new(&server_b);
+
+    // B registers a listener for type 1
+    let listener_b = client_b.listener(1).unwrap();
+
+    // A gets a request handle to send to EID 42
+    let req_a = client_a.req(42).unwrap();
+
+    // A sends a message
+    let payload = b"Hello from A!";
+    client_a
+        .send(Some(req_a), 1, None, None, false, payload)
+        .unwrap();
+
+    // Transfer packets A→B
+    while let Some(pkt) = pair.pop_a_to_b() {
+        server_b.borrow_mut().inbound(&pkt).unwrap();
+    }
+
+    // B receives the message
+    let mut recv_buf = [0u8; 255];
+    let meta = client_b
+        .recv(listener_b, 0, &mut recv_buf)
+        .expect("B should receive message");
+
+    let received = &recv_buf[..meta.payload_size];
+    assert_eq!(received, payload);
+    assert_eq!(meta.msg_type, 1);
+    assert_eq!(meta.remote_eid, 8);
+
+    client_a.drop_handle(req_a);
+    client_b.drop_handle(listener_b);
+}
+
+/// MCTP echo roundtrip through loopback transport.
+///
+/// Server A listens for type-1 messages and echoes them.
+/// Server B sends a request and verifies the echo response.
+#[test]
+fn loopback_echo_roundtrip() {
+    let pair = LoopbackPair::new();
+    let sender_a = LoopbackSender::new(&pair.a_to_b);
+    let sender_b = LoopbackSender::new(&pair.b_to_a);
+
+    let server_a: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(8), 0, sender_a));
+    let server_b: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(42), 0, sender_b));
+
+    let client_a = DirectClient::new(&server_a);
+    let client_b = DirectClient::new(&server_b);
+
+    // A registers listener for type 1 (echo responder)
+    let listener_a = client_a.listener(1).unwrap();
+
+    // B gets request handle to send to EID 8
+    let req_b = client_b.req(8).unwrap();
+
+    // B sends a request
+    let payload = b"Hello MCTP loopback!";
+    let _tag = client_b
+        .send(Some(req_b), 1, None, None, false, payload)
+        .unwrap();
+
+    // Transfer B→A
+    while let Some(pkt) = pair.pop_b_to_a() {
+        server_a.borrow_mut().inbound(&pkt).unwrap();
+    }
+
+    // A echoes the message
+    echo_once(&client_a, listener_a);
+
+    // Transfer A→B
+    while let Some(pkt) = pair.pop_a_to_b() {
+        server_b.borrow_mut().inbound(&pkt).unwrap();
+    }
+
+    // B receives the echo response
+    let mut resp_buf = [0u8; 255];
+    let resp_meta = client_b
+        .recv(req_b, 0, &mut resp_buf)
+        .expect("B should receive echo response");
+
+    let response = &resp_buf[..resp_meta.payload_size];
+    assert_eq!(response, payload, "Echo response should match original");
+    assert_eq!(resp_meta.msg_type, 1);
+    assert_eq!(resp_meta.remote_eid, 8);
+
+    client_a.drop_handle(listener_a);
+    client_b.drop_handle(req_b);
+}
+
+/// Multiple echo roundtrips.
+#[test]
+fn loopback_echo_multiple() {
+    let pair = LoopbackPair::new();
+    let sender_a = LoopbackSender::new(&pair.a_to_b);
+    let sender_b = LoopbackSender::new(&pair.b_to_a);
+
+    let server_a: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(8), 0, sender_a));
+    let server_b: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(42), 0, sender_b));
+
+    let client_a = DirectClient::new(&server_a);
+    let client_b = DirectClient::new(&server_b);
+
+    let listener = client_a.listener(1).unwrap();
+    let req = client_b.req(8).unwrap();
+
+    for i in 0..5u8 {
+        let msg = [i; 32];
+
+        // B sends request
+        client_b
+            .send(Some(req), 1, None, None, false, &msg)
+            .unwrap();
+        while let Some(pkt) = pair.pop_b_to_a() {
+            server_a.borrow_mut().inbound(&pkt).unwrap();
+        }
+
+        // A echoes
+        echo_once(&client_a, listener);
+        while let Some(pkt) = pair.pop_a_to_b() {
+            server_b.borrow_mut().inbound(&pkt).unwrap();
+        }
+
+        // B verifies echo
+        let mut resp_buf = [0u8; 255];
+        let resp = client_b
+            .recv(req, 0, &mut resp_buf)
+            .unwrap_or_else(|_| panic!("iteration {i}: no response"));
+        assert_eq!(&resp_buf[..resp.payload_size], &msg);
+    }
+
+    client_a.drop_handle(listener);
+    client_b.drop_handle(req);
+}
diff --git a/target/ast1060-evb/mctp/BUILD.bazel b/target/ast1060-evb/mctp/BUILD.bazel
index f813178..20508de 100644
--- a/target/ast1060-evb/mctp/BUILD.bazel
+++ b/target/ast1060-evb/mctp/BUILD.bazel
@@ -8,6 +8,109 @@
 load("//target/ast1060-evb:defs.bzl", "TARGET_COMPATIBLE_WITH")
 load("//target:uart_boot_image.bzl", "uart_boot_image")
 
+# ===========================================================================
+# MCTP Loopback Test (no I2C, in-process dual servers)
+# ===========================================================================
+
+rust_binary(
+    name = "mctp_loopback_test",
+    srcs = ["mctp_loopback_test.rs"],
+    edition = "2024",
+    tags = ["kernel"],
+    deps = [
+        ":app_mctp_loopback_test",
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/server:mctp_server_lib",
+        "//services/mctp/transport-loopback:mctp_transport_loopback",
+        "@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",
+    ],
+)
+
+app_package(
+    name = "app_mctp_loopback_test",
+    app_name = "mctp_loopback_test",
+    edition = "2024",
+    system_config = ":system_config_loopback",
+    tags = ["kernel"],
+)
+
+system_image(
+    name = "mctp_loopback",
+    apps = [
+        ":mctp_loopback_test",
+    ],
+    kernel = ":target_loopback",
+    platform = "//target/ast1060-evb",
+    system_config = ":system_config_loopback",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+)
+
+system_image_test(
+    name = "mctp_loopback_test_run",
+    image = ":mctp_loopback",
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+uart_boot_image(
+    name = "mctp_loopback_uart",
+    src = ":mctp_loopback",
+    out = "mctp_loopback_uart.bin",
+)
+
+filegroup(
+    name = "system_config_loopback",
+    srcs = ["system_loopback.json5"],
+    visibility = ["//visibility:public"],
+)
+
+target_codegen(
+    name = "codegen_loopback",
+    arch = "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+    system_config = ":system_config_loopback",
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+target_linker_script(
+    name = "linker_script_loopback",
+    system_config = ":system_config_loopback",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    template = "//target/ast1060-evb:linker_script_template",
+)
+
+rust_binary(
+    name = "target_loopback",
+    srcs = [
+        "target_loopback.rs",
+    ],
+    edition = "2024",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [
+        ":codegen_loopback",
+        ":linker_script_loopback",
+        "//target/ast1060-evb:console_backend_uart",
+        "//target/ast1060-evb:entry",
+        "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+        "@pigweed//pw_kernel/kernel",
+        "@pigweed//pw_kernel/target:target_common",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+        "@rust_crates//:cortex-m-semihosting",
+    ],
+)
+
+# ===========================================================================
+# MCTP Echo (Original - with I2C server)
+# ===========================================================================
+
 # ---------------------------------------------------------------------------
 # MCTP echo binary (userspace process)
 # ---------------------------------------------------------------------------
diff --git a/target/ast1060-evb/mctp/mctp_loopback_test.rs b/target/ast1060-evb/mctp/mctp_loopback_test.rs
new file mode 100644
index 0000000..4960ed3
--- /dev/null
+++ b/target/ast1060-evb/mctp/mctp_loopback_test.rs
@@ -0,0 +1,259 @@
+// Licensed under the Apache-2.0 license
+
+//! MCTP Loopback Test Application
+//!
+//! This application demonstrates MCTP communication using loopback transport.
+//! It creates two MCTP servers (A and B) within the same process, connected
+//! via loopback transport, and verifies bidirectional message exchange.
+//!
+//! # Architecture
+//!
+//! ```text
+//! ┌─────────────────────────────────────────────┐
+//! │ mctp_loopback_test (single process)         │
+//! │                                              │
+//! │  ┌─ Server A (EID 8) ──┐                    │
+//! │  │  Router              │                    │
+//! │  │  Listener(type=1)    │                    │
+//! │  └──────┬───────────────┘                    │
+//! │         │                                     │
+//! │         │ LoopbackSender(A→B)                │
+//! │         ▼                                     │
+//! │  [ LoopbackPair ]                            │
+//! │         │                                     │
+//! │         │ LoopbackSender(B→A)                │
+//! │         ▼                                     │
+//! │  ┌─ Server B (EID 42) ─┐                    │
+//! │  │  Router              │                    │
+//! │  │  Request handle      │                    │
+//! │  └──────────────────────┘                    │
+//! └─────────────────────────────────────────────┘
+//! ```
+//!
+//! # Test Sequence
+//!
+//! 1. Server A registers a listener for message type 1
+//! 2. Server B sends a request to EID 8 with type 1
+//! 3. Transfer packets B→A through loopback pair
+//! 4. Server A receives the message on the listener
+//! 5. Server A echoes the message back
+//! 6. Transfer packets A→B through loopback pair
+//! 7. Server B receives the echo response
+//! 8. Verify payload matches
+
+#![no_main]
+#![no_std]
+
+use core::cell::RefCell;
+
+use mctp::Eid;
+use openprot_mctp_api::{Handle, MctpClient, MctpError, RecvMetadata, ResponseCode};
+use openprot_mctp_server::Server;
+use openprot_mctp_transport_loopback::{LoopbackPair, LoopbackSender};
+
+use pw_status::Result;
+use userspace::entry;
+use userspace::syscall;
+
+// ---------------------------------------------------------------------------
+// DirectClient wrapper - provides MctpClient trait for in-process server
+// ---------------------------------------------------------------------------
+
+struct DirectClient<'a, S: mctp_lib::Sender, const N: usize> {
+    server: &'a RefCell<Server<S, N>>,
+}
+
+impl<'a, S: mctp_lib::Sender, const N: usize> DirectClient<'a, S, N> {
+    fn new(server: &'a RefCell<Server<S, N>>) -> Self {
+        Self { server }
+    }
+}
+
+impl<S: mctp_lib::Sender, const N: usize> MctpClient for DirectClient<'_, S, N> {
+    fn req(&self, eid: u8) -> core::result::Result<Handle, MctpError> {
+        self.server.borrow_mut().req(eid)
+    }
+
+    fn listener(&self, msg_type: u8) -> core::result::Result<Handle, MctpError> {
+        self.server.borrow_mut().listener(msg_type)
+    }
+
+    fn get_eid(&self) -> u8 {
+        self.server.borrow().get_eid()
+    }
+
+    fn set_eid(&self, eid: u8) -> core::result::Result<(), MctpError> {
+        self.server.borrow_mut().set_eid(eid)
+    }
+
+    fn recv(
+        &self,
+        handle: Handle,
+        _timeout_millis: u32,
+        buf: &mut [u8],
+    ) -> core::result::Result<RecvMetadata, MctpError> {
+        self.server
+            .borrow_mut()
+            .try_recv(handle, buf)
+            .ok_or(MctpError::from_code(ResponseCode::TimedOut))
+    }
+
+    fn send(
+        &self,
+        handle: Option<Handle>,
+        msg_type: u8,
+        eid: Option<u8>,
+        tag: Option<u8>,
+        integrity_check: bool,
+        buf: &[u8],
+    ) -> core::result::Result<u8, MctpError> {
+        self.server
+            .borrow_mut()
+            .send(handle, msg_type, eid, tag, integrity_check, buf)
+    }
+
+    fn drop_handle(&self, handle: Handle) {
+        let _ = self.server.borrow_mut().unbind(handle);
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Test logic
+// ---------------------------------------------------------------------------
+
+fn echo_once(client: &impl MctpClient, listener_handle: Handle) -> core::result::Result<(), MctpError> {
+    let mut recv_buf = [0u8; 255];
+    let meta = client.recv(listener_handle, 0, &mut recv_buf)?;
+
+    pw_log::info!(
+        "Echo: received {} bytes from EID {}",
+        meta.payload_size as u32,
+        meta.remote_eid as u32
+    );
+
+    let payload = &recv_buf[..meta.payload_size];
+    client.send(
+        None,
+        meta.msg_type,
+        Some(meta.remote_eid),
+        Some(meta.msg_tag),
+        false,
+        payload,
+    )?;
+
+    Ok(())
+}
+
+fn run_loopback_test() -> Result<()> {
+    pw_log::info!("MCTP Loopback Test starting");
+
+    // Create loopback pair
+    let pair = LoopbackPair::new();
+    let sender_a = LoopbackSender::new(&pair.a_to_b);
+    let sender_b = LoopbackSender::new(&pair.b_to_a);
+
+    // Create two MCTP servers
+    let server_a: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(8), 0, sender_a));
+    let server_b: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(42), 0, sender_b));
+
+    let client_a = DirectClient::new(&server_a);
+    let client_b = DirectClient::new(&server_b);
+
+    pw_log::info!("Created Server A (EID 8) and Server B (EID 42)");
+
+    // A registers listener for type 1 (echo responder)
+    let listener_a = client_a
+        .listener(1)
+        .map_err(|_| pw_status::Error::Internal)?;
+    pw_log::info!("Server A: registered listener for type 1");
+
+    // B gets request handle to send to EID 8
+    let req_b = client_b
+        .req(8)
+        .map_err(|_| pw_status::Error::Internal)?;
+    pw_log::info!("Server B: got request handle for EID 8");
+
+    // Run multiple echo roundtrips
+    for iteration in 0..5u8 {
+        let msg = [iteration; 32];
+
+        pw_log::info!("=== Iteration {} ===", iteration as u32);
+
+        // B sends request
+        pw_log::info!("Server B: sending request");
+        client_b
+            .send(Some(req_b), 1, None, None, false, &msg)
+            .map_err(|_| pw_status::Error::Internal)?;
+
+        // Transfer B→A
+        let mut count = 0;
+        while let Some(pkt) = pair.pop_b_to_a() {
+            server_a.borrow_mut().inbound(&pkt).map_err(|_| pw_status::Error::Internal)?;
+            count += 1;
+        }
+        pw_log::info!("Transferred {} packets B→A", count as u32);
+
+        // A echoes
+        pw_log::info!("Server A: echoing message");
+        echo_once(&client_a, listener_a).map_err(|_| pw_status::Error::Internal)?;
+
+        // Transfer A→B
+        count = 0;
+        while let Some(pkt) = pair.pop_a_to_b() {
+            server_b.borrow_mut().inbound(&pkt).map_err(|_| pw_status::Error::Internal)?;
+            count += 1;
+        }
+        pw_log::info!("Transferred {} packets A→B", count as u32);
+
+        // B receives echo response
+        let mut resp_buf = [0u8; 255];
+        let resp = client_b
+            .recv(req_b, 0, &mut resp_buf)
+            .map_err(|_| pw_status::Error::Internal)?;
+
+        pw_log::info!(
+            "Server B: received response ({} bytes)",
+            resp.payload_size as u32
+        );
+
+        // Verify payload
+        let response = &resp_buf[..resp.payload_size];
+        if response != &msg[..] {
+            pw_log::error!("Payload mismatch!");
+            return Err(pw_status::Error::DataLoss);
+        }
+
+        pw_log::info!("✓ Iteration {} passed", iteration as u32);
+    }
+
+    // Cleanup
+    client_a.drop_handle(listener_a);
+    client_b.drop_handle(req_b);
+
+    pw_log::info!("=== All tests PASSED ===");
+    Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+#[entry]
+fn entry() -> ! {
+    match run_loopback_test() {
+        Ok(()) => {
+            pw_log::info!("MCTP loopback test completed successfully");
+            let _ = syscall::debug_shutdown(Ok(()));
+        }
+        Err(e) => {
+            pw_log::error!("MCTP loopback test failed: {}", e as u32);
+            let _ = syscall::debug_shutdown(Err(e));
+        }
+    }
+    loop {}
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast1060-evb/mctp/system.json5 b/target/ast1060-evb/mctp/system.json5
index 86c2abd..1b97419 100644
--- a/target/ast1060-evb/mctp/system.json5
+++ b/target/ast1060-evb/mctp/system.json5
@@ -5,28 +5,28 @@
 // 768KB SRAM (640KB usable), executes from RAM
 //
 // Memory Layout (PMSAv7-friendly, power-of-2 aligned regions):
-//   0x00000000 - 0x00000500: Vector table + kernel annotations (1280 bytes)
-//   0x00000500 - 0x00020000: Kernel flash (~127KB, ends at 128KB boundary)
+//   0x00000000 - 0x00000680: Vector table + kernel annotations (1664 bytes)
+//   0x00000680 - 0x00020000: Kernel flash (~126KB, ends at 128KB boundary)
 //   0x00020000 - 0x00040000: i2c_server flash (128KB)
 //   0x00040000 - 0x00060000: mctp_server flash (128KB)
 //   0x00060000 - 0x00080000: mctp_echo flash (128KB)
-//   0x00080000 - 0x00088000: i2c_server RAM (32KB)
-//   0x00088000 - 0x00090000: mctp_server RAM (32KB)
-//   0x00090000 - 0x00098000: mctp_echo RAM (32KB)
-//   0x00098000 - 0x000A0000: Kernel RAM (32KB)
+//   0x00080000 - 0x000A0000: Kernel RAM (128KB)
+//   0x000A0000 - 0x000B0000: i2c_server RAM (64KB)
+//   0x000B0000 - 0x000B8000: mctp_server RAM (32KB)
+//   0x000B8000 - 0x000C0000: mctp_echo RAM (32KB)
 //
-// Total: 0xA0000 = 640KB (fits within the 640KB usable window of the AST1060's 768KB SRAM).
+// Total: 0xC0000 = 768KB (uses all of the AST1060's 768KB SRAM).
 {
     arch: {
         type: "armv7m",
         vector_table_start_address: 0x00000000,
-        vector_table_size_bytes: 1280,  // 0x500 (272 vectors + thread/stack annotations)
+        vector_table_size_bytes: 1664,  // 0x680 (includes vector table + annotations)
     },
     kernel: {
-        flash_start_address: 0x00000500,  // After vector table + annotations
-        flash_size_bytes: 129792,         // 0x1FB00: ends at exactly 0x20000 (128KB boundary)
-        ram_start_address: 0x00098000,    // After all app RAM (3 × 32KB = 0x18000 past 0x80000)
-        ram_size_bytes: 32768,            // 32KB
+        flash_start_address: 0x00000680,  // After vector table + annotations
+        flash_size_bytes: 129408,         // ~126KB (ends at 0x00020000, power-of-2 boundary)
+        ram_start_address: 0x00080000,    // After all flash regions
+        ram_size_bytes: 131072,           // 128KB
     },
     apps: [
         // ──── I2C Server ────
@@ -35,7 +35,7 @@
         {
             name: "i2c_server",
             flash_size_bytes: 131072,     // 128KB for server code
-            ram_size_bytes: 32768,        // 32KB RAM (register maps + buffers)
+            ram_size_bytes: 65536,        // 64KB RAM (register maps + buffers)
             process: {
                 name: "i2c server process",
                 objects: [
diff --git a/target/ast1060-evb/mctp/system_loopback.json5 b/target/ast1060-evb/mctp/system_loopback.json5
new file mode 100644
index 0000000..8b52656
--- /dev/null
+++ b/target/ast1060-evb/mctp/system_loopback.json5
@@ -0,0 +1,47 @@
+// Licensed under the Apache-2.0 license
+
+// AST1060-EVB MCTP Loopback Test Configuration
+// ARM Cortex-M4 @ 200 MHz
+// 768KB SRAM (640KB usable), executes from RAM
+//
+// Memory Layout (PMSAv7-friendly, power-of-2 aligned regions):
+//   0x00000000 - 0x00000680: Vector table + kernel annotations (1664 bytes)
+//   0x00000680 - 0x00020000: Kernel flash (~126KB, ends at 128KB boundary)
+//   0x00020000 - 0x00040000: mctp_loopback_test flash (128KB)
+//   0x00040000 - 0x00080000: Kernel RAM (256KB)
+//   0x00080000 - 0x000A0000: mctp_loopback_test RAM (128KB)
+//
+// Total: 0xA0000 = 640KB
+{
+    arch: {
+        type: "armv7m",
+        vector_table_start_address: 0x00000000,
+        vector_table_size_bytes: 1664,  // 0x680 (includes vector table + annotations)
+    },
+    kernel: {
+        flash_start_address: 0x00000680,  // After vector table + annotations
+        flash_size_bytes: 129408,         // ~126KB (ends at 0x00020000, power-of-2 boundary)
+        ram_start_address: 0x00040000,    // After flash regions
+        ram_size_bytes: 262144,           // 256KB
+    },
+    apps: [
+        // ──── MCTP Loopback Test ────
+        // Single application that manages two MCTP servers internally
+        // using loopback transport for bidirectional communication.
+        {
+            name: "mctp_loopback_test",
+            flash_size_bytes: 131072,     // 128KB for test code
+            ram_size_bytes: 131072,       // 128KB RAM (two servers + buffers)
+            process: {
+                name: "mctp loopback test process",
+                objects: [],              // No IPC - everything is in-process
+                threads: [
+                    {
+                        name: "mctp loopback test thread",
+                        stack_size_bytes: 8192,   // 8KB stack
+                    },
+                ],
+            },
+        },
+    ],
+}
diff --git a/target/ast1060-evb/mctp/target.rs b/target/ast1060-evb/mctp/target.rs
index 3cb0a2e..931afa5 100644
--- a/target/ast1060-evb/mctp/target.rs
+++ b/target/ast1060-evb/mctp/target.rs
@@ -8,7 +8,6 @@
 #![no_std]
 #![no_main]
 
-use cortex_m_semihosting::debug::{EXIT_FAILURE, EXIT_SUCCESS, exit};
 use target_common::{TargetInterface, declare_target};
 use {console_backend as _, entry as _};
 
@@ -25,11 +24,6 @@
 
     fn shutdown(code: u32) -> ! {
         pw_log::info!("Shutting down with code {}", code as u32);
-        let status = match code {
-            0 => EXIT_SUCCESS,
-            _ => EXIT_FAILURE,
-        };
-        exit(status);
         #[expect(clippy::empty_loop)]
         loop {}
     }
diff --git a/target/ast1060-evb/mctp/target_loopback.rs b/target/ast1060-evb/mctp/target_loopback.rs
new file mode 100644
index 0000000..a784768
--- /dev/null
+++ b/target/ast1060-evb/mctp/target_loopback.rs
@@ -0,0 +1,32 @@
+// Licensed under the Apache-2.0 license
+
+//! AST1060-EVB MCTP Loopback Test Target
+//!
+//! This target runs a single test application that manages two MCTP
+//! servers internally using loopback transport (no I2C required).
+
+#![no_std]
+#![no_main]
+
+use target_common::{TargetInterface, declare_target};
+use {console_backend as _, entry as _};
+
+pub struct Target {}
+
+impl TargetInterface for Target {
+    const NAME: &'static str = "AST1060-EVB MCTP Loopback Test";
+
+    fn main() -> ! {
+        codegen_loopback::start();
+        #[expect(clippy::empty_loop)]
+        loop {}
+    }
+
+    fn shutdown(code: u32) -> ! {
+        pw_log::info!("Shutting down with code {}", code as u32);
+        #[expect(clippy::empty_loop)]
+        loop {}
+    }
+}
+
+declare_target!(Target);