mctp-server: add in-process SPDM requester

Parallel to the existing in-process SPDM responder. Same src/main.rs,
selected by Cargo feature flag (in-process-responder vs
in-process-requester) on the Bazel rust_app target, enforced mutually
exclusive via compile_error!.

- New system_image at //target/ast1060-evb/mctp-requester:mctp_requester;
  the existing //target/ast1060-evb/mctp:mctp keeps the responder role.
- Second rust_app target mctp_server_requester in services/mctp/server/
  with distinct codegen_crate_name (app_mctp_server_requester).
- Phase-2 loop body runs an 8-state VCA FSM one step per iteration
  (SendVersion -> AwaitVersion -> ... -> Done / Failed), interleaved
  with the existing Phase-1 I2C drain.
- AWAIT_STEP_BUDGET (10_000) safety net: an Await state that sits on
  TimedOut too long transitions to Failed instead of hanging.
- Counters req_send_ok / req_recv_ok / req_recv_pending logged on the
  Done transition.

Bundled pre-Phase-1 unblockers for the mctp-lib hotfix at 023e75e:
- Server::inbound now discards the new Option<AppCookie> return.
- main.rs consumes the MctpI2cHeader struct from receiver.decode()
  (was a bare u8 source address).

Docs updated: DESIGN_IN_PROCESS_REQUESTER.md, IMPLEMENTATION_PLAN.md
(new), INITIALIZATION.md (requester setup + glossary).

Build verification:
  bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp:mctp
  bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp-requester:mctp_requester
diff --git a/services/mctp/server/BUILD.bazel b/services/mctp/server/BUILD.bazel
index d22ca9d..d90e598 100644
--- a/services/mctp/server/BUILD.bazel
+++ b/services/mctp/server/BUILD.bazel
@@ -104,6 +104,7 @@
     crate_features = [
         "i2c-polling",
         "direct-client",
+        "in-process-responder",
     ],
     edition = "2024",
     system_config = "//target/ast1060-evb/mctp:system_config",
@@ -126,3 +127,37 @@
         "@oot_crates_no_std//:spdm-lib",
     ],
 )
+
+# In-process SPDM requester variant.  Same src/main.rs, different feature
+# set.  No separate system_image target yet — use the mctp:mctp image once
+# it is re-wired, or build this target alone to verify compilation.
+rust_app(
+    name = "mctp_server_requester",
+    codegen_crate_name = "app_mctp_server_requester",
+    srcs = ["src/main.rs"],
+    crate_features = [
+        "i2c-polling",
+        "direct-client",
+        "in-process-requester",
+    ],
+    edition = "2024",
+    system_config = "//target/ast1060-evb/mctp-requester:system_config",
+    tags = ["kernel"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":mctp_server_lib",
+        "//services/i2c/api:i2c_api",
+        "//services/i2c/client:i2c_client",
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/transport-i2c:mctp_transport_i2c",
+        "//services/spdm/transport-mctp:spdm_transport_mctp",
+        "//target/ast1060-evb/spdm-req-resp-test:mock_platform",
+        "@rust_crates//:mctp",
+        "@rust_crates//:mctp-lib",
+        "@pigweed//pw_kernel/syscall:syscall_user",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+        "@pigweed//pw_status/rust:pw_status",
+        "@oot_crates_no_std//:spdm-lib",
+    ],
+)
diff --git a/services/mctp/server/Cargo.toml b/services/mctp/server/Cargo.toml
index 570e3b5..22f8eb0 100644
--- a/services/mctp/server/Cargo.toml
+++ b/services/mctp/server/Cargo.toml
@@ -18,14 +18,21 @@
 [features]
 # Polling mode + in-process SPDM responder is the default build.
 # Disable with --no-default-features to get the notification (WaitGroup + IRQ) mode.
-default = ["i2c-polling", "direct-client"]
+default = ["i2c-polling", "in-process-responder"]
 # Build mctp_server with a blocking I2C poll loop instead of the WaitGroup +
 # IRQ-driven notification loop.  No WG or I2C2_IRQ capability is required.
 # Enable via crate_features = ["i2c-polling"] in the Bazel rust_app target.
 i2c-polling = []
 # Expose DirectMctpClient for in-process use (no IPC channel required).
-# Required when running an SPDM responder inside the MCTP server process.
+# Required by in-process-responder and in-process-requester; on its own it
+# only exposes the type from the library without instantiating any SPDM role.
 direct-client = []
+# Run an SPDM responder inside the MCTP server process (polling-loop Phase 2).
+# Mutually exclusive with in-process-requester.
+in-process-responder = ["direct-client"]
+# Run an SPDM requester inside the MCTP server process (polling-loop Phase 2).
+# Mutually exclusive with in-process-responder.
+in-process-requester = ["direct-client"]
 
 [dev-dependencies]
 mctp-lib = { git = "https://github.com/9elements/mctp-lib.git", branch = "buildup", package = "mctp-lib" }
diff --git a/services/mctp/server/DESIGN_IN_PROCESS_REQUESTER.md b/services/mctp/server/DESIGN_IN_PROCESS_REQUESTER.md
new file mode 100644
index 0000000..f8a6e4c
--- /dev/null
+++ b/services/mctp/server/DESIGN_IN_PROCESS_REQUESTER.md
@@ -0,0 +1,465 @@
+# In-Process SPDM Requester — Design Spec
+
+Status: draft
+Scope: add an in-process SPDM **requester** to the MCTP server, mirroring the
+existing in-process SPDM responder at
+[src/main.rs:144-446](src/main.rs#L144-L446).
+
+---
+
+## 1. Motivation
+
+The responder path collapses three user-space processes (mctp_server +
+spdm_responder + i2c_server) into two by running the SPDM responder inside
+the MCTP server via `DirectMctpClient`. We want the symmetric capability on
+the requester side: drive SPDM VCA / attestation flows from inside the MCTP
+server without an IPC channel to a separate `spdm_requester` app.
+
+Benefits (same as responder):
+
+- No `channel_handler` / `channel_initiator` pair between mctp_server and
+  spdm_requester — lower RAM, fewer kernel objects in `system.json5`.
+- No IPC serialize/deserialize on every SPDM request.
+- One process to flash/debug for requester-side scenarios.
+
+---
+
+## 2. Key Asymmetry vs. the Responder
+
+The responder is **passive** — it consumes what arrives. Its loop is
+naturally polling-friendly:
+
+```
+loop {
+    drain_i2c_into_router();                    // Phase 1
+    let _ = ctx.responder_process_message(..);  // Phase 2: TimedOut == normal
+}
+```
+
+The requester is **active** — it initiates exchanges. `requester_process_message`
+internally calls `receive_response`, which calls `client.recv(..)`.
+`DirectMctpClient::recv` returns `Err(TimedOut)` immediately if the Router has
+not yet assembled a full message. So a requester that calls
+`requester_process_message` before I2C has delivered the response will *fail
+fast* instead of blocking.
+
+The design must therefore **interleave I2C draining with SPDM progress** while
+the requester is waiting for a response. Two viable shapes:
+
+| # | Shape | Loop call site | SPDM call site |
+|---|---|---|---|
+| A | External FSM with two-phase loop (same shape as responder) | mctp_server main | Only advances when I2C has produced a message |
+| B | Blocking client that pumps I2C inside `recv` | mctp_server main (simpler) | `requester_process_message` looks synchronous |
+
+We pick **Shape A** for symmetry with the responder and because it keeps
+`DirectMctpClient` unchanged and decoupled from I2C. Shape B is recorded in
+§9 as a future simplification.
+
+---
+
+## 3. Architecture
+
+```text
+┌─ I2C Hardware ───────────────────────────────────────┐
+│  Slave-mode frames arrive on bus 2                   │
+│  (responses from the remote SPDM responder)          │
+└──────────────┬───────────────────────────────────────┘
+               │ wait_for_messages() → TargetMessage
+               ▼
+┌─ MCTP Server Process ────────────────────────────────┐
+│                                                      │
+│  Phase 1 (always):                                   │
+│     receiver.decode() → raw MCTP packet              │
+│     server_cell.borrow_mut().inbound(pkt)            │
+│        └─ Router reassembles fragments               │
+│                                                      │
+│  Phase 2 (requester FSM step):                       │
+│     match req_state {                                │
+│       SendVersion → generate_get_version             │
+│                     ctx.requester_send_request()     │
+│                     → I2cSender → wire               │
+│                     state = AwaitVersion             │
+│       AwaitVersion → ctx.requester_process_message() │
+│                      Ok  → state = SendCaps          │
+│                      Err → stay (TimedOut == normal) │
+│       … SendCaps / AwaitCaps / SendAlgs / AwaitAlgs  │
+│       Done        → log success, transition to Idle  │
+│     }                                                │
+│                                                      │
+│     All SPDM transport I/O goes through              │
+│     DirectMctpClient ↔ server_cell (no IPC).         │
+└──────────────────────────────────────────────────────┘
+```
+
+Same `RefCell<Server>` shared-ownership trick as the responder
+([direct_client.rs](src/direct_client.rs)).
+
+---
+
+## 4. Compilation Switches
+
+### 4.1 Cargo features (services/mctp/server/Cargo.toml)
+
+Current:
+
+```toml
+default = ["i2c-polling", "direct-client"]
+i2c-polling    = []   # polling I2C loop (no WG/IRQ)
+direct-client  = []   # expose DirectMctpClient
+```
+
+Add one new feature:
+
+```toml
+# Build the in-process SPDM requester inside the polling loop.
+# Mutually exclusive with `in-process-responder`.
+# Depends on `direct-client` (the in-process MctpClient impl).
+in-process-requester = ["direct-client"]
+```
+
+Rename the existing responder role to a feature that makes the two roles
+symmetric and mutually exclusive:
+
+```toml
+# Build the in-process SPDM responder inside the polling loop.
+# Mutually exclusive with `in-process-requester`.
+in-process-responder = ["direct-client"]
+```
+
+Keep `direct-client` as the lower-level capability (exposes
+`DirectMctpClient`). Either role feature implies it.
+
+Backwards compatibility: keep `direct-client` alone as a valid build (library
+only; no SPDM role instantiated in main), and treat the old
+`i2c-polling + direct-client` combination as equivalent to
+`i2c-polling + in-process-responder` via a transitional alias in `Cargo.toml`:
+
+```toml
+# Transitional: prior builds selected the responder via bare `direct-client`.
+# New builds should name the role explicitly.
+```
+
+The Bazel `rust_app` target keeps `direct-client` until call sites migrate to
+the role-named feature.
+
+### 4.2 Build-time guard
+
+A `compile_error!` in [src/main.rs](src/main.rs) rejects enabling both roles:
+
+```rust
+#[cfg(all(feature = "in-process-requester", feature = "in-process-responder"))]
+compile_error!(
+    "features `in-process-requester` and `in-process-responder` are mutually exclusive"
+);
+```
+
+### 4.3 Bazel `rust_app` target
+
+New target `mctp_server_requester` in
+[services/mctp/server/BUILD.bazel](services/mctp/server/BUILD.bazel), parallel
+to the existing `mctp_server` target:
+
+```starlark
+rust_app(
+    name = "mctp_server_requester",
+    codegen_crate_name = "app_mctp_server",       # same handle::MCTP/I2C bindings
+    srcs = ["src/main.rs"],
+    crate_features = [
+        "i2c-polling",
+        "in-process-requester",
+    ],
+    edition = "2024",
+    system_config = "//target/ast1060-evb/mctp:system_config",
+    tags = ["kernel"],
+    visibility = ["//visibility:public"],
+    deps = [
+        # … identical deps to mctp_server …
+    ],
+)
+```
+
+The existing `mctp_server` target flips to
+`crate_features = ["i2c-polling", "in-process-responder"]` once downstream
+system images migrate.
+
+### 4.4 System configuration
+
+No new kernel objects are required. The in-process requester reuses exactly
+the same `system.json5` process definition as the in-process responder
+([target/ast1060-evb/mctp/system.json5](../../target/ast1060-evb/mctp/system.json5)):
+`MCTP` channel_handler (still unused in polling+in-process mode), `I2C`
+channel_initiator, `WG` wait_group (unused in polling mode).
+
+---
+
+## 5. Module Layout
+
+All new code in [services/mctp/server/src/main.rs](src/main.rs); no new files
+unless a helper grows past ~50 lines.
+
+```
+src/main.rs
+ ├─ imports                                 (cfg-gated by role)
+ ├─ consts: OWN_EID, OWN_I2C_ADDR,
+ │          REMOTE_RESPONDER_EID (new, 42 to match spdm_requester.rs)
+ ├─ mctp_loop()  [feature = "i2c-polling"]
+ │    ├─ I2C setup (unchanged)
+ │    ├─ Server::new → RefCell<Server>    (unchanged)
+ │    ├─ #[cfg(in-process-responder)]  responder setup (unchanged)
+ │    ├─ #[cfg(in-process-requester)]  requester setup  (new, §6)
+ │    └─ loop {
+ │          Phase 1: drain I2C → server.inbound()       (unchanged)
+ │          Phase 2:
+ │             #[cfg(in-process-responder)] responder_step(..)
+ │             #[cfg(in-process-requester)] requester_step(&mut req_state, ..)
+ │       }
+ ├─ mctp_loop()  [not(i2c-polling)]         (unchanged)
+ └─ entry / panic_handler                   (unchanged)
+```
+
+If `requester_step` / `requester_setup` together exceed ~100 lines, move
+them into a new module `src/in_process_requester.rs`, gated
+`#[cfg(feature = "in-process-requester")]`, exposing:
+
+```rust
+pub(crate) struct InProcessRequester<'a, S: Sender, const N: usize> { … }
+impl<…> InProcessRequester<'_, S, N> {
+    pub fn new(server: &'a RefCell<Server<S, N>>, remote_eid: u8, …) -> Result<Self, Error>;
+    pub fn step(&mut self);    // one FSM step; no-op when Done
+    pub fn is_done(&self) -> bool;
+}
+```
+
+Prefer the in-line `main.rs` layout first (matches the responder pattern);
+extract only if the symmetry breaks readability.
+
+---
+
+## 6. Requester State Machine
+
+### 6.1 States
+
+```rust
+#[cfg(feature = "in-process-requester")]
+enum ReqState {
+    SendVersion,
+    AwaitVersion,
+    SendCapabilities,
+    AwaitCapabilities,
+    SendAlgorithms,
+    AwaitAlgorithms,
+    Done,       // success; loop idles in Phase 2 no-op
+    Failed,     // terminal; loop idles in Phase 2 no-op
+}
+```
+
+Start state: `SendVersion`.
+
+### 6.2 Step semantics (Phase 2)
+
+One **step** = one Phase 2 invocation of the FSM from one loop iteration.
+A step must return promptly so that Phase 1 keeps draining I2C on the next
+iteration — a step never blocks waiting for a response.
+
+```rust
+match req_state {
+    ReqState::SendVersion => {
+        msg_buf.reset();
+        if generate_get_version(&mut ctx, &mut msg_buf, VersionReqPayload::new(0, 0)).is_err()
+           || ctx.requester_send_request(&mut msg_buf, REMOTE_RESPONDER_EID).is_err() {
+            req_state = ReqState::Failed;
+        } else {
+            req_state = ReqState::AwaitVersion;
+        }
+    }
+    ReqState::AwaitVersion => {
+        msg_buf.reset();
+        match ctx.requester_process_message(&mut msg_buf) {
+            Ok(_)  => req_state = ReqState::SendCapabilities,
+            Err(_) => { /* TimedOut == stay; protocol errors also stay but counted */ }
+        }
+    }
+    // … same pattern for Capabilities, Algorithms …
+    ReqState::Done | ReqState::Failed => { /* no-op */ }
+}
+```
+
+### 6.3 Distinguishing "no message yet" from real protocol errors
+
+`requester_process_message` currently returns a single `Err(_)` value that
+collapses transport `TimedOut` with genuine SPDM protocol errors. Two options:
+
+1. **Short-term**: Ride on `Err(_)` as "stay in state" — accept that a real
+   protocol error would wedge the requester in an await state forever. Add
+   a safety-net **retry budget** per await state (e.g. 10,000 steps) after
+   which `req_state = ReqState::Failed`.
+
+2. **Proper fix**: Plumb `TransportError` through `requester_process_message`
+   so callers can discriminate. Preferred, but touches spdm-lib — track as
+   a follow-up.
+
+Ship option 1; open follow-up for option 2.
+
+### 6.4 Retry / timeout budget
+
+Per await state:
+
+```rust
+const AWAIT_STEP_BUDGET: u32 = 10_000;
+let mut await_steps: u32 = 0;
+// on each Err in an Await* state:
+await_steps = await_steps.wrapping_add(1);
+if await_steps >= AWAIT_STEP_BUDGET {
+    req_state = ReqState::Failed;
+}
+// reset to 0 on state transition
+```
+
+10k steps at the measured steady-state poll rate gives an order-of-magnitude
+wall-clock ceiling; the exact number is tunable once we measure.
+
+### 6.5 Interleaving constraints
+
+The two-phase loop's interleaving of I2C draining and SPDM FSM steps is the
+mechanism that makes the design work — without Phase 1 pumping inbound
+fragments into the router, Phase 2's `requester_process_message` would
+never see an assembled response. Three properties must hold for this to
+be safe and responsive:
+
+1. **Latency tax per step.** Each loop iteration runs Phase 1 (one
+   `wait_for_messages` call) before Phase 2 (FSM step). If the response is
+   already assembled in the router, the FSM still waits out a Phase 1 poll
+   cycle before consuming it — one I2C poll-budget duration of added
+   latency per step. Acceptable for VCA at wire speed; revisit if
+   attestation flows grow enough steps to care.
+
+2. **Multi-fragment responses.** `wait_for_messages` reads at most one
+   frame per call (`msgs: [TargetMessage; 1]`). A response that spans N
+   MCTP fragments needs N Phase-1 iterations before Phase 2 can succeed,
+   so `AWAIT_STEP_BUDGET` (§6.4) must cover
+   `N × (I2C poll period)` for the largest expected message. The 10,000
+   default is generous for VCA; recheck when GET_CERTIFICATE lands.
+
+3. **`wait_for_messages` must be non-blocking.** The design relies on
+   `wait_for_messages(..., None)` returning `Err(TimedOut)` when idle
+   rather than blocking forever; otherwise Phase 2 never runs. The
+   responder loop already depends on this — see the comment at
+   [src/main.rs:390-393](src/main.rs#L390-L393).
+
+Non-issues (explicitly enumerated so future readers don't reopen them):
+
+- **Cross-flow routing.** The MCTP router dispatches by handle; unrelated
+  inbound MCTP traffic cannot land in the requester's `req_handle` queue.
+- **RefCell borrow conflicts.** Phase 1's `server.borrow_mut()` finishes
+  before Phase 2 starts; the borrows never overlap.
+- **Dropped frames during send.** `requester_send_request` blocks on IPC
+  to the I2C server, but slave-mode receives are buffered in the I2C
+  server process and picked up on the next `wait_for_messages`.
+
+---
+
+## 7. Initialization Sequence (requester mode)
+
+Executed once before the loop, between I2C setup and the `loop { }`. Mirrors
+responder setup at
+[src/main.rs:182-299](src/main.rs#L182-L299); the only functional differences
+are marked **[R]**.
+
+```
+1.  DirectMctpClient::new(&server)              [same]
+2.  MctpSpdmTransport::new_requester(client, REMOTE_RESPONDER_EID)  [R]
+3.  transport.init_sequence()                   [R: allocates req handle,
+                                                     not listener]
+4.  MockCertStore / MockHash×3 / MockRng / MockEvidence            [same]
+5.  DemoPeerCertStore (needed for future GET_DIGESTS/CERTIFICATE)   [R]
+6.  CapabilityFlags: cert_cap=1, chal_cap=1, meas_cap=0, chunk_cap=1 [R]
+                                                 (no meas_fresh_cap; meas_cap=0)
+7.  DeviceCapabilities { include_supported_algorithms: false }      [R]
+                                (see spdm_requester.rs comment: V1.3 param1 bit 2
+                                 is rejected by current responder)
+8.  LocalDeviceAlgorithms (identical to responder)  [same]
+9.  SpdmContext::new(..., peer_cert_store=Some(&mut demo_peer), ...) [R]
+10. MessageBuf (reused across FSM states)        [same]
+11. req_state = ReqState::SendVersion            [R]
+```
+
+Constants borrowed verbatim from
+[target/ast1060-evb/spdm-req-resp-test/spdm_requester.rs](../../target/ast1060-evb/spdm-req-resp-test/spdm_requester.rs)
+so behavior matches the existing external requester app bit-for-bit.
+
+---
+
+## 8. Observability
+
+Mirror the responder’s fault-isolation counters. All `u32`, log on first
+event and every Nth after to avoid flooding:
+
+| Counter | Increment on | Log cadence |
+|---|---|---|
+| `i2c_pkt` | successful `receiver.decode` | already exists |
+| `inbound_err` | `server.inbound()` error | already exists |
+| `decode_err` | `receiver.decode()` error | already exists |
+| `i2c_recv_err` | `wait_for_messages` non-timeout error | already exists |
+| `idle_polls` | `wait_for_messages` timeout | already exists |
+| `req_state_entered` | each ReqState transition | log every transition |
+| `req_send_ok` | successful requester_send_request | every event |
+| `req_send_err` | failed requester_send_request | every event (terminal) |
+| `req_recv_ok` | successful requester_process_message | every event |
+| `req_recv_pending` | Err in Await* state (treated as TimedOut) | first + every 256th |
+| `req_recv_err_terminal` | state transitioned to Failed | every event |
+
+A `ReqState::Done` transition logs:
+
+```
+SPDM VCA completed: version/caps/algs OK in N steps, elapsed ~M idle_polls
+```
+
+after which the loop continues stepping (Phase 1 still drains I2C so later
+inbound traffic is handled; Phase 2 is a no-op).
+
+---
+
+## 9. Open Questions / Future Work
+
+1. **Blocking DirectMctpClient variant (Shape B)**: add an
+   `I2cPumpingDirectMctpClient` that holds `&I2cClient + &MctpI2cReceiver +
+   &RefCell<Server>` and, inside `recv`, drains I2C until a message is
+   assembled (or a deadline expires). With that, `spdm_requester_test()` from
+   the existing app could be reused almost verbatim and the external FSM
+   disappears. Requires splitting current Server RefCell borrows or wrapping
+   in a struct to satisfy the borrow checker.
+
+2. **TransportError discrimination**: plumb the underlying `TransportError`
+   through `requester_process_message` so we can distinguish
+   `NoRequestInFlight` / `ReceiveError(TimedOut)` from protocol errors
+   without a step budget.
+
+3. **Triggering**: when does the requester start? Currently the spec assumes
+   "immediately at boot, single-shot VCA". A real use will want external
+   triggers — an IPC operation (`start_spdm`, target EID as arg), a GPIO,
+   or a policy. Out of scope here; add when there is a caller.
+
+4. **Mutual exclusion of roles**: we chose mutually-exclusive features. If
+   we later want a **dual role** (a device that both attests others and is
+   attested), we need two SpdmContexts sharing one Server — tractable but
+   not covered by this spec.
+
+5. **Mock platform sharing**: `target/ast1060-evb/spdm-req-resp-test/mock_platform`
+   is already a dep of `mctp_server`. Reuse as-is; if the cert material
+   diverges between requester and responder builds, consider a
+   `mock_platform_requester` / `mock_platform_responder` split.
+
+---
+
+## 10. Summary of Compilation Switch Matrix (post-change)
+
+| Features | I2C IRQ | IPC served | In-process role |
+|---|---|---|---|
+| _(none)_ | WG + IRQ | MCTP clients | none |
+| `i2c-polling` | polling | none | none |
+| `i2c-polling` + `in-process-responder` | polling | none | **responder** |
+| `i2c-polling` + `in-process-requester` | polling | none | **requester** |
+| any combination setting both roles | — | — | `compile_error!` |
+
+Default remains `i2c-polling + in-process-responder` (current behavior),
+until system images choose otherwise.
diff --git a/services/mctp/server/IMPLEMENTATION_PLAN.md b/services/mctp/server/IMPLEMENTATION_PLAN.md
new file mode 100644
index 0000000..ba935bc
--- /dev/null
+++ b/services/mctp/server/IMPLEMENTATION_PLAN.md
@@ -0,0 +1,536 @@
+# In-Process SPDM Requester — Implementation Plan
+
+Companion to [DESIGN_IN_PROCESS_REQUESTER.md](DESIGN_IN_PROCESS_REQUESTER.md).
+Breaks the design into landable phases, each independently compileable and
+testable so nothing goes dark on `main` between merges.
+
+## Status
+
+| Phase | Status | Notes |
+|---|---|---|
+| 0 — baseline build + assumption check | ✅ done | Required two pre-Phase-1 fixes (see Phase 0 Resolution Log) |
+| 1 — feature flag restructuring | ✅ done | `direct-client` is now the shared capability; role features `in-process-responder` / `in-process-requester` pick the FSM. Mutual-exclusion `compile_error!` in `main.rs`. |
+| 2 — compileable requester stub | ✅ done | Forced creation of a second system_image package `//target/ast1060-evb/mctp-requester` so the standalone `rust_app` target actually compiles (standalone `rust_app` hits a `pw_kernel/userspace` platform-constraint error without a system image). |
+| 3 — FSM happy path | ✅ done | Eight-state VCA FSM, one step per polling-loop iteration. |
+| 4 — safety net + counters | ✅ done | `AWAIT_STEP_BUDGET = 10_000`, `await_try!` macro, counters `req_send_ok` / `req_recv_ok` / `req_recv_pending`. |
+| 5 — Bazel target + system image | ✅ done (merged into Phase 2) | See Phase 2 note above. |
+| 6 — two-board integration test | ⏸ blocked | No on-target hardware available in this environment. See IMPLEMENTATION_PLAN.md §A for measurements to collect when hardware is accessible. |
+| 7 — docs + follow-up TODOs | ✅ done | [INITIALIZATION.md](INITIALIZATION.md) updated with requester glossary + setup steps; follow-up design refs annotated at the FSM in `main.rs`. |
+
+Build verification at end of Phase 7:
+
+```
+bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp:mctp                      # responder — clean
+bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp-requester:mctp_requester  # requester — clean
+```
+
+Legend:
+- **Goal** — what this phase must achieve.
+- **Deliverables** — concrete artifacts produced.
+- **Files** — edited / added in this phase.
+- **Exit criteria** — what proves the phase is done.
+- **Risks / rollback** — what can go wrong; how to back out.
+
+---
+
+## How To Build Which Role
+
+Role (requester vs. responder) is selected at **build time**, not at
+runtime. Same `src/main.rs` source, two binaries, chosen by which Bazel
+**system-image** target you build. Standalone `rust_app` targets do not
+build in isolation — `pw_kernel/userspace` requires a `target_codegen` →
+`kernel_config` chain that only exists inside a `system_image`.
+
+**Developer commands:**
+
+```bash
+# Responder image (default)
+bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp:mctp
+
+# Requester image
+bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp-requester:mctp_requester
+```
+
+Running any test or integration probe uses the same prefix
+(`bazel test --config=k_ast1060_evb <target>`).
+
+**Selection chain** — each layer picks the next:
+
+```
+ System image                                    →  rust_app label                              →  Cargo features                                           →  #[cfg] in main.rs
+ ────────────────────────────────────────────────   ─────────────────────────────────────────────   ───────────────────────────────────────────────────────   ─────────────────────────
+ //target/ast1060-evb/mctp:mctp                  →  //services/mctp/server:mctp_server          →  ["i2c-polling", "direct-client", "in-process-responder"] →  responder setup + step
+ //target/ast1060-evb/mctp-requester:mctp_requester →  //services/mctp/server:mctp_server_requester  →  ["i2c-polling", "direct-client", "in-process-requester"] →  requester setup + FSM
+```
+
+Each `rust_app` has its own `codegen_crate_name` — `app_mctp_server` for
+the responder, `app_mctp_server_requester` for the requester — because two
+`rust_app` rules in the same Bazel package cannot share the codegen name.
+`src/main.rs` picks the right one via a cfg-gated `use`.
+
+**Guards:**
+
+- Both roles at once → rejected by `compile_error!` at the top of `main.rs`.
+- Neither role + `i2c-polling` → polling loop compiles with no SPDM in-process
+  (still valid; drains I2C only).
+- No `i2c-polling` → the notification (WG + IRQ) loop compiles; role features
+  are ignored because `in-process-*` code is also gated on `i2c-polling`.
+- `direct-client` is listed explicitly in both `crate_features` sets;
+  Bazel's `rust_binary` does not resolve Cargo-level feature implications
+  (`in-process-*  = ["direct-client"]`), so the transitive dep must be
+  named directly.
+
+**What flashes:** each system image binds one of the two `rust_app` labels
+via its `apps = [...]` list, together with `i2c_server`. The two images
+are mutually exclusive — flash one or the other, not both.
+
+**Direct `cargo build` is not a supported path** for the binary — the
+package sets `autobins = false` and `main.rs` is a Pigweed binary built
+only through Bazel. The Cargo features still matter because Bazel passes
+them through; they're just never consumed by a bare `cargo` invocation
+of `main.rs`.
+
+---
+
+## Phase 0 — Baseline & Assumption Verification
+
+**Goal**: confirm the starting source tree builds clean before any new
+code is written. Source-level only — no on-target execution is required
+or assumed at this stage.
+
+**Deliverables**:
+- Successful Bazel build of
+  `//target/ast1060-evb/mctp:mctp` (responder system image) at current
+  `ocp-emea-demo-spdm-spdm-req-resp-anthony-i2c-polling` HEAD.
+- Source-level re-read of the existing polling loop at
+  [src/main.rs:322-445](src/main.rs#L322-L445) to confirm the interleaving
+  assumptions in design §6.5 still match the code (in particular, that
+  `wait_for_messages(..., None)` paired with the `e.is_timeout()` arm at
+  [src/main.rs:390](src/main.rs#L390) matches constraint §6.5.3).
+- **Skipped:** standalone build of
+  `//target/ast1060-evb/spdm-req-resp-test:spdm_requester_app`. The app is
+  marked `# DISABLED` in the sibling `system_image` and fails platform
+  analysis when built alone. The reference constants in
+  [spdm_requester.rs](../../target/ast1060-evb/spdm-req-resp-test/spdm_requester.rs)
+  are consumed as source, not as a binary, so skipping this build does
+  not block later phases.
+
+**Files**: none modified **initially**. See Phase 0 Resolution Log below.
+
+**Exit criteria**: the responder system image builds clean; the §6.5
+cross-check is recorded as "verified against HEAD" in the commit
+message of Phase 1.
+
+**Risks / rollback**: none — read-only phase by default; any unblocker
+applied here gets its own commit (see log below).
+
+### Phase 0 Resolution Log
+
+- **Blocker discovered**: at branch HEAD, commit `023e75e "Update mctp-lib
+  (to hotfix branch)"` changed `mctp_lib::Router::inbound` to return
+  `Result<Option<AppCookie>, MctpError>`, but the
+  [services/mctp/server/src/server.rs:267-271](src/server.rs#L267-L271)
+  wrapper `Server::inbound` still declared `Result<(), MctpError>`. The
+  error surfaces as E0308 and fails *every* Bazel target that depends on
+  `mctp_server_lib` (responder system image, req-resp test image,
+  etc.).
+- **Verification that the break is isolated**: a worktree at
+  `d4719a0 "Chasing down an MCTP packet issue."` (the commit immediately
+  preceding the mctp-lib hotfix) built `//target/ast1060-evb/mctp:mctp`
+  clean. So the break is entirely within `023e75e`.
+- **Resolution — Fix A** (discard the new cookie):
+  ```rust
+  pub fn inbound(&mut self, pkt: &[u8]) -> Result<(), MctpError> {
+      self.stack
+          .inbound(pkt)
+          .map(|_| ())
+          .map_err(mctp_error_to_server_error)
+      }
+  ```
+  Chosen over Fix B (propagate `Option<AppCookie>` to callers) because:
+  no caller reads the return value today, the propagation case is cheap
+  to add later if Shape-B from design §9.1 ever lands, and keeping the
+  unblocker orthogonal to the requester work eases `git blame`.
+  Landed as its own small commit before any Phase 1 work.
+
+- **Second blocker** (surfaced after Fix A unblocked rustc past the first
+  error): the same mctp-lib hotfix changed
+  `MctpI2cReceiver::decode`'s return type from `(&[u8], u8)` to
+  `(&[u8], MctpI2cHeader)` — a struct
+  `{ dest: u8, source: u8, byte_count: usize }` — but
+  [src/main.rs:350-361](src/main.rs#L350-L361) still treated the second
+  tuple element as a bare `u8` source address. `as u32` on a struct
+  triggers E0605.
+- **Resolution — Fix A′** (match the new type, minimum diff): rename
+  the binding from `src_addr` to `hdr` and read the source address
+  field explicitly: `hdr.source as u32`. Landed in the same
+  "pre-Phase-1 unblocker" commit as Fix A.
+
+**Deferred to on-target bringup**: measuring actual idle-poll rate,
+`wait_for_messages` latency, and worst-case fragment count is out of
+scope for Phase 0. `AWAIT_STEP_BUDGET` will ship with a generous
+compile-time default (§6.4) and be tuned when a runtime becomes
+available — see Appendix §A.
+
+---
+
+## Phase 1 — Feature Flag Restructuring
+
+**Goal**: rename the responder role feature and add the requester role
+feature, without changing behavior. Pure refactor.
+
+**Deliverables**:
+- `services/mctp/server/Cargo.toml`:
+  - Add `in-process-responder = ["direct-client"]`.
+  - Add `in-process-requester = ["direct-client"]`.
+  - Update `default` to `["i2c-polling", "in-process-responder"]`.
+  - Keep `direct-client` as-is (library-exposed capability).
+- `services/mctp/server/src/main.rs`:
+  - Add at top:
+    ```rust
+    #[cfg(all(feature = "in-process-requester", feature = "in-process-responder"))]
+    compile_error!("features `in-process-requester` and `in-process-responder` are mutually exclusive");
+    ```
+  - Replace every `#[cfg(feature = "direct-client")]` inside the polling
+    loop and its setup block with
+    `#[cfg(feature = "in-process-responder")]`.
+  - Leave the `mod direct_client;` export and `DirectMctpClient` untouched
+    — those belong to the `direct-client` feature, which still exists.
+- `services/mctp/server/BUILD.bazel`:
+  - `mctp_server` target: replace
+    `crate_features = ["i2c-polling", "direct-client"]` with
+    `["i2c-polling", "in-process-responder"]`.
+  - Keep `rust_library` `mctp_server_lib` with `crate_features = ["direct-client"]`.
+
+**Files**: `Cargo.toml`, `src/main.rs`, `BUILD.bazel`.
+
+**Exit criteria**:
+- `bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp:mctp`
+  succeeds (the standalone `rust_app` target cannot be built in isolation
+  because `pw_kernel/userspace` needs a system-image codegen context).
+- Built binary is byte-identical in behavior to Phase 0 baseline (responder
+  loop still runs; log lines at startup unchanged).
+- Attempting to build with both features enabled emits the
+  `compile_error!`.
+
+**Risks / rollback**:
+- *Risk*: downstream Bazel targets still set `direct-client` directly and
+  silently lose the responder. *Mitigation*: grep the repo for
+  `direct-client` in `crate_features`; the only consumer is
+  `services/mctp/server/BUILD.bazel`.
+- *Rollback*: revert the single refactor commit; no logic changed.
+
+---
+
+## Phase 2 — Compileable Requester Stub
+
+**Goal**: add the requester setup skeleton behind
+`in-process-requester` as a no-op Phase 2 step. Proves feature gating,
+imports, and the init sequence compile — no FSM logic yet.
+
+**Deliverables**:
+- `services/mctp/server/src/main.rs`:
+  - Add constant `const REMOTE_RESPONDER_EID: u8 = 42;` (module-scope, not
+    cfg-gated — a single `#[allow(dead_code)]` keeps it clean when the
+    feature is off).
+  - Add imports gated on
+    `#[cfg(all(feature = "i2c-polling", feature = "in-process-requester"))]`:
+    mock platform types, `MctpSpdmTransport`, `SpdmContext`, VCA
+    `generate_*` functions, `DemoPeerCertStore`.
+  - Inside `mctp_loop()` under the same cfg, add the full init sequence
+    from §7 of the design doc:
+    1. `DirectMctpClient::new(&server)`
+    2. `MctpSpdmTransport::new_requester(client, REMOTE_RESPONDER_EID)`
+    3. `transport.init_sequence()` with error log + return
+    4. Mock platform instances (cert_store, hash×3, rng, evidence,
+       peer_cert_store)
+    5. `CapabilityFlags` / `DeviceCapabilities` matching
+       [spdm_requester.rs](../../target/ast1060-evb/spdm-req-resp-test/spdm_requester.rs)
+    6. `LocalDeviceAlgorithms` (factor into a helper to avoid duplicating
+       the responder's inline block)
+    7. `SpdmContext::new(..., Some(&mut peer_cert_store), ...)`
+    8. `MessageBuf` on stack buffer
+    9. `let mut req_state = ReqState::SendVersion;` — **but** Phase 2
+       step body is still just
+       `#[allow(unused_variables)] let _ = &mut req_state;` with a log
+       `"requester FSM stub: state=..."` every 0xfff loops.
+  - Add the `ReqState` enum (full variant set — §6.1) even though only
+    one variant is used, so Phase 3 just fills in match arms.
+
+**Files**: `src/main.rs`.
+
+**Exit criteria**:
+- `bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp-requester:mctp_requester`
+  succeeds.  (Phase 2 verification required pulling Phase 5's
+  system-image package forward — a standalone `rust_app` target is not
+  buildable on its own in this tree; see `mctp-requester/` for the
+  system image created here.)
+- The responder build is unaffected.
+- Running the requester build on-target logs the init sequence, then
+  idle-poll messages with the stub state — no SPDM traffic emitted yet.
+
+**Risks / rollback**:
+- *Risk*: Rust borrow checker rejects `SpdmContext::new` arg layout
+  because `transport` and `server` share a lifetime. *Mitigation*: mirror
+  the responder's exact let-binding order, which is already known to
+  compile.
+- *Risk*: `DemoPeerCertStore` import path differs from responder
+  (responder uses `None`). *Mitigation*: path already resolved in
+  `spdm_requester.rs` — copy.
+- *Rollback*: revert commit; responder build unaffected.
+
+---
+
+## Phase 3 — FSM Implementation (Happy Path)
+
+**Goal**: wire the six Send/Await states so a full VCA flow completes on
+the wire.
+
+**Deliverables**:
+- Replace the Phase-2 stub body with the match from §6.2 of the design
+  doc. Each Send state:
+  1. `msg_buf.reset()`
+  2. `generate_<step>(...)`
+  3. `ctx.requester_send_request(&mut msg_buf, REMOTE_RESPONDER_EID)`
+  4. Advance to matching `Await*` state.
+- Each Await state:
+  1. `msg_buf.reset()`
+  2. `ctx.requester_process_message(&mut msg_buf)`
+  3. `Ok(_)` → advance; `Err(_)` → stay (counted, not yet budgeted).
+- On entry to `ReqState::Done`, log once:
+  `"SPDM VCA completed: version/caps/algs OK"`.
+- `Done` and `Failed` states become no-ops in Phase 2 (Phase 1 keeps
+  draining I2C unconditionally, so late inbound traffic is still handled).
+
+**Files**: `src/main.rs`.
+
+**Exit criteria**:
+- End-to-end test on AST1060-EVB against the existing
+  `spdm_responder` app running on a second device (or the same device
+  with the responder built separately) produces three successful
+  request/response pairs.
+- Protocol-analyzer capture (or I2C frame logs already present) shows
+  GET_VERSION / GET_CAPABILITIES / NEGOTIATE_ALGORITHMS emitted in order
+  with matching response consumption.
+- `spdm_err` / equivalent pending-counter stays bounded (not monotonically
+  growing past expected poll-while-waiting count).
+
+**Risks / rollback**:
+- *Risk*: a protocol error wedges the FSM in an Await state forever
+  (design §6.3, option 1 not yet implemented). *Mitigation*: Phase 4
+  adds the step budget; until then, a power-cycle is acceptable for
+  bringup.
+- *Risk*: `include_supported_algorithms = false` / other capability
+  fields diverge between the in-process requester and the responder's
+  expectations. *Mitigation*: copy verbatim from `spdm_requester.rs`;
+  already known to interoperate with `spdm_responder.rs`.
+- *Rollback*: revert to Phase 2 stub.
+
+---
+
+## Phase 4 — Safety Net & Observability
+
+**Goal**: bound failure latency and give on-target debugging the same
+counter set as the responder.
+
+**Deliverables**:
+- `AWAIT_STEP_BUDGET` const + `await_steps` counter per design §6.4,
+  reset on every state transition, transitions FSM to `Failed` on
+  exhaustion with an error log naming the state that timed out.
+- Counters from design §8, each `u32` wrapping, logged on first event and
+  every 256th:
+  - `req_send_ok`, `req_send_err`, `req_recv_ok`, `req_recv_pending`,
+    `req_recv_err_terminal`.
+- State-transition log (one line per transition, not rate-limited — low
+  volume).
+- `Done` transition log augmented with
+  `"completed in {N} steps, ~{idle_polls} idle polls"`.
+
+**Files**: `src/main.rs`.
+
+**Exit criteria**:
+- Forcing a fault (e.g. responder offline) causes `Failed` transition
+  within the budget and emits the named-state error; device does not
+  hang.
+- Counters visible in logs match observed behavior (send counts == 3 on
+  success path; pending spikes and settles at each Await entry).
+
+**Risks / rollback**:
+- *Risk*: step budget too tight → spurious failures under a legitimately
+  multi-fragment response (§6.5.2). *Mitigation*: ship with a deliberately
+  generous default (`AWAIT_STEP_BUDGET = 10_000`) and defer tightening
+  until on-target measurements exist (see Appendix §A). Erring high
+  costs only wall-clock on genuine failures; erring low wedges the
+  happy path.
+- *Rollback*: revert budget block, keep counters (useful regardless).
+
+---
+
+## Phase 5 — Bazel Target & System Image
+
+**Goal**: make the requester role selectable as a first-class Bazel build
+without touching the responder's target.
+
+**Deliverables**:
+- New target in `services/mctp/server/BUILD.bazel`:
+  ```starlark
+  rust_app(
+      name = "mctp_server_requester",
+      codegen_crate_name = "app_mctp_server",
+      srcs = ["src/main.rs"],
+      crate_features = ["i2c-polling", "in-process-requester"],
+      edition = "2024",
+      system_config = "//target/ast1060-evb/mctp:system_config",
+      tags = ["kernel"],
+      visibility = ["//visibility:public"],
+      deps = [ # identical to mctp_server
+          ":mctp_server_lib",
+          "//services/i2c/api:i2c_api",
+          "//services/i2c/client:i2c_client",
+          "//services/mctp/api:mctp_api",
+          "//services/mctp/transport-i2c:mctp_transport_i2c",
+          "//services/spdm/transport-mctp:spdm_transport_mctp",
+          "//target/ast1060-evb/spdm-req-resp-test:mock_platform",
+          "@rust_crates//:mctp",
+          "@rust_crates//:mctp-lib",
+          "@pigweed//pw_kernel/syscall:syscall_user",
+          "@pigweed//pw_kernel/userspace",
+          "@pigweed//pw_log/rust:pw_log",
+          "@pigweed//pw_status/rust:pw_status",
+          "@oot_crates_no_std//:spdm-lib",
+      ],
+  )
+  ```
+- Choose whether the default system image
+  ([target/ast1060-evb/mctp/BUILD.bazel](../../target/ast1060-evb/mctp/BUILD.bazel))
+  wires the requester or the responder. Do **not** change the default in
+  this phase — add a second system image build instead (e.g.
+  `system_requester`) so both can be flashed side by side during
+  bringup.
+
+**Files actually landed**:
+- `services/mctp/server/BUILD.bazel` — second `rust_app(name = "mctp_server_requester", codegen_crate_name = "app_mctp_server_requester", ...)` target.
+- `target/ast1060-evb/mctp-requester/BUILD.bazel` — new sibling package with its own `system_image`, `target_codegen`, `target_linker_script`, `rust_binary(name="target",...)`, and `uart_boot_image`.
+- `target/ast1060-evb/mctp-requester/system.json5` — copy of the mctp image's config with the MCTP app renamed to `mctp_server_requester`.
+- `target/ast1060-evb/mctp-requester/target.rs` — copy of the kernel entry shim.
+
+**Exit criteria**:
+- Both system images build clean from scratch:
+  ```
+  bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp:mctp
+  bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp-requester:mctp_requester
+  ```
+- Responder system image continues to boot unchanged.
+- Requester system image boots and reaches the FSM entry log within
+  the expected startup window (deferred to on-target bringup — Phase 6).
+
+**Risks / rollback**:
+- *Risk*: `codegen_crate_name` collision when both `rust_app` targets
+  live in the same Bazel package. *Resolved*: the two targets use
+  distinct codegen crate names (`app_mctp_server`,
+  `app_mctp_server_requester`) and `src/main.rs` selects between them
+  with a cfg-gated `use`.
+- *Risk*: platform-constraint analysis error when building
+  `rust_app` standalone. *Resolved*: the `rust_app` target must be
+  consumed by a `system_image`, never built directly.
+- *Rollback*: delete the new system-image package; responder unaffected.
+
+---
+
+## Phase 6 — Integration Test
+
+**Goal**: a repeatable test that exercises the in-process requester
+against the in-process responder on the bench.
+
+**Deliverables**:
+- Two-board test config: board A flashed with
+  `mctp_server_requester` system image, board B flashed with
+  `mctp_server` (responder). Both use current `OWN_EID` / `OWN_I2C_ADDR`
+  constants, with board A targeting board B at
+  `REMOTE_RESPONDER_EID = 42` — board B must be re-configured to EID
+  42 (its current `OWN_EID = 0x08`; this requires a new const
+  `RESPONDER_OWN_EID` on the responder image, or swapping A↔B roles).
+- Documented test recipe in `README.md` (new section) or a dedicated
+  `tests/ON_TARGET_README.md`: flash steps, expected log lines, expected
+  duration-to-`Done`.
+- Automated log-checking script is out of scope; bringup-level manual
+  verification is sufficient.
+
+**Files**: docs only, plus any const bump in main.rs if we change
+`OWN_EID` on the responder side.
+
+**Exit criteria**:
+- Manual test produces a `SPDM VCA completed` line on the requester side
+  and three matching processed-request lines on the responder side.
+
+**Risks / rollback**:
+- *Risk*: EID mismatch between the two images. *Mitigation*: the spec
+  prescribes explicit named constants for both ends; document the
+  pairing in the test recipe.
+- *Rollback*: none needed — docs only.
+
+---
+
+## Phase 7 — Cleanup & Follow-Up Tickets
+
+**Goal**: close loose ends the design doc called out and make sure
+nothing is left under-documented.
+
+**Deliverables**:
+- File follow-up issues (or local TODO comments tagged with a tracking
+  tag) for:
+  - Design §6.3 option 2 — propagate `TransportError` out of
+    `requester_process_message` so TimedOut can be discriminated.
+  - Design §9.1 — pumping-`DirectMctpClient` variant (Shape B).
+  - Design §9.3 — external trigger mechanism (IPC / GPIO / policy).
+  - Design §9.4 — dual-role configuration.
+- Update [INITIALIZATION.md](INITIALIZATION.md) with a new "Polling mode
+  (requester)" subsection mirroring the responder's, so the glossary
+  stays truthful.
+- Update the build-modes table in both `main.rs` module docs and
+  `INITIALIZATION.md` to list the new requester row.
+
+**Files**: `INITIALIZATION.md`, `src/main.rs` (module-level doc comment
+only).
+
+**Exit criteria**: follow-up issues filed; docs reviewed; no
+undocumented feature flag.
+
+**Risks / rollback**: none — docs and issue filing.
+
+---
+
+## Phase Dependency Graph
+
+```
+Phase 0 ──► Phase 1 ──► Phase 2 ──► Phase 3 ──► Phase 4 ──► Phase 5 ──► Phase 6 ──► Phase 7
+                          │                                    ▲
+                          └────────────────────────────────────┘
+                    (Phase 5 can start after Phase 2 in parallel
+                     with Phase 3/4 if a separate hand is available)
+```
+
+Phases 1 and 2 are small (half-day each). Phase 3 is the bulk of the
+work — expect a full day including on-target debug. Phases 4–6 are
+each half-day. Phase 7 is a loose-ends sweep.
+
+---
+
+## A. Deferred Measurements (on-target bringup)
+
+These values are **not** required to land Phases 0–5. Capture them once
+hardware execution is available (Phase 6 onward) and revisit §6.4 of the
+design doc if any value contradicts the back-of-envelope `10_000`
+default.
+
+| Metric | Measured value | Source |
+|---|---|---|
+| `idle_polls` increment rate (per wall-clock second) | _deferred_ | responder log, counted over a fixed window |
+| `wait_for_messages` average return latency when idle | _deferred_ | derived from idle_polls rate |
+| Largest expected VCA message fragment count | _deferred_ | `receiver.decode` SOM/EOM logs |
+| Observed peak `await_steps` on happy path | _deferred_ | requester log at `Done` transition |
+| Tuned `AWAIT_STEP_BUDGET` (≥ ~3× observed peak) | _deferred_ | computed |
+
+Until these exist, the design ships with the default and relies on the
+Phase-4 state-timeout log line to surface a bad choice loudly rather
+than silently.
diff --git a/services/mctp/server/INITIALIZATION.md b/services/mctp/server/INITIALIZATION.md
index fb6fe1e..7a1cd31 100644
--- a/services/mctp/server/INITIALIZATION.md
+++ b/services/mctp/server/INITIALIZATION.md
@@ -6,14 +6,24 @@
 
 ## Build Modes
 
-The server compiles into one of two mutually exclusive modes, selected by Cargo
-feature flags set in the Bazel `rust_app` target.
+The server compiles into one of several mutually exclusive modes, selected by
+Cargo feature flags set in the Bazel `rust_app` target.  `in-process-requester`
+and `in-process-responder` are mutually exclusive and the binary emits a
+`compile_error!` if both are enabled at once.
 
-| Feature flags | Mode | IPC served? | SPDM in-process? |
+| Feature flags | Mode | IPC served? | SPDM role in-process |
 |---|---|---|---|
-| _(none)_ | Notification | Yes | No |
-| `i2c-polling` | Polling | No | No |
-| `i2c-polling` + `direct-client` | Polling + SPDM | No | Yes |
+| _(none)_ | Notification | Yes | none |
+| `i2c-polling` | Polling | No | none |
+| `i2c-polling` + `direct-client` + `in-process-responder` | Polling + SPDM | No | responder |
+| `i2c-polling` + `direct-client` + `in-process-requester` | Polling + SPDM | No | requester |
+
+Build targets:
+
+```
+bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp:mctp                      # responder image
+bazel build --config=k_ast1060_evb //target/ast1060-evb/mctp-requester:mctp_requester  # requester image
+```
 
 ---
 
@@ -70,7 +80,7 @@
        → MCTP router, exclusively owned.
          Stored in `server_plain`.
 
-── SPDM setup (direct-client only) ──────────────────────────────────────────
+── SPDM setup (in-process-responder) ──────────────────────────────────────
 
 9.  DirectMctpClient::new(&server_cell)
        → In-process MctpClient. Calls server_cell methods directly;
@@ -116,6 +126,58 @@
          Both declared outside the loop to avoid borrow-checker conflict.
 ```
 
+── SPDM setup (in-process-requester) ──────────────────────────────────────
+
+Mirrors steps 9–18 of the responder path; only the differences are shown.
+
+```
+9.   DirectMctpClient::new(&server_cell)     [identical to responder]
+
+10.  MctpSpdmTransport::new_requester(client, REMOTE_RESPONDER_EID)
+       → SPDM transport layer in requester mode (targets EID 42).
+         `REMOTE_RESPONDER_EID = 42` matches `spdm_requester.rs`.
+
+11.  transport.init_sequence()
+       → Calls server.req(REMOTE_RESPONDER_EID) in-process, reserving a
+         request handle in the router.
+         FATAL if the router's request table is full.
+
+12.  MockCertStore / MockHash×3 / MockRng / MockEvidence  [identical]
+12a. DemoPeerCertStore::default()            [requester-only]
+       → Provides the peer's certificate chain to SPDM; required even if
+         only VCA is exercised.  Stored in `peer_cert_store`.
+
+13.  CapabilityFlags { cert_cap=1, chal_cap=1, meas_cap=0, chunk_cap=1 }
+       → No `meas_fresh_cap` (requester does not publish measurements).
+
+14.  DeviceCapabilities { include_supported_algorithms: false }
+       → V1.3 param1 bit 2 is rejected by the responder as reserved; leave
+         it unset so algorithm negotiation goes through NEGOTIATE_ALGORITHMS.
+
+15.  SUPPORTED_VERSIONS = [V12, V13]         [identical]
+16.  LocalDeviceAlgorithms (SHA-384, ECDSA-P384)  [identical]
+
+17.  SpdmContext::new(..., Some(&mut peer_cert_store), ...)
+       → Requester passes `Some(peer_cert_store)` (responder passes `None`).
+
+18.  spdm_buf + msg_buf                      [identical]
+
+19.  req_state = ReqState::SendVersion        [requester-only]
+       → Initial FSM state.  The Phase-2 step below walks through
+         SendVersion → AwaitVersion → SendCapabilities → AwaitCapabilities →
+         SendAlgorithms → AwaitAlgorithms → Done (or → Failed on error /
+         AWAIT_STEP_BUDGET exhaustion).
+```
+
+The polling loop then runs two phases per iteration:
+
+```
+Phase 1 (always):     drain I2C → server.inbound() → router reassembly
+Phase 2 (responder):  spdm_ctx.responder_process_message() — Err on no data
+Phase 2 (requester):  FSM step — advance on Ok, stay on Err, transition to
+                      Failed if await_steps hits AWAIT_STEP_BUDGET (10_000)
+```
+
 ---
 
 ### Notification mode (default, no `i2c-polling`)
@@ -159,6 +221,8 @@
 |---|---|---|
 | `OWN_EID` | `0x08` | MCTP Endpoint ID of this device |
 | `OWN_I2C_ADDR` | `0x10` | I2C 7-bit slave address of this device |
+| `REMOTE_RESPONDER_EID` | `42` | MCTP EID of the peer responder (requester-only) |
+| `AWAIT_STEP_BUDGET` | `10_000` | Max FSM steps per Await state before Failed (requester-only) |
 
 ### Polling mode variables
 
@@ -206,15 +270,28 @@
 | `i2c_recv_err` | `wait_for_messages` returns `Err` | 1st occurrence, then every 16th |
 | `decode_err` | `receiver.decode()` failure | 1st occurrence, then every 16th |
 | `inbound_err` | `server.inbound()` returns `Err` | 1st occurrence, then every 16th |
-| `spdm_ok` | `responder_process_message` returns `Ok` | Every success (at `info` level) |
-| `spdm_err` | `responder_process_message` returns `Err` | 1st occurrence, then every 256th (at `debug` level — mostly TimedOut noise) |
+| `spdm_ok` | `responder_process_message` returns `Ok` (responder-only) | Every success (at `info` level) |
+| `spdm_err` | `responder_process_message` returns `Err` (responder-only) | 1st occurrence, then every 256th (at `debug` level — mostly TimedOut noise) |
+
+### Requester FSM variables (`in-process-requester`, loop-scoped)
+
+| Name | Type | Purpose |
+|---|---|---|
+| `req_state` | `ReqState` | Current FSM state (SendVersion → AwaitAlgorithms → Done/Failed) |
+| `req_send_ok` | `u32` | Successful `requester_send_request` calls (3 on happy path) |
+| `req_recv_ok` | `u32` | Successful `requester_process_message` calls (3 on happy path) |
+| `req_recv_pending` | `u32` | `requester_process_message` returned `Err` (mostly TimedOut) |
+| `await_steps` | `u32` | Consecutive Phase-2 steps in the current Await state; reset on transition; triggers Failed at `AWAIT_STEP_BUDGET` |
+| `peer_cert_store` | `DemoPeerCertStore` | Peer cert chain handed to `SpdmContext::new` (requester-only) |
 
 ---
 
-## Handle IDs (`app_mctp_server::handle`)
+## Handle IDs (`app_mctp_server::handle` or `app_mctp_server_requester::handle`)
 
-Generated by `app_package` codegen from `system.json5`. The numeric values are
-target-specific and should not be hardcoded.
+Each Bazel `rust_app` target generates its own codegen crate:
+`app_mctp_server` for the responder image, `app_mctp_server_requester` for the
+requester image.  `src/main.rs` picks the right one with a cfg-gated `use`.
+The numeric values are target-specific and should not be hardcoded.
 
 | Handle | Used by | Purpose |
 |---|---|---|
diff --git a/services/mctp/server/src/main.rs b/services/mctp/server/src/main.rs
index b6d8171..bf4bd4c 100644
--- a/services/mctp/server/src/main.rs
+++ b/services/mctp/server/src/main.rs
@@ -9,11 +9,15 @@
 //!
 //! Two mutually exclusive event loops are compiled depending on feature flags:
 //!
-//! | Features | Mode | IPC served | SPDM in-process |
+//! | Features | Mode | IPC served | SPDM role in-process |
 //! |---|---|---|---|
-//! | _(none)_ | Notification (WaitGroup + IRQ) | Yes | No |
-//! | `i2c-polling` | Polling | No | No |
-//! | `i2c-polling` + `direct-client` | Polling + SPDM | No | Yes |
+//! | _(none)_ | Notification (WaitGroup + IRQ) | Yes | none |
+//! | `i2c-polling` | Polling | No | none |
+//! | `i2c-polling` + `in-process-responder` | Polling + SPDM | No | responder |
+//! | `i2c-polling` + `in-process-requester` | Polling + SPDM | No | requester (added in Phase 2+) |
+//!
+//! `in-process-requester` and `in-process-responder` are mutually
+//! exclusive and enforced with a `compile_error!` below.
 //!
 //! # Notification Mode Architecture (default)
 //!
@@ -39,7 +43,7 @@
 //! └──────────────────────────────────┘
 //! ```
 //!
-//! # In-Process SPDM Responder Architecture (`i2c-polling` + `direct-client`)
+//! # In-Process SPDM Responder Architecture (`i2c-polling` + `in-process-responder`)
 //!
 //! When both features are enabled the SPDM responder runs inside this process.
 //! `DirectMctpClient` replaces the IPC channel — it calls `Server` methods
@@ -86,6 +90,13 @@
 #![no_main]
 #![no_std]
 
+// Role-feature mutual exclusion: the in-process requester and responder
+// cannot both be instantiated in the same binary.
+#[cfg(all(feature = "in-process-requester", feature = "in-process-responder"))]
+compile_error!(
+    "features `in-process-requester` and `in-process-responder` are mutually exclusive"
+);
+
 // Imports shared by both modes.
 use i2c_api::{BusIndex, I2cAddress, I2cTargetClient, TargetMessage};
 use i2c_client::IpcI2cClient;
@@ -94,9 +105,16 @@
 use pw_status::Result;
 use userspace::entry;
 use userspace::syscall;
-use app_mctp_server::handle;
 
-// Imports used only by the in-process SPDM responder (i2c-polling + direct-client).
+// Each Bazel `rust_app` target generates its own handle-table crate named
+// after `codegen_crate_name`.  Pick the right one for this build.
+#[cfg(not(feature = "in-process-requester"))]
+use app_mctp_server::handle;
+#[cfg(feature = "in-process-requester")]
+use app_mctp_server_requester::handle;
+
+// Imports shared by every in-process SPDM role (requester or responder).
+// Gated on `direct-client` because both role features imply it.
 #[cfg(feature = "i2c-polling")]
 use core::cell::RefCell;
 #[cfg(all(feature = "i2c-polling", feature = "direct-client"))]
@@ -119,6 +137,18 @@
     ReqBaseAsymAlg, SpdmVersion,
 };
 
+// Imports used only by the in-process SPDM requester.
+#[cfg(all(feature = "i2c-polling", feature = "in-process-requester"))]
+use mock_platform::DemoPeerCertStore;
+#[cfg(all(feature = "i2c-polling", feature = "in-process-requester"))]
+use spdm_lib::commands::algorithms::request::generate_negotiate_algorithms_request;
+#[cfg(all(feature = "i2c-polling", feature = "in-process-requester"))]
+use spdm_lib::commands::capabilities::request::generate_capabilities_request_local;
+#[cfg(all(feature = "i2c-polling", feature = "in-process-requester"))]
+use spdm_lib::commands::version::VersionReqPayload;
+#[cfg(all(feature = "i2c-polling", feature = "in-process-requester"))]
+use spdm_lib::commands::version::request::generate_get_version;
+
 // Imports used only by the notification (WaitGroup + IRQ) loop.
 #[cfg(not(feature = "i2c-polling"))]
 use openprot_mctp_api::wire::{MctpRequestHeader, MAX_REQUEST_SIZE, MAX_RESPONSE_SIZE};
@@ -134,11 +164,44 @@
 const OWN_EID: u8 = 8;
 const OWN_I2C_ADDR: u8 = 0x10;
 
+/// Remote EID of the SPDM responder targeted by the in-process requester.
+/// Matches `spdm_requester.rs` so the two requester implementations are
+/// interchangeable against the same responder image.
+#[allow(dead_code)]
+const REMOTE_RESPONDER_EID: u8 = 42;
+
+/// Max number of Phase-2 steps spent in a single `Await*` state before the
+/// requester gives up and transitions to `Failed`.  Generous default — see
+/// design §6.4 and IMPLEMENTATION_PLAN.md Appendix §A; tune once on-target
+/// measurements exist.  10k at the observed idle-poll rate gives a
+/// comfortable wall-clock ceiling while still bounding genuine hangs.
+#[cfg(feature = "in-process-requester")]
+#[allow(dead_code)]
+const AWAIT_STEP_BUDGET: u32 = 10_000;
+
+/// Requester FSM state (Phase 2 of the polling loop when
+/// `in-process-requester` is enabled).  Stepped once per loop iteration;
+/// each `Await*` tick either advances on `Ok` or stays (counted) on `Err`.
+#[cfg(feature = "in-process-requester")]
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+#[repr(u32)]
+enum ReqState {
+    SendVersion = 0,
+    AwaitVersion = 1,
+    SendCapabilities = 2,
+    AwaitCapabilities = 3,
+    SendAlgorithms = 4,
+    AwaitAlgorithms = 5,
+    Done = 6,
+    Failed = 7,
+}
+
 // ---------------------------------------------------------------------------
 // Polling mode — blocks on wait_for_messages(); no WaitGroup or IRQ needed.
-// When built with the "direct-client" feature, an SPDM responder runs
-// in-process using DirectMctpClient (no IPC channel to a separate process).
-// Enable with crate_features = ["i2c-polling", "direct-client"] in Bazel.
+// When built with the "in-process-responder" feature, an SPDM responder
+// runs in-process using DirectMctpClient (no IPC channel to a separate
+// process).  Enable with crate_features = ["i2c-polling",
+// "in-process-responder"] in Bazel.
 // ---------------------------------------------------------------------------
 
 #[cfg(feature = "i2c-polling")]
@@ -179,13 +242,13 @@
     // ---------------------------------------------------------------------------
     // SPDM responder setup (only when direct-client feature is enabled)
     // ---------------------------------------------------------------------------
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let mut transport = {
         let client = DirectMctpClient::new(&server);
         MctpSpdmTransport::new_responder(client)
     };
 
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     {
         pw_log::info!("MCTP server: registering SPDM listener (msg_type=0x05)");
         if transport.init_sequence().is_err() {
@@ -196,22 +259,22 @@
         pw_log::info!("MCTP server: SPDM listener registered");
     }
 
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let mut cert_store = MockCertStore::new();
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let mut hash = MockHash::new();
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let mut m1_hash = MockHash::new();
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let mut l1_hash = MockHash::new();
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let mut rng = MockRng::new();
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let evidence = MockEvidence::new();
 
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let mut flags = CapabilityFlags::default();
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     {
         flags.set_cert_cap(1);
         flags.set_chal_cap(1);
@@ -220,7 +283,7 @@
         flags.set_chunk_cap(1);
     }
 
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let capabilities = DeviceCapabilities {
         ct_exponent: 0,
         flags,
@@ -229,10 +292,10 @@
         include_supported_algorithms: true,
     };
 
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     static SUPPORTED_VERSIONS: [SpdmVersion; 2] = [SpdmVersion::V12, SpdmVersion::V13];
 
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let algorithms = {
         let mut measurement_spec = MeasurementSpecification::default();
         measurement_spec.set_dmtf_measurement_spec(1);
@@ -270,7 +333,7 @@
         }
     };
 
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let mut spdm_ctx = {
         pw_log::info!("MCTP server: creating SPDM context (v1.2+v1.3, ECDSA-P384, SHA-384)");
         match SpdmContext::new(
@@ -298,6 +361,142 @@
         }
     };
 
+    // ---------------------------------------------------------------------------
+    // SPDM requester setup (only when in-process-requester feature is enabled).
+    // Mirrors the responder block above; role-specific differences:
+    //   • transport created with new_requester(client, REMOTE_RESPONDER_EID)
+    //   • capabilities: meas_cap=0, no meas_fresh_cap, include_supported_algorithms=false
+    //   • peer_cert_store: Some(&mut DemoPeerCertStore) (required by VCA+)
+    // ---------------------------------------------------------------------------
+    #[cfg(feature = "in-process-requester")]
+    let mut transport = {
+        let client = DirectMctpClient::new(&server);
+        MctpSpdmTransport::new_requester(client, REMOTE_RESPONDER_EID)
+    };
+
+    #[cfg(feature = "in-process-requester")]
+    {
+        pw_log::info!(
+            "MCTP server: initializing SPDM requester transport (target eid={})",
+            REMOTE_RESPONDER_EID as u32
+        );
+        if transport.init_sequence().is_err() {
+            pw_log::error!("MCTP server: SPDM transport init_sequence failed — \
+                req({}) rejected; router request table may be full",
+                REMOTE_RESPONDER_EID as u32);
+            return Err(pw_status::Error::Internal);
+        }
+        pw_log::info!("MCTP server: SPDM requester transport ready");
+    }
+
+    #[cfg(feature = "in-process-requester")]
+    let mut cert_store = MockCertStore::new();
+    #[cfg(feature = "in-process-requester")]
+    let mut hash = MockHash::new();
+    #[cfg(feature = "in-process-requester")]
+    let mut m1_hash = MockHash::new();
+    #[cfg(feature = "in-process-requester")]
+    let mut l1_hash = MockHash::new();
+    #[cfg(feature = "in-process-requester")]
+    let mut rng = MockRng::new();
+    #[cfg(feature = "in-process-requester")]
+    let evidence = MockEvidence::new();
+    #[cfg(feature = "in-process-requester")]
+    let mut peer_cert_store = DemoPeerCertStore::default();
+
+    #[cfg(feature = "in-process-requester")]
+    let mut flags = CapabilityFlags::default();
+    #[cfg(feature = "in-process-requester")]
+    {
+        flags.set_cert_cap(1);
+        flags.set_chal_cap(1);
+        flags.set_meas_cap(0);
+        flags.set_chunk_cap(1);
+    }
+
+    #[cfg(feature = "in-process-requester")]
+    let capabilities = DeviceCapabilities {
+        ct_exponent: 0,
+        flags,
+        data_transfer_size: 1024,
+        max_spdm_msg_size: 4096,
+        // Setting true at V1.3 encodes param1 bit 2 of GET_CAPABILITIES,
+        // which the responder currently rejects as an unexpected reserved
+        // field.  Algorithm negotiation goes through NEGOTIATE_ALGORITHMS.
+        include_supported_algorithms: false,
+    };
+
+    #[cfg(feature = "in-process-requester")]
+    static SUPPORTED_VERSIONS: [SpdmVersion; 2] = [SpdmVersion::V12, SpdmVersion::V13];
+
+    #[cfg(feature = "in-process-requester")]
+    let algorithms = {
+        let mut measurement_spec = MeasurementSpecification::default();
+        measurement_spec.set_dmtf_measurement_spec(1);
+        let mut measurement_hash_algo = MeasurementHashAlgo::default();
+        measurement_hash_algo.set_tpm_alg_sha_384(1);
+        let mut base_asym_algo = BaseAsymAlgo::default();
+        base_asym_algo.set_tpm_alg_ecdsa_ecc_nist_p384(1);
+        let mut base_hash_algo = BaseHashAlgo::default();
+        base_hash_algo.set_tpm_alg_sha_384(1);
+        let device_algorithms = DeviceAlgorithms {
+            measurement_spec,
+            other_param_support: OtherParamSupport::default(),
+            measurement_hash_algo,
+            base_asym_algo,
+            base_hash_algo,
+            mel_specification: MelSpecification::default(),
+            dhe_group: DheNamedGroup::default(),
+            aead_cipher_suite: AeadCipherSuite::default(),
+            req_base_asym_algo: ReqBaseAsymAlg::default(),
+            key_schedule: KeySchedule::default(),
+        };
+        LocalDeviceAlgorithms {
+            device_algorithms,
+            algorithm_priority_table: AlgorithmPriorityTable {
+                measurement_specification: None,
+                opaque_data_format: None,
+                base_asym_algo: None,
+                base_hash_algo: None,
+                mel_specification: None,
+                dhe_group: None,
+                aead_cipher_suite: None,
+                req_base_asym_algo: None,
+                key_schedule: None,
+            },
+        }
+    };
+
+    #[cfg(feature = "in-process-requester")]
+    let mut spdm_ctx = {
+        pw_log::info!("MCTP server: creating SPDM requester context (v1.2+v1.3, ECDSA-P384, SHA-384)");
+        match SpdmContext::new(
+            &SUPPORTED_VERSIONS,
+            &mut transport,
+            capabilities,
+            algorithms,
+            &mut cert_store,
+            Some(&mut peer_cert_store),
+            &mut hash,
+            &mut m1_hash,
+            &mut l1_hash,
+            &mut rng,
+            &evidence,
+        ) {
+            Ok(ctx) => {
+                pw_log::info!("MCTP server: SPDM requester context ready");
+                ctx
+            }
+            Err(_) => {
+                pw_log::error!("MCTP server: SpdmContext::new failed (requester) — \
+                    check cert_store, hash, rng platform impls");
+                return Err(pw_status::Error::Internal);
+            }
+        }
+    };
+
+    // Shared between responder and requester — both need a MessageBuf for
+    // SPDM encode/decode.  Gated on `direct-client` (implied by either role).
     #[cfg(feature = "direct-client")]
     let mut spdm_buf = [0u8; MAX_PAYLOAD_SIZE];
     // MessageBuf is created once outside the loop; reset() is called each iteration.
@@ -310,13 +509,31 @@
     let mut i2c_recv_err: u32 = 0;
     let mut decode_err: u32 = 0;
     let mut inbound_err: u32 = 0;
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let mut spdm_ok: u32 = 0;
-    #[cfg(feature = "direct-client")]
+    #[cfg(feature = "in-process-responder")]
     let mut spdm_err: u32 = 0;
     let mut i2c_pkt: u32 = 0;
     let mut idle_polls: u32 = 0;
 
+    // Requester FSM: starts at SendVersion, walks the VCA flow, ends at
+    // Done or Failed.  Stepped once per loop iteration in Phase 2 below.
+    #[cfg(feature = "in-process-requester")]
+    let mut req_state = ReqState::SendVersion;
+    // Observability counters — see design §8.  All u32 wrapping; log volume
+    // is bounded by rate-limiting inside the FSM step.
+    #[cfg(feature = "in-process-requester")]
+    let mut req_send_ok: u32 = 0;
+    #[cfg(feature = "in-process-requester")]
+    let mut req_recv_ok: u32 = 0;
+    #[cfg(feature = "in-process-requester")]
+    let mut req_recv_pending: u32 = 0;
+    // Number of consecutive steps spent in the current Await state.  Reset
+    // to zero on every state transition; exhaustion of AWAIT_STEP_BUDGET
+    // transitions the FSM to Failed so the device does not hang.
+    #[cfg(feature = "in-process-requester")]
+    let mut await_steps: u32 = 0;
+
     pw_log::info!("MCTP server ready, polling for I2C packets");
 
     loop {
@@ -374,7 +591,7 @@
                     );
 
                     match receiver.decode(&msg_with_dest) {
-                        Ok((pkt, src_addr)) => {
+                        Ok((pkt, hdr)) => {
                             i2c_pkt = i2c_pkt.wrapping_add(1);
                             // Log MCTP packet fields: SOM/EOM from flags byte (pkt[3])
                             let som = pkt.get(3).map_or(0u8, |b| (b >> 7) & 1);
@@ -385,9 +602,9 @@
                                 "MCTP pkt #{}: src_i2c=0x{:02x} dest_i2c=0x{:02x} bc={} len={} \
                                 SOM={} EOM={} seq={} msg_type=0x{:02x}",
                                 i2c_pkt as u32,
-                                src_addr.source as u32,
-                                src_addr.dest as u32,
-                                src_addr.byte_count as u32,
+                                hdr.source as u32,
+                                hdr.dest as u32,
+                                hdr.byte_count as u32,
                                 pkt.len() as u32,
                                 som as u32, eom as u32, seq as u32, msg_type as u32,
                             );
@@ -456,7 +673,7 @@
         // Phase 2: poll the SPDM responder for an assembled message.
         // Transport returning TimedOut means no SPDM message is assembled yet
         // — this is the normal steady-state; log only genuine protocol errors.
-        #[cfg(feature = "direct-client")]
+        #[cfg(feature = "in-process-responder")]
         {
             msg_buf.reset();
             match spdm_ctx.responder_process_message(&mut msg_buf) {
@@ -483,6 +700,155 @@
                 }
             }
         }
+
+        // Phase 2 (requester): one FSM step per loop iteration.  Send
+        // states emit a request and advance; Await states try to consume
+        // a response and either advance on Ok or stay on Err (which is
+        // the normal "router has not assembled the response yet" case
+        // given DirectMctpClient::recv returns TimedOut immediately).
+        // Done/Failed are terminal no-ops; Phase 1 keeps draining I2C.
+        #[cfg(feature = "in-process-requester")]
+        {
+            // Design follow-ups (see DESIGN_IN_PROCESS_REQUESTER.md):
+            //   §6.3 option 2: plumb TransportError through
+            //     `requester_process_message` so genuine protocol errors
+            //     can be distinguished from TimedOut; today the Err arm
+            //     relies on AWAIT_STEP_BUDGET alone.
+            //   §9.1: pumping-`DirectMctpClient` (Shape B) would let the
+            //     FSM call a synchronous API and delete the state machine.
+            //   §9.3: external trigger — today the requester fires VCA
+            //     at boot; a real caller will want IPC/GPIO/policy control.
+            //
+            // Await* states share a retry pattern: try to process one
+            // message; on Ok advance, on Err count it as pending and
+            // check the step budget.  `await_try!` centralizes that
+            // logic and emits the state-timeout log if the budget
+            // runs out.
+            macro_rules! await_try {
+                ($next:expr, $state_code:expr) => {{
+                    msg_buf.reset();
+                    match spdm_ctx.requester_process_message(&mut msg_buf) {
+                        Ok(_) => {
+                            req_recv_ok = req_recv_ok.wrapping_add(1);
+                            await_steps = 0;
+                            req_state = $next;
+                        }
+                        Err(_) => {
+                            req_recv_pending = req_recv_pending.wrapping_add(1);
+                            await_steps = await_steps.wrapping_add(1);
+                            if await_steps >= AWAIT_STEP_BUDGET {
+                                pw_log::error!(
+                                    "SPDM requester: AWAIT_STEP_BUDGET exhausted in \
+                                    state={} (steps={} pending_total={}) — giving up",
+                                    $state_code as u32,
+                                    await_steps as u32,
+                                    req_recv_pending as u32,
+                                );
+                                req_state = ReqState::Failed;
+                            }
+                        }
+                    }
+                }};
+            }
+
+            match req_state {
+                ReqState::SendVersion => {
+                    msg_buf.reset();
+                    let ok = generate_get_version(
+                        &mut spdm_ctx,
+                        &mut msg_buf,
+                        VersionReqPayload::new(0, 0),
+                    )
+                    .is_ok()
+                        && spdm_ctx
+                            .requester_send_request(&mut msg_buf, REMOTE_RESPONDER_EID)
+                            .is_ok();
+                    if ok {
+                        req_send_ok = req_send_ok.wrapping_add(1);
+                        await_steps = 0;
+                        pw_log::info!("SPDM requester: sent GET_VERSION, awaiting VERSION");
+                        req_state = ReqState::AwaitVersion;
+                    } else {
+                        pw_log::error!("SPDM requester: GET_VERSION send failed");
+                        req_state = ReqState::Failed;
+                    }
+                }
+                ReqState::AwaitVersion => {
+                    await_try!(ReqState::SendCapabilities, ReqState::AwaitVersion);
+                    if req_state == ReqState::SendCapabilities {
+                        pw_log::info!("SPDM requester: VERSION ok, sending GET_CAPABILITIES");
+                    }
+                }
+                ReqState::SendCapabilities => {
+                    msg_buf.reset();
+                    let ok = generate_capabilities_request_local(&mut spdm_ctx, &mut msg_buf)
+                        .is_ok()
+                        && spdm_ctx
+                            .requester_send_request(&mut msg_buf, REMOTE_RESPONDER_EID)
+                            .is_ok();
+                    if ok {
+                        req_send_ok = req_send_ok.wrapping_add(1);
+                        await_steps = 0;
+                        pw_log::info!("SPDM requester: sent GET_CAPABILITIES, awaiting CAPABILITIES");
+                        req_state = ReqState::AwaitCapabilities;
+                    } else {
+                        pw_log::error!("SPDM requester: GET_CAPABILITIES send failed");
+                        req_state = ReqState::Failed;
+                    }
+                }
+                ReqState::AwaitCapabilities => {
+                    await_try!(ReqState::SendAlgorithms, ReqState::AwaitCapabilities);
+                    if req_state == ReqState::SendAlgorithms {
+                        pw_log::info!(
+                            "SPDM requester: CAPABILITIES ok, sending NEGOTIATE_ALGORITHMS"
+                        );
+                    }
+                }
+                ReqState::SendAlgorithms => {
+                    msg_buf.reset();
+                    let ok = generate_negotiate_algorithms_request(
+                        &mut spdm_ctx,
+                        &mut msg_buf,
+                        None,
+                        None,
+                        None,
+                        None,
+                    )
+                    .is_ok()
+                        && spdm_ctx
+                            .requester_send_request(&mut msg_buf, REMOTE_RESPONDER_EID)
+                            .is_ok();
+                    if ok {
+                        req_send_ok = req_send_ok.wrapping_add(1);
+                        await_steps = 0;
+                        pw_log::info!(
+                            "SPDM requester: sent NEGOTIATE_ALGORITHMS, awaiting ALGORITHMS"
+                        );
+                        req_state = ReqState::AwaitAlgorithms;
+                    } else {
+                        pw_log::error!("SPDM requester: NEGOTIATE_ALGORITHMS send failed");
+                        req_state = ReqState::Failed;
+                    }
+                }
+                ReqState::AwaitAlgorithms => {
+                    await_try!(ReqState::Done, ReqState::AwaitAlgorithms);
+                    if req_state == ReqState::Done {
+                        pw_log::info!(
+                            "SPDM VCA completed: version/caps/algs OK \
+                            (send_ok={} recv_ok={} recv_pending={} idle_polls={})",
+                            req_send_ok as u32,
+                            req_recv_ok as u32,
+                            req_recv_pending as u32,
+                            idle_polls as u32,
+                        );
+                    }
+                }
+                ReqState::Done | ReqState::Failed => {
+                    // Terminal states — Phase 1 continues draining I2C,
+                    // Phase 2 is a no-op.
+                }
+            }
+        }
     }
 }
 
diff --git a/target/ast1060-evb/mctp-requester/BUILD.bazel b/target/ast1060-evb/mctp-requester/BUILD.bazel
new file mode 100644
index 0000000..13965b4
--- /dev/null
+++ b/target/ast1060-evb/mctp-requester/BUILD.bazel
@@ -0,0 +1,85 @@
+# Licensed under the Apache-2.0 license
+
+load("@pigweed//pw_kernel/tooling:system_image.bzl", "system_image", "system_image_test")
+load("@pigweed//pw_kernel/tooling:target_codegen.bzl", "target_codegen")
+load("@pigweed//pw_kernel/tooling:target_linker_script.bzl", "target_linker_script")
+load("@rules_rust//rust:defs.bzl", "rust_binary")
+load("//target/ast1060-evb:defs.bzl", "TARGET_COMPATIBLE_WITH")
+load("//target:uart_boot_image.bzl", "uart_boot_image")
+
+# ---------------------------------------------------------------------------
+# System image: I2C server + MCTP server (in-process SPDM requester variant)
+# Mirrors //target/ast1060-evb/mctp:mctp but substitutes mctp_server_requester
+# for mctp_server.  The two images are mutually exclusive — flash one or the
+# other, not both.
+# ---------------------------------------------------------------------------
+
+system_image(
+    name = "mctp_requester",
+    apps = [
+        "//services/i2c/server:i2c_server",
+        "//services/mctp/server:mctp_server_requester",
+    ],
+    kernel = ":target",
+    platform = "//target/ast1060-evb",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+)
+
+system_image_test(
+    name = "mctp_requester_test",
+    image = ":mctp_requester",
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+# Generate UART bootable image
+uart_boot_image(
+    name = "mctp_requester_uart",
+    src = ":mctp_requester",
+    out = "mctp_requester_uart.bin",
+)
+
+filegroup(
+    name = "system_config",
+    srcs = ["system.json5"],
+    visibility = ["//visibility:public"],
+)
+
+target_codegen(
+    name = "codegen",
+    arch = "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+    system_config = ":system_config",
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+target_linker_script(
+    name = "linker_script",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    template = "//target/ast1060-evb:linker_script_template",
+)
+
+rust_binary(
+    name = "target",
+    srcs = [
+        "target.rs",
+    ],
+    edition = "2024",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [
+        ":codegen",
+        ":linker_script",
+        "//target/ast1060-evb:entry",
+        "//target/ast1060-evb:console_backend_uart",
+        "@rust_crates//:cortex-m-semihosting",
+        "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+        "@pigweed//pw_kernel/kernel",
+        "@pigweed//pw_kernel/target:target_common",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+    ],
+)
diff --git a/target/ast1060-evb/mctp-requester/system.json5 b/target/ast1060-evb/mctp-requester/system.json5
new file mode 100644
index 0000000..a905ca1
--- /dev/null
+++ b/target/ast1060-evb/mctp-requester/system.json5
@@ -0,0 +1,125 @@
+// Licensed under the Apache-2.0 license
+
+// AST1060-EVB MCTP Echo Server + Client Configuration
+// ARM Cortex-M4 @ 200 MHz
+// 768KB SRAM (640KB usable), executes from RAM
+//
+// Memory Layout (PMSAv7-friendly, power-of-2 aligned regions):
+// FIXED: Kernel RAM now comes BEFORE application RAM to avoid boot crashes
+//   0x00000000 - 0x00000800: Vector table + kernel annotations (2048 bytes)
+//   0x00000800 - 0x00020000: Kernel flash (~126KB)
+//   0x00020000 - 0x00040000: i2c_server flash (128KB)
+//   0x00040000 - 0x00060000: mctp_server flash (128KB)
+//   0x00060000 - 0x00080000: Kernel RAM (128KB)
+//   0x00080000 - 0x00090000: i2c_server RAM (64KB)
+//   0x00090000 - 0x000A0000: mctp_server RAM (64KB)
+//
+// Total: 0xA0000 = 640KB (fits within the 640KB usable window of the AST1060's 768KB SRAM).
+{
+    arch: {
+        type: "armv7m",
+        vector_table_start_address: 0x00000000,
+        vector_table_size_bytes: 2048,  // 0x800 (vector table + process annotations for all 3 apps)
+    },
+    kernel: {
+        flash_start_address: 0x00000800,  // After vector table + annotations
+        flash_size_bytes: 129024,         // 0x1F800: ends at exactly 0x20000 (128KB boundary)
+        ram_start_address: 0x00060000,    // Kernel RAM comes FIRST, before all app RAM
+        ram_size_bytes: 131072,           // 128KB
+    },
+    apps: [
+        // ──── I2C Server ────
+        // Owns the AST1060 I2C controllers.
+        // The MCTP server uses I2C as its transport.
+        {
+            name: "i2c_server",
+            flash_size_bytes: 131072,     // 128KB for server code
+            ram_size_bytes: 65536,        // 64KB RAM (register maps + buffers)
+            processes: [
+                {
+                    name: "i2c server process",
+                objects: [
+                    {
+                        name: "I2C",
+                        type: "channel_handler",  // Server side of client→server channel
+                    },
+                    //{
+                    //    // AST1060 I2C2 combined master+slave interrupt (IRQ 112).
+                    //    name: "I2C2_IRQ",
+                    //    type: "interrupt",
+                    //    irqs: [
+                    //        {
+                    //            name: "i2c2",
+                    //            number: 112,
+                    //        },
+                    //    ],
+                    //},
+                    {
+                        name: "WG",
+                        type: "wait_group",
+                    },
+                ],
+                threads: [
+                    {
+                        name: "i2c server thread",
+                        stack_size_bytes: 4096,   // 4KB stack
+                    },
+                ],                
+                memory_mappings: [
+                    {
+                        // AST1060 I2C peripheral block: I2cglobal through I2cFilter
+                        // Base: 0x7e7b0000, covers all 14 controllers + buffers + filter
+                        name: "i2c_regs",
+                        type: "device",
+                        start_address: 0x7e7b0000,
+                        size_bytes: 0x4000,
+                    },
+                    {
+                        // SCU register block — scu310 at +0x310 is read by
+                        // ClockConfig::from_hardware() to derive APB clock frequency.
+                        name: "scu",
+                        type: "device",
+                        start_address: 0x7e6e2000,
+                        size_bytes: 0x1000,
+                    },
+                ],
+                },
+            ],
+        },
+        // ──── MCTP Server ────
+        // Runs the MCTP stack + in-process SPDM requester. Same footprint
+        // as the responder variant; only the app name differs.
+        {
+            name: "mctp_server_requester",
+            flash_size_bytes: 65536,      // 64KB for MCTP server code
+            ram_size_bytes: 65536,        // 64KB RAM (Router state + SPDM context + buffers)
+            processes: [
+                {
+                    name: "mctp server process",
+                    objects: [
+                        {
+                            name: "MCTP",
+                            type: "channel_handler",  // Server side: echo client → MCTP server
+                        },
+                        {
+                            name: "I2C",
+                            type: "channel_initiator",  // Client side: MCTP server → I2C server
+                            handler_process: "i2c server process",
+                            handler_object_name: "I2C",
+                        },
+                        {
+                            name: "WG",
+                            type: "wait_group",
+                        },
+                    ],
+                    threads: [
+                        {
+                            name: "mctp server thread",
+                            stack_size_bytes: 32768,   // 8KB stack (Router + fragmentation buffers)
+                        },
+                    ],
+                },
+            ],
+        },
+    ],
+}
diff --git a/target/ast1060-evb/mctp-requester/target.rs b/target/ast1060-evb/mctp-requester/target.rs
new file mode 100644
index 0000000..931afa5
--- /dev/null
+++ b/target/ast1060-evb/mctp-requester/target.rs
@@ -0,0 +1,32 @@
+// Licensed under the Apache-2.0 license
+
+//! AST1060-EVB MCTP Echo Target
+//!
+//! This target runs the I2C server, MCTP server, and MCTP echo application
+//! as separate userspace processes communicating over IPC channels.
+
+#![no_std]
+#![no_main]
+
+use target_common::{TargetInterface, declare_target};
+use {console_backend as _, entry as _};
+
+pub struct Target {}
+
+impl TargetInterface for Target {
+    const NAME: &'static str = "AST1060-EVB MCTP Echo";
+
+    fn main() -> ! {
+        codegen::start();
+        #[expect(clippy::empty_loop)]
+        loop {}
+    }
+
+    fn shutdown(code: u32) -> ! {
+        pw_log::info!("Shutting down with code {}", code as u32);
+        #[expect(clippy::empty_loop)]
+        loop {}
+    }
+}
+
+declare_target!(Target);