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