docs: add README.md to each MCTP service crate

diff --git a/Cargo.lock b/Cargo.lock
index 96d40f5..59b3811 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -117,6 +117,21 @@
 ]
 
 [[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
+
+[[package]]
 name = "crypto-bigint"
 version = "0.5.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -201,6 +216,12 @@
 ]
 
 [[package]]
+name = "embedded-crc-macros"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f1c75747a43b086df1a87fb2a889590bc0725e0abf54bba6d0c4bf7bd9e762c"
+
+[[package]]
 name = "embedded-hal"
 version = "0.2.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -236,6 +257,21 @@
 ]
 
 [[package]]
+name = "embedded-io"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+
+[[package]]
+name = "embedded-io-async"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f"
+dependencies = [
+ "embedded-io",
+]
+
+[[package]]
 name = "ff"
 version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -288,6 +324,16 @@
 
 [[package]]
 name = "heapless"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
+dependencies = [
+ "hash32",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "heapless"
 version = "0.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b1edcd5a338e64688fbdcb7531a846cfd3476a54784dcb918a0844682bc7ada5"
@@ -342,6 +388,41 @@
 checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
 
 [[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "mctp"
+version = "0.2.0"
+source = "git+https://github.com/OpenPRoT/mctp-rs.git?branch=sync-features#fafe92f6fcadf6e57683b91757fad9933ddd223d"
+
+[[package]]
+name = "mctp-estack"
+version = "0.1.0"
+source = "git+https://github.com/OpenPRoT/mctp-rs.git?branch=sync-features#fafe92f6fcadf6e57683b91757fad9933ddd223d"
+dependencies = [
+ "crc",
+ "embedded-io",
+ "embedded-io-async",
+ "heapless 0.8.0",
+ "log",
+ "mctp",
+ "smbus-pec",
+ "uuid",
+]
+
+[[package]]
+name = "mctp-lib"
+version = "0.1.0"
+source = "git+https://github.com/9elements/mctp-lib.git?branch=buildup#9c53adaeb7ae544cbeab4654530a50d158e33fe5"
+dependencies = [
+ "mctp",
+ "mctp-estack",
+]
+
+[[package]]
 name = "nb"
 version = "0.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -402,6 +483,43 @@
 ]
 
 [[package]]
+name = "openprot-mctp-api"
+version = "0.1.0"
+dependencies = [
+ "heapless 0.9.1",
+ "zerocopy",
+]
+
+[[package]]
+name = "openprot-mctp-client"
+version = "0.1.0"
+dependencies = [
+ "openprot-mctp-api",
+]
+
+[[package]]
+name = "openprot-mctp-server"
+version = "0.1.0"
+dependencies = [
+ "heapless 0.9.1",
+ "mctp",
+ "mctp-lib",
+ "openprot-mctp-api",
+]
+
+[[package]]
+name = "openprot-mctp-transport-i2c"
+version = "0.1.0"
+dependencies = [
+ "embedded-hal 1.0.0",
+ "heapless 0.9.1",
+ "mctp",
+ "mctp-lib",
+ "openprot-i2c-api",
+ "openprot-mctp-api",
+]
+
+[[package]]
 name = "openprot-platform-impl-hubris"
 version = "0.1.0"
 dependencies = [
@@ -474,7 +592,7 @@
  "aes-gcm",
  "cipher",
  "ctr",
- "heapless",
+ "heapless 0.9.1",
  "hmac",
  "k256",
  "openprot-hal-blocking",
@@ -627,6 +745,15 @@
 ]
 
 [[package]]
+name = "smbus-pec"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca0763a680cd5d72b28f7bfc8a054c117d8841380a6ad4f72f05bd2a34217d3e"
+dependencies = [
+ "embedded-crc-macros",
+]
+
+[[package]]
 name = "stable_deref_trait"
 version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -672,6 +799,12 @@
 ]
 
 [[package]]
+name = "uuid"
+version = "1.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
+
+[[package]]
 name = "vcell"
 version = "0.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 7a4c80e..62b29db 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,10 @@
     "platform/impls/hubris",
     "platform/impls/rustcrypto",
     "services/i2c/api",
+    "services/mctp/api",
+    "services/mctp/server",
+    "services/mctp/transport-i2c",
+    "services/mctp/client",
     "services/telemetry",
     "services/storage",
 ]
diff --git a/port-mctp-echo.md b/port-mctp-echo.md
new file mode 100644
index 0000000..bba4a31
--- /dev/null
+++ b/port-mctp-echo.md
@@ -0,0 +1,124 @@
+# Port Hubris `ast1060-mctp-i2c-echo` to OpenPRoT (Pigweed)
+
+## Context
+
+**Source:** `hubris/app/ast1060-mctp-i2c-echo/`
+A Hubris RTOS application running MCTP-over-I2C echo on an AST1060 RoT.
+
+| Task | Crate | Role |
+|------|-------|------|
+| `jefe` | `task-jefe` | Supervisor / fault manager |
+| `idle` | `task-idle` | Idle task |
+| `mctp_echo` | `mctp-echo` | Listens for MCTP type-1 requests, echoes payload back |
+| `mctp_server` | `mctp-server` | MCTP transport layer (features: `serial_log`, `transport_i2c`) |
+| `uart_driver` | `drv-ast1060-uart` | UART peripheral driver |
+| `i2c_driver` | `drv-mock-i2c` | Mock I2C driver (feature: `mock-only`) |
+
+**Target:** `openprot/`
+Cargo workspace + Bazel, targeting the **Pigweed kernel** (pw_kernel). Existing services follow `api/` + `server/` + `backend-*/` pattern (see `services/i2c/`). Platform integration is Pigweed-only — the MCTP server will run as a **userspace process** under pw_kernel, not a Hubris task or Linux process.
+
+## Porting Principle
+
+**Preserve as much of the original Hubris code as possible.** Only change what is OS-dependent. The MCTP protocol logic, packet handling, server state management, transport encoding/decoding, and application logic (echo) should be carried over as-is. The only parts that change are:
+
+- **IPC mechanism**: Hubris `idol` / `sys_reply` / `Leased` → Pigweed `pw_kernel` IPC/channels
+- **Task/process model**: Hubris `task_slot!` / `sys_recv_open` / notifications → Pigweed userspace process event loop
+- **Driver APIs**: Hubris `drv-i2c-api` / `ast1060-uart-api` → OpenPRoT `services/i2c/` userspace driver
+- **Build system**: Hubris `app.toml` + `build.rs` code generation → Bazel BUILD files + `system.json5`
+
+Everything else — the `Server` struct, `Router` integration, `Sender` implementations, handle management, timeout logic, MCTP type definitions — should remain structurally identical to the Hubris originals.
+
+---
+
+## Phase 1: MCTP Service API (`services/mctp/api`) — COMPLETE
+
+Create the platform-independent MCTP types and traits crate.
+
+- [x] Create `services/mctp/api/` directory structure
+- [x] `Cargo.toml` — `openprot-mctp-api` crate
+- [x] `src/lib.rs` — `Handle`, `RecvMetadata` types
+- [x] `src/error.rs` — `ResponseCode`, `MctpError` (ported from hubris `mctp-api` `ServerError`)
+- [x] `src/traits.rs` — `MctpClient`, `MctpListener`, `MctpReqChannel`, `MctpRespChannel`
+- [x] Add to workspace `Cargo.toml`
+- [x] Verify `cargo check` passes
+
+## Phase 2: MCTP Server Core (`services/mctp/server`) — COMPLETE
+
+Create the platform-independent server logic crate.
+
+- [x] Create `services/mctp/server/` directory structure
+- [x] `Cargo.toml` — `openprot-mctp-server` crate
+- [x] `src/lib.rs` + `src/server.rs` — `Server` struct with EID mgmt, pending recv tracking, timeouts
+- [x] Add to workspace `Cargo.toml`
+- [x] Verify `cargo check` passes
+- [x] Integrate `mctp-stack` (`mctp-lib`) `Router` as the packet processing engine
+- [x] Re-export `mctp_stack::Sender` trait for transport bindings
+- [x] Wire up inbound packet → Router → pending recv fulfillment via `Server::inbound()` + `Server::update()`
+
+## Phase 3: I2C Transport Binding (`services/mctp/transport-i2c`) — COMPLETE
+
+Port the I2C transport from hubris `mctp-server/src/i2c.rs`, using the I2C userspace driver at `services/i2c/` as the underlying transport.
+
+- [x] Create `services/mctp/transport-i2c/` crate
+- [x] Implement `Sender` for I2C using the `services/i2c/` userspace driver (client API + IPC to I2C server)
+- [x] Implement inbound I2C → MCTP packet decoding (using `mctp-stack::i2c::MctpI2cHandler`)
+- [x] Use `I2cTargetClient` from `services/i2c/api` for slave/target mode receive
+- [x] Echo integration test with client/server partition (`DirectClient` implementing `MctpClient`, 2 tests passing)
+
+## Phase 4: Serial Transport Binding (`services/mctp/transport-serial`) — NOT STARTED
+
+Port the serial transport from hubris `mctp-server/src/serial.rs`. (Lower priority than I2C.)
+
+- [ ] Create `services/mctp/transport-serial/` crate
+- [ ] Implement `Sender` for serial (using `embedded-io::Write`, not hubris `ast1060-uart-api`)
+- [ ] Implement inbound serial → MCTP packet decoding (using `mctp-stack::serial::MctpSerialHandler`)
+
+## Phase 5: MCTP Echo Application — IN PROGRESS
+
+Port the echo task from hubris `task/mctp-echo/`.
+
+- [x] IPC wire protocol (`services/mctp/api/src/wire.rs`) — request/response encoding for all MCTP operations
+- [x] IPC client (`services/mctp/client/`) — `IpcMctpClient` implementing `MctpClient` via wire protocol + IPC
+- [x] Server-side IPC dispatch (`services/mctp/server/src/dispatch.rs`) — decodes wire requests, calls `Server`
+- [x] Wire-protocol dispatch integration test (`tests/dispatch.rs`) — full round-trip through wire encoding
+- [ ] Echo binary as Pigweed userspace process (needs Phase 6 platform wiring)
+- [ ] Wire up with server + I2C transport for an end-to-end demo
+
+## Phase 6: Pigweed Platform Integration — NOT STARTED
+
+Wire up the MCTP server as a Pigweed userspace process on the AST1060-EVB (`target/ast1060-evb/`).
+
+- [ ] MCTP server `main.rs`: event loop driven by pw_kernel IPC/channels
+  - Replaces hubris `sys_recv_open` / notifications / `idol` IPC dispatch
+  - Uses `dispatch_mctp_op` for IPC request handling
+  - Follows the pattern established by `services/i2c/server/`
+- [ ] Connect `IpcMctpClient::send_recv` to `syscall::channel_transact`
+- [ ] Bazel BUILD files for each new crate (following `services/i2c/` pattern)
+- [ ] `system.json5` entry for MCTP server + echo processes
+- [ ] Integration with `target/ast1060-evb/` platform definition
+
+## Phase 7: Testing & Documentation — PARTIALLY DONE
+
+- [x] Wire protocol unit tests (7 tests in `api/src/wire.rs`)
+- [x] Echo integration tests with client/server partition (2 tests in `server/tests/echo.rs`)
+- [x] Wire-protocol dispatch integration tests (2 tests in `server/tests/dispatch.rs`)
+- [ ] QEMU-based end-to-end test (following `services/i2c/` test pattern)
+- [ ] Update `docs/src/specification/middleware/mctp.md` with implementation status
+- [ ] README for `services/mctp/`
+
+---
+
+## Current Status
+
+**Phases 1–3 complete, Phase 5 mostly done.** All library code is written: wire protocol, IPC client, server dispatch, and transport bindings. 11 tests pass. The remaining work is Phase 6 (Pigweed platform integration): server `main.rs`, Bazel BUILD files, and `system.json5`.
+
+## Key Dependencies
+
+| Crate | Source | Role |
+|-------|--------|------|
+| `mctp` | workspace (types crate) | `Eid`, `MsgType`, `Tag`, `Error` etc. |
+| `mctp-stack` / `mctp-lib` | `github.com/9elements/mctp-lib` branch `buildup` | `Router`, `Sender`, fragmentation, serial/I2C handlers |
+| `services/i2c/` | I2C userspace driver | I2C client/target/server — MCTP transport-i2c uses this as its underlying I2C transport |
+| `heapless` | workspace | `no_std` collections |
+| `zerocopy` | workspace | Zero-copy serialization |
+| Pigweed (`pw_kernel`) | Bazel via `MODULE.bazel` | Userspace processes, IPC channels, system image |
diff --git a/services/mctp/api/Cargo.toml b/services/mctp/api/Cargo.toml
new file mode 100644
index 0000000..7f3d297
--- /dev/null
+++ b/services/mctp/api/Cargo.toml
@@ -0,0 +1,12 @@
+# Licensed under the Apache-2.0 license
+
+[package]
+name = "openprot-mctp-api"
+version = "0.1.0"
+edition = "2021"
+description = "MCTP service API types and traits for OpenPRoT"
+license = "Apache-2.0"
+
+[dependencies]
+zerocopy = { workspace = true }
+heapless = { workspace = true }
diff --git a/services/mctp/api/README.md b/services/mctp/api/README.md
new file mode 100644
index 0000000..b034290
--- /dev/null
+++ b/services/mctp/api/README.md
@@ -0,0 +1,31 @@
+# openprot-mctp-api
+
+Platform-independent MCTP types and traits crate.
+
+## Overview
+
+This crate defines the core API contract between MCTP clients and the MCTP server. It provides traits for client operations, listener management, and request/response channels, as well as the binary IPC wire protocol used for inter-process communication.
+
+## Key Types
+
+- `Handle` — opaque handle for listeners, requests, or response channels
+- `RecvMetadata` — metadata from a successful receive (msg_type, tag, remote_eid, payload_size, etc.)
+- `MctpError` / `ResponseCode` — error types (Success, InternalError, NoSpace, AddrInUse, TimedOut, BadArgument, ServerRestarted)
+
+## Traits
+
+- `MctpClient` — main client interface (req, listener, get/set EID, recv, send, drop_handle)
+- `MctpListener` — receiving incoming MCTP messages of a specific type
+- `MctpReqChannel` — request/response channels
+- `MctpRespChannel` — response channels
+
+## Wire Protocol
+
+The `wire` module implements binary request/response encoding for IPC communication between userspace processes and the MCTP server.
+
+## Dependencies
+
+- `zerocopy` — zero-copy serialization
+- `heapless` — `no_std` collections
+
+This crate is `no_std` compatible.
diff --git a/services/mctp/api/src/error.rs b/services/mctp/api/src/error.rs
new file mode 100644
index 0000000..24cb1d1
--- /dev/null
+++ b/services/mctp/api/src/error.rs
@@ -0,0 +1,107 @@
+// Licensed under the Apache-2.0 license
+
+//! MCTP error types
+//!
+//! This module defines error types for MCTP operations, providing both
+//! transport-level errors and higher-level service response codes.
+
+use core::fmt;
+
+/// Response codes from the MCTP service.
+///
+/// These codes indicate the result of an MCTP operation and are designed
+/// to be compatible with IPC/RPC response handling.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[repr(u8)]
+pub enum ResponseCode {
+    /// Operation completed successfully.
+    Success = 0,
+    /// Internal server error.
+    InternalError = 1,
+    /// No space available (e.g., message buffers full).
+    NoSpace = 2,
+    /// Address/handle already in use.
+    AddrInUse = 3,
+    /// Operation timed out.
+    TimedOut = 4,
+    /// Invalid argument provided.
+    BadArgument = 5,
+    /// Server restarted, state lost.
+    ServerRestarted = 6,
+}
+
+impl ResponseCode {
+    /// Returns `true` if this represents a successful operation.
+    #[inline]
+    pub const fn is_success(self) -> bool {
+        matches!(self, ResponseCode::Success)
+    }
+
+    /// Returns `true` if this represents an error condition.
+    #[inline]
+    pub const fn is_error(self) -> bool {
+        !self.is_success()
+    }
+
+    /// Converts from a raw u8 value.
+    pub const fn from_u8(value: u8) -> Option<Self> {
+        match value {
+            0 => Some(ResponseCode::Success),
+            1 => Some(ResponseCode::InternalError),
+            2 => Some(ResponseCode::NoSpace),
+            3 => Some(ResponseCode::AddrInUse),
+            4 => Some(ResponseCode::TimedOut),
+            5 => Some(ResponseCode::BadArgument),
+            6 => Some(ResponseCode::ServerRestarted),
+            _ => None,
+        }
+    }
+}
+
+impl fmt::Display for ResponseCode {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            ResponseCode::Success => write!(f, "success"),
+            ResponseCode::InternalError => write!(f, "internal error"),
+            ResponseCode::NoSpace => write!(f, "no space"),
+            ResponseCode::AddrInUse => write!(f, "address in use"),
+            ResponseCode::TimedOut => write!(f, "timed out"),
+            ResponseCode::BadArgument => write!(f, "bad argument"),
+            ResponseCode::ServerRestarted => write!(f, "server restarted"),
+        }
+    }
+}
+
+/// MCTP operation error.
+///
+/// This is the main error type returned by MCTP client operations.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MctpError {
+    /// The response code from the service.
+    pub code: ResponseCode,
+}
+
+impl MctpError {
+    /// Creates a new error from a response code.
+    pub const fn from_code(code: ResponseCode) -> Self {
+        MctpError { code }
+    }
+
+    /// Returns `true` if this is a timeout error.
+    #[inline]
+    pub const fn is_timeout(&self) -> bool {
+        matches!(self.code, ResponseCode::TimedOut)
+    }
+}
+
+impl fmt::Display for MctpError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "MCTP error: {}", self.code)
+    }
+}
+
+impl From<ResponseCode> for MctpError {
+    fn from(code: ResponseCode) -> Self {
+        MctpError::from_code(code)
+    }
+}
diff --git a/services/mctp/api/src/lib.rs b/services/mctp/api/src/lib.rs
new file mode 100644
index 0000000..ffe5b7c
--- /dev/null
+++ b/services/mctp/api/src/lib.rs
@@ -0,0 +1,66 @@
+// Licensed under the Apache-2.0 license
+
+//! # MCTP Service API
+//!
+//! This crate provides the client-side API for interacting with the MCTP service.
+//! It defines the types, traits, and error handling for MCTP operations.
+//!
+//! ## Architecture
+//!
+//! ```text
+//! ┌─────────────────────┐
+//! │   Application       │
+//! │  (echo, SPDM, etc.) │
+//! └─────────┬───────────┘
+//!           │ uses MctpClient trait
+//!           ▼
+//! ┌─────────────────────┐
+//! │   mctp-api           │◄── This crate
+//! │  (types & traits)    │
+//! └─────────┬───────────┘
+//!           │ IPC (implementation specific)
+//!           ▼
+//! ┌─────────────────────┐
+//! │   MCTP Server       │
+//! │  (transport layer)  │
+//! └─────────────────────┘
+//! ```
+//!
+//! ## Features
+//!
+//! - **Listener mode**: Receive incoming MCTP messages by type
+//! - **Request mode**: Send requests to a remote EID and receive responses
+//! - **Platform independent**: No OS-specific dependencies
+
+#![no_std]
+#![warn(missing_docs)]
+
+mod error;
+mod traits;
+pub mod wire;
+
+pub use error::{MctpError, ResponseCode};
+pub use traits::{MctpClient, MctpListener, MctpReqChannel, MctpRespChannel};
+
+/// An opaque handle for a listener, request, or response channel.
+///
+/// Handles are allocated by the MCTP server and must be released
+/// via `drop` when no longer needed.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+#[repr(transparent)]
+pub struct Handle(pub u32);
+
+/// Metadata returned by a successful receive operation.
+#[derive(Clone, Copy, Debug)]
+pub struct RecvMetadata {
+    /// MCTP message type.
+    pub msg_type: u8,
+    /// Whether an integrity check was present.
+    pub msg_ic: bool,
+    /// The tag value for correlating request/response pairs.
+    pub msg_tag: u8,
+    /// The source endpoint ID.
+    pub remote_eid: u8,
+    /// The size of the payload in bytes.
+    pub payload_size: usize,
+}
diff --git a/services/mctp/api/src/traits.rs b/services/mctp/api/src/traits.rs
new file mode 100644
index 0000000..7360c2b
--- /dev/null
+++ b/services/mctp/api/src/traits.rs
@@ -0,0 +1,93 @@
+// Licensed under the Apache-2.0 license
+
+//! MCTP client traits
+//!
+//! Platform-independent traits for interacting with an MCTP server.
+//! Implementations are provided per-platform (Hubris IPC, Linux sockets, etc.).
+
+use crate::{Handle, MctpError, RecvMetadata};
+
+/// A client interface to an MCTP stack/server.
+///
+/// Provides methods for obtaining listener and request handles, and
+/// for managing the endpoint ID (EID).
+pub trait MctpClient {
+    /// Obtain a request handle for sending messages to the given EID.
+    fn req(&self, eid: u8) -> Result<Handle, MctpError>;
+
+    /// Register a listener for incoming messages of the given MCTP type.
+    fn listener(&self, msg_type: u8) -> Result<Handle, MctpError>;
+
+    /// Get the local endpoint ID.
+    fn get_eid(&self) -> u8;
+
+    /// Set the local endpoint ID.
+    fn set_eid(&self, eid: u8) -> Result<(), MctpError>;
+
+    /// Receive a message on the given handle into `buf`.
+    ///
+    /// `timeout_millis` of 0 means no timeout (block indefinitely).
+    fn recv(
+        &self,
+        handle: Handle,
+        timeout_millis: u32,
+        buf: &mut [u8],
+    ) -> Result<RecvMetadata, MctpError>;
+
+    /// Send a message through the given handle.
+    ///
+    /// For requests, `handle` is `Some`. For responses, `handle` is `None`.
+    /// Returns the tag value used.
+    fn send(
+        &self,
+        handle: Option<Handle>,
+        msg_type: u8,
+        eid: Option<u8>,
+        tag: Option<u8>,
+        integrity_check: bool,
+        buf: &[u8],
+    ) -> Result<u8, MctpError>;
+
+    /// Release a handle previously obtained from `req` or `listener`.
+    fn drop_handle(&self, handle: Handle);
+}
+
+/// A listener that receives incoming MCTP messages of a specific type.
+pub trait MctpListener {
+    /// The response channel type returned when a message is received.
+    type RespChannel<'a>: MctpRespChannel
+    where
+        Self: 'a;
+
+    /// Wait for an incoming message, writing the payload into `buf`.
+    ///
+    /// Returns the message metadata, payload slice, and a response channel.
+    fn recv<'f>(
+        &mut self,
+        buf: &'f mut [u8],
+    ) -> Result<(RecvMetadata, &'f mut [u8], Self::RespChannel<'_>), MctpError>;
+}
+
+/// A request channel for sending MCTP requests and receiving responses.
+pub trait MctpReqChannel {
+    /// Send a request message.
+    fn send(&mut self, msg_type: u8, buf: &[u8]) -> Result<(), MctpError>;
+
+    /// Receive the response to a previously sent request.
+    fn recv<'f>(
+        &mut self,
+        buf: &'f mut [u8],
+    ) -> Result<(RecvMetadata, &'f mut [u8]), MctpError>;
+
+    /// The remote endpoint ID this channel targets.
+    fn remote_eid(&self) -> u8;
+}
+
+/// A response channel for replying to an incoming MCTP request.
+pub trait MctpRespChannel {
+    /// Send a response message.
+    fn send(&mut self, buf: &[u8]) -> Result<(), MctpError>;
+
+    /// The remote endpoint ID that sent the original request.
+    fn remote_eid(&self) -> u8;
+}
diff --git a/services/mctp/api/src/wire.rs b/services/mctp/api/src/wire.rs
new file mode 100644
index 0000000..1f8a8bb
--- /dev/null
+++ b/services/mctp/api/src/wire.rs
@@ -0,0 +1,670 @@
+// Licensed under the Apache-2.0 license
+
+//! MCTP IPC Wire Protocol
+//!
+//! Binary wire protocol for MCTP operations over IPC channels.
+//! Uses manual byte encoding for `no_std` compatibility.
+//!
+//! ## Wire Format
+//!
+//! ```text
+//! Request (12 bytes header + optional payload):
+//! ┌────┬───────┬──────────┬─────┬────────┬─────┬──────────┐
+//! │ op │ flags │ msg_type │ eid │ handle │ tag │ reserved │
+//! │ 1B │  1B   │   1B     │ 1B  │  4B LE │ 1B  │   3B     │
+//! └────┴───────┴──────────┴─────┴────────┴─────┴──────────┘
+//!
+//! Response (12 bytes header + optional payload):
+//! ┌──────┬───────┬──────────┬─────┬────────┬────────────┬─────┐
+//! │ code │ flags │ msg_type │ eid │ handle │ payload_len│ tag │  + [payload]
+//! │ 1B   │  1B   │   1B     │ 1B  │  4B LE │   2B LE    │ 1B  │
+//! └──────┴───────┴──────────┴─────┴────────┴────────────┴─────┘
+//! ```
+//!
+//! For `Recv` requests, the first 4 bytes of payload contain `timeout_millis` (u32 LE).
+//! For `Send` requests, the MCTP payload follows the header.
+
+use crate::ResponseCode;
+
+// ============================================================================
+// Wire Error
+// ============================================================================
+
+/// Error type for wire protocol encoding/decoding.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WireError {
+    /// Output buffer too small.
+    BufferTooSmall,
+    /// Payload exceeds maximum size.
+    PayloadTooLarge,
+    /// Unrecognized operation code.
+    InvalidOpcode(u8),
+    /// Input buffer too short for a complete header.
+    Truncated,
+}
+
+// ============================================================================
+// Operation Codes
+// ============================================================================
+
+/// MCTP IPC operation codes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[repr(u8)]
+pub enum MctpOp {
+    /// Set the local endpoint ID.
+    SetEid = 0,
+    /// Get the local endpoint ID.
+    GetEid = 1,
+    /// Register a listener for a message type.
+    Listener = 2,
+    /// Allocate a request handle for a remote EID.
+    Req = 3,
+    /// Receive a message on a handle.
+    Recv = 4,
+    /// Send a message.
+    Send = 5,
+    /// Release a handle.
+    Unbind = 6,
+}
+
+impl MctpOp {
+    /// Convert from raw byte.
+    pub fn from_u8(val: u8) -> Option<Self> {
+        match val {
+            0 => Some(Self::SetEid),
+            1 => Some(Self::GetEid),
+            2 => Some(Self::Listener),
+            3 => Some(Self::Req),
+            4 => Some(Self::Recv),
+            5 => Some(Self::Send),
+            6 => Some(Self::Unbind),
+            _ => None,
+        }
+    }
+}
+
+// ============================================================================
+// Request Flags
+// ============================================================================
+
+/// Request flag bits.
+pub mod flags {
+    /// Integrity check bit in flags byte.
+    pub const IC: u8 = 1 << 0;
+    /// Handle field is valid.
+    pub const HAS_HANDLE: u8 = 1 << 1;
+    /// EID field is valid.
+    pub const HAS_EID: u8 = 1 << 2;
+    /// Tag field is valid.
+    pub const HAS_TAG: u8 = 1 << 3;
+}
+
+// ============================================================================
+// Request Header
+// ============================================================================
+
+/// MCTP request header (12 bytes).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MctpRequestHeader {
+    /// Operation code.
+    pub op: u8,
+    /// Flags (see [`flags`] module).
+    pub flags: u8,
+    /// MCTP message type.
+    pub msg_type: u8,
+    /// Endpoint ID.
+    pub eid: u8,
+    /// Handle value.
+    pub handle: u32,
+    /// Tag value.
+    pub tag: u8,
+}
+
+impl MctpRequestHeader {
+    /// Header size in bytes.
+    pub const SIZE: usize = 12;
+
+    /// Encode to bytes (little-endian).
+    pub fn to_bytes(&self) -> [u8; Self::SIZE] {
+        let h = self.handle.to_le_bytes();
+        [
+            self.op,
+            self.flags,
+            self.msg_type,
+            self.eid,
+            h[0], h[1], h[2], h[3],
+            self.tag,
+            0, 0, 0, // reserved
+        ]
+    }
+
+    /// Decode from bytes.
+    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
+        if bytes.len() < Self::SIZE {
+            return None;
+        }
+        Some(Self {
+            op: bytes[0],
+            flags: bytes[1],
+            msg_type: bytes[2],
+            eid: bytes[3],
+            handle: u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
+            tag: bytes[8],
+        })
+    }
+
+    /// Get the operation code.
+    pub fn operation(&self) -> Option<MctpOp> {
+        MctpOp::from_u8(self.op)
+    }
+}
+
+// ============================================================================
+// Response Header
+// ============================================================================
+
+/// MCTP response header (12 bytes).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MctpResponseHeader {
+    /// Response code.
+    pub code: u8,
+    /// Flags (bit 0 = msg_ic).
+    pub flags: u8,
+    /// MCTP message type (for Recv responses).
+    pub msg_type: u8,
+    /// Remote endpoint ID (for Recv responses).
+    pub eid: u8,
+    /// Handle (for Listener/Req) or 0.
+    pub handle: u32,
+    /// Payload length (for Recv responses).
+    pub payload_len: u16,
+    /// Message tag (for Recv/Send responses).
+    pub tag: u8,
+}
+
+impl MctpResponseHeader {
+    /// Header size in bytes.
+    pub const SIZE: usize = 12;
+
+    /// Create a success response with no data.
+    pub const fn success() -> Self {
+        Self {
+            code: ResponseCode::Success as u8,
+            flags: 0,
+            msg_type: 0,
+            eid: 0,
+            handle: 0,
+            payload_len: 0,
+            tag: 0,
+        }
+    }
+
+    /// Create an error response.
+    pub const fn error(code: ResponseCode) -> Self {
+        Self {
+            code: code as u8,
+            flags: 0,
+            msg_type: 0,
+            eid: 0,
+            handle: 0,
+            payload_len: 0,
+            tag: 0,
+        }
+    }
+
+    /// Check if the response indicates success.
+    pub fn is_success(&self) -> bool {
+        self.code == ResponseCode::Success as u8
+    }
+
+    /// Get the response code.
+    pub fn response_code(&self) -> ResponseCode {
+        ResponseCode::from_u8(self.code).unwrap_or(ResponseCode::InternalError)
+    }
+
+    /// Encode to bytes (little-endian).
+    pub fn to_bytes(&self) -> [u8; Self::SIZE] {
+        let h = self.handle.to_le_bytes();
+        let pl = self.payload_len.to_le_bytes();
+        [
+            self.code,
+            self.flags,
+            self.msg_type,
+            self.eid,
+            h[0], h[1], h[2], h[3],
+            pl[0], pl[1],
+            self.tag,
+            0, // reserved
+        ]
+    }
+
+    /// Decode from bytes.
+    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
+        if bytes.len() < Self::SIZE {
+            return None;
+        }
+        Some(Self {
+            code: bytes[0],
+            flags: bytes[1],
+            msg_type: bytes[2],
+            eid: bytes[3],
+            handle: u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
+            payload_len: u16::from_le_bytes([bytes[8], bytes[9]]),
+            tag: bytes[10],
+        })
+    }
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/// Maximum MCTP payload size.
+pub const MAX_PAYLOAD_SIZE: usize = 1023;
+
+/// Maximum total request size (header + payload).
+pub const MAX_REQUEST_SIZE: usize = MctpRequestHeader::SIZE + MAX_PAYLOAD_SIZE;
+
+/// Maximum total response size (header + payload).
+pub const MAX_RESPONSE_SIZE: usize = MctpResponseHeader::SIZE + MAX_PAYLOAD_SIZE;
+
+/// Sentinel value for "no handle".
+pub const NO_HANDLE: u32 = 0xFFFF_FFFF;
+
+// ============================================================================
+// Request Encoding
+// ============================================================================
+
+/// Encode a `SetEid` request.
+pub fn encode_set_eid(buf: &mut [u8], eid: u8) -> Result<usize, WireError> {
+    if buf.len() < MctpRequestHeader::SIZE {
+        return Err(WireError::BufferTooSmall);
+    }
+    let header = MctpRequestHeader {
+        op: MctpOp::SetEid as u8,
+        flags: 0,
+        msg_type: 0,
+        eid,
+        handle: 0,
+        tag: 0,
+    };
+    buf[..MctpRequestHeader::SIZE].copy_from_slice(&header.to_bytes());
+    Ok(MctpRequestHeader::SIZE)
+}
+
+/// Encode a `GetEid` request.
+pub fn encode_get_eid(buf: &mut [u8]) -> Result<usize, WireError> {
+    if buf.len() < MctpRequestHeader::SIZE {
+        return Err(WireError::BufferTooSmall);
+    }
+    let header = MctpRequestHeader {
+        op: MctpOp::GetEid as u8,
+        flags: 0,
+        msg_type: 0,
+        eid: 0,
+        handle: 0,
+        tag: 0,
+    };
+    buf[..MctpRequestHeader::SIZE].copy_from_slice(&header.to_bytes());
+    Ok(MctpRequestHeader::SIZE)
+}
+
+/// Encode a `Listener` request.
+pub fn encode_listener(buf: &mut [u8], msg_type: u8) -> Result<usize, WireError> {
+    if buf.len() < MctpRequestHeader::SIZE {
+        return Err(WireError::BufferTooSmall);
+    }
+    let header = MctpRequestHeader {
+        op: MctpOp::Listener as u8,
+        flags: 0,
+        msg_type,
+        eid: 0,
+        handle: 0,
+        tag: 0,
+    };
+    buf[..MctpRequestHeader::SIZE].copy_from_slice(&header.to_bytes());
+    Ok(MctpRequestHeader::SIZE)
+}
+
+/// Encode a `Req` request.
+pub fn encode_req(buf: &mut [u8], eid: u8) -> Result<usize, WireError> {
+    if buf.len() < MctpRequestHeader::SIZE {
+        return Err(WireError::BufferTooSmall);
+    }
+    let header = MctpRequestHeader {
+        op: MctpOp::Req as u8,
+        flags: 0,
+        msg_type: 0,
+        eid,
+        handle: 0,
+        tag: 0,
+    };
+    buf[..MctpRequestHeader::SIZE].copy_from_slice(&header.to_bytes());
+    Ok(MctpRequestHeader::SIZE)
+}
+
+/// Encode a `Recv` request.
+pub fn encode_recv(buf: &mut [u8], handle: u32, timeout_millis: u32) -> Result<usize, WireError> {
+    let total = MctpRequestHeader::SIZE + 4;
+    if buf.len() < total {
+        return Err(WireError::BufferTooSmall);
+    }
+    let header = MctpRequestHeader {
+        op: MctpOp::Recv as u8,
+        flags: flags::HAS_HANDLE,
+        msg_type: 0,
+        eid: 0,
+        handle,
+        tag: 0,
+    };
+    buf[..MctpRequestHeader::SIZE].copy_from_slice(&header.to_bytes());
+    buf[MctpRequestHeader::SIZE..total].copy_from_slice(&timeout_millis.to_le_bytes());
+    Ok(total)
+}
+
+/// Encode a `Send` request.
+pub fn encode_send(
+    buf: &mut [u8],
+    handle: Option<u32>,
+    msg_type: u8,
+    eid: Option<u8>,
+    tag: Option<u8>,
+    ic: bool,
+    payload: &[u8],
+) -> Result<usize, WireError> {
+    if payload.len() > MAX_PAYLOAD_SIZE {
+        return Err(WireError::PayloadTooLarge);
+    }
+    let total = MctpRequestHeader::SIZE + payload.len();
+    if buf.len() < total {
+        return Err(WireError::BufferTooSmall);
+    }
+
+    let mut f: u8 = 0;
+    if ic {
+        f |= flags::IC;
+    }
+    if handle.is_some() {
+        f |= flags::HAS_HANDLE;
+    }
+    if eid.is_some() {
+        f |= flags::HAS_EID;
+    }
+    if tag.is_some() {
+        f |= flags::HAS_TAG;
+    }
+
+    let header = MctpRequestHeader {
+        op: MctpOp::Send as u8,
+        flags: f,
+        msg_type,
+        eid: eid.unwrap_or(0),
+        handle: handle.unwrap_or(NO_HANDLE),
+        tag: tag.unwrap_or(0),
+    };
+    buf[..MctpRequestHeader::SIZE].copy_from_slice(&header.to_bytes());
+    buf[MctpRequestHeader::SIZE..total].copy_from_slice(payload);
+    Ok(total)
+}
+
+/// Encode an `Unbind` request.
+pub fn encode_unbind(buf: &mut [u8], handle: u32) -> Result<usize, WireError> {
+    if buf.len() < MctpRequestHeader::SIZE {
+        return Err(WireError::BufferTooSmall);
+    }
+    let header = MctpRequestHeader {
+        op: MctpOp::Unbind as u8,
+        flags: flags::HAS_HANDLE,
+        msg_type: 0,
+        eid: 0,
+        handle,
+        tag: 0,
+    };
+    buf[..MctpRequestHeader::SIZE].copy_from_slice(&header.to_bytes());
+    Ok(MctpRequestHeader::SIZE)
+}
+
+// ============================================================================
+// Response Encoding (server side)
+// ============================================================================
+
+/// Encode a success response for `GetEid`.
+pub fn encode_get_eid_response(buf: &mut [u8], eid: u8) -> Result<usize, WireError> {
+    if buf.len() < MctpResponseHeader::SIZE {
+        return Err(WireError::BufferTooSmall);
+    }
+    let mut resp = MctpResponseHeader::success();
+    resp.eid = eid;
+    buf[..MctpResponseHeader::SIZE].copy_from_slice(&resp.to_bytes());
+    Ok(MctpResponseHeader::SIZE)
+}
+
+/// Encode a success response for `Listener` or `Req` (returns a handle).
+pub fn encode_handle_response(buf: &mut [u8], handle: u32) -> Result<usize, WireError> {
+    if buf.len() < MctpResponseHeader::SIZE {
+        return Err(WireError::BufferTooSmall);
+    }
+    let mut resp = MctpResponseHeader::success();
+    resp.handle = handle;
+    buf[..MctpResponseHeader::SIZE].copy_from_slice(&resp.to_bytes());
+    Ok(MctpResponseHeader::SIZE)
+}
+
+/// Encode a success response for `Send` (returns the tag).
+pub fn encode_send_response(buf: &mut [u8], tag: u8) -> Result<usize, WireError> {
+    if buf.len() < MctpResponseHeader::SIZE {
+        return Err(WireError::BufferTooSmall);
+    }
+    let mut resp = MctpResponseHeader::success();
+    resp.tag = tag;
+    buf[..MctpResponseHeader::SIZE].copy_from_slice(&resp.to_bytes());
+    Ok(MctpResponseHeader::SIZE)
+}
+
+/// Encode a success response for `Recv` (returns metadata + payload).
+pub fn encode_recv_response(
+    buf: &mut [u8],
+    msg_type: u8,
+    msg_ic: bool,
+    eid: u8,
+    tag: u8,
+    payload: &[u8],
+) -> Result<usize, WireError> {
+    if payload.len() > MAX_PAYLOAD_SIZE {
+        return Err(WireError::PayloadTooLarge);
+    }
+    let total = MctpResponseHeader::SIZE + payload.len();
+    if buf.len() < total {
+        return Err(WireError::BufferTooSmall);
+    }
+    let resp = MctpResponseHeader {
+        code: ResponseCode::Success as u8,
+        flags: if msg_ic { flags::IC } else { 0 },
+        msg_type,
+        eid,
+        handle: 0,
+        payload_len: payload.len() as u16,
+        tag,
+    };
+    buf[..MctpResponseHeader::SIZE].copy_from_slice(&resp.to_bytes());
+    buf[MctpResponseHeader::SIZE..total].copy_from_slice(payload);
+    Ok(total)
+}
+
+/// Encode a simple success response (no data).
+pub fn encode_success_response(buf: &mut [u8]) -> Result<usize, WireError> {
+    if buf.len() < MctpResponseHeader::SIZE {
+        return Err(WireError::BufferTooSmall);
+    }
+    buf[..MctpResponseHeader::SIZE].copy_from_slice(&MctpResponseHeader::success().to_bytes());
+    Ok(MctpResponseHeader::SIZE)
+}
+
+/// Encode an error response.
+pub fn encode_error_response(buf: &mut [u8], code: ResponseCode) -> Result<usize, WireError> {
+    if buf.len() < MctpResponseHeader::SIZE {
+        return Err(WireError::BufferTooSmall);
+    }
+    buf[..MctpResponseHeader::SIZE].copy_from_slice(&MctpResponseHeader::error(code).to_bytes());
+    Ok(MctpResponseHeader::SIZE)
+}
+
+// ============================================================================
+// Response Decoding (client side)
+// ============================================================================
+
+/// Decode a response header.
+pub fn decode_response_header(buf: &[u8]) -> Result<MctpResponseHeader, WireError> {
+    MctpResponseHeader::from_bytes(buf).ok_or(WireError::Truncated)
+}
+
+/// Get response payload data (after header).
+pub fn get_response_payload<'a>(buf: &'a [u8], header: &MctpResponseHeader) -> Result<&'a [u8], WireError> {
+    let end = MctpResponseHeader::SIZE + header.payload_len as usize;
+    if buf.len() < end {
+        return Err(WireError::Truncated);
+    }
+    Ok(&buf[MctpResponseHeader::SIZE..end])
+}
+
+/// Decode a request header.
+pub fn decode_request_header(buf: &[u8]) -> Result<MctpRequestHeader, WireError> {
+    MctpRequestHeader::from_bytes(buf).ok_or(WireError::Truncated)
+}
+
+/// Get request payload (after header, for Send operations).
+pub fn get_request_payload(buf: &[u8]) -> &[u8] {
+    if buf.len() > MctpRequestHeader::SIZE {
+        &buf[MctpRequestHeader::SIZE..]
+    } else {
+        &[]
+    }
+}
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn request_header_roundtrip() {
+        let header = MctpRequestHeader {
+            op: MctpOp::Send as u8,
+            flags: flags::HAS_HANDLE | flags::IC,
+            msg_type: 1,
+            eid: 42,
+            handle: 0x1234,
+            tag: 7,
+        };
+        let bytes = header.to_bytes();
+        let decoded = MctpRequestHeader::from_bytes(&bytes).unwrap();
+
+        assert_eq!(decoded.op, MctpOp::Send as u8);
+        assert_eq!(decoded.flags, flags::HAS_HANDLE | flags::IC);
+        assert_eq!(decoded.msg_type, 1);
+        assert_eq!(decoded.eid, 42);
+        assert_eq!(decoded.handle, 0x1234);
+        assert_eq!(decoded.tag, 7);
+    }
+
+    #[test]
+    fn response_header_roundtrip() {
+        let header = MctpResponseHeader {
+            code: ResponseCode::Success as u8,
+            flags: flags::IC,
+            msg_type: 1,
+            eid: 8,
+            handle: 0,
+            payload_len: 16,
+            tag: 3,
+        };
+        let bytes = header.to_bytes();
+        let decoded = MctpResponseHeader::from_bytes(&bytes).unwrap();
+
+        assert!(decoded.is_success());
+        assert_eq!(decoded.flags & flags::IC, flags::IC);
+        assert_eq!(decoded.msg_type, 1);
+        assert_eq!(decoded.eid, 8);
+        assert_eq!(decoded.payload_len, 16);
+        assert_eq!(decoded.tag, 3);
+    }
+
+    #[test]
+    fn encode_send_roundtrip() {
+        let mut buf = [0u8; 64];
+        let payload = b"hello";
+        let len = encode_send(&mut buf, Some(5), 1, Some(8), Some(3), true, payload).unwrap();
+
+        let header = MctpRequestHeader::from_bytes(&buf).unwrap();
+        assert_eq!(header.operation(), Some(MctpOp::Send));
+        assert_eq!(header.flags & flags::IC, flags::IC);
+        assert_eq!(header.flags & flags::HAS_HANDLE, flags::HAS_HANDLE);
+        assert_eq!(header.flags & flags::HAS_EID, flags::HAS_EID);
+        assert_eq!(header.flags & flags::HAS_TAG, flags::HAS_TAG);
+        assert_eq!(header.msg_type, 1);
+        assert_eq!(header.eid, 8);
+        assert_eq!(header.handle, 5);
+        assert_eq!(header.tag, 3);
+        assert_eq!(&buf[MctpRequestHeader::SIZE..len], b"hello");
+    }
+
+    #[test]
+    fn encode_recv_roundtrip() {
+        let mut buf = [0u8; 32];
+        let len = encode_recv(&mut buf, 7, 5000).unwrap();
+        assert_eq!(len, MctpRequestHeader::SIZE + 4);
+
+        let header = MctpRequestHeader::from_bytes(&buf).unwrap();
+        assert_eq!(header.operation(), Some(MctpOp::Recv));
+        assert_eq!(header.handle, 7);
+
+        let timeout = u32::from_le_bytes([
+            buf[MctpRequestHeader::SIZE],
+            buf[MctpRequestHeader::SIZE + 1],
+            buf[MctpRequestHeader::SIZE + 2],
+            buf[MctpRequestHeader::SIZE + 3],
+        ]);
+        assert_eq!(timeout, 5000);
+    }
+
+    #[test]
+    fn error_response() {
+        let mut buf = [0u8; 16];
+        let len = encode_error_response(&mut buf, ResponseCode::NoSpace).unwrap();
+        assert_eq!(len, MctpResponseHeader::SIZE);
+
+        let header = decode_response_header(&buf).unwrap();
+        assert!(!header.is_success());
+        assert_eq!(header.response_code(), ResponseCode::NoSpace);
+    }
+
+    #[test]
+    fn recv_response_with_payload() {
+        let mut buf = [0u8; 64];
+        let payload = b"echo data";
+        let len = encode_recv_response(&mut buf, 1, false, 42, 5, payload).unwrap();
+
+        let header = decode_response_header(&buf).unwrap();
+        assert!(header.is_success());
+        assert_eq!(header.msg_type, 1);
+        assert_eq!(header.eid, 42);
+        assert_eq!(header.tag, 5);
+        assert_eq!(header.payload_len, 9);
+
+        let data = get_response_payload(&buf[..len], &header).unwrap();
+        assert_eq!(data, b"echo data");
+    }
+
+    #[test]
+    fn send_payload_too_large() {
+        let mut buf = [0u8; 2048];
+        let oversized = [0u8; MAX_PAYLOAD_SIZE + 1];
+        assert_eq!(
+            encode_send(&mut buf, None, 1, None, None, false, &oversized),
+            Err(WireError::PayloadTooLarge)
+        );
+    }
+}
diff --git a/services/mctp/client/Cargo.toml b/services/mctp/client/Cargo.toml
new file mode 100644
index 0000000..7de8400
--- /dev/null
+++ b/services/mctp/client/Cargo.toml
@@ -0,0 +1,11 @@
+# Licensed under the Apache-2.0 license
+
+[package]
+name = "openprot-mctp-client"
+version = "0.1.0"
+edition = "2021"
+description = "MCTP IPC client for OpenPRoT (Pigweed userspace)"
+license = "Apache-2.0"
+
+[dependencies]
+openprot-mctp-api = { path = "../api" }
diff --git a/services/mctp/client/README.md b/services/mctp/client/README.md
new file mode 100644
index 0000000..17c789b
--- /dev/null
+++ b/services/mctp/client/README.md
@@ -0,0 +1,21 @@
+# openprot-mctp-client
+
+IPC client for the MCTP server, used by userspace applications under Pigweed (`pw_kernel`).
+
+## Overview
+
+This crate provides `IpcMctpClient`, which implements the `MctpClient` trait from `openprot-mctp-api`. It communicates with the MCTP server process over a Pigweed IPC channel using the binary wire protocol defined in `openprot-mctp-api`.
+
+Applications (such as the MCTP echo task) use this crate to interact with the MCTP server without needing to know about transport details.
+
+## Key Types
+
+- `IpcMctpClient` — implements `MctpClient` via Pigweed IPC; uses `RefCell` for interior mutability so trait methods taking `&self` can mutate internal IPC buffers
+
+## Status
+
+The `send_recv` method is currently stubbed, pending Phase 6 integration with `syscall::channel_transact`.
+
+## Dependencies
+
+- `openprot-mctp-api` — `MctpClient` trait and wire protocol
diff --git a/services/mctp/client/src/lib.rs b/services/mctp/client/src/lib.rs
new file mode 100644
index 0000000..4eeaa11
--- /dev/null
+++ b/services/mctp/client/src/lib.rs
@@ -0,0 +1,215 @@
+// Licensed under the Apache-2.0 license
+
+//! MCTP IPC Client
+//!
+//! Provides an `MctpClient` implementation that communicates with the MCTP
+//! server over a Pigweed IPC channel, using the wire protocol from
+//! `openprot-mctp-api`.
+//!
+//! This is the MCTP equivalent of `i2c_client::IpcI2cClient`.
+//!
+//! ## Usage
+//!
+//! ```rust,ignore
+//! use openprot_mctp_client::IpcMctpClient;
+//! use openprot_mctp_api::MctpClient;
+//!
+//! let client = IpcMctpClient::new(handle::MCTP);
+//!
+//! client.set_eid(8).unwrap();
+//! let listener = client.listener(1).unwrap();
+//! let meta = client.recv(listener, 0, &mut buf).unwrap();
+//! ```
+
+#![no_std]
+#![warn(missing_docs)]
+
+use core::cell::RefCell;
+
+use openprot_mctp_api::wire::{self, MctpResponseHeader, MAX_REQUEST_SIZE, MAX_RESPONSE_SIZE};
+use openprot_mctp_api::{Handle, MctpClient, MctpError, RecvMetadata, ResponseCode};
+
+/// Internal mutable state for the IPC client.
+struct ClientBuffers {
+    request_buf: [u8; MAX_REQUEST_SIZE],
+    response_buf: [u8; MAX_RESPONSE_SIZE],
+}
+
+/// MCTP client that communicates with the MCTP server over Pigweed IPC.
+///
+/// Uses `RefCell` for interior mutability so that `MctpClient` trait
+/// methods (which take `&self`) can mutate the internal IPC buffers.
+/// This matches the Hubris pattern where IPC calls are logically
+/// stateless from the caller's perspective.
+pub struct IpcMctpClient {
+    handle: u32,
+    inner: RefCell<ClientBuffers>,
+}
+
+impl IpcMctpClient {
+    /// Create a new IPC MCTP client bound to the given channel handle.
+    ///
+    /// The handle comes from the application's `app_package`-generated
+    /// handle module (e.g., `handle::MCTP`).
+    pub fn new(handle: u32) -> Self {
+        Self {
+            handle,
+            inner: RefCell::new(ClientBuffers {
+                request_buf: [0u8; MAX_REQUEST_SIZE],
+                response_buf: [0u8; MAX_RESPONSE_SIZE],
+            }),
+        }
+    }
+
+    /// Get the IPC channel handle.
+    pub fn channel_handle(&self) -> u32 {
+        self.handle
+    }
+
+    /// Encode, send, and decode a transaction.
+    fn transact(&self, req_len: usize) -> Result<(MctpResponseHeader, usize), MctpError> {
+        let resp_len = self.send_recv(req_len)?;
+        let inner = self.inner.borrow();
+
+        if resp_len < MctpResponseHeader::SIZE {
+            return Err(MctpError::from_code(ResponseCode::InternalError));
+        }
+
+        let header = wire::decode_response_header(&inner.response_buf[..resp_len])
+            .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?;
+
+        if !header.is_success() {
+            return Err(MctpError::from_code(header.response_code()));
+        }
+
+        Ok((header, resp_len))
+    }
+
+    /// Platform-specific send/receive.
+    ///
+    /// In a Pigweed build, this calls `syscall::channel_transact`.
+    /// For now, this is a stub that returns an error.
+    fn send_recv(&self, _req_len: usize) -> Result<usize, MctpError> {
+        // TODO(Phase 6): Replace with:
+        //   let mut inner = self.inner.borrow_mut();
+        //   syscall::channel_transact(
+        //       self.handle,
+        //       &inner.request_buf[..req_len],
+        //       &mut inner.response_buf,
+        //       Instant::MAX,
+        //   ).map_err(|_| MctpError::from_code(ResponseCode::InternalError))
+        Err(MctpError::from_code(ResponseCode::InternalError))
+    }
+}
+
+impl MctpClient for IpcMctpClient {
+    fn req(&self, eid: u8) -> Result<Handle, MctpError> {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            wire::encode_req(&mut inner.request_buf, eid)
+                .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?
+        };
+        let (header, _) = self.transact(req_len)?;
+        Ok(Handle(header.handle))
+    }
+
+    fn listener(&self, msg_type: u8) -> Result<Handle, MctpError> {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            wire::encode_listener(&mut inner.request_buf, msg_type)
+                .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?
+        };
+        let (header, _) = self.transact(req_len)?;
+        Ok(Handle(header.handle))
+    }
+
+    fn get_eid(&self) -> u8 {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            match wire::encode_get_eid(&mut inner.request_buf) {
+                Ok(len) => len,
+                Err(_) => return 0,
+            }
+        };
+        match self.transact(req_len) {
+            Ok((header, _)) => header.eid,
+            Err(_) => 0,
+        }
+    }
+
+    fn set_eid(&self, eid: u8) -> Result<(), MctpError> {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            wire::encode_set_eid(&mut inner.request_buf, eid)
+                .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?
+        };
+        self.transact(req_len)?;
+        Ok(())
+    }
+
+    fn recv(
+        &self,
+        handle: Handle,
+        timeout_millis: u32,
+        buf: &mut [u8],
+    ) -> Result<RecvMetadata, MctpError> {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            wire::encode_recv(&mut inner.request_buf, handle.0, timeout_millis)
+                .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?
+        };
+        let (header, resp_len) = self.transact(req_len)?;
+
+        let inner = self.inner.borrow();
+        let payload = wire::get_response_payload(&inner.response_buf[..resp_len], &header)
+            .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?;
+
+        let copy_len = core::cmp::min(payload.len(), buf.len());
+        buf[..copy_len].copy_from_slice(&payload[..copy_len]);
+
+        Ok(RecvMetadata {
+            msg_type: header.msg_type,
+            msg_ic: header.flags & wire::flags::IC != 0,
+            msg_tag: header.tag,
+            remote_eid: header.eid,
+            payload_size: payload.len(),
+        })
+    }
+
+    fn send(
+        &self,
+        handle: Option<Handle>,
+        msg_type: u8,
+        eid: Option<u8>,
+        tag: Option<u8>,
+        integrity_check: bool,
+        buf: &[u8],
+    ) -> Result<u8, MctpError> {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            wire::encode_send(
+                &mut inner.request_buf,
+                handle.map(|h| h.0),
+                msg_type,
+                eid,
+                tag,
+                integrity_check,
+                buf,
+            )
+            .map_err(|_| MctpError::from_code(ResponseCode::InternalError))?
+        };
+        let (header, _) = self.transact(req_len)?;
+        Ok(header.tag)
+    }
+
+    fn drop_handle(&self, handle: Handle) {
+        let req_len = {
+            let mut inner = self.inner.borrow_mut();
+            match wire::encode_unbind(&mut inner.request_buf, handle.0) {
+                Ok(len) => len,
+                Err(_) => return,
+            }
+        };
+        let _ = self.transact(req_len);
+    }
+}
diff --git a/services/mctp/server/Cargo.toml b/services/mctp/server/Cargo.toml
new file mode 100644
index 0000000..73a7acf
--- /dev/null
+++ b/services/mctp/server/Cargo.toml
@@ -0,0 +1,18 @@
+# Licensed under the Apache-2.0 license
+
+[package]
+name = "openprot-mctp-server"
+version = "0.1.0"
+edition = "2021"
+description = "MCTP server implementation for OpenPRoT"
+license = "Apache-2.0"
+
+[dependencies]
+openprot-mctp-api = { path = "../api" }
+mctp-stack = { git = "https://github.com/9elements/mctp-lib.git", branch = "buildup", package = "mctp-lib" }
+mctp = { git = "https://github.com/OpenPRoT/mctp-rs.git", branch = "sync-features", default-features = false }
+heapless = { workspace = true }
+
+[dev-dependencies]
+mctp-stack = { git = "https://github.com/9elements/mctp-lib.git", branch = "buildup", package = "mctp-lib" }
+mctp = { git = "https://github.com/OpenPRoT/mctp-rs.git", branch = "sync-features" }
diff --git a/services/mctp/server/README.md b/services/mctp/server/README.md
new file mode 100644
index 0000000..fcd534d
--- /dev/null
+++ b/services/mctp/server/README.md
@@ -0,0 +1,28 @@
+# openprot-mctp-server
+
+Platform-independent MCTP server core, ported from the Hubris `mctp-server` crate.
+
+## Overview
+
+This crate implements the central MCTP server logic: listener and request handle allocation, inbound message routing, outbound message fragmentation/sending, and timeout management for pending receive calls. It is generic over transport bindings via the `mctp_stack::Sender` trait.
+
+## Key Types
+
+- `Server<S, N>` — the MCTP server, generic over `Sender` (transport) and `N` (config)
+- `ServerConfig` — configuration constants (MAX_REQUESTS: 8, MAX_LISTENERS: 8, MAX_OUTSTANDING: 16, MAX_PAYLOAD: 1023)
+- `RecvResult` — result from a receive operation
+
+## Modules
+
+- `dispatch` — IPC request dispatcher; decodes wire-protocol requests and calls the corresponding `Server` methods
+
+## Architecture
+
+The server wraps the `mctp-lib` `Router` as its packet processing engine. Inbound packets are fed via `Server::inbound()`, and `Server::update()` drives pending-recv fulfillment. The `dispatch` module bridges IPC wire-protocol messages to server operations.
+
+## Dependencies
+
+- `openprot-mctp-api` — API traits and wire protocol
+- `mctp-stack` (`mctp-lib`) — `Router`, `Sender`, fragmentation, serial/I2C handlers
+- `mctp` — core MCTP types (`Eid`, `MsgType`, `Tag`)
+- `heapless` — `no_std` collections
diff --git a/services/mctp/server/src/dispatch.rs b/services/mctp/server/src/dispatch.rs
new file mode 100644
index 0000000..1fba65a
--- /dev/null
+++ b/services/mctp/server/src/dispatch.rs
@@ -0,0 +1,127 @@
+// Licensed under the Apache-2.0 license
+
+//! 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..118bfd8
--- /dev/null
+++ b/services/mctp/server/src/lib.rs
@@ -0,0 +1,34 @@
+// Licensed under the Apache-2.0 license
+
+//! # 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_stack::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_stack::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_stack::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..5f63256
--- /dev/null
+++ b/services/mctp/server/src/server.rs
@@ -0,0 +1,293 @@
+// Licensed under the Apache-2.0 license
+
+//! 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_stack::{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/dispatch.rs b/services/mctp/server/tests/dispatch.rs
new file mode 100644
index 0000000..1e09dfb
--- /dev/null
+++ b/services/mctp/server/tests/dispatch.rs
@@ -0,0 +1,174 @@
+// Licensed under the Apache-2.0 license
+
+//! Wire-protocol dispatch integration test.
+//!
+//! Exercises the full IPC path: encode request → `dispatch_mctp_op` → decode
+//! response. This verifies that the wire protocol + dispatch layer + Server
+//! work together correctly, simulating what happens when `IpcMctpClient`
+//! talks to the MCTP server process over a Pigweed IPC channel.
+
+use std::cell::RefCell;
+
+use mctp::{Eid, Tag};
+use mctp_stack::fragment::{Fragmenter, SendOutput};
+use mctp_stack::Sender;
+use openprot_mctp_api::wire;
+use openprot_mctp_server::{dispatch::dispatch_mctp_op, Server};
+
+// ---------------------------------------------------------------------------
+// Mock transport (same as echo.rs)
+// ---------------------------------------------------------------------------
+
+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
+// ---------------------------------------------------------------------------
+
+/// 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);
+}
diff --git a/services/mctp/server/tests/echo.rs b/services/mctp/server/tests/echo.rs
new file mode 100644
index 0000000..183b8ea
--- /dev/null
+++ b/services/mctp/server/tests/echo.rs
@@ -0,0 +1,272 @@
+// Licensed under the Apache-2.0 license
+
+//! MCTP echo integration test.
+//!
+//! This test exercises the full MCTP server stack with a mock transport,
+//! replicating the Hubris `mctp-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. This mirrors the
+//! real deployment where the echo task is an IPC client of the MCTP server.
+
+use std::cell::RefCell;
+
+use mctp::{Eid, Tag};
+use mctp_stack::fragment::{Fragmenter, SendOutput};
+use mctp_stack::Sender;
+use openprot_mctp_api::{Handle, MctpClient, MctpError, RecvMetadata, ResponseCode};
+use openprot_mctp_server::Server;
+
+// ---------------------------------------------------------------------------
+// Mock transport
+// ---------------------------------------------------------------------------
+
+/// 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)
+// ---------------------------------------------------------------------------
+
+/// Echo one message: receive on the listener, send the payload back.
+///
+/// This is the same logic as `hubris/task/mctp-echo/src/main.rs`:
+/// ```ignore
+/// let (_, _, msg, mut resp) = listener.recv(&mut recv_buf).unwrap_lite();
+/// resp.send(msg).unwrap();
+/// ```
+/// but expressed through the `MctpClient` trait instead of Hubris IPC types.
+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 the Hubris mctp-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)
+            .unwrap_or_else(|_| panic!("iteration {i}: no response"));
+        assert_eq!(&resp_buf[..resp.payload_size], &msg);
+    }
+
+    client_a.drop_handle(listener);
+    client_b.drop_handle(req);
+}
diff --git a/services/mctp/transport-i2c/Cargo.toml b/services/mctp/transport-i2c/Cargo.toml
new file mode 100644
index 0000000..2894858
--- /dev/null
+++ b/services/mctp/transport-i2c/Cargo.toml
@@ -0,0 +1,16 @@
+# Licensed under the Apache-2.0 license
+
+[package]
+name = "openprot-mctp-transport-i2c"
+version = "0.1.0"
+edition = "2021"
+description = "MCTP over I2C transport binding for OpenPRoT"
+license = "Apache-2.0"
+
+[dependencies]
+openprot-mctp-api = { path = "../api" }
+openprot-i2c-api = { path = "../../i2c/api" }
+mctp-stack = { git = "https://github.com/9elements/mctp-lib.git", branch = "buildup", package = "mctp-lib" }
+mctp = { git = "https://github.com/OpenPRoT/mctp-rs.git", branch = "sync-features", default-features = false }
+heapless = { workspace = true }
+embedded-hal = { workspace = true }
diff --git a/services/mctp/transport-i2c/README.md b/services/mctp/transport-i2c/README.md
new file mode 100644
index 0000000..73da720
--- /dev/null
+++ b/services/mctp/transport-i2c/README.md
@@ -0,0 +1,21 @@
+# openprot-mctp-transport-i2c
+
+I2C transport binding for the MCTP server, ported from Hubris `mctp-server/src/i2c.rs`.
+
+## Overview
+
+This crate implements MCTP-over-I2C transport. It provides the `Sender` implementation for outbound packets and a receiver/decoder for inbound I2C target messages. It uses the OpenPRoT `services/i2c/` userspace driver as the underlying I2C transport, replacing the Hubris `drv_i2c_api::I2cDevice`.
+
+## Key Types
+
+- `I2cSender<C>` — implements `mctp_stack::Sender` for I2C; handles fragmentation, encoding, and PEC (Packet Error Checking) via `mctp_stack::i2c::MctpI2cEncap`
+- `MctpI2cReceiver` — decodes inbound I2C target-mode messages into MCTP packets
+
+## Dependencies
+
+- `openprot-mctp-api` — API traits
+- `openprot-i2c-api` — `I2cClientBlocking` trait for I2C bus access
+- `mctp-stack` — `Sender` trait, I2C encapsulation/decapsulation
+- `mctp` — core MCTP types
+- `embedded-hal` — hardware abstraction
+- `heapless` — `no_std` collections
diff --git a/services/mctp/transport-i2c/src/lib.rs b/services/mctp/transport-i2c/src/lib.rs
new file mode 100644
index 0000000..d8a1448
--- /dev/null
+++ b/services/mctp/transport-i2c/src/lib.rs
@@ -0,0 +1,26 @@
+// Licensed under the Apache-2.0 license
+
+//! # MCTP over I2C Transport Binding
+//!
+//! This crate provides the I2C transport binding for the MCTP server,
+//! ported from the Hubris `mctp-server/src/i2c.rs`.
+//!
+//! It implements [`mctp_stack::Sender`] for outbound MCTP-over-I2C packets
+//! and provides [`MctpI2cReceiver`] for decoding inbound I2C target messages
+//! into MCTP packets.
+//!
+//! ## Changes from Hubris
+//!
+//! - Hubris `drv_i2c_api::I2cDevice` → OpenPRoT `I2cClientBlocking` trait
+//! - Hubris `TaskId` → generic `I2cClientBlocking` implementor
+//! - All MCTP protocol logic (encoding, fragmentation, PEC) preserved as-is
+//!   via `mctp_stack::i2c::MctpI2cEncap`
+
+#![no_std]
+#![warn(missing_docs)]
+
+mod receiver;
+mod sender;
+
+pub use receiver::MctpI2cReceiver;
+pub use sender::I2cSender;
diff --git a/services/mctp/transport-i2c/src/receiver.rs b/services/mctp/transport-i2c/src/receiver.rs
new file mode 100644
index 0000000..d7107e8
--- /dev/null
+++ b/services/mctp/transport-i2c/src/receiver.rs
@@ -0,0 +1,48 @@
+// Licensed under the Apache-2.0 license
+
+//! I2C MCTP receiver — inbound transport binding.
+//!
+//! Decodes incoming I2C target-mode messages into raw MCTP packets
+//! that can be fed to `Server::inbound()`.
+//!
+//! This corresponds to the `handle_i2c_transport` function in Hubris
+//! `mctp-server/src/main.rs`, using `mctp_stack::i2c::MctpI2cEncap`
+//! for decoding (same as Hubris).
+
+use mctp_stack::i2c::MctpI2cEncap;
+use openprot_i2c_api::TargetMessage;
+
+/// Decodes I2C target messages into raw MCTP packets.
+///
+/// Wraps the `mctp_stack::i2c::MctpI2cEncap` decoder. One instance
+/// should exist per I2C bus carrying MCTP traffic.
+pub struct MctpI2cReceiver {
+    encap: MctpI2cEncap,
+}
+
+impl MctpI2cReceiver {
+    /// Create a new receiver for the given own I2C address.
+    pub fn new(own_addr: u8) -> Self {
+        Self {
+            encap: MctpI2cEncap::new(own_addr),
+        }
+    }
+
+    /// Decode an I2C target message into a raw MCTP packet.
+    ///
+    /// Strips the MCTP-I2C transport header and validates PEC.
+    /// Returns the raw MCTP packet bytes (suitable for `Server::inbound()`)
+    /// and the I2C source address, or an error if decoding fails.
+    ///
+    /// This is the same decode path as Hubris `handle_i2c_transport`:
+    /// `i2c_reader.recv(data)` → `server.stack.inbound(pkt)`.
+    pub fn decode<'a>(
+        &self,
+        msg: &'a TargetMessage,
+    ) -> Result<(&'a [u8], u8), mctp::Error> {
+        let data = msg.data();
+        // MctpI2cEncap::decode strips the I2C header, validates PEC,
+        // and returns the raw MCTP packet + source I2C address.
+        self.encap.decode(data, true)
+    }
+}
diff --git a/services/mctp/transport-i2c/src/sender.rs b/services/mctp/transport-i2c/src/sender.rs
new file mode 100644
index 0000000..90d01fc
--- /dev/null
+++ b/services/mctp/transport-i2c/src/sender.rs
@@ -0,0 +1,86 @@
+// Licensed under the Apache-2.0 license
+
+//! I2C MCTP sender — outbound transport binding.
+//!
+//! Direct port of Hubris `mctp-server/src/i2c.rs` `I2cSender`.
+//! Only the I2C driver API is replaced: `drv_i2c_api::I2cDevice` →
+//! `openprot_i2c_api::I2cClientBlocking`.
+
+use mctp::Result;
+use mctp_stack::i2c::{MctpI2cEncap, MCTP_I2C_MAXMTU};
+use openprot_i2c_api::{BusIndex, I2cAddress, I2cClientBlocking};
+
+/// I2C MCTP sender.
+///
+/// Implements `mctp_stack::Sender` to fragment and send MCTP packets
+/// over I2C using the OpenPRoT I2C client API.
+///
+/// This is a direct port of the Hubris `I2cSender`. The fragmentation
+/// loop, I2C encoding via `MctpI2cEncap`, and error mapping are preserved
+/// as-is. Only the I2C write call is changed from `drv_i2c_api::I2cDevice::write`
+/// to `I2cClientBlocking::write`.
+pub struct I2cSender<C: I2cClientBlocking> {
+    i2c: C,
+    bus: BusIndex,
+    own_addr: u8,
+    // TODO: neighbor table mapping EID → I2C address
+    //       see https://github.com/OpenPRoT/mctp-lib/issues/4
+    //       For now, destination address is hardcoded (same as Hubris).
+}
+
+impl<C: I2cClientBlocking> I2cSender<C> {
+    /// Create a new I2C sender.
+    ///
+    /// * `i2c` - I2C client for bus writes
+    /// * `bus` - I2C bus index to use
+    /// * `own_addr` - Own I2C address (7-bit, used in MCTP-I2C header)
+    pub fn new(i2c: C, bus: BusIndex, own_addr: u8) -> Self {
+        Self {
+            i2c,
+            bus,
+            own_addr,
+        }
+    }
+}
+
+impl<C: I2cClientBlocking> mctp_stack::Sender for I2cSender<C> {
+    fn send_vectored(
+        &mut self,
+        mut fragmenter: mctp_stack::fragment::Fragmenter,
+        payload: &[&[u8]],
+    ) -> Result<mctp::Tag> {
+        // TODO The stack needs to provide the destination EID to the sender
+        // let addr = self
+        //     .neighbor_table
+        //     .get(eid)
+        //     .ok_or(mctp::Error::Unreachable)?;
+        let addr = 0x42;
+        let dest_address = I2cAddress::new_unchecked(addr);
+        let encoder = MctpI2cEncap::new(self.own_addr);
+
+        loop {
+            let mut pkt = [0u8; mctp_stack::serial::MTU_MAX];
+            let r = fragmenter.fragment_vectored(payload, &mut pkt);
+
+            match r {
+                mctp_stack::fragment::SendOutput::Packet(p) => {
+                    let mut out = [0; MCTP_I2C_MAXMTU + 8]; // max MTU + I2C header size
+                    let _packet = encoder.encode(addr, p, &mut out, true)?;
+                    self.i2c
+                        .write(self.bus, dest_address, &out)
+                        .map_err(|_| mctp::Error::TxFailure)?;
+                }
+                mctp_stack::fragment::SendOutput::Complete { tag, .. } => {
+                    break Ok(tag);
+                }
+                mctp_stack::fragment::SendOutput::Error { err, .. } => {
+                    break Err(err);
+                }
+            }
+        }
+    }
+
+    fn get_mtu(&self) -> usize {
+        MCTP_I2C_MAXMTU
+    }
+}