---
phase: 26-add-oauth-support-to-load-testing
plan: 03
type: execute
wave: 2
depends_on: ["26-01"]
files_modified:
  - cargo-pmcp/Cargo.toml
  - cargo-pmcp/src/loadtest/client.rs
  - cargo-pmcp/src/loadtest/vu.rs
  - cargo-pmcp/src/loadtest/engine.rs
  - cargo-pmcp/src/commands/loadtest/mod.rs
  - cargo-pmcp/src/commands/loadtest/run.rs
autonomous: true
requirements:
  - OAUTH-04
  - OAUTH-05

must_haves:
  truths:
    - "User can pass --oauth-client-id to `cargo pmcp loadtest run` and VUs authenticate with OAuth Bearer tokens"
    - "User can pass --api-key to `cargo pmcp loadtest run` and VUs send Authorization: Bearer <key> headers"
    - "OAuth token is acquired ONCE at startup before VUs spawn (fail fast on bad auth config)"
    - "Same flags/env vars as `cargo pmcp test`: --oauth-client-id, --oauth-issuer, --oauth-scopes, --oauth-no-cache, --oauth-redirect-port, --api-key"
    - "Auth headers are injected transparently via HttpMiddlewareChain -- McpClient has no auth-specific logic"
  artifacts:
    - path: "cargo-pmcp/Cargo.toml"
      provides: "pmcp dependency with oauth feature for OAuthHelper + HttpMiddlewareChain types"
      contains: "pmcp"
    - path: "cargo-pmcp/src/commands/loadtest/mod.rs"
      provides: "OAuth and API key CLI flags on LoadtestCommand::Run variant"
      contains: "oauth_client_id"
    - path: "cargo-pmcp/src/commands/loadtest/run.rs"
      provides: "Auth setup before engine start -- OAuthHelper creates middleware chain at startup"
      contains: "create_middleware_chain"
    - path: "cargo-pmcp/src/loadtest/client.rs"
      provides: "McpClient with optional HttpMiddlewareChain applied in send_request"
      contains: "HttpMiddlewareChain"
    - path: "cargo-pmcp/src/loadtest/vu.rs"
      provides: "VU loop accepts and threads middleware chain to McpClient creation"
      contains: "http_middleware_chain"
    - path: "cargo-pmcp/src/loadtest/engine.rs"
      provides: "LoadTestEngine accepts optional middleware chain, passes to VU spawning"
      contains: "http_middleware_chain"
  key_links:
    - from: "cargo-pmcp/src/commands/loadtest/mod.rs"
      to: "cargo-pmcp/src/commands/loadtest/run.rs"
      via: "CLI flags passed as parameters to execute_run"
      pattern: "oauth_client_id.*execute_run"
    - from: "cargo-pmcp/src/commands/loadtest/run.rs"
      to: "cargo-pmcp/src/loadtest/engine.rs"
      via: "Middleware chain passed to LoadTestEngine builder"
      pattern: "with_http_middleware"
    - from: "cargo-pmcp/src/loadtest/engine.rs"
      to: "cargo-pmcp/src/loadtest/vu.rs"
      via: "Middleware chain cloned (Arc) and passed to vu_loop spawns"
      pattern: "http_middleware_chain.*vu_loop"
    - from: "cargo-pmcp/src/loadtest/vu.rs"
      to: "cargo-pmcp/src/loadtest/client.rs"
      via: "Middleware chain passed to McpClient::new"
      pattern: "McpClient::new.*middleware"
    - from: "cargo-pmcp/src/loadtest/client.rs"
      to: "pmcp::client::http_middleware::HttpMiddlewareChain"
      via: "chain.process_request() called in send_request before building reqwest request"
      pattern: "process_request"
---

<objective>
Wire OAuth middleware into the loadtest subsystem: McpClient, VU loop, engine, and CLI flags. Uses the SDK's OAuthHelper (from plan 01) to acquire tokens and inject them via HttpMiddlewareChain.

Purpose: Users need to load-test OAuth-protected MCP servers. This plan adds the same auth flags as `cargo pmcp test` and uses the middleware chain to transparently inject auth headers into every VU HTTP request.

Output: `cargo pmcp loadtest run --oauth-client-id X` acquires a token at startup, creates an OAuthClientMiddleware chain, and all VU requests have `Authorization: Bearer <token>` injected.
</objective>

<execution_context>
@./.claude/get-shit-done/workflows/execute-plan.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/26-add-oauth-support-to-load-testing/26-CONTEXT.md
@.planning/phases/26-add-oauth-support-to-load-testing/26-01-SUMMARY.md

<interfaces>
<!-- From plan 01: SDK's oauth module -->
From pmcp::client::oauth (available with features = ["oauth"]):
```rust
pub struct OAuthConfig {
    pub issuer: Option<String>,
    pub mcp_server_url: Option<String>,
    pub client_id: String,
    pub scopes: Vec<String>,
    pub cache_file: Option<PathBuf>,
    pub redirect_port: u16,
}
pub struct OAuthHelper { /* ... */ }
impl OAuthHelper {
    pub fn new(config: OAuthConfig) -> pmcp::Result<Self>;
    pub async fn get_access_token(&self) -> pmcp::Result<String>;
    pub async fn create_middleware_chain(&self) -> pmcp::Result<Arc<HttpMiddlewareChain>>;
}
pub fn default_cache_path() -> PathBuf;
pub async fn create_oauth_middleware(config: OAuthConfig) -> pmcp::Result<Arc<HttpMiddlewareChain>>;
```

From pmcp::client::http_middleware (already available):
```rust
pub struct HttpMiddlewareContext { pub request_id, pub url, pub method, pub attempt, pub metadata }
pub struct HttpRequest { pub method, pub url, pub headers: HeaderMap, pub body: Vec<u8> }
impl HttpRequest { pub fn new(...); pub fn add_header(...); }
pub struct HttpMiddlewareChain { /* middlewares: Vec<Arc<dyn HttpMiddleware>> */ }
impl HttpMiddlewareChain { pub fn new(); pub fn add(...); pub async fn process_request(...); }
```

From pmcp::client::oauth_middleware:
```rust
pub struct BearerToken { pub fn new(token: String) -> Self; }
pub struct OAuthClientMiddleware { pub fn new(token: BearerToken) -> Self; }
```

Current McpClient (cargo-pmcp/src/loadtest/client.rs):
```rust
pub struct McpClient { http, base_url, session_id, request_timeout, next_request_id }
impl McpClient { pub fn new(http: Client, base_url: String, timeout: Duration) -> Self; }
// send_request builds POST with Content-Type + session headers
```

Current engine (cargo-pmcp/src/loadtest/engine.rs):
```rust
pub struct LoadTestEngine { config, base_url, max_iterations, ramp_up, no_color }
// run_flat and run_staged both spawn vu_loop tasks
```

Current vu_loop (cargo-pmcp/src/loadtest/vu.rs):
```rust
pub async fn vu_loop(vu_id, config, http_client, base_url, sample_tx, cancel, iteration_counter, max_iterations, active_vus);
// try_initialize creates McpClient::new(http_client.clone(), base_url, timeout)
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add pmcp dependency and thread HttpMiddlewareChain through McpClient, VU, and engine</name>
  <files>cargo-pmcp/Cargo.toml, cargo-pmcp/src/loadtest/client.rs, cargo-pmcp/src/loadtest/vu.rs, cargo-pmcp/src/loadtest/engine.rs</files>
  <action>
**Add pmcp dependency (Cargo.toml):**
Add `pmcp` as a dependency in cargo-pmcp/Cargo.toml's `[dependencies]` section:
```toml
pmcp = { version = "1.11.0", path = "..", features = ["streamable-http", "oauth"] }
```
This gives cargo-pmcp access to `HttpMiddlewareChain`, `HttpRequest`, `HttpMiddlewareContext`, `OAuthHelper`, `OAuthConfig`, `BearerToken`, and `OAuthClientMiddleware`.

**McpClient (client.rs) -- middleware chain pattern per user decision:**
- Add field `http_middleware_chain: Option<Arc<pmcp::client::http_middleware::HttpMiddlewareChain>>` to the `McpClient` struct
- Add imports at top: `use std::sync::Arc;`
- Change `McpClient::new` signature to accept `http_middleware_chain: Option<Arc<pmcp::client::http_middleware::HttpMiddlewareChain>>` as the 4th parameter
- Store it in the struct: `http_middleware_chain`
- In `send_request`, BEFORE building the reqwest request, if middleware chain is present:
  1. Serialize the body to bytes: `let body_bytes = serde_json::to_vec(body).map_err(|e| McpError::Connection { message: format!("JSON serialize error: {e}") })?;`
  2. Create an `HttpRequest`: `let mut http_req = pmcp::client::http_middleware::HttpRequest::new("POST".to_string(), self.base_url.clone(), body_bytes);`
  3. Copy existing headers to http_req: Content-Type, Accept, session header if present
  4. Create context: `let context = pmcp::client::http_middleware::HttpMiddlewareContext::new(self.base_url.clone(), "POST".to_string());`
  5. Run middleware: `chain.process_request(&mut http_req, &context).await.map_err(|e| McpError::Connection { message: format!("Middleware error: {e}") })?;`
  6. After middleware runs, build the reqwest request using the modified headers from `http_req.headers` instead of the original headers. Iterate `http_req.headers` and add each to the reqwest RequestBuilder.

  The full `send_request` flow when middleware is present:
  ```rust
  async fn send_request(&mut self, body: &Value) -> Result<(HeaderMap, Vec<u8>), McpError> {
      if let Some(ref chain) = self.http_middleware_chain {
          use pmcp::client::http_middleware::{HttpMiddlewareContext, HttpRequest as MwRequest};
          let body_bytes = serde_json::to_vec(body)
              .map_err(|e| McpError::Connection { message: format!("JSON serialize: {e}") })?;
          let mut http_req = MwRequest::new("POST".to_string(), self.base_url.clone(), body_bytes);
          http_req.add_header("Content-Type", "application/json");
          http_req.add_header("Accept", "application/json, text/event-stream");
          if let Some(ref sid) = self.session_id {
              http_req.add_header("mcp-session-id", sid);
          }
          let context = HttpMiddlewareContext::new(self.base_url.clone(), "POST".to_string());
          chain.process_request(&mut http_req, &context).await
              .map_err(|e| McpError::Connection { message: format!("Middleware error: {e}") })?;
          let mut request = self.http.post(&self.base_url).timeout(self.request_timeout);
          for (key, value) in &http_req.headers {
              request = request.header(key, value);
          }
          request = request.body(http_req.body);
          let response = request.send().await.map_err(|e| McpError::classify_reqwest(&e))?;
          // ... same status/headers/bytes handling as non-middleware path
      } else {
          // Original path (no middleware) -- unchanged
      }
  }
  ```

- Update ALL existing tests in `mod tests` to pass `None` as the 4th arg to `McpClient::new`
- Add one new test `test_client_accepts_middleware_chain` that creates a client with `Some(Arc::new(HttpMiddlewareChain::new()))` and verifies `http_middleware_chain.is_some()`

**VU loop (vu.rs):**
- Add `http_middleware_chain: Option<Arc<pmcp::client::http_middleware::HttpMiddlewareChain>>` parameter to `vu_loop` (after `active_vus`)
- Pass it through to `vu_loop_inner` (add same parameter)
- In `try_initialize`, add parameter `http_middleware_chain: Option<Arc<...>>` and pass `http_middleware_chain.clone()` to `McpClient::new`
- In `vu_loop_inner`, pass `http_middleware_chain.clone()` to both calls to `try_initialize`

**Engine (engine.rs):**
- Add field `http_middleware_chain: Option<Arc<pmcp::client::http_middleware::HttpMiddlewareChain>>` to `LoadTestEngine` struct
- Initialize to `None` in `LoadTestEngine::new`
- Add builder method: `pub fn with_http_middleware(mut self, chain: Option<Arc<pmcp::client::http_middleware::HttpMiddlewareChain>>) -> Self { self.http_middleware_chain = chain; self }`
- In `run_flat`, pass `self.http_middleware_chain.clone()` as the last parameter to every `vu_loop` spawn call
- In `run_staged`, same -- pass `self.http_middleware_chain.clone()` to every `vu_loop` spawn call
  </action>
  <verify>
    <automated>cd /Users/guy/Development/mcp/sdk/rust-mcp-sdk && cargo test -p cargo-pmcp --lib loadtest::client::tests -- --test-threads=1 && cargo test -p cargo-pmcp --lib loadtest::vu::tests -- --test-threads=1 && cargo test -p cargo-pmcp --lib loadtest::engine::tests -- --test-threads=1</automated>
  </verify>
  <done>McpClient, vu_loop, and LoadTestEngine all accept and propagate an optional HttpMiddlewareChain (Arc-wrapped). Middleware chain is applied in send_request using the same pattern as StreamableHttpTransport. All existing tests pass with None. New test verifies middleware chain is stored in McpClient.</done>
</task>

<task type="auto">
  <name>Task 2: Add OAuth/API-key CLI flags and middleware chain setup in run command</name>
  <files>cargo-pmcp/src/commands/loadtest/mod.rs, cargo-pmcp/src/commands/loadtest/run.rs</files>
  <action>
**CLI flags (mod.rs):**
Add the following fields to `LoadtestCommand::Run` variant, mirroring `crates/mcp-tester/src/main.rs` lines 57-87:

```rust
/// API key for authentication
#[arg(long, env = "MCP_API_KEY")]
api_key: Option<String>,

/// OAuth client ID (triggers OAuth flow)
#[arg(long, env = "MCP_OAUTH_CLIENT_ID")]
oauth_client_id: Option<String>,

/// OAuth issuer URL (auto-discovered from server if omitted)
#[arg(long, env = "MCP_OAUTH_ISSUER")]
oauth_issuer: Option<String>,

/// OAuth scopes (comma-separated, default: openid)
#[arg(long, env = "MCP_OAUTH_SCOPES", value_delimiter = ',')]
oauth_scopes: Option<Vec<String>>,

/// Disable OAuth token caching
#[arg(long)]
oauth_no_cache: bool,

/// OAuth redirect port for localhost callback (default: 8080)
#[arg(long, env = "MCP_OAUTH_REDIRECT_PORT", default_value = "8080")]
oauth_redirect_port: u16,
```

Update the `execute` match arm for `Run` to destructure these new fields and pass them all to `run::execute_run`.

**Auth setup (run.rs):**
Update `execute_run` signature to accept the new auth parameters. Add auth setup between step 2 (apply overrides) and step 3 (build engine):

```rust
// Step 2.5: Set up authentication middleware (acquire token ONCE before spawning VUs)
let is_oauth = oauth_client_id.is_some();
let http_middleware_chain = resolve_auth_middleware(
    &url,
    api_key,
    oauth_client_id,
    oauth_issuer,
    oauth_scopes,
    oauth_no_cache,
    oauth_redirect_port,
).await?;
```

Create helper function `resolve_auth_middleware` in run.rs:

```rust
use pmcp::client::http_middleware::HttpMiddlewareChain;
use std::sync::Arc;

async fn resolve_auth_middleware(
    mcp_server_url: &str,
    api_key: Option<String>,
    oauth_client_id: Option<String>,
    oauth_issuer: Option<String>,
    oauth_scopes: Option<Vec<String>>,
    oauth_no_cache: bool,
    oauth_redirect_port: u16,
) -> anyhow::Result<Option<Arc<HttpMiddlewareChain>>> {
    // API key takes precedence (simpler, no flow needed)
    if let Some(key) = api_key {
        use pmcp::client::oauth_middleware::{BearerToken, OAuthClientMiddleware};
        eprintln!("Using API key authentication");
        let bearer_token = BearerToken::new(key);
        let middleware = OAuthClientMiddleware::new(bearer_token);
        let mut chain = HttpMiddlewareChain::new();
        chain.add(Arc::new(middleware));
        return Ok(Some(Arc::new(chain)));
    }

    // OAuth flow if client_id is provided
    if let Some(client_id) = oauth_client_id {
        use pmcp::client::oauth::{OAuthConfig, OAuthHelper, default_cache_path};

        let scopes = oauth_scopes.unwrap_or_else(|| vec!["openid".to_string()]);
        let cache_file = if oauth_no_cache { None } else { Some(default_cache_path()) };

        let config = OAuthConfig {
            issuer: oauth_issuer,
            mcp_server_url: Some(mcp_server_url.to_string()),
            client_id,
            scopes,
            cache_file,
            redirect_port: oauth_redirect_port,
        };

        let helper = OAuthHelper::new(config)
            .map_err(|e| anyhow::anyhow!("OAuth setup failed: {e}"))?;
        let chain = helper.create_middleware_chain().await
            .map_err(|e| anyhow::anyhow!("OAuth authentication failed: {e}"))?;
        return Ok(Some(chain));
    }

    // Warn if issuer provided without client_id
    if oauth_issuer.is_some() {
        eprintln!("Warning: --oauth-issuer provided but --oauth-client-id missing. OAuth disabled.");
    }

    Ok(None)
}
```

Pass middleware chain to engine builder: `engine = engine.with_http_middleware(http_middleware_chain);`

Display auth mode AFTER resolving middleware but BEFORE engine.run():
```rust
match &http_middleware_chain {
    Some(_) if is_oauth => eprintln!("Authentication: OAuth 2.0 (token acquired)"),
    Some(_) => eprintln!("Authentication: API key"),
    None => eprintln!("Authentication: none"),
}
```
  </action>
  <verify>
    <automated>cd /Users/guy/Development/mcp/sdk/rust-mcp-sdk && cargo build -p cargo-pmcp 2>&1 | tail -5 && cargo test -p cargo-pmcp --lib commands::loadtest -- --test-threads=1</automated>
  </verify>
  <done>Running `cargo pmcp loadtest run --help` shows --oauth-client-id, --oauth-issuer, --oauth-scopes, --oauth-no-cache, --oauth-redirect-port, and --api-key flags. Code compiles cleanly. Middleware chain is created at startup (fail-fast on bad auth) and passed to engine. Auth type is displayed to user.</done>
</task>

</tasks>

<verification>
```bash
# Full build check
cd /Users/guy/Development/mcp/sdk/rust-mcp-sdk && cargo build -p cargo-pmcp

# All loadtest tests pass
cargo test -p cargo-pmcp --lib loadtest -- --test-threads=1

# CLI help shows new flags
cargo run -p cargo-pmcp -- pmcp loadtest run --help 2>&1 | grep -E "oauth|api-key"

# Clippy clean
cargo clippy -p cargo-pmcp -- -D warnings
```
</verification>

<success_criteria>
1. `cargo pmcp loadtest run --help` shows all 6 auth flags matching `mcp-tester` CLI
2. `cargo build -p cargo-pmcp` succeeds with zero warnings
3. All existing loadtest tests pass (middleware_chain=None path is backward compatible)
4. McpClient uses HttpMiddlewareChain.process_request() pattern -- no auth-specific logic in McpClient
5. Auth middleware resolved once at startup, shared via Arc across all VUs
</success_criteria>

<output>
After completion, create `.planning/phases/26-add-oauth-support-to-load-testing/26-03-SUMMARY.md`
</output>
