Skip to main content

pmcp_server_toolkit/
code_mode.rs

1// Net-new code for Phase 83 TKIT-06 / TKIT-09 (code-mode wiring surface).
2//
3// Bridges `[code_mode]` config blocks into pmcp-code-mode's `ValidationPipeline`
4// + HMAC token machinery, with every public type RE-EXPORTED from pmcp-code-mode
5// per D-16 (NO duplicate HMAC / token code per PATTERNS §"Anti-Patterns" #2).
6//
7// Per Phase 83 review R1, the preflight at
8// `.planning/phases/83-toolkit-core-lift-pmcp-server-toolkit/CODE_MODE_API_NOTES.md`
9// determined the wiring strategy: **R1 split** —
10// `validation_pipeline_from_config(&ServerConfig) -> Result<ValidationPipeline>`
11// + `code_mode_tools_from_executor(executor, config) -> Result<...>` — because
12// `pmcp-code-mode`'s `CodeExecutor` trait requires backend injection
13// (`HttpExecutor`, `SdkExecutor`, `McpExecutor`) and no config-only constructor
14// exists.
15
16//! Code-mode wiring: bridges `[code_mode]` config blocks into pmcp-code-mode's
17//! validation pipeline + HMAC token machinery, with policy / executor /
18//! validation types re-exported verbatim (NO duplicate impl per RESEARCH
19//! §"Anti-Patterns" #2).
20//!
21//! # R1 split (per `CODE_MODE_API_NOTES.md` Section 6)
22//!
23//! - [`validation_pipeline_from_config`] builds a [`ValidationPipeline`] from a
24//!   parsed [`crate::config::ServerConfig`]. This is the entry point Shape A /
25//!   Shape C consumers reach for — no per-server Rust glue needed.
26//! - [`code_mode_tools_from_executor`] composes a caller-supplied
27//!   [`CodeExecutor`] (Plan 08 wires this into `pmcp::ServerBuilder` via
28//!   `code_mode_from_config`).
29//! - [`register_code_mode_tools`] is the tolerant builder-extension entry
30//!   point: a no-op when `[code_mode]` is absent, an R9 enforcement gate when
31//!   present.
32//!
33//! # Security invariants (R6 + R9)
34//!
35//! - **R6 — toolkit-owned secret type.** `token_secret` resolution flows
36//!   through [`crate::secrets::SecretValue`] (feature-independent) and
37//!   converts to [`TokenSecret`] via `From` only at the HMAC boundary. This
38//!   keeps `--no-default-features` stable.
39//! - **R9 — inline-secret rejection.** A `[code_mode] token_secret = "raw"`
40//!   literal is REJECTED at validation/resolve time unless the operator
41//!   explicitly sets `allow_inline_token_secret_for_dev = true`. Default-deny;
42//!   warnings are not protection.
43
44#![cfg(feature = "code-mode")]
45
46// === Re-exports (TKIT-06 + D-16) ===
47//
48// Every symbol below is a pure re-export of `pmcp_code_mode::*`. Plan 06 ships
49// NO duplicate HMAC / token / policy / pipeline code (PATTERNS §"Anti-Patterns"
50// #2 — duplicating these would create two copies of a security-critical
51// invariant set).
52//
53// Symbols verified against `crates/pmcp-code-mode/src/lib.rs` per
54// CODE_MODE_API_NOTES.md Section 7.
55
56pub use pmcp_code_mode::{
57    canonicalize_code, compute_context_hash, hash_code, ApprovalToken, AuthorizationDecision,
58    CodeExecutor, CodeModeConfig, ExecutionError, HmacTokenGenerator, NoopPolicyEvaluator,
59    PolicyEvaluator, TokenGenerator, TokenSecret, ValidationContext, ValidationPipeline,
60};
61
62#[cfg(feature = "avp")]
63pub use pmcp_code_mode::{AvpClient, AvpConfig, AvpPolicyEvaluator};
64
65// OpenAPI / Code-Mode engine surface (Plan 90-04 / OAPI-05). Gated under the
66// `openapi-code-mode` umbrella (which forwards `pmcp-code-mode/js-runtime`), so
67// the bare `code-mode` (SQL-only) build does NOT pull the SWC JS engine
68// (RESEARCH Pitfall 4). Re-exported here so the binary (Plan 06) + Plan 05
69// reference ONE stable path for the engine types the OpenAPI flavor needs.
70#[cfg(feature = "openapi-code-mode")]
71pub use pmcp_code_mode::{ExecutionConfig, HttpExecutor, JsCodeExecutor};
72
73use std::sync::Arc;
74
75use crate::config::{CodeModeSection, ServerConfig};
76use crate::error::{ConfigValidationError, Result, ToolkitError};
77use crate::secrets::SecretValue;
78use crate::sql::{Dialect, SqlConnector};
79
80/// Which validation surface the generalized code-mode wiring drives (OAPI-10 /
81/// D-02 / Gemini review: a compile-time enum, NOT a stringly-typed `&str`, so a
82/// flavor typo is impossible).
83///
84/// Selects BOTH the `CodeModeToolBuilder` format string (the `validate_code` /
85/// `execute_code` tool schema `format` enum, via the private `code_format`
86/// accessor) AND which `ValidationPipeline` method `validate_code` calls:
87/// - [`ValidationFlavor::Sql`] → the `sql` format + `validate_sql_query` (the
88///   Shape A SQL path; unchanged behavior).
89/// - [`ValidationFlavor::OpenApi`] → the `openapi` format +
90///   `validate_javascript_code` (the OpenAPI JS path; really runs SWC-backed JS
91///   validation, not a stub).
92#[cfg(feature = "code-mode")]
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum ValidationFlavor {
95    /// SQL Code Mode — validate via `validate_sql_query`, `"sql"` tool format.
96    Sql,
97    /// OpenAPI Code Mode — validate via `validate_javascript_code`, `"openapi"`
98    /// tool format. Available regardless of the JS engine feature at the type
99    /// level; the OpenAPI `validate_code` path requires `openapi-code-mode`.
100    OpenApi,
101}
102
103#[cfg(feature = "code-mode")]
104impl ValidationFlavor {
105    /// The `CodeModeToolBuilder` format string for this flavor.
106    fn code_format(self) -> &'static str {
107        match self {
108            Self::Sql => "sql",
109            Self::OpenApi => "openapi",
110        }
111    }
112}
113
114/// Derive a per-request [`HttpCodeExecutor`] from a [`pmcp::RequestHandlerExtra`]
115/// by threading the captured inbound MCP client token (Plan 90-10 / OAPI-03 /
116/// OAPI-05).
117///
118/// This is the **toolkit-resident** replacement for the binary's dead
119/// `assemble.rs::request_executor` (WR-01 — the binary helper had NO runtime
120/// callers because the handlers, which live in THIS crate, could not reach it
121/// across the crate boundary). Both [`crate::tools`]'s `ScriptToolHandler` and
122/// the OpenAPI [`tool_handlers::ExecuteCodeHandler`] call this from inside their
123/// `handle` methods so the per-request `oauth_passthrough` token actually reaches
124/// the outbound request at runtime.
125///
126/// Reads `extra.auth_context().and_then(|ctx| ctx.token.clone())` (the raw
127/// inbound `Authorization` header captured by the binary's
128/// `TokenCaptureAuthProvider`) and returns a cheap clone of `base` carrying that
129/// token via [`HttpCodeExecutor::with_inbound_token`]. For an `oauth_passthrough`
130/// backend the cloned executor forwards the captured token to `target_header`;
131/// for static-auth backends the token is ignored (harmless).
132#[cfg(feature = "openapi-code-mode")]
133#[must_use]
134pub fn request_executor_from_extra(
135    base: &HttpCodeExecutor,
136    extra: &pmcp::RequestHandlerExtra,
137) -> HttpCodeExecutor {
138    let token = extra.auth_context().and_then(|ctx| ctx.token.clone());
139    base.clone().with_inbound_token(token)
140}
141
142// =============================================================================
143// R1 split — validation_pipeline_from_config + code_mode_tools_from_executor
144// =============================================================================
145
146/// Build a [`ValidationPipeline`] from a [`ServerConfig`]'s `[code_mode]` block.
147///
148/// Maps every reference-server [`CodeModeSection`] field onto
149/// [`CodeModeConfig`] per the verified construction surface in
150/// `CODE_MODE_API_NOTES.md` Section 2. The pipeline's HMAC token machinery is
151/// keyed by the resolved [`TokenSecret`] (derived from a toolkit-owned
152/// [`SecretValue`] per review R6).
153///
154/// Per Phase 83 review R1 — the preflight selected the R1 split because
155/// `pmcp-code-mode`'s [`CodeExecutor`] requires backend injection
156/// (`HttpExecutor` / `SdkExecutor` / `McpExecutor`); no config-only executor
157/// constructor exists. This function delivers the validation surface; the
158/// caller supplies the executor (see [`code_mode_tools_from_executor`]).
159///
160/// # Errors
161///
162/// - [`ToolkitError::CodeMode`] if `config.code_mode` is `None`.
163/// - [`ToolkitError::Validation`] wrapping
164///   [`ConfigValidationError::InlineSecretRejected`] when `token_secret` is an
165///   inline literal without `allow_inline_token_secret_for_dev` (review R9).
166/// - [`ToolkitError::CodeMode`] if the env var referenced by `env:VAR_NAME` is
167///   unset, or if the resolved secret is shorter than
168///   [`HmacTokenGenerator::MIN_SECRET_LEN`] (16 bytes).
169///
170/// # Example
171///
172/// ```no_run
173/// use pmcp_server_toolkit::code_mode::validation_pipeline_from_config;
174/// use pmcp_server_toolkit::config::ServerConfig;
175///
176/// // ServerConfig with a [code_mode] block + env:-style token_secret
177/// // resolves into a ValidationPipeline ready to validate SQL / GraphQL.
178/// let toml = r#"
179/// [server]
180/// name = "demo"
181/// version = "0.1.0"
182/// [code_mode]
183/// enabled = true
184/// token_secret = "env:DEMO_HMAC_SECRET"
185/// "#;
186/// std::env::set_var("DEMO_HMAC_SECRET", "demo-secret-that-is-long-enough");
187/// let cfg = ServerConfig::from_toml_strict_validated(toml).unwrap();
188/// let _pipeline = validation_pipeline_from_config(&cfg).unwrap();
189/// ```
190pub fn validation_pipeline_from_config(config: &ServerConfig) -> Result<ValidationPipeline> {
191    let section = config.code_mode.as_ref().ok_or_else(|| {
192        ToolkitError::CodeMode("ServerConfig has no [code_mode] block".to_string())
193    })?;
194    let cm_config = build_cm_config(section);
195    let secret_value = resolve_token_secret(section)?;
196    let token_secret: TokenSecret = secret_value.into(); // R6 conversion
197    ValidationPipeline::from_token_secret(cm_config, &token_secret)
198        .map_err(|e| ToolkitError::CodeMode(format!("ValidationPipeline construction failed: {e}")))
199}
200
201/// Register `validate_code` + `execute_code` on `builder`, driven by the
202/// `[code_mode]` block, a caller-supplied [`CodeExecutor`], and a
203/// [`ValidationFlavor`] (OAPI-10 / D-02).
204///
205/// This is the ONE backend-agnostic wiring function serving BOTH the SQL path
206/// (`flavor = ValidationFlavor::Sql`, executor = [`SqlCodeExecutor`]) and the
207/// OpenAPI path (`flavor = ValidationFlavor::OpenApi`, executor =
208/// `JsCodeExecutor<HttpCodeExecutor>`). The `executor` is type-erased to
209/// `Arc<dyn CodeExecutor>` so the same function — and the same `execute_code`
210/// handler body, which already dispatches through the trait — works for any
211/// backend; only the `flavor` selects the validation surface + tool format.
212///
213/// This is the actual two-tool registration the LOCKED
214/// [`crate::builder_ext::ServerBuilderExt::try_code_mode_from_config_with_connector`]
215/// delegates to (the Phase 83-06 R1 split precedent: the connector-aware
216/// builder method constructs the executor, this helper wires the tools).
217///
218/// - When `config.code_mode.is_none()` the builder is returned UNCHANGED
219///   (no-op) — code-mode is opt-in at the config level.
220/// - When `[code_mode]` IS present, the R9 inline-secret gate and the
221///   secret-resolution / HMAC machinery run via [`validation_pipeline_from_config`]
222///   (errors surface BEFORE `.build()`), then both tools are registered with
223///   the static `[code_mode]` policy baked into the pipeline (SC-3 / D-13).
224///   A [`NoopPolicyEvaluator`] is wired so authorization is purely the static
225///   config policy (allow_writes / allow_deletes / allow_ddl), not an external
226///   Cedar/AVP engine.
227///
228/// # Errors
229///
230/// Surfaces every error from [`validation_pipeline_from_config`] when
231/// `config.code_mode.is_some()` — most notably
232/// [`ConfigValidationError::InlineSecretRejected`] (review R9) and the
233/// [`ToolkitError::CodeMode`] secret-resolution / 16-byte-minimum failures.
234pub fn code_mode_tools_from_executor(
235    builder: pmcp::ServerBuilder,
236    config: &ServerConfig,
237    executor: Arc<dyn CodeExecutor>,
238    flavor: ValidationFlavor,
239) -> Result<pmcp::ServerBuilder> {
240    let Some(section) = config.code_mode.as_ref() else {
241        return Ok(builder); // no-op when block absent
242    };
243    // Build the policy-bearing pipeline. This is also the R9 enforcement gate +
244    // secret resolution — must run BEFORE the builder is returned so a
245    // misconfigured token_secret is caught at builder-time, not first request.
246    let cm_config = build_cm_config(section);
247    let secret_value = resolve_token_secret(section)?;
248    let token_secret: TokenSecret = secret_value.into();
249    let evaluator: Arc<dyn PolicyEvaluator> = Arc::new(NoopPolicyEvaluator::new());
250    let pipeline = ValidationPipeline::from_token_secret_with_policy(
251        cm_config.clone(),
252        &token_secret,
253        evaluator,
254    )
255    .map_err(|e| ToolkitError::CodeMode(format!("ValidationPipeline construction failed: {e}")))?;
256    let pipeline = Arc::new(pipeline);
257
258    let validate_handler = tool_handlers::ValidateCodeHandler {
259        pipeline: Arc::clone(&pipeline),
260        config: cm_config,
261        flavor,
262    };
263    let execute_handler = tool_handlers::ExecuteCodeHandler {
264        pipeline,
265        source: tool_handlers::ExecSource::Static(executor),
266        flavor,
267    };
268
269    Ok(builder
270        .tool_arc("validate_code", Arc::new(validate_handler))
271        .tool_arc("execute_code", Arc::new(execute_handler)))
272}
273
274/// Register `validate_code` + `execute_code` on `builder` for the **OpenAPI
275/// per-request** Code-Mode path (Plan 90-10 / OAPI-03 / OAPI-05).
276///
277/// This is the per-request analog of [`code_mode_tools_from_executor`]. Where
278/// that helper takes a FIXED type-erased `Arc<dyn CodeExecutor>` (the SQL path,
279/// whose `SqlCodeExecutor` carries no per-request state), this helper takes the
280/// concrete [`HttpCodeExecutor`] `base` + [`ExecutionConfig`] so the
281/// [`tool_handlers::ExecuteCodeHandler`] can RE-DERIVE a request-scoped
282/// `JsCodeExecutor` per call via [`request_executor_from_extra`] — threading the
283/// captured inbound MCP token so an `oauth_passthrough` backend forwards it to
284/// the real backend.
285///
286/// The reason a per-request entry point is required: a `JsCodeExecutor`'s inner
287/// `http` field is private with no accessor, so a type-erased
288/// `Arc<dyn CodeExecutor>` cannot be re-derived per request. Holding the base
289/// [`HttpCodeExecutor`] (which IS `Clone` + has the `with_inbound_token` builder)
290/// makes the per-request rederivation possible WITHOUT changing the SQL path.
291///
292/// The `validate_code` handler is identical to the
293/// [`code_mode_tools_from_executor`] one; only `execute_code` differs (it carries
294/// the [`tool_handlers::ExecSource::PerRequestHttp`] source instead of
295/// [`tool_handlers::ExecSource::Static`]).
296///
297/// # Errors
298///
299/// Surfaces every error from [`validation_pipeline_from_config`] when
300/// `config.code_mode.is_some()` (R9 inline-secret rejection, secret-resolution /
301/// 16-byte-minimum failures). No-op (returns the builder unchanged) when
302/// `config.code_mode.is_none()`.
303#[cfg(feature = "openapi-code-mode")]
304pub fn code_mode_http_tools_from_executor(
305    builder: pmcp::ServerBuilder,
306    config: &ServerConfig,
307    base: HttpCodeExecutor,
308    exec_config: ExecutionConfig,
309    flavor: ValidationFlavor,
310) -> Result<pmcp::ServerBuilder> {
311    let Some(section) = config.code_mode.as_ref() else {
312        return Ok(builder); // no-op when block absent
313    };
314    // R9 enforcement gate + secret resolution — must run BEFORE the builder is
315    // returned so a misconfigured token_secret is caught at builder-time.
316    let cm_config = build_cm_config(section);
317    let secret_value = resolve_token_secret(section)?;
318    let token_secret: TokenSecret = secret_value.into();
319    let evaluator: Arc<dyn PolicyEvaluator> = Arc::new(NoopPolicyEvaluator::new());
320    let pipeline = ValidationPipeline::from_token_secret_with_policy(
321        cm_config.clone(),
322        &token_secret,
323        evaluator,
324    )
325    .map_err(|e| ToolkitError::CodeMode(format!("ValidationPipeline construction failed: {e}")))?;
326    let pipeline = Arc::new(pipeline);
327
328    let validate_handler = tool_handlers::ValidateCodeHandler {
329        pipeline: Arc::clone(&pipeline),
330        config: cm_config,
331        flavor,
332    };
333    let execute_handler = tool_handlers::ExecuteCodeHandler {
334        pipeline,
335        source: tool_handlers::ExecSource::PerRequestHttp { base, exec_config },
336        flavor,
337    };
338
339    Ok(builder
340        .tool_arc("validate_code", Arc::new(validate_handler))
341        .tool_arc("execute_code", Arc::new(execute_handler)))
342}
343
344/// Tolerant builder-extension entry point for `[code_mode]` config — the
345/// CONNECTORLESS, **validation-only / no-tool** path.
346///
347/// Used by [`crate::builder_ext::ServerBuilderExt::try_code_mode_from_config`]
348/// (the connectorless companion). It is deliberately tolerant of
349/// `config.code_mode = None` (returns the builder unchanged) so callers can
350/// invoke it unconditionally — code-mode is opt-in at the config level.
351///
352/// When `[code_mode]` IS present, this helper drives
353/// [`validation_pipeline_from_config`] to surface R9 enforcement errors
354/// (inline `token_secret` rejection) before the builder reaches `.build()`,
355/// but registers NO tools because there is no executor to bind to. The
356/// tool-registering path is
357/// [`crate::builder_ext::ServerBuilderExt::try_code_mode_from_config_with_connector`]
358/// (which delegates to [`code_mode_tools_from_executor`]).
359///
360/// # Errors
361///
362/// Returns every error from [`validation_pipeline_from_config`] when
363/// `config.code_mode.is_some()`. No errors when `config.code_mode.is_none()`.
364pub fn register_code_mode_tools(
365    builder: pmcp::ServerBuilder,
366    config: &ServerConfig,
367) -> Result<pmcp::ServerBuilder> {
368    if config.code_mode.is_none() {
369        return Ok(builder); // no-op when block absent
370    }
371    // R9 enforcement gate — must run BEFORE the builder is returned so that a
372    // misconfigured `[code_mode] token_secret = "inline-string"` is caught at
373    // builder-time, not at first request. NO tools registered (no executor) —
374    // this is the documented connectorless validation-only path.
375    let _pipeline = validation_pipeline_from_config(config)?;
376    Ok(builder)
377}
378
379// =============================================================================
380// Hand-built validate_code / execute_code ToolHandlers (Plan 85-02 Task 2)
381//
382// Mirrors the `#[derive(CodeMode)]` macro output in pmcp-code-mode-derive but
383// hand-written here so the toolkit does NOT take a proc-macro dependency. Only
384// the PUBLIC API (`code_mode_tools_from_executor` +
385// `try_code_mode_from_config_with_connector`) is LOCKED; this internal
386// mechanism is the implementer's discretion (Plan 85-02 Task 2).
387// =============================================================================
388mod tool_handlers {
389    use std::sync::Arc;
390
391    use super::ValidationFlavor;
392    use pmcp_code_mode::TokenGenerator as _;
393
394    /// Run the flavor-appropriate validation surface (OAPI-10 / D-02).
395    ///
396    /// - [`ValidationFlavor::Sql`] → `validate_sql_query` (Shape A SQL path).
397    /// - [`ValidationFlavor::OpenApi`] → `validate_javascript_code` (the OpenAPI
398    ///   JS path; really runs SWC-backed JS validation). Only reachable when the
399    ///   `openapi-code-mode` feature is enabled — the binary that wires the
400    ///   OpenApi flavor enables that umbrella, so the arm is feature-gated.
401    fn run_flavored_validation(
402        pipeline: &pmcp_code_mode::ValidationPipeline,
403        flavor: ValidationFlavor,
404        code: &str,
405        context: &pmcp_code_mode::ValidationContext,
406    ) -> std::result::Result<pmcp_code_mode::ValidationResult, String> {
407        match flavor {
408            ValidationFlavor::Sql => pipeline
409                .validate_sql_query(code, context)
410                .map_err(|e| format!("Validation error: {e}")),
411            #[cfg(feature = "openapi-code-mode")]
412            ValidationFlavor::OpenApi => pipeline
413                .validate_javascript_code(code, context)
414                .map_err(|e| format!("Validation error: {e}")),
415            #[cfg(not(feature = "openapi-code-mode"))]
416            ValidationFlavor::OpenApi => Err(
417                "OpenAPI Code Mode validation requires the `openapi-code-mode` feature".to_string(),
418            ),
419        }
420    }
421
422    /// `validate_code` tool handler: runs the code through the policy-bearing
423    /// [`ValidationPipeline`](pmcp_code_mode::ValidationPipeline) (SQL or JS per
424    /// [`ValidationFlavor`]) and returns the explanation + (on success) an HMAC
425    /// approval token.
426    pub(super) struct ValidateCodeHandler {
427        pub(super) pipeline: Arc<pmcp_code_mode::ValidationPipeline>,
428        pub(super) config: pmcp_code_mode::CodeModeConfig,
429        pub(super) flavor: ValidationFlavor,
430    }
431
432    #[pmcp_code_mode::async_trait]
433    impl pmcp::ToolHandler for ValidateCodeHandler {
434        async fn handle(
435            &self,
436            args: serde_json::Value,
437            _extra: pmcp::RequestHandlerExtra,
438        ) -> pmcp::Result<serde_json::Value> {
439            let input: pmcp_code_mode::ValidateCodeInput = serde_json::from_value(args)
440                .map_err(|e| pmcp::Error::Internal(format!("Invalid arguments: {e}")))?;
441            let code = input.code.trim();
442            let dry_run = input.dry_run.unwrap_or(false);
443
444            // Static-policy ValidationContext — the toolkit binds approval
445            // tokens to a fixed config-derived context (no live user/session
446            // surface in the pure-config binary). Static `[code_mode]` policy
447            // (allow_writes/deletes/ddl for SQL; openapi_blocked_paths /
448            // disallowed ops for OpenApi) is enforced inside the validation
449            // surface selected by `flavor`.
450            let context = pmcp_code_mode::ValidationContext::new(
451                "code-mode-config",
452                "code-mode-session",
453                "schema-hash",
454                "perms-hash",
455            );
456
457            let result = run_flavored_validation(&self.pipeline, self.flavor, code, &context)
458                .map_err(pmcp::Error::Internal)?;
459
460            let mut response = pmcp_code_mode::ValidationResponse::from_result(result);
461            if response.result.is_valid {
462                if dry_run {
463                    response.result.approval_token = None;
464                }
465                let risk = response.result.risk_level;
466                response = response.with_auto_approved(self.config.should_auto_approve(risk));
467            }
468            let (json, is_error) = response.to_json_response();
469            // A policy rejection (allow_writes/deletes/ddl off, require_limit, …)
470            // is reported by `to_json_response` with `is_error == true`. Surface it
471            // as a TOOL-level rejection via `Error::tool_rejected` so the MCP
472            // `tools/call` result is `CallToolResult { isError: true }` carrying a
473            // model-actionable `message` plus the full violation JSON in
474            // `structuredContent` — NOT a `-32603` protocol error (which reads as a
475            // server fault and gives the model nothing to correct). This is the
476            // production-reference observable the generated.yaml `failure`
477            // assertions (DELETE/DDL/no-LIMIT) verify: mcp-tester treats
478            // `isError: true` as a failed step (SC-3 policy-enforcement proof,
479            // threat T-85-02-02).
480            if is_error {
481                let message = response
482                    .result
483                    .violations
484                    .first()
485                    .map(ToString::to_string)
486                    .unwrap_or_else(|| {
487                        "Code Mode rejected the query (policy validation failed)".to_string()
488                    });
489                return Err(pmcp::Error::tool_rejected(message, Some(json)));
490            }
491            Ok(json)
492        }
493
494        fn metadata(&self) -> Option<pmcp::types::ToolInfo> {
495            Some(
496                pmcp_code_mode::CodeModeToolBuilder::new(self.flavor.code_format())
497                    .build_validate_tool(),
498            )
499        }
500    }
501
502    /// How `execute_code` obtains the [`CodeExecutor`](pmcp_code_mode::CodeExecutor)
503    /// for a request (Plan 90-10 / OAPI-03 / OAPI-05).
504    ///
505    /// - [`ExecSource::Static`] — a FIXED type-erased executor (the SQL path's
506    ///   `SqlCodeExecutor`, which carries no per-request state). Unchanged
507    ///   behavior; available under bare `code-mode`.
508    /// - [`ExecSource::PerRequestHttp`] — the OpenAPI path: a base
509    ///   [`HttpCodeExecutor`](super::HttpCodeExecutor) + [`ExecutionConfig`](super::ExecutionConfig)
510    ///   from which a request-scoped `JsCodeExecutor` is RE-DERIVED per call (via
511    ///   [`request_executor_from_extra`](super::request_executor_from_extra)) so
512    ///   the captured inbound `oauth_passthrough` token is threaded to the
513    ///   backend. Feature-gated `openapi-code-mode` (the engine types are only in
514    ///   scope there); the SQL build is unaffected.
515    pub(super) enum ExecSource {
516        /// SQL path — a fixed type-erased executor, no per-request derivation.
517        Static(Arc<dyn pmcp_code_mode::CodeExecutor>),
518        /// OpenAPI path — re-derive a request-scoped executor per call so the
519        /// captured inbound token reaches the backend (OAPI-03 / OAPI-05).
520        #[cfg(feature = "openapi-code-mode")]
521        PerRequestHttp {
522            /// The base executor (cloned + token-threaded per request).
523            base: super::HttpCodeExecutor,
524            /// The execution bounds for the per-request `JsCodeExecutor`.
525            exec_config: super::ExecutionConfig,
526        },
527    }
528
529    /// `execute_code` tool handler: verifies the approval token + code hash,
530    /// then runs the code through the backend-agnostic
531    /// [`CodeExecutor`](pmcp_code_mode::CodeExecutor) (SQL re-validates before
532    /// the connector; OpenAPI runs the validated JS through a request-scoped
533    /// `JsCodeExecutor`). The `flavor` only selects the tool `format` metadata —
534    /// the `handle` body dispatches through the trait regardless of backend.
535    pub(super) struct ExecuteCodeHandler {
536        pub(super) pipeline: Arc<pmcp_code_mode::ValidationPipeline>,
537        pub(super) source: ExecSource,
538        pub(super) flavor: ValidationFlavor,
539    }
540
541    impl ExecuteCodeHandler {
542        /// Run the validated `code` through the source-appropriate executor
543        /// (Plan 90-10). The `Static` arm dispatches through the fixed
544        /// type-erased executor (SQL); the `PerRequestHttp` arm RE-DERIVES a
545        /// request-scoped `JsCodeExecutor` carrying the captured inbound token
546        /// via [`request_executor_from_extra`](super::request_executor_from_extra)
547        /// so an `oauth_passthrough` backend forwards it (OAPI-03 / OAPI-05).
548        ///
549        /// Extracted from `handle` to keep both bodies under the cog ≤25 budget.
550        async fn run_code(
551            &self,
552            code: &str,
553            variables: Option<&serde_json::Value>,
554            #[cfg_attr(not(feature = "openapi-code-mode"), allow(unused_variables))]
555            extra: &pmcp::RequestHandlerExtra,
556        ) -> std::result::Result<serde_json::Value, pmcp_code_mode::ExecutionError> {
557            // Gated to match the ONE arm that needs it. Under `openapi-code-mode`
558            // the `PerRequestHttp` arm calls `.execute()` on a freshly built
559            // `JsCodeExecutor`, which resolves only through this trait. Without
560            // that feature the arm is cfg'd out and the `Static` arm resolves
561            // `.execute()` without the trait, leaving the import unused — a
562            // warning that becomes a hard error wherever `-D warnings` reaches
563            // this crate. Measured both ways: `--features http` warns,
564            // `--features http,openapi-code-mode` does not.
565            #[cfg(feature = "openapi-code-mode")]
566            use pmcp_code_mode::CodeExecutor as _;
567            match &self.source {
568                ExecSource::Static(executor) => executor.execute(code, variables).await,
569                #[cfg(feature = "openapi-code-mode")]
570                ExecSource::PerRequestHttp { base, exec_config } => {
571                    let http_exec = super::request_executor_from_extra(base, extra);
572                    super::JsCodeExecutor::new(http_exec, exec_config.clone())
573                        .execute(code, variables)
574                        .await
575                },
576            }
577        }
578    }
579
580    #[pmcp_code_mode::async_trait]
581    impl pmcp::ToolHandler for ExecuteCodeHandler {
582        async fn handle(
583            &self,
584            args: serde_json::Value,
585            extra: pmcp::RequestHandlerExtra,
586        ) -> pmcp::Result<serde_json::Value> {
587            let input: pmcp_code_mode::ExecuteCodeInput = serde_json::from_value(args)
588                .map_err(|e| pmcp::Error::Internal(format!("Invalid arguments: {e}")))?;
589            let code = input.code.trim();
590
591            // Token / code-hash verification failures are model-actionable
592            // rejections (the model must re-run validate_code to obtain a fresh
593            // token, or resend the exact validated code), so surface them as
594            // `CallToolResult { isError: true }` via `Error::tool_rejected` —
595            // not `-32603`. A genuine execution fault (connector/SQL runtime,
596            // below) stays an `Internal` protocol error: the caller cannot fix
597            // it by changing input.
598            let token_gen = self.pipeline.token_generator();
599            let token =
600                pmcp_code_mode::ApprovalToken::decode(&input.approval_token).map_err(|e| {
601                    pmcp::Error::tool_rejected(
602                        format!(
603                        "Invalid approval_token: {e}. Call validate_code to obtain a valid token."
604                    ),
605                        None,
606                    )
607                })?;
608            token_gen.verify(&token).map_err(|e| {
609                pmcp::Error::tool_rejected(
610                    format!(
611                        "Approval token is invalid or expired: {e}. \
612                         Call validate_code again to obtain a fresh token."
613                    ),
614                    None,
615                )
616            })?;
617            token_gen.verify_code(code, &token).map_err(|e| {
618                pmcp::Error::tool_rejected(
619                    format!(
620                        "Code does not match the validated code: {e}. execute_code must use the \
621                         exact code string that was passed to validate_code."
622                    ),
623                    None,
624                )
625            })?;
626
627            let result = self
628                .run_code(code, input.variables.as_ref(), &extra)
629                .await
630                .map_err(|e| pmcp::Error::Internal(format!("Execution error: {e}")))?;
631            Ok(result)
632        }
633
634        fn metadata(&self) -> Option<pmcp::types::ToolInfo> {
635            Some(
636                pmcp_code_mode::CodeModeToolBuilder::new(self.flavor.code_format())
637                    .build_execute_tool(),
638            )
639        }
640    }
641}
642
643// =============================================================================
644// SHAP-A-01 — SqlCodeExecutor (Plan 85-02 Task 1)
645// =============================================================================
646
647/// [`CodeExecutor`] adapter bridging the toolkit's single-method
648/// [`SqlConnector`] to the code-mode `validate_code` / `execute_code` flow.
649///
650/// # Re-derived for the single-method trait
651///
652/// The production reference (`mcp-sql-server-core::SqlCodeModeHandler`) is
653/// written over a 2-method `DatabaseConnector` (`execute_query` /
654/// `execute_statement`) and dispatches by [`crate::sql`]'s
655/// `QueryType`. The toolkit's [`SqlConnector`] exposes a SINGLE
656/// [`SqlConnector::execute`] entry point, so this adapter collapses that
657/// 2-method dispatch into one `connector.execute(sql, &params)` call regardless
658/// of statement type — re-validating the SQL FIRST for defense-in-depth. The
659/// `execute_code` `variables` input IS bound as named params (85-10 WR-02);
660/// it is never silently dropped.
661///
662/// # Defense-in-depth re-validation (threat T-85-02-01)
663///
664/// Before touching the connector, [`SqlCodeExecutor::execute`] re-runs the
665/// `[code_mode]` policy against the supplied SQL via the same
666/// [`ValidationPipeline`] the `validate_code` tool used. The code-mode
667/// framework already verified the approval token + code hash before calling
668/// this method, but re-validation guards against a token issued for an
669/// allowed statement being replayed with a different (e.g. mutating)
670/// statement. A policy violation returns `Err(ExecutionError::BackendError)`
671/// BEFORE the connector is reached — a config-driven server cannot bypass the
672/// write/DDL guards (SC-3, threat T-85-02-02).
673///
674/// # Observable result shape (REVIEW FIX Codex MEDIUM #6b)
675///
676/// The production handler returns
677/// `{"columns": [...], "rows": [...], "rows_affected": N}` because its
678/// 2-method connector surfaces columns + affected-row counts separately. The
679/// toolkit's [`SqlConnector::execute`] returns `Vec<Value>` (one JSON object
680/// per row, keyed by column name) with no separate columns/rows_affected
681/// channel, so this adapter mirrors production's OBSERVABLE `"rows"` key:
682/// `{"rows": <values>}`. The parity replay (Plan 06) only exercises
683/// `execute_code` with an INVALID token (asserts `failure`), so this success
684/// shape is not asserted by `generated.yaml`; mirroring production keeps the
685/// executor correct for any future success-path scenario and for the direct
686/// unit assertions in this crate.
687pub struct SqlCodeExecutor {
688    connector: Arc<dyn SqlConnector>,
689    /// The re-validation pipeline, built ONCE at construction (85-10 IN-01).
690    ///
691    /// Previously [`SqlCodeExecutor::revalidate`] rebuilt the pipeline AND
692    /// re-resolved the `token_secret` env var on EVERY `execute` call. Caching
693    /// it here means the secret is resolved a single time (at construction /
694    /// builder time) — a removed/rotated env var after startup no longer breaks
695    /// in-flight requests, and a bad secret still fails fast at builder time.
696    pipeline: Arc<ValidationPipeline>,
697}
698
699impl SqlCodeExecutor {
700    /// Construct an executor over `connector`, enforcing the `[code_mode]`
701    /// policy carried by `config` on every [`SqlCodeExecutor::execute`] call.
702    ///
703    /// The [`ValidationPipeline`] is built ONCE here (85-10 IN-01) via
704    /// [`validation_pipeline_from_config`], so the `token_secret` env var is
705    /// resolved a single time at construction rather than on every request.
706    ///
707    /// # Errors
708    ///
709    /// Returns every error from [`validation_pipeline_from_config`] — most
710    /// notably the R9 inline-secret rejection and the secret-resolution /
711    /// 16-byte-minimum failures — so a misconfigured `token_secret` fails at
712    /// builder time, not first request.
713    pub fn new(connector: Arc<dyn SqlConnector>, config: ServerConfig) -> Result<Self> {
714        let pipeline = Arc::new(validation_pipeline_from_config(&config)?);
715        Ok(Self {
716            connector,
717            pipeline,
718        })
719    }
720
721    /// Defense-in-depth re-validation of `code` against the `[code_mode]`
722    /// policy (threat T-85-02-01). Returns `Err` BEFORE any connector call when
723    /// the statement violates the static policy (e.g. a DELETE under
724    /// `allow_deletes = false`) or fails to parse.
725    ///
726    /// Reuses the cached [`SqlCodeExecutor::pipeline`] (85-10 IN-01) — it does
727    /// NOT rebuild the pipeline or re-read the `token_secret` env var per call.
728    fn revalidate(&self, code: &str) -> std::result::Result<(), ExecutionError> {
729        let ctx = ValidationContext::new(
730            "code-mode-executor",
731            "code-mode-session",
732            "schema-hash",
733            "perms-hash",
734        );
735        let result = self
736            .pipeline
737            .validate_sql_query(code, &ctx)
738            .map_err(|e| ExecutionError::BackendError(format!("SQL validation failed: {e}")))?;
739        if !result.is_valid {
740            return Err(ExecutionError::BackendError(
741                "SQL rejected by [code_mode] policy on re-validation".to_string(),
742            ));
743        }
744        Ok(())
745    }
746}
747
748/// Convert the `execute_code` `variables` input (a JSON object of name→value)
749/// into the `(name, value)` pairs [`SqlConnector::execute`] binds (85-10
750/// WR-02). A leading `:` on a key is stripped so callers may send either
751/// `{":name": ...}` or `{"name": ...}` — the connector's
752/// `translate_placeholders` keys params WITHOUT the `:` (matching
753/// [`extract_named_params`](crate::tools)). `None` or a non-object value yields
754/// an empty slice, so the parity `execute_code` scenario (passes `None`) is
755/// unaffected.
756fn variables_to_params(variables: Option<&serde_json::Value>) -> Vec<(String, serde_json::Value)> {
757    let Some(serde_json::Value::Object(map)) = variables else {
758        return Vec::new();
759    };
760    map.iter()
761        .map(|(k, v)| {
762            let key = k.strip_prefix(':').unwrap_or(k).to_string();
763            (key, v.clone())
764        })
765        .collect()
766}
767
768#[pmcp_code_mode::async_trait]
769impl CodeExecutor for SqlCodeExecutor {
770    /// Re-validate the SQL against the `[code_mode]` policy, then execute it via
771    /// the single-method [`SqlConnector::execute`].
772    ///
773    /// # Errors
774    ///
775    /// Returns [`ExecutionError::BackendError`] when re-validation rejects the
776    /// statement (policy violation or parse failure) or when the connector
777    /// surfaces a [`crate::sql::ConnectorError`]. Connector error messages are
778    /// surfaced verbatim from the toolkit's already-sanitized
779    /// `ConnectorError` Display (T-84-01-01 / threat T-85-02-04) — no raw
780    /// backend credentials are echoed.
781    async fn execute(
782        &self,
783        code: &str,
784        variables: Option<&serde_json::Value>,
785    ) -> std::result::Result<serde_json::Value, ExecutionError> {
786        // (1) Defense-in-depth re-validation BEFORE the connector is reached.
787        self.revalidate(code)?;
788        // (2) Honor the schema-advertised `variables` input by BINDING it as
789        //     named params (85-10 WR-02 / threat T-85-10-01) — never a silent
790        //     drop. A `None` / absent map yields `&[]`, so the parity scenario
791        //     (passes None) is unaffected. Binding (not string interpolation)
792        //     preserves parameterized-query safety.
793        let params = variables_to_params(variables);
794        let rows =
795            self.connector.execute(code, &params).await.map_err(|e| {
796                ExecutionError::BackendError(format!("connector execute failed: {e}"))
797            })?;
798        // (3) Mirror production's observable `"rows"` key (REVIEW FIX #6b).
799        Ok(serde_json::json!({ "rows": rows }))
800    }
801}
802
803// =============================================================================
804// OAPI-05 — HttpCodeExecutor (Plan 90-04 Task 1 / H1 / H2)
805// =============================================================================
806
807/// Low-level HTTP executor bridging the toolkit's outbound
808/// [`HttpAuthProvider`](crate::http::auth::HttpAuthProvider) to pmcp-code-mode's
809/// [`HttpExecutor`](pmcp_code_mode::HttpExecutor) trait.
810///
811/// This is the OpenAPI analog of [`SqlCodeExecutor`], but at a DIFFERENT layer:
812/// it impls the LOW-LEVEL `pmcp_code_mode::HttpExecutor`
813/// (`execute_request(method, path, body)`), NOT the high-level
814/// [`CodeExecutor`]. It is wrapped by a
815/// [`JsCodeExecutor`](pmcp_code_mode::JsCodeExecutor) for the Code Mode path
816/// (the `JsCodeExecutor<HttpCodeExecutor>: CodeExecutor` blanket impl) and is
817/// called directly by script tools (Plan 05). The single-call synthesizer
818/// (Plan 03) does NOT use this path — it calls `HttpConnector::execute`
819/// directly.
820///
821/// # Per-request passthrough token (H1)
822///
823/// The `inbound_token` field carries the per-request MCP client token captured
824/// by the binary (Plan 06) into [`AuthContext`]. It is passed to
825/// [`HttpAuthProvider::apply`](crate::http::auth::HttpAuthProvider::apply) so an
826/// [`OAuthPassthroughAuth`](crate::http::auth::OAuthPassthroughAuth) provider
827/// forwards it to the backend; static providers ignore it (proven in Plan 01).
828/// Because Code Mode reuses ONE executor instance across requests, the binary
829/// produces a per-request clone carrying the captured token via
830/// [`HttpCodeExecutor::with_inbound_token`].
831///
832/// # Redaction (Pitfall 5 / T-90-04-01)
833///
834/// Auth/transport failures are mapped to
835/// [`ExecutionError::RuntimeError`](pmcp_code_mode::ExecutionError::RuntimeError)
836/// whose message names the operation / status only — it NEVER echoes the
837/// request URL or the `Authorization` token.
838///
839/// # Feature gate (H2)
840///
841/// Gated under `openapi-code-mode` (the Plan 90-01 umbrella that forwards
842/// `pmcp-code-mode/js-runtime`). The bare `code-mode` feature does NOT bring
843/// `HttpExecutor` into scope, so this type cannot be gated on
844/// `all(feature = "http", feature = "code-mode")`.
845#[cfg(feature = "openapi-code-mode")]
846#[derive(Clone)]
847pub struct HttpCodeExecutor {
848    client: reqwest::Client,
849    base_url: String,
850    auth: Arc<dyn crate::http::auth::HttpAuthProvider>,
851    /// Per-request captured MCP client token for `oauth_passthrough` (H1).
852    /// `None` for the static-auth path; set per request via
853    /// [`HttpCodeExecutor::with_inbound_token`].
854    inbound_token: Option<String>,
855}
856
857#[cfg(feature = "openapi-code-mode")]
858impl HttpCodeExecutor {
859    /// Construct an executor over `client` + `base_url`, authenticating outgoing
860    /// requests via `auth`. The per-request `inbound_token` starts `None`;
861    /// the binary attaches it per request with
862    /// [`HttpCodeExecutor::with_inbound_token`].
863    #[must_use]
864    pub fn new(
865        client: reqwest::Client,
866        base_url: String,
867        auth: Arc<dyn crate::http::auth::HttpAuthProvider>,
868    ) -> Self {
869        Self {
870            client,
871            base_url,
872            auth,
873            inbound_token: None,
874        }
875    }
876
877    /// Cheap clone-with-token builder (H1): the binary calls this PER REQUEST to
878    /// attach the captured inbound MCP token so an `oauth_passthrough` provider
879    /// forwards it. Static providers ignore the token, so calling this on a
880    /// static-auth executor is harmless.
881    ///
882    /// Single-call tools (Plan 03) don't use this path; the per-request token
883    /// flows through Code Mode + script tools only.
884    #[must_use]
885    pub fn with_inbound_token(mut self, token: Option<String>) -> Self {
886        self.inbound_token = token;
887        self
888    }
889
890    /// Test-only accessor for the per-request captured token, so unit tests can
891    /// assert [`request_executor_from_extra`] threads the inbound token (the
892    /// field is otherwise private — Plan 90-10).
893    #[cfg(test)]
894    pub(crate) fn inbound_token_for_test(&self) -> Option<&str> {
895        self.inbound_token.as_deref()
896    }
897
898    /// Substitute `{key}` path-template segments from `body` keys, returning the
899    /// resolved path and the remaining (non-path) body fields.
900    ///
901    /// Lifted from the pmcp-run reference `execute_request` (kept a free helper
902    /// so the trait method stays under the cog ≤25 budget).
903    ///
904    /// # Errors
905    ///
906    /// Returns [`ExecutionError::RuntimeError`] naming the offending key when a
907    /// `{key}` path value is a non-scalar (`Object`/`Array`) — see
908    /// [`HttpCodeExecutor::scalar_str`] for the decided rule (WR-03 / GAP 4).
909    fn resolve_path(
910        path: &str,
911        body: &Option<serde_json::Value>,
912    ) -> std::result::Result<(String, Option<serde_json::Value>), ExecutionError> {
913        let mut resolved_path = path.to_string();
914        let remaining = if let Some(serde_json::Value::Object(obj)) = body {
915            let mut remaining = serde_json::Map::new();
916            for (key, value) in obj {
917                let placeholder = format!("{{{key}}}");
918                if resolved_path.contains(&placeholder) {
919                    resolved_path =
920                        resolved_path.replace(&placeholder, &Self::scalar_str(key, value)?);
921                } else {
922                    remaining.insert(key.clone(), value.clone());
923                }
924            }
925            if remaining.is_empty() {
926                None
927            } else {
928                Some(serde_json::Value::Object(remaining))
929            }
930        } else {
931            body.clone()
932        };
933        Ok((resolved_path, remaining))
934    }
935
936    /// Render a JSON scalar for path / query substitution (strings unquoted),
937    /// REJECTING non-scalar values (WR-03 / GAP 4).
938    ///
939    /// This is the `code_mode` counterpart of [`crate::http::client`]'s
940    /// `render_scalar`; both HTTP surfaces apply the SAME decided rule. Because
941    /// the `Parameter` model carries no OpenAPI `style`/`explode`/`type` hint,
942    /// the rule is uniform: a scalar (`String`, `Number`, `Bool`, `Null`)
943    /// renders to a bare string (`Null` → `"null"`, preserving prior behavior);
944    /// an `Object` or `Array` in a `{path}` substitution or a GET-query field is
945    /// rejected rather than silently JSON-stringified into the URL.
946    ///
947    /// # Errors
948    ///
949    /// Returns [`ExecutionError::RuntimeError`] naming `key` when `value` is a
950    /// non-scalar. Per Pitfall 5 the message names the KEY only — never the value.
951    fn scalar_str(
952        key: &str,
953        value: &serde_json::Value,
954    ) -> std::result::Result<String, ExecutionError> {
955        match value {
956            serde_json::Value::String(s) => Ok(s.clone()),
957            serde_json::Value::Null => Ok("null".to_string()),
958            serde_json::Value::Number(n) => Ok(n.to_string()),
959            serde_json::Value::Bool(b) => Ok(b.to_string()),
960            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
961                Err(ExecutionError::RuntimeError {
962                    message: format!("path/query param '{key}' must be a scalar"),
963                })
964            },
965        }
966    }
967}
968
969#[cfg(feature = "openapi-code-mode")]
970#[pmcp_code_mode::async_trait]
971impl pmcp_code_mode::HttpExecutor for HttpCodeExecutor {
972    async fn execute_request(
973        &self,
974        method: &str,
975        path: &str,
976        body: Option<serde_json::Value>,
977    ) -> std::result::Result<serde_json::Value, ExecutionError> {
978        let upper = method.to_uppercase();
979        let is_get_like = matches!(upper.as_str(), "GET" | "HEAD" | "OPTIONS");
980
981        // (1) Path-param substitution from the body object. A non-scalar `{key}`
982        //     value is rejected (WR-03) rather than JSON-stringified into the URL.
983        let (resolved_path, remaining_body) = Self::resolve_path(path, &body)?;
984
985        // (2) Shared join_url helper (Pitfall 2 — preserves an API-Gateway
986        //     stage prefix; it does NOT use the RFC-3986 path-replacing join).
987        //     join_url does the base+path CONCAT; we still parse the result to
988        //     append query pairs because reqwest 0.13 gates
989        //     RequestBuilder::query behind a `query` feature the toolkit
990        //     deliberately does not enable (Plan 01 Rule 1).
991        let url = crate::http::join_url(&self.base_url, &resolved_path);
992
993        // (3) Apply auth, threading the per-request inbound token (H1). Auth
994        //     failures map to a RuntimeError WITHOUT echoing URL/token
995        //     (Pitfall 5 / T-90-04-01).
996        let mut headers = reqwest::header::HeaderMap::new();
997        let mut auth_query: std::collections::HashMap<String, String> =
998            std::collections::HashMap::new();
999        self.auth
1000            .apply(&mut headers, &mut auth_query, self.inbound_token.as_deref())
1001            .await
1002            .map_err(|_| ExecutionError::RuntimeError {
1003                message: "authentication failed for outgoing request".to_string(),
1004            })?;
1005
1006        let mut query_params: Vec<(String, String)> = auth_query.into_iter().collect();
1007
1008        // (4) For GET-like requests, serialize remaining body fields as query
1009        //     params; otherwise keep them as the JSON body.
1010        let request_body = if is_get_like {
1011            if let Some(serde_json::Value::Object(obj)) = &remaining_body {
1012                for (key, value) in obj {
1013                    // A non-scalar GET-query value is rejected (WR-03) rather than
1014                    // silently JSON-stringified into the URL.
1015                    query_params.push((key.clone(), Self::scalar_str(key, value)?));
1016                }
1017            }
1018            None
1019        } else {
1020            remaining_body
1021        };
1022
1023        // Append query params via url::Url (reqwest 0.13's RequestBuilder::query
1024        // is behind the off-by-default `query` feature; Plan 01 Rule 1).
1025        let final_url = if query_params.is_empty() {
1026            url
1027        } else {
1028            let mut parsed = url::Url::parse(&url).map_err(|_| ExecutionError::RuntimeError {
1029                message: "could not construct the request URL".to_string(),
1030            })?;
1031            {
1032                let mut pairs = parsed.query_pairs_mut();
1033                for (k, v) in &query_params {
1034                    pairs.append_pair(k, v);
1035                }
1036            }
1037            parsed.to_string()
1038        };
1039
1040        let mut request = match upper.as_str() {
1041            "GET" => self.client.get(&final_url),
1042            "POST" => self.client.post(&final_url),
1043            "PUT" => self.client.put(&final_url),
1044            "DELETE" => self.client.delete(&final_url),
1045            "PATCH" => self.client.patch(&final_url),
1046            "HEAD" => self.client.head(&final_url),
1047            _ => {
1048                return Err(ExecutionError::RuntimeError {
1049                    message: "unsupported HTTP method".to_string(),
1050                })
1051            },
1052        };
1053        request = request.headers(headers);
1054        if let Some(b) = request_body {
1055            request = request.header("Content-Type", "application/json").json(&b);
1056        }
1057
1058        // (5) Send + read. Transport / status / parse errors NEVER echo the URL
1059        //     or token (Pitfall 5).
1060        let response = request
1061            .send()
1062            .await
1063            .map_err(|_| ExecutionError::RuntimeError {
1064                message: "outgoing HTTP request failed".to_string(),
1065            })?;
1066        let status = response.status();
1067        let text = response
1068            .text()
1069            .await
1070            .map_err(|_| ExecutionError::RuntimeError {
1071                message: "failed to read response body".to_string(),
1072            })?;
1073        if !status.is_success() {
1074            return Err(ExecutionError::RuntimeError {
1075                message: format!("backend returned HTTP status {}", status.as_u16()),
1076            });
1077        }
1078        if text.is_empty() {
1079            return Ok(serde_json::Value::Null);
1080        }
1081        serde_json::from_str(&text).map_err(|_| ExecutionError::RuntimeError {
1082            message: "failed to parse response body as JSON".to_string(),
1083        })
1084    }
1085}
1086
1087// =============================================================================
1088// Helpers (Pattern G — cog ≤25 each, kept small + explicit)
1089// =============================================================================
1090
1091/// Translate unprefixed toolkit [`CodeModeSection`] fields into pmcp-code-mode's
1092/// `sql_`-prefixed [`CodeModeConfig`].
1093///
1094/// Mapping is **explicit field-by-field** (PATTERNS §10 + D-13). Silent serde
1095/// aliasing would couple the toolkit's stable surface to pmcp-code-mode's
1096/// internal field names — undesirable. Fields on `CodeModeSection` without a
1097/// `CodeModeConfig` counterpart are noted in inline comments rather than
1098/// silently dropped (review R1 + threat T-83-06-04).
1099fn build_cm_config(section: &CodeModeSection) -> CodeModeConfig {
1100    let mut cfg = CodeModeConfig {
1101        enabled: section.enabled,
1102        // SQL policy bits — toolkit's unprefixed names → pmcp_code_mode's sql_-prefixed.
1103        sql_allow_writes: section.allow_writes,
1104        sql_allow_deletes: section.allow_deletes,
1105        sql_allow_ddl: section.allow_ddl,
1106        sql_blocked_tables: section.blocked_tables.iter().cloned().collect(),
1107        sql_blocked_columns: section.sensitive_columns.iter().cloned().collect(),
1108        ..CodeModeConfig::default()
1109    };
1110    if let Some(ref sid) = section.server_id {
1111        cfg.server_id = Some(sid.clone());
1112    }
1113    // Token TTL — both sides use seconds, but pmcp_code_mode uses i64 and the
1114    // toolkit uses Option<u64>. Saturate to i64::MAX rather than wrap.
1115    if let Some(ttl) = section.token_ttl_seconds {
1116        cfg.token_ttl_seconds = i64::try_from(ttl).unwrap_or(i64::MAX);
1117    }
1118    // Auto-approval — toolkit ships risk-level names as strings; the
1119    // pmcp_code_mode side wants RiskLevel enums. Best-effort parse; unrecognised
1120    // entries are silently skipped (operator typos surface as "nothing auto-
1121    // approved" rather than a parse error — by design, since the registry is
1122    // open-ended).
1123    map_auto_approve_levels(&section.auto_approve_levels, &mut cfg);
1124    // `max_limit` (toolkit) corresponds to `sql_max_rows` (pmcp_code_mode).
1125    if let Some(max) = section.max_limit {
1126        cfg.sql_max_rows = max;
1127    }
1128    // `require_limit` (toolkit) → `sql_require_limit` (pmcp_code_mode). Enforced
1129    // in check_sql_config_authorization: a read-only statement without a LIMIT
1130    // is rejected when this is set (closes VERIFICATION Gap 1 — previously this
1131    // field was parsed but discarded, so a low-row no-LIMIT SELECT was accepted
1132    // despite require_limit=true).
1133    cfg.sql_require_limit = section.require_limit;
1134    // [code_mode.limits] — pmcp_code_mode's CodeModeConfig has `max_depth` and
1135    // `max_field_count` (GraphQL-flavoured) but no direct counterparts for
1136    // `max_tables_per_query` / `max_join_depth` / `max_subquery_depth`. These
1137    // toolkit fields are exposed for forward compatibility with Phase 84's
1138    // SQL connector enforcement; they are NOT silently mapped here.
1139    if let Some(ref limits) = section.limits {
1140        let _gap_max_tables = limits.max_tables_per_query;
1141        let _gap_max_join = limits.max_join_depth;
1142        let _gap_max_subquery = limits.max_subquery_depth;
1143    }
1144    cfg
1145}
1146
1147/// Decompose auto-approve-level parsing to keep [`build_cm_config`] under
1148/// Pattern G's cog ≤25 budget.
1149fn map_auto_approve_levels(levels: &[String], cfg: &mut CodeModeConfig) {
1150    use pmcp_code_mode::RiskLevel;
1151    let mut out = Vec::with_capacity(levels.len());
1152    for level in levels {
1153        match level.to_ascii_lowercase().as_str() {
1154            "low" => out.push(RiskLevel::Low),
1155            "medium" => out.push(RiskLevel::Medium),
1156            "high" => out.push(RiskLevel::High),
1157            "critical" => out.push(RiskLevel::Critical),
1158            _ => {
1159                tracing::debug!(
1160                    target: "pmcp_server_toolkit::code_mode",
1161                    "[code_mode] auto_approve_levels: unrecognised level '{}' — skipping",
1162                    level
1163                );
1164            },
1165        }
1166    }
1167    if !out.is_empty() {
1168        cfg.auto_approve_levels = out;
1169    }
1170}
1171
1172/// Per review R9: `token_secret` is `env:`- or `${VAR}`-only by default. Inline
1173/// literals are REJECTED at config-validation time unless
1174/// `allow_inline_token_secret_for_dev` is set. Returns the resolved bytes
1175/// wrapped in the toolkit-owned [`SecretValue`] (per review R6).
1176///
1177/// Accepted forms:
1178/// - `token_secret = "env:VAR_NAME"` — reads `VAR_NAME` from the process env.
1179/// - `token_secret = "${VAR_NAME}"` — reads `VAR_NAME` from the process env
1180///   (the form every reference SQL-API config emits, Plan 85-01 Gap #3).
1181/// - `token_secret = "raw-string"` — REJECTED unless
1182///   `allow_inline_token_secret_for_dev = true`.
1183///
1184/// A missing/unset env var (either form) returns
1185/// [`ToolkitError::CodeMode`] — never a panic, never a fall-back to a weak or
1186/// empty secret (threat-model item T-85-01-01).
1187/// Read `var` from the process env for `token_secret`, treating a missing OR
1188/// set-but-empty/whitespace value as UNSET (85-10 secondary fix, threat
1189/// T-85-10-03).
1190///
1191/// `HmacTokenGenerator` enforces a 16-byte minimum downstream, but an empty
1192/// (or all-whitespace) env value should surface as a clear "set but empty"
1193/// configuration error at startup — never flow to the HMAC layer as a
1194/// degenerate secret. Both the `env:VAR` and `${VAR}` forms route through here.
1195fn resolve_secret_env_var(var: &str) -> Result<SecretValue> {
1196    let value = std::env::var(var)
1197        .map_err(|_| ToolkitError::CodeMode(format!("env var '{var}' not set for token_secret")))?;
1198    if value.trim().is_empty() {
1199        return Err(ToolkitError::CodeMode(format!(
1200            "env var '{var}' is set but empty for token_secret"
1201        )));
1202    }
1203    Ok(SecretValue::new(value.into_bytes()))
1204}
1205
1206fn resolve_token_secret(section: &CodeModeSection) -> Result<SecretValue> {
1207    let raw = section.token_secret.as_ref().ok_or_else(|| {
1208        ToolkitError::CodeMode(
1209            "[code_mode] token_secret is required when code-mode is enabled".to_string(),
1210        )
1211    })?;
1212    // Both reference forms (`env:VAR` and `${VAR}`) are parsed by the ONE
1213    // toolkit-wide grammar chokepoint (Phase 120 Plan 04 Task 2). This module
1214    // previously carried its own `expand_braced_var`; a second `${}` parser with
1215    // slightly different edge cases is a latent security bug, so the grammar is
1216    // now single-sourced and only the RESOLUTION policy stays local (error on
1217    // unset, never a fall-back to a weak or empty secret — T-85-01-01).
1218    //
1219    // A string that merely *contains* `${` (e.g. an Athena `output_location`
1220    // substring) is still NOT a reference — `parse_env_ref` requires the exact
1221    // `${...}` shape — so it falls through to the inline-secret handling below
1222    // and stays rejected unless the dev flag is set (R9 / REVIEW FIX #6).
1223    match crate::env_ref::parse_env_ref(raw) {
1224        // A MALFORMED reference: the empty `${}`, or a `${NAME}` whose NAME is
1225        // not portably settable (`${MY-SECRET}`, `${a.b}`), or a
1226        // multi-placeholder composition. The grammar maps all of them to the
1227        // empty name, and `resolve_secret_env_var("")` would report
1228        // `env var '' not set for token_secret` — a message that names neither
1229        // what the operator wrote nor what to do about it. Say the actual thing
1230        // instead, and point at the `env:` escape hatch, which keeps its
1231        // any-non-empty-remainder rule precisely for exotic names.
1232        Some("") => {
1233            return Err(ToolkitError::CodeMode(
1234                "[code_mode] token_secret is a malformed environment reference; a `${VAR}` \
1235                 reference must name exactly ONE variable matching [A-Za-z0-9_]+ and nothing \
1236                 else. For a name outside that set, use the `env:NAME` form, which accepts any \
1237                 non-empty name. (The value is not echoed here.)"
1238                    .to_string(),
1239            ))
1240        },
1241        Some(var) => return resolve_secret_env_var(var),
1242        None => {},
1243    }
1244    if section.allow_inline_token_secret_for_dev {
1245        tracing::warn!(
1246            target: "pmcp_server_toolkit::code_mode",
1247            "[code_mode] token_secret is inline AND allow_inline_token_secret_for_dev=true; \
1248             accepting under dev/test exception — NEVER set this flag in a committed \
1249             production config"
1250        );
1251        return Ok(SecretValue::new(raw.as_bytes().to_vec()));
1252    }
1253    Err(ToolkitError::Validation(
1254        ConfigValidationError::InlineSecretRejected,
1255    ))
1256}
1257
1258// =============================================================================
1259// TKIT-10 — assemble_code_mode_prompt (D-12 / review R2)
1260// =============================================================================
1261
1262/// TKIT-10: assemble the code-mode bootstrap prompt body from a connector's
1263/// [`SqlConnector::schema_text`] + curated `[[database.tables]]` descriptions.
1264///
1265/// Per Phase 83 review R2 (BOTH reviewers HIGH severity), this function calls
1266/// ONLY [`SqlConnector::schema_text`] — never `execute()`, which is deferred
1267/// to Phase 84. Dialect-aware placeholder GUIDANCE is included even though
1268/// `translate_placeholders` is deferred, because the LLM still benefits from
1269/// knowing the eventual binding shape.
1270///
1271/// # Output structure
1272///
1273/// ```text
1274/// # Code Mode — {dialect.name()}
1275///
1276/// {dialect.placeholder_guidance()}
1277///
1278/// ## Schema
1279///
1280/// {connector.schema_text()}
1281///
1282/// ## Curated Tables
1283///
1284/// - `table_a`: description A
1285/// - `table_b`: description B
1286/// ```
1287///
1288/// The "Curated Tables" section is omitted entirely when
1289/// `config.database.tables` is empty OR every entry has no `description`.
1290/// Entries with `description = None` are skipped individually.
1291///
1292/// # Errors
1293///
1294/// Returns [`ToolkitError::CodeMode`] if `connector.schema_text()` fails.
1295/// The toolkit does not retry; callers should ensure the connector is ready
1296/// before assembling.
1297///
1298/// # Example
1299///
1300/// ```no_run
1301/// use pmcp_server_toolkit::code_mode::assemble_code_mode_prompt;
1302/// use pmcp_server_toolkit::config::ServerConfig;
1303/// use pmcp_server_toolkit::sql::SqlConnector;
1304///
1305/// async fn assemble<C: SqlConnector>(connector: &C, config: &ServerConfig) {
1306///     let prompt = assemble_code_mode_prompt(connector, config).await.unwrap();
1307///     assert!(prompt.contains("# Code Mode"));
1308/// }
1309/// ```
1310pub async fn assemble_code_mode_prompt(
1311    connector: &(dyn SqlConnector + '_),
1312    config: &ServerConfig,
1313) -> Result<String> {
1314    let dialect = connector.dialect();
1315    let schema_text = connector
1316        .schema_text()
1317        .await
1318        .map_err(|e| ToolkitError::CodeMode(format!("schema_text failed: {e}")))?;
1319
1320    let curated = format_curated_tables(config);
1321
1322    let mut out = String::with_capacity(schema_text.len() + curated.len() + 256);
1323    out.push_str("# Code Mode — ");
1324    out.push_str(dialect.name());
1325    out.push_str("\n\n");
1326    out.push_str(dialect.placeholder_guidance());
1327    out.push_str("\n\n## Schema\n\n");
1328    out.push_str(&schema_text);
1329    if !curated.is_empty() {
1330        out.push_str("\n\n## Curated Tables\n\n");
1331        out.push_str(&curated);
1332    }
1333    out.push('\n');
1334    Ok(out)
1335}
1336
1337/// Alias for [`assemble_code_mode_prompt`] satisfying CONN-04's literal naming.
1338///
1339/// Identical behavior; both names are valid public surface. Per Phase 84 D-12 +
1340/// RESEARCH §"Open Questions" Q2 / Landmine #15 the recommendation is an
1341/// alias-next-to (no deprecation attribute on either name), matching the P83
1342/// dual-naming precedent (`register_code_mode_tools` vs
1343/// `code_mode_tools_from_executor`).
1344///
1345/// # Errors
1346///
1347/// Returns [`ToolkitError::CodeMode`] if `connector.schema_text()` fails —
1348/// surfaced verbatim from [`assemble_code_mode_prompt`].
1349///
1350/// # Example
1351///
1352/// ```no_run
1353/// use pmcp_server_toolkit::code_mode::build_code_mode_prompt;
1354/// use pmcp_server_toolkit::config::ServerConfig;
1355/// use pmcp_server_toolkit::sql::SqlConnector;
1356///
1357/// async fn assemble<C: SqlConnector>(connector: &C, config: &ServerConfig) {
1358///     let prompt = build_code_mode_prompt(connector, config).await.unwrap();
1359///     assert!(prompt.contains("# Code Mode"));
1360/// }
1361/// ```
1362pub async fn build_code_mode_prompt(
1363    connector: &(dyn SqlConnector + '_),
1364    config: &ServerConfig,
1365) -> Result<String> {
1366    assemble_code_mode_prompt(connector, config).await
1367}
1368
1369/// File-based counterpart to [`assemble_code_mode_prompt`] — assemble the
1370/// code-mode prompt body from a `--schema` file's text WITHOUT any live
1371/// connector introspection (Plan 85-02 Task 3 / D-04 / D-05).
1372///
1373/// This is a SYNC fn taking the [`Dialect`] + the already-loaded `schema_text`
1374/// directly, so it can NEVER trigger a [`SqlConnector::schema_text`] round-trip.
1375/// For lazy / network-backed non-SQLite connectors that matters: the
1376/// connector-based [`assemble_code_mode_prompt`] would hit the network at prompt
1377/// time (breaking SC-1), and it would surface the LIVE schema rather than the
1378/// admin-redacted `--schema` file. Routing the `--schema` file content through
1379/// THIS helper makes the file the single source of truth — what's in the file
1380/// is exactly what the client sees (the D-05 redaction guarantee).
1381///
1382/// # Output structure
1383///
1384/// Mirrors [`assemble_code_mode_prompt`] except the schema block is preceded by
1385/// a `# Database Schema` header (REVIEW FIX — Gemini LOW, folded here per D-05;
1386/// the header text is kept identical to the resource-surface
1387/// `merge_schema_resource` helper Plan 05 uses, so prompt + resource parity
1388/// holds):
1389///
1390/// ```text
1391/// # Code Mode — {dialect.name()}
1392///
1393/// {dialect.placeholder_guidance()}
1394///
1395/// ## Schema
1396///
1397/// # Database Schema
1398///
1399/// {schema_text}
1400///
1401/// ## Curated Tables
1402///
1403/// - `table_a`: description A
1404/// ```
1405///
1406/// An empty `schema_text` still produces a valid (non-panicking) prompt with
1407/// the `# Code Mode` header present.
1408#[must_use]
1409pub fn assemble_code_mode_prompt_with_schema(
1410    schema_text: &str,
1411    dialect: Dialect,
1412    config: &ServerConfig,
1413) -> String {
1414    const SCHEMA_HEADER: &str = "# Database Schema\n\n";
1415
1416    let curated = format_curated_tables(config);
1417
1418    let mut out = String::with_capacity(schema_text.len() + curated.len() + 256);
1419    out.push_str("# Code Mode — ");
1420    out.push_str(dialect.name());
1421    out.push_str("\n\n");
1422    out.push_str(dialect.placeholder_guidance());
1423    out.push_str("\n\n## Schema\n\n");
1424    out.push_str(SCHEMA_HEADER);
1425    out.push_str(schema_text);
1426    if !curated.is_empty() {
1427        out.push_str("\n\n## Curated Tables\n\n");
1428        out.push_str(&curated);
1429    }
1430    out.push('\n');
1431    out
1432}
1433
1434/// Format the `[[database.tables]]` curated descriptions as a Markdown list.
1435///
1436/// Entries with no `description` are skipped. Returns an empty string when no
1437/// described entries exist; callers use that as the signal to omit the whole
1438/// "Curated Tables" section (keeping the prompt body tight).
1439fn format_curated_tables(config: &ServerConfig) -> String {
1440    config
1441        .database
1442        .tables
1443        .iter()
1444        .filter_map(|t| {
1445            t.description
1446                .as_deref()
1447                .filter(|d| !d.is_empty())
1448                .map(|d| format!("- `{}`: {}", t.name, d))
1449        })
1450        .collect::<Vec<_>>()
1451        .join("\n")
1452}
1453
1454// =============================================================================
1455// Unit tests
1456// =============================================================================
1457
1458/// Process-global lock serializing every test that reads or mutates the shared
1459/// process environment via `std::env::{set_var, remove_var}`.
1460///
1461/// Those calls are process-global and not thread-safe, so under the default
1462/// multi-threaded test runner the env-touching tests in this file's `tests` and
1463/// `sql_code_executor_tests` modules otherwise interleave and corrupt each
1464/// other's variables (e.g. an executor build fails to read the `TEST_SECRET_VAR`
1465/// it just set). Acquire the guard around each synchronous env-op group; NEVER
1466/// hold it across an `.await` (the `std` `MutexGuard` is `!Send`, and tokio's
1467/// multi-thread runtime requires the test future to be `Send`).
1468#[cfg(test)]
1469mod test_env_guard {
1470    use std::sync::{Mutex, MutexGuard};
1471
1472    static ENV_LOCK: Mutex<()> = Mutex::new(());
1473
1474    /// Lock the process-env mutex, recovering from poisoning so a panicking
1475    /// test does not cascade-fail its siblings.
1476    pub(super) fn lock() -> MutexGuard<'static, ()> {
1477        ENV_LOCK
1478            .lock()
1479            .unwrap_or_else(|poisoned| poisoned.into_inner())
1480    }
1481}
1482
1483#[cfg(test)]
1484mod tests {
1485    use super::*;
1486    use crate::config::{CodeModeLimits, CodeModeSection};
1487
1488    /// Compile-only assertion that the headline re-exports resolve at the
1489    /// `code_mode::*` path (TKIT-06 + D-16 + R3).
1490    #[allow(dead_code)]
1491    const _RE_EXPORTS_COMPILE: fn() = || {
1492        let _: Option<Box<dyn CodeExecutor>> = None;
1493        let _: Option<Box<dyn PolicyEvaluator>> = None;
1494        let _: Option<ApprovalToken> = None;
1495        let _: Option<HmacTokenGenerator> = None;
1496        let _: Option<TokenSecret> = None;
1497        let _: Option<NoopPolicyEvaluator> = None;
1498        let _: Option<ValidationPipeline> = None;
1499        let _: Option<ValidationContext> = None;
1500        let _: Option<CodeModeConfig> = None;
1501        let _: Option<AuthorizationDecision> = None;
1502        let _hash = canonicalize_code;
1503        let _ctx = compute_context_hash;
1504        let _h = hash_code;
1505    };
1506
1507    /// Lightweight test fixture: a `CodeModeSection` with all required fields
1508    /// populated for env-style secret resolution.
1509    fn env_section(var: &str) -> CodeModeSection {
1510        CodeModeSection {
1511            enabled: true,
1512            server_id: Some("test-server".to_string()),
1513            allow_writes: false,
1514            allow_deletes: false,
1515            allow_ddl: false,
1516            require_limit: false,
1517            max_limit: Some(1000),
1518            blocked_tables: vec![],
1519            sensitive_columns: vec![],
1520            auto_approve_levels: vec!["low".to_string()],
1521            token_ttl_seconds: Some(300),
1522            token_secret: Some(format!("env:{var}")),
1523            allow_inline_token_secret_for_dev: false,
1524            limits: Some(CodeModeLimits {
1525                max_tables_per_query: Some(5),
1526                max_join_depth: Some(3),
1527                max_subquery_depth: Some(2),
1528            }),
1529        }
1530    }
1531
1532    #[test]
1533    fn build_cm_config_maps_allow_writes() {
1534        let mut section = env_section("UNUSED");
1535        section.allow_writes = true;
1536        let cfg = build_cm_config(&section);
1537        assert!(
1538            cfg.sql_allow_writes,
1539            "unprefixed allow_writes=true must map to sql_allow_writes=true"
1540        );
1541        assert!(cfg.enabled);
1542        assert_eq!(cfg.server_id.as_deref(), Some("test-server"));
1543        // max_limit → sql_max_rows
1544        assert_eq!(cfg.sql_max_rows, 1000);
1545        // token_ttl_seconds → i64
1546        assert_eq!(cfg.token_ttl_seconds, 300);
1547    }
1548
1549    #[test]
1550    fn build_cm_config_maps_require_limit_true() {
1551        // VERIFICATION Gap 1: toolkit `require_limit` must flow to the enforced
1552        // pmcp-code-mode `sql_require_limit` (previously discarded).
1553        let mut section = env_section("UNUSED");
1554        section.require_limit = true;
1555        let cfg = build_cm_config(&section);
1556        assert!(
1557            cfg.sql_require_limit,
1558            "require_limit=true must map to sql_require_limit=true"
1559        );
1560    }
1561
1562    #[test]
1563    fn build_cm_config_maps_require_limit_false() {
1564        let mut section = env_section("UNUSED");
1565        section.require_limit = false;
1566        let cfg = build_cm_config(&section);
1567        assert!(
1568            !cfg.sql_require_limit,
1569            "require_limit=false must map to sql_require_limit=false"
1570        );
1571    }
1572
1573    #[test]
1574    fn build_cm_config_propagates_blocked_tables() {
1575        let mut section = env_section("UNUSED");
1576        section.blocked_tables = vec!["users".into(), "secrets".into()];
1577        section.sensitive_columns = vec!["users.password".into()];
1578        let cfg = build_cm_config(&section);
1579        assert!(cfg.sql_blocked_tables.contains("users"));
1580        assert!(cfg.sql_blocked_tables.contains("secrets"));
1581        assert!(cfg.sql_blocked_columns.contains("users.password"));
1582    }
1583
1584    #[test]
1585    fn resolve_token_secret_env_reference_succeeds() {
1586        let _env = super::test_env_guard::lock();
1587        const VAR: &str = "PMCP_TOOLKIT_CODE_MODE_TEST_RESOLVE_ENV";
1588        // Long enough to satisfy HmacTokenGenerator::MIN_SECRET_LEN (16 bytes).
1589        std::env::set_var(VAR, "a-test-secret-bytes-16-or-more");
1590        let section = env_section(VAR);
1591        let resolved = resolve_token_secret(&section).expect("env resolution must succeed");
1592        assert_eq!(resolved.expose_secret(), b"a-test-secret-bytes-16-or-more");
1593        std::env::remove_var(VAR);
1594    }
1595
1596    #[test]
1597    fn resolve_token_secret_inline_without_dev_flag_rejected() {
1598        // R9 — inline literal + flag absent → InlineSecretRejected.
1599        let mut section = env_section("UNUSED");
1600        section.token_secret = Some("raw-string-that-should-be-rejected".to_string());
1601        section.allow_inline_token_secret_for_dev = false;
1602        // SecretValue intentionally does not implement Debug (R5 invariant),
1603        // so we cannot use `expect_err` directly on Result<SecretValue, _>.
1604        match resolve_token_secret(&section) {
1605            Ok(_) => panic!("must reject inline literal"),
1606            Err(ToolkitError::Validation(ConfigValidationError::InlineSecretRejected)) => {},
1607            Err(other) => panic!("expected InlineSecretRejected, got {other:?}"),
1608        }
1609    }
1610
1611    #[test]
1612    fn resolve_token_secret_inline_with_dev_flag_accepted() {
1613        // R9 — inline literal + dev flag → accepted (with tracing::warn).
1614        let mut section = env_section("UNUSED");
1615        section.token_secret = Some("a-test-secret-bytes-16-or-more".to_string());
1616        section.allow_inline_token_secret_for_dev = true;
1617        let resolved = resolve_token_secret(&section).expect("dev flag must permit inline literal");
1618        assert_eq!(resolved.expose_secret(), b"a-test-secret-bytes-16-or-more");
1619    }
1620
1621    #[test]
1622    fn resolve_token_secret_empty_env_var_is_set_but_empty_error() {
1623        let _env = super::test_env_guard::lock();
1624        // 85-10 / T-85-10-03: a set-but-EMPTY env value must NOT flow to the
1625        // HMAC layer as a degenerate secret — it surfaces as a clear
1626        // "set but empty" CodeMode error (env: form).
1627        const VAR: &str = "PMCP_TOOLKIT_CODE_MODE_TEST_EMPTY_ENV";
1628        std::env::set_var(VAR, "");
1629        let section = env_section(VAR);
1630        let outcome = resolve_token_secret(&section);
1631        std::env::remove_var(VAR);
1632        match outcome {
1633            Ok(_) => panic!("empty env var must error, not yield an empty secret"),
1634            Err(ToolkitError::CodeMode(msg)) => {
1635                assert!(
1636                    msg.contains(VAR) && msg.contains("set but empty"),
1637                    "error must name the var as set-but-empty, got: {msg}"
1638                );
1639            },
1640            Err(other) => panic!("expected CodeMode 'set but empty', got {other:?}"),
1641        }
1642    }
1643
1644    #[test]
1645    fn resolve_token_secret_whitespace_env_var_is_set_but_empty_error() {
1646        let _env = super::test_env_guard::lock();
1647        // All-whitespace is treated the same as empty (${VAR} form).
1648        const VAR: &str = "PMCP_TOOLKIT_CODE_MODE_TEST_WS_ENV";
1649        std::env::set_var(VAR, "   ");
1650        let mut section = env_section("UNUSED");
1651        section.token_secret = Some(format!("${{{VAR}}}"));
1652        let outcome = resolve_token_secret(&section);
1653        std::env::remove_var(VAR);
1654        match outcome {
1655            Ok(_) => panic!("whitespace-only env var must error"),
1656            Err(ToolkitError::CodeMode(msg)) => {
1657                assert!(
1658                    msg.contains(VAR) && msg.contains("set but empty"),
1659                    "error must name the var as set-but-empty, got: {msg}"
1660                );
1661            },
1662            Err(other) => panic!("expected CodeMode 'set but empty', got {other:?}"),
1663        }
1664    }
1665
1666    #[test]
1667    fn variables_to_params_maps_object_stripping_colon_prefix() {
1668        // 85-10 WR-02: a JSON object of name→value becomes (name, value) pairs,
1669        // with a leading `:` stripped to match the connector's keying.
1670        let vars = serde_json::json!({ ":name": "Rock", "limit": 5 });
1671        let mut params = variables_to_params(Some(&vars));
1672        params.sort_by(|a, b| a.0.cmp(&b.0));
1673        assert_eq!(
1674            params,
1675            vec![
1676                ("limit".to_string(), serde_json::json!(5)),
1677                ("name".to_string(), serde_json::json!("Rock")),
1678            ]
1679        );
1680    }
1681
1682    #[test]
1683    fn variables_to_params_none_or_non_object_is_empty() {
1684        // None / non-object yields an empty slice — the parity execute_code
1685        // scenario (passes None) is unaffected.
1686        assert!(variables_to_params(None).is_empty());
1687        assert!(variables_to_params(Some(&serde_json::json!("not-an-object"))).is_empty());
1688        assert!(variables_to_params(Some(&serde_json::json!([1, 2, 3]))).is_empty());
1689    }
1690
1691    #[test]
1692    fn resolve_token_secret_missing_env_var_surfaces_error() {
1693        // Use a var name that is overwhelmingly unlikely to be set in CI.
1694        let section = env_section("PMCP_TOOLKIT_DEFINITELY_NOT_SET_FOR_TEST");
1695        // SecretValue has no Debug — pattern-match instead of expect_err.
1696        match resolve_token_secret(&section) {
1697            Ok(_) => panic!("missing env var must error"),
1698            Err(ToolkitError::CodeMode(msg)) => {
1699                assert!(
1700                    msg.contains("PMCP_TOOLKIT_DEFINITELY_NOT_SET_FOR_TEST"),
1701                    "error message must name the missing env var, got: {msg}"
1702                );
1703            },
1704            Err(other) => panic!("expected CodeMode error, got {other:?}"),
1705        }
1706    }
1707}
1708
1709// =============================================================================
1710// SHAP-A-01 — SqlCodeExecutor unit tests (Plan 85-02 Task 1)
1711// =============================================================================
1712
1713#[cfg(all(test, feature = "sqlite"))]
1714mod sql_code_executor_tests {
1715    use super::*;
1716    use crate::config::{CodeModeSection, ServerConfig, ServerSection};
1717    use crate::sql::SqliteConnector;
1718
1719    const TEST_SECRET_VAR: &str = "PMCP_TOOLKIT_SQL_EXECUTOR_TEST_SECRET";
1720
1721    fn ensure_secret() {
1722        std::env::set_var(TEST_SECRET_VAR, "executor-test-secret-16-or-more");
1723    }
1724
1725    /// A read-only `[code_mode]` config (no writes/deletes/DDL) plus an
1726    /// in-memory SQLite connector seeded with a single `Artist` row.
1727    async fn read_only_executor() -> SqlCodeExecutor {
1728        let connector = SqliteConnector::open_in_memory().expect("open in-memory sqlite");
1729        connector
1730            .execute(
1731                "CREATE TABLE Artist (ArtistId INTEGER PRIMARY KEY, Name TEXT)",
1732                &[],
1733            )
1734            .await
1735            .expect("create table");
1736        connector
1737            .execute(
1738                "INSERT INTO Artist (ArtistId, Name) VALUES (1, 'AC/DC')",
1739                &[],
1740            )
1741            .await
1742            .expect("seed row");
1743
1744        let config = ServerConfig {
1745            server: ServerSection {
1746                name: "executor-test".to_string(),
1747                version: "0.1.0".to_string(),
1748                ..Default::default()
1749            },
1750            code_mode: Some(CodeModeSection {
1751                enabled: true,
1752                server_id: Some("executor-test".to_string()),
1753                allow_writes: false,
1754                allow_deletes: false,
1755                allow_ddl: false,
1756                token_secret: Some(format!("env:{TEST_SECRET_VAR}")),
1757                ..Default::default()
1758            }),
1759            ..Default::default()
1760        };
1761        // Serialize set-secret + env-read (build) so a concurrent test cannot
1762        // corrupt the process environment between them. Synchronous — no
1763        // `.await` inside the locked section (the `std` guard is `!Send`).
1764        let _env = super::test_env_guard::lock();
1765        ensure_secret();
1766        SqlCodeExecutor::new(Arc::new(connector), config).expect("build executor")
1767    }
1768
1769    /// Same in-memory connector as [`read_only_executor`], but the `[code_mode]`
1770    /// config sets `require_limit = true` so a bare SELECT must reject on policy.
1771    async fn read_only_executor_with_require_limit() -> SqlCodeExecutor {
1772        let connector = SqliteConnector::open_in_memory().expect("open in-memory sqlite");
1773        connector
1774            .execute(
1775                "CREATE TABLE Artist (ArtistId INTEGER PRIMARY KEY, Name TEXT)",
1776                &[],
1777            )
1778            .await
1779            .expect("create table");
1780        connector
1781            .execute(
1782                "INSERT INTO Artist (ArtistId, Name) VALUES (1, 'AC/DC')",
1783                &[],
1784            )
1785            .await
1786            .expect("seed row");
1787
1788        let config = ServerConfig {
1789            server: ServerSection {
1790                name: "executor-test".to_string(),
1791                version: "0.1.0".to_string(),
1792                ..Default::default()
1793            },
1794            code_mode: Some(CodeModeSection {
1795                enabled: true,
1796                server_id: Some("executor-test".to_string()),
1797                allow_writes: false,
1798                allow_deletes: false,
1799                allow_ddl: false,
1800                require_limit: true,
1801                token_secret: Some(format!("env:{TEST_SECRET_VAR}")),
1802                ..Default::default()
1803            }),
1804            ..Default::default()
1805        };
1806        // Serialize set-secret + env-read (build) so a concurrent test cannot
1807        // corrupt the process environment between them. Synchronous — no
1808        // `.await` inside the locked section (the `std` guard is `!Send`).
1809        let _env = super::test_env_guard::lock();
1810        ensure_secret();
1811        SqlCodeExecutor::new(Arc::new(connector), config).expect("build executor")
1812    }
1813
1814    #[tokio::test]
1815    async fn read_only_select_returns_rows() {
1816        let executor = read_only_executor().await;
1817        let result = executor
1818            .execute("SELECT ArtistId, Name FROM Artist", None)
1819            .await
1820            .expect("read-only SELECT must succeed under a read-only policy");
1821        // Mirrors production's observable `"rows"` key (REVIEW FIX #6b).
1822        let rows = result.get("rows").expect("payload has a `rows` key");
1823        let arr = rows.as_array().expect("`rows` is an array");
1824        assert_eq!(arr.len(), 1, "one seeded row expected, got {arr:?}");
1825        assert_eq!(arr[0]["Name"], "AC/DC");
1826    }
1827
1828    #[tokio::test]
1829    async fn require_limit_rejects_bare_select_before_connector() {
1830        // VERIFICATION Gap 1: with require_limit=true, a no-LIMIT SELECT is
1831        // rejected on re-validation BEFORE the connector — even though the
1832        // single seeded row never exceeds any row-count limit.
1833        let executor = read_only_executor_with_require_limit().await;
1834        let err = executor
1835            .execute("SELECT * FROM Artist", None)
1836            .await
1837            .expect_err("bare SELECT must be rejected when require_limit=true");
1838        assert!(
1839            matches!(err, ExecutionError::BackendError(_)),
1840            "expected a policy-rejection BackendError, got {err:?}"
1841        );
1842        // The table is untouched — proving the rejection is the require_limit
1843        // policy, not a row-count failure.
1844        let count = executor
1845            .connector
1846            .execute("SELECT COUNT(*) AS n FROM Artist", &[])
1847            .await
1848            .expect("count query");
1849        assert_eq!(count[0]["n"], 1, "row count must be unchanged");
1850    }
1851
1852    #[tokio::test]
1853    async fn require_limit_allows_limited_select() {
1854        let executor = read_only_executor_with_require_limit().await;
1855        let result = executor
1856            .execute("SELECT ArtistId, Name FROM Artist LIMIT 5", None)
1857            .await
1858            .expect("a LIMITed SELECT must succeed under require_limit=true");
1859        let rows = result.get("rows").expect("payload has a `rows` key");
1860        let arr = rows.as_array().expect("`rows` is an array");
1861        assert_eq!(arr.len(), 1, "one seeded row expected, got {arr:?}");
1862    }
1863
1864    #[tokio::test]
1865    async fn delete_rejected_before_connector_under_read_only_policy() {
1866        // allow_deletes=false → re-validation rejects DELETE BEFORE the
1867        // connector is reached (threat T-85-02-01 / SC-3).
1868        let executor = read_only_executor().await;
1869        let err = executor
1870            .execute("DELETE FROM Artist WHERE ArtistId = 1", None)
1871            .await
1872            .expect_err("DELETE must be rejected when allow_deletes=false");
1873        assert!(
1874            matches!(err, ExecutionError::BackendError(_)),
1875            "expected a policy-rejection BackendError, got {err:?}"
1876        );
1877        // The row must still be present — proving the connector was never reached.
1878        let still_there = executor
1879            .connector
1880            .execute("SELECT COUNT(*) AS n FROM Artist", &[])
1881            .await
1882            .expect("count query");
1883        assert_eq!(still_there[0]["n"], 1, "DELETE must not have run");
1884    }
1885
1886    #[tokio::test]
1887    async fn ddl_rejected_under_read_only_policy() {
1888        // allow_ddl=false → re-validation rejects DROP TABLE.
1889        let executor = read_only_executor().await;
1890        let err = executor
1891            .execute("DROP TABLE Artist", None)
1892            .await
1893            .expect_err("DROP must be rejected when allow_ddl=false");
1894        assert!(matches!(err, ExecutionError::BackendError(_)));
1895    }
1896
1897    #[tokio::test]
1898    async fn malformed_sql_returns_err_never_panics() {
1899        let executor = read_only_executor().await;
1900        let result = executor.execute("SELEC nonsense FRM", None).await;
1901        assert!(
1902            result.is_err(),
1903            "malformed SQL must surface an Err, never panic"
1904        );
1905    }
1906
1907    #[tokio::test]
1908    async fn execute_binds_variables_input() {
1909        // 85-10 WR-02 / T-85-10-01: the schema-advertised `variables` input is
1910        // BOUND as named params (not silently dropped), so a `WHERE Name = :name`
1911        // resolves against the seeded row.
1912        let executor = read_only_executor().await;
1913        let vars = serde_json::json!({ ":name": "AC/DC" });
1914        let result = executor
1915            .execute(
1916                "SELECT ArtistId FROM Artist WHERE Name = :name",
1917                Some(&vars),
1918            )
1919            .await
1920            .expect("bound variable must resolve the WHERE clause");
1921        let rows = result.get("rows").expect("payload has a `rows` key");
1922        let arr = rows.as_array().expect("`rows` is an array");
1923        assert_eq!(arr.len(), 1, "the bound :name must match the seeded row");
1924        assert_eq!(arr[0]["ArtistId"], 1);
1925    }
1926
1927    #[tokio::test]
1928    async fn execute_empty_variables_is_unaffected() {
1929        // An empty variables map binds nothing — identical to today's None path.
1930        let executor = read_only_executor().await;
1931        let empty = serde_json::json!({});
1932        let result = executor
1933            .execute("SELECT ArtistId, Name FROM Artist", Some(&empty))
1934            .await
1935            .expect("empty variables must behave exactly like None");
1936        let arr = result["rows"].as_array().expect("`rows` array");
1937        assert_eq!(arr.len(), 1);
1938    }
1939
1940    #[tokio::test]
1941    async fn pipeline_cached_at_construction_not_reread_per_execute() {
1942        // 85-10 IN-01 / T-85-10-03: the pipeline is built ONCE in `new`, so a
1943        // SECOND execute does NOT re-resolve the token_secret env var. Remove the
1944        // env var after construction — the executor must STILL succeed (proving
1945        // it did not re-read the now-missing secret).
1946        let executor = read_only_executor().await;
1947        // First execute (baseline) succeeds.
1948        executor
1949            .execute("SELECT ArtistId FROM Artist LIMIT 1", None)
1950            .await
1951            .expect("first execute succeeds");
1952        // Remove the secret the pipeline was built from. Each discrete env
1953        // mutation is serialized under the shared lock (held only across the
1954        // synchronous call, never across the `.await`s above/below).
1955        {
1956            let _env = super::test_env_guard::lock();
1957            std::env::remove_var(TEST_SECRET_VAR);
1958        }
1959        // Second execute STILL succeeds — the cached pipeline never re-reads env.
1960        let result = executor
1961            .execute("SELECT ArtistId FROM Artist LIMIT 1", None)
1962            .await
1963            .expect("second execute must succeed from the cached pipeline");
1964        // Restore for any sibling tests sharing the process env.
1965        {
1966            let _env = super::test_env_guard::lock();
1967            ensure_secret();
1968        }
1969        assert!(result.get("rows").is_some());
1970    }
1971}
1972
1973// =============================================================================
1974// TKIT-10 — assemble_code_mode_prompt integration tests
1975// =============================================================================
1976
1977#[cfg(test)]
1978mod tkit10_tests {
1979    use super::*;
1980    use crate::config::{DatabaseSection, DatabaseTableDecl, ServerConfig, ServerSection};
1981    use crate::sql::{Dialect, MockSqlConnector};
1982
1983    fn make_cfg(tables: Vec<DatabaseTableDecl>) -> ServerConfig {
1984        ServerConfig {
1985            server: ServerSection {
1986                name: "test".to_string(),
1987                version: "0.1.0".to_string(),
1988                ..Default::default()
1989            },
1990            database: DatabaseSection {
1991                tables,
1992                ..Default::default()
1993            },
1994            ..Default::default()
1995        }
1996    }
1997
1998    #[tokio::test]
1999    async fn assemble_includes_schema_text_and_dialect_name() {
2000        let connector = MockSqlConnector {
2001            dialect: Dialect::Postgres,
2002            schema: "CREATE TABLE users (id SERIAL PRIMARY KEY);".to_string(),
2003        };
2004        let cfg = make_cfg(vec![]);
2005        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
2006        assert!(
2007            prompt.contains("# Code Mode — PostgreSQL"),
2008            "prompt missing dialect header: {prompt}"
2009        );
2010        assert!(
2011            prompt.contains("CREATE TABLE users"),
2012            "prompt missing schema body: {prompt}"
2013        );
2014        assert!(
2015            prompt.contains("$1"),
2016            "Postgres guidance should mention $1: {prompt}"
2017        );
2018    }
2019
2020    #[tokio::test]
2021    async fn assemble_includes_curated_descriptions() {
2022        let connector = MockSqlConnector {
2023            dialect: Dialect::Athena,
2024            schema: "(see Glue catalog)".to_string(),
2025        };
2026        let cfg = make_cfg(vec![
2027            DatabaseTableDecl {
2028                name: "users".to_string(),
2029                description: Some("App users".to_string()),
2030            },
2031            DatabaseTableDecl {
2032                name: "orders".to_string(),
2033                description: Some("Customer orders".to_string()),
2034            },
2035        ]);
2036        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
2037        assert!(
2038            prompt.contains("## Curated Tables"),
2039            "prompt missing curated header: {prompt}"
2040        );
2041        assert!(
2042            prompt.contains("`users`: App users"),
2043            "prompt missing users description: {prompt}"
2044        );
2045        assert!(
2046            prompt.contains("`orders`: Customer orders"),
2047            "prompt missing orders description: {prompt}"
2048        );
2049        // Athena uses ? placeholders, not $1
2050        assert!(
2051            prompt.contains("Amazon Athena"),
2052            "prompt missing Athena dialect name: {prompt}"
2053        );
2054    }
2055
2056    #[tokio::test]
2057    async fn assemble_omits_curated_section_when_tables_empty() {
2058        let connector = MockSqlConnector {
2059            dialect: Dialect::Sqlite,
2060            schema: "CREATE TABLE t (id INTEGER PRIMARY KEY);".to_string(),
2061        };
2062        let cfg = make_cfg(vec![]);
2063        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
2064        assert!(
2065            !prompt.contains("## Curated Tables"),
2066            "empty [[database.tables]] must omit curated section: {prompt}"
2067        );
2068        assert!(
2069            prompt.contains("SQLite"),
2070            "prompt missing SQLite dialect name: {prompt}"
2071        );
2072    }
2073
2074    #[tokio::test]
2075    async fn assemble_skips_tables_without_descriptions() {
2076        // A described entry mixed with an undescribed one — only the described
2077        // row should render. Curated section still emits because at least one
2078        // row qualifies.
2079        let connector = MockSqlConnector {
2080            dialect: Dialect::MySql,
2081            schema: "CREATE TABLE t (id INT);".to_string(),
2082        };
2083        let cfg = make_cfg(vec![
2084            DatabaseTableDecl {
2085                name: "with_desc".to_string(),
2086                description: Some("has description".to_string()),
2087            },
2088            DatabaseTableDecl {
2089                name: "no_desc".to_string(),
2090                description: None,
2091            },
2092        ]);
2093        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
2094        assert!(prompt.contains("`with_desc`: has description"));
2095        assert!(
2096            !prompt.contains("`no_desc`"),
2097            "undescribed table must not appear in curated section: {prompt}"
2098        );
2099    }
2100
2101    // =========================================================================
2102    // assemble_code_mode_prompt_with_schema — file-based prompt seam (Task 3)
2103    // =========================================================================
2104
2105    #[test]
2106    fn with_schema_includes_header_dialect_schema_and_curated() {
2107        let cfg = make_cfg(vec![DatabaseTableDecl {
2108            name: "Artist".to_string(),
2109            description: Some("Musical artists".to_string()),
2110        }]);
2111        let schema = "CREATE TABLE Artist (ArtistId INTEGER PRIMARY KEY, Name TEXT);";
2112        let prompt = assemble_code_mode_prompt_with_schema(schema, Dialect::Sqlite, &cfg);
2113
2114        assert!(
2115            prompt.contains("# Code Mode"),
2116            "missing code-mode header: {prompt}"
2117        );
2118        assert!(prompt.contains("SQLite"), "missing dialect name: {prompt}");
2119        assert!(
2120            prompt.contains("# Database Schema"),
2121            "missing schema-resource header: {prompt}"
2122        );
2123        assert!(
2124            prompt.contains(schema),
2125            "schema text must appear verbatim: {prompt}"
2126        );
2127        assert!(
2128            prompt.contains("`Artist`: Musical artists"),
2129            "curated table description must appear: {prompt}"
2130        );
2131    }
2132
2133    /// The helper is a SYNC fn — this test calls it from a non-async context,
2134    /// which only compiles because it never awaits a connector (proving it
2135    /// cannot trigger a live `schema_text()`).
2136    #[test]
2137    fn with_schema_is_sync_and_uses_passed_dialect() {
2138        let cfg = make_cfg(vec![]);
2139        let prompt = assemble_code_mode_prompt_with_schema(
2140            "CREATE TABLE t (id INT);",
2141            Dialect::Postgres,
2142            &cfg,
2143        );
2144        assert!(
2145            prompt.contains("# Code Mode — PostgreSQL"),
2146            "passed dialect must drive the header: {prompt}"
2147        );
2148        // Postgres placeholder guidance mentions $1 — proves dialect param is used.
2149        assert!(prompt.contains("$1"), "Postgres guidance missing: {prompt}");
2150        // No curated section when [[database.tables]] is empty.
2151        assert!(
2152            !prompt.contains("## Curated Tables"),
2153            "empty tables must omit curated section: {prompt}"
2154        );
2155    }
2156
2157    #[test]
2158    fn with_schema_empty_text_still_has_header() {
2159        let cfg = make_cfg(vec![]);
2160        let prompt = assemble_code_mode_prompt_with_schema("", Dialect::MySql, &cfg);
2161        assert!(
2162            prompt.contains("# Code Mode — MySQL"),
2163            "empty schema must still produce a valid prompt with the header: {prompt}"
2164        );
2165        assert!(
2166            prompt.contains("# Database Schema"),
2167            "schema-resource header present even for empty schema: {prompt}"
2168        );
2169    }
2170}
2171
2172// =============================================================================
2173// Plan 90-10 — per-request executor seam + OpenAPI per-request wiring tests
2174// =============================================================================
2175
2176#[cfg(all(test, feature = "openapi-code-mode"))]
2177mod per_request_executor_tests {
2178    use super::*;
2179    use crate::config::{CodeModeSection, ServerConfig, ServerSection};
2180    use crate::http::auth::{create_passthrough_auth_provider, AuthConfig};
2181    use pmcp::server::auth::AuthContext;
2182
2183    /// A passthrough-configured `HttpCodeExecutor` over a fixed base_url.
2184    fn passthrough_base() -> HttpCodeExecutor {
2185        let auth = create_passthrough_auth_provider(
2186            &AuthConfig::OAuthPassthrough {
2187                target_header: "Authorization".to_string(),
2188                required: true,
2189            },
2190            None,
2191        )
2192        .expect("passthrough auth provider");
2193        HttpCodeExecutor::new(
2194            reqwest::Client::new(),
2195            "https://api.example".to_string(),
2196            auth,
2197        )
2198    }
2199
2200    fn extra_with_token(token: Option<&str>) -> pmcp::RequestHandlerExtra {
2201        let ctx = AuthContext {
2202            subject: "s".to_string(),
2203            scopes: vec![],
2204            claims: std::collections::HashMap::new(),
2205            token: token.map(str::to_string),
2206            client_id: None,
2207            expires_at: None,
2208            authenticated: token.is_some(),
2209        };
2210        pmcp::RequestHandlerExtra::default().with_auth_context(Some(ctx))
2211    }
2212
2213    #[test]
2214    fn request_executor_from_extra_threads_present_token() {
2215        // Plan 90-10 / OAPI-03 / OAPI-05: the captured inbound token reaches the
2216        // per-request executor's inbound_token field.
2217        let base = passthrough_base();
2218        assert_eq!(
2219            base.inbound_token_for_test(),
2220            None,
2221            "base executor starts with no inbound token"
2222        );
2223        let extra = extra_with_token(Some("Bearer client-tok"));
2224        let scoped = request_executor_from_extra(&base, &extra);
2225        assert_eq!(
2226            scoped.inbound_token_for_test(),
2227            Some("Bearer client-tok"),
2228            "the captured inbound token must be threaded into the per-request executor"
2229        );
2230    }
2231
2232    #[test]
2233    fn request_executor_from_extra_no_token_yields_none() {
2234        let base = passthrough_base();
2235        let extra = extra_with_token(None);
2236        let scoped = request_executor_from_extra(&base, &extra);
2237        assert_eq!(
2238            scoped.inbound_token_for_test(),
2239            None,
2240            "an extra carrying no token must yield an executor with inbound_token None"
2241        );
2242        // No auth context at all also yields None (never panics).
2243        let bare = request_executor_from_extra(&base, &pmcp::RequestHandlerExtra::default());
2244        assert_eq!(bare.inbound_token_for_test(), None);
2245    }
2246
2247    fn cfg_with_code_mode() -> ServerConfig {
2248        std::env::set_var(
2249            "PMCP_TOOLKIT_90_10_HTTP_SECRET",
2250            "per-request-test-secret-16-or-more",
2251        );
2252        ServerConfig {
2253            server: ServerSection {
2254                name: "http-cm".to_string(),
2255                version: "0.1.0".to_string(),
2256                ..Default::default()
2257            },
2258            code_mode: Some(CodeModeSection {
2259                enabled: true,
2260                server_id: Some("http-cm".to_string()),
2261                token_secret: Some("env:PMCP_TOOLKIT_90_10_HTTP_SECRET".to_string()),
2262                ..Default::default()
2263            }),
2264            ..Default::default()
2265        }
2266    }
2267
2268    #[test]
2269    fn http_tools_register_validate_and_execute_with_per_request_source() {
2270        // code_mode_http_tools_from_executor builds the ExecuteCodeHandler over
2271        // the PerRequestHttp source (constructed without panic over a passthrough
2272        // executor) and registers both Code-Mode tools.
2273        let _env = super::test_env_guard::lock();
2274        let cfg = cfg_with_code_mode();
2275        let builder = pmcp::Server::builder().name("http-cm").version("0.1.0");
2276        let builder = code_mode_http_tools_from_executor(
2277            builder,
2278            &cfg,
2279            passthrough_base(),
2280            ExecutionConfig::default(),
2281            ValidationFlavor::OpenApi,
2282        )
2283        .expect("OpenAPI per-request code-mode wiring must build");
2284        let server = builder.build().expect("server builds");
2285        assert!(
2286            server.get_tool("validate_code").is_some(),
2287            "validate_code registered"
2288        );
2289        assert!(
2290            server.get_tool("execute_code").is_some(),
2291            "execute_code registered"
2292        );
2293        std::env::remove_var("PMCP_TOOLKIT_90_10_HTTP_SECRET");
2294    }
2295
2296    #[test]
2297    fn http_tools_no_op_when_code_mode_absent() {
2298        let cfg = ServerConfig {
2299            server: ServerSection {
2300                name: "no-cm".to_string(),
2301                version: "0.1.0".to_string(),
2302                ..Default::default()
2303            },
2304            ..Default::default()
2305        };
2306        let builder = pmcp::Server::builder().name("no-cm").version("0.1.0");
2307        let builder = code_mode_http_tools_from_executor(
2308            builder,
2309            &cfg,
2310            passthrough_base(),
2311            ExecutionConfig::default(),
2312            ValidationFlavor::OpenApi,
2313        )
2314        .expect("no-op when [code_mode] absent");
2315        let server = builder.build().expect("server builds");
2316        assert!(
2317            server.get_tool("execute_code").is_none(),
2318            "no tools without [code_mode]"
2319        );
2320    }
2321}
2322
2323#[cfg(all(test, feature = "sqlite", feature = "openapi-code-mode"))]
2324mod sql_static_source_tests {
2325    use super::*;
2326    use crate::config::{CodeModeSection, ServerConfig, ServerSection};
2327    use crate::sql::SqliteConnector;
2328
2329    #[test]
2330    fn sql_path_registers_static_source_unchanged() {
2331        // The SQL path via code_mode_tools_from_executor still builds the
2332        // ExecuteCodeHandler with the Static source (SqlCodeExecutor) — Plan
2333        // 90-10 must not change the SQL wiring.
2334        let _env = super::test_env_guard::lock();
2335        std::env::set_var(
2336            "PMCP_TOOLKIT_90_10_SQL_SECRET",
2337            "sql-static-test-secret-16-or-more",
2338        );
2339        let connector = SqliteConnector::open_in_memory().expect("sqlite");
2340        let cfg = ServerConfig {
2341            server: ServerSection {
2342                name: "sql-cm".to_string(),
2343                version: "0.1.0".to_string(),
2344                ..Default::default()
2345            },
2346            code_mode: Some(CodeModeSection {
2347                enabled: true,
2348                server_id: Some("sql-cm".to_string()),
2349                token_secret: Some("env:PMCP_TOOLKIT_90_10_SQL_SECRET".to_string()),
2350                ..Default::default()
2351            }),
2352            ..Default::default()
2353        };
2354        let executor: Arc<dyn CodeExecutor> =
2355            Arc::new(SqlCodeExecutor::new(Arc::new(connector), cfg.clone()).expect("executor"));
2356        let builder = pmcp::Server::builder().name("sql-cm").version("0.1.0");
2357        let builder = code_mode_tools_from_executor(builder, &cfg, executor, ValidationFlavor::Sql)
2358            .expect("SQL code-mode wiring must build");
2359        let server = builder.build().expect("server builds");
2360        assert!(server.get_tool("validate_code").is_some());
2361        assert!(server.get_tool("execute_code").is_some());
2362        std::env::remove_var("PMCP_TOOLKIT_90_10_SQL_SECRET");
2363    }
2364}