services/mctp: add transport-i2c crate
diff --git a/services/mctp/transport-i2c/BUILD.bazel b/services/mctp/transport-i2c/BUILD.bazel
new file mode 100644
index 0000000..a355b52
--- /dev/null
+++ b/services/mctp/transport-i2c/BUILD.bazel
@@ -0,0 +1,33 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")
+
+rust_library(
+    name = "mctp_transport_i2c",
+    srcs = glob(["src/**/*.rs"]),
+    crate_name = "openprot_mctp_transport_i2c",
+    edition = "2024",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/i2c/api:i2c_api",
+        "//services/mctp/api:mctp_api",
+        "@pigweed//pw_log/rust:pw_log",
+        "@rust_crates//:embedded-hal",
+        "@rust_crates//:heapless",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
+
+rust_test(
+    name = "mctp_transport_i2c_test",
+    crate = ":mctp_transport_i2c",
+    deps = [
+        "//services/i2c/client:i2c_client",
+        "//services/i2c/server:i2c_server",
+        "//services/mctp/server:mctp_server_lib",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+    ],
+)
diff --git a/services/mctp/transport-i2c/Cargo.toml b/services/mctp/transport-i2c/Cargo.toml
new file mode 100644
index 0000000..dd86c66
--- /dev/null
+++ b/services/mctp/transport-i2c/Cargo.toml
@@ -0,0 +1,17 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+[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" }
+i2c_api = { path = "../../i2c/api", package = "openprot-i2c-api" }
+mctp-lib = { git = "https://github.com/9elements/mctp-lib.git", branch = "buildup", package = "mctp-lib" }
+mctp = { git = "https://github.com/OpenPRoT/mctp-rs.git", branch = "sync-features", default-features = false }
+heapless = { workspace = true }
+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..c93a245
--- /dev/null
+++ b/services/mctp/transport-i2c/README.md
@@ -0,0 +1,21 @@
+# openprot-mctp-transport-i2c
+
+I2C transport binding for the MCTP server.
+
+## Overview
+
+This crate implements MCTP-over-I2C transport. It provides the `Sender` implementation for outbound packets and a receiver/decoder for inbound target-mode frames. It uses the drivers/i2c userspace stack for transport.
+
+## Key Types
+
+- `I2cSender<C>` — implements `mctp_lib::Sender` for I2C; handles fragmentation, encoding, and PEC via `mctp_lib::i2c::MctpI2cEncap`
+- `MctpI2cReceiver` — decodes inbound I2C target-mode frames into MCTP packets
+
+## Dependencies
+
+- `openprot-mctp-api` — API traits
+- `i2c_api` (drivers/i2c) — protocol and transport seam types
+- `mctp-lib` — `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..43f3194
--- /dev/null
+++ b/services/mctp/transport-i2c/src/lib.rs
@@ -0,0 +1,26 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! # MCTP over I2C Transport Binding
+//!
+//! This crate provides the I2C transport binding for the MCTP server.
+//!
+//! It implements [`mctp_lib::Sender`] for outbound MCTP-over-I2C packets
+//! and provides [`MctpI2cReceiver`] for decoding inbound I2C target frames
+//! into MCTP packets.
+//!
+//! ## Current I2C seam
+//!
+//! - Outbound path is built on the `embedded_hal::i2c::I2c` contract.
+//! - Inbound target-mode data comes from the i2c userspace driver
+//!   notification + `SlaveReceive` flow.
+//! - MCTP framing/PEC logic stays in `mctp_lib::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..37b0e40
--- /dev/null
+++ b/services/mctp/transport-i2c/src/receiver.rs
@@ -0,0 +1,436 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! 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_lib::i2c::MctpI2cEncap`
+//! for decoding (same as Hubris).
+
+use mctp_lib::i2c::{MctpI2cEncap, MctpI2cHeader};
+
+/// Decodes I2C target frames into raw MCTP packets.
+///
+/// Wraps the `mctp_lib::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 frame 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, data: &'a [u8]) -> Result<(&'a [u8], MctpI2cHeader), mctp::Error> {
+        // MctpI2cEncap::decode strips the I2C header, validates PEC,
+        // and returns the raw MCTP packet + source I2C address.
+        self.encap.decode(data, true)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    // Enable std for tests - they run on the host, not embedded target
+    extern crate std;
+    use std::print;
+    use std::println;
+
+    use super::*;
+
+    /// Test decoding the provided sample I2C MCTP frame with detailed debugging.
+    ///
+    /// Original sample data: 0F 0A 85 01 08 30 C8 05 10 84 00 00 65
+    ///
+    /// This data is MISSING the destination address byte that the I2C hardware
+    /// prepends. The complete frame should be:
+    /// 20 0F 0A 85 01 08 30 C8 05 10 84 00 00 65
+    ///
+    /// PEC Analysis:
+    /// - PEC over bytes [0F..00]: 0x9B (INVALID)
+    /// - PEC over bytes [20..00]: 0x65 (VALID!)
+    ///
+    /// The PEC 0x65 is calculated including the destination address 0x20.
+    ///
+    /// This test confirms the decoder requires the full SMBus frame.
+    #[test]
+    fn decode_sample_frame_detailed() {
+        println!("\n========================================");
+        println!("Testing Sample MCTP Frame (CORRECTED)");
+        println!("========================================");
+
+        // Create receiver configured for I2C address 0x10
+        let receiver = MctpI2cReceiver::new(0x10);
+        println!("Receiver configured for I2C address: 0x10");
+
+        // CORRECTED: Complete SMBus frame including destination address
+        // The original sample was missing byte [0] = 0x20
+        let frame_data: [u8; 14] = [
+            0x20, // [0] Destination address (0x10 << 1 | 0 for write) - WAS MISSING!
+            0x0F, // [1] Command code (MCTP over SMBus)
+            0x0A, // [2] Byte count = 10
+            0x85, // [3] Source slave address (0x42 << 1 | 1 for read) - VALID!
+            0x01, // [4] MCTP hdr ver=1 (lower nibble), reserved=0 (upper nibble)
+            0x08, // [5] Destination EID = 8
+            0x30, // [6] Source EID = 48
+            0xC8, // [7] SOM=1, EOM=1, Seq=0, TO=1, Tag=0
+            0x05, // [8] IC=0, Message Type=0x05 (SPDM)
+            0x10, // [9] SPDM version 1.0
+            0x84, // [10] SPDM GET_VERSION
+            0x00, // [11] Param1
+            0x00, // [12] Param2
+            0x65, // [13] PEC (valid!)
+        ];
+
+        println!("\nComplete SMBus frame ({} bytes):", frame_data.len());
+        print!("  Hex: ");
+        for (i, byte) in frame_data.iter().enumerate() {
+            print!("{byte:02X} ");
+            if (i + 1) % 8 == 0 {
+                print!("\n       ");
+            }
+        }
+        println!();
+
+        println!("\nFrame structure:");
+        println!(
+            "  [0] 0x{:02X} - Destination address (0x10 << 1 | 0 for write)",
+            frame_data[0]
+        );
+        println!(
+            "  [1] 0x{:02X} - Command code (MCTP over SMBus)",
+            frame_data[1]
+        );
+        println!("  [2] 0x{:02X} - Byte count = 10", frame_data[2]);
+        let src_7bit = frame_data[3] >> 1;
+        let src_rw = frame_data[3] & 0x01;
+        println!(
+            "  [3] 0x{:02X} - Source slave address (0x{:02X} << 1 | {} for {})",
+            frame_data[3],
+            src_7bit,
+            src_rw,
+            if src_rw == 1 { "read" } else { "write" }
+        );
+        println!("  [4] 0x{:02X} - MCTP hdr ver=1, reserved=0", frame_data[4]);
+        println!("  [5] 0x{:02X} - Destination EID = 8", frame_data[5]);
+        println!("  [6] 0x{:02X} - Source EID = 48", frame_data[6]);
+        println!(
+            "  [7] 0x{:02X} - SOM=1, EOM=1, Seq=0, TO=1, Tag=0",
+            frame_data[7]
+        );
+        println!("  [8] 0x{:02X} - Message Type=0x05 (SPDM)", frame_data[8]);
+        println!("  [9] 0x{:02X} - SPDM version 1.0", frame_data[9]);
+        println!(" [10] 0x{:02X} - SPDM GET_VERSION", frame_data[10]);
+        println!(" [11] 0x{:02X} - Param1", frame_data[11]);
+        println!(" [12] 0x{:02X} - Param2", frame_data[12]);
+        println!(
+            " [13] 0x{:02X} - PEC (CRC-8, polynomial 0x07)",
+            frame_data[13]
+        );
+
+        // Verify PEC calculation
+        println!("\nPEC Verification:");
+        let mut crc = 0u8;
+        let polynomial = 0x07u8;
+        for &byte in &frame_data[..13] {
+            // All bytes except PEC
+            crc ^= byte;
+            for _ in 0..8 {
+                if crc & 0x80 != 0 {
+                    crc = (crc << 1) ^ polynomial;
+                } else {
+                    crc <<= 1;
+                }
+            }
+        }
+        println!("  Calculated PEC: 0x{crc:02X}");
+        println!("  Received PEC:   0x{:02X}", frame_data[13]);
+        println!("  PEC Valid:      {}", crc == frame_data[13]);
+
+        println!("\nPrepared frame for decode");
+
+        // Decode the frame
+        println!("\nCalling MctpI2cReceiver::decode()...");
+        let result = receiver.decode(&frame_data);
+
+        match &result {
+            Ok((pkt, header)) => {
+                println!("\nāœ“ Decode SUCCEEDED!");
+                println!("  Source address: 0x{:02X}", header.source);
+                println!("  Dest address: 0x{:02X}", header.dest);
+                println!("  MCTP packet ({} bytes):", pkt.len());
+                print!("    ");
+                for (i, byte) in pkt.iter().enumerate() {
+                    print!("{byte:02X} ");
+                    if (i + 1) % 16 == 0 && i + 1 < pkt.len() {
+                        print!("\n    ");
+                    }
+                }
+                println!();
+
+                // Analyze the MCTP packet structure
+                if pkt.len() >= 4 {
+                    println!("\n  MCTP Transport Header:");
+                    println!("    Dest EID:        0x{:02X} ({})", pkt[0], pkt[0]);
+                    let hdr_byte = pkt[1];
+                    let version = (hdr_byte >> 4) & 0x0F;
+                    let reserved = hdr_byte & 0x0F;
+                    println!("    Header version:  {version} (byte=0x{hdr_byte:02X})");
+                    println!("    Reserved:        0x{reserved:X}");
+                    println!("    Source EID:      0x{:02X} ({})", pkt[2], pkt[2]);
+
+                    let flags = pkt[3];
+                    println!("\n  Message Framing:");
+                    println!(
+                        "    SOM:             {}",
+                        if flags & 0x80 != 0 { "Yes" } else { "No" }
+                    );
+                    println!(
+                        "    EOM:             {}",
+                        if flags & 0x40 != 0 { "Yes" } else { "No" }
+                    );
+                    println!("    Packet Seq:      {}", (flags >> 4) & 0x03);
+                    println!(
+                        "    Tag Owner:       {}",
+                        if flags & 0x08 != 0 { "Yes" } else { "No" }
+                    );
+                    println!("    Message Tag:     {}", flags & 0x07);
+                }
+
+                if pkt.len() >= 5 {
+                    let msg_byte = pkt[4];
+                    println!("\n  Message Body:");
+                    println!(
+                        "    Integrity Check: {}",
+                        if msg_byte & 0x80 != 0 { "Yes" } else { "No" }
+                    );
+                    println!("    Message Type:    0x{:02X}", msg_byte & 0x7F);
+                }
+            }
+            Err(e) => {
+                println!("\nāœ— Decode FAILED!");
+                println!("  Error: {e:?}");
+
+                // Try to provide more context about the error
+                match e {
+                    mctp::Error::BadArgument => {
+                        println!("  Cause: Invalid arguments to the decoder");
+                    }
+                    mctp::Error::InternalError => {
+                        println!("  Cause: Internal decoder error");
+                    }
+                    mctp::Error::NoSpace => {
+                        println!("  Cause: Buffer too small");
+                    }
+                    mctp::Error::InvalidInput => {
+                        println!("  Cause: Invalid input data format");
+                        println!(
+                            "  Note: The I2C decoder might be expecting a different frame format"
+                        );
+                        println!("  Hint: SMBus MCTP frames seen by the target (slave) have:");
+                        println!("        - Destination address byte (with R/W bit)");
+                        println!("        - Command code (0x0F for MCTP)");
+                        println!("        - Byte count");
+                        println!("        - Source slave address");
+                        println!("        - MCTP packet data");
+                        println!("        - PEC");
+                        println!("  But MctpI2cEncap might expect only the SMBus payload portion");
+                    }
+                    _ => {
+                        println!("  Cause: See mctp::Error enum for details");
+                    }
+                }
+            }
+        }
+
+        println!("\n========================================");
+        println!("SUMMARY:");
+        println!("========================================");
+        println!("Original sample data: 0F 0A 85 01 08 30 C8 05 10 84 00 00 65");
+        println!();
+        println!("Analysis:");
+        println!("1. Missing destination address byte at start");
+        println!("   - Should be: 0x20 (0x10 << 1 | 0 for write)");
+        println!();
+        println!("2. Source address 0x85 is VALID!");
+        println!("   - 0x85 in 8-bit format = 0x42 << 1 | 1 (read)");
+        println!("   - 7-bit address 0x42 is in valid range 0x08-0x77");
+        println!();
+        println!("3. PEC 0x65 is CORRECT when destination address included");
+        println!("   - PEC over [20 0F .. 00] = 0x65 āœ“");
+        println!();
+        println!("Corrected complete frame:");
+        println!("  20 0F 0A 85 01 08 30 C8 05 10 84 00 00 65");
+        println!("  ^^ added destination address");
+        println!();
+        println!("========================================\n");
+
+        // Still getting BadArgument - need to investigate mctp-lib's exact
+        // expectations for the I2C binding format
+    }
+
+    /// Test that decoder rejects frames with empty data.
+    #[test]
+    fn decode_empty_frame() {
+        let receiver = MctpI2cReceiver::new(0x10);
+        let frame: [u8; 0] = [];
+
+        let result = receiver.decode(&frame);
+        assert!(result.is_err(), "Empty frame should be rejected");
+    }
+
+    /// Test that decoder rejects truncated frames.
+    #[test]
+    fn decode_truncated_frame() {
+        let receiver = MctpI2cReceiver::new(0x10);
+
+        // Frame with only 5 bytes - too short to contain valid MCTP packet
+        let frame_data: [u8; 5] = [0x20, 0x0F, 0x05, 0x50, 0x10];
+
+        let result = receiver.decode(&frame_data);
+        assert!(result.is_err(), "Truncated frame should be rejected");
+    }
+
+    /// Experiment with different frame formats to understand what MctpI2cEncap expects.
+    ///
+    /// The I2C hardware delivers frames to the target (slave) in SMBus format.
+    /// This test tries different interpretations to find the correct format.
+    #[test]
+    fn decode_format_experiments() {
+        println!("\n========================================");
+        println!("I2C Frame Format Experiments");
+        println!("========================================");
+
+        let receiver = MctpI2cReceiver::new(0x10);
+
+        // Original sample: 0F 0A 85 01 08 30 C8 05 10 84 00 00 65
+        // According to mctp-parse:
+        //   0x0F = cmd code
+        //   0x0A = byte count (10)
+        //   0x85 = source slave addr
+        //   0x01 = MCTP hdr ver
+        //   0x08 = dest EID
+        //   0x30 = source EID
+        //   0xC8 = SOM/EOM/tag
+        //   0x05 = msg type (SPDM)
+        //   0x10 0x84 0x00 0x00 = SPDM payload
+        //   0x65 = PEC
+
+        println!("\n--- Test 1: Full frame as received by I2C hardware ---");
+        let full_frame: [u8; 13] = [
+            0x0F, 0x0A, 0x85, 0x01, 0x08, 0x30, 0xC8, 0x05, 0x10, 0x84, 0x00, 0x00, 0x65,
+        ];
+        let result1 = receiver.decode(&full_frame);
+        match result1 {
+            Ok((_, hdr)) => println!("OK: src: {:02X?}, dest: {:02X?}", hdr.source, hdr.dest),
+            Err(e) => println!("ERROR: {e}"),
+        }
+
+        println!("\n--- Test 2: Without destination address (if HW strips it) ---");
+        // Maybe the I2C hardware already stripped the destination address byte?
+        let without_dest: [u8; 13] = [
+            0x0F, 0x0A, 0x85, 0x01, 0x08, 0x30, 0xC8, 0x05, 0x10, 0x84, 0x00, 0x00, 0x65,
+        ];
+        let result2 = receiver.decode(&without_dest);
+        match result2 {
+            Ok((_, hdr)) => println!("OK: src: {:02X?}, dest: {:02X?}", hdr.source, hdr.dest),
+            Err(e) => println!("ERROR: {e}"),
+        }
+
+        println!("\n--- Test 3: Starting from command code with dest prepended ---");
+        // SMBus master-to-slave write: dest_addr(W) + cmd + data
+        // The I2C controller receiving might give us: cmd + data
+        // Let me try prepending the destination address
+        let with_dest_addr: [u8; 14] = [
+            0x20, // 0x10 << 1 | 0 (write bit)
+            0x0F, 0x0A, 0x85, 0x01, 0x08, 0x30, 0xC8, 0x05, 0x10, 0x84, 0x00, 0x00, 0x65,
+        ];
+        let result3 = receiver.decode(&with_dest_addr);
+        match result3 {
+            Ok((_, hdr)) => println!("OK: src: {:02X?}, dest: {:02X?}", hdr.source, hdr.dest),
+            Err(e) => println!("ERROR: {e}"),
+        }
+
+        println!("\n--- Test 4: Just the SMBus data block (byte count onwards) ---");
+        // Maybe mctp-lib expects: byte_count + src_addr + hdr + EIDs + packet + PEC
+        let smbus_block: [u8; 12] = [
+            0x0A, 0x85, 0x01, 0x08, 0x30, 0xC8, 0x05, 0x10, 0x84, 0x00, 0x00, 0x65,
+        ];
+        let result4 = receiver.decode(&smbus_block);
+        match result4 {
+            Ok((_, hdr)) => println!("OK: src: {:02X?}, dest: {:02X?}", hdr.source, hdr.dest),
+            Err(e) => println!("ERROR: {e}"),
+        }
+
+        println!("\n--- Test 5: WITH destination address prepended (what I2C HW delivers) ---");
+        // The I2C hardware should prepend the destination address!
+        // Format: dest_addr(W) + cmd + byte_count + src_addr + mctp_hdr + payload + PEC
+        // Dest address for 0x10 with write bit: 0x10 << 1 | 0 = 0x20
+        //
+        // NOTE: The original sample had 0x85 as source address, but that's in the
+        // I2C reserved range (0x78-0x7F)! Let me use 0x50 instead.
+        let complete_frame: [u8; 14] = [
+            0x20, // Destination address (0x10 << 1, write bit = 0)
+            0x0F, // Command code
+            0x0A, // Byte count = 10
+            0x50, // Source slave address (valid, was 0x85 in original)
+            0x01, // MCTP hdr ver=1
+            0x08, // Dest EID
+            0x30, // Source EID
+            0xC8, // SOM/EOM/tag
+            0x05, // Message type (SPDM)
+            0x10, // SPDM v1.0
+            0x84, // GET_VERSION
+            0x00, // Param1
+            0x00, // Param2
+            0x65, // PEC (will be wrong now, but let's see if it gets that far)
+        ];
+        let result5 = receiver.decode(&complete_frame);
+        match result5 {
+            Ok((_, ref hdr)) => println!("OK: src: {:02X?}, dest: {:02X?}", hdr.source, hdr.dest),
+            Err(ref e) => println!("ERROR: {e}"),
+        }
+        if let Ok((pkt, header)) = &result5 {
+            println!("  SUCCESS! Decoded MCTP packet:");
+            println!(
+                "    I2C Header: dest: {:02X?}, src: {:02X?}, byte_count: {:02X?}",
+                header.dest, header.source, header.byte_count
+            );
+            println!("    Packet ({} bytes): {:02X?}", pkt.len(), pkt);
+        }
+
+        println!("\n--- Test 6: Try with PEC validation disabled ---");
+        // The MctpI2cEncap::decode second parameter controls PEC validation
+        // Let me manually call it with false to see if that helps
+        let result6 = receiver.encap.decode(&complete_frame, false);
+        match &result6 {
+            Ok((pkt, header)) => {
+                println!("  SUCCESS! Decoded MCTP packet:");
+                println!(
+                    "    I2C Header: dest: {:02X?}, src: {:02X?}, byte_count: {:02X?}",
+                    header.dest, header.source, header.byte_count
+                );
+                println!("    Packet ({} bytes): {:02X?}", pkt.len(), pkt);
+            }
+            Err(e) => println!("  ERROR: {e:?}"),
+        }
+
+        println!("\n========================================\n");
+
+        // Note: We don't assert success here because we're still experimenting
+    }
+}
diff --git a/services/mctp/transport-i2c/src/sender.rs b/services/mctp/transport-i2c/src/sender.rs
new file mode 100644
index 0000000..a4bc3de
--- /dev/null
+++ b/services/mctp/transport-i2c/src/sender.rs
@@ -0,0 +1,283 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! I2C MCTP sender — outbound transport binding.
+//!
+//! Direct port of Hubris `mctp-server/src/i2c.rs` `I2cSender`.
+//! Only the I2C driver API is replaced with `embedded_hal::i2c::I2c`.
+
+use embedded_hal::i2c::I2c;
+use mctp::Result;
+use mctp_lib::i2c::{MctpI2cEncap, MCTP_I2C_MAXMTU};
+
+/// I2C MCTP sender.
+///
+/// Implements `mctp_lib::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 `embedded_hal::i2c::I2c::write`.
+pub struct I2cSender<C: I2c<u8>> {
+    i2c: C,
+    own_addr: u8,
+    // Simple static remote address. Full neighbor table (EID → I2C address mapping)
+    // will be implemented later per https://github.com/OpenPRoT/mctp-lib/issues/4.
+    // For now, this supports single-peer communication (requester ↔ responder).
+    remote_addr: u8,
+}
+
+impl<C: I2c<u8>> I2cSender<C> {
+    /// Create a new I2C sender.
+    ///
+    /// * `i2c` - I2C client for bus writes
+    /// * `own_addr` - Own I2C address (7-bit, used in MCTP-I2C header)
+    /// * `remote_addr` - Remote peer's I2C address (7-bit, destination for outbound packets)
+    pub fn new(i2c: C, own_addr: u8, remote_addr: u8) -> Self {
+        Self {
+            i2c,
+            own_addr,
+            remote_addr,
+        }
+    }
+}
+
+impl<C: I2c<u8>> mctp_lib::Sender for I2cSender<C> {
+    fn send_vectored(
+        &mut self,
+        mut fragmenter: mctp_lib::fragment::Fragmenter,
+        payload: &[&[u8]],
+    ) -> Result<mctp::Tag> {
+        // Use the configured remote address. In a full implementation, this would
+        // look up the destination EID in a neighbor table to find the corresponding
+        // I2C address. For now, we support single-peer communication with a static
+        // remote address configured at construction time.
+        // TODO: Implement full EID → I2C address neighbor table
+        //       (see https://github.com/OpenPRoT/mctp-lib/issues/4)
+        let addr = self.remote_addr;
+        let encoder = MctpI2cEncap::new(self.own_addr);
+        let mtu = self.get_mtu();
+        pw_log::info!(
+            "Starting fragmentation: MTU=0x{:04x}, buffer size=0x{:04x}",
+            mtu as u32,
+            mctp_lib::serial::MTU_MAX as u32
+        );
+
+        loop {
+            let mut pkt = [0u8; MCTP_I2C_MAXMTU + 4]; // MTU + MCTP transport header
+            pw_log::debug!(
+                "Calling fragment_vectored with buffer size 0x{:04x}",
+                pkt.len() as u32
+            );
+            let r = fragmenter.fragment_vectored(payload, &mut pkt);
+
+            match r {
+                mctp_lib::fragment::SendOutput::Packet(p) => {
+                    pw_log::info!("packet sending to 0x{:02x}...", addr as u32);
+                    let mut out = [0; MCTP_I2C_MAXMTU + 8]; // max MTU + I2C header size
+                    let packet = encoder.encode(addr, p, &mut out, true)?;
+                    pw_log::debug!("Encoded packet length: 0x{:04x}", packet.len() as u32);
+
+                    // Skip the first byte (destination address) since the I2C driver
+                    // automatically prepends it. mctp-estack's encode() includes the
+                    // full I2C frame [dest][cmd][bc][src][...], but embedded-hal I2C
+                    // write() expects [cmd][bc][src][...] and adds [dest] itself.
+                    let packet_without_dest = &packet[1..];
+                    let packet_len = packet_without_dest.len();
+                    if let Err(i2c_err) = self.i2c.write(addr, packet_without_dest) {
+                        use embedded_hal::i2c::Error as _;
+                        let kind = i2c_err.kind();
+                        let kind_code: u8 = match kind {
+                            embedded_hal::i2c::ErrorKind::Bus => 0,
+                            embedded_hal::i2c::ErrorKind::ArbitrationLoss => 1,
+                            embedded_hal::i2c::ErrorKind::NoAcknowledge(src) => match src {
+                                embedded_hal::i2c::NoAcknowledgeSource::Address => 2,
+                                embedded_hal::i2c::NoAcknowledgeSource::Data => 3,
+                                embedded_hal::i2c::NoAcknowledgeSource::Unknown => 4,
+                            },
+                            embedded_hal::i2c::ErrorKind::Overrun => 5,
+                            embedded_hal::i2c::ErrorKind::Other => 6,
+                            _ => 0xFF,
+                        };
+                        pw_log::error!("I2C write failed: kind=0x{:02x}", kind_code as u32);
+                        pw_log::error!(
+                            "Address: 0x{:02x}, Data len: 0x{:04x}",
+                            addr as u32,
+                            packet_len as u32
+                        );
+                        return Err(mctp::Error::TxFailure);
+                    }
+                    pw_log::info!("packet sent");
+                }
+                mctp_lib::fragment::SendOutput::Complete { tag, .. } => {
+                    pw_log::info!("complete");
+                    break Ok(tag);
+                }
+                mctp_lib::fragment::SendOutput::Error { err, .. } => {
+                    let err_code: u8 = match err {
+                        mctp::Error::TxFailure => 0,
+                        mctp::Error::RxFailure => 1,
+                        mctp::Error::TimedOut => 2,
+                        mctp::Error::BadArgument => 3,
+                        mctp::Error::InvalidInput => 4,
+                        mctp::Error::TagUnavailable => 5,
+                        mctp::Error::Unreachable => 6,
+                        mctp::Error::AddrInUse => 7,
+                        mctp::Error::NoSpace => 8,
+                        mctp::Error::Unsupported => 9,
+                        _ => 0xFF,
+                    };
+                    pw_log::error!(
+                        "fragment_vectored failed with error: 0x{:02x}",
+                        err_code as u32
+                    );
+                    pw_log::error!(
+                        "Buffer size: 0x{:04x}, MTU: 0x{:04x}",
+                        mctp_lib::serial::MTU_MAX as u32,
+                        mtu as u32
+                    );
+                    break Err(err);
+                }
+            }
+        }
+    }
+
+    fn get_mtu(&self) -> usize {
+        MCTP_I2C_MAXMTU
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    extern crate std;
+    use std::cell::RefCell;
+    use std::vec::Vec;
+
+    use mctp::Eid;
+
+    use i2c_api::seam::{ErrorKind, ErrorType, I2c, I2cBusError, Operation, SevenBitAddress};
+    use i2c_client::I2cClient;
+    use i2c_server::loopback::LoopbackTransport;
+    use openprot_mctp_server::Server;
+
+    use super::I2cSender;
+    use crate::MctpI2cReceiver;
+
+    // A bus that records every write() payload verbatim. Reads are not needed
+    // since MCTP-over-I2C is master-write only for outbound packets.
+    struct CaptureBus<'a> {
+        writes: &'a RefCell<Vec<Vec<u8>>>,
+        addr: &'a RefCell<Vec<u8>>,
+    }
+
+    #[derive(Debug)]
+    struct CaptureErr;
+    impl I2cBusError for CaptureErr {
+        fn kind(&self) -> ErrorKind {
+            ErrorKind::Other
+        }
+    }
+    impl ErrorType for CaptureBus<'_> {
+        type Error = CaptureErr;
+    }
+    impl I2c<SevenBitAddress> for CaptureBus<'_> {
+        fn transaction(
+            &mut self,
+            address: SevenBitAddress,
+            operations: &mut [Operation<'_>],
+        ) -> Result<(), Self::Error> {
+            for op in operations.iter() {
+                if let Operation::Write(bytes) = op {
+                    self.writes.borrow_mut().push(bytes.to_vec());
+                    self.addr.borrow_mut().push(address);
+                }
+            }
+            Ok(())
+        }
+    }
+
+    // Drive Server::send() to push a message through I2cSender and capture the
+    // raw I2C frames, then decode them with MctpI2cReceiver and assert the
+    // payload survives the round-trip.
+    #[test]
+    fn sender_receiver_roundtrip() {
+        let writes: RefCell<Vec<Vec<u8>>> = RefCell::new(Vec::new());
+        let addrs: RefCell<Vec<u8>> = RefCell::new(Vec::new());
+
+        const OWN_ADDR: u8 = 0x10;
+        const REMOTE_ADDR: u8 = 0x42;
+        const OWN_EID: u8 = 8;
+        const REMOTE_EID: u8 = 48;
+        const MSG_TYPE: u8 = 0x05; // SPDM
+
+        let bus = CaptureBus {
+            writes: &writes,
+            addr: &addrs,
+        };
+        let transport = LoopbackTransport::new(bus);
+        let i2c_client = I2cClient::new(transport);
+        let sender = I2cSender::new(i2c_client, OWN_ADDR, REMOTE_ADDR);
+
+        let mut server: Server<I2cSender<I2cClient<LoopbackTransport<CaptureBus<'_>>>>, 16> =
+            Server::new(Eid(OWN_EID), 0, sender);
+
+        let payload = b"hello mctp";
+        let req_handle = server.req(REMOTE_EID).unwrap();
+        server
+            .send(Some(req_handle), MSG_TYPE, None, None, false, payload)
+            .unwrap();
+
+        // I2cSender skips the first byte (dest addr) before calling i2c.write(),
+        // so CaptureBus sees [cmd][byte_count][src_addr][mctp_hdr...][payload][PEC].
+        // MctpI2cReceiver::decode() expects the full SMBus frame including the
+        // leading dest addr byte. Prepend it before decoding.
+        let captured = writes.borrow();
+        assert!(!captured.is_empty(), "no I2C writes captured");
+
+        let receiver = MctpI2cReceiver::new(REMOTE_ADDR);
+
+        // Reconstruct the full SMBus frame: [dest_addr_byte] + captured write bytes.
+        // The dest addr byte is REMOTE_ADDR << 1 (write bit = 0).
+        let mut full_frame = Vec::new();
+        full_frame.push(REMOTE_ADDR << 1);
+        full_frame.extend_from_slice(&captured[0]);
+
+        let (mctp_pkt, i2c_hdr) = receiver.decode(&full_frame).expect("decode failed");
+
+        // source is already a 7-bit address per MctpI2cHeader docs.
+        assert_eq!(i2c_hdr.source, OWN_ADDR, "source address mismatch");
+
+        // The MCTP packet payload starts after the 4-byte MCTP transport header
+        // and the 1-byte message type field.
+        assert!(mctp_pkt.len() >= 5, "MCTP packet too short");
+        let msg_type_byte = mctp_pkt[4];
+        assert_eq!(msg_type_byte & 0x7F, MSG_TYPE, "message type mismatch");
+        assert_eq!(&mctp_pkt[5..], payload, "payload mismatch");
+    }
+
+    // A payload that fits in one fragment should produce exactly one I2C write.
+    #[test]
+    fn single_fragment_produces_one_write() {
+        let writes: RefCell<Vec<Vec<u8>>> = RefCell::new(Vec::new());
+        let addrs: RefCell<Vec<u8>> = RefCell::new(Vec::new());
+
+        let bus = CaptureBus {
+            writes: &writes,
+            addr: &addrs,
+        };
+        let sender = I2cSender::new(I2cClient::new(LoopbackTransport::new(bus)), 0x10, 0x42);
+        let mut server: Server<_, 16> = Server::new(Eid(8), 0, sender);
+
+        let req = server.req(48).unwrap();
+        server
+            .send(Some(req), 1, None, None, false, b"short")
+            .unwrap();
+
+        assert_eq!(
+            writes.borrow().len(),
+            1,
+            "expected exactly one I2C write for a short payload"
+        );
+    }
+}