mctp/server: add mock-transport test suite

Add a comprehensive set of integration and unit tests for the MCTP server
that run without the I2C transport layer. A BufferSender mock replaces
the real I2C sender; no transport hardware or system linker is required.

New files:
- tests/common/mod.rs     -- shared fixtures: BufferSender, DroppingBufferSender,
                             DirectClient, DirectListener, DirectRespChannel,
                             DirectReqChannel, transfer()
- tests/server_unit.rs    -- unit tests for Server methods: EID management,
                             handle alloc/free, try_recv, inbound routing,
                             oversized payload, register_recv + timeout
- tests/integration.rs    -- multi-fragment reassembly, concurrent listeners,
                             echo via MctpListener+MctpRespChannel traits,
                             MctpReqChannel, drop_handle mid-flight,
                             response EID/tag threading

Updated files:
- tests/echo.rs           -- refactored to use common/ (no duplicated fixtures)
- tests/dispatch.rs       -- refactored to use common/; added edge-case tests:
                             malformed request, unknown opcode, Recv with no
                             message, Unbind
- BUILD.bazel             -- four new rust_test targets, one per test file
- README.md               -- document new test layout and Bazel invocations

All four Bazel test targets pass:
  bazel test //services/mctp/server:mctp_server_{echo,dispatch,unit,integration}_test
diff --git a/.claude/mctp-test-plan.md b/.claude/mctp-test-plan.md
new file mode 100644
index 0000000..d09d9c4
--- /dev/null
+++ b/.claude/mctp-test-plan.md
@@ -0,0 +1,80 @@
+# MCTP Server Test Plan (without I2C transport)
+
+Branch: ocp-emea-demo
+
+## Core Insight
+`Server<S: Sender, const N>` is generic over `mctp_lib::Sender`.
+I2C transport is just one `Sender` impl + a caller of `server.inbound()`.
+No mocking framework, feature flags, or cfg(test) shims needed.
+
+---
+
+## File Layout
+
+```
+services/mctp/server/tests/
+├── common/
+│   └── mod.rs         ← shared BufferSender, transfer(), DirectClient
+├── echo.rs            ← already exists; refactor to use common/
+├── dispatch.rs        ← already exists; add missing cases
+├── server_unit.rs     ← NEW: Layer 2 unit tests
+└── integration.rs     ← NEW: Layer 4 multi-fragment / concurrency
+```
+
+---
+
+## Layer 1 — Shared Test Fixtures (common/mod.rs)
+
+- [ ] Extract `BufferSender<'_>` from echo.rs and dispatch.rs into `tests/common/mod.rs`
+- [ ] Add `DroppingBufferSender` (discards writes, always returns Ok) for tests that only care about inbound routing
+- [ ] Extract `transfer(from, to)` helper into common
+- [ ] Extract `DirectClient<'a, S, N>` into common (wraps &RefCell<Server> as MctpClient)
+
+---
+
+## Layer 2 — Server Unit Tests (server_unit.rs)
+
+- [ ] `req()` + `unbind()` — handle allocation/deallocation
+- [ ] `listener()` duplicate msg_type — expect AlreadyBound error
+- [ ] `try_recv()` before any `inbound()` — returns None
+- [ ] `inbound(raw_pkt)` + `try_recv()` — full routing path (use mctp_lib::fragment::Fragmenter to build raw pkts)
+- [ ] `register_recv()` + `update(now + timeout)` — timeout fires RecvResult::TimedOut
+- [ ] `set_eid()` / `get_eid()` — EID round-trip
+- [ ] `send()` with payload > MAX_PAYLOAD — expect NoSpace error
+
+---
+
+## Layer 3 — Dispatch Unit Tests (dispatch.rs additions)
+
+- [ ] Malformed wire request → BadArgument
+- [ ] `MctpOp::Send` via response path (no handle, HAS_EID flag, explicit tag)
+- [ ] `MctpOp::Unbind` for never-allocated handle → error
+- [ ] `MctpOp::Recv` when no message ready → TimedOut (gap noted in code comment)
+
+---
+
+## Layer 4 — Integration Tests (integration.rs)
+
+- [ ] Multi-fragment roundtrip: set `get_mtu()=64`, send 200-byte payload, verify reassembly
+- [ ] Multiple concurrent listeners: two msg_type values, cross-deliver, verify no cross-talk
+- [ ] Response-without-handle: verify tag & EID threading through echo
+- [ ] Interleaved requests from two senders: tag collision avoidance
+
+---
+
+## Layer 5 — MctpClient Trait Tests (via DirectClient in echo.rs)
+
+- [ ] `MctpListener::recv()` — called after inbound, returns payload
+- [ ] `MctpReqChannel::send()` + `recv()` — full request-response cycle
+- [ ] `drop_handle` mid-flight — verify outstanding entry is cleared
+
+---
+
+## Out of Scope (belongs in other crates)
+
+| Concern | Owner crate |
+|---|---|
+| MCTP-over-I2C framing/PEC | openprot-mctp-transport-i2c |
+| MctpI2cReceiver::decode | openprot-mctp-transport-i2c |
+| I2cClientBlocking mock | i2c service tests |
+| IpcI2cClient / handle::I2C wiring | target/platform integration |
diff --git a/services/mctp/server/BUILD.bazel b/services/mctp/server/BUILD.bazel
index 7a6098d..027a423 100644
--- a/services/mctp/server/BUILD.bazel
+++ b/services/mctp/server/BUILD.bazel
@@ -26,6 +26,74 @@
     crate = ":mctp_server_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/echo.rs",
+        "tests/common/mod.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/dispatch.rs",
+        "tests/common/mod.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/server_unit.rs",
+        "tests/common/mod.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/integration.rs",
+        "tests/common/mod.rs",
+    ],
+    crate_root = "tests/integration.rs",
+    edition = "2024",
+    deps = [
+        ":mctp_server_lib",
+        "//services/mctp/api:mctp_api",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
+
 rust_binary(
     name = "mctp_server",
     srcs = ["src/main.rs"],
diff --git a/services/mctp/server/README.md b/services/mctp/server/README.md
index c6dac68..2cfed72 100644
--- a/services/mctp/server/README.md
+++ b/services/mctp/server/README.md
@@ -84,47 +84,34 @@
 
 ## Tests
 
-The `tests/` directory contains two integration tests that run on the host (std):
+The `tests/` directory contains integration tests that run on the host (std) with **no I2C transport** — a `BufferSender` mock replaces it entirely.
+
+### Test files
 
 | File | What it tests |
 |------|---------------|
-| `tests/echo.rs` | Full MCTP echo round-trip: server A listens, server B sends a request, verifies the echoed response via a mock `BufferSender` transport. |
-| `tests/dispatch.rs` | IPC wire-protocol dispatch: encodes a request with `wire::encode_*`, calls `dispatch_mctp_op`, and verifies the decoded response. |
+| `tests/common/mod.rs` | Shared fixtures: `BufferSender`, `DroppingBufferSender`, `DirectClient`, `DirectListener`, `DirectRespChannel`, `DirectReqChannel` |
+| `tests/echo.rs` | Full MCTP echo round-trip via `MctpClient` trait |
+| `tests/dispatch.rs` | IPC wire-protocol dispatch: encodes requests, calls `dispatch_mctp_op`, verifies responses. Includes edge cases: malformed request, unknown opcode, `Recv` with no message, `Unbind` |
+| `tests/server_unit.rs` | Unit tests for `Server` methods: EID management, handle allocation, `try_recv` before/after `inbound`, oversized payload, timeout via `register_recv` + `update` |
+| `tests/integration.rs` | Multi-fragment reassembly, multiple concurrent listeners (no cross-talk), echo via `MctpListener` + `MctpRespChannel` traits, `MctpReqChannel` trait, `drop_handle` mid-flight, response EID/tag threading |
 
-### Running with Bazel (primary)
+The echo via `MctpListener` + `MctpRespChannel` test (`echo_via_mctplistener_trait`) is the key one: it exercises the exact interface the real echo application uses, with `BufferSender` as the only transport.
 
-This is a Bazel project. Use these commands from the workspace root:
+### Running with Bazel
 
 ```sh
-# Run both integration tests via the mctp_server_test rule
-bazel test //services/mctp/server:mctp_server_test
-
-# Run alongside the API tests
-bazel test //services/mctp/server:mctp_server_test //services/mctp/api:mctp_api_test
+# Run all test targets
+bazel test //services/mctp/server:mctp_server_echo_test \
+           //services/mctp/server:mctp_server_dispatch_test \
+           //services/mctp/server:mctp_server_unit_test \
+           //services/mctp/server:mctp_server_integration_test
 
 # Show test output
-bazel test //services/mctp/server:mctp_server_test --test_output=all
+bazel test //services/mctp/server:mctp_server_unit_test --test_output=all
 ```
 
 > **Note:** Do not use the `//services/mctp/...` wildcard — it will also pick up
 > the `mctp_server` and `mctp_echo` kernel binaries, which require `kernel_config`
-> (generated only during a full system image build). Target the test rule directly
+> (generated only during a full system image build). Target the test rules directly
 > as shown above.
-
-### Running with Cargo (host-only convenience)
-
-Because the integration tests are `std`-only, they can also be run with Cargo for quick iteration without a full Bazel setup:
-
-```sh
-# Run all tests for this crate
-cargo test -p openprot-mctp-server
-
-# Run only the echo test
-cargo test -p openprot-mctp-server --test echo
-
-# Run only the dispatch test
-cargo test -p openprot-mctp-server --test dispatch
-
-# Show stdout from passing tests
-cargo test -p openprot-mctp-server -- --nocapture
-```
diff --git a/services/mctp/server/tests/common/mod.rs b/services/mctp/server/tests/common/mod.rs
new file mode 100644
index 0000000..19ad95e
--- /dev/null
+++ b/services/mctp/server/tests/common/mod.rs
@@ -0,0 +1,358 @@
+// Licensed under the Apache-2.0 license
+
+//! Shared test fixtures for MCTP server integration tests.
+//!
+//! Provides:
+//! - [`BufferSender`] — captures outbound packets into a `Vec` (no I2C)
+//! - [`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` (no IPC)
+//! - [`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 (no IPC).
+///
+/// In production an IPC channel sits between the application and the MCTP
+/// server. `DirectClient` replaces that channel, allowing application code
+/// written against `MctpClient` to be exercised in pure `std` tests without
+/// any transport hardware.
+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
index d94dad8..3fc72ba 100644
--- a/services/mctp/server/tests/dispatch.rs
+++ b/services/mctp/server/tests/dispatch.rs
@@ -7,53 +7,16 @@
 //! work together correctly, simulating what happens when `IpcMctpClient`
 //! talks to the MCTP server process over a Pigweed IPC channel.
 
+mod common;
+
 use std::cell::RefCell;
 
-use mctp::{Eid, Tag};
-use mctp_lib::fragment::{Fragmenter, SendOutput};
-use mctp_lib::Sender;
+use mctp::Eid;
 use openprot_mctp_api::wire;
 use openprot_mctp_server::{dispatch::dispatch_mctp_op, Server};
 
-// ---------------------------------------------------------------------------
-// Mock transport (same as echo.rs)
-// ---------------------------------------------------------------------------
+use common::{transfer, BufferSender};
 
-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>,
-) {
-    for pkt in packets.borrow().iter() {
-        dest.inbound(pkt).unwrap();
-    }
-}
 
 // ---------------------------------------------------------------------------
 // Tests
@@ -172,3 +135,129 @@
     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
index 28e1173..b3a7790 100644
--- a/services/mctp/server/tests/echo.rs
+++ b/services/mctp/server/tests/echo.rs
@@ -13,123 +13,16 @@
 //! the `Server` + transport plumbing is the server side. This mirrors the
 //! real deployment where the echo task is an IPC client of the MCTP server.
 
+mod common;
+
 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, RecvMetadata, ResponseCode};
+use mctp::Eid;
+use openprot_mctp_api::{Handle, MctpClient};
 use openprot_mctp_server::Server;
 
-// ---------------------------------------------------------------------------
-// Mock transport
-// ---------------------------------------------------------------------------
+use common::{transfer, BufferSender, DirectClient};
 
-/// A mock sender that captures outbound packets in a buffer.
-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
-    }
-}
-
-/// Transfer packets from one server's outbound buffer to another server.
-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();
-    }
-}
-
-// ---------------------------------------------------------------------------
-// Client-side wrapper (simulates IPC client)
-// ---------------------------------------------------------------------------
-
-/// A direct (in-process) MCTP client that wraps a `Server` via `RefCell`.
-///
-/// This plays the same role as the Hubris `mctp_api::Stack` — it provides the
-/// `MctpClient` trait interface to application code. In production, IPC would
-/// sit between client and server; here we call `Server` methods directly.
-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(ResponseCode::TimedOut))
-    }
-
-    fn send(
-        &self,
-        handle: Option<Handle>,
-        msg_type: u8,
-        eid: Option<u8>,
-        tag: Option<u8>,
-        integrity_check: bool,
-        buf: &[u8],
-    ) -> Result<u8, MctpError> {
-        self.server
-            .borrow_mut()
-            .send(handle, msg_type, eid, tag, integrity_check, buf)
-    }
-
-    fn drop_handle(&self, handle: Handle) {
-        let _ = self.server.borrow_mut().unbind(handle);
-    }
-}
 
 // ---------------------------------------------------------------------------
 // Echo application logic (client side — mirrors Hubris mctp-echo task)
diff --git a/services/mctp/server/tests/integration.rs b/services/mctp/server/tests/integration.rs
new file mode 100644
index 0000000..3f1b006
--- /dev/null
+++ b/services/mctp/server/tests/integration.rs
@@ -0,0 +1,323 @@
+// Licensed under the Apache-2.0 license
+
+//! 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 I2C transport is used anywhere in this file.
+
+mod common;
+
+use std::cell::RefCell;
+
+use mctp::Eid;
+use openprot_mctp_api::{MctpClient, MctpListener, MctpReqChannel, MctpRespChannel};
+use openprot_mctp_server::Server;
+
+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 I2C:
+/// ```
+/// 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 in hubris/task/mctp-echo)
+    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
+    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);
+}
diff --git a/services/mctp/server/tests/server_unit.rs b/services/mctp/server/tests/server_unit.rs
new file mode 100644
index 0000000..f468961
--- /dev/null
+++ b/services/mctp/server/tests/server_unit.rs
@@ -0,0 +1,241 @@
+// Licensed under the Apache-2.0 license
+
+//! Server unit tests — exercise `Server` methods directly without I2C.
+//!
+//! 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);
+    }
+}