Skip to main content

pmcp_server_toolkit/
code_mode.rs

1// Net-new code for Phase 83 TKIT-06 / TKIT-09 (code-mode wiring surface).
2//
3// Bridges `[code_mode]` config blocks into pmcp-code-mode's `ValidationPipeline`
4// + HMAC token machinery, with every public type RE-EXPORTED from pmcp-code-mode
5// per D-16 (NO duplicate HMAC / token code per PATTERNS §"Anti-Patterns" #2).
6//
7// Per Phase 83 review R1, the preflight at
8// `.planning/phases/83-toolkit-core-lift-pmcp-server-toolkit/CODE_MODE_API_NOTES.md`
9// determined the wiring strategy: **R1 split** —
10// `validation_pipeline_from_config(&ServerConfig) -> Result<ValidationPipeline>`
11// + `code_mode_tools_from_executor(executor, config) -> Result<...>` — because
12// `pmcp-code-mode`'s `CodeExecutor` trait requires backend injection
13// (`HttpExecutor`, `SdkExecutor`, `McpExecutor`) and no config-only constructor
14// exists.
15
16//! Code-mode wiring: bridges `[code_mode]` config blocks into pmcp-code-mode's
17//! validation pipeline + HMAC token machinery, with policy / executor /
18//! validation types re-exported verbatim (NO duplicate impl per RESEARCH
19//! §"Anti-Patterns" #2).
20//!
21//! # R1 split (per `CODE_MODE_API_NOTES.md` Section 6)
22//!
23//! - [`validation_pipeline_from_config`] builds a [`ValidationPipeline`] from a
24//!   parsed [`crate::config::ServerConfig`]. This is the entry point Shape A /
25//!   Shape C consumers reach for — no per-server Rust glue needed.
26//! - [`code_mode_tools_from_executor`] composes a caller-supplied
27//!   [`CodeExecutor`] (Plan 08 wires this into `pmcp::ServerBuilder` via
28//!   `code_mode_from_config`).
29//! - [`register_code_mode_tools`] is the tolerant builder-extension entry
30//!   point: a no-op when `[code_mode]` is absent, an R9 enforcement gate when
31//!   present.
32//!
33//! # Security invariants (R6 + R9)
34//!
35//! - **R6 — toolkit-owned secret type.** `token_secret` resolution flows
36//!   through [`crate::secrets::SecretValue`] (feature-independent) and
37//!   converts to [`TokenSecret`] via `From` only at the HMAC boundary. This
38//!   keeps `--no-default-features` stable.
39//! - **R9 — inline-secret rejection.** A `[code_mode] token_secret = "raw"`
40//!   literal is REJECTED at validation/resolve time unless the operator
41//!   explicitly sets `allow_inline_token_secret_for_dev = true`. Default-deny;
42//!   warnings are not protection.
43
44#![cfg(feature = "code-mode")]
45
46// === Re-exports (TKIT-06 + D-16) ===
47//
48// Every symbol below is a pure re-export of `pmcp_code_mode::*`. Plan 06 ships
49// NO duplicate HMAC / token / policy / pipeline code (PATTERNS §"Anti-Patterns"
50// #2 — duplicating these would create two copies of a security-critical
51// invariant set).
52//
53// Symbols verified against `crates/pmcp-code-mode/src/lib.rs` per
54// CODE_MODE_API_NOTES.md Section 7.
55
56pub use pmcp_code_mode::{
57    canonicalize_code, compute_context_hash, hash_code, ApprovalToken, AuthorizationDecision,
58    CodeExecutor, CodeModeConfig, ExecutionError, HmacTokenGenerator, NoopPolicyEvaluator,
59    PolicyEvaluator, TokenGenerator, TokenSecret, ValidationContext, ValidationPipeline,
60};
61
62#[cfg(feature = "avp")]
63pub use pmcp_code_mode::{AvpClient, AvpConfig, AvpPolicyEvaluator};
64
65// OpenAPI / Code-Mode engine surface (Plan 90-04 / OAPI-05). Gated under the
66// `openapi-code-mode` umbrella (which forwards `pmcp-code-mode/js-runtime`), so
67// the bare `code-mode` (SQL-only) build does NOT pull the SWC JS engine
68// (RESEARCH Pitfall 4). Re-exported here so the binary (Plan 06) + Plan 05
69// reference ONE stable path for the engine types the OpenAPI flavor needs.
70#[cfg(feature = "openapi-code-mode")]
71pub use pmcp_code_mode::{ExecutionConfig, HttpExecutor, JsCodeExecutor};
72
73use std::sync::Arc;
74
75use crate::config::{CodeModeSection, ServerConfig};
76use crate::error::{ConfigValidationError, Result, ToolkitError};
77use crate::secrets::SecretValue;
78use crate::sql::{Dialect, SqlConnector};
79
80/// Which validation surface the generalized code-mode wiring drives (OAPI-10 /
81/// D-02 / Gemini review: a compile-time enum, NOT a stringly-typed `&str`, so a
82/// flavor typo is impossible).
83///
84/// Selects BOTH the `CodeModeToolBuilder` format string (the `validate_code` /
85/// `execute_code` tool schema `format` enum, via the private `code_format`
86/// accessor) AND which `ValidationPipeline` method `validate_code` calls:
87/// - [`ValidationFlavor::Sql`] → the `sql` format + `validate_sql_query` (the
88///   Shape A SQL path; unchanged behavior).
89/// - [`ValidationFlavor::OpenApi`] → the `openapi` format +
90///   `validate_javascript_code` (the OpenAPI JS path; really runs SWC-backed JS
91///   validation, not a stub).
92#[cfg(feature = "code-mode")]
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum ValidationFlavor {
95    /// SQL Code Mode — validate via `validate_sql_query`, `"sql"` tool format.
96    Sql,
97    /// OpenAPI Code Mode — validate via `validate_javascript_code`, `"openapi"`
98    /// tool format. Available regardless of the JS engine feature at the type
99    /// level; the OpenAPI `validate_code` path requires `openapi-code-mode`.
100    OpenApi,
101}
102
103#[cfg(feature = "code-mode")]
104impl ValidationFlavor {
105    /// The `CodeModeToolBuilder` format string for this flavor.
106    fn code_format(self) -> &'static str {
107        match self {
108            Self::Sql => "sql",
109            Self::OpenApi => "openapi",
110        }
111    }
112}
113
114/// Derive a per-request [`HttpCodeExecutor`] from a [`pmcp::RequestHandlerExtra`]
115/// by threading the captured inbound MCP client token (Plan 90-10 / OAPI-03 /
116/// OAPI-05).
117///
118/// This is the **toolkit-resident** replacement for the binary's dead
119/// `assemble.rs::request_executor` (WR-01 — the binary helper had NO runtime
120/// callers because the handlers, which live in THIS crate, could not reach it
121/// across the crate boundary). Both [`crate::tools`]'s `ScriptToolHandler` and
122/// the OpenAPI [`tool_handlers::ExecuteCodeHandler`] call this from inside their
123/// `handle` methods so the per-request `oauth_passthrough` token actually reaches
124/// the outbound request at runtime.
125///
126/// Reads `extra.auth_context().and_then(|ctx| ctx.token.clone())` (the raw
127/// inbound `Authorization` header captured by the binary's
128/// `TokenCaptureAuthProvider`) and returns a cheap clone of `base` carrying that
129/// token via [`HttpCodeExecutor::with_inbound_token`]. For an `oauth_passthrough`
130/// backend the cloned executor forwards the captured token to `target_header`;
131/// for static-auth backends the token is ignored (harmless).
132#[cfg(feature = "openapi-code-mode")]
133#[must_use]
134pub fn request_executor_from_extra(
135    base: &HttpCodeExecutor,
136    extra: &pmcp::RequestHandlerExtra,
137) -> HttpCodeExecutor {
138    let token = extra.auth_context().and_then(|ctx| ctx.token.clone());
139    // Every derivation is ONE `tools/call`, so this is where the call id is minted:
140    // every request that call makes carries the same id, and the next call gets a
141    // fresh one. Both Code Mode (`execute_code`) and script tools derive here.
142    base.clone()
143        .with_inbound_token(token)
144        .with_call_id(crate::policy::next_call_id())
145}
146
147// =============================================================================
148// R1 split — validation_pipeline_from_config + code_mode_tools_from_executor
149// =============================================================================
150
151/// Build a [`ValidationPipeline`] from a [`ServerConfig`]'s `[code_mode]` block.
152///
153/// Maps every reference-server [`CodeModeSection`] field onto
154/// [`CodeModeConfig`] per the verified construction surface in
155/// `CODE_MODE_API_NOTES.md` Section 2. The pipeline's HMAC token machinery is
156/// keyed by the resolved [`TokenSecret`] (derived from a toolkit-owned
157/// [`SecretValue`] per review R6).
158///
159/// Per Phase 83 review R1 — the preflight selected the R1 split because
160/// `pmcp-code-mode`'s [`CodeExecutor`] requires backend injection
161/// (`HttpExecutor` / `SdkExecutor` / `McpExecutor`); no config-only executor
162/// constructor exists. This function delivers the validation surface; the
163/// caller supplies the executor (see [`code_mode_tools_from_executor`]).
164///
165/// # Errors
166///
167/// - [`ToolkitError::CodeMode`] if `config.code_mode` is `None`.
168/// - [`ToolkitError::Validation`] wrapping
169///   [`ConfigValidationError::InlineSecretRejected`] when `token_secret` is an
170///   inline literal without `allow_inline_token_secret_for_dev` (review R9).
171/// - [`ToolkitError::CodeMode`] if the env var referenced by `env:VAR_NAME` is
172///   unset, or if the resolved secret is shorter than
173///   [`HmacTokenGenerator::MIN_SECRET_LEN`] (16 bytes).
174///
175/// # Example
176///
177/// ```no_run
178/// use pmcp_server_toolkit::code_mode::validation_pipeline_from_config;
179/// use pmcp_server_toolkit::config::ServerConfig;
180///
181/// // ServerConfig with a [code_mode] block + env:-style token_secret
182/// // resolves into a ValidationPipeline ready to validate SQL / GraphQL.
183/// let toml = r#"
184/// [server]
185/// name = "demo"
186/// version = "0.1.0"
187/// [code_mode]
188/// enabled = true
189/// token_secret = "env:DEMO_HMAC_SECRET"
190/// "#;
191/// std::env::set_var("DEMO_HMAC_SECRET", "demo-secret-that-is-long-enough");
192/// let cfg = ServerConfig::from_toml_strict_validated(toml).unwrap();
193/// let _pipeline = validation_pipeline_from_config(&cfg).unwrap();
194/// ```
195pub fn validation_pipeline_from_config(config: &ServerConfig) -> Result<ValidationPipeline> {
196    let section = config.code_mode.as_ref().ok_or_else(|| {
197        ToolkitError::CodeMode("ServerConfig has no [code_mode] block".to_string())
198    })?;
199    let cm_config = build_cm_config(section);
200    let secret_value = resolve_token_secret(section)?;
201    let token_secret: TokenSecret = secret_value.into(); // R6 conversion
202    ValidationPipeline::from_token_secret(cm_config, &token_secret)
203        .map_err(|e| ToolkitError::CodeMode(format!("ValidationPipeline construction failed: {e}")))
204}
205
206/// Register `validate_code` + `execute_code` on `builder`, driven by the
207/// `[code_mode]` block, a caller-supplied [`CodeExecutor`], and a
208/// [`ValidationFlavor`] (OAPI-10 / D-02).
209///
210/// This is the ONE backend-agnostic wiring function serving BOTH the SQL path
211/// (`flavor = ValidationFlavor::Sql`, executor = [`SqlCodeExecutor`]) and the
212/// OpenAPI path (`flavor = ValidationFlavor::OpenApi`, executor =
213/// `JsCodeExecutor<HttpCodeExecutor>`). The `executor` is type-erased to
214/// `Arc<dyn CodeExecutor>` so the same function — and the same `execute_code`
215/// handler body, which already dispatches through the trait — works for any
216/// backend; only the `flavor` selects the validation surface + tool format.
217///
218/// This is the actual two-tool registration the LOCKED
219/// [`crate::builder_ext::ServerBuilderExt::try_code_mode_from_config_with_connector`]
220/// delegates to (the Phase 83-06 R1 split precedent: the connector-aware
221/// builder method constructs the executor, this helper wires the tools).
222///
223/// - When `config.code_mode.is_none()` the builder is returned UNCHANGED
224///   (no-op) — code-mode is opt-in at the config level.
225/// - When `[code_mode]` IS present, the R9 inline-secret gate and the
226///   secret-resolution / HMAC machinery run via [`validation_pipeline_from_config`]
227///   (errors surface BEFORE `.build()`), then both tools are registered with
228///   the static `[code_mode]` policy baked into the pipeline (SC-3 / D-13).
229///   A [`NoopPolicyEvaluator`] is wired so authorization is purely the static
230///   config policy (allow_writes / allow_deletes / allow_ddl), not an external
231///   Cedar/AVP engine.
232///
233/// # Errors
234///
235/// Surfaces every error from [`validation_pipeline_from_config`] when
236/// `config.code_mode.is_some()` — most notably
237/// [`ConfigValidationError::InlineSecretRejected`] (review R9) and the
238/// [`ToolkitError::CodeMode`] secret-resolution / 16-byte-minimum failures.
239pub fn code_mode_tools_from_executor(
240    builder: pmcp::ServerBuilder,
241    config: &ServerConfig,
242    executor: Arc<dyn CodeExecutor>,
243    flavor: ValidationFlavor,
244) -> Result<pmcp::ServerBuilder> {
245    let Some(section) = config.code_mode.as_ref() else {
246        return Ok(builder); // no-op when block absent
247    };
248    // Build the policy-bearing pipeline. This is also the R9 enforcement gate +
249    // secret resolution — must run BEFORE the builder is returned so a
250    // misconfigured token_secret is caught at builder-time, not first request.
251    let cm_config = build_cm_config(section);
252    let secret_value = resolve_token_secret(section)?;
253    let token_secret: TokenSecret = secret_value.into();
254    let evaluator: Arc<dyn PolicyEvaluator> = Arc::new(NoopPolicyEvaluator::new());
255    let pipeline = ValidationPipeline::from_token_secret_with_policy(
256        cm_config.clone(),
257        &token_secret,
258        evaluator,
259    )
260    .map_err(|e| ToolkitError::CodeMode(format!("ValidationPipeline construction failed: {e}")))?;
261    let pipeline = Arc::new(pipeline);
262
263    let validate_handler = tool_handlers::ValidateCodeHandler {
264        pipeline: Arc::clone(&pipeline),
265        config: cm_config,
266        flavor,
267        description_notice: section.description_notice.clone(),
268        #[cfg(feature = "openapi-code-mode")]
269        preview: None,
270    };
271    let execute_handler = tool_handlers::ExecuteCodeHandler {
272        pipeline,
273        source: tool_handlers::ExecSource::Static(executor),
274        flavor,
275        description_notice: section.description_notice.clone(),
276    };
277
278    Ok(builder
279        .tool_arc("validate_code", Arc::new(validate_handler))
280        .tool_arc("execute_code", Arc::new(execute_handler)))
281}
282
283/// Register `validate_code` + `execute_code` on `builder` for the **OpenAPI
284/// per-request** Code-Mode path (Plan 90-10 / OAPI-03 / OAPI-05).
285///
286/// This is the per-request analog of [`code_mode_tools_from_executor`]. Where
287/// that helper takes a FIXED type-erased `Arc<dyn CodeExecutor>` (the SQL path,
288/// whose `SqlCodeExecutor` carries no per-request state), this helper takes the
289/// concrete [`HttpCodeExecutor`] `base` + [`ExecutionConfig`] so the
290/// [`tool_handlers::ExecuteCodeHandler`] can RE-DERIVE a request-scoped
291/// `JsCodeExecutor` per call via [`request_executor_from_extra`] — threading the
292/// captured inbound MCP token so an `oauth_passthrough` backend forwards it to
293/// the real backend.
294///
295/// The reason a per-request entry point is required: a `JsCodeExecutor`'s inner
296/// `http` field is private with no accessor, so a type-erased
297/// `Arc<dyn CodeExecutor>` cannot be re-derived per request. Holding the base
298/// [`HttpCodeExecutor`] (which IS `Clone` + has the `with_inbound_token` builder)
299/// makes the per-request rederivation possible WITHOUT changing the SQL path.
300///
301/// The `validate_code` handler is identical to the
302/// [`code_mode_tools_from_executor`] one; only `execute_code` differs (it carries
303/// the [`tool_handlers::ExecSource::PerRequestHttp`] source instead of
304/// [`tool_handlers::ExecSource::Static`]).
305///
306/// # Errors
307///
308/// Surfaces every error from [`validation_pipeline_from_config`] when
309/// `config.code_mode.is_some()` (R9 inline-secret rejection, secret-resolution /
310/// 16-byte-minimum failures). No-op (returns the builder unchanged) when
311/// `config.code_mode.is_none()`.
312#[cfg(feature = "openapi-code-mode")]
313pub fn code_mode_http_tools_from_executor(
314    builder: pmcp::ServerBuilder,
315    config: &ServerConfig,
316    base: HttpCodeExecutor,
317    exec_config: ExecutionConfig,
318    flavor: ValidationFlavor,
319) -> Result<pmcp::ServerBuilder> {
320    let Some(section) = config.code_mode.as_ref() else {
321        return Ok(builder); // no-op when block absent
322    };
323    // R9 enforcement gate + secret resolution — must run BEFORE the builder is
324    // returned so a misconfigured token_secret is caught at builder-time.
325    let cm_config = build_cm_config(section);
326    let secret_value = resolve_token_secret(section)?;
327    let token_secret: TokenSecret = secret_value.into();
328    let evaluator: Arc<dyn PolicyEvaluator> = Arc::new(NoopPolicyEvaluator::new());
329    let pipeline = ValidationPipeline::from_token_secret_with_policy(
330        cm_config.clone(),
331        &token_secret,
332        evaluator,
333    )
334    .map_err(|e| ToolkitError::CodeMode(format!("ValidationPipeline construction failed: {e}")))?;
335    let pipeline = Arc::new(pipeline);
336
337    // The preview asks the policy about a call under the label `execute_code`
338    // will use, so a policy keyed on the tool name answers identically in both
339    // phases. Only kept when a policy is registered: with none there is nothing
340    // that could refuse.
341    let preview = base.has_request_policy().then(|| {
342        (
343            base.clone().with_tool_label("execute_code"),
344            exec_config.clone(),
345        )
346    });
347    let validate_handler = tool_handlers::ValidateCodeHandler {
348        pipeline: Arc::clone(&pipeline),
349        config: cm_config,
350        flavor,
351        description_notice: section.description_notice.clone(),
352        preview,
353    };
354    let execute_handler = tool_handlers::ExecuteCodeHandler {
355        pipeline,
356        source: tool_handlers::ExecSource::PerRequestHttp {
357            // Phase 128 E1: label the executor with the tool it serves, so a
358            // registered `RequestPolicy` can attribute an outbound request. One
359            // `execute_code` call may issue many requests; they all carry this
360            // label.
361            base: base.with_tool_label("execute_code"),
362            exec_config,
363        },
364        flavor,
365        description_notice: section.description_notice.clone(),
366    };
367
368    Ok(builder
369        .tool_arc("validate_code", Arc::new(validate_handler))
370        .tool_arc("execute_code", Arc::new(execute_handler)))
371}
372
373/// Tolerant builder-extension entry point for `[code_mode]` config — the
374/// CONNECTORLESS, **validation-only / no-tool** path.
375///
376/// Used by [`crate::builder_ext::ServerBuilderExt::try_code_mode_from_config`]
377/// (the connectorless companion). It is deliberately tolerant of
378/// `config.code_mode = None` (returns the builder unchanged) so callers can
379/// invoke it unconditionally — code-mode is opt-in at the config level.
380///
381/// When `[code_mode]` IS present, this helper drives
382/// [`validation_pipeline_from_config`] to surface R9 enforcement errors
383/// (inline `token_secret` rejection) before the builder reaches `.build()`,
384/// but registers NO tools because there is no executor to bind to. The
385/// tool-registering path is
386/// [`crate::builder_ext::ServerBuilderExt::try_code_mode_from_config_with_connector`]
387/// (which delegates to [`code_mode_tools_from_executor`]).
388///
389/// # Errors
390///
391/// Returns every error from [`validation_pipeline_from_config`] when
392/// `config.code_mode.is_some()`. No errors when `config.code_mode.is_none()`.
393pub fn register_code_mode_tools(
394    builder: pmcp::ServerBuilder,
395    config: &ServerConfig,
396) -> Result<pmcp::ServerBuilder> {
397    if config.code_mode.is_none() {
398        return Ok(builder); // no-op when block absent
399    }
400    // R9 enforcement gate — must run BEFORE the builder is returned so that a
401    // misconfigured `[code_mode] token_secret = "inline-string"` is caught at
402    // builder-time, not at first request. NO tools registered (no executor) —
403    // this is the documented connectorless validation-only path.
404    let _pipeline = validation_pipeline_from_config(config)?;
405    Ok(builder)
406}
407
408/// The literal JSON a plan expression is guaranteed to evaluate to, or `None` when
409/// any part of it depends on runtime state (a variable, an operator, a spread).
410#[cfg(feature = "openapi-code-mode")]
411fn literal_json(expr: &pmcp_code_mode::ValueExpr) -> Option<serde_json::Value> {
412    use pmcp_code_mode::executor::ObjectField;
413    use pmcp_code_mode::ValueExpr;
414    match expr {
415        ValueExpr::Literal(v) => Some(v.clone()),
416        ValueExpr::ArrayLiteral { items } => items
417            .iter()
418            .map(literal_json)
419            .collect::<Option<Vec<_>>>()
420            .map(serde_json::Value::Array),
421        ValueExpr::ObjectLiteral { fields } => {
422            let mut map = serde_json::Map::new();
423            for field in fields {
424                match field {
425                    ObjectField::KeyValue { key, value } => {
426                        map.insert(key.clone(), literal_json(value)?);
427                    },
428                    ObjectField::Spread { .. } => return None,
429                }
430            }
431            Some(serde_json::Value::Object(map))
432        },
433        _ => None,
434    }
435}
436
437/// One call whose complete request is known before the script runs.
438#[cfg(feature = "openapi-code-mode")]
439type LiteralCall = (String, String, Option<serde_json::Value>);
440
441/// Collect every call in `steps`, nested blocks included, that is fully literal:
442/// a path made only of literal text with no `{placeholder}`, and a body that is
443/// absent or a literal. A call built from a variable, a loop item or a template is
444/// skipped, because its request is not known until it runs and execution stays the
445/// authority for it.
446#[cfg(feature = "openapi-code-mode")]
447fn collect_literal_calls(steps: &[pmcp_code_mode::PlanStep], out: &mut Vec<LiteralCall>) {
448    use pmcp_code_mode::PlanStep;
449    for step in steps {
450        match step {
451            PlanStep::ApiCall {
452                method, path, body, ..
453            } => push_literal_call(method, path, body.as_ref(), out),
454            PlanStep::ParallelApiCalls { calls, .. } => {
455                for (_, method, path, body) in calls {
456                    push_literal_call(method, path, body.as_ref(), out);
457                }
458            },
459            other => nested_blocks(other)
460                .into_iter()
461                .for_each(|block| collect_literal_calls(block, out)),
462        }
463    }
464}
465
466/// The step lists nested inside a branch, loop or `try` step (none for any other
467/// step), so [`collect_literal_calls`] can recurse into each.
468#[cfg(feature = "openapi-code-mode")]
469fn nested_blocks(step: &pmcp_code_mode::PlanStep) -> Vec<&[pmcp_code_mode::PlanStep]> {
470    use pmcp_code_mode::PlanStep;
471    match step {
472        PlanStep::Conditional {
473            then_steps,
474            else_steps,
475            ..
476        } => vec![then_steps, else_steps],
477        PlanStep::BoundedLoop { body, .. } => vec![body],
478        PlanStep::TryCatch {
479            try_steps,
480            catch_steps,
481            finally_steps,
482            ..
483        } => vec![try_steps, catch_steps, finally_steps],
484        _ => Vec::new(),
485    }
486}
487
488#[cfg(feature = "openapi-code-mode")]
489fn push_literal_call(
490    method: &str,
491    path: &pmcp_code_mode::PathTemplate,
492    body: Option<&pmcp_code_mode::ValueExpr>,
493    out: &mut Vec<LiteralCall>,
494) {
495    let mut joined = String::new();
496    for part in &path.parts {
497        match part {
498            pmcp_code_mode::PathPart::Literal(text) => joined.push_str(text),
499            _ => return,
500        }
501    }
502    if joined.contains('{') {
503        return;
504    }
505    let body = match body {
506        None => None,
507        Some(expr) => match literal_json(expr) {
508            Some(value) => Some(value),
509            None => return,
510        },
511    };
512    out.push((method.to_string(), joined, body));
513}
514
515/// `validate_code`'s outbound-policy preview: the first refusal, as a
516/// `PolicyViolation`, for a call in `code` whose request is fully known.
517///
518/// `None` when the code does not compile to a plan (validation already reported
519/// that), or when no literal call is refused. One call id is minted for the whole
520/// validation, so a policy sees the calls of one script as one run.
521#[cfg(feature = "openapi-code-mode")]
522async fn preview_policy_violation(
523    base: &HttpCodeExecutor,
524    exec_config: &ExecutionConfig,
525    code: &str,
526) -> Option<pmcp_code_mode::PolicyViolation> {
527    let plan = pmcp_code_mode::PlanCompiler::with_config(exec_config)
528        .compile_code(code)
529        .ok()?;
530    let mut calls = Vec::new();
531    collect_literal_calls(&plan.steps, &mut calls);
532    let call_id = crate::policy::next_call_id();
533    for (index, (method, path, body)) in calls.into_iter().enumerate() {
534        if let Err(error) = base.preview_request(&method, &path, body, &call_id).await {
535            let message = match error {
536                ExecutionError::RequestRefused { message } => message,
537                other => other.to_string(),
538            };
539            // The call is named by its position, never by its method or path: those
540            // are caller-written text, and a refusal that echoes them would put a
541            // caller-supplied value (a PHI literal in a path) back into a message
542            // the SDK promises is value-free.
543            return Some(pmcp_code_mode::PolicyViolation::new(
544                "outbound_request_policy",
545                "request_refused",
546                format!("literal api call #{}: {message}", index + 1),
547            ));
548        }
549    }
550    None
551}
552
553// =============================================================================
554// Hand-built validate_code / execute_code ToolHandlers (Plan 85-02 Task 2)
555//
556// Mirrors the `#[derive(CodeMode)]` macro output in pmcp-code-mode-derive but
557// hand-written here so the toolkit does NOT take a proc-macro dependency. Only
558// the PUBLIC API (`code_mode_tools_from_executor` +
559// `try_code_mode_from_config_with_connector`) is LOCKED; this internal
560// mechanism is the implementer's discretion (Plan 85-02 Task 2).
561// =============================================================================
562mod tool_handlers {
563    use std::sync::Arc;
564
565    use super::ValidationFlavor;
566    use pmcp_code_mode::TokenGenerator as _;
567
568    /// Run the flavor-appropriate validation surface (OAPI-10 / D-02).
569    ///
570    /// - [`ValidationFlavor::Sql`] → `validate_sql_query` (Shape A SQL path).
571    /// - [`ValidationFlavor::OpenApi`] → `validate_javascript_code` (the OpenAPI
572    ///   JS path; really runs SWC-backed JS validation). Only reachable when the
573    ///   `openapi-code-mode` feature is enabled — the binary that wires the
574    ///   OpenApi flavor enables that umbrella, so the arm is feature-gated.
575    fn run_flavored_validation(
576        pipeline: &pmcp_code_mode::ValidationPipeline,
577        flavor: ValidationFlavor,
578        code: &str,
579        context: &pmcp_code_mode::ValidationContext,
580    ) -> std::result::Result<pmcp_code_mode::ValidationResult, String> {
581        match flavor {
582            ValidationFlavor::Sql => pipeline
583                .validate_sql_query(code, context)
584                .map_err(|e| format!("Validation error: {e}")),
585            #[cfg(feature = "openapi-code-mode")]
586            ValidationFlavor::OpenApi => pipeline
587                .validate_javascript_code(code, context)
588                .map_err(|e| format!("Validation error: {e}")),
589            #[cfg(not(feature = "openapi-code-mode"))]
590            ValidationFlavor::OpenApi => Err(
591                "OpenAPI Code Mode validation requires the `openapi-code-mode` feature".to_string(),
592            ),
593        }
594    }
595
596    /// Append the operator's `[code_mode] description_notice`, if any, to a tool
597    /// description, after the SDK's own text and a blank line. A blank or unset
598    /// notice leaves the description exactly as the SDK wrote it.
599    fn with_notice(mut info: pmcp::types::ToolInfo, notice: Option<&str>) -> pmcp::types::ToolInfo {
600        if let Some(notice) = notice.map(str::trim).filter(|n| !n.is_empty()) {
601            info.description = Some(match info.description.take() {
602                Some(base) => format!("{base}\n\n{notice}"),
603                None => notice.to_string(),
604            });
605        }
606        info
607    }
608
609    /// Classify an `execute_code` failure for the tool boundary.
610    ///
611    /// A refused request ([`ExecutionError::RequestRefused`]: the path floor, a
612    /// non-scalar value, or an embedder's outbound policy) is the CALLER's to fix by
613    /// changing what the script sends, so it is a tool-level rejection the model can
614    /// act on. Every other error stays `Internal`: a backend or runtime fault is not
615    /// something the caller can correct by changing input.
616    ///
617    /// Until pmcp-code-mode 0.7 a refusal was indistinguishable from a fault, so a
618    /// policy refusal reached the model as `Internal error: Execution error: Runtime
619    /// error: ...` and read as a server crash.
620    ///
621    /// The wildcard arm is required: `ExecutionError` is `#[non_exhaustive]`.
622    pub(super) fn execution_failure(error: pmcp_code_mode::ExecutionError) -> pmcp::Error {
623        match error {
624            pmcp_code_mode::ExecutionError::RequestRefused { message } => {
625                pmcp::Error::tool_rejected(message, None)
626            },
627            other => pmcp::Error::Internal(format!("Execution error: {other}")),
628        }
629    }
630
631    /// `validate_code` tool handler: runs the code through the policy-bearing
632    /// [`ValidationPipeline`](pmcp_code_mode::ValidationPipeline) (SQL or JS per
633    /// [`ValidationFlavor`]) and returns the explanation + (on success) an HMAC
634    /// approval token.
635    pub(super) struct ValidateCodeHandler {
636        pub(super) pipeline: Arc<pmcp_code_mode::ValidationPipeline>,
637        pub(super) config: pmcp_code_mode::CodeModeConfig,
638        pub(super) flavor: ValidationFlavor,
639        /// `[code_mode] description_notice`, appended to the tool description.
640        pub(super) description_notice: Option<String>,
641        /// The executor and limits `validate_code` previews literal calls
642        /// against, when an E1 policy could refuse one. `None` on the SQL path and
643        /// on any server that registered no outbound policy.
644        #[cfg(feature = "openapi-code-mode")]
645        pub(super) preview: Option<(super::HttpCodeExecutor, pmcp_code_mode::ExecutionConfig)>,
646    }
647
648    #[pmcp_code_mode::async_trait]
649    impl pmcp::ToolHandler for ValidateCodeHandler {
650        async fn handle(
651            &self,
652            args: serde_json::Value,
653            _extra: pmcp::RequestHandlerExtra,
654        ) -> pmcp::Result<serde_json::Value> {
655            let input: pmcp_code_mode::ValidateCodeInput = serde_json::from_value(args)
656                .map_err(|e| pmcp::Error::Internal(format!("Invalid arguments: {e}")))?;
657            let code = input.code.trim();
658            let dry_run = input.dry_run.unwrap_or(false);
659
660            // Static-policy ValidationContext — the toolkit binds approval
661            // tokens to a fixed config-derived context (no live user/session
662            // surface in the pure-config binary). Static `[code_mode]` policy
663            // (allow_writes/deletes/ddl for SQL; openapi_blocked_paths /
664            // disallowed ops for OpenApi) is enforced inside the validation
665            // surface selected by `flavor`.
666            let context = pmcp_code_mode::ValidationContext::new(
667                "code-mode-config",
668                "code-mode-session",
669                "schema-hash",
670                "perms-hash",
671            );
672
673            #[cfg_attr(not(feature = "openapi-code-mode"), allow(unused_mut))]
674            // Why: only the `openapi-code-mode` preview below mutates it.
675            let mut result = run_flavored_validation(&self.pipeline, self.flavor, code, &context)
676                .map_err(pmcp::Error::Internal)?;
677
678            // Run the outbound policy over the calls whose request is already known.
679            // A refusal here reaches the model as a rejected validation, with no
680            // approval token, instead of as a failure after it was approved.
681            #[cfg(feature = "openapi-code-mode")]
682            if result.is_valid {
683                if let Some((base, exec_config)) = &self.preview {
684                    if let Some(violation) =
685                        super::preview_policy_violation(base, exec_config, code).await
686                    {
687                        result.is_valid = false;
688                        result.approval_token = None;
689                        result.violations.push(violation);
690                    }
691                }
692            }
693
694            let mut response = pmcp_code_mode::ValidationResponse::from_result(result);
695            if response.result.is_valid {
696                if dry_run {
697                    response.result.approval_token = None;
698                }
699                let risk = response.result.risk_level;
700                response = response.with_auto_approved(self.config.should_auto_approve(risk));
701            }
702            let (json, is_error) = response.to_json_response();
703            // A policy rejection (allow_writes/deletes/ddl off, require_limit, …)
704            // is reported by `to_json_response` with `is_error == true`. Surface it
705            // as a TOOL-level rejection via `Error::tool_rejected` so the MCP
706            // `tools/call` result is `CallToolResult { isError: true }` carrying a
707            // model-actionable `message` plus the full violation JSON in
708            // `structuredContent` — NOT a `-32603` protocol error (which reads as a
709            // server fault and gives the model nothing to correct). This is the
710            // production-reference observable the generated.yaml `failure`
711            // assertions (DELETE/DDL/no-LIMIT) verify: mcp-tester treats
712            // `isError: true` as a failed step (SC-3 policy-enforcement proof,
713            // threat T-85-02-02).
714            if is_error {
715                let message = response
716                    .result
717                    .violations
718                    .first()
719                    .map(ToString::to_string)
720                    .unwrap_or_else(|| {
721                        "Code Mode rejected the query (policy validation failed)".to_string()
722                    });
723                return Err(pmcp::Error::tool_rejected(message, Some(json)));
724            }
725            Ok(json)
726        }
727
728        fn metadata(&self) -> Option<pmcp::types::ToolInfo> {
729            Some(with_notice(
730                pmcp_code_mode::CodeModeToolBuilder::new(self.flavor.code_format())
731                    .build_validate_tool(),
732                self.description_notice.as_deref(),
733            ))
734        }
735    }
736
737    /// How `execute_code` obtains the [`CodeExecutor`](pmcp_code_mode::CodeExecutor)
738    /// for a request (Plan 90-10 / OAPI-03 / OAPI-05).
739    ///
740    /// - [`ExecSource::Static`] — a FIXED type-erased executor (the SQL path's
741    ///   `SqlCodeExecutor`, which carries no per-request state). Unchanged
742    ///   behavior; available under bare `code-mode`.
743    /// - [`ExecSource::PerRequestHttp`] — the OpenAPI path: a base
744    ///   [`HttpCodeExecutor`](super::HttpCodeExecutor) + [`ExecutionConfig`](super::ExecutionConfig)
745    ///   from which a request-scoped `JsCodeExecutor` is RE-DERIVED per call (via
746    ///   [`request_executor_from_extra`](super::request_executor_from_extra)) so
747    ///   the captured inbound `oauth_passthrough` token is threaded to the
748    ///   backend. Feature-gated `openapi-code-mode` (the engine types are only in
749    ///   scope there); the SQL build is unaffected.
750    pub(super) enum ExecSource {
751        /// SQL path — a fixed type-erased executor, no per-request derivation.
752        Static(Arc<dyn pmcp_code_mode::CodeExecutor>),
753        /// OpenAPI path — re-derive a request-scoped executor per call so the
754        /// captured inbound token reaches the backend (OAPI-03 / OAPI-05).
755        #[cfg(feature = "openapi-code-mode")]
756        PerRequestHttp {
757            /// The base executor (cloned + token-threaded per request).
758            base: super::HttpCodeExecutor,
759            /// The execution bounds for the per-request `JsCodeExecutor`.
760            exec_config: super::ExecutionConfig,
761        },
762    }
763
764    /// `execute_code` tool handler: verifies the approval token + code hash,
765    /// then runs the code through the backend-agnostic
766    /// [`CodeExecutor`](pmcp_code_mode::CodeExecutor) (SQL re-validates before
767    /// the connector; OpenAPI runs the validated JS through a request-scoped
768    /// `JsCodeExecutor`). The `flavor` only selects the tool `format` metadata —
769    /// the `handle` body dispatches through the trait regardless of backend.
770    pub(super) struct ExecuteCodeHandler {
771        pub(super) pipeline: Arc<pmcp_code_mode::ValidationPipeline>,
772        pub(super) source: ExecSource,
773        pub(super) flavor: ValidationFlavor,
774        /// `[code_mode] description_notice`, appended to the tool description.
775        pub(super) description_notice: Option<String>,
776    }
777
778    impl ExecuteCodeHandler {
779        /// Run the validated `code` through the source-appropriate executor
780        /// (Plan 90-10). The `Static` arm dispatches through the fixed
781        /// type-erased executor (SQL); the `PerRequestHttp` arm RE-DERIVES a
782        /// request-scoped `JsCodeExecutor` carrying the captured inbound token
783        /// via [`request_executor_from_extra`](super::request_executor_from_extra)
784        /// so an `oauth_passthrough` backend forwards it (OAPI-03 / OAPI-05).
785        ///
786        /// Extracted from `handle` to keep both bodies under the cog ≤25 budget.
787        async fn run_code(
788            &self,
789            code: &str,
790            variables: Option<&serde_json::Value>,
791            #[cfg_attr(not(feature = "openapi-code-mode"), allow(unused_variables))]
792            extra: &pmcp::RequestHandlerExtra,
793        ) -> std::result::Result<serde_json::Value, pmcp_code_mode::ExecutionError> {
794            // Gated to match the ONE arm that needs it. Under `openapi-code-mode`
795            // the `PerRequestHttp` arm calls `.execute()` on a freshly built
796            // `JsCodeExecutor`, which resolves only through this trait. Without
797            // that feature the arm is cfg'd out and the `Static` arm resolves
798            // `.execute()` without the trait, leaving the import unused — a
799            // warning that becomes a hard error wherever `-D warnings` reaches
800            // this crate. Measured both ways: `--features http` warns,
801            // `--features http,openapi-code-mode` does not.
802            #[cfg(feature = "openapi-code-mode")]
803            use pmcp_code_mode::CodeExecutor as _;
804            match &self.source {
805                ExecSource::Static(executor) => executor.execute(code, variables).await,
806                #[cfg(feature = "openapi-code-mode")]
807                ExecSource::PerRequestHttp { base, exec_config } => {
808                    let http_exec = super::request_executor_from_extra(base, extra);
809                    super::JsCodeExecutor::new(http_exec, exec_config.clone())
810                        .execute(code, variables)
811                        .await
812                },
813            }
814        }
815    }
816
817    #[pmcp_code_mode::async_trait]
818    impl pmcp::ToolHandler for ExecuteCodeHandler {
819        async fn handle(
820            &self,
821            args: serde_json::Value,
822            extra: pmcp::RequestHandlerExtra,
823        ) -> pmcp::Result<serde_json::Value> {
824            let input: pmcp_code_mode::ExecuteCodeInput = serde_json::from_value(args)
825                .map_err(|e| pmcp::Error::Internal(format!("Invalid arguments: {e}")))?;
826            let code = input.code.trim();
827
828            // Token / code-hash verification failures are model-actionable
829            // rejections (the model must re-run validate_code to obtain a fresh
830            // token, or resend the exact validated code), so surface them as
831            // `CallToolResult { isError: true }` via `Error::tool_rejected` —
832            // not `-32603`. A genuine execution fault (connector/SQL runtime,
833            // below) stays an `Internal` protocol error: the caller cannot fix
834            // it by changing input.
835            let token_gen = self.pipeline.token_generator();
836            let token =
837                pmcp_code_mode::ApprovalToken::decode(&input.approval_token).map_err(|e| {
838                    pmcp::Error::tool_rejected(
839                        format!(
840                        "Invalid approval_token: {e}. Call validate_code to obtain a valid token."
841                    ),
842                        None,
843                    )
844                })?;
845            token_gen.verify(&token).map_err(|e| {
846                pmcp::Error::tool_rejected(
847                    format!(
848                        "Approval token is invalid or expired: {e}. \
849                         Call validate_code again to obtain a fresh token."
850                    ),
851                    None,
852                )
853            })?;
854            token_gen.verify_code(code, &token).map_err(|e| {
855                pmcp::Error::tool_rejected(
856                    format!(
857                        "Code does not match the validated code: {e}. execute_code must use the \
858                         exact code string that was passed to validate_code."
859                    ),
860                    None,
861                )
862            })?;
863
864            let result = self
865                .run_code(code, input.variables.as_ref(), &extra)
866                .await
867                .map_err(execution_failure)?;
868            Ok(result)
869        }
870
871        fn metadata(&self) -> Option<pmcp::types::ToolInfo> {
872            Some(with_notice(
873                pmcp_code_mode::CodeModeToolBuilder::new(self.flavor.code_format())
874                    .build_execute_tool(),
875                self.description_notice.as_deref(),
876            ))
877        }
878    }
879}
880
881// =============================================================================
882// SHAP-A-01 — SqlCodeExecutor (Plan 85-02 Task 1)
883// =============================================================================
884
885/// [`CodeExecutor`] adapter bridging the toolkit's single-method
886/// [`SqlConnector`] to the code-mode `validate_code` / `execute_code` flow.
887///
888/// # Re-derived for the single-method trait
889///
890/// The production reference (`mcp-sql-server-core::SqlCodeModeHandler`) is
891/// written over a 2-method `DatabaseConnector` (`execute_query` /
892/// `execute_statement`) and dispatches by [`crate::sql`]'s
893/// `QueryType`. The toolkit's [`SqlConnector`] exposes a SINGLE
894/// [`SqlConnector::execute`] entry point, so this adapter collapses that
895/// 2-method dispatch into one `connector.execute(sql, &params)` call regardless
896/// of statement type — re-validating the SQL FIRST for defense-in-depth. The
897/// `execute_code` `variables` input IS bound as named params (85-10 WR-02);
898/// it is never silently dropped.
899///
900/// # Defense-in-depth re-validation (threat T-85-02-01)
901///
902/// Before touching the connector, [`SqlCodeExecutor::execute`] re-runs the
903/// `[code_mode]` policy against the supplied SQL via the same
904/// [`ValidationPipeline`] the `validate_code` tool used. The code-mode
905/// framework already verified the approval token + code hash before calling
906/// this method, but re-validation guards against a token issued for an
907/// allowed statement being replayed with a different (e.g. mutating)
908/// statement. A policy violation returns `Err(ExecutionError::BackendError)`
909/// BEFORE the connector is reached — a config-driven server cannot bypass the
910/// write/DDL guards (SC-3, threat T-85-02-02).
911///
912/// # Observable result shape (REVIEW FIX Codex MEDIUM #6b)
913///
914/// The production handler returns
915/// `{"columns": [...], "rows": [...], "rows_affected": N}` because its
916/// 2-method connector surfaces columns + affected-row counts separately. The
917/// toolkit's [`SqlConnector::execute`] returns `Vec<Value>` (one JSON object
918/// per row, keyed by column name) with no separate columns/rows_affected
919/// channel, so this adapter mirrors production's OBSERVABLE `"rows"` key:
920/// `{"rows": <values>}`. The parity replay (Plan 06) only exercises
921/// `execute_code` with an INVALID token (asserts `failure`), so this success
922/// shape is not asserted by `generated.yaml`; mirroring production keeps the
923/// executor correct for any future success-path scenario and for the direct
924/// unit assertions in this crate.
925pub struct SqlCodeExecutor {
926    connector: Arc<dyn SqlConnector>,
927    /// The re-validation pipeline, built ONCE at construction (85-10 IN-01).
928    ///
929    /// Previously [`SqlCodeExecutor::revalidate`] rebuilt the pipeline AND
930    /// re-resolved the `token_secret` env var on EVERY `execute` call. Caching
931    /// it here means the secret is resolved a single time (at construction /
932    /// builder time) — a removed/rotated env var after startup no longer breaks
933    /// in-flight requests, and a bad secret still fails fast at builder time.
934    pipeline: Arc<ValidationPipeline>,
935}
936
937impl SqlCodeExecutor {
938    /// Construct an executor over `connector`, enforcing the `[code_mode]`
939    /// policy carried by `config` on every [`SqlCodeExecutor::execute`] call.
940    ///
941    /// The [`ValidationPipeline`] is built ONCE here (85-10 IN-01) via
942    /// [`validation_pipeline_from_config`], so the `token_secret` env var is
943    /// resolved a single time at construction rather than on every request.
944    ///
945    /// # Errors
946    ///
947    /// Returns every error from [`validation_pipeline_from_config`] — most
948    /// notably the R9 inline-secret rejection and the secret-resolution /
949    /// 16-byte-minimum failures — so a misconfigured `token_secret` fails at
950    /// builder time, not first request.
951    pub fn new(connector: Arc<dyn SqlConnector>, config: ServerConfig) -> Result<Self> {
952        let pipeline = Arc::new(validation_pipeline_from_config(&config)?);
953        Ok(Self {
954            connector,
955            pipeline,
956        })
957    }
958
959    /// Defense-in-depth re-validation of `code` against the `[code_mode]`
960    /// policy (threat T-85-02-01). Returns `Err` BEFORE any connector call when
961    /// the statement violates the static policy (e.g. a DELETE under
962    /// `allow_deletes = false`) or fails to parse.
963    ///
964    /// Reuses the cached [`SqlCodeExecutor::pipeline`] (85-10 IN-01) — it does
965    /// NOT rebuild the pipeline or re-read the `token_secret` env var per call.
966    fn revalidate(&self, code: &str) -> std::result::Result<(), ExecutionError> {
967        let ctx = ValidationContext::new(
968            "code-mode-executor",
969            "code-mode-session",
970            "schema-hash",
971            "perms-hash",
972        );
973        let result = self
974            .pipeline
975            .validate_sql_query(code, &ctx)
976            .map_err(|e| ExecutionError::BackendError(format!("SQL validation failed: {e}")))?;
977        if !result.is_valid {
978            return Err(ExecutionError::BackendError(
979                "SQL rejected by [code_mode] policy on re-validation".to_string(),
980            ));
981        }
982        Ok(())
983    }
984}
985
986/// Convert the `execute_code` `variables` input (a JSON object of name→value)
987/// into the `(name, value)` pairs [`SqlConnector::execute`] binds (85-10
988/// WR-02). A leading `:` on a key is stripped so callers may send either
989/// `{":name": ...}` or `{"name": ...}` — the connector's
990/// `translate_placeholders` keys params WITHOUT the `:` (matching
991/// [`extract_named_params`](crate::tools)). `None` or a non-object value yields
992/// an empty slice, so the parity `execute_code` scenario (passes `None`) is
993/// unaffected.
994fn variables_to_params(variables: Option<&serde_json::Value>) -> Vec<(String, serde_json::Value)> {
995    let Some(serde_json::Value::Object(map)) = variables else {
996        return Vec::new();
997    };
998    map.iter()
999        .map(|(k, v)| {
1000            let key = k.strip_prefix(':').unwrap_or(k).to_string();
1001            (key, v.clone())
1002        })
1003        .collect()
1004}
1005
1006#[pmcp_code_mode::async_trait]
1007impl CodeExecutor for SqlCodeExecutor {
1008    /// Re-validate the SQL against the `[code_mode]` policy, then execute it via
1009    /// the single-method [`SqlConnector::execute`].
1010    ///
1011    /// # Errors
1012    ///
1013    /// Returns [`ExecutionError::BackendError`] when re-validation rejects the
1014    /// statement (policy violation or parse failure) or when the connector
1015    /// surfaces a [`crate::sql::ConnectorError`]. Connector error messages are
1016    /// surfaced verbatim from the toolkit's already-sanitized
1017    /// `ConnectorError` Display (T-84-01-01 / threat T-85-02-04) — no raw
1018    /// backend credentials are echoed.
1019    async fn execute(
1020        &self,
1021        code: &str,
1022        variables: Option<&serde_json::Value>,
1023    ) -> std::result::Result<serde_json::Value, ExecutionError> {
1024        // (1) Defense-in-depth re-validation BEFORE the connector is reached.
1025        self.revalidate(code)?;
1026        // (2) Honor the schema-advertised `variables` input by BINDING it as
1027        //     named params (85-10 WR-02 / threat T-85-10-01) — never a silent
1028        //     drop. A `None` / absent map yields `&[]`, so the parity scenario
1029        //     (passes None) is unaffected. Binding (not string interpolation)
1030        //     preserves parameterized-query safety.
1031        let params = variables_to_params(variables);
1032        let rows =
1033            self.connector.execute(code, &params).await.map_err(|e| {
1034                ExecutionError::BackendError(format!("connector execute failed: {e}"))
1035            })?;
1036        // (3) Mirror production's observable `"rows"` key (REVIEW FIX #6b).
1037        Ok(serde_json::json!({ "rows": rows }))
1038    }
1039}
1040
1041// =============================================================================
1042// OAPI-05 — HttpCodeExecutor (Plan 90-04 Task 1 / H1 / H2)
1043// =============================================================================
1044
1045/// Low-level HTTP executor bridging the toolkit's outbound
1046/// [`HttpAuthProvider`](crate::http::auth::HttpAuthProvider) to pmcp-code-mode's
1047/// [`HttpExecutor`](pmcp_code_mode::HttpExecutor) trait.
1048///
1049/// This is the OpenAPI analog of [`SqlCodeExecutor`], but at a DIFFERENT layer:
1050/// it impls the LOW-LEVEL `pmcp_code_mode::HttpExecutor`
1051/// (`execute_request(method, path, body)`), NOT the high-level
1052/// [`CodeExecutor`]. It is wrapped by a
1053/// [`JsCodeExecutor`](pmcp_code_mode::JsCodeExecutor) for the Code Mode path
1054/// (the `JsCodeExecutor<HttpCodeExecutor>: CodeExecutor` blanket impl) and is
1055/// called directly by script tools (Plan 05). The single-call synthesizer
1056/// (Plan 03) does NOT use this path — it calls `HttpConnector::execute`
1057/// directly.
1058///
1059/// # Per-request passthrough token (H1)
1060///
1061/// The `inbound_token` field carries the per-request MCP client token captured
1062/// by the binary (Plan 06) into [`AuthContext`]. It is passed to
1063/// [`HttpAuthProvider::apply`](crate::http::auth::HttpAuthProvider::apply) so an
1064/// [`OAuthPassthroughAuth`](crate::http::auth::OAuthPassthroughAuth) provider
1065/// forwards it to the backend; static providers ignore it (proven in Plan 01).
1066/// Because Code Mode reuses ONE executor instance across requests, the binary
1067/// produces a per-request clone carrying the captured token via
1068/// [`HttpCodeExecutor::with_inbound_token`].
1069///
1070/// # Redaction (Pitfall 5 / T-90-04-01)
1071///
1072/// Auth/transport failures are mapped to
1073/// [`ExecutionError::RuntimeError`](pmcp_code_mode::ExecutionError::RuntimeError)
1074/// whose message names the operation / status only — it NEVER echoes the
1075/// request URL or the `Authorization` token.
1076///
1077/// # Feature gate (H2)
1078///
1079/// Gated under `openapi-code-mode` (the Plan 90-01 umbrella that forwards
1080/// `pmcp-code-mode/js-runtime`). The bare `code-mode` feature does NOT bring
1081/// `HttpExecutor` into scope, so this type cannot be gated on
1082/// `all(feature = "http", feature = "code-mode")`.
1083#[cfg(feature = "openapi-code-mode")]
1084#[derive(Clone)]
1085pub struct HttpCodeExecutor {
1086    client: reqwest::Client,
1087    base_url: String,
1088    auth: Arc<dyn crate::http::auth::HttpAuthProvider>,
1089    /// Per-request captured MCP client token for `oauth_passthrough` (H1).
1090    /// `None` for the static-auth path; set per request via
1091    /// [`HttpCodeExecutor::with_inbound_token`].
1092    inbound_token: Option<String>,
1093    /// The operator's parsed OpenAPI document, when one was supplied
1094    /// (Phase 128 D4(b)).
1095    ///
1096    /// `None` on every executor built by [`HttpCodeExecutor::new`], which is what
1097    /// keeps that constructor's signature unchanged. An `Arc` rather than an owned
1098    /// document because the same parse is also served verbatim as the `api_schema`
1099    /// resource, and the spec can be large — one allocation is shared, never
1100    /// cloned. Read ONLY by
1101    /// [`placeholder_rules`](pmcp_code_mode::HttpExecutor::placeholder_rules).
1102    schema: Option<Arc<crate::http::OpenApiSchema>>,
1103    /// The E1 outbound-request policy, when one was registered (Phase 128).
1104    ///
1105    /// `None` on every executor built by [`HttpCodeExecutor::new`], which keeps
1106    /// that constructor's signature unchanged and keeps the no-policy request
1107    /// path allocation-free.
1108    policy: Option<Arc<dyn crate::policy::RequestPolicy>>,
1109    /// The MCP tool this executor serves, for [`crate::policy::OutboundRequest`]'s
1110    /// `tool` field (Phase 128 E1).
1111    ///
1112    /// `HttpExecutor::execute_request` is a `pmcp-code-mode` trait method and
1113    /// carries no tool name, so the label is attached where a PER-TOOL executor is
1114    /// minted: a script tool's own `[[tools]]` `name` at synthesis, and
1115    /// `execute_code` for the generic Code Mode tool. `Arc<str>` because the
1116    /// executor is cloned per request.
1117    tool_label: Option<Arc<str>>,
1118    /// The per-`tools/call` id stamped onto every policy request this executor
1119    /// makes. `None` on a base executor, set by [`request_executor_from_extra`].
1120    call_id: Option<Arc<str>>,
1121}
1122
1123#[cfg(feature = "openapi-code-mode")]
1124impl HttpCodeExecutor {
1125    /// Construct an executor over `client` + `base_url`, authenticating outgoing
1126    /// requests via `auth`. The per-request `inbound_token` starts `None`;
1127    /// the binary attaches it per request with
1128    /// [`HttpCodeExecutor::with_inbound_token`].
1129    #[must_use]
1130    pub fn new(
1131        client: reqwest::Client,
1132        base_url: String,
1133        auth: Arc<dyn crate::http::auth::HttpAuthProvider>,
1134    ) -> Self {
1135        Self {
1136            client,
1137            base_url,
1138            auth,
1139            inbound_token: None,
1140            schema: None,
1141            policy: None,
1142            tool_label: None,
1143            call_id: None,
1144        }
1145    }
1146
1147    /// Label this executor with the MCP tool it serves, so an E1 policy is told
1148    /// which `tools/call` an outbound request came from (Phase 128).
1149    ///
1150    /// Attach it where a PER-TOOL executor is minted — `ScriptToolHandler::new`
1151    /// for a script tool, `code_mode_http_tools_from_executor` for `execute_code`.
1152    /// On the Code Mode surface one `tools/call` may issue many outbound requests
1153    /// and they all carry this same label.
1154    #[must_use]
1155    pub fn with_tool_label(mut self, tool: impl AsRef<str>) -> Self {
1156        self.tool_label = Some(Arc::from(tool.as_ref()));
1157        self
1158    }
1159
1160    /// The MCP tool this executor serves, or `""` when it carries no label (a
1161    /// caller driving the executor directly, with no tool to name).
1162    #[must_use]
1163    pub fn tool_label(&self) -> &str {
1164        self.tool_label.as_deref().unwrap_or("")
1165    }
1166
1167    /// Stamp the per-`tools/call` id onto every policy request this executor makes,
1168    /// see [`crate::policy::OutboundRequest::call_id`].
1169    ///
1170    /// [`request_executor_from_extra`] calls this once per `tools/call`, so an
1171    /// embedder normally never does. It is public for an embedder that derives its
1172    /// own per-call executor and wants its requests grouped the same way.
1173    #[must_use]
1174    pub fn with_call_id(mut self, call_id: impl AsRef<str>) -> Self {
1175        self.call_id = Some(Arc::from(call_id.as_ref()));
1176        self
1177    }
1178
1179    /// The id of the `tools/call` this executor serves, or `""` on a base executor
1180    /// that no call has derived from (nothing to group by).
1181    #[must_use]
1182    pub fn call_id(&self) -> &str {
1183        self.call_id.as_deref().unwrap_or("")
1184    }
1185
1186    /// Steps (2) and (2a) of a request: the base-URL join and the non-auth half of
1187    /// the remaining-body-to-query conversion.
1188    ///
1189    /// Shared by [`Self::execute_request`] and [`Self::preview_request`] on purpose:
1190    /// a preview that assembled the request by its own code would be a second copy
1191    /// of the rules, and a policy that passes the preview but refuses the send (or
1192    /// the reverse) is exactly the drift this one function prevents.
1193    ///
1194    /// `join_url` preserves an API-Gateway stage prefix (Pitfall 2; it is NOT the
1195    /// RFC-3986 path-replacing join). The query pairs are appended by the caller
1196    /// through `url::Url` because reqwest 0.13 gates `RequestBuilder::query` behind
1197    /// a `query` feature the toolkit deliberately does not enable.
1198    ///
1199    /// The conversion runs BEFORE the E1 hook: a policy documented to inspect the
1200    /// query pairs would otherwise inspect an EMPTY slice while the pairs about to
1201    /// be sent still sat in the body, a security hook that is present, documented
1202    /// and blind. D-12 is preserved: only the AUTH-supplied additions stay behind
1203    /// the hook.
1204    fn prepare_request(
1205        &self,
1206        is_get_like: bool,
1207        resolved_path: &str,
1208        remaining_body: Option<serde_json::Value>,
1209    ) -> std::result::Result<
1210        (String, Vec<(String, String)>, Option<serde_json::Value>),
1211        ExecutionError,
1212    > {
1213        let url = crate::http::join_url(&self.base_url, resolved_path);
1214        let mut query_params: Vec<(String, String)> = Vec::new();
1215        let request_body = if is_get_like {
1216            if let Some(serde_json::Value::Object(obj)) = &remaining_body {
1217                for (key, value) in obj {
1218                    // A non-scalar GET-query value is rejected (WR-03) rather than
1219                    // silently JSON-stringified into the URL.
1220                    query_params.push((key.clone(), Self::scalar_str(key, value)?));
1221                }
1222            }
1223            None
1224        } else {
1225            remaining_body
1226        };
1227        Ok((url, query_params, request_body))
1228    }
1229
1230    /// Ask the registered E1 policy about one fully literal call, WITHOUT sending
1231    /// it: the validation-time preview behind `validate_code`.
1232    ///
1233    /// Applies the same layer-0 path floor the executor applies, assembles the
1234    /// request through [`Self::prepare_request`], and consults the policy with
1235    /// [`RequestPhase::Validate`](crate::policy::RequestPhase::Validate). `Ok(())`
1236    /// when no policy is registered.
1237    pub(crate) async fn preview_request(
1238        &self,
1239        method: &str,
1240        path: &str,
1241        body: Option<serde_json::Value>,
1242        call_id: &str,
1243    ) -> std::result::Result<(), ExecutionError> {
1244        let Some(policy) = self.policy.as_ref() else {
1245            return Ok(());
1246        };
1247        pmcp_code_mode::validate_resolved_target(path).map_err(|e| {
1248            ExecutionError::RequestRefused {
1249                message: format!("{e}"),
1250            }
1251        })?;
1252        let upper = method.to_uppercase();
1253        let is_get_like = matches!(upper.as_str(), "GET" | "HEAD" | "OPTIONS");
1254        let (url, query, body) = self.prepare_request(is_get_like, path, body)?;
1255        let req = crate::policy::OutboundRequest::new(
1256            self.tool_label(),
1257            &upper,
1258            &url,
1259            &query,
1260            body.as_ref(),
1261        )
1262        .with_call_id(call_id)
1263        .with_phase(crate::policy::RequestPhase::Validate);
1264        policy
1265            .check(&req)
1266            .await
1267            .map_err(|refusal| ExecutionError::RequestRefused {
1268                message: format!("outbound request refused by policy: {refusal}"),
1269            })
1270    }
1271
1272    /// Consult the registered E1 policy, if any, for one already-assembled
1273    /// outbound request (Phase 128).
1274    ///
1275    /// The mirror of `http::HttpClient`'s helper of the same name. Its own
1276    /// function so `execute_request` keeps ONE added statement and stays under
1277    /// the cognitive-complexity 25 gate, and returns immediately when no policy
1278    /// is registered.
1279    async fn run_request_policy(
1280        &self,
1281        method: &str,
1282        path: &str,
1283        query: &[(String, String)],
1284        body: Option<&serde_json::Value>,
1285    ) -> std::result::Result<(), ExecutionError> {
1286        let Some(policy) = self.policy.as_ref() else {
1287            return Ok(());
1288        };
1289        let req = crate::policy::OutboundRequest::new(self.tool_label(), method, path, query, body)
1290            .with_call_id(self.call_id());
1291        policy
1292            .check(&req)
1293            .await
1294            .map_err(|refusal| ExecutionError::RequestRefused {
1295                message: format!("outbound request refused by policy: {refusal}"),
1296            })
1297    }
1298
1299    /// Attach the E1 [`crate::policy::RequestPolicy`] consulted before every
1300    /// outbound request this executor makes (Phase 128).
1301    ///
1302    /// Cheap clone-with-builder, the same shape as
1303    /// [`with_inbound_token`](Self::with_inbound_token).
1304    ///
1305    /// # Call it BEFORE the executor fans out
1306    ///
1307    /// Both HTTP surfaces run on ONE executor (D-02) — script tools take a clone
1308    /// and Code Mode takes the original — so a clone taken before this builder
1309    /// runs is permanently ungoverned. The same constraint
1310    /// [`with_schema`](Self::with_schema) documents, for the same reason.
1311    #[must_use]
1312    pub fn with_request_policy(mut self, policy: Arc<dyn crate::policy::RequestPolicy>) -> Self {
1313        self.policy = Some(policy);
1314        self
1315    }
1316
1317    /// Whether this executor consults an E1 policy before sending.
1318    ///
1319    /// Public for the same reason [`has_schema`](Self::has_schema) is: the wiring
1320    /// lives in a different crate, so a registered-but-unreached policy must be
1321    /// observable from outside rather than only from a `#[cfg(test)]` accessor.
1322    #[must_use]
1323    pub fn has_request_policy(&self) -> bool {
1324        self.policy.is_some()
1325    }
1326
1327    /// Attach the operator's parsed OpenAPI document, so a path placeholder can be
1328    /// narrowed by what the spec DECLARES for it (Phase 128 D4(b)).
1329    ///
1330    /// Cheap clone-with-builder, the same shape as
1331    /// [`with_inbound_token`](Self::with_inbound_token): the `Arc` is shared with
1332    /// the `api_schema` resource rather than the document being duplicated.
1333    ///
1334    /// # Call it BEFORE the executor fans out
1335    ///
1336    /// Both HTTP surfaces run on ONE executor (D-02) — script tools take a clone
1337    /// and Code Mode takes the original. A clone taken before this builder runs is
1338    /// permanently unnarrowed, so the call has to precede both fan-out sites. The
1339    /// production wiring is `pmcp-openapi-server`'s `build_server`, the only place
1340    /// the executor and the parsed spec are both in scope.
1341    #[must_use]
1342    pub fn with_schema(mut self, schema: Arc<crate::http::OpenApiSchema>) -> Self {
1343        warn_if_narrowing_unavailable();
1344        self.schema = Some(schema);
1345        self
1346    }
1347
1348    /// Cheap clone-with-token builder (H1): the binary calls this PER REQUEST to
1349    /// attach the captured inbound MCP token so an `oauth_passthrough` provider
1350    /// forwards it. Static providers ignore the token, so calling this on a
1351    /// static-auth executor is harmless.
1352    ///
1353    /// Single-call tools (Plan 03) don't use this path; the per-request token
1354    /// flows through Code Mode + script tools only.
1355    #[must_use]
1356    pub fn with_inbound_token(mut self, token: Option<String>) -> Self {
1357        self.inbound_token = token;
1358        self
1359    }
1360
1361    /// Whether this executor carries an OpenAPI document, and therefore whether a
1362    /// path placeholder can be narrowed by a spec DECLARATION (Phase 128 D4(b)).
1363    ///
1364    /// `false` never means "unchecked": a spec-less executor still applies the
1365    /// unconditional character floor and the always-on length cap to every
1366    /// placeholder value. It means only that no ADDITIONAL declared narrowing is
1367    /// available.
1368    ///
1369    /// Public, and deliberately so. T-128-36c is the risk that `with_schema` gets
1370    /// wired to a `#[cfg(test)]` helper — or applied after the executor has already
1371    /// fanned out — leaving the production binary unnarrowed while every test
1372    /// passes. The wiring lives in a DIFFERENT crate (`pmcp-openapi-server`'s
1373    /// `build_server`), so a `#[cfg(test)]` accessor could not prove it from there.
1374    /// This is a read-only boolean over a private field; it exposes nothing about
1375    /// the document.
1376    #[must_use]
1377    pub fn has_schema(&self) -> bool {
1378        self.schema.is_some()
1379    }
1380
1381    /// Test-only accessor for the per-request captured token, so unit tests can
1382    /// assert [`request_executor_from_extra`] threads the inbound token (the
1383    /// field is otherwise private — Plan 90-10).
1384    #[cfg(test)]
1385    pub(crate) fn inbound_token_for_test(&self) -> Option<&str> {
1386        self.inbound_token.as_deref()
1387    }
1388
1389    /// Render a JSON scalar for GET-query substitution (strings unquoted),
1390    /// REJECTING non-scalar values (WR-03 / GAP 4).
1391    ///
1392    /// This is the `code_mode` counterpart of [`crate::http::client`]'s
1393    /// `render_scalar`; both HTTP surfaces apply the SAME decided rule. Because
1394    /// the `Parameter` model carries no OpenAPI `style`/`explode`/`type` hint,
1395    /// the rule is uniform: a scalar (`String`, `Number`, `Bool`, `Null`)
1396    /// renders to a bare string (`Null` → `"null"`, preserving prior behavior);
1397    /// an `Object` or `Array` in a GET-query field is rejected rather than
1398    /// silently JSON-stringified into the URL.
1399    ///
1400    /// # Scope after Phase 128 D-09
1401    ///
1402    /// This is now reached ONLY from step (4) — the remaining-body-as-query-params
1403    /// step. The `{path}` substitution half moved up to
1404    /// `pmcp_code_mode::PlanExecutor`, which applies the identical rule
1405    /// (`render_path_scalar`) and then additionally floors the rendered value
1406    /// through `validate_path_placeholder`. The sibling `resolve_path` helper that
1407    /// used to live here was deleted with step (1) rather than left as a
1408    /// caller-less function.
1409    ///
1410    /// # Errors
1411    ///
1412    /// Returns [`ExecutionError::RequestRefused`] naming `key` when `value` is a
1413    /// non-scalar: a script that sends an object as a path value is the script's to
1414    /// fix. Per Pitfall 5 the message names the KEY only — never the value.
1415    fn scalar_str(
1416        key: &str,
1417        value: &serde_json::Value,
1418    ) -> std::result::Result<String, ExecutionError> {
1419        match value {
1420            serde_json::Value::String(s) => Ok(s.clone()),
1421            serde_json::Value::Null => Ok("null".to_string()),
1422            serde_json::Value::Number(n) => Ok(n.to_string()),
1423            serde_json::Value::Bool(b) => Ok(b.to_string()),
1424            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1425                Err(ExecutionError::RequestRefused {
1426                    message: format!("path/query param '{key}' must be a scalar"),
1427                })
1428            },
1429        }
1430    }
1431}
1432
1433/// The spec-declared narrowing for ONE layer-2 `{param}` value (Phase 128 D4(b)).
1434///
1435/// A free helper, not an inline block, for two reasons: it keeps the trait method
1436/// trivially under the cog-25 gate (SP-4), and it lets the `input-validation`-off
1437/// build be a SIBLING FUNCTION with its own doc rather than a `cfg` arm buried in
1438/// the impl.
1439///
1440/// The lookup is an O(1) index hit. [`OpenApiSchema`](crate::http::OpenApiSchema)
1441/// indexes operations by `(path, METHOD)`, which is why `method` is part of the
1442/// trait signature: `GET /things/{id}` and `DELETE /things/{id}` are two
1443/// operations that may declare different constraints for the same `id`, and a
1444/// `(path_template, param)` signature could only scan linearly or narrow from the
1445/// wrong operation.
1446///
1447/// Only PATH-position parameters are consulted. A query-position namesake
1448/// describes a different part of the request and must not narrow a path
1449/// placeholder.
1450///
1451/// # What a MISS costs
1452///
1453/// No schema, no matching operation, or no matching PATH parameter returns
1454/// [`PlaceholderRules::default()`](pmcp_code_mode::PlaceholderRules) — which
1455/// RETAINS the unconditional character floor and the always-on
1456/// 256-code-point cap, and LOSES the spec's additional narrowing. That is a real
1457/// reduction, not a no-op: a Code Mode script writing `/users/{alias}` against a
1458/// spec that declares `/users/{id}` reaches the SAME endpoint while the declared
1459/// `pattern` silently disappears, because the lookup is by exact template text.
1460///
1461/// Three things bound that, and they are all the bound there is:
1462///
1463/// 1. the sentence above, so the cost is stated rather than described as harmless;
1464/// 2. [`log_spec_lookup_miss`], a `tracing::debug!` fired once per
1465///    `(method, template)` pair, naming the method and the template and never a
1466///    value — so a drifted template produces a signal instead of silence;
1467/// 3. `ServerConfig::lint_against_spec`, which refuses the CONFIGURED case before
1468///    deploy. Its bound, stated: it covers a template written in the config. A
1469///    template a Code Mode script COMPOSES at runtime is not visible at config
1470///    time, which is why (2) exists as well.
1471///
1472/// Template canonicalization is deliberately NOT attempted. Normalizing `{alias}`
1473/// to `{id}` requires knowing the two denote the same parameter, which only the
1474/// spec's own path can establish — so a canonicalizer either re-derives the exact
1475/// match it was meant to replace, or guesses, and a wrong guess narrows from
1476/// ANOTHER parameter's declared rules. That can refuse a legitimate value under a
1477/// rule the caller's endpoint does not carry, which is strictly worse than not
1478/// narrowing.
1479///
1480/// This function builds [`PlaceholderRules`](pmcp_code_mode::PlaceholderRules) and
1481/// nothing else. It evaluates no pattern of its own: there is exactly one regex
1482/// path in this phase and it lives in core, which is what makes a placeholder
1483/// `pattern` and an `inputSchema` `pattern` resolve the whitespace shorthand
1484/// identically.
1485#[cfg(all(feature = "openapi-code-mode", feature = "input-validation"))]
1486fn spec_placeholder_rules<'a>(
1487    schema: Option<&'a crate::http::OpenApiSchema>,
1488    method: &str,
1489    path_template: &str,
1490    param: &str,
1491) -> pmcp_code_mode::PlaceholderRules<'a> {
1492    let default = pmcp_code_mode::PlaceholderRules::default();
1493    let Some(schema) = schema else {
1494        return default;
1495    };
1496    let Some(operation) = schema.operation_for(path_template, method) else {
1497        log_spec_lookup_miss(method, path_template);
1498        return default;
1499    };
1500    operation
1501        .path_parameters()
1502        .into_iter()
1503        .find(|p| p.name == param)
1504        .map_or(default, |p| p.placeholder_rules())
1505}
1506
1507/// Report a `(method, path_template)` pair the spec does not carry, ONCE.
1508///
1509/// At `debug!` rather than `warn!` because a Code Mode script may legitimately
1510/// address a long-tail endpoint the operator's spec omits, so this is diagnostic
1511/// signal and not an error. It names only author-written text — the method and the
1512/// template — and never a placeholder value.
1513#[cfg(all(feature = "openapi-code-mode", feature = "input-validation"))]
1514fn log_spec_lookup_miss(method: &str, path_template: &str) {
1515    /// Bound on the distinct pairs remembered.
1516    ///
1517    /// A Code Mode script composes its template at RUNTIME, so an unbounded memo
1518    /// is an unbounded allocation driven by caller-influenced input. Past the
1519    /// bound the LOG goes quiet rather than the process growing: a server that has
1520    /// already produced this many distinct misses has a configuration problem the
1521    /// first entries already named. Enforcement is unaffected either way — the
1522    /// floor and the cap never depend on this memo.
1523    const MAX_REMEMBERED: usize = 64;
1524
1525    static SEEN: std::sync::OnceLock<
1526        std::sync::Mutex<std::collections::HashSet<(String, String)>>,
1527    > = std::sync::OnceLock::new();
1528
1529    let Ok(mut seen) = SEEN
1530        .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
1531        .lock()
1532    else {
1533        return;
1534    };
1535    if seen.len() >= MAX_REMEMBERED || !seen.insert((method.to_string(), path_template.to_string()))
1536    {
1537        return;
1538    }
1539    drop(seen);
1540    tracing::debug!(
1541        target: "pmcp_server_toolkit::code_mode",
1542        method = method,
1543        path_template = path_template,
1544        "no OpenAPI operation matches this (method, path template) pair: every placeholder \
1545         value still faces the unconditional character floor and the always-on length cap, \
1546         and the spec's ADDITIONAL narrowing is NOT applied. A template written in the \
1547         config is reported before deploy by ServerConfig::lint_against_spec; a template a \
1548         Code Mode script composes at runtime can only be reported here."
1549    );
1550}
1551
1552/// The `input-validation`-off half of the spec narrowing.
1553///
1554/// `Parameter::placeholder_rules` — the accessor that names the core
1555/// `PlaceholderRules` type — is gated on the toolkit's `input-validation` feature,
1556/// so on a build without it there is no declared-rules accessor to read and the
1557/// spec contributes no narrowing.
1558///
1559/// **This is not the floor being switched off.** The floor and the cap run inside
1560/// `pmcp_code_mode::PlanExecutor`, which depends on `pmcp/schema-validation`
1561/// unconditionally; they are not behind this feature and this feature cannot turn
1562/// them off. What IS off is the spec's additional narrowing — and
1563/// [`warn_if_narrowing_unavailable`] says so once, at the moment an operator
1564/// supplies a spec and would otherwise believe it was being enforced.
1565#[cfg(all(feature = "openapi-code-mode", not(feature = "input-validation")))]
1566fn spec_placeholder_rules<'a>(
1567    schema: Option<&'a crate::http::OpenApiSchema>,
1568    method: &str,
1569    path_template: &str,
1570    param: &str,
1571) -> pmcp_code_mode::PlaceholderRules<'a> {
1572    let _ = (schema, method, path_template, param);
1573    pmcp_code_mode::PlaceholderRules::default()
1574}
1575
1576/// No-op on a build that HAS `input-validation`: the narrowing is available, so
1577/// there is no opt-out to report. The sibling below is the half that speaks.
1578#[cfg(all(feature = "openapi-code-mode", feature = "input-validation"))]
1579fn warn_if_narrowing_unavailable() {}
1580
1581/// Report, ONCE, that a supplied spec cannot narrow on this build.
1582///
1583/// An enforcement that is off must never read as on. An operator who passes
1584/// `--spec` has asked for the spec's declarations to be applied; on a build
1585/// without `input-validation` they are not, and this is the only moment at which
1586/// that intent is observable.
1587#[cfg(all(feature = "openapi-code-mode", not(feature = "input-validation")))]
1588fn warn_if_narrowing_unavailable() {
1589    static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
1590    if WARNED.set(()).is_ok() {
1591        tracing::warn!(
1592            target: "pmcp_server_toolkit::code_mode",
1593            "an OpenAPI spec was supplied but this build has the toolkit's \
1594             `input-validation` feature OFF, so a path placeholder gets the unconditional \
1595             character floor and the always-on length cap and NOT the spec's declared \
1596             pattern/maxLength narrowing. Rebuild with `input-validation` (it is in the \
1597             toolkit's default feature set) to apply the declarations."
1598        );
1599    }
1600}
1601
1602#[cfg(feature = "openapi-code-mode")]
1603#[pmcp_code_mode::async_trait]
1604impl pmcp_code_mode::HttpExecutor for HttpCodeExecutor {
1605    /// Narrow a layer-2 `{param}` value by what the carried OpenAPI document
1606    /// DECLARES for it (Phase 128 D4(b)).
1607    ///
1608    /// Delegates to the private `spec_placeholder_rules` helper in this module,
1609    /// whose rustdoc states exactly what a schema/operation/parameter MISS costs —
1610    /// the floor and the cap are retained, the spec's narrowing is lost — and what
1611    /// bounds that loss. Named in plain backticks rather than as an intra-doc link
1612    /// because this method is public and the helper is private, which rustdoc
1613    /// (correctly) warns about.
1614    fn placeholder_rules(
1615        &self,
1616        method: &str,
1617        path_template: &str,
1618        param: &str,
1619    ) -> pmcp_code_mode::PlaceholderRules<'_> {
1620        spec_placeholder_rules(self.schema.as_deref(), method, path_template, param)
1621    }
1622
1623    async fn execute_request(
1624        &self,
1625        method: &str,
1626        path: pmcp_code_mode::ResolvedPath<'_>,
1627        body: Option<serde_json::Value>,
1628    ) -> std::result::Result<serde_json::Value, ExecutionError> {
1629        let path = path.as_str();
1630        let upper = method.to_uppercase();
1631        let is_get_like = matches!(upper.as_str(), "GET" | "HEAD" | "OPTIONS");
1632
1633        // (1) REMOVED in Phase 128 (D-09). Placeholder resolution used to happen
1634        //     here, and that is exactly what made this executor — and every other
1635        //     `HttpExecutor` implementor, in this repo and out of it — blind BY
1636        //     CONSTRUCTION to what it was about to send: a decorator wrapping the
1637        //     public trait saw only the template the script wrote, never the
1638        //     substituted values, so a placeholder carrying a query separator
1639        //     became a different endpoint with nothing in a position to notice.
1640        //
1641        //     `pmcp_code_mode::PlanExecutor` now resolves BOTH layers and checks
1642        //     the composed result before dispatch, so `path` arrives as a
1643        //     `ResolvedPath` with every placeholder already substituted and
1644        //     checked, and `body` already has the path-consumed keys removed.
1645        //     Resolving again here would be a double-resolution bug.
1646        let resolved_path = path;
1647        let remaining_body = body;
1648
1649        // (2)+(2a) join_url + the non-auth remaining-body-to-query conversion. ONE
1650        //      helper shared with `preview_request`, so what `validate_code` asks the
1651        //      policy about is assembled by the same code that assembles what is
1652        //      sent and the two cannot drift. See `prepare_request` for why the
1653        //      conversion sits above the E1 hook.
1654        let (url, mut query_params, request_body) =
1655            self.prepare_request(is_get_like, resolved_path, remaining_body)?;
1656
1657        // (2b) Phase 128 E1 / D-12 — the outbound-policy hook. AFTER `join_url` so
1658        //      the policy sees the URL as it will be sent, and BEFORE `auth.apply`
1659        //      so no credential exists yet in `headers` / `query`. A refusal returns
1660        //      before auth and before the send. The mirror of the curated surface's
1661        //      hook in `http/client.rs::execute_inner`.
1662        self.run_request_policy(&upper, &url, &query_params, request_body.as_ref())
1663            .await?;
1664
1665        // (3) Apply auth, threading the per-request inbound token (H1). Auth
1666        //     failures map to a RuntimeError WITHOUT echoing URL/token
1667        //     (Pitfall 5 / T-90-04-01).
1668        let mut headers = reqwest::header::HeaderMap::new();
1669        let mut auth_query: std::collections::HashMap<String, String> =
1670            std::collections::HashMap::new();
1671        self.auth
1672            .apply(&mut headers, &mut auth_query, self.inbound_token.as_deref())
1673            .await
1674            .map_err(|_| ExecutionError::RuntimeError {
1675                message: "authentication failed for outgoing request".to_string(),
1676            })?;
1677
1678        // (3a) The auth-supplied query additions — an API-key-in-query credential —
1679        //      join the pairs AFTER the hook, which is what keeps them invisible to
1680        //      the policy.
1681        query_params.extend(auth_query);
1682
1683        // Append query params via url::Url (reqwest 0.13's RequestBuilder::query
1684        // is behind the off-by-default `query` feature; Plan 01 Rule 1).
1685        let final_url = if query_params.is_empty() {
1686            url
1687        } else {
1688            let mut parsed = url::Url::parse(&url).map_err(|_| ExecutionError::RuntimeError {
1689                message: "could not construct the request URL".to_string(),
1690            })?;
1691            {
1692                let mut pairs = parsed.query_pairs_mut();
1693                for (k, v) in &query_params {
1694                    pairs.append_pair(k, v);
1695                }
1696            }
1697            parsed.to_string()
1698        };
1699
1700        let mut request = match upper.as_str() {
1701            "GET" => self.client.get(&final_url),
1702            "POST" => self.client.post(&final_url),
1703            "PUT" => self.client.put(&final_url),
1704            "DELETE" => self.client.delete(&final_url),
1705            "PATCH" => self.client.patch(&final_url),
1706            "HEAD" => self.client.head(&final_url),
1707            _ => {
1708                return Err(ExecutionError::RuntimeError {
1709                    message: "unsupported HTTP method".to_string(),
1710                })
1711            },
1712        };
1713        request = request.headers(headers);
1714        if let Some(b) = request_body {
1715            request = request.header("Content-Type", "application/json").json(&b);
1716        }
1717
1718        // (5) Send + read. Transport / status / parse errors NEVER echo the URL
1719        //     or token (Pitfall 5).
1720        let response = request
1721            .send()
1722            .await
1723            .map_err(|_| ExecutionError::RuntimeError {
1724                message: "outgoing HTTP request failed".to_string(),
1725            })?;
1726        let status = response.status();
1727        let text = response
1728            .text()
1729            .await
1730            .map_err(|_| ExecutionError::RuntimeError {
1731                message: "failed to read response body".to_string(),
1732            })?;
1733        if !status.is_success() {
1734            return Err(ExecutionError::RuntimeError {
1735                message: format!("backend returned HTTP status {}", status.as_u16()),
1736            });
1737        }
1738        if text.is_empty() {
1739            return Ok(serde_json::Value::Null);
1740        }
1741        serde_json::from_str(&text).map_err(|_| ExecutionError::RuntimeError {
1742            message: "failed to parse response body as JSON".to_string(),
1743        })
1744    }
1745}
1746
1747// =============================================================================
1748// Helpers (Pattern G — cog ≤25 each, kept small + explicit)
1749// =============================================================================
1750
1751/// Translate unprefixed toolkit [`CodeModeSection`] fields into pmcp-code-mode's
1752/// `sql_`-prefixed [`CodeModeConfig`].
1753///
1754/// Mapping is **explicit field-by-field** (PATTERNS §10 + D-13). Silent serde
1755/// aliasing would couple the toolkit's stable surface to pmcp-code-mode's
1756/// internal field names — undesirable. Fields on `CodeModeSection` without a
1757/// `CodeModeConfig` counterpart are noted in inline comments rather than
1758/// silently dropped (review R1 + threat T-83-06-04).
1759fn build_cm_config(section: &CodeModeSection) -> CodeModeConfig {
1760    let mut cfg = CodeModeConfig {
1761        enabled: section.enabled,
1762        // SQL policy bits — toolkit's unprefixed names → pmcp_code_mode's sql_-prefixed.
1763        sql_allow_writes: section.allow_writes,
1764        sql_allow_deletes: section.allow_deletes,
1765        sql_allow_ddl: section.allow_ddl,
1766        sql_blocked_tables: section.blocked_tables.iter().cloned().collect(),
1767        sql_blocked_columns: section.sensitive_columns.iter().cloned().collect(),
1768        ..CodeModeConfig::default()
1769    };
1770    if let Some(ref sid) = section.server_id {
1771        cfg.server_id = Some(sid.clone());
1772    }
1773    // Token TTL — both sides use seconds, but pmcp_code_mode uses i64 and the
1774    // toolkit uses Option<u64>. Saturate to i64::MAX rather than wrap.
1775    if let Some(ttl) = section.token_ttl_seconds {
1776        cfg.token_ttl_seconds = i64::try_from(ttl).unwrap_or(i64::MAX);
1777    }
1778    // Auto-approval — toolkit ships risk-level names as strings; the
1779    // pmcp_code_mode side wants RiskLevel enums. Best-effort parse; unrecognised
1780    // entries are silently skipped (operator typos surface as "nothing auto-
1781    // approved" rather than a parse error — by design, since the registry is
1782    // open-ended).
1783    map_auto_approve_levels(&section.auto_approve_levels, &mut cfg);
1784    // `max_limit` (toolkit) corresponds to `sql_max_rows` (pmcp_code_mode).
1785    if let Some(max) = section.max_limit {
1786        cfg.sql_max_rows = max;
1787    }
1788    // `require_limit` (toolkit) → `sql_require_limit` (pmcp_code_mode). Enforced
1789    // in check_sql_config_authorization: a read-only statement without a LIMIT
1790    // is rejected when this is set (closes VERIFICATION Gap 1 — previously this
1791    // field was parsed but discarded, so a low-row no-LIMIT SELECT was accepted
1792    // despite require_limit=true).
1793    cfg.sql_require_limit = section.require_limit;
1794    // [code_mode.limits] — pmcp_code_mode's CodeModeConfig has `max_depth` and
1795    // `max_field_count` (GraphQL-flavoured) but no direct counterparts for
1796    // `max_tables_per_query` / `max_join_depth` / `max_subquery_depth`. These
1797    // toolkit fields are exposed for forward compatibility with Phase 84's
1798    // SQL connector enforcement; they are NOT silently mapped here.
1799    if let Some(ref limits) = section.limits {
1800        let _gap_max_tables = limits.max_tables_per_query;
1801        let _gap_max_join = limits.max_join_depth;
1802        let _gap_max_subquery = limits.max_subquery_depth;
1803    }
1804    cfg
1805}
1806
1807/// Decompose auto-approve-level parsing to keep [`build_cm_config`] under
1808/// Pattern G's cog ≤25 budget.
1809fn map_auto_approve_levels(levels: &[String], cfg: &mut CodeModeConfig) {
1810    use pmcp_code_mode::RiskLevel;
1811    let mut out = Vec::with_capacity(levels.len());
1812    for level in levels {
1813        match level.to_ascii_lowercase().as_str() {
1814            "low" => out.push(RiskLevel::Low),
1815            "medium" => out.push(RiskLevel::Medium),
1816            "high" => out.push(RiskLevel::High),
1817            "critical" => out.push(RiskLevel::Critical),
1818            _ => {
1819                tracing::debug!(
1820                    target: "pmcp_server_toolkit::code_mode",
1821                    "[code_mode] auto_approve_levels: unrecognised level '{}' — skipping",
1822                    level
1823                );
1824            },
1825        }
1826    }
1827    if !out.is_empty() {
1828        cfg.auto_approve_levels = out;
1829    }
1830}
1831
1832/// Per review R9: `token_secret` is `env:`- or `${VAR}`-only by default. Inline
1833/// literals are REJECTED at config-validation time unless
1834/// `allow_inline_token_secret_for_dev` is set. Returns the resolved bytes
1835/// wrapped in the toolkit-owned [`SecretValue`] (per review R6).
1836///
1837/// Accepted forms:
1838/// - `token_secret = "env:VAR_NAME"` — reads `VAR_NAME` from the process env.
1839/// - `token_secret = "${VAR_NAME}"` — reads `VAR_NAME` from the process env
1840///   (the form every reference SQL-API config emits, Plan 85-01 Gap #3).
1841/// - `token_secret = "raw-string"` — REJECTED unless
1842///   `allow_inline_token_secret_for_dev = true`.
1843///
1844/// A missing/unset env var (either form) returns
1845/// [`ToolkitError::CodeMode`] — never a panic, never a fall-back to a weak or
1846/// empty secret (threat-model item T-85-01-01).
1847/// Read `var` from the process env for `token_secret`, treating a missing OR
1848/// set-but-empty/whitespace value as UNSET (85-10 secondary fix, threat
1849/// T-85-10-03).
1850///
1851/// `HmacTokenGenerator` enforces a 16-byte minimum downstream, but an empty
1852/// (or all-whitespace) env value should surface as a clear "set but empty"
1853/// configuration error at startup — never flow to the HMAC layer as a
1854/// degenerate secret. Both the `env:VAR` and `${VAR}` forms route through here.
1855fn resolve_secret_env_var(var: &str) -> Result<SecretValue> {
1856    let value = std::env::var(var)
1857        .map_err(|_| ToolkitError::CodeMode(format!("env var '{var}' not set for token_secret")))?;
1858    if value.trim().is_empty() {
1859        return Err(ToolkitError::CodeMode(format!(
1860            "env var '{var}' is set but empty for token_secret"
1861        )));
1862    }
1863    Ok(SecretValue::new(value.into_bytes()))
1864}
1865
1866fn resolve_token_secret(section: &CodeModeSection) -> Result<SecretValue> {
1867    let raw = section.token_secret.as_ref().ok_or_else(|| {
1868        ToolkitError::CodeMode(
1869            "[code_mode] token_secret is required when code-mode is enabled".to_string(),
1870        )
1871    })?;
1872    // Both reference forms (`env:VAR` and `${VAR}`) are parsed by the ONE
1873    // toolkit-wide grammar chokepoint (Phase 120 Plan 04 Task 2). This module
1874    // previously carried its own `expand_braced_var`; a second `${}` parser with
1875    // slightly different edge cases is a latent security bug, so the grammar is
1876    // now single-sourced and only the RESOLUTION policy stays local (error on
1877    // unset, never a fall-back to a weak or empty secret — T-85-01-01).
1878    //
1879    // A string that merely *contains* `${` (e.g. an Athena `output_location`
1880    // substring) is still NOT a reference — `parse_env_ref` requires the exact
1881    // `${...}` shape — so it falls through to the inline-secret handling below
1882    // and stays rejected unless the dev flag is set (R9 / REVIEW FIX #6).
1883    match crate::env_ref::parse_env_ref(raw) {
1884        // A MALFORMED reference: the empty `${}`, or a `${NAME}` whose NAME is
1885        // not portably settable (`${MY-SECRET}`, `${a.b}`), or a
1886        // multi-placeholder composition. The grammar maps all of them to the
1887        // empty name, and `resolve_secret_env_var("")` would report
1888        // `env var '' not set for token_secret` — a message that names neither
1889        // what the operator wrote nor what to do about it. Say the actual thing
1890        // instead, and point at the `env:` escape hatch, which keeps its
1891        // any-non-empty-remainder rule precisely for exotic names.
1892        Some("") => {
1893            return Err(ToolkitError::CodeMode(
1894                "[code_mode] token_secret is a malformed environment reference; a `${VAR}` \
1895                 reference must name exactly ONE variable matching [A-Za-z0-9_]+ and nothing \
1896                 else. For a name outside that set, use the `env:NAME` form, which accepts any \
1897                 non-empty name. (The value is not echoed here.)"
1898                    .to_string(),
1899            ))
1900        },
1901        Some(var) => return resolve_secret_env_var(var),
1902        None => {},
1903    }
1904    if section.allow_inline_token_secret_for_dev {
1905        tracing::warn!(
1906            target: "pmcp_server_toolkit::code_mode",
1907            "[code_mode] token_secret is inline AND allow_inline_token_secret_for_dev=true; \
1908             accepting under dev/test exception — NEVER set this flag in a committed \
1909             production config"
1910        );
1911        return Ok(SecretValue::new(raw.as_bytes().to_vec()));
1912    }
1913    Err(ToolkitError::Validation(
1914        ConfigValidationError::InlineSecretRejected,
1915    ))
1916}
1917
1918// =============================================================================
1919// TKIT-10 — assemble_code_mode_prompt (D-12 / review R2)
1920// =============================================================================
1921
1922/// TKIT-10: assemble the code-mode bootstrap prompt body from a connector's
1923/// [`SqlConnector::schema_text`] + curated `[[database.tables]]` descriptions.
1924///
1925/// Per Phase 83 review R2 (BOTH reviewers HIGH severity), this function calls
1926/// ONLY [`SqlConnector::schema_text`] — never `execute()`, which is deferred
1927/// to Phase 84. Dialect-aware placeholder GUIDANCE is included even though
1928/// `translate_placeholders` is deferred, because the LLM still benefits from
1929/// knowing the eventual binding shape.
1930///
1931/// # Output structure
1932///
1933/// ```text
1934/// # Code Mode — {dialect.name()}
1935///
1936/// {dialect.placeholder_guidance()}
1937///
1938/// ## Schema
1939///
1940/// {connector.schema_text()}
1941///
1942/// ## Curated Tables
1943///
1944/// - `table_a`: description A
1945/// - `table_b`: description B
1946/// ```
1947///
1948/// The "Curated Tables" section is omitted entirely when
1949/// `config.database.tables` is empty OR every entry has no `description`.
1950/// Entries with `description = None` are skipped individually.
1951///
1952/// # Errors
1953///
1954/// Returns [`ToolkitError::CodeMode`] if `connector.schema_text()` fails.
1955/// The toolkit does not retry; callers should ensure the connector is ready
1956/// before assembling.
1957///
1958/// # Example
1959///
1960/// ```no_run
1961/// use pmcp_server_toolkit::code_mode::assemble_code_mode_prompt;
1962/// use pmcp_server_toolkit::config::ServerConfig;
1963/// use pmcp_server_toolkit::sql::SqlConnector;
1964///
1965/// async fn assemble<C: SqlConnector>(connector: &C, config: &ServerConfig) {
1966///     let prompt = assemble_code_mode_prompt(connector, config).await.unwrap();
1967///     assert!(prompt.contains("# Code Mode"));
1968/// }
1969/// ```
1970pub async fn assemble_code_mode_prompt(
1971    connector: &(dyn SqlConnector + '_),
1972    config: &ServerConfig,
1973) -> Result<String> {
1974    let dialect = connector.dialect();
1975    let schema_text = connector
1976        .schema_text()
1977        .await
1978        .map_err(|e| ToolkitError::CodeMode(format!("schema_text failed: {e}")))?;
1979
1980    let curated = format_curated_tables(config);
1981
1982    let mut out = String::with_capacity(schema_text.len() + curated.len() + 256);
1983    out.push_str("# Code Mode — ");
1984    out.push_str(dialect.name());
1985    out.push_str("\n\n");
1986    out.push_str(dialect.placeholder_guidance());
1987    out.push_str("\n\n## Schema\n\n");
1988    out.push_str(&schema_text);
1989    if !curated.is_empty() {
1990        out.push_str("\n\n## Curated Tables\n\n");
1991        out.push_str(&curated);
1992    }
1993    out.push('\n');
1994    Ok(out)
1995}
1996
1997/// Alias for [`assemble_code_mode_prompt`] satisfying CONN-04's literal naming.
1998///
1999/// Identical behavior; both names are valid public surface. Per Phase 84 D-12 +
2000/// RESEARCH §"Open Questions" Q2 / Landmine #15 the recommendation is an
2001/// alias-next-to (no deprecation attribute on either name), matching the P83
2002/// dual-naming precedent (`register_code_mode_tools` vs
2003/// `code_mode_tools_from_executor`).
2004///
2005/// # Errors
2006///
2007/// Returns [`ToolkitError::CodeMode`] if `connector.schema_text()` fails —
2008/// surfaced verbatim from [`assemble_code_mode_prompt`].
2009///
2010/// # Example
2011///
2012/// ```no_run
2013/// use pmcp_server_toolkit::code_mode::build_code_mode_prompt;
2014/// use pmcp_server_toolkit::config::ServerConfig;
2015/// use pmcp_server_toolkit::sql::SqlConnector;
2016///
2017/// async fn assemble<C: SqlConnector>(connector: &C, config: &ServerConfig) {
2018///     let prompt = build_code_mode_prompt(connector, config).await.unwrap();
2019///     assert!(prompt.contains("# Code Mode"));
2020/// }
2021/// ```
2022pub async fn build_code_mode_prompt(
2023    connector: &(dyn SqlConnector + '_),
2024    config: &ServerConfig,
2025) -> Result<String> {
2026    assemble_code_mode_prompt(connector, config).await
2027}
2028
2029/// File-based counterpart to [`assemble_code_mode_prompt`] — assemble the
2030/// code-mode prompt body from a `--schema` file's text WITHOUT any live
2031/// connector introspection (Plan 85-02 Task 3 / D-04 / D-05).
2032///
2033/// This is a SYNC fn taking the [`Dialect`] + the already-loaded `schema_text`
2034/// directly, so it can NEVER trigger a [`SqlConnector::schema_text`] round-trip.
2035/// For lazy / network-backed non-SQLite connectors that matters: the
2036/// connector-based [`assemble_code_mode_prompt`] would hit the network at prompt
2037/// time (breaking SC-1), and it would surface the LIVE schema rather than the
2038/// admin-redacted `--schema` file. Routing the `--schema` file content through
2039/// THIS helper makes the file the single source of truth — what's in the file
2040/// is exactly what the client sees (the D-05 redaction guarantee).
2041///
2042/// # Output structure
2043///
2044/// Mirrors [`assemble_code_mode_prompt`] except the schema block is preceded by
2045/// a `# Database Schema` header (REVIEW FIX — Gemini LOW, folded here per D-05;
2046/// the header text is kept identical to the resource-surface
2047/// `merge_schema_resource` helper Plan 05 uses, so prompt + resource parity
2048/// holds):
2049///
2050/// ```text
2051/// # Code Mode — {dialect.name()}
2052///
2053/// {dialect.placeholder_guidance()}
2054///
2055/// ## Schema
2056///
2057/// # Database Schema
2058///
2059/// {schema_text}
2060///
2061/// ## Curated Tables
2062///
2063/// - `table_a`: description A
2064/// ```
2065///
2066/// An empty `schema_text` still produces a valid (non-panicking) prompt with
2067/// the `# Code Mode` header present.
2068#[must_use]
2069pub fn assemble_code_mode_prompt_with_schema(
2070    schema_text: &str,
2071    dialect: Dialect,
2072    config: &ServerConfig,
2073) -> String {
2074    const SCHEMA_HEADER: &str = "# Database Schema\n\n";
2075
2076    let curated = format_curated_tables(config);
2077
2078    let mut out = String::with_capacity(schema_text.len() + curated.len() + 256);
2079    out.push_str("# Code Mode — ");
2080    out.push_str(dialect.name());
2081    out.push_str("\n\n");
2082    out.push_str(dialect.placeholder_guidance());
2083    out.push_str("\n\n## Schema\n\n");
2084    out.push_str(SCHEMA_HEADER);
2085    out.push_str(schema_text);
2086    if !curated.is_empty() {
2087        out.push_str("\n\n## Curated Tables\n\n");
2088        out.push_str(&curated);
2089    }
2090    out.push('\n');
2091    out
2092}
2093
2094/// Format the `[[database.tables]]` curated descriptions as a Markdown list.
2095///
2096/// Entries with no `description` are skipped. Returns an empty string when no
2097/// described entries exist; callers use that as the signal to omit the whole
2098/// "Curated Tables" section (keeping the prompt body tight).
2099fn format_curated_tables(config: &ServerConfig) -> String {
2100    config
2101        .database
2102        .tables
2103        .iter()
2104        .filter_map(|t| {
2105            t.description
2106                .as_deref()
2107                .filter(|d| !d.is_empty())
2108                .map(|d| format!("- `{}`: {}", t.name, d))
2109        })
2110        .collect::<Vec<_>>()
2111        .join("\n")
2112}
2113
2114// =============================================================================
2115// Unit tests
2116// =============================================================================
2117
2118/// Process-global lock serializing every test that reads or mutates the shared
2119/// process environment via `std::env::{set_var, remove_var}`.
2120///
2121/// Those calls are process-global and not thread-safe, so under the default
2122/// multi-threaded test runner the env-touching tests in this file's `tests` and
2123/// `sql_code_executor_tests` modules otherwise interleave and corrupt each
2124/// other's variables (e.g. an executor build fails to read the `TEST_SECRET_VAR`
2125/// it just set). Acquire the guard around each synchronous env-op group; NEVER
2126/// hold it across an `.await` (the `std` `MutexGuard` is `!Send`, and tokio's
2127/// multi-thread runtime requires the test future to be `Send`).
2128#[cfg(test)]
2129mod test_env_guard {
2130    use std::sync::{Mutex, MutexGuard};
2131
2132    static ENV_LOCK: Mutex<()> = Mutex::new(());
2133
2134    /// Lock the process-env mutex, recovering from poisoning so a panicking
2135    /// test does not cascade-fail its siblings.
2136    pub(super) fn lock() -> MutexGuard<'static, ()> {
2137        ENV_LOCK
2138            .lock()
2139            .unwrap_or_else(|poisoned| poisoned.into_inner())
2140    }
2141}
2142
2143#[cfg(test)]
2144mod tests {
2145    use super::*;
2146    use crate::config::{CodeModeLimits, CodeModeSection};
2147
2148    /// Compile-only assertion that the headline re-exports resolve at the
2149    /// `code_mode::*` path (TKIT-06 + D-16 + R3).
2150    #[allow(dead_code)]
2151    const _RE_EXPORTS_COMPILE: fn() = || {
2152        let _: Option<Box<dyn CodeExecutor>> = None;
2153        let _: Option<Box<dyn PolicyEvaluator>> = None;
2154        let _: Option<ApprovalToken> = None;
2155        let _: Option<HmacTokenGenerator> = None;
2156        let _: Option<TokenSecret> = None;
2157        let _: Option<NoopPolicyEvaluator> = None;
2158        let _: Option<ValidationPipeline> = None;
2159        let _: Option<ValidationContext> = None;
2160        let _: Option<CodeModeConfig> = None;
2161        let _: Option<AuthorizationDecision> = None;
2162        let _hash = canonicalize_code;
2163        let _ctx = compute_context_hash;
2164        let _h = hash_code;
2165    };
2166
2167    /// Lightweight test fixture: a `CodeModeSection` with all required fields
2168    /// populated for env-style secret resolution.
2169    fn env_section(var: &str) -> CodeModeSection {
2170        CodeModeSection {
2171            enabled: true,
2172            server_id: Some("test-server".to_string()),
2173            allow_writes: false,
2174            allow_deletes: false,
2175            allow_ddl: false,
2176            require_limit: false,
2177            max_limit: Some(1000),
2178            blocked_tables: vec![],
2179            sensitive_columns: vec![],
2180            auto_approve_levels: vec!["low".to_string()],
2181            token_ttl_seconds: Some(300),
2182            token_secret: Some(format!("env:{var}")),
2183            allow_inline_token_secret_for_dev: false,
2184            limits: Some(CodeModeLimits {
2185                max_tables_per_query: Some(5),
2186                max_join_depth: Some(3),
2187                max_subquery_depth: Some(2),
2188            }),
2189            description_notice: None,
2190        }
2191    }
2192
2193    #[test]
2194    fn build_cm_config_maps_allow_writes() {
2195        let mut section = env_section("UNUSED");
2196        section.allow_writes = true;
2197        let cfg = build_cm_config(&section);
2198        assert!(
2199            cfg.sql_allow_writes,
2200            "unprefixed allow_writes=true must map to sql_allow_writes=true"
2201        );
2202        assert!(cfg.enabled);
2203        assert_eq!(cfg.server_id.as_deref(), Some("test-server"));
2204        // max_limit → sql_max_rows
2205        assert_eq!(cfg.sql_max_rows, 1000);
2206        // token_ttl_seconds → i64
2207        assert_eq!(cfg.token_ttl_seconds, 300);
2208    }
2209
2210    #[test]
2211    fn build_cm_config_maps_require_limit_true() {
2212        // VERIFICATION Gap 1: toolkit `require_limit` must flow to the enforced
2213        // pmcp-code-mode `sql_require_limit` (previously discarded).
2214        let mut section = env_section("UNUSED");
2215        section.require_limit = true;
2216        let cfg = build_cm_config(&section);
2217        assert!(
2218            cfg.sql_require_limit,
2219            "require_limit=true must map to sql_require_limit=true"
2220        );
2221    }
2222
2223    #[test]
2224    fn build_cm_config_maps_require_limit_false() {
2225        let mut section = env_section("UNUSED");
2226        section.require_limit = false;
2227        let cfg = build_cm_config(&section);
2228        assert!(
2229            !cfg.sql_require_limit,
2230            "require_limit=false must map to sql_require_limit=false"
2231        );
2232    }
2233
2234    #[test]
2235    fn build_cm_config_propagates_blocked_tables() {
2236        let mut section = env_section("UNUSED");
2237        section.blocked_tables = vec!["users".into(), "secrets".into()];
2238        section.sensitive_columns = vec!["users.password".into()];
2239        let cfg = build_cm_config(&section);
2240        assert!(cfg.sql_blocked_tables.contains("users"));
2241        assert!(cfg.sql_blocked_tables.contains("secrets"));
2242        assert!(cfg.sql_blocked_columns.contains("users.password"));
2243    }
2244
2245    #[test]
2246    fn resolve_token_secret_env_reference_succeeds() {
2247        let _env = super::test_env_guard::lock();
2248        const VAR: &str = "PMCP_TOOLKIT_CODE_MODE_TEST_RESOLVE_ENV";
2249        // Long enough to satisfy HmacTokenGenerator::MIN_SECRET_LEN (16 bytes).
2250        std::env::set_var(VAR, "a-test-secret-bytes-16-or-more");
2251        let section = env_section(VAR);
2252        let resolved = resolve_token_secret(&section).expect("env resolution must succeed");
2253        assert_eq!(resolved.expose_secret(), b"a-test-secret-bytes-16-or-more");
2254        std::env::remove_var(VAR);
2255    }
2256
2257    #[test]
2258    fn resolve_token_secret_inline_without_dev_flag_rejected() {
2259        // R9 — inline literal + flag absent → InlineSecretRejected.
2260        let mut section = env_section("UNUSED");
2261        section.token_secret = Some("raw-string-that-should-be-rejected".to_string());
2262        section.allow_inline_token_secret_for_dev = false;
2263        // SecretValue intentionally does not implement Debug (R5 invariant),
2264        // so we cannot use `expect_err` directly on Result<SecretValue, _>.
2265        match resolve_token_secret(&section) {
2266            Ok(_) => panic!("must reject inline literal"),
2267            Err(ToolkitError::Validation(ConfigValidationError::InlineSecretRejected)) => {},
2268            Err(other) => panic!("expected InlineSecretRejected, got {other:?}"),
2269        }
2270    }
2271
2272    #[test]
2273    fn resolve_token_secret_inline_with_dev_flag_accepted() {
2274        // R9 — inline literal + dev flag → accepted (with tracing::warn).
2275        let mut section = env_section("UNUSED");
2276        section.token_secret = Some("a-test-secret-bytes-16-or-more".to_string());
2277        section.allow_inline_token_secret_for_dev = true;
2278        let resolved = resolve_token_secret(&section).expect("dev flag must permit inline literal");
2279        assert_eq!(resolved.expose_secret(), b"a-test-secret-bytes-16-or-more");
2280    }
2281
2282    #[test]
2283    fn resolve_token_secret_empty_env_var_is_set_but_empty_error() {
2284        let _env = super::test_env_guard::lock();
2285        // 85-10 / T-85-10-03: a set-but-EMPTY env value must NOT flow to the
2286        // HMAC layer as a degenerate secret — it surfaces as a clear
2287        // "set but empty" CodeMode error (env: form).
2288        const VAR: &str = "PMCP_TOOLKIT_CODE_MODE_TEST_EMPTY_ENV";
2289        std::env::set_var(VAR, "");
2290        let section = env_section(VAR);
2291        let outcome = resolve_token_secret(&section);
2292        std::env::remove_var(VAR);
2293        match outcome {
2294            Ok(_) => panic!("empty env var must error, not yield an empty secret"),
2295            Err(ToolkitError::CodeMode(msg)) => {
2296                assert!(
2297                    msg.contains(VAR) && msg.contains("set but empty"),
2298                    "error must name the var as set-but-empty, got: {msg}"
2299                );
2300            },
2301            Err(other) => panic!("expected CodeMode 'set but empty', got {other:?}"),
2302        }
2303    }
2304
2305    #[test]
2306    fn resolve_token_secret_whitespace_env_var_is_set_but_empty_error() {
2307        let _env = super::test_env_guard::lock();
2308        // All-whitespace is treated the same as empty (${VAR} form).
2309        const VAR: &str = "PMCP_TOOLKIT_CODE_MODE_TEST_WS_ENV";
2310        std::env::set_var(VAR, "   ");
2311        let mut section = env_section("UNUSED");
2312        section.token_secret = Some(format!("${{{VAR}}}"));
2313        let outcome = resolve_token_secret(&section);
2314        std::env::remove_var(VAR);
2315        match outcome {
2316            Ok(_) => panic!("whitespace-only env var must error"),
2317            Err(ToolkitError::CodeMode(msg)) => {
2318                assert!(
2319                    msg.contains(VAR) && msg.contains("set but empty"),
2320                    "error must name the var as set-but-empty, got: {msg}"
2321                );
2322            },
2323            Err(other) => panic!("expected CodeMode 'set but empty', got {other:?}"),
2324        }
2325    }
2326
2327    #[test]
2328    fn variables_to_params_maps_object_stripping_colon_prefix() {
2329        // 85-10 WR-02: a JSON object of name→value becomes (name, value) pairs,
2330        // with a leading `:` stripped to match the connector's keying.
2331        let vars = serde_json::json!({ ":name": "Rock", "limit": 5 });
2332        let mut params = variables_to_params(Some(&vars));
2333        params.sort_by(|a, b| a.0.cmp(&b.0));
2334        assert_eq!(
2335            params,
2336            vec![
2337                ("limit".to_string(), serde_json::json!(5)),
2338                ("name".to_string(), serde_json::json!("Rock")),
2339            ]
2340        );
2341    }
2342
2343    #[test]
2344    fn variables_to_params_none_or_non_object_is_empty() {
2345        // None / non-object yields an empty slice — the parity execute_code
2346        // scenario (passes None) is unaffected.
2347        assert!(variables_to_params(None).is_empty());
2348        assert!(variables_to_params(Some(&serde_json::json!("not-an-object"))).is_empty());
2349        assert!(variables_to_params(Some(&serde_json::json!([1, 2, 3]))).is_empty());
2350    }
2351
2352    #[test]
2353    fn resolve_token_secret_missing_env_var_surfaces_error() {
2354        // Use a var name that is overwhelmingly unlikely to be set in CI.
2355        let section = env_section("PMCP_TOOLKIT_DEFINITELY_NOT_SET_FOR_TEST");
2356        // SecretValue has no Debug — pattern-match instead of expect_err.
2357        match resolve_token_secret(&section) {
2358            Ok(_) => panic!("missing env var must error"),
2359            Err(ToolkitError::CodeMode(msg)) => {
2360                assert!(
2361                    msg.contains("PMCP_TOOLKIT_DEFINITELY_NOT_SET_FOR_TEST"),
2362                    "error message must name the missing env var, got: {msg}"
2363                );
2364            },
2365            Err(other) => panic!("expected CodeMode error, got {other:?}"),
2366        }
2367    }
2368}
2369
2370// =============================================================================
2371// SHAP-A-01 — SqlCodeExecutor unit tests (Plan 85-02 Task 1)
2372// =============================================================================
2373
2374#[cfg(all(test, feature = "sqlite"))]
2375mod sql_code_executor_tests {
2376    use super::*;
2377    use crate::config::{CodeModeSection, ServerConfig, ServerSection};
2378    use crate::sql::SqliteConnector;
2379
2380    const TEST_SECRET_VAR: &str = "PMCP_TOOLKIT_SQL_EXECUTOR_TEST_SECRET";
2381
2382    fn ensure_secret() {
2383        std::env::set_var(TEST_SECRET_VAR, "executor-test-secret-16-or-more");
2384    }
2385
2386    /// A read-only `[code_mode]` config (no writes/deletes/DDL) plus an
2387    /// in-memory SQLite connector seeded with a single `Artist` row.
2388    async fn read_only_executor() -> SqlCodeExecutor {
2389        let connector = SqliteConnector::open_in_memory().expect("open in-memory sqlite");
2390        connector
2391            .execute(
2392                "CREATE TABLE Artist (ArtistId INTEGER PRIMARY KEY, Name TEXT)",
2393                &[],
2394            )
2395            .await
2396            .expect("create table");
2397        connector
2398            .execute(
2399                "INSERT INTO Artist (ArtistId, Name) VALUES (1, 'AC/DC')",
2400                &[],
2401            )
2402            .await
2403            .expect("seed row");
2404
2405        let config = ServerConfig {
2406            server: ServerSection {
2407                name: "executor-test".to_string(),
2408                version: "0.1.0".to_string(),
2409                ..Default::default()
2410            },
2411            code_mode: Some(CodeModeSection {
2412                enabled: true,
2413                server_id: Some("executor-test".to_string()),
2414                allow_writes: false,
2415                allow_deletes: false,
2416                allow_ddl: false,
2417                token_secret: Some(format!("env:{TEST_SECRET_VAR}")),
2418                ..Default::default()
2419            }),
2420            ..Default::default()
2421        };
2422        // Serialize set-secret + env-read (build) so a concurrent test cannot
2423        // corrupt the process environment between them. Synchronous — no
2424        // `.await` inside the locked section (the `std` guard is `!Send`).
2425        let _env = super::test_env_guard::lock();
2426        ensure_secret();
2427        SqlCodeExecutor::new(Arc::new(connector), config).expect("build executor")
2428    }
2429
2430    /// Same in-memory connector as [`read_only_executor`], but the `[code_mode]`
2431    /// config sets `require_limit = true` so a bare SELECT must reject on policy.
2432    async fn read_only_executor_with_require_limit() -> SqlCodeExecutor {
2433        let connector = SqliteConnector::open_in_memory().expect("open in-memory sqlite");
2434        connector
2435            .execute(
2436                "CREATE TABLE Artist (ArtistId INTEGER PRIMARY KEY, Name TEXT)",
2437                &[],
2438            )
2439            .await
2440            .expect("create table");
2441        connector
2442            .execute(
2443                "INSERT INTO Artist (ArtistId, Name) VALUES (1, 'AC/DC')",
2444                &[],
2445            )
2446            .await
2447            .expect("seed row");
2448
2449        let config = ServerConfig {
2450            server: ServerSection {
2451                name: "executor-test".to_string(),
2452                version: "0.1.0".to_string(),
2453                ..Default::default()
2454            },
2455            code_mode: Some(CodeModeSection {
2456                enabled: true,
2457                server_id: Some("executor-test".to_string()),
2458                allow_writes: false,
2459                allow_deletes: false,
2460                allow_ddl: false,
2461                require_limit: true,
2462                token_secret: Some(format!("env:{TEST_SECRET_VAR}")),
2463                ..Default::default()
2464            }),
2465            ..Default::default()
2466        };
2467        // Serialize set-secret + env-read (build) so a concurrent test cannot
2468        // corrupt the process environment between them. Synchronous — no
2469        // `.await` inside the locked section (the `std` guard is `!Send`).
2470        let _env = super::test_env_guard::lock();
2471        ensure_secret();
2472        SqlCodeExecutor::new(Arc::new(connector), config).expect("build executor")
2473    }
2474
2475    #[tokio::test]
2476    async fn read_only_select_returns_rows() {
2477        let executor = read_only_executor().await;
2478        let result = executor
2479            .execute("SELECT ArtistId, Name FROM Artist", None)
2480            .await
2481            .expect("read-only SELECT must succeed under a read-only policy");
2482        // Mirrors production's observable `"rows"` key (REVIEW FIX #6b).
2483        let rows = result.get("rows").expect("payload has a `rows` key");
2484        let arr = rows.as_array().expect("`rows` is an array");
2485        assert_eq!(arr.len(), 1, "one seeded row expected, got {arr:?}");
2486        assert_eq!(arr[0]["Name"], "AC/DC");
2487    }
2488
2489    #[tokio::test]
2490    async fn require_limit_rejects_bare_select_before_connector() {
2491        // VERIFICATION Gap 1: with require_limit=true, a no-LIMIT SELECT is
2492        // rejected on re-validation BEFORE the connector — even though the
2493        // single seeded row never exceeds any row-count limit.
2494        let executor = read_only_executor_with_require_limit().await;
2495        let err = executor
2496            .execute("SELECT * FROM Artist", None)
2497            .await
2498            .expect_err("bare SELECT must be rejected when require_limit=true");
2499        assert!(
2500            matches!(err, ExecutionError::BackendError(_)),
2501            "expected a policy-rejection BackendError, got {err:?}"
2502        );
2503        // The table is untouched — proving the rejection is the require_limit
2504        // policy, not a row-count failure.
2505        let count = executor
2506            .connector
2507            .execute("SELECT COUNT(*) AS n FROM Artist", &[])
2508            .await
2509            .expect("count query");
2510        assert_eq!(count[0]["n"], 1, "row count must be unchanged");
2511    }
2512
2513    #[tokio::test]
2514    async fn require_limit_allows_limited_select() {
2515        let executor = read_only_executor_with_require_limit().await;
2516        let result = executor
2517            .execute("SELECT ArtistId, Name FROM Artist LIMIT 5", None)
2518            .await
2519            .expect("a LIMITed SELECT must succeed under require_limit=true");
2520        let rows = result.get("rows").expect("payload has a `rows` key");
2521        let arr = rows.as_array().expect("`rows` is an array");
2522        assert_eq!(arr.len(), 1, "one seeded row expected, got {arr:?}");
2523    }
2524
2525    #[tokio::test]
2526    async fn delete_rejected_before_connector_under_read_only_policy() {
2527        // allow_deletes=false → re-validation rejects DELETE BEFORE the
2528        // connector is reached (threat T-85-02-01 / SC-3).
2529        let executor = read_only_executor().await;
2530        let err = executor
2531            .execute("DELETE FROM Artist WHERE ArtistId = 1", None)
2532            .await
2533            .expect_err("DELETE must be rejected when allow_deletes=false");
2534        assert!(
2535            matches!(err, ExecutionError::BackendError(_)),
2536            "expected a policy-rejection BackendError, got {err:?}"
2537        );
2538        // The row must still be present — proving the connector was never reached.
2539        let still_there = executor
2540            .connector
2541            .execute("SELECT COUNT(*) AS n FROM Artist", &[])
2542            .await
2543            .expect("count query");
2544        assert_eq!(still_there[0]["n"], 1, "DELETE must not have run");
2545    }
2546
2547    #[tokio::test]
2548    async fn ddl_rejected_under_read_only_policy() {
2549        // allow_ddl=false → re-validation rejects DROP TABLE.
2550        let executor = read_only_executor().await;
2551        let err = executor
2552            .execute("DROP TABLE Artist", None)
2553            .await
2554            .expect_err("DROP must be rejected when allow_ddl=false");
2555        assert!(matches!(err, ExecutionError::BackendError(_)));
2556    }
2557
2558    #[tokio::test]
2559    async fn malformed_sql_returns_err_never_panics() {
2560        let executor = read_only_executor().await;
2561        let result = executor.execute("SELEC nonsense FRM", None).await;
2562        assert!(
2563            result.is_err(),
2564            "malformed SQL must surface an Err, never panic"
2565        );
2566    }
2567
2568    #[tokio::test]
2569    async fn execute_binds_variables_input() {
2570        // 85-10 WR-02 / T-85-10-01: the schema-advertised `variables` input is
2571        // BOUND as named params (not silently dropped), so a `WHERE Name = :name`
2572        // resolves against the seeded row.
2573        let executor = read_only_executor().await;
2574        let vars = serde_json::json!({ ":name": "AC/DC" });
2575        let result = executor
2576            .execute(
2577                "SELECT ArtistId FROM Artist WHERE Name = :name",
2578                Some(&vars),
2579            )
2580            .await
2581            .expect("bound variable must resolve the WHERE clause");
2582        let rows = result.get("rows").expect("payload has a `rows` key");
2583        let arr = rows.as_array().expect("`rows` is an array");
2584        assert_eq!(arr.len(), 1, "the bound :name must match the seeded row");
2585        assert_eq!(arr[0]["ArtistId"], 1);
2586    }
2587
2588    #[tokio::test]
2589    async fn execute_empty_variables_is_unaffected() {
2590        // An empty variables map binds nothing — identical to today's None path.
2591        let executor = read_only_executor().await;
2592        let empty = serde_json::json!({});
2593        let result = executor
2594            .execute("SELECT ArtistId, Name FROM Artist", Some(&empty))
2595            .await
2596            .expect("empty variables must behave exactly like None");
2597        let arr = result["rows"].as_array().expect("`rows` array");
2598        assert_eq!(arr.len(), 1);
2599    }
2600
2601    #[tokio::test]
2602    async fn pipeline_cached_at_construction_not_reread_per_execute() {
2603        // 85-10 IN-01 / T-85-10-03: the pipeline is built ONCE in `new`, so a
2604        // SECOND execute does NOT re-resolve the token_secret env var. Remove the
2605        // env var after construction — the executor must STILL succeed (proving
2606        // it did not re-read the now-missing secret).
2607        let executor = read_only_executor().await;
2608        // First execute (baseline) succeeds.
2609        executor
2610            .execute("SELECT ArtistId FROM Artist LIMIT 1", None)
2611            .await
2612            .expect("first execute succeeds");
2613        // Remove the secret the pipeline was built from. Each discrete env
2614        // mutation is serialized under the shared lock (held only across the
2615        // synchronous call, never across the `.await`s above/below).
2616        {
2617            let _env = super::test_env_guard::lock();
2618            std::env::remove_var(TEST_SECRET_VAR);
2619        }
2620        // Second execute STILL succeeds — the cached pipeline never re-reads env.
2621        let result = executor
2622            .execute("SELECT ArtistId FROM Artist LIMIT 1", None)
2623            .await
2624            .expect("second execute must succeed from the cached pipeline");
2625        // Restore for any sibling tests sharing the process env.
2626        {
2627            let _env = super::test_env_guard::lock();
2628            ensure_secret();
2629        }
2630        assert!(result.get("rows").is_some());
2631    }
2632}
2633
2634// =============================================================================
2635// TKIT-10 — assemble_code_mode_prompt integration tests
2636// =============================================================================
2637
2638#[cfg(test)]
2639mod tkit10_tests {
2640    use super::*;
2641    use crate::config::{DatabaseSection, DatabaseTableDecl, ServerConfig, ServerSection};
2642    use crate::sql::{Dialect, MockSqlConnector};
2643
2644    fn make_cfg(tables: Vec<DatabaseTableDecl>) -> ServerConfig {
2645        ServerConfig {
2646            server: ServerSection {
2647                name: "test".to_string(),
2648                version: "0.1.0".to_string(),
2649                ..Default::default()
2650            },
2651            database: DatabaseSection {
2652                tables,
2653                ..Default::default()
2654            },
2655            ..Default::default()
2656        }
2657    }
2658
2659    #[tokio::test]
2660    async fn assemble_includes_schema_text_and_dialect_name() {
2661        let connector = MockSqlConnector {
2662            dialect: Dialect::Postgres,
2663            schema: "CREATE TABLE users (id SERIAL PRIMARY KEY);".to_string(),
2664        };
2665        let cfg = make_cfg(vec![]);
2666        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
2667        assert!(
2668            prompt.contains("# Code Mode — PostgreSQL"),
2669            "prompt missing dialect header: {prompt}"
2670        );
2671        assert!(
2672            prompt.contains("CREATE TABLE users"),
2673            "prompt missing schema body: {prompt}"
2674        );
2675        assert!(
2676            prompt.contains("$1"),
2677            "Postgres guidance should mention $1: {prompt}"
2678        );
2679    }
2680
2681    #[tokio::test]
2682    async fn assemble_includes_curated_descriptions() {
2683        let connector = MockSqlConnector {
2684            dialect: Dialect::Athena,
2685            schema: "(see Glue catalog)".to_string(),
2686        };
2687        let cfg = make_cfg(vec![
2688            DatabaseTableDecl {
2689                name: "users".to_string(),
2690                description: Some("App users".to_string()),
2691            },
2692            DatabaseTableDecl {
2693                name: "orders".to_string(),
2694                description: Some("Customer orders".to_string()),
2695            },
2696        ]);
2697        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
2698        assert!(
2699            prompt.contains("## Curated Tables"),
2700            "prompt missing curated header: {prompt}"
2701        );
2702        assert!(
2703            prompt.contains("`users`: App users"),
2704            "prompt missing users description: {prompt}"
2705        );
2706        assert!(
2707            prompt.contains("`orders`: Customer orders"),
2708            "prompt missing orders description: {prompt}"
2709        );
2710        // Athena uses ? placeholders, not $1
2711        assert!(
2712            prompt.contains("Amazon Athena"),
2713            "prompt missing Athena dialect name: {prompt}"
2714        );
2715    }
2716
2717    #[tokio::test]
2718    async fn assemble_omits_curated_section_when_tables_empty() {
2719        let connector = MockSqlConnector {
2720            dialect: Dialect::Sqlite,
2721            schema: "CREATE TABLE t (id INTEGER PRIMARY KEY);".to_string(),
2722        };
2723        let cfg = make_cfg(vec![]);
2724        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
2725        assert!(
2726            !prompt.contains("## Curated Tables"),
2727            "empty [[database.tables]] must omit curated section: {prompt}"
2728        );
2729        assert!(
2730            prompt.contains("SQLite"),
2731            "prompt missing SQLite dialect name: {prompt}"
2732        );
2733    }
2734
2735    #[tokio::test]
2736    async fn assemble_skips_tables_without_descriptions() {
2737        // A described entry mixed with an undescribed one — only the described
2738        // row should render. Curated section still emits because at least one
2739        // row qualifies.
2740        let connector = MockSqlConnector {
2741            dialect: Dialect::MySql,
2742            schema: "CREATE TABLE t (id INT);".to_string(),
2743        };
2744        let cfg = make_cfg(vec![
2745            DatabaseTableDecl {
2746                name: "with_desc".to_string(),
2747                description: Some("has description".to_string()),
2748            },
2749            DatabaseTableDecl {
2750                name: "no_desc".to_string(),
2751                description: None,
2752            },
2753        ]);
2754        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
2755        assert!(prompt.contains("`with_desc`: has description"));
2756        assert!(
2757            !prompt.contains("`no_desc`"),
2758            "undescribed table must not appear in curated section: {prompt}"
2759        );
2760    }
2761
2762    // =========================================================================
2763    // assemble_code_mode_prompt_with_schema — file-based prompt seam (Task 3)
2764    // =========================================================================
2765
2766    #[test]
2767    fn with_schema_includes_header_dialect_schema_and_curated() {
2768        let cfg = make_cfg(vec![DatabaseTableDecl {
2769            name: "Artist".to_string(),
2770            description: Some("Musical artists".to_string()),
2771        }]);
2772        let schema = "CREATE TABLE Artist (ArtistId INTEGER PRIMARY KEY, Name TEXT);";
2773        let prompt = assemble_code_mode_prompt_with_schema(schema, Dialect::Sqlite, &cfg);
2774
2775        assert!(
2776            prompt.contains("# Code Mode"),
2777            "missing code-mode header: {prompt}"
2778        );
2779        assert!(prompt.contains("SQLite"), "missing dialect name: {prompt}");
2780        assert!(
2781            prompt.contains("# Database Schema"),
2782            "missing schema-resource header: {prompt}"
2783        );
2784        assert!(
2785            prompt.contains(schema),
2786            "schema text must appear verbatim: {prompt}"
2787        );
2788        assert!(
2789            prompt.contains("`Artist`: Musical artists"),
2790            "curated table description must appear: {prompt}"
2791        );
2792    }
2793
2794    /// The helper is a SYNC fn — this test calls it from a non-async context,
2795    /// which only compiles because it never awaits a connector (proving it
2796    /// cannot trigger a live `schema_text()`).
2797    #[test]
2798    fn with_schema_is_sync_and_uses_passed_dialect() {
2799        let cfg = make_cfg(vec![]);
2800        let prompt = assemble_code_mode_prompt_with_schema(
2801            "CREATE TABLE t (id INT);",
2802            Dialect::Postgres,
2803            &cfg,
2804        );
2805        assert!(
2806            prompt.contains("# Code Mode — PostgreSQL"),
2807            "passed dialect must drive the header: {prompt}"
2808        );
2809        // Postgres placeholder guidance mentions $1 — proves dialect param is used.
2810        assert!(prompt.contains("$1"), "Postgres guidance missing: {prompt}");
2811        // No curated section when [[database.tables]] is empty.
2812        assert!(
2813            !prompt.contains("## Curated Tables"),
2814            "empty tables must omit curated section: {prompt}"
2815        );
2816    }
2817
2818    #[test]
2819    fn with_schema_empty_text_still_has_header() {
2820        let cfg = make_cfg(vec![]);
2821        let prompt = assemble_code_mode_prompt_with_schema("", Dialect::MySql, &cfg);
2822        assert!(
2823            prompt.contains("# Code Mode — MySQL"),
2824            "empty schema must still produce a valid prompt with the header: {prompt}"
2825        );
2826        assert!(
2827            prompt.contains("# Database Schema"),
2828            "schema-resource header present even for empty schema: {prompt}"
2829        );
2830    }
2831}
2832
2833// =============================================================================
2834// Plan 90-10 — per-request executor seam + OpenAPI per-request wiring tests
2835// =============================================================================
2836
2837#[cfg(all(test, feature = "openapi-code-mode"))]
2838mod per_request_executor_tests {
2839    use super::*;
2840    use crate::config::{CodeModeSection, ServerConfig, ServerSection};
2841    use crate::http::auth::{create_passthrough_auth_provider, AuthConfig};
2842    use pmcp::server::auth::AuthContext;
2843
2844    /// A passthrough-configured `HttpCodeExecutor` over a fixed base_url.
2845    fn passthrough_base() -> HttpCodeExecutor {
2846        let auth = create_passthrough_auth_provider(
2847            &AuthConfig::OAuthPassthrough {
2848                target_header: "Authorization".to_string(),
2849                required: true,
2850            },
2851            None,
2852        )
2853        .expect("passthrough auth provider");
2854        HttpCodeExecutor::new(
2855            reqwest::Client::new(),
2856            "https://api.example".to_string(),
2857            auth,
2858        )
2859    }
2860
2861    fn extra_with_token(token: Option<&str>) -> pmcp::RequestHandlerExtra {
2862        let ctx = AuthContext {
2863            subject: "s".to_string(),
2864            scopes: vec![],
2865            claims: std::collections::HashMap::new(),
2866            token: token.map(str::to_string),
2867            client_id: None,
2868            expires_at: None,
2869            authenticated: token.is_some(),
2870        };
2871        pmcp::RequestHandlerExtra::default().with_auth_context(Some(ctx))
2872    }
2873
2874    #[test]
2875    fn request_executor_from_extra_threads_present_token() {
2876        // Plan 90-10 / OAPI-03 / OAPI-05: the captured inbound token reaches the
2877        // per-request executor's inbound_token field.
2878        let base = passthrough_base();
2879        assert_eq!(
2880            base.inbound_token_for_test(),
2881            None,
2882            "base executor starts with no inbound token"
2883        );
2884        let extra = extra_with_token(Some("Bearer client-tok"));
2885        let scoped = request_executor_from_extra(&base, &extra);
2886        assert_eq!(
2887            scoped.inbound_token_for_test(),
2888            Some("Bearer client-tok"),
2889            "the captured inbound token must be threaded into the per-request executor"
2890        );
2891    }
2892
2893    #[test]
2894    fn request_executor_from_extra_no_token_yields_none() {
2895        let base = passthrough_base();
2896        let extra = extra_with_token(None);
2897        let scoped = request_executor_from_extra(&base, &extra);
2898        assert_eq!(
2899            scoped.inbound_token_for_test(),
2900            None,
2901            "an extra carrying no token must yield an executor with inbound_token None"
2902        );
2903        // No auth context at all also yields None (never panics).
2904        let bare = request_executor_from_extra(&base, &pmcp::RequestHandlerExtra::default());
2905        assert_eq!(bare.inbound_token_for_test(), None);
2906    }
2907
2908    fn cfg_with_code_mode() -> ServerConfig {
2909        std::env::set_var(
2910            "PMCP_TOOLKIT_90_10_HTTP_SECRET",
2911            "per-request-test-secret-16-or-more",
2912        );
2913        ServerConfig {
2914            server: ServerSection {
2915                name: "http-cm".to_string(),
2916                version: "0.1.0".to_string(),
2917                ..Default::default()
2918            },
2919            code_mode: Some(CodeModeSection {
2920                enabled: true,
2921                server_id: Some("http-cm".to_string()),
2922                token_secret: Some("env:PMCP_TOOLKIT_90_10_HTTP_SECRET".to_string()),
2923                ..Default::default()
2924            }),
2925            ..Default::default()
2926        }
2927    }
2928
2929    #[test]
2930    fn http_tools_register_validate_and_execute_with_per_request_source() {
2931        // code_mode_http_tools_from_executor builds the ExecuteCodeHandler over
2932        // the PerRequestHttp source (constructed without panic over a passthrough
2933        // executor) and registers both Code-Mode tools.
2934        let _env = super::test_env_guard::lock();
2935        let cfg = cfg_with_code_mode();
2936        let builder = pmcp::Server::builder().name("http-cm").version("0.1.0");
2937        let builder = code_mode_http_tools_from_executor(
2938            builder,
2939            &cfg,
2940            passthrough_base(),
2941            ExecutionConfig::default(),
2942            ValidationFlavor::OpenApi,
2943        )
2944        .expect("OpenAPI per-request code-mode wiring must build");
2945        let server = builder.build().expect("server builds");
2946        assert!(
2947            server.get_tool("validate_code").is_some(),
2948            "validate_code registered"
2949        );
2950        assert!(
2951            server.get_tool("execute_code").is_some(),
2952            "execute_code registered"
2953        );
2954        std::env::remove_var("PMCP_TOOLKIT_90_10_HTTP_SECRET");
2955    }
2956
2957    #[test]
2958    fn http_tools_no_op_when_code_mode_absent() {
2959        let cfg = ServerConfig {
2960            server: ServerSection {
2961                name: "no-cm".to_string(),
2962                version: "0.1.0".to_string(),
2963                ..Default::default()
2964            },
2965            ..Default::default()
2966        };
2967        let builder = pmcp::Server::builder().name("no-cm").version("0.1.0");
2968        let builder = code_mode_http_tools_from_executor(
2969            builder,
2970            &cfg,
2971            passthrough_base(),
2972            ExecutionConfig::default(),
2973            ValidationFlavor::OpenApi,
2974        )
2975        .expect("no-op when [code_mode] absent");
2976        let server = builder.build().expect("server builds");
2977        assert!(
2978            server.get_tool("execute_code").is_none(),
2979            "no tools without [code_mode]"
2980        );
2981    }
2982}
2983
2984#[cfg(all(test, feature = "sqlite", feature = "openapi-code-mode"))]
2985mod sql_static_source_tests {
2986    use super::*;
2987    use crate::config::{CodeModeSection, ServerConfig, ServerSection};
2988    use crate::sql::SqliteConnector;
2989
2990    #[test]
2991    fn sql_path_registers_static_source_unchanged() {
2992        // The SQL path via code_mode_tools_from_executor still builds the
2993        // ExecuteCodeHandler with the Static source (SqlCodeExecutor) — Plan
2994        // 90-10 must not change the SQL wiring.
2995        let _env = super::test_env_guard::lock();
2996        std::env::set_var(
2997            "PMCP_TOOLKIT_90_10_SQL_SECRET",
2998            "sql-static-test-secret-16-or-more",
2999        );
3000        let connector = SqliteConnector::open_in_memory().expect("sqlite");
3001        let cfg = ServerConfig {
3002            server: ServerSection {
3003                name: "sql-cm".to_string(),
3004                version: "0.1.0".to_string(),
3005                ..Default::default()
3006            },
3007            code_mode: Some(CodeModeSection {
3008                enabled: true,
3009                server_id: Some("sql-cm".to_string()),
3010                token_secret: Some("env:PMCP_TOOLKIT_90_10_SQL_SECRET".to_string()),
3011                ..Default::default()
3012            }),
3013            ..Default::default()
3014        };
3015        let executor: Arc<dyn CodeExecutor> =
3016            Arc::new(SqlCodeExecutor::new(Arc::new(connector), cfg.clone()).expect("executor"));
3017        let builder = pmcp::Server::builder().name("sql-cm").version("0.1.0");
3018        let builder = code_mode_tools_from_executor(builder, &cfg, executor, ValidationFlavor::Sql)
3019            .expect("SQL code-mode wiring must build");
3020        let server = builder.build().expect("server builds");
3021        assert!(server.get_tool("validate_code").is_some());
3022        assert!(server.get_tool("execute_code").is_some());
3023        std::env::remove_var("PMCP_TOOLKIT_90_10_SQL_SECRET");
3024    }
3025}
3026
3027// =============================================================================
3028// Phase 128 D4(b) — the `placeholder_rules` override on `HttpCodeExecutor`
3029// =============================================================================
3030
3031/// The spec-narrowing half of D4(b) on the Code Mode surface.
3032///
3033/// Plan 05 moved placeholder resolution ahead of dispatch and left
3034/// `HttpExecutor::placeholder_rules` default-implemented, because `PlanExecutor`
3035/// has no access to an OpenAPI document. These rows prove the executor that DOES
3036/// own the document supplies the narrowing, and — the load-bearing half — that an
3037/// executor without one is still floored and capped.
3038///
3039/// Nine of the rows assert what a MISS does, because a miss is the shape a reader
3040/// most easily mistakes for "no checks".
3041#[cfg(all(test, feature = "openapi-code-mode", feature = "input-validation"))]
3042mod placeholder_rules_override {
3043    use super::HttpCodeExecutor;
3044    use crate::http::auth::{create_auth_provider, AuthConfig};
3045    use crate::http::OpenApiSchema;
3046    use pmcp_code_mode::HttpExecutor;
3047    use std::sync::Arc;
3048
3049    /// A spec declaring a NARROW pattern on `GET /things/{id}`, a DIFFERENT
3050    /// pattern on `DELETE /things/{id}` (so the method is provably load-bearing),
3051    /// a `maxLength`, and `allowReserved: true` on a third path parameter (D-11).
3052    const SPEC: &str = r#"{
3053      "openapi": "3.0.0",
3054      "info": { "title": "t", "version": "1" },
3055      "paths": {
3056        "/things/{id}": {
3057          "get": {
3058            "operationId": "getThing",
3059            "parameters": [
3060              { "name": "id", "in": "path", "required": true,
3061                "schema": { "type": "string", "pattern": "^G[0-9]+$", "maxLength": 12 } }
3062            ],
3063            "responses": { "200": { "description": "ok" } }
3064          },
3065          "delete": {
3066            "operationId": "deleteThing",
3067            "parameters": [
3068              { "name": "id", "in": "path", "required": true,
3069                "schema": { "type": "string", "pattern": "^D[0-9]+$" } }
3070            ],
3071            "responses": { "200": { "description": "ok" } }
3072          }
3073        },
3074        "/reserved/{seg}": {
3075          "get": {
3076            "operationId": "getReserved",
3077            "parameters": [
3078              { "name": "seg", "in": "path", "required": true,
3079                "allowReserved": true,
3080                "schema": { "type": "string" } }
3081            ],
3082            "responses": { "200": { "description": "ok" } }
3083          }
3084        }
3085      }
3086    }"#;
3087
3088    fn bare() -> HttpCodeExecutor {
3089        let auth = create_auth_provider(&AuthConfig::None).expect("noauth");
3090        HttpCodeExecutor::new(
3091            reqwest::Client::new(),
3092            "https://api.example".to_string(),
3093            auth,
3094        )
3095    }
3096
3097    fn with_spec() -> HttpCodeExecutor {
3098        bare().with_schema(Arc::new(
3099            OpenApiSchema::parse(SPEC).expect("the fixture spec parses"),
3100        ))
3101    }
3102
3103    /// `new`'s signature is unchanged, so the schema starts absent and every
3104    /// pre-existing construction site keeps compiling. A default-returning
3105    /// executor is FLOORED AND CAPPED — the assertion below is about the absence
3106    /// of NARROWING, not about the absence of checks.
3107    #[test]
3108    fn an_executor_with_no_schema_returns_the_default() {
3109        let exec = bare();
3110        let rules = exec.placeholder_rules("GET", "/things/{id}", "id");
3111        assert_eq!(rules.declared_pattern, None);
3112        assert_eq!(rules.declared_max_length, None);
3113        assert!(!rules.allow_slash);
3114    }
3115
3116    #[test]
3117    fn a_declared_pattern_reaches_the_rules() {
3118        let exec = with_spec();
3119        let rules = exec.placeholder_rules("GET", "/things/{id}", "id");
3120        assert_eq!(rules.declared_pattern, Some("^G[0-9]+$"));
3121        assert_eq!(rules.declared_max_length, Some(12));
3122    }
3123
3124    /// The `method` parameter is load-bearing, not decoration: two operations on
3125    /// one path declare different patterns and each must get its own.
3126    #[test]
3127    fn the_method_selects_the_operation() {
3128        let exec = with_spec();
3129        assert_eq!(
3130            exec.placeholder_rules("DELETE", "/things/{id}", "id")
3131                .declared_pattern,
3132            Some("^D[0-9]+$"),
3133            "a DELETE must never be narrowed by the GET's declared pattern"
3134        );
3135        assert_eq!(
3136            exec.placeholder_rules("get", "/things/{id}", "id")
3137                .declared_pattern,
3138            Some("^G[0-9]+$"),
3139            "`operation_for` upper-cases the method, so a lowercase verb still hits"
3140        );
3141    }
3142
3143    #[test]
3144    fn an_unknown_path_template_returns_the_default() {
3145        let exec = with_spec();
3146        // The `/users/{alias}` versus `/users/{id}` spelling-drift case: a template
3147        // the spec does not carry loses the NARROWING and keeps the floor + cap.
3148        let rules = exec.placeholder_rules("GET", "/things/{alias}", "alias");
3149        assert_eq!(rules.declared_pattern, None);
3150        assert_eq!(rules.declared_max_length, None);
3151        assert!(!rules.allow_slash);
3152    }
3153
3154    #[test]
3155    fn an_unknown_method_on_a_known_path_returns_the_default() {
3156        let exec = with_spec();
3157        assert_eq!(
3158            exec.placeholder_rules("PUT", "/things/{id}", "id")
3159                .declared_pattern,
3160            None
3161        );
3162    }
3163
3164    #[test]
3165    fn an_unknown_parameter_name_returns_the_default() {
3166        let exec = with_spec();
3167        assert_eq!(
3168            exec.placeholder_rules("GET", "/things/{id}", "nope")
3169                .declared_pattern,
3170            None
3171        );
3172    }
3173
3174    /// A QUERY-position parameter of the same name must not narrow a PATH
3175    /// placeholder: `placeholder_rules` answers a question about the path.
3176    #[test]
3177    fn a_non_path_parameter_is_not_consulted() {
3178        let spec = r#"{
3179          "openapi": "3.0.0",
3180          "info": { "title": "t", "version": "1" },
3181          "paths": {
3182            "/q": {
3183              "get": {
3184                "operationId": "q",
3185                "parameters": [
3186                  { "name": "id", "in": "query", "required": false,
3187                    "schema": { "type": "string", "pattern": "^Q[0-9]+$" } }
3188                ],
3189                "responses": { "200": { "description": "ok" } }
3190              }
3191            }
3192          }
3193        }"#;
3194        let exec = bare().with_schema(Arc::new(OpenApiSchema::parse(spec).expect("parses")));
3195        assert_eq!(
3196            exec.placeholder_rules("GET", "/q", "id").declared_pattern,
3197            None
3198        );
3199    }
3200
3201    /// D-11 / T-128-37 — a spec's reserved-expansion keyword must never reach
3202    /// `allow_slash`. Asserted for EVERY spec-derived result this fixture can
3203    /// produce, not only the one that declares the keyword.
3204    #[test]
3205    fn allow_slash_is_false_for_every_spec_derived_result() {
3206        let exec = with_spec();
3207        for (method, template, param) in [
3208            ("GET", "/things/{id}", "id"),
3209            ("DELETE", "/things/{id}", "id"),
3210            ("GET", "/reserved/{seg}", "seg"),
3211            ("GET", "/things/{alias}", "alias"),
3212            ("PUT", "/things/{id}", "id"),
3213        ] {
3214            assert!(
3215                !exec.placeholder_rules(method, template, param).allow_slash,
3216                "{method} {template} {param}: allow_slash is config-only (D-11)"
3217            );
3218        }
3219    }
3220
3221    /// A miss is not a hole: the value a floored-and-capped default refuses is
3222    /// still refused. This is T-128-36 stated as a test rather than as a doc
3223    /// sentence.
3224    #[test]
3225    fn a_schema_miss_still_refuses_a_floor_denied_value() {
3226        let exec = with_spec();
3227        let rules = exec.placeholder_rules("GET", "/things/{alias}", "alias");
3228        assert!(
3229            pmcp_code_mode::validate_path_placeholder("alias", "current/../../etc", &rules)
3230                .is_err(),
3231            "the floor survives a schema miss"
3232        );
3233        assert!(
3234            pmcp_code_mode::validate_path_placeholder("alias", &"x".repeat(257), &rules).is_err(),
3235            "the always-on cap survives a schema miss"
3236        );
3237    }
3238
3239    /// The narrowing actually narrows: a value that CLEARS the floor is refused by
3240    /// the spec's declared pattern, and accepted without the schema.
3241    #[test]
3242    fn the_narrowing_refuses_a_floor_clean_value_the_spec_forbids() {
3243        let clean = "NOTMATCHING";
3244        let spec_exec = with_spec();
3245        let narrowed = spec_exec.placeholder_rules("GET", "/things/{id}", "id");
3246        let err = pmcp_code_mode::validate_path_placeholder("id", clean, &narrowed)
3247            .expect_err("the declared pattern must refuse it");
3248        assert_eq!(err.rule, "pattern");
3249        assert!(
3250            !err.to_string().contains(clean),
3251            "the refusal must not echo the value: {err}"
3252        );
3253
3254        let bare_exec = bare();
3255        let bare_rules = bare_exec.placeholder_rules("GET", "/things/{id}", "id");
3256        assert!(
3257            pmcp_code_mode::validate_path_placeholder("id", clean, &bare_rules).is_ok(),
3258            "without the schema the same value passes — so the NARROWING refused it, not the floor"
3259        );
3260    }
3261}
3262
3263// -----------------------------------------------------------------------------
3264// Phase 128 E1 — the Code Mode surface's outbound-policy seam.
3265//
3266// The exact mirror of `http::client`'s `request_policy_seam`: same contract, same
3267// assertions, different surface. Selected by the `--lib code_mode::` filter.
3268// -----------------------------------------------------------------------------
3269
3270/// The E1 hook on the Code Mode / script-tool surface.
3271#[cfg(all(test, feature = "openapi-code-mode"))]
3272mod request_policy_seam {
3273    use super::HttpCodeExecutor;
3274    use crate::http::auth::HttpAuthProvider;
3275    use crate::http::HttpConnectorError;
3276    use crate::policy::{OutboundRequest, PolicyRefusal, RequestPolicy};
3277    use async_trait::async_trait;
3278    use pmcp_code_mode::{HttpExecutor, ResolvedPath};
3279    use reqwest::header::{HeaderMap, HeaderValue};
3280    use std::collections::HashMap;
3281    use std::sync::atomic::{AtomicUsize, Ordering};
3282    use std::sync::{Arc, Mutex};
3283
3284    /// Records whether auth ran, so a refusal test proves the hook is BEFORE auth.
3285    struct RecordingAuth {
3286        calls: Arc<AtomicUsize>,
3287    }
3288
3289    #[async_trait]
3290    impl HttpAuthProvider for RecordingAuth {
3291        async fn apply(
3292            &self,
3293            headers: &mut HeaderMap,
3294            query: &mut HashMap<String, String>,
3295            _inbound_token: Option<&str>,
3296        ) -> Result<(), HttpConnectorError> {
3297            self.calls.fetch_add(1, Ordering::SeqCst);
3298            headers.insert("authorization", HeaderValue::from_static("Bearer tok"));
3299            // An API-key-in-query credential: the policy must NEVER see this pair.
3300            query.insert("app_key".to_string(), "super-secret".to_string());
3301            Ok(())
3302        }
3303    }
3304
3305    type Seen = Arc<Mutex<Vec<(String, String, Vec<(String, String)>, Option<String>)>>>;
3306
3307    struct Recorder {
3308        seen: Seen,
3309        refuse: Option<&'static str>,
3310    }
3311
3312    #[async_trait]
3313    impl RequestPolicy for Recorder {
3314        async fn check(&self, req: &OutboundRequest<'_>) -> Result<(), PolicyRefusal> {
3315            self.seen.lock().expect("lock").push((
3316                req.tool.to_string(),
3317                req.path.to_string(),
3318                req.query.to_vec(),
3319                req.body.map(ToString::to_string),
3320            ));
3321            match self.refuse {
3322                Some(msg) => Err(PolicyRefusal::new(msg)),
3323                None => Ok(()),
3324            }
3325        }
3326    }
3327
3328    fn recorder(refuse: Option<&'static str>) -> (Arc<Recorder>, Seen) {
3329        let seen: Seen = Arc::new(Mutex::new(Vec::new()));
3330        (
3331            Arc::new(Recorder {
3332                seen: Arc::clone(&seen),
3333                refuse,
3334            }),
3335            seen,
3336        )
3337    }
3338
3339    fn exec(
3340        base_url: String,
3341        policy: Option<Arc<dyn RequestPolicy>>,
3342    ) -> (HttpCodeExecutor, Arc<AtomicUsize>) {
3343        let calls = Arc::new(AtomicUsize::new(0));
3344        let auth = Arc::new(RecordingAuth {
3345            calls: Arc::clone(&calls),
3346        });
3347        let e = HttpCodeExecutor::new(reqwest::Client::new(), base_url, auth);
3348        let e = match policy {
3349            Some(p) => e.with_request_policy(p),
3350            None => e,
3351        };
3352        (e, calls)
3353    }
3354
3355    #[tokio::test]
3356    async fn a_refusing_policy_stops_the_request_before_auth_and_before_the_send() {
3357        use wiremock::MockServer;
3358        let server = MockServer::start().await;
3359        let (policy, _seen) = recorder(Some("refused by test policy"));
3360        let (executor, auth_calls) = exec(server.uri(), Some(policy));
3361
3362        let err = executor
3363            .execute_request(
3364                "GET",
3365                ResolvedPath::from_checked("/users/42").expect("checked"),
3366                None,
3367            )
3368            .await
3369            .expect_err("the policy refuses");
3370        assert!(
3371            err.to_string().contains("refused by test policy"),
3372            "the refusal must carry the policy's own message, got: {err}"
3373        );
3374        assert_eq!(
3375            auth_calls.load(Ordering::SeqCst),
3376            0,
3377            "the auth provider must NOT have been invoked — the hook is before auth"
3378        );
3379        assert!(server
3380            .received_requests()
3381            .await
3382            .expect("recorded")
3383            .is_empty());
3384    }
3385
3386    #[tokio::test]
3387    async fn an_allowing_policy_lets_the_request_through_and_auth_is_applied() {
3388        use wiremock::matchers::{header, method, path};
3389        use wiremock::{Mock, MockServer, ResponseTemplate};
3390
3391        let server = MockServer::start().await;
3392        Mock::given(method("GET"))
3393            .and(path("/users/42"))
3394            .and(header("authorization", "Bearer tok"))
3395            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
3396            .mount(&server)
3397            .await;
3398
3399        let (policy, _seen) = recorder(None);
3400        let (executor, auth_calls) = exec(server.uri(), Some(policy));
3401        let out = executor
3402            .execute_request(
3403                "GET",
3404                ResolvedPath::from_checked("/users/42").expect("checked"),
3405                None,
3406            )
3407            .await
3408            .expect("allowed");
3409        assert_eq!(out["ok"], true);
3410        assert_eq!(auth_calls.load(Ordering::SeqCst), 1);
3411    }
3412
3413    /// The assertion that fails if the non-auth half of step (4) is moved back
3414    /// BELOW the hook: a policy written to inspect query pairs would then inspect
3415    /// an empty slice while the pairs about to be sent still sat in the body.
3416    #[tokio::test]
3417    async fn the_policy_sees_the_remaining_body_query_pairs_on_a_get() {
3418        use wiremock::matchers::method;
3419        use wiremock::{Mock, MockServer, ResponseTemplate};
3420
3421        let server = MockServer::start().await;
3422        Mock::given(method("GET"))
3423            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
3424            .mount(&server)
3425            .await;
3426
3427        let (policy, seen) = recorder(None);
3428        let (executor, _auth) = exec(server.uri(), Some(policy));
3429        executor
3430            .execute_request(
3431                "GET",
3432                ResolvedPath::from_checked("/search").expect("checked"),
3433                Some(serde_json::json!({ "term": "aspirin", "limit": 5 })),
3434            )
3435            .await
3436            .expect("allowed");
3437
3438        let seen = seen.lock().expect("lock");
3439        assert_eq!(seen.len(), 1);
3440        let (_tool, observed_path, query, body) = &seen[0];
3441        assert!(observed_path.ends_with("/search"));
3442        assert!(
3443            !query.is_empty(),
3444            "OutboundRequest.query must carry the remaining-body pairs a GET will send"
3445        );
3446        let keys: Vec<&str> = query.iter().map(|(k, _)| k.as_str()).collect();
3447        assert!(
3448            keys.contains(&"term") && keys.contains(&"limit"),
3449            "got {keys:?}"
3450        );
3451        assert!(
3452            body.is_none(),
3453            "a GET's remaining body became query pairs before the hook ran"
3454        );
3455    }
3456
3457    /// The credential must be absent from every field, in-crate as well as in the
3458    /// integration binary: the auth provider above contributes BOTH a header and
3459    /// an `app_key` query pair, and neither may be visible.
3460    #[tokio::test]
3461    async fn the_policy_never_sees_the_credential() {
3462        use wiremock::matchers::method;
3463        use wiremock::{Mock, MockServer, ResponseTemplate};
3464
3465        let server = MockServer::start().await;
3466        Mock::given(method("POST"))
3467            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
3468            .mount(&server)
3469            .await;
3470
3471        let (policy, seen) = recorder(None);
3472        let (executor, _auth) = exec(server.uri(), Some(policy));
3473        executor
3474            .execute_request(
3475                "POST",
3476                ResolvedPath::from_checked("/items").expect("checked"),
3477                Some(serde_json::json!({ "name": "widget" })),
3478            )
3479            .await
3480            .expect("allowed");
3481
3482        let seen = seen.lock().expect("lock");
3483        let (tool, observed_path, query, body) = &seen[0];
3484        for field in [tool.as_str(), observed_path.as_str()] {
3485            assert!(
3486                !field.contains("super-secret"),
3487                "credential leaked: {field}"
3488            );
3489        }
3490        assert!(
3491            !query
3492                .iter()
3493                .any(|(k, v)| k == "app_key" || v.contains("super-secret")),
3494            "the auth provider's query credential must be invisible to the policy"
3495        );
3496        assert!(!body.as_deref().unwrap_or("").contains("super-secret"));
3497    }
3498
3499    #[tokio::test]
3500    async fn no_policy_behaves_exactly_as_before() {
3501        use wiremock::matchers::method;
3502        use wiremock::{Mock, MockServer, ResponseTemplate};
3503
3504        let server = MockServer::start().await;
3505        Mock::given(method("GET"))
3506            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
3507            .mount(&server)
3508            .await;
3509
3510        let (executor, auth_calls) = exec(server.uri(), None);
3511        assert!(!executor.has_request_policy());
3512        let out = executor
3513            .execute_request(
3514                "GET",
3515                ResolvedPath::from_checked("/x").expect("checked"),
3516                None,
3517            )
3518            .await
3519            .expect("succeeds");
3520        assert_eq!(out["ok"], true);
3521        assert_eq!(auth_calls.load(Ordering::SeqCst), 1);
3522    }
3523
3524    #[tokio::test]
3525    async fn the_policy_is_told_which_tool_the_executor_serves() {
3526        use wiremock::matchers::method;
3527        use wiremock::{Mock, MockServer, ResponseTemplate};
3528
3529        let server = MockServer::start().await;
3530        Mock::given(method("GET"))
3531            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
3532            .mount(&server)
3533            .await;
3534
3535        let (policy, seen) = recorder(None);
3536        let (executor, _auth) = exec(server.uri(), Some(policy));
3537        let executor = executor.with_tool_label("lookup_code");
3538        executor
3539            .execute_request(
3540                "GET",
3541                ResolvedPath::from_checked("/x").expect("checked"),
3542                None,
3543            )
3544            .await
3545            .expect("allowed");
3546        assert_eq!(seen.lock().expect("lock")[0].0, "lookup_code");
3547    }
3548}