introduce mtcp subsystem of openprot
diff --git a/services/mctp/BUILD.bazel b/services/mctp/BUILD.bazel
new file mode 100644
index 0000000..688e62e
--- /dev/null
+++ b/services/mctp/BUILD.bazel
@@ -0,0 +1,27 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+package(default_visibility = ["//visibility:public"])
+
+# Embedded-safe production crates only (no rust_test targets).
+filegroup(
+    name = "mctp_embedded_all",
+    srcs = [
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/echo:mctp_echo",
+        "//services/mctp/server:mctp_server_lib",
+    ],
+)
+
+# Host-side tests; run without embedded target config.
+test_suite(
+    name = "mctp_host_tests",
+    tests = [
+        "//services/mctp/api:mctp_api_test",
+        "//services/mctp/echo:mctp_echo_host_test",
+        "//services/mctp/server:mctp_server_dispatch_test",
+        "//services/mctp/server:mctp_server_echo_test",
+        "//services/mctp/server:mctp_server_integration_test",
+        "//services/mctp/server:mctp_server_unit_test",
+    ],
+)
diff --git a/services/mctp/README.md b/services/mctp/README.md
new file mode 100644
index 0000000..165cb4f
--- /dev/null
+++ b/services/mctp/README.md
@@ -0,0 +1,28 @@
+# MCTP Service
+
+This directory contains the MCTP API, echo policy crate, and server implementation.
+
+## Test Coverage
+
+This section documents test target coverage (Bazel targets), not line or branch coverage percentages.
+
+### Host Test Suite
+
+Run all host-side tests with:
+
+```bash
+bazelisk test //services/mctp:mctp_host_tests --test_output=errors
+```
+
+`//services/mctp:mctp_host_tests` includes:
+
+- `//services/mctp/api:mctp_api_test`
+- `//services/mctp/echo:mctp_echo_host_test`
+- `//services/mctp/server:mctp_server_dispatch_test`
+- `//services/mctp/server:mctp_server_echo_test`
+- `//services/mctp/server:mctp_server_integration_test`
+- `//services/mctp/server:mctp_server_unit_test`
+
+## Notes
+
+- Transport-dependent behavior is covered in integration-style host tests using in-memory fixtures.
diff --git a/services/mctp/api/BUILD.bazel b/services/mctp/api/BUILD.bazel
index 3f3df45..171f7a3 100644
--- a/services/mctp/api/BUILD.bazel
+++ b/services/mctp/api/BUILD.bazel
@@ -9,10 +9,6 @@
     crate_name = "openprot_mctp_api",
     edition = "2024",
     visibility = ["//visibility:public"],
-    deps = [
-        "@rust_crates//:heapless",
-        "@rust_crates//:zerocopy",
-    ],
 )
 
 rust_test(
diff --git a/services/mctp/api/README.md b/services/mctp/api/README.md
index de26023..c2f1726 100644
--- a/services/mctp/api/README.md
+++ b/services/mctp/api/README.md
@@ -7,22 +7,23 @@
 This crate defines the API contract between MCTP applications and the MCTP server.
 It provides two layers:
 
-1. **`MctpClient` trait** — low-level interface mirroring the IPC wire operations
-   (req, listener, recv, send, drop_handle). Platform-specific crates such as
-   `openprot-mctp-client` implement this trait using the OS transport (e.g. Pigweed IPC).
+1. **`MctpClient` trait** — low-level service-transport interface for
+   `req`, `listener`, `recv`, `send`, `drop_handle`, plus local EID control
+   (`get_eid` / `set_eid`). Platform-specific clients implement this trait
+   over a chosen transport (for example Pigweed IPC, sockets, or test doubles).
 
 2. **`Stack` facade** (`stack` module) — high-level entry point that wraps any `MctpClient`
    and returns typed channel objects (`StackListener`, `StackReqChannel`, `StackRespChannel`)
    that implement the `MctpListener` / `MctpReqChannel` / `MctpRespChannel` traits.
 
 This two-layer design hides both the **concrete MCTP stack implementation** (which lives
-inside the server process) and the **OS / IPC transport** from application code.
+inside the server process) and the **transport mechanism** from application code.
 Applications depend only on the high-level traits; swapping the transport or stack
 requires no application changes.
 
 ```text
 ┌─────────────────────┐
-│   Application       │  uses MctpListener / MctpReqChannel / MctpRespChannel traits
+│   Application       │  uses Stack facade, then channel traits
 └─────────┬───────────┘


@@ -32,10 +33,10 @@
           │ MctpClient trait

 ┌─────────────────────┐
-│  IpcMctpClient      │  encodes wire protocol, calls OS IPC (e.g. Pigweed channel_transact)
-│  (mctp-client crate)│
+│  MctpClient impl    │  encodes wire protocol, uses a transport backend
+│  (IPC/sockets/test) │
 └─────────┬───────────┘
-          │ IPC
+          │ Transport

 ┌─────────────────────┐
 │   MCTP Server       │  owns the concrete MCTP stack (mctp-lib, etc.)
@@ -75,11 +76,13 @@
 `Stack<C: MctpClient>` applies the **Strategy pattern**:
 
 - **Context** → `Stack<C>` holds the strategy and exposes the high-level API
-- **Strategy trait** → `MctpClient` defines the IPC operations (req, listener, recv, send, drop_handle)
-- **Concrete strategies** → `IpcMctpClient` (Pigweed IPC), test `DirectClient`, future Linux socket client
+- **Strategy trait** → `MctpClient` defines the service-transport operations (`req`, `listener`, `recv`, `send`, `drop_handle`) and local EID control (`get_eid`/`set_eid`).
+- **Concrete strategies** → transport-specific `MctpClient` implementations (for example IPC clients) and test `DirectClient`
 
-Applications code against `MctpListener` / `MctpReqChannel` / `MctpRespChannel` traits and never
-see the strategy. The concrete `MctpClient` implementation is injected via `Stack::new(client)`.
+Application code enters through the `Stack` facade and then operates on channels
+implementing `MctpListener` / `MctpReqChannel` / `MctpRespChannel`. During
+initialization, a concrete `MctpClient` is provided to `Stack::new(client)`;
+after that, protocol logic remains transport-agnostic.
 
 This gives two independent axes of variation:
 
@@ -91,13 +94,11 @@
 
 
 
-The `wire` module implements binary request/response encoding for IPC communication.
-It is used internally by `openprot-mctp-client` and the server; applications do not
-use it directly.
+The `wire` module implements binary request/response encoding.
+It is used internally by transport client implementations and server endpoints;
+applications do not use it directly.
 
 ## Dependencies
 
-- `zerocopy` — zero-copy serialization
-- `heapless` — `no_std` collections
-
-This crate is `no_std` compatible.
+This crate currently has no external Rust crate dependencies.
+It is no_std compatible and relies on core plus internal modules.
\ No newline at end of file
diff --git a/services/mctp/api/src/stack.rs b/services/mctp/api/src/stack.rs
index 3de5470..89adbe5 100644
--- a/services/mctp/api/src/stack.rs
+++ b/services/mctp/api/src/stack.rs
@@ -11,7 +11,7 @@
 //! ## Usage
 //!
 //! ```rust,ignore
-//! use openprot_mctp_client::IpcMctpClient;
+//! use openprot_mctp_client_ipc::IpcMctpClient;
 //! use openprot_mctp_api::stack::Stack;
 //! use openprot_mctp_api::{MctpListener, MctpReqChannel, MctpRespChannel};
 //!
diff --git a/services/mctp/echo/BUILD.bazel b/services/mctp/echo/BUILD.bazel
new file mode 100644
index 0000000..2a280d1
--- /dev/null
+++ b/services/mctp/echo/BUILD.bazel
@@ -0,0 +1,30 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")
+
+rust_library(
+    name = "mctp_echo",
+    srcs = ["src/lib.rs"],
+    crate_name = "openprot_mctp_echo",
+    edition = "2024",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/mctp/api:mctp_api",
+        "@pigweed//pw_log/rust:pw_log",
+    ],
+)
+
+rust_test(
+    name = "mctp_echo_host_test",
+    srcs = ["tests/echo_host.rs"],
+    crate_root = "tests/echo_host.rs",
+    edition = "2024",
+    deps = [
+        ":mctp_echo",
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/server:mctp_server_lib",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
diff --git a/services/mctp/echo/src/lib.rs b/services/mctp/echo/src/lib.rs
new file mode 100644
index 0000000..0f811ed
--- /dev/null
+++ b/services/mctp/echo/src/lib.rs
@@ -0,0 +1,122 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Reusable MCTP echo loop.
+//!
+//! This crate keeps the echo application policy separate from target wiring:
+//! callers create an `openprot_mctp_api::Stack`, then hand it to the helpers
+//! here to configure the local EID, open a listener, and echo received payloads
+//! back to the sender.
+//!
+//! The loop can optionally send periodic test messages to a peer endpoint
+//! to bootstrap communication when both endpoints are passive listeners.
+
+#![no_std]
+
+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;
+
+/// Default local EID used by the echo app.
+pub const ECHO_EID: u8 = 8;
+
+/// Prepare a stack for echoing by setting the local EID and opening a listener.
+pub fn prepare_listener<C: MctpClient>(stack: &Stack<C>) -> Result<StackListener<'_, C>, MctpError> {
+    stack.set_eid(ECHO_EID)?;
+    stack.listener(ECHO_MSG_TYPE, 0)
+}
+
+/// Prepare a stack with a custom EID and timeout for echoing.
+pub fn prepare_listener_with_eid_and_timeout<C: MctpClient>(
+    stack: &Stack<C>,
+    eid: u8,
+    timeout_millis: u32,
+) -> Result<StackListener<'_, C>, MctpError> {
+    stack.set_eid(eid)?;
+    stack.listener(ECHO_MSG_TYPE, timeout_millis)
+}
+
+/// 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>
+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 echo_once(listener, &mut buf) {
+            Ok(()) => {}
+            Err(e) => {
+                if e.code as u32 != 4 {
+                    // Suppress timeout (code 4) errors; only log other errors
+                    pw_log::error!("echo recv failed: code={}", e.code as u32);
+                }
+            }
+        }
+    }
+}
+
+/// Run the echo loop with periodic sends to a peer endpoint.
+///
+/// This variant sends a test message every `send_interval` receive attempts,
+/// allowing two passive listeners to bootstrap communication.
+pub fn run_with_peer<C: MctpClient, L: openprot_mctp_api::MctpListener>(
+    stack: &Stack<C>,
+    peer_eid: u8,
+    listener: &mut L,
+) -> ! {
+    run_with_peer_round_trip_limit(stack, peer_eid, listener, u32::MAX)
+}
+
+/// Run the echo loop with periodic sends to a peer endpoint, stopping 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;
+    let mut completed_round_trips: u32 = 0;
+    let send_interval = 10; // Send every 10 iterations
+
+    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 echo_once(listener, &mut buf) {
+            Ok(()) => {}
+            Err(e) => {
+                if e.code as u32 != 4 {
+                    // Suppress timeout (code 4) errors; only log other errors
+                    pw_log::error!("echo recv failed: code={}", e.code as u32);
+                }
+            }
+        }
+    }
+}
diff --git a/services/mctp/echo/tests/echo_host.rs b/services/mctp/echo/tests/echo_host.rs
new file mode 100644
index 0000000..b3b939e
--- /dev/null
+++ b/services/mctp/echo/tests/echo_host.rs
@@ -0,0 +1,150 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Host integration test for the reusable echo crate.
+//!
+//! Exercises the echo path via Stack + MctpClient against two in-memory
+//! server instances. This validates that openprot_mctp_echo helpers can be
+//! used in a host-only environment without IPC/I2C transport.
+
+use core::cell::RefCell;
+
+use mctp::{Eid, Tag};
+use mctp_lib::fragment::{Fragmenter, SendOutput};
+use mctp_lib::Sender;
+use openprot_mctp_api::{Handle, MctpClient, MctpError, MctpReqChannel, RecvMetadata, Stack};
+use openprot_mctp_echo::{echo_once, prepare_listener, ECHO_MSG_TYPE};
+use openprot_mctp_server::Server;
+
+struct BufferSender<'a> {
+    packets: &'a RefCell<Vec<Vec<u8>>>,
+}
+
+impl Sender for BufferSender<'_> {
+    fn send_vectored(
+        &mut self,
+        mut fragmenter: Fragmenter,
+        payload: &[&[u8]],
+    ) -> mctp::Result<Tag> {
+        loop {
+            let mut buf = [0u8; 255];
+            match fragmenter.fragment_vectored(payload, &mut buf) {
+                SendOutput::Packet(p) => self.packets.borrow_mut().push(p.to_vec()),
+                SendOutput::Complete { tag, .. } => return Ok(tag),
+                SendOutput::Error { err, .. } => return Err(err),
+            }
+        }
+    }
+
+    fn get_mtu(&self) -> usize {
+        255
+    }
+}
+
+fn transfer<S: Sender, const N: usize>(
+    packets: &RefCell<Vec<Vec<u8>>>,
+    dest: &mut Server<S, N>,
+) {
+    let pkts = packets.borrow();
+    for pkt in pkts.iter() {
+        dest.inbound(pkt).expect("inbound should accept packet");
+    }
+}
+
+struct DirectClient<'a, S: Sender, const N: usize> {
+    server: &'a RefCell<Server<S, N>>,
+}
+
+impl<'a, S: Sender, const N: usize> DirectClient<'a, S, N> {
+    fn new(server: &'a RefCell<Server<S, N>>) -> Self {
+        Self { server }
+    }
+}
+
+impl<S: 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(openprot_mctp_api::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);
+    }
+}
+
+#[test]
+fn echo_path_roundtrip_via_stack_and_echo_helper() {
+    let buf_a = RefCell::new(Vec::new());
+    let sender_a = BufferSender { packets: &buf_a };
+    let server_a: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(8), 0, sender_a));
+
+    let buf_b = RefCell::new(Vec::new());
+    let sender_b = BufferSender { packets: &buf_b };
+    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 stack_a = Stack::new(client_a);
+    let stack_b = Stack::new(client_b);
+
+    let mut listener_a = prepare_listener(&stack_a).expect("listener setup should succeed");
+
+    let mut req_b = stack_b.req(8, 0).expect("request channel should open");
+    let payload = b"echo from host test";
+    req_b.send(ECHO_MSG_TYPE, payload)
+        .expect("request send should succeed");
+
+    // Deliver request packets from B -> A, run one echo step, then A -> B.
+    transfer(&buf_b, &mut server_a.borrow_mut());
+    buf_b.borrow_mut().clear();
+
+    let mut echo_buf = [0u8; 255];
+    echo_once(&mut listener_a, &mut echo_buf).expect("echo_once should receive and reply");
+
+    transfer(&buf_a, &mut server_b.borrow_mut());
+    buf_a.borrow_mut().clear();
+
+    let mut resp_buf = [0u8; 255];
+    let (meta, response) = req_b.recv(&mut resp_buf).expect("response should arrive");
+    assert_eq!(meta.msg_type, ECHO_MSG_TYPE);
+    assert_eq!(meta.remote_eid, 8);
+    assert_eq!(response, payload);
+}
diff --git a/services/mctp/server/BUILD.bazel b/services/mctp/server/BUILD.bazel
new file mode 100644
index 0000000..e8ab5cf
--- /dev/null
+++ b/services/mctp/server/BUILD.bazel
@@ -0,0 +1,90 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")
+
+rust_library(
+    name = "mctp_server_lib",
+    srcs = [
+        "src/dispatch.rs",
+        "src/lib.rs",
+        "src/server.rs",
+    ],
+    crate_name = "openprot_mctp_server",
+    edition = "2024",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/mctp/api:mctp_api",
+        "@rust_crates//:heapless",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
+
+# Integration tests — each tests/ file is its own Bazel test target.
+# All share tests/common/mod.rs for fixtures (BufferSender, DirectClient, etc.).
+# No I2C transport dependency; the mock Sender replaces it entirely.
+
+rust_test(
+    name = "mctp_server_echo_test",
+    srcs = [
+        "tests/common/mod.rs",
+        "tests/echo.rs",
+    ],
+    crate_root = "tests/echo.rs",
+    edition = "2024",
+    deps = [
+        ":mctp_server_lib",
+        "//services/mctp/api:mctp_api",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
+
+rust_test(
+    name = "mctp_server_dispatch_test",
+    srcs = [
+        "tests/common/mod.rs",
+        "tests/dispatch.rs",
+    ],
+    crate_root = "tests/dispatch.rs",
+    edition = "2024",
+    deps = [
+        ":mctp_server_lib",
+        "//services/mctp/api:mctp_api",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
+
+rust_test(
+    name = "mctp_server_unit_test",
+    srcs = [
+        "tests/common/mod.rs",
+        "tests/server_unit.rs",
+    ],
+    crate_root = "tests/server_unit.rs",
+    edition = "2024",
+    deps = [
+        ":mctp_server_lib",
+        "//services/mctp/api:mctp_api",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
+
+rust_test(
+    name = "mctp_server_integration_test",
+    srcs = [
+        "tests/common/mod.rs",
+        "tests/integration.rs",
+    ],
+    crate_root = "tests/integration.rs",
+    edition = "2024",
+    deps = [
+        ":mctp_server_lib",
+        "//services/mctp/api:mctp_api",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
diff --git a/services/mctp/server/src/dispatch.rs b/services/mctp/server/src/dispatch.rs
new file mode 100644
index 0000000..15a11f6
--- /dev/null
+++ b/services/mctp/server/src/dispatch.rs
@@ -0,0 +1,128 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! MCTP IPC request dispatch.
+//!
+//! Decodes wire-protocol requests and dispatches them to the [`Server`].
+//! This is the server-side counterpart of `openprot-mctp-client`.
+
+use openprot_mctp_api::wire::{
+    self, flags, MctpOp, MctpRequestHeader,
+};
+use openprot_mctp_api::ResponseCode;
+
+use crate::{Sender, Server};
+
+/// Dispatch an IPC request to the MCTP server.
+///
+/// Decodes the request header, calls the appropriate `Server` method,
+/// and encodes the response into `response`. Returns the response length.
+///
+/// This is the MCTP equivalent of `dispatch_i2c_op` in the I2C server.
+pub fn dispatch_mctp_op<S: Sender, const N: usize>(
+    request: &[u8],
+    response: &mut [u8],
+    server: &mut Server<S, N>,
+    recv_buf: &mut [u8],
+) -> usize {
+    let header = match MctpRequestHeader::from_bytes(request) {
+        Some(h) => h,
+        None => return encode_error(response, ResponseCode::BadArgument),
+    };
+
+    let Some(op) = header.operation() else {
+        return encode_error(response, ResponseCode::BadArgument);
+    };
+
+    match op {
+        MctpOp::SetEid => match server.set_eid(header.eid) {
+            Ok(()) => encode_success(response),
+            Err(e) => encode_error(response, e.code),
+        },
+
+        MctpOp::GetEid => {
+            let eid = server.get_eid();
+            wire::encode_get_eid_response(response, eid)
+                .unwrap_or_else(|_| encode_error(response, ResponseCode::InternalError))
+        }
+
+        MctpOp::Listener => match server.listener(header.msg_type) {
+            Ok(handle) => wire::encode_handle_response(response, handle.0)
+                .unwrap_or_else(|_| encode_error(response, ResponseCode::InternalError)),
+            Err(e) => encode_error(response, e.code),
+        },
+
+        MctpOp::Req => match server.req(header.eid) {
+            Ok(handle) => wire::encode_handle_response(response, handle.0)
+                .unwrap_or_else(|_| encode_error(response, ResponseCode::InternalError)),
+            Err(e) => encode_error(response, e.code),
+        },
+
+        MctpOp::Recv => {
+            let handle = openprot_mctp_api::Handle(header.handle);
+
+            match server.try_recv(handle, recv_buf) {
+                Some(meta) => {
+                    let payload = &recv_buf[..meta.payload_size];
+                    wire::encode_recv_response(
+                        response,
+                        meta.msg_type,
+                        meta.msg_ic,
+                        meta.remote_eid,
+                        meta.msg_tag,
+                        payload,
+                    )
+                    .unwrap_or_else(|_| encode_error(response, ResponseCode::InternalError))
+                }
+                None => {
+                    // No message available yet.
+                    // In a real Pigweed server, we'd register a pending recv
+                    // and respond later. For now, return TimedOut.
+                    encode_error(response, ResponseCode::TimedOut)
+                }
+            }
+        }
+
+        MctpOp::Send => {
+            let handle = if header.flags & flags::HAS_HANDLE != 0 {
+                Some(openprot_mctp_api::Handle(header.handle))
+            } else {
+                None
+            };
+            let eid = if header.flags & flags::HAS_EID != 0 {
+                Some(header.eid)
+            } else {
+                None
+            };
+            let tag = if header.flags & flags::HAS_TAG != 0 {
+                Some(header.tag)
+            } else {
+                None
+            };
+            let ic = header.flags & flags::IC != 0;
+            let payload = wire::get_request_payload(request);
+
+            match server.send(handle, header.msg_type, eid, tag, ic, payload) {
+                Ok(tag_val) => wire::encode_send_response(response, tag_val)
+                    .unwrap_or_else(|_| encode_error(response, ResponseCode::InternalError)),
+                Err(e) => encode_error(response, e.code),
+            }
+        }
+
+        MctpOp::Unbind => {
+            let handle = openprot_mctp_api::Handle(header.handle);
+            match server.unbind(handle) {
+                Ok(()) => encode_success(response),
+                Err(e) => encode_error(response, e.code),
+            }
+        }
+    }
+}
+
+fn encode_error(response: &mut [u8], code: ResponseCode) -> usize {
+    wire::encode_error_response(response, code).unwrap_or(0)
+}
+
+fn encode_success(response: &mut [u8]) -> usize {
+    wire::encode_success_response(response).unwrap_or(0)
+}
diff --git a/services/mctp/server/src/lib.rs b/services/mctp/server/src/lib.rs
new file mode 100644
index 0000000..f61494c
--- /dev/null
+++ b/services/mctp/server/src/lib.rs
@@ -0,0 +1,35 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! # MCTP Server
+//!
+//! Platform-independent MCTP server implementation for OpenPRoT.
+//!
+//! This crate provides the core MCTP server logic that manages:
+//! - Listener and request handle allocation (via `mctp-lib` [`Router`](mctp_lib::Router))
+//! - Inbound message routing to registered listeners
+//! - Outbound message fragmentation and sending
+//! - Timeout management for pending receive calls
+//!
+//! ## Transport Bindings
+//!
+//! The server is generic over the `mctp-lib` [`Sender`](mctp_lib::Sender) trait
+//! for outbound transport. Transport-specific bindings (I2C, serial) implement
+//! this trait and feed inbound packets via [`Server::inbound`].
+//!
+//! ## Platform Integration
+//!
+//! The server does not depend on any OS primitives. The platform layer
+//! is responsible for:
+//! - Driving the event loop (notifications, IPC dispatch)
+//! - Providing a time source via [`Server::update`]
+//! - Wiring up transport bindings
+
+#![no_std]
+#![warn(missing_docs)]
+
+pub mod dispatch;
+mod server;
+
+pub use mctp_lib::Sender;
+pub use server::{RecvResult, Server, ServerConfig};
diff --git a/services/mctp/server/src/server.rs b/services/mctp/server/src/server.rs
new file mode 100644
index 0000000..ab4c6c0
--- /dev/null
+++ b/services/mctp/server/src/server.rs
@@ -0,0 +1,294 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Core MCTP server logic.
+//!
+//! This is a direct port of the Hubris `mctp-server/src/server.rs`.
+//! The `Router` integration, handle management, timeout logic, and message
+//! routing are preserved as-is. Only Hubris IPC primitives (`sys_reply`,
+//! `Leased`, `RecvMessage`) have been replaced with platform-independent
+//! equivalents.
+
+use heapless::LinearMap;
+use mctp::{Eid, MsgIC, MsgType, Tag, TagValue};
+use mctp_lib::{AppCookie, Router, Sender};
+use openprot_mctp_api::{Handle, MctpError, RecvMetadata, ResponseCode};
+
+/// Maximum payload size in bytes.
+// TODO: Use configuration from mctp-lib (mctp-estack)
+//       see https://github.com/OpenPRoT/mctp-lib/issues/4
+const MAX_PAYLOAD: usize = 1023;
+
+/// Configuration constants for the MCTP server.
+pub struct ServerConfig;
+
+impl ServerConfig {
+    /// Maximum number of concurrent requests the server can handle.
+    pub const MAX_REQUESTS: usize = 8;
+    /// Maximum number of listeners that can be registered concurrently.
+    pub const MAX_LISTENERS: usize = 8;
+    /// Maximum number of concurrent outstanding receive calls.
+    pub const MAX_OUTSTANDING: usize = 16;
+    /// Maximum payload size in bytes.
+    pub const MAX_PAYLOAD: usize = MAX_PAYLOAD;
+}
+
+/// A pending receive call waiting for a message or timeout.
+#[derive(Debug, Clone, Copy)]
+struct PendingRecv {
+    /// Deadline in milliseconds (0 = no timeout).
+    deadline: u64,
+}
+
+/// The platform-independent MCTP server.
+///
+/// This struct wraps the `mctp-lib` [`Router`] and manages outstanding
+/// receive calls with timeout tracking. It is a direct port of the
+/// Hubris `Server` struct with OS-specific IPC removed.
+///
+/// # Type Parameters
+///
+/// * `S` - The [`Sender`] implementation for outbound transport.
+/// * `OUTSTANDING` - Maximum number of concurrent pending receive calls.
+pub struct Server<S: Sender, const OUTSTANDING: usize> {
+    /// The underlying MCTP router (from mctp-lib).
+    pub stack: Router<S, { ServerConfig::MAX_LISTENERS }, { ServerConfig::MAX_REQUESTS }>,
+    /// Currently outstanding recv calls, keyed by handle value.
+    ///
+    /// Maps the handle to a deadline. The platform layer is responsible
+    /// for storing any additional per-recv state (e.g., reply channels).
+    outstanding: LinearMap<u32, PendingRecv, OUTSTANDING>,
+}
+
+impl<S: Sender, const OUTSTANDING: usize> Server<S, OUTSTANDING> {
+    /// Create a new MCTP server instance.
+    pub fn new(own_eid: Eid, now_millis: u64, outbound: S) -> Self {
+        let stack = Router::new(own_eid, now_millis, outbound);
+        Self {
+            stack,
+            outstanding: LinearMap::new(),
+        }
+    }
+
+    /// Allocate a request handle for sending messages to the given EID.
+    pub fn req(&mut self, eid: u8) -> Result<Handle, MctpError> {
+        match self.stack.req(Eid(eid)) {
+            Ok(cookie) => Ok(Handle(cookie.0 as u32)),
+            Err(e) => Err(mctp_error_to_server_error(e)),
+        }
+    }
+
+    /// Register a listener for incoming messages of the given type.
+    pub fn listener(&mut self, typ: u8) -> Result<Handle, MctpError> {
+        match self.stack.listener(MsgType(typ)) {
+            Ok(cookie) => Ok(Handle(cookie.0 as u32)),
+            Err(e) => Err(mctp_error_to_server_error(e)),
+        }
+    }
+
+    /// Get the currently configured EID.
+    pub fn get_eid(&self) -> u8 {
+        self.stack.get_eid().0
+    }
+
+    /// Set the EID for this endpoint.
+    pub fn set_eid(&mut self, eid: u8) -> Result<(), MctpError> {
+        self.stack
+            .set_eid(Eid(eid))
+            .map_err(mctp_error_to_server_error)
+    }
+
+    /// Check for an available message on the given handle.
+    ///
+    /// If a message is available, returns the metadata and copies the
+    /// payload into `buf`. Otherwise returns `None` and the caller
+    /// should register a pending recv via [`register_recv`](Self::register_recv).
+    pub fn try_recv(
+        &mut self,
+        handle: Handle,
+        buf: &mut [u8],
+    ) -> Option<RecvMetadata> {
+        let cookie = AppCookie(handle.0 as usize);
+        let msg = self.stack.recv(cookie)?;
+
+        let payload_len = msg.payload.len();
+        if payload_len <= buf.len() {
+            buf[..payload_len].copy_from_slice(msg.payload);
+        }
+
+        Some(RecvMetadata {
+            msg_type: msg.typ.0,
+            msg_ic: msg.ic.0,
+            msg_tag: msg.tag.tag().0,
+            remote_eid: msg.source.0,
+            payload_size: payload_len,
+        })
+    }
+
+    /// Register a pending receive call for the given handle.
+    ///
+    /// The platform layer should call this when `try_recv` returns `None`
+    /// and the client wants to block. Returns an error if the outstanding
+    /// table is full.
+    pub fn register_recv(
+        &mut self,
+        handle: Handle,
+        timeout_millis: u32,
+        now_millis: u64,
+    ) -> Result<(), MctpError> {
+        let deadline = if timeout_millis != 0 {
+            now_millis + timeout_millis as u64
+        } else {
+            0
+        };
+
+        // Don't overwrite existing entries
+        if self.outstanding.contains_key(&handle.0) {
+            return Ok(());
+        }
+
+        self.outstanding
+            .insert(handle.0, PendingRecv { deadline })
+            .map_err(|_| MctpError::from_code(ResponseCode::NoSpace))?;
+        Ok(())
+    }
+
+    /// Send a message.
+    ///
+    /// For requests, `handle` is `Some`. For responses, `handle` is `None`.
+    /// When responding to a request received by a listener, `eid` and `tag`
+    /// must be set. Returns the tag value used.
+    pub fn send(
+        &mut self,
+        handle: Option<Handle>,
+        typ: u8,
+        eid: Option<u8>,
+        tag: Option<u8>,
+        ic: bool,
+        buf: &[u8],
+    ) -> Result<u8, MctpError> {
+        if buf.len() > MAX_PAYLOAD {
+            return Err(MctpError::from_code(ResponseCode::NoSpace));
+        }
+
+        let tag = if handle.is_none() {
+            // Responses use unowned tags
+            tag.map(|x| Tag::Unowned(TagValue(x)))
+        } else {
+            // Requests use owned tags (or allocate a new one)
+            tag.map(|x| Tag::Owned(TagValue(x)))
+        };
+
+        // Responses need no handle, use 255 as dummy
+        let cookie = AppCookie(handle.unwrap_or(Handle(255)).0 as usize);
+
+        let result = self.stack.send(
+            eid.map(Eid),
+            MsgType(typ),
+            tag,
+            MsgIC(ic),
+            cookie,
+            buf,
+        );
+
+        match result {
+            Ok(tag) => Ok(tag.tag().0),
+            Err(e) => Err(mctp_error_to_server_error(e)),
+        }
+    }
+
+    /// Update the stack and check for fulfilled receive calls.
+    ///
+    /// Should be called on timer events. Returns the interval (ms) until
+    /// the next required update, and a list of handles that now have
+    /// messages available (the platform layer should deliver them).
+    pub fn update(
+        &mut self,
+        now_millis: u64,
+        recv_buf: &mut [u8],
+    ) -> (u32, heapless::Vec<(Handle, RecvResult), OUTSTANDING>) {
+        // Update the mctp-stack; get the next timeout interval
+        let stack_timeout = self
+            .stack
+            .update(now_millis)
+            .unwrap_or(60_000) as u32;
+
+        let mut ready: heapless::Vec<(Handle, RecvResult), OUTSTANDING> = heapless::Vec::new();
+
+        for (handle_val, pending) in self.outstanding.iter() {
+            let handle = Handle(*handle_val);
+            let cookie = AppCookie(*handle_val as usize);
+
+            // Check if a message arrived for this handle
+            if let Some(mctp_msg) = self.stack.recv(cookie) {
+                let payload_len = mctp_msg.payload.len();
+                if payload_len <= recv_buf.len() {
+                    recv_buf[..payload_len].copy_from_slice(mctp_msg.payload);
+                }
+                let metadata = RecvMetadata {
+                    msg_type: mctp_msg.typ.0,
+                    msg_ic: mctp_msg.ic.0,
+                    msg_tag: mctp_msg.tag.tag().0,
+                    remote_eid: mctp_msg.source.0,
+                    payload_size: payload_len,
+                };
+                let _ = ready.push((handle, RecvResult::Message(metadata)));
+                continue;
+            }
+
+            // Check for timeout
+            if pending.deadline != 0 && now_millis >= pending.deadline {
+                let _ = ready.push((handle, RecvResult::TimedOut));
+            }
+        }
+
+        // Remove fulfilled/timed-out entries
+        for (handle, _) in &ready {
+            self.outstanding.remove(&handle.0);
+        }
+
+        (stack_timeout, ready)
+    }
+
+    /// Unbind a handle previously allocated by `req` or `listener`.
+    pub fn unbind(&mut self, handle: Handle) -> Result<(), MctpError> {
+        let cookie = AppCookie(handle.0 as usize);
+        let _ = self.stack.unbind(cookie);
+        self.outstanding.remove(&handle.0);
+        Ok(())
+    }
+
+    /// Feed an inbound MCTP packet to the router.
+    ///
+    /// The platform layer calls this when data arrives from a transport
+    /// binding. The packet should be a raw MCTP packet without transport
+    /// headers (the transport binding strips those).
+    pub fn inbound(&mut self, pkt: &[u8]) -> Result<(), MctpError> {
+        self.stack
+            .inbound(pkt)
+            .map_err(mctp_error_to_server_error)
+    }
+}
+
+/// Result of a pending receive call.
+#[derive(Debug, Clone, Copy)]
+pub enum RecvResult {
+    /// A message was received.
+    Message(RecvMetadata),
+    /// The receive call timed out.
+    TimedOut,
+}
+
+/// Map mctp::Error to our MctpError.
+fn mctp_error_to_server_error(e: mctp::Error) -> MctpError {
+    use mctp::Error::*;
+    let code = match e {
+        InternalError => ResponseCode::InternalError,
+        NoSpace => ResponseCode::NoSpace,
+        AddrInUse => ResponseCode::AddrInUse,
+        TimedOut => ResponseCode::TimedOut,
+        BadArgument => ResponseCode::BadArgument,
+        _ => ResponseCode::InternalError,
+    };
+    MctpError::from_code(code)
+}
diff --git a/services/mctp/server/tests/common/mod.rs b/services/mctp/server/tests/common/mod.rs
new file mode 100644
index 0000000..5a747ba
--- /dev/null
+++ b/services/mctp/server/tests/common/mod.rs
@@ -0,0 +1,357 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Shared test fixtures for MCTP server integration tests.
+//!
+//! Provides:
+//! - [`BufferSender`] — captures outbound packets into a `Vec`
+//! - [`DroppingBufferSender`] — discards all outbound packets (for inbound-only tests)
+//! - [`transfer`] — drains one server's outbound buffer into another's inbound path
+//! - [`DirectClient`] — implements `MctpClient` directly against a `Server`
+//! - [`DirectListener`] — implements `MctpListener` via a `DirectClient`
+//! - [`DirectRespChannel`] — implements `MctpRespChannel` via a `DirectClient`
+//! - [`DirectReqChannel`] — implements `MctpReqChannel` via a `DirectClient`
+
+// Each integration test file is its own crate in Bazel. Not every file uses
+// every fixture, so suppress dead-code warnings for the shared module.
+#![allow(dead_code)]
+
+use std::cell::RefCell;
+
+use mctp::{Eid, Tag};
+use mctp_lib::fragment::{Fragmenter, SendOutput};
+use mctp_lib::Sender;
+use openprot_mctp_api::{
+    Handle, MctpClient, MctpError, MctpListener, MctpReqChannel, MctpRespChannel, RecvMetadata,
+    ResponseCode,
+};
+use openprot_mctp_server::Server;
+
+// ---------------------------------------------------------------------------
+// BufferSender
+// ---------------------------------------------------------------------------
+
+/// A mock [`Sender`] that captures every outbound MCTP packet into a shared buffer.
+///
+/// Use [`transfer`] to drain the buffer into another server's inbound path.
+pub struct BufferSender<'a> {
+    pub packets: &'a RefCell<Vec<Vec<u8>>>,
+}
+
+impl Sender for BufferSender<'_> {
+    fn send_vectored(
+        &mut self,
+        mut fragmenter: Fragmenter,
+        payload: &[&[u8]],
+    ) -> mctp::Result<Tag> {
+        loop {
+            let mut buf = [0u8; 255];
+            match fragmenter.fragment_vectored(payload, &mut buf) {
+                SendOutput::Packet(p) => {
+                    self.packets.borrow_mut().push(p.to_vec());
+                }
+                SendOutput::Complete { tag, .. } => return Ok(tag),
+                SendOutput::Error { err, .. } => return Err(err),
+            }
+        }
+    }
+
+    fn get_mtu(&self) -> usize {
+        255
+    }
+}
+
+// ---------------------------------------------------------------------------
+// SmallMtuBufferSender
+// ---------------------------------------------------------------------------
+
+/// A [`BufferSender`] variant with a configurable (small) MTU for fragment tests.
+pub struct SmallMtuBufferSender<'a> {
+    pub packets: &'a RefCell<Vec<Vec<u8>>>,
+    pub mtu: usize,
+}
+
+impl Sender for SmallMtuBufferSender<'_> {
+    fn send_vectored(
+        &mut self,
+        mut fragmenter: Fragmenter,
+        payload: &[&[u8]],
+    ) -> mctp::Result<Tag> {
+        loop {
+            let mut buf = [0u8; 255];
+            match fragmenter.fragment_vectored(payload, &mut buf) {
+                SendOutput::Packet(p) => {
+                    self.packets.borrow_mut().push(p.to_vec());
+                }
+                SendOutput::Complete { tag, .. } => return Ok(tag),
+                SendOutput::Error { err, .. } => return Err(err),
+            }
+        }
+    }
+
+    fn get_mtu(&self) -> usize {
+        self.mtu
+    }
+}
+
+// ---------------------------------------------------------------------------
+// DroppingBufferSender
+// ---------------------------------------------------------------------------
+
+/// A mock [`Sender`] that silently discards all outbound packets.
+///
+/// Use when a test only cares about the inbound path and does not need to
+/// inspect what the server would have sent out.
+pub struct DroppingBufferSender;
+
+impl Sender for DroppingBufferSender {
+    fn send_vectored(
+        &mut self,
+        mut fragmenter: Fragmenter,
+        payload: &[&[u8]],
+    ) -> mctp::Result<Tag> {
+        loop {
+            let mut buf = [0u8; 255];
+            match fragmenter.fragment_vectored(payload, &mut buf) {
+                SendOutput::Packet(_) => {}
+                SendOutput::Complete { tag, .. } => return Ok(tag),
+                SendOutput::Error { err, .. } => return Err(err),
+            }
+        }
+    }
+
+    fn get_mtu(&self) -> usize {
+        255
+    }
+}
+
+// ---------------------------------------------------------------------------
+// transfer
+// ---------------------------------------------------------------------------
+
+/// Drain `packets` into `dest` as inbound MCTP packets.
+///
+/// Call this after the sender server has processed a send, to deliver the
+/// packets to the receiver server. The buffer is **not** cleared; call
+/// `packets.borrow_mut().clear()` manually between rounds if needed.
+pub fn transfer<S: Sender, const N: usize>(
+    packets: &RefCell<Vec<Vec<u8>>>,
+    dest: &mut Server<S, N>,
+) {
+    let pkts = packets.borrow();
+    for pkt in pkts.iter() {
+        dest.inbound(pkt).unwrap();
+    }
+}
+
+// ---------------------------------------------------------------------------
+// DirectClient
+// ---------------------------------------------------------------------------
+
+/// Implements [`MctpClient`] by calling [`Server`] methods directly.
+///
+/// `DirectClient` allows application code written against `MctpClient` to be
+/// exercised in pure `std` tests without platform transport dependencies.
+pub struct DirectClient<'a, S: Sender, const N: usize> {
+    pub server: &'a RefCell<Server<S, N>>,
+}
+
+impl<'a, S: Sender, const N: usize> DirectClient<'a, S, N> {
+    pub fn new(server: &'a RefCell<Server<S, N>>) -> Self {
+        Self { server }
+    }
+}
+
+impl<S: 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);
+    }
+}
+
+// ---------------------------------------------------------------------------
+// DirectRespChannel
+// ---------------------------------------------------------------------------
+
+/// Implements [`MctpRespChannel`] — sends a reply back through a [`DirectClient`].
+///
+/// Captured metadata from the received request carries the EID and tag needed
+/// to route the response correctly.
+pub struct DirectRespChannel<'a, S: Sender, const N: usize> {
+    client: &'a DirectClient<'a, S, N>,
+    msg_type: u8,
+    remote_eid: u8,
+    tag: u8,
+}
+
+impl<S: Sender, const N: usize> MctpRespChannel for DirectRespChannel<'_, S, N> {
+    fn send(&mut self, buf: &[u8]) -> Result<(), MctpError> {
+        self.client
+            .send(
+                None,
+                self.msg_type,
+                Some(self.remote_eid),
+                Some(self.tag),
+                false,
+                buf,
+            )
+            .map(|_| ())
+    }
+
+    fn remote_eid(&self) -> u8 {
+        self.remote_eid
+    }
+}
+
+// ---------------------------------------------------------------------------
+// DirectListener
+// ---------------------------------------------------------------------------
+
+/// Implements [`MctpListener`] by polling a listener handle via [`DirectClient`].
+///
+/// `recv` will return `TimedOut` if no message is available yet. In tests,
+/// call this after feeding inbound packets with [`transfer`].
+pub struct DirectListener<'a, S: Sender, const N: usize> {
+    pub client: &'a DirectClient<'a, S, N>,
+    pub handle: Handle,
+}
+
+impl<'a, S: Sender, const N: usize> DirectListener<'a, S, N> {
+    pub fn new(client: &'a DirectClient<'a, S, N>, handle: Handle) -> Self {
+        Self { client, handle }
+    }
+}
+
+impl<'a, S: Sender, const N: usize> MctpListener for DirectListener<'a, S, N> {
+    type RespChannel<'r> = DirectRespChannel<'a, S, N> where Self: 'r;
+
+    fn recv<'f>(
+        &mut self,
+        buf: &'f mut [u8],
+    ) -> Result<(RecvMetadata, &'f mut [u8], Self::RespChannel<'_>), MctpError> {
+        let meta = self
+            .client
+            .server
+            .borrow_mut()
+            .try_recv(self.handle, buf)
+            .ok_or(MctpError::from_code(ResponseCode::TimedOut))?;
+
+        let len = meta.payload_size;
+        let resp = DirectRespChannel {
+            client: self.client,
+            msg_type: meta.msg_type,
+            remote_eid: meta.remote_eid,
+            tag: meta.msg_tag,
+        };
+        Ok((meta, &mut buf[..len], resp))
+    }
+}
+
+// ---------------------------------------------------------------------------
+// DirectReqChannel
+// ---------------------------------------------------------------------------
+
+/// Implements [`MctpReqChannel`] — send a request and receive the response
+/// via [`DirectClient`].
+pub struct DirectReqChannel<'a, S: Sender, const N: usize> {
+    client: &'a DirectClient<'a, S, N>,
+    handle: Handle,
+    msg_type: u8,
+    remote_eid: u8,
+}
+
+impl<'a, S: Sender, const N: usize> DirectReqChannel<'a, S, N> {
+    pub fn new(
+        client: &'a DirectClient<'a, S, N>,
+        handle: Handle,
+        msg_type: u8,
+        remote_eid: u8,
+    ) -> Self {
+        Self {
+            client,
+            handle,
+            msg_type,
+            remote_eid,
+        }
+    }
+}
+
+impl<S: Sender, const N: usize> MctpReqChannel for DirectReqChannel<'_, S, N> {
+    fn send(&mut self, msg_type: u8, buf: &[u8]) -> Result<(), MctpError> {
+        self.msg_type = msg_type;
+        self.client
+            .send(Some(self.handle), msg_type, None, None, false, buf)
+            .map(|_| ())
+    }
+
+    fn recv<'f>(
+        &mut self,
+        buf: &'f mut [u8],
+    ) -> Result<(RecvMetadata, &'f mut [u8]), MctpError> {
+        let meta = self
+            .client
+            .server
+            .borrow_mut()
+            .try_recv(self.handle, buf)
+            .ok_or(MctpError::from_code(ResponseCode::TimedOut))?;
+        let len = meta.payload_size;
+        Ok((meta, &mut buf[..len]))
+    }
+
+    fn remote_eid(&self) -> u8 {
+        self.remote_eid
+    }
+}
+
+// ---------------------------------------------------------------------------
+// make_server helper
+// ---------------------------------------------------------------------------
+
+/// Construct a `Server` + its outbound packet buffer, for two-endpoint tests.
+pub fn make_server(
+    eid: u8,
+    packets: &RefCell<Vec<Vec<u8>>>,
+) -> Server<BufferSender<'_>, 16> {
+    Server::new(Eid(eid), 0, BufferSender { packets })
+}
diff --git a/services/mctp/server/tests/dispatch.rs b/services/mctp/server/tests/dispatch.rs
new file mode 100644
index 0000000..3aab24d
--- /dev/null
+++ b/services/mctp/server/tests/dispatch.rs
@@ -0,0 +1,263 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Wire-protocol dispatch integration test.
+//!
+//! Exercises the full request path: encode request → `dispatch_mctp_op` → decode
+//! response. This verifies that the wire protocol + dispatch layer + Server
+//! work together correctly for a client/server integration boundary.
+
+mod common;
+
+use std::cell::RefCell;
+
+use mctp::Eid;
+use openprot_mctp_api::wire;
+use openprot_mctp_server::{dispatch::dispatch_mctp_op, Server};
+
+use common::{transfer, BufferSender};
+
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+/// Test SetEid + GetEid via dispatch.
+#[test]
+fn dispatch_set_get_eid() {
+    let buf = RefCell::new(Vec::new());
+    let sender = BufferSender { packets: &buf };
+    let mut server: Server<_, 16> = Server::new(Eid(0), 0, sender);
+
+    let mut req = [0u8; 64];
+    let mut resp = [0u8; 64];
+    let mut recv_buf = [0u8; 255];
+
+    // SetEid(42)
+    let req_len = wire::encode_set_eid(&mut req, 42).unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server, &mut recv_buf);
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(header.is_success());
+
+    // GetEid → 42
+    let req_len = wire::encode_get_eid(&mut req).unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server, &mut recv_buf);
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(header.is_success());
+    assert_eq!(header.eid, 42);
+}
+
+/// Test Listener + Req + Send + Recv via dispatch (full echo round-trip).
+#[test]
+fn dispatch_echo_roundtrip() {
+    // Server A (echo responder, EID 8)
+    let buf_a = RefCell::new(Vec::new());
+    let sender_a = BufferSender { packets: &buf_a };
+    let mut server_a: Server<_, 16> = Server::new(Eid(8), 0, sender_a);
+
+    // Server B (requester, EID 42)
+    let buf_b = RefCell::new(Vec::new());
+    let sender_b = BufferSender { packets: &buf_b };
+    let mut server_b: Server<_, 16> = Server::new(Eid(42), 0, sender_b);
+
+    let mut req = [0u8; 128];
+    let mut resp = [0u8; 128];
+    let mut recv_buf = [0u8; 255];
+
+    // Register listener on A for MsgType(1)
+    let req_len = wire::encode_listener(&mut req, 1).unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server_a, &mut recv_buf);
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(header.is_success());
+    let listener_handle = header.handle;
+
+    // Register req on B targeting EID 8
+    let req_len = wire::encode_req(&mut req, 8).unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server_b, &mut recv_buf);
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(header.is_success());
+    let req_handle = header.handle;
+
+    // B sends a message via dispatch
+    let payload = b"dispatch echo!";
+    let req_len = wire::encode_send(
+        &mut req,
+        Some(req_handle),
+        1,
+        None,
+        None,
+        false,
+        payload,
+    )
+    .unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server_b, &mut recv_buf);
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(header.is_success());
+
+    // Transfer B → A
+    transfer(&buf_b, &mut server_a);
+
+    // A receives via dispatch
+    let req_len = wire::encode_recv(&mut req, listener_handle, 0).unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server_a, &mut recv_buf);
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(header.is_success());
+    assert_eq!(header.msg_type, 1);
+    assert_eq!(header.eid, 42); // remote EID
+    let recv_payload = wire::get_response_payload(&resp[..resp_len], &header).unwrap();
+    assert_eq!(recv_payload, payload);
+
+    // A echoes back via dispatch (response: no handle, set eid + tag)
+    let req_len = wire::encode_send(
+        &mut req,
+        None,
+        header.msg_type,
+        Some(header.eid),
+        Some(header.tag),
+        false,
+        recv_payload,
+    )
+    .unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server_a, &mut recv_buf);
+    let send_header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(send_header.is_success());
+
+    // Transfer A → B
+    transfer(&buf_a, &mut server_b);
+
+    // B receives the echo via dispatch
+    let req_len = wire::encode_recv(&mut req, req_handle, 0).unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server_b, &mut recv_buf);
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(header.is_success());
+    assert_eq!(header.msg_type, 1);
+    assert_eq!(header.eid, 8); // from server A
+    let echo_payload = wire::get_response_payload(&resp[..resp_len], &header).unwrap();
+    assert_eq!(echo_payload, payload);
+}
+
+// ---------------------------------------------------------------------------
+// Malformed request → BadArgument
+// ---------------------------------------------------------------------------
+
+/// A request buffer shorter than the header size must return `BadArgument`.
+#[test]
+fn dispatch_malformed_request_returns_bad_argument() {
+    use openprot_mctp_api::ResponseCode;
+
+    let buf = RefCell::new(Vec::new());
+    let sender = BufferSender { packets: &buf };
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+
+    let mut resp = [0u8; 64];
+    let mut recv_buf = [0u8; 255];
+
+    // Two bytes — shorter than MctpRequestHeader::SIZE (12)
+    let bad_request = [0u8; 2];
+    let resp_len = dispatch_mctp_op(&bad_request, &mut resp, &mut server, &mut recv_buf);
+
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(!header.is_success());
+    assert_eq!(header.response_code(), ResponseCode::BadArgument);
+}
+
+/// An opcode byte that is not a known `MctpOp` must return `BadArgument`.
+#[test]
+fn dispatch_unknown_opcode_returns_bad_argument() {
+    use openprot_mctp_api::ResponseCode;
+
+    let buf = RefCell::new(Vec::new());
+    let sender = BufferSender { packets: &buf };
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+
+    let mut resp = [0u8; 64];
+    let mut recv_buf = [0u8; 255];
+
+    // 12-byte header with opcode 0xFF (unrecognised)
+    let mut bad_request = [0u8; 12];
+    bad_request[0] = 0xFF;
+    let resp_len = dispatch_mctp_op(&bad_request, &mut resp, &mut server, &mut recv_buf);
+
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(!header.is_success());
+    assert_eq!(header.response_code(), ResponseCode::BadArgument);
+}
+
+// ---------------------------------------------------------------------------
+// MctpOp::Recv when no message is ready → TimedOut
+// ---------------------------------------------------------------------------
+
+/// `Recv` dispatched when no message has arrived must return `TimedOut`.
+#[test]
+fn dispatch_recv_no_message_returns_timed_out() {
+    use openprot_mctp_api::ResponseCode;
+
+    let buf = RefCell::new(Vec::new());
+    let sender = BufferSender { packets: &buf };
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+
+    let mut req = [0u8; 64];
+    let mut resp = [0u8; 64];
+    let mut recv_buf = [0u8; 255];
+
+    // Register a listener
+    let req_len = wire::encode_listener(&mut req, 1).unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server, &mut recv_buf);
+    let h = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(h.is_success());
+    let listener_handle = h.handle;
+
+    // Attempt Recv immediately — no inbound packet
+    let req_len = wire::encode_recv(&mut req, listener_handle, 0).unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server, &mut recv_buf);
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(!header.is_success());
+    assert_eq!(header.response_code(), ResponseCode::TimedOut);
+}
+
+// ---------------------------------------------------------------------------
+// MctpOp::Unbind
+// ---------------------------------------------------------------------------
+
+/// `Unbind` on a valid handle returns success.
+#[test]
+fn dispatch_unbind_valid_handle() {
+    let buf = RefCell::new(Vec::new());
+    let sender = BufferSender { packets: &buf };
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+
+    let mut req = [0u8; 64];
+    let mut resp = [0u8; 64];
+    let mut recv_buf = [0u8; 255];
+
+    // Allocate a listener handle
+    let req_len = wire::encode_listener(&mut req, 1).unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server, &mut recv_buf);
+    let h = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(h.is_success());
+    let listener_handle = h.handle;
+
+    // Unbind it
+    let req_len = wire::encode_unbind(&mut req, listener_handle).unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server, &mut recv_buf);
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(header.is_success());
+}
+
+/// `Unbind` on a handle that was never allocated still returns success.
+/// (The server's `unbind` is idempotent — it ignores unknown handles.)
+#[test]
+fn dispatch_unbind_unknown_handle_is_idempotent() {
+    let buf = RefCell::new(Vec::new());
+    let sender = BufferSender { packets: &buf };
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+
+    let mut req = [0u8; 64];
+    let mut resp = [0u8; 64];
+    let mut recv_buf = [0u8; 255];
+
+    let req_len = wire::encode_unbind(&mut req, 0xDEAD_BEEF).unwrap();
+    let resp_len = dispatch_mctp_op(&req[..req_len], &mut resp, &mut server, &mut recv_buf);
+    let header = wire::decode_response_header(&resp[..resp_len]).unwrap();
+    assert!(header.is_success());
+}
diff --git a/services/mctp/server/tests/echo.rs b/services/mctp/server/tests/echo.rs
new file mode 100644
index 0000000..8c13f71
--- /dev/null
+++ b/services/mctp/server/tests/echo.rs
@@ -0,0 +1,165 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! MCTP echo integration test.
+//!
+//! This test exercises the full MCTP server stack with a mock transport,
+//! replicating a production echo task behavior:
+//!
+//! 1. Server A (echo responder): listens for MCTP type-1 messages, echoes payload back
+//! 2. Server B (requester): sends a request to A and verifies the echo response
+//!
+//! The test uses a **client/server partition**: the echo application logic
+//! interacts exclusively through the `MctpClient` trait (client side), while
+//! the `Server` + transport plumbing is the server side.
+
+mod common;
+
+use std::cell::RefCell;
+
+use mctp::Eid;
+use openprot_mctp_api::{Handle, MctpClient};
+use openprot_mctp_server::Server;
+
+use common::{transfer, BufferSender, DirectClient};
+
+
+// ---------------------------------------------------------------------------
+// Echo application logic (client side)
+// ---------------------------------------------------------------------------
+
+/// Echo one message: receive on the listener, send the payload back.
+///
+/// This is the same shape as production echo logic:
+/// ```ignore
+/// let (_, _, msg, mut resp) = listener.recv(&mut recv_buf).unwrap_lite();
+/// resp.send(msg).unwrap();
+/// ```
+/// but expressed through the `MctpClient` trait.
+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
+// ---------------------------------------------------------------------------
+
+/// MCTP echo: send a request, receive it on a listener, echo back, verify.
+///
+/// This replicates a production echo task behavior:
+/// - EID 8 listens for MsgType(1) and echoes the payload
+/// - EID 42 sends a request and checks the response matches
+#[test]
+fn mctp_echo_roundtrip() {
+    // -- Server side: set up two MCTP server instances with mock transport --
+    let buf_a = RefCell::new(Vec::new());
+    let sender_a = BufferSender { packets: &buf_a };
+    let server_a: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, sender_a));
+
+    let buf_b = RefCell::new(Vec::new());
+    let sender_b = BufferSender { packets: &buf_b };
+    let server_b: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(42), 0, sender_b));
+
+    // -- Client side: wrap servers in DirectClient to use MctpClient trait --
+    let client_a = DirectClient::new(&server_a);
+    let client_b = DirectClient::new(&server_b);
+
+    // Client A: register listener for MsgType(1) — same as echo task
+    let listener_handle = client_a.listener(1).unwrap();
+
+    // Client B: get a request handle targeting EID 8
+    let req_handle = client_b.req(8).unwrap();
+
+    // Client B: send a request with MsgType(1)
+    let payload = b"Hello MCTP echo!";
+    let _tag = client_b
+        .send(Some(req_handle), 1, None, None, false, payload)
+        .unwrap();
+
+    // Server side: transfer B's outbound packets to A
+    transfer(&buf_b, &mut server_a.borrow_mut());
+
+    // Client A: echo the message back (uses MctpClient trait)
+    echo_once(&client_a, listener_handle);
+
+    // Server side: transfer A's outbound packets to B
+    transfer(&buf_a, &mut server_b.borrow_mut());
+
+    // Client B: receive the echo response (uses MctpClient trait)
+    let mut resp_buf = [0u8; 255];
+    let resp_meta = client_b
+        .recv(req_handle, 0, &mut resp_buf)
+        .expect("Client B should have received the echo response");
+
+    let response = &resp_buf[..resp_meta.payload_size];
+    assert_eq!(response, payload, "Echo response should match original payload");
+    assert_eq!(resp_meta.msg_type, 1);
+    assert_eq!(resp_meta.remote_eid, 8);
+
+    // Clean up
+    client_a.drop_handle(listener_handle);
+    client_b.drop_handle(req_handle);
+}
+
+/// Test that multiple messages can be echoed in sequence.
+#[test]
+fn mctp_echo_multiple() {
+    let buf_a = RefCell::new(Vec::new());
+    let sender_a = BufferSender { packets: &buf_a };
+    let server_a: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, sender_a));
+
+    let buf_b = RefCell::new(Vec::new());
+    let sender_b = BufferSender { packets: &buf_b };
+    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];
+
+        // Client B: send request
+        client_b
+            .send(Some(req), 1, None, None, false, &msg)
+            .unwrap();
+        transfer(&buf_b, &mut server_a.borrow_mut());
+        buf_b.borrow_mut().clear();
+
+        // Client A: echo (uses MctpClient trait)
+        echo_once(&client_a, listener);
+        transfer(&buf_a, &mut server_b.borrow_mut());
+        buf_a.borrow_mut().clear();
+
+        // Client B: verify echo response
+        let mut resp_buf = [0u8; 255];
+        let resp = client_b
+            .recv(req, 0, &mut resp_buf)
+            .expect("echo response should be available");
+        assert_eq!(&resp_buf[..resp.payload_size], &msg);
+    }
+
+    client_a.drop_handle(listener);
+    client_b.drop_handle(req);
+}
diff --git a/services/mctp/server/tests/integration.rs b/services/mctp/server/tests/integration.rs
new file mode 100644
index 0000000..cfe1a54
--- /dev/null
+++ b/services/mctp/server/tests/integration.rs
@@ -0,0 +1,548 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! MCTP integration tests — multi-fragment, multi-listener, MctpListener trait.
+//!
+//! Tests in this file exercise:
+//! - Multi-fragment reassembly (small MTU sender)
+//! - Multiple concurrent listeners with no cross-talk
+//! - `MctpListener` + `MctpRespChannel` trait path (mirrors real echo application)
+//! - `MctpReqChannel` trait path
+//! - `drop_handle` mid-flight clears the outstanding entry
+//!
+//! No platform transport binding is used anywhere in this file.
+
+mod common;
+
+use std::cell::RefCell;
+
+use mctp::Eid;
+use openprot_mctp_api::stack::Stack;
+use openprot_mctp_api::{MctpClient, MctpListener, MctpReqChannel, MctpRespChannel};
+use openprot_mctp_server::Server;
+use openprot_mctp_server::ServerConfig;
+
+use common::{transfer, BufferSender, DirectClient, DirectListener, DirectReqChannel, SmallMtuBufferSender};
+
+// ---------------------------------------------------------------------------
+// Multi-fragment roundtrip
+// ---------------------------------------------------------------------------
+
+/// Send a 200-byte payload through a server whose sender MTU is 64 bytes.
+///
+/// The fragmenter must split it into multiple packets. The receiving server
+/// must reassemble them before delivering to the listener.
+#[test]
+fn multi_fragment_roundtrip() {
+    let buf_a = RefCell::new(Vec::new());
+    let buf_b = RefCell::new(Vec::new());
+
+    // Server A: small MTU sender (forces fragmentation)
+    let sender_a = SmallMtuBufferSender {
+        packets: &buf_a,
+        mtu: 64,
+    };
+    let server_a: RefCell<Server<_, 16>> = RefCell::new(Server::new(Eid(8), 0, sender_a));
+
+    // Server B: normal MTU (sends the request)
+    let server_b: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(42), 0, BufferSender { packets: &buf_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();
+
+    // 200-byte payload — exceeds a single 64-byte MTU fragment
+    let payload: Vec<u8> = (0u8..200).collect();
+    client_b
+        .send(Some(req), 1, None, None, false, &payload)
+        .unwrap();
+
+    // Transfer B → A (may be multiple packets)
+    transfer(&buf_b, &mut server_a.borrow_mut());
+
+    // A should have reassembled and delivered to the listener
+    let mut recv_buf = [0u8; 512];
+    let meta = client_a
+        .recv(listener, 0, &mut recv_buf)
+        .expect("A should receive the reassembled message");
+
+    assert_eq!(meta.payload_size, payload.len());
+    assert_eq!(&recv_buf[..meta.payload_size], payload.as_slice());
+    assert_eq!(meta.remote_eid, 42);
+}
+
+// ---------------------------------------------------------------------------
+// Multiple concurrent listeners — no cross-talk
+// ---------------------------------------------------------------------------
+
+/// Two listeners on the same server for different msg_types each receive only
+/// their own messages.
+#[test]
+fn multiple_listeners_no_crosstalk() {
+    let buf_a = RefCell::new(Vec::new());
+    let buf_b = RefCell::new(Vec::new());
+
+    let server_a: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, BufferSender { packets: &buf_a }));
+    let server_b: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(42), 0, BufferSender { packets: &buf_b }));
+
+    let client_a = DirectClient::new(&server_a);
+    let client_b = DirectClient::new(&server_b);
+
+    // Two listeners: type 1 and type 2
+    let listener_type1 = client_a.listener(1).unwrap();
+    let listener_type2 = client_a.listener(2).unwrap();
+
+    // B sends type 2
+    let req = client_b.req(8).unwrap();
+    let msg_type2 = b"for type 2";
+    client_b
+        .send(Some(req), 2, None, None, false, msg_type2)
+        .unwrap();
+    transfer(&buf_b, &mut server_a.borrow_mut());
+
+    // Type 1 listener should see nothing
+    let mut buf = [0u8; 255];
+    assert!(
+        client_a.recv(listener_type1, 0, &mut buf).is_err(),
+        "type-1 listener should not receive a type-2 message"
+    );
+
+    // Type 2 listener should see the message
+    let meta = client_a
+        .recv(listener_type2, 0, &mut buf)
+        .expect("type-2 listener should receive the message");
+    assert_eq!(&buf[..meta.payload_size], msg_type2);
+}
+
+// ---------------------------------------------------------------------------
+// MctpListener + MctpRespChannel trait path (real echo application shape)
+// ---------------------------------------------------------------------------
+
+/// Exercises the `MctpListener` / `MctpRespChannel` traits — the same interface
+/// used by the real echo application — with `BufferSender` as the transport.
+///
+/// This is the key test that lets the echo application logic run without
+/// platform-specific bindings:
+/// ```
+/// fn echo_app(listener: &mut impl MctpListener) {
+///     let (meta, msg, mut resp) = listener.recv(&mut buf).unwrap();
+///     resp.send(msg).unwrap();
+/// }
+/// ```
+#[test]
+fn echo_via_mctplistener_trait() {
+    let buf_a = RefCell::new(Vec::new());
+    let buf_b = RefCell::new(Vec::new());
+
+    let server_a: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, BufferSender { packets: &buf_a }));
+    let server_b: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(42), 0, BufferSender { packets: &buf_b }));
+
+    let client_a = DirectClient::new(&server_a);
+    let client_b = DirectClient::new(&server_b);
+
+    let listener_handle = client_a.listener(1).unwrap();
+    let req_handle = client_b.req(8).unwrap();
+
+    // B sends a request
+    let request = b"echo via trait";
+    client_b
+        .send(Some(req_handle), 1, None, None, false, request)
+        .unwrap();
+    transfer(&buf_b, &mut server_a.borrow_mut());
+
+    // A echoes back using the MctpListener + MctpRespChannel trait path
+    // (same shape as the real echo application)
+    let mut listener = DirectListener::new(&client_a, listener_handle);
+    let mut recv_buf = [0u8; 255];
+    let (meta, payload, mut resp) = listener
+        .recv(&mut recv_buf)
+        .expect("listener should have a message ready");
+
+    assert_eq!(payload, request);
+    assert_eq!(meta.remote_eid, 42);
+
+    resp.send(payload).expect("response send should succeed");
+
+    // Transfer A → B and verify
+    transfer(&buf_a, &mut server_b.borrow_mut());
+
+    let mut resp_buf = [0u8; 255];
+    let resp_meta = client_b
+        .recv(req_handle, 0, &mut resp_buf)
+        .expect("B should receive the echo");
+
+    assert_eq!(&resp_buf[..resp_meta.payload_size], request);
+    assert_eq!(resp_meta.remote_eid, 8);
+    assert_eq!(resp_meta.msg_type, 1);
+}
+
+// ---------------------------------------------------------------------------
+// MctpReqChannel trait path
+// ---------------------------------------------------------------------------
+
+/// Exercises `MctpReqChannel::send` + `MctpReqChannel::recv` trait methods.
+#[test]
+fn req_channel_send_recv() {
+    let buf_a = RefCell::new(Vec::new());
+    let buf_b = RefCell::new(Vec::new());
+
+    let server_a: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, BufferSender { packets: &buf_a }));
+    let server_b: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(42), 0, BufferSender { packets: &buf_b }));
+
+    let client_a = DirectClient::new(&server_a);
+    let client_b = DirectClient::new(&server_b);
+
+    let listener_handle = client_a.listener(1).unwrap();
+    let req_handle = client_b.req(8).unwrap();
+
+    // B sends via MctpReqChannel
+    let mut req_channel = DirectReqChannel::new(&client_b, req_handle, 1, 8);
+    req_channel
+        .send(1, b"req channel test")
+        .expect("req channel send should succeed");
+    assert_eq!(req_channel.remote_eid(), 8);
+
+    transfer(&buf_b, &mut server_a.borrow_mut());
+
+    // A echoes manually (through MctpClient)
+    let mut echo_buf = [0u8; 255];
+    let meta = client_a
+        .recv(listener_handle, 0, &mut echo_buf)
+        .unwrap();
+    client_a
+        .send(
+            None,
+            meta.msg_type,
+            Some(meta.remote_eid),
+            Some(meta.msg_tag),
+            false,
+            &echo_buf[..meta.payload_size],
+        )
+        .unwrap();
+
+    transfer(&buf_a, &mut server_b.borrow_mut());
+
+    // B receives via MctpReqChannel
+    let mut resp_buf = [0u8; 255];
+    let (resp_meta, resp_payload) = req_channel
+        .recv(&mut resp_buf)
+        .expect("req channel recv should succeed");
+
+    assert_eq!(resp_payload, b"req channel test");
+    assert_eq!(resp_meta.remote_eid, 8);
+}
+
+// ---------------------------------------------------------------------------
+// drop_handle mid-flight
+// ---------------------------------------------------------------------------
+
+/// Dropping a listener handle while a recv is outstanding clears the entry.
+/// After `unbind`, `try_recv` no longer panics and the handle is gone.
+#[test]
+fn drop_handle_mid_flight_clears_entry() {
+    let sender = common::DroppingBufferSender;
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+
+    let handle = server.listener(1).unwrap();
+
+    // Register a pending recv
+    server
+        .register_recv(handle, 1000, 0)
+        .expect("register_recv should succeed");
+
+    // Drop the handle before any message or timeout
+    server.unbind(handle).expect("unbind should succeed");
+
+    // update should return nothing for that handle
+    let mut recv_buf = [0u8; 255];
+    let (_, ready) = server.update(500, &mut recv_buf);
+    assert!(
+        ready.iter().all(|(h, _)| *h != handle),
+        "dropped handle should not appear in update results"
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Response-without-handle: tag & EID threading
+// ---------------------------------------------------------------------------
+
+/// A response sent without a handle (the reply path) correctly threads the
+/// remote EID and tag back so the requester receives it.
+#[test]
+fn response_without_handle_eid_tag_threading() {
+    let buf_a = RefCell::new(Vec::new());
+    let buf_b = RefCell::new(Vec::new());
+
+    let server_a: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, BufferSender { packets: &buf_a }));
+    let server_b: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(42), 0, BufferSender { packets: &buf_b }));
+
+    let client_a = DirectClient::new(&server_a);
+    let client_b = DirectClient::new(&server_b);
+
+    let listener = client_a.listener(5).unwrap();
+    let req = client_b.req(8).unwrap();
+
+    // B sends a type-5 request
+    let sent_tag = client_b
+        .send(Some(req), 5, None, None, false, b"ping")
+        .unwrap();
+    transfer(&buf_b, &mut server_a.borrow_mut());
+
+    // A receives and replies — no handle, explicit EID + tag
+    let mut buf = [0u8; 255];
+    let meta = client_a.recv(listener, 0, &mut buf).unwrap();
+    client_a
+        .send(
+            None,
+            meta.msg_type,
+            Some(meta.remote_eid),
+            Some(meta.msg_tag),
+            false,
+            b"pong",
+        )
+        .unwrap();
+
+    transfer(&buf_a, &mut server_b.borrow_mut());
+
+    // B receives the response and verifies metadata
+    let mut resp_buf = [0u8; 255];
+    let resp = client_b
+        .recv(req, 0, &mut resp_buf)
+        .expect("B should receive pong");
+
+    assert_eq!(&resp_buf[..resp.payload_size], b"pong");
+    assert_eq!(resp.remote_eid, 8);
+    assert_eq!(resp.msg_type, 5);
+    assert_eq!(resp.msg_tag, sent_tag);
+}
+
+// ---------------------------------------------------------------------------
+// Stack facade (openprot-mctp-api::stack)
+// ---------------------------------------------------------------------------
+//
+// These tests exercise `Stack<DirectClient>` — the same code path used by the
+// real application (`mctp_echo.rs` with `Stack<IpcMctpClient>`), but running
+// entirely on the host with no platform transport binding.
+
+/// Echo via `Stack::listener` → `StackListener::recv` → `StackRespChannel::send`.
+///
+/// This is the exact sequence used by `mctp_echo.rs`:
+/// ```ignore
+/// let mut listener = stack.listener(ECHO_MSG_TYPE, 0).unwrap();
+/// let (meta, msg, mut resp) = listener.recv(&mut buf).unwrap();
+/// resp.send(msg).unwrap();
+/// ```
+#[test]
+fn stack_listener_echo() {
+    let buf_a = RefCell::new(Vec::new());
+    let buf_b = RefCell::new(Vec::new());
+
+    let server_a: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, BufferSender { packets: &buf_a }));
+    let server_b: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(42), 0, BufferSender { packets: &buf_b }));
+
+    // Application A uses Stack — the same API as in production.
+    let stack_a = Stack::new(DirectClient::new(&server_a));
+    // Application B uses a raw client to send the request and check the reply.
+    let client_b = DirectClient::new(&server_b);
+
+    let mut listener = stack_a.listener(1, 0).expect("listener alloc");
+    let req_handle = client_b.req(8).unwrap();
+
+    // B sends a request
+    client_b
+        .send(Some(req_handle), 1, None, None, false, b"hello from B")
+        .unwrap();
+    transfer(&buf_b, &mut server_a.borrow_mut());
+
+    // A echoes back via Stack facade
+    let mut recv_buf = [0u8; 255];
+    let (meta, payload, mut resp) = listener
+        .recv(&mut recv_buf)
+        .expect("stack listener should receive the message");
+
+    assert_eq!(payload, b"hello from B");
+    assert_eq!(meta.remote_eid, 42);
+
+    resp.send(payload).expect("stack resp send");
+
+    // Deliver A → B and verify
+    transfer(&buf_a, &mut server_b.borrow_mut());
+
+    let mut resp_buf = [0u8; 255];
+    let resp_meta = client_b
+        .recv(req_handle, 0, &mut resp_buf)
+        .expect("B should receive the echo");
+
+    assert_eq!(&resp_buf[..resp_meta.payload_size], b"hello from B");
+    assert_eq!(resp_meta.remote_eid, 8);
+    assert_eq!(resp_meta.msg_type, 1);
+}
+
+/// Echo via `Stack::req` → `StackReqChannel::send` + `StackReqChannel::recv`.
+#[test]
+fn stack_req_channel_roundtrip() {
+    let buf_a = RefCell::new(Vec::new());
+    let buf_b = RefCell::new(Vec::new());
+
+    let server_a: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, BufferSender { packets: &buf_a }));
+    let server_b: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(42), 0, BufferSender { packets: &buf_b }));
+
+    let client_a = DirectClient::new(&server_a);
+    // B uses Stack for the request side.
+    let stack_b = Stack::new(DirectClient::new(&server_b));
+
+    let listener_handle = client_a.listener(1).unwrap();
+
+    let mut req = stack_b.req(8, 0).expect("req channel alloc");
+    req.send(1, b"stack req test").expect("req send");
+    assert_eq!(req.remote_eid(), 8);
+
+    transfer(&buf_b, &mut server_a.borrow_mut());
+
+    // A echoes back manually
+    let mut echo_buf = [0u8; 255];
+    let meta = client_a.recv(listener_handle, 0, &mut echo_buf).unwrap();
+    client_a
+        .send(
+            None,
+            meta.msg_type,
+            Some(meta.remote_eid),
+            Some(meta.msg_tag),
+            false,
+            &echo_buf[..meta.payload_size],
+        )
+        .unwrap();
+
+    transfer(&buf_a, &mut server_b.borrow_mut());
+
+    let mut resp_buf = [0u8; 255];
+    let (resp_meta, resp_payload) = req.recv(&mut resp_buf).expect("req channel recv");
+
+    assert_eq!(resp_payload, b"stack req test");
+    assert_eq!(resp_meta.remote_eid, 8);
+    assert_eq!(resp_meta.msg_type, 1);
+}
+
+/// Calling `StackReqChannel::recv` before `send` returns `BadArgument`.
+#[test]
+fn stack_req_channel_recv_before_send_errors() {
+    let buf = RefCell::new(Vec::new());
+    let server: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, BufferSender { packets: &buf }));
+    let stack = Stack::new(DirectClient::new(&server));
+
+    let mut req = stack.req(42, 0).expect("req alloc");
+    let mut resp_buf = [0u8; 255];
+    let err = req.recv(&mut resp_buf).expect_err("recv before send must fail");
+    assert_eq!(err.code, openprot_mctp_api::ResponseCode::BadArgument);
+}
+
+/// A payload at exactly `ServerConfig::MAX_PAYLOAD` bytes round-trips.
+#[test]
+fn max_payload_roundtrip() {
+    let buf_a = RefCell::new(Vec::new());
+    let buf_b = RefCell::new(Vec::new());
+
+    let server_a: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, BufferSender { packets: &buf_a }));
+    let server_b: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(42), 0, BufferSender { packets: &buf_b }));
+
+    let client_a = DirectClient::new(&server_a);
+    let client_b = DirectClient::new(&server_b);
+
+    let listener = client_a.listener(7).unwrap();
+    let req = client_b.req(8).unwrap();
+
+    let payload = vec![0xA5; ServerConfig::MAX_PAYLOAD];
+    let sent_tag = client_b
+        .send(Some(req), 7, None, None, false, &payload)
+        .unwrap();
+    transfer(&buf_b, &mut server_a.borrow_mut());
+
+    let mut recv_buf = [0u8; 1024];
+    let meta = client_a.recv(listener, 0, &mut recv_buf).unwrap();
+    assert_eq!(meta.payload_size, payload.len());
+    assert_eq!(&recv_buf[..meta.payload_size], payload.as_slice());
+
+    client_a
+        .send(
+            None,
+            meta.msg_type,
+            Some(meta.remote_eid),
+            Some(meta.msg_tag),
+            false,
+            &recv_buf[..meta.payload_size],
+        )
+        .unwrap();
+    transfer(&buf_a, &mut server_b.borrow_mut());
+
+    let mut resp_buf = [0u8; 1024];
+    let resp = client_b.recv(req, 0, &mut resp_buf).unwrap();
+    assert_eq!(resp.payload_size, payload.len());
+    assert_eq!(&resp_buf[..resp.payload_size], payload.as_slice());
+    assert_eq!(resp.msg_tag, sent_tag);
+}
+
+/// Both sides use `Stack` — listener on A, request channel on B.
+#[test]
+fn stack_both_sides_echo() {
+    let buf_a = RefCell::new(Vec::new());
+    let buf_b = RefCell::new(Vec::new());
+
+    let server_a: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, BufferSender { packets: &buf_a }));
+    let server_b: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(42), 0, BufferSender { packets: &buf_b }));
+
+    let stack_a = Stack::new(DirectClient::new(&server_a));
+    let stack_b = Stack::new(DirectClient::new(&server_b));
+
+    let mut listener = stack_a.listener(1, 0).expect("listener alloc");
+    let mut req = stack_b.req(8, 0).expect("req alloc");
+
+    // B sends
+    req.send(1, b"both sides").expect("req send");
+    transfer(&buf_b, &mut server_a.borrow_mut());
+
+    // A receives and replies via Stack
+    let mut recv_buf = [0u8; 255];
+    let (_, payload, mut resp) = listener.recv(&mut recv_buf).expect("listener recv");
+    assert_eq!(payload, b"both sides");
+    resp.send(payload).expect("resp send");
+    transfer(&buf_a, &mut server_b.borrow_mut());
+
+    // B receives via Stack req channel
+    let mut resp_buf = [0u8; 255];
+    let (meta, data) = req.recv(&mut resp_buf).expect("req recv");
+    assert_eq!(data, b"both sides");
+    assert_eq!(meta.remote_eid, 8);
+}
+
+/// `Stack::get_eid` and `Stack::set_eid` delegate correctly.
+#[test]
+fn stack_eid_accessors() {
+    let server: RefCell<Server<_, 16>> =
+        RefCell::new(Server::new(Eid(8), 0, common::DroppingBufferSender));
+    let stack = Stack::new(DirectClient::new(&server));
+
+    assert_eq!(stack.get_eid(), 8);
+    stack.set_eid(99).expect("set_eid should succeed");
+    assert_eq!(stack.get_eid(), 99);
+}
+
diff --git a/services/mctp/server/tests/server_unit.rs b/services/mctp/server/tests/server_unit.rs
new file mode 100644
index 0000000..165d472
--- /dev/null
+++ b/services/mctp/server/tests/server_unit.rs
@@ -0,0 +1,242 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Server unit tests — exercise `Server` methods directly with mock transport.
+//!
+//! Each test constructs a `Server` with `DroppingBufferSender` (or
+//! `BufferSender` when outbound packets are needed) and calls the
+//! server API directly. No transport hardware is involved.
+
+mod common;
+
+use std::cell::RefCell;
+
+use mctp::Eid;
+use openprot_mctp_api::{ResponseCode};
+use openprot_mctp_server::{RecvResult, Server, ServerConfig};
+
+use common::{BufferSender, DroppingBufferSender, transfer};
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/// Deliver a message from a sender EID to a receiver server by routing it
+/// through a real `Server::send()` call, avoiding any direct Fragmenter API.
+///
+/// Creates a temporary sender server (EID `src`) with a `BufferSender`, sends
+/// one message of `msg_type` to `dst_eid`, then feeds the captured packets
+/// into `dest` via `inbound`.
+fn deliver_to<S: mctp_lib::Sender, const N: usize>(
+    src: u8,
+    dst_eid: u8,
+    msg_type: u8,
+    payload: &[u8],
+    dest: &mut Server<S, N>,
+) {
+    let buf = RefCell::new(Vec::new());
+    let mut sender_server: Server<BufferSender<'_>, 16> =
+        Server::new(Eid(src), 0, BufferSender { packets: &buf });
+
+    let req_handle = sender_server.req(dst_eid).unwrap();
+    sender_server
+        .send(Some(req_handle), msg_type, None, None, false, payload)
+        .unwrap();
+
+    transfer(&buf, dest);
+}
+
+// ---------------------------------------------------------------------------
+// EID management
+// ---------------------------------------------------------------------------
+
+/// `get_eid` returns the EID passed to `Server::new`.
+#[test]
+fn eid_initial_value() {
+    let sender = DroppingBufferSender;
+    let server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+    assert_eq!(server.get_eid(), 8);
+}
+
+/// `set_eid` + `get_eid` round-trip.
+#[test]
+fn eid_set_get_roundtrip() {
+    let sender = DroppingBufferSender;
+    let mut server: Server<_, 16> = Server::new(Eid(0), 0, sender);
+    server.set_eid(42).expect("set_eid should succeed");
+    assert_eq!(server.get_eid(), 42);
+}
+
+// ---------------------------------------------------------------------------
+// Handle allocation / deallocation
+// ---------------------------------------------------------------------------
+
+/// `req()` succeeds and `unbind()` releases the handle cleanly.
+#[test]
+fn req_handle_alloc_and_unbind() {
+    let sender = DroppingBufferSender;
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+    let handle = server.req(42).expect("req should succeed");
+    server.unbind(handle).expect("unbind should succeed");
+}
+
+/// `listener()` succeeds and `unbind()` releases the handle cleanly.
+#[test]
+fn listener_handle_alloc_and_unbind() {
+    let sender = DroppingBufferSender;
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+    let handle = server.listener(1).expect("listener should succeed");
+    server.unbind(handle).expect("unbind should succeed");
+}
+
+/// Registering a second listener for the same `msg_type` returns `AddrInUse`.
+#[test]
+fn listener_duplicate_msg_type_returns_addr_in_use() {
+    let sender = DroppingBufferSender;
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+    server.listener(1).expect("first listener should succeed");
+    let err = server.listener(1).expect_err("duplicate listener should fail");
+    assert_eq!(err.code, ResponseCode::AddrInUse);
+}
+
+/// Two listeners for *different* `msg_type` values both succeed.
+#[test]
+fn listener_different_types_both_succeed() {
+    let sender = DroppingBufferSender;
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+    let h1 = server.listener(1).expect("listener type 1 should succeed");
+    let h2 = server.listener(2).expect("listener type 2 should succeed");
+    assert_ne!(h1, h2);
+}
+
+// ---------------------------------------------------------------------------
+// try_recv before inbound
+// ---------------------------------------------------------------------------
+
+/// `try_recv` returns `None` when no message has been fed via `inbound`.
+#[test]
+fn try_recv_before_inbound_returns_none() {
+    let sender = DroppingBufferSender;
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+    let handle = server.listener(1).unwrap();
+    let mut buf = [0u8; 255];
+    assert!(server.try_recv(handle, &mut buf).is_none());
+}
+
+// ---------------------------------------------------------------------------
+// inbound → try_recv routing
+// ---------------------------------------------------------------------------
+
+/// A raw packet fed via `inbound` is delivered to the matching listener.
+#[test]
+fn inbound_then_try_recv_delivers_message() {
+    let buf_out = RefCell::new(Vec::new());
+    let sender = BufferSender { packets: &buf_out };
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+
+    let listener = server.listener(1).unwrap();
+
+    let payload = b"hello";
+    deliver_to(42, 8, 1, payload, &mut server);
+
+    let mut recv_buf = [0u8; 255];
+    let meta = server
+        .try_recv(listener, &mut recv_buf)
+        .expect("message should be available after inbound");
+
+    assert_eq!(meta.msg_type, 1);
+    assert_eq!(meta.remote_eid, 42);
+    assert_eq!(meta.payload_size, payload.len());
+    assert_eq!(&recv_buf[..meta.payload_size], payload);
+}
+
+/// A packet for msg_type 2 is not delivered to a listener for msg_type 1.
+#[test]
+fn inbound_wrong_type_not_delivered() {
+    let sender = DroppingBufferSender;
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+    let listener = server.listener(1).unwrap();
+
+    deliver_to(42, 8, 2, b"wrong type", &mut server);
+
+    let mut buf = [0u8; 255];
+    assert!(server.try_recv(listener, &mut buf).is_none());
+}
+
+// ---------------------------------------------------------------------------
+// send with oversized payload
+// ---------------------------------------------------------------------------
+
+/// `send` with a payload larger than `MAX_PAYLOAD` returns `NoSpace`.
+#[test]
+fn send_oversized_payload_returns_no_space() {
+    let sender = DroppingBufferSender;
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+    let req_handle = server.req(42).unwrap();
+
+    let big_payload = vec![0u8; ServerConfig::MAX_PAYLOAD + 1];
+    let err = server
+        .send(Some(req_handle), 1, None, None, false, &big_payload)
+        .expect_err("oversized send should fail");
+    assert_eq!(err.code, ResponseCode::NoSpace);
+}
+
+// ---------------------------------------------------------------------------
+// register_recv + update timeout
+// ---------------------------------------------------------------------------
+
+/// A registered recv with a timeout fires `RecvResult::TimedOut` after the deadline.
+#[test]
+fn pending_recv_times_out() {
+    let sender = DroppingBufferSender;
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+    let listener = server.listener(1).unwrap();
+
+    // Register a recv with a 100 ms timeout, starting at t=0.
+    server
+        .register_recv(listener, 100, 0)
+        .expect("register_recv should succeed");
+
+    let mut recv_buf = [0u8; 255];
+
+    // At t=50 ms: not yet timed out, no message.
+    let (_, ready) = server.update(50, &mut recv_buf);
+    assert!(ready.is_empty(), "should not fire before deadline");
+
+    // At t=100 ms: deadline reached.
+    let (_, ready) = server.update(100, &mut recv_buf);
+    assert_eq!(ready.len(), 1);
+    assert!(
+        matches!(ready[0], (h, RecvResult::TimedOut) if h == listener),
+        "expected TimedOut for listener handle"
+    );
+}
+
+/// A registered recv that receives a message before the deadline resolves with
+/// `RecvResult::Message`, not a timeout.
+#[test]
+fn pending_recv_fulfilled_before_timeout() {
+    let buf_out = RefCell::new(Vec::new());
+    let sender = BufferSender { packets: &buf_out };
+    let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+    let listener = server.listener(1).unwrap();
+
+    server
+        .register_recv(listener, 1000, 0)
+        .expect("register_recv should succeed");
+
+    deliver_to(42, 8, 1, b"data", &mut server);
+
+    let mut recv_buf = [0u8; 255];
+    let (_, ready) = server.update(50, &mut recv_buf);
+
+    assert_eq!(ready.len(), 1);
+    assert!(
+        matches!(ready[0], (h, RecvResult::Message(_)) if h == listener),
+        "expected Message result"
+    );
+    if let (_, RecvResult::Message(meta)) = ready[0] {
+        assert_eq!(meta.remote_eid, 42);
+        assert_eq!(meta.msg_type, 1);
+    }
+}