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