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            use pmcp_code_mode::CodeExecutor as _;
558            match &self.source {
559                ExecSource::Static(executor) => executor.execute(code, variables).await,
560                #[cfg(feature = "openapi-code-mode")]
561                ExecSource::PerRequestHttp { base, exec_config } => {
562                    let http_exec = super::request_executor_from_extra(base, extra);
563                    super::JsCodeExecutor::new(http_exec, exec_config.clone())
564                        .execute(code, variables)
565                        .await
566                },
567            }
568        }
569    }
570
571    #[pmcp_code_mode::async_trait]
572    impl pmcp::ToolHandler for ExecuteCodeHandler {
573        async fn handle(
574            &self,
575            args: serde_json::Value,
576            extra: pmcp::RequestHandlerExtra,
577        ) -> pmcp::Result<serde_json::Value> {
578            let input: pmcp_code_mode::ExecuteCodeInput = serde_json::from_value(args)
579                .map_err(|e| pmcp::Error::Internal(format!("Invalid arguments: {e}")))?;
580            let code = input.code.trim();
581
582            // Token / code-hash verification failures are model-actionable
583            // rejections (the model must re-run validate_code to obtain a fresh
584            // token, or resend the exact validated code), so surface them as
585            // `CallToolResult { isError: true }` via `Error::tool_rejected` —
586            // not `-32603`. A genuine execution fault (connector/SQL runtime,
587            // below) stays an `Internal` protocol error: the caller cannot fix
588            // it by changing input.
589            let token_gen = self.pipeline.token_generator();
590            let token =
591                pmcp_code_mode::ApprovalToken::decode(&input.approval_token).map_err(|e| {
592                    pmcp::Error::tool_rejected(
593                        format!(
594                        "Invalid approval_token: {e}. Call validate_code to obtain a valid token."
595                    ),
596                        None,
597                    )
598                })?;
599            token_gen.verify(&token).map_err(|e| {
600                pmcp::Error::tool_rejected(
601                    format!(
602                        "Approval token is invalid or expired: {e}. \
603                         Call validate_code again to obtain a fresh token."
604                    ),
605                    None,
606                )
607            })?;
608            token_gen.verify_code(code, &token).map_err(|e| {
609                pmcp::Error::tool_rejected(
610                    format!(
611                        "Code does not match the validated code: {e}. execute_code must use the \
612                         exact code string that was passed to validate_code."
613                    ),
614                    None,
615                )
616            })?;
617
618            let result = self
619                .run_code(code, input.variables.as_ref(), &extra)
620                .await
621                .map_err(|e| pmcp::Error::Internal(format!("Execution error: {e}")))?;
622            Ok(result)
623        }
624
625        fn metadata(&self) -> Option<pmcp::types::ToolInfo> {
626            Some(
627                pmcp_code_mode::CodeModeToolBuilder::new(self.flavor.code_format())
628                    .build_execute_tool(),
629            )
630        }
631    }
632}
633
634// =============================================================================
635// SHAP-A-01 — SqlCodeExecutor (Plan 85-02 Task 1)
636// =============================================================================
637
638/// [`CodeExecutor`] adapter bridging the toolkit's single-method
639/// [`SqlConnector`] to the code-mode `validate_code` / `execute_code` flow.
640///
641/// # Re-derived for the single-method trait
642///
643/// The production reference (`mcp-sql-server-core::SqlCodeModeHandler`) is
644/// written over a 2-method `DatabaseConnector` (`execute_query` /
645/// `execute_statement`) and dispatches by [`crate::sql`]'s
646/// `QueryType`. The toolkit's [`SqlConnector`] exposes a SINGLE
647/// [`SqlConnector::execute`] entry point, so this adapter collapses that
648/// 2-method dispatch into one `connector.execute(sql, &params)` call regardless
649/// of statement type — re-validating the SQL FIRST for defense-in-depth. The
650/// `execute_code` `variables` input IS bound as named params (85-10 WR-02);
651/// it is never silently dropped.
652///
653/// # Defense-in-depth re-validation (threat T-85-02-01)
654///
655/// Before touching the connector, [`SqlCodeExecutor::execute`] re-runs the
656/// `[code_mode]` policy against the supplied SQL via the same
657/// [`ValidationPipeline`] the `validate_code` tool used. The code-mode
658/// framework already verified the approval token + code hash before calling
659/// this method, but re-validation guards against a token issued for an
660/// allowed statement being replayed with a different (e.g. mutating)
661/// statement. A policy violation returns `Err(ExecutionError::BackendError)`
662/// BEFORE the connector is reached — a config-driven server cannot bypass the
663/// write/DDL guards (SC-3, threat T-85-02-02).
664///
665/// # Observable result shape (REVIEW FIX Codex MEDIUM #6b)
666///
667/// The production handler returns
668/// `{"columns": [...], "rows": [...], "rows_affected": N}` because its
669/// 2-method connector surfaces columns + affected-row counts separately. The
670/// toolkit's [`SqlConnector::execute`] returns `Vec<Value>` (one JSON object
671/// per row, keyed by column name) with no separate columns/rows_affected
672/// channel, so this adapter mirrors production's OBSERVABLE `"rows"` key:
673/// `{"rows": <values>}`. The parity replay (Plan 06) only exercises
674/// `execute_code` with an INVALID token (asserts `failure`), so this success
675/// shape is not asserted by `generated.yaml`; mirroring production keeps the
676/// executor correct for any future success-path scenario and for the direct
677/// unit assertions in this crate.
678pub struct SqlCodeExecutor {
679    connector: Arc<dyn SqlConnector>,
680    /// The re-validation pipeline, built ONCE at construction (85-10 IN-01).
681    ///
682    /// Previously [`SqlCodeExecutor::revalidate`] rebuilt the pipeline AND
683    /// re-resolved the `token_secret` env var on EVERY `execute` call. Caching
684    /// it here means the secret is resolved a single time (at construction /
685    /// builder time) — a removed/rotated env var after startup no longer breaks
686    /// in-flight requests, and a bad secret still fails fast at builder time.
687    pipeline: Arc<ValidationPipeline>,
688}
689
690impl SqlCodeExecutor {
691    /// Construct an executor over `connector`, enforcing the `[code_mode]`
692    /// policy carried by `config` on every [`SqlCodeExecutor::execute`] call.
693    ///
694    /// The [`ValidationPipeline`] is built ONCE here (85-10 IN-01) via
695    /// [`validation_pipeline_from_config`], so the `token_secret` env var is
696    /// resolved a single time at construction rather than on every request.
697    ///
698    /// # Errors
699    ///
700    /// Returns every error from [`validation_pipeline_from_config`] — most
701    /// notably the R9 inline-secret rejection and the secret-resolution /
702    /// 16-byte-minimum failures — so a misconfigured `token_secret` fails at
703    /// builder time, not first request.
704    pub fn new(connector: Arc<dyn SqlConnector>, config: ServerConfig) -> Result<Self> {
705        let pipeline = Arc::new(validation_pipeline_from_config(&config)?);
706        Ok(Self {
707            connector,
708            pipeline,
709        })
710    }
711
712    /// Defense-in-depth re-validation of `code` against the `[code_mode]`
713    /// policy (threat T-85-02-01). Returns `Err` BEFORE any connector call when
714    /// the statement violates the static policy (e.g. a DELETE under
715    /// `allow_deletes = false`) or fails to parse.
716    ///
717    /// Reuses the cached [`SqlCodeExecutor::pipeline`] (85-10 IN-01) — it does
718    /// NOT rebuild the pipeline or re-read the `token_secret` env var per call.
719    fn revalidate(&self, code: &str) -> std::result::Result<(), ExecutionError> {
720        let ctx = ValidationContext::new(
721            "code-mode-executor",
722            "code-mode-session",
723            "schema-hash",
724            "perms-hash",
725        );
726        let result = self
727            .pipeline
728            .validate_sql_query(code, &ctx)
729            .map_err(|e| ExecutionError::BackendError(format!("SQL validation failed: {e}")))?;
730        if !result.is_valid {
731            return Err(ExecutionError::BackendError(
732                "SQL rejected by [code_mode] policy on re-validation".to_string(),
733            ));
734        }
735        Ok(())
736    }
737}
738
739/// Convert the `execute_code` `variables` input (a JSON object of name→value)
740/// into the `(name, value)` pairs [`SqlConnector::execute`] binds (85-10
741/// WR-02). A leading `:` on a key is stripped so callers may send either
742/// `{":name": ...}` or `{"name": ...}` — the connector's
743/// `translate_placeholders` keys params WITHOUT the `:` (matching
744/// [`extract_named_params`](crate::tools)). `None` or a non-object value yields
745/// an empty slice, so the parity `execute_code` scenario (passes `None`) is
746/// unaffected.
747fn variables_to_params(variables: Option<&serde_json::Value>) -> Vec<(String, serde_json::Value)> {
748    let Some(serde_json::Value::Object(map)) = variables else {
749        return Vec::new();
750    };
751    map.iter()
752        .map(|(k, v)| {
753            let key = k.strip_prefix(':').unwrap_or(k).to_string();
754            (key, v.clone())
755        })
756        .collect()
757}
758
759#[pmcp_code_mode::async_trait]
760impl CodeExecutor for SqlCodeExecutor {
761    /// Re-validate the SQL against the `[code_mode]` policy, then execute it via
762    /// the single-method [`SqlConnector::execute`].
763    ///
764    /// # Errors
765    ///
766    /// Returns [`ExecutionError::BackendError`] when re-validation rejects the
767    /// statement (policy violation or parse failure) or when the connector
768    /// surfaces a [`crate::sql::ConnectorError`]. Connector error messages are
769    /// surfaced verbatim from the toolkit's already-sanitized
770    /// `ConnectorError` Display (T-84-01-01 / threat T-85-02-04) — no raw
771    /// backend credentials are echoed.
772    async fn execute(
773        &self,
774        code: &str,
775        variables: Option<&serde_json::Value>,
776    ) -> std::result::Result<serde_json::Value, ExecutionError> {
777        // (1) Defense-in-depth re-validation BEFORE the connector is reached.
778        self.revalidate(code)?;
779        // (2) Honor the schema-advertised `variables` input by BINDING it as
780        //     named params (85-10 WR-02 / threat T-85-10-01) — never a silent
781        //     drop. A `None` / absent map yields `&[]`, so the parity scenario
782        //     (passes None) is unaffected. Binding (not string interpolation)
783        //     preserves parameterized-query safety.
784        let params = variables_to_params(variables);
785        let rows =
786            self.connector.execute(code, &params).await.map_err(|e| {
787                ExecutionError::BackendError(format!("connector execute failed: {e}"))
788            })?;
789        // (3) Mirror production's observable `"rows"` key (REVIEW FIX #6b).
790        Ok(serde_json::json!({ "rows": rows }))
791    }
792}
793
794// =============================================================================
795// OAPI-05 — HttpCodeExecutor (Plan 90-04 Task 1 / H1 / H2)
796// =============================================================================
797
798/// Low-level HTTP executor bridging the toolkit's outbound
799/// [`HttpAuthProvider`](crate::http::auth::HttpAuthProvider) to pmcp-code-mode's
800/// [`HttpExecutor`](pmcp_code_mode::HttpExecutor) trait.
801///
802/// This is the OpenAPI analog of [`SqlCodeExecutor`], but at a DIFFERENT layer:
803/// it impls the LOW-LEVEL `pmcp_code_mode::HttpExecutor`
804/// (`execute_request(method, path, body)`), NOT the high-level
805/// [`CodeExecutor`]. It is wrapped by a
806/// [`JsCodeExecutor`](pmcp_code_mode::JsCodeExecutor) for the Code Mode path
807/// (the `JsCodeExecutor<HttpCodeExecutor>: CodeExecutor` blanket impl) and is
808/// called directly by script tools (Plan 05). The single-call synthesizer
809/// (Plan 03) does NOT use this path — it calls `HttpConnector::execute`
810/// directly.
811///
812/// # Per-request passthrough token (H1)
813///
814/// The `inbound_token` field carries the per-request MCP client token captured
815/// by the binary (Plan 06) into [`AuthContext`]. It is passed to
816/// [`HttpAuthProvider::apply`](crate::http::auth::HttpAuthProvider::apply) so an
817/// [`OAuthPassthroughAuth`](crate::http::auth::OAuthPassthroughAuth) provider
818/// forwards it to the backend; static providers ignore it (proven in Plan 01).
819/// Because Code Mode reuses ONE executor instance across requests, the binary
820/// produces a per-request clone carrying the captured token via
821/// [`HttpCodeExecutor::with_inbound_token`].
822///
823/// # Redaction (Pitfall 5 / T-90-04-01)
824///
825/// Auth/transport failures are mapped to
826/// [`ExecutionError::RuntimeError`](pmcp_code_mode::ExecutionError::RuntimeError)
827/// whose message names the operation / status only — it NEVER echoes the
828/// request URL or the `Authorization` token.
829///
830/// # Feature gate (H2)
831///
832/// Gated under `openapi-code-mode` (the Plan 90-01 umbrella that forwards
833/// `pmcp-code-mode/js-runtime`). The bare `code-mode` feature does NOT bring
834/// `HttpExecutor` into scope, so this type cannot be gated on
835/// `all(feature = "http", feature = "code-mode")`.
836#[cfg(feature = "openapi-code-mode")]
837#[derive(Clone)]
838pub struct HttpCodeExecutor {
839    client: reqwest::Client,
840    base_url: String,
841    auth: Arc<dyn crate::http::auth::HttpAuthProvider>,
842    /// Per-request captured MCP client token for `oauth_passthrough` (H1).
843    /// `None` for the static-auth path; set per request via
844    /// [`HttpCodeExecutor::with_inbound_token`].
845    inbound_token: Option<String>,
846}
847
848#[cfg(feature = "openapi-code-mode")]
849impl HttpCodeExecutor {
850    /// Construct an executor over `client` + `base_url`, authenticating outgoing
851    /// requests via `auth`. The per-request `inbound_token` starts `None`;
852    /// the binary attaches it per request with
853    /// [`HttpCodeExecutor::with_inbound_token`].
854    #[must_use]
855    pub fn new(
856        client: reqwest::Client,
857        base_url: String,
858        auth: Arc<dyn crate::http::auth::HttpAuthProvider>,
859    ) -> Self {
860        Self {
861            client,
862            base_url,
863            auth,
864            inbound_token: None,
865        }
866    }
867
868    /// Cheap clone-with-token builder (H1): the binary calls this PER REQUEST to
869    /// attach the captured inbound MCP token so an `oauth_passthrough` provider
870    /// forwards it. Static providers ignore the token, so calling this on a
871    /// static-auth executor is harmless.
872    ///
873    /// Single-call tools (Plan 03) don't use this path; the per-request token
874    /// flows through Code Mode + script tools only.
875    #[must_use]
876    pub fn with_inbound_token(mut self, token: Option<String>) -> Self {
877        self.inbound_token = token;
878        self
879    }
880
881    /// Test-only accessor for the per-request captured token, so unit tests can
882    /// assert [`request_executor_from_extra`] threads the inbound token (the
883    /// field is otherwise private — Plan 90-10).
884    #[cfg(test)]
885    pub(crate) fn inbound_token_for_test(&self) -> Option<&str> {
886        self.inbound_token.as_deref()
887    }
888
889    /// Substitute `{key}` path-template segments from `body` keys, returning the
890    /// resolved path and the remaining (non-path) body fields.
891    ///
892    /// Lifted from the pmcp-run reference `execute_request` (kept a free helper
893    /// so the trait method stays under the cog ≤25 budget).
894    ///
895    /// # Errors
896    ///
897    /// Returns [`ExecutionError::RuntimeError`] naming the offending key when a
898    /// `{key}` path value is a non-scalar (`Object`/`Array`) — see
899    /// [`HttpCodeExecutor::scalar_str`] for the decided rule (WR-03 / GAP 4).
900    fn resolve_path(
901        path: &str,
902        body: &Option<serde_json::Value>,
903    ) -> std::result::Result<(String, Option<serde_json::Value>), ExecutionError> {
904        let mut resolved_path = path.to_string();
905        let remaining = if let Some(serde_json::Value::Object(obj)) = body {
906            let mut remaining = serde_json::Map::new();
907            for (key, value) in obj {
908                let placeholder = format!("{{{key}}}");
909                if resolved_path.contains(&placeholder) {
910                    resolved_path =
911                        resolved_path.replace(&placeholder, &Self::scalar_str(key, value)?);
912                } else {
913                    remaining.insert(key.clone(), value.clone());
914                }
915            }
916            if remaining.is_empty() {
917                None
918            } else {
919                Some(serde_json::Value::Object(remaining))
920            }
921        } else {
922            body.clone()
923        };
924        Ok((resolved_path, remaining))
925    }
926
927    /// Render a JSON scalar for path / query substitution (strings unquoted),
928    /// REJECTING non-scalar values (WR-03 / GAP 4).
929    ///
930    /// This is the `code_mode` counterpart of [`crate::http::client`]'s
931    /// `render_scalar`; both HTTP surfaces apply the SAME decided rule. Because
932    /// the `Parameter` model carries no OpenAPI `style`/`explode`/`type` hint,
933    /// the rule is uniform: a scalar (`String`, `Number`, `Bool`, `Null`)
934    /// renders to a bare string (`Null` → `"null"`, preserving prior behavior);
935    /// an `Object` or `Array` in a `{path}` substitution or a GET-query field is
936    /// rejected rather than silently JSON-stringified into the URL.
937    ///
938    /// # Errors
939    ///
940    /// Returns [`ExecutionError::RuntimeError`] naming `key` when `value` is a
941    /// non-scalar. Per Pitfall 5 the message names the KEY only — never the value.
942    fn scalar_str(
943        key: &str,
944        value: &serde_json::Value,
945    ) -> std::result::Result<String, ExecutionError> {
946        match value {
947            serde_json::Value::String(s) => Ok(s.clone()),
948            serde_json::Value::Null => Ok("null".to_string()),
949            serde_json::Value::Number(n) => Ok(n.to_string()),
950            serde_json::Value::Bool(b) => Ok(b.to_string()),
951            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
952                Err(ExecutionError::RuntimeError {
953                    message: format!("path/query param '{key}' must be a scalar"),
954                })
955            },
956        }
957    }
958}
959
960#[cfg(feature = "openapi-code-mode")]
961#[pmcp_code_mode::async_trait]
962impl pmcp_code_mode::HttpExecutor for HttpCodeExecutor {
963    async fn execute_request(
964        &self,
965        method: &str,
966        path: &str,
967        body: Option<serde_json::Value>,
968    ) -> std::result::Result<serde_json::Value, ExecutionError> {
969        let upper = method.to_uppercase();
970        let is_get_like = matches!(upper.as_str(), "GET" | "HEAD" | "OPTIONS");
971
972        // (1) Path-param substitution from the body object. A non-scalar `{key}`
973        //     value is rejected (WR-03) rather than JSON-stringified into the URL.
974        let (resolved_path, remaining_body) = Self::resolve_path(path, &body)?;
975
976        // (2) Shared join_url helper (Pitfall 2 — preserves an API-Gateway
977        //     stage prefix; it does NOT use the RFC-3986 path-replacing join).
978        //     join_url does the base+path CONCAT; we still parse the result to
979        //     append query pairs because reqwest 0.13 gates
980        //     RequestBuilder::query behind a `query` feature the toolkit
981        //     deliberately does not enable (Plan 01 Rule 1).
982        let url = crate::http::join_url(&self.base_url, &resolved_path);
983
984        // (3) Apply auth, threading the per-request inbound token (H1). Auth
985        //     failures map to a RuntimeError WITHOUT echoing URL/token
986        //     (Pitfall 5 / T-90-04-01).
987        let mut headers = reqwest::header::HeaderMap::new();
988        let mut auth_query: std::collections::HashMap<String, String> =
989            std::collections::HashMap::new();
990        self.auth
991            .apply(&mut headers, &mut auth_query, self.inbound_token.as_deref())
992            .await
993            .map_err(|_| ExecutionError::RuntimeError {
994                message: "authentication failed for outgoing request".to_string(),
995            })?;
996
997        let mut query_params: Vec<(String, String)> = auth_query.into_iter().collect();
998
999        // (4) For GET-like requests, serialize remaining body fields as query
1000        //     params; otherwise keep them as the JSON body.
1001        let request_body = if is_get_like {
1002            if let Some(serde_json::Value::Object(obj)) = &remaining_body {
1003                for (key, value) in obj {
1004                    // A non-scalar GET-query value is rejected (WR-03) rather than
1005                    // silently JSON-stringified into the URL.
1006                    query_params.push((key.clone(), Self::scalar_str(key, value)?));
1007                }
1008            }
1009            None
1010        } else {
1011            remaining_body
1012        };
1013
1014        // Append query params via url::Url (reqwest 0.13's RequestBuilder::query
1015        // is behind the off-by-default `query` feature; Plan 01 Rule 1).
1016        let final_url = if query_params.is_empty() {
1017            url
1018        } else {
1019            let mut parsed = url::Url::parse(&url).map_err(|_| ExecutionError::RuntimeError {
1020                message: "could not construct the request URL".to_string(),
1021            })?;
1022            {
1023                let mut pairs = parsed.query_pairs_mut();
1024                for (k, v) in &query_params {
1025                    pairs.append_pair(k, v);
1026                }
1027            }
1028            parsed.to_string()
1029        };
1030
1031        let mut request = match upper.as_str() {
1032            "GET" => self.client.get(&final_url),
1033            "POST" => self.client.post(&final_url),
1034            "PUT" => self.client.put(&final_url),
1035            "DELETE" => self.client.delete(&final_url),
1036            "PATCH" => self.client.patch(&final_url),
1037            "HEAD" => self.client.head(&final_url),
1038            _ => {
1039                return Err(ExecutionError::RuntimeError {
1040                    message: "unsupported HTTP method".to_string(),
1041                })
1042            },
1043        };
1044        request = request.headers(headers);
1045        if let Some(b) = request_body {
1046            request = request.header("Content-Type", "application/json").json(&b);
1047        }
1048
1049        // (5) Send + read. Transport / status / parse errors NEVER echo the URL
1050        //     or token (Pitfall 5).
1051        let response = request
1052            .send()
1053            .await
1054            .map_err(|_| ExecutionError::RuntimeError {
1055                message: "outgoing HTTP request failed".to_string(),
1056            })?;
1057        let status = response.status();
1058        let text = response
1059            .text()
1060            .await
1061            .map_err(|_| ExecutionError::RuntimeError {
1062                message: "failed to read response body".to_string(),
1063            })?;
1064        if !status.is_success() {
1065            return Err(ExecutionError::RuntimeError {
1066                message: format!("backend returned HTTP status {}", status.as_u16()),
1067            });
1068        }
1069        if text.is_empty() {
1070            return Ok(serde_json::Value::Null);
1071        }
1072        serde_json::from_str(&text).map_err(|_| ExecutionError::RuntimeError {
1073            message: "failed to parse response body as JSON".to_string(),
1074        })
1075    }
1076}
1077
1078// =============================================================================
1079// Helpers (Pattern G — cog ≤25 each, kept small + explicit)
1080// =============================================================================
1081
1082/// Translate unprefixed toolkit [`CodeModeSection`] fields into pmcp-code-mode's
1083/// `sql_`-prefixed [`CodeModeConfig`].
1084///
1085/// Mapping is **explicit field-by-field** (PATTERNS §10 + D-13). Silent serde
1086/// aliasing would couple the toolkit's stable surface to pmcp-code-mode's
1087/// internal field names — undesirable. Fields on `CodeModeSection` without a
1088/// `CodeModeConfig` counterpart are noted in inline comments rather than
1089/// silently dropped (review R1 + threat T-83-06-04).
1090fn build_cm_config(section: &CodeModeSection) -> CodeModeConfig {
1091    let mut cfg = CodeModeConfig {
1092        enabled: section.enabled,
1093        // SQL policy bits — toolkit's unprefixed names → pmcp_code_mode's sql_-prefixed.
1094        sql_allow_writes: section.allow_writes,
1095        sql_allow_deletes: section.allow_deletes,
1096        sql_allow_ddl: section.allow_ddl,
1097        sql_blocked_tables: section.blocked_tables.iter().cloned().collect(),
1098        sql_blocked_columns: section.sensitive_columns.iter().cloned().collect(),
1099        ..CodeModeConfig::default()
1100    };
1101    if let Some(ref sid) = section.server_id {
1102        cfg.server_id = Some(sid.clone());
1103    }
1104    // Token TTL — both sides use seconds, but pmcp_code_mode uses i64 and the
1105    // toolkit uses Option<u64>. Saturate to i64::MAX rather than wrap.
1106    if let Some(ttl) = section.token_ttl_seconds {
1107        cfg.token_ttl_seconds = i64::try_from(ttl).unwrap_or(i64::MAX);
1108    }
1109    // Auto-approval — toolkit ships risk-level names as strings; the
1110    // pmcp_code_mode side wants RiskLevel enums. Best-effort parse; unrecognised
1111    // entries are silently skipped (operator typos surface as "nothing auto-
1112    // approved" rather than a parse error — by design, since the registry is
1113    // open-ended).
1114    map_auto_approve_levels(&section.auto_approve_levels, &mut cfg);
1115    // `max_limit` (toolkit) corresponds to `sql_max_rows` (pmcp_code_mode).
1116    if let Some(max) = section.max_limit {
1117        cfg.sql_max_rows = max;
1118    }
1119    // `require_limit` (toolkit) → `sql_require_limit` (pmcp_code_mode). Enforced
1120    // in check_sql_config_authorization: a read-only statement without a LIMIT
1121    // is rejected when this is set (closes VERIFICATION Gap 1 — previously this
1122    // field was parsed but discarded, so a low-row no-LIMIT SELECT was accepted
1123    // despite require_limit=true).
1124    cfg.sql_require_limit = section.require_limit;
1125    // [code_mode.limits] — pmcp_code_mode's CodeModeConfig has `max_depth` and
1126    // `max_field_count` (GraphQL-flavoured) but no direct counterparts for
1127    // `max_tables_per_query` / `max_join_depth` / `max_subquery_depth`. These
1128    // toolkit fields are exposed for forward compatibility with Phase 84's
1129    // SQL connector enforcement; they are NOT silently mapped here.
1130    if let Some(ref limits) = section.limits {
1131        let _gap_max_tables = limits.max_tables_per_query;
1132        let _gap_max_join = limits.max_join_depth;
1133        let _gap_max_subquery = limits.max_subquery_depth;
1134    }
1135    cfg
1136}
1137
1138/// Decompose auto-approve-level parsing to keep [`build_cm_config`] under
1139/// Pattern G's cog ≤25 budget.
1140fn map_auto_approve_levels(levels: &[String], cfg: &mut CodeModeConfig) {
1141    use pmcp_code_mode::RiskLevel;
1142    let mut out = Vec::with_capacity(levels.len());
1143    for level in levels {
1144        match level.to_ascii_lowercase().as_str() {
1145            "low" => out.push(RiskLevel::Low),
1146            "medium" => out.push(RiskLevel::Medium),
1147            "high" => out.push(RiskLevel::High),
1148            "critical" => out.push(RiskLevel::Critical),
1149            _ => {
1150                tracing::debug!(
1151                    target: "pmcp_server_toolkit::code_mode",
1152                    "[code_mode] auto_approve_levels: unrecognised level '{}' — skipping",
1153                    level
1154                );
1155            },
1156        }
1157    }
1158    if !out.is_empty() {
1159        cfg.auto_approve_levels = out;
1160    }
1161}
1162
1163/// Extract `NAME` from a string of the exact shape `${NAME}`.
1164///
1165/// Returns `Some(name)` only when `raw` both starts with `${` and ends with `}`
1166/// AND `name` is non-empty. A string that merely *contains* `${` (e.g. an
1167/// Athena `output_location` substring, or a malformed `${` without a closing
1168/// brace) returns `None`, so it falls through to the existing inline-secret
1169/// handling (still rejected unless the dev flag is set). This is what scopes
1170/// `${VAR}` expansion to `token_secret` only and preserves the R9 guarantee
1171/// (REVIEW FIX #6).
1172fn expand_braced_var(raw: &str) -> Option<&str> {
1173    let inner = raw.strip_prefix("${")?.strip_suffix('}')?;
1174    if inner.is_empty() {
1175        return None;
1176    }
1177    Some(inner)
1178}
1179
1180/// Per review R9: `token_secret` is `env:`- or `${VAR}`-only by default. Inline
1181/// literals are REJECTED at config-validation time unless
1182/// `allow_inline_token_secret_for_dev` is set. Returns the resolved bytes
1183/// wrapped in the toolkit-owned [`SecretValue`] (per review R6).
1184///
1185/// Accepted forms:
1186/// - `token_secret = "env:VAR_NAME"` — reads `VAR_NAME` from the process env.
1187/// - `token_secret = "${VAR_NAME}"` — reads `VAR_NAME` from the process env
1188///   (the form every reference SQL-API config emits, Plan 85-01 Gap #3).
1189/// - `token_secret = "raw-string"` — REJECTED unless
1190///   `allow_inline_token_secret_for_dev = true`.
1191///
1192/// A missing/unset env var (either form) returns
1193/// [`ToolkitError::CodeMode`] — never a panic, never a fall-back to a weak or
1194/// empty secret (threat-model item T-85-01-01).
1195/// Read `var` from the process env for `token_secret`, treating a missing OR
1196/// set-but-empty/whitespace value as UNSET (85-10 secondary fix, threat
1197/// T-85-10-03).
1198///
1199/// `HmacTokenGenerator` enforces a 16-byte minimum downstream, but an empty
1200/// (or all-whitespace) env value should surface as a clear "set but empty"
1201/// configuration error at startup — never flow to the HMAC layer as a
1202/// degenerate secret. Both the `env:VAR` and `${VAR}` forms route through here.
1203fn resolve_secret_env_var(var: &str) -> Result<SecretValue> {
1204    let value = std::env::var(var)
1205        .map_err(|_| ToolkitError::CodeMode(format!("env var '{var}' not set for token_secret")))?;
1206    if value.trim().is_empty() {
1207        return Err(ToolkitError::CodeMode(format!(
1208            "env var '{var}' is set but empty for token_secret"
1209        )));
1210    }
1211    Ok(SecretValue::new(value.into_bytes()))
1212}
1213
1214fn resolve_token_secret(section: &CodeModeSection) -> Result<SecretValue> {
1215    let raw = section.token_secret.as_ref().ok_or_else(|| {
1216        ToolkitError::CodeMode(
1217            "[code_mode] token_secret is required when code-mode is enabled".to_string(),
1218        )
1219    })?;
1220    if let Some(var) = raw.strip_prefix("env:") {
1221        return resolve_secret_env_var(var);
1222    }
1223    if let Some(var) = expand_braced_var(raw) {
1224        return resolve_secret_env_var(var);
1225    }
1226    if section.allow_inline_token_secret_for_dev {
1227        tracing::warn!(
1228            target: "pmcp_server_toolkit::code_mode",
1229            "[code_mode] token_secret is inline AND allow_inline_token_secret_for_dev=true; \
1230             accepting under dev/test exception — NEVER set this flag in a committed \
1231             production config"
1232        );
1233        return Ok(SecretValue::new(raw.as_bytes().to_vec()));
1234    }
1235    Err(ToolkitError::Validation(
1236        ConfigValidationError::InlineSecretRejected,
1237    ))
1238}
1239
1240// =============================================================================
1241// TKIT-10 — assemble_code_mode_prompt (D-12 / review R2)
1242// =============================================================================
1243
1244/// TKIT-10: assemble the code-mode bootstrap prompt body from a connector's
1245/// [`SqlConnector::schema_text`] + curated `[[database.tables]]` descriptions.
1246///
1247/// Per Phase 83 review R2 (BOTH reviewers HIGH severity), this function calls
1248/// ONLY [`SqlConnector::schema_text`] — never `execute()`, which is deferred
1249/// to Phase 84. Dialect-aware placeholder GUIDANCE is included even though
1250/// `translate_placeholders` is deferred, because the LLM still benefits from
1251/// knowing the eventual binding shape.
1252///
1253/// # Output structure
1254///
1255/// ```text
1256/// # Code Mode — {dialect.name()}
1257///
1258/// {dialect.placeholder_guidance()}
1259///
1260/// ## Schema
1261///
1262/// {connector.schema_text()}
1263///
1264/// ## Curated Tables
1265///
1266/// - `table_a`: description A
1267/// - `table_b`: description B
1268/// ```
1269///
1270/// The "Curated Tables" section is omitted entirely when
1271/// `config.database.tables` is empty OR every entry has no `description`.
1272/// Entries with `description = None` are skipped individually.
1273///
1274/// # Errors
1275///
1276/// Returns [`ToolkitError::CodeMode`] if `connector.schema_text()` fails.
1277/// The toolkit does not retry; callers should ensure the connector is ready
1278/// before assembling.
1279///
1280/// # Example
1281///
1282/// ```no_run
1283/// use pmcp_server_toolkit::code_mode::assemble_code_mode_prompt;
1284/// use pmcp_server_toolkit::config::ServerConfig;
1285/// use pmcp_server_toolkit::sql::SqlConnector;
1286///
1287/// async fn assemble<C: SqlConnector>(connector: &C, config: &ServerConfig) {
1288///     let prompt = assemble_code_mode_prompt(connector, config).await.unwrap();
1289///     assert!(prompt.contains("# Code Mode"));
1290/// }
1291/// ```
1292pub async fn assemble_code_mode_prompt(
1293    connector: &(dyn SqlConnector + '_),
1294    config: &ServerConfig,
1295) -> Result<String> {
1296    let dialect = connector.dialect();
1297    let schema_text = connector
1298        .schema_text()
1299        .await
1300        .map_err(|e| ToolkitError::CodeMode(format!("schema_text failed: {e}")))?;
1301
1302    let curated = format_curated_tables(config);
1303
1304    let mut out = String::with_capacity(schema_text.len() + curated.len() + 256);
1305    out.push_str("# Code Mode — ");
1306    out.push_str(dialect.name());
1307    out.push_str("\n\n");
1308    out.push_str(dialect.placeholder_guidance());
1309    out.push_str("\n\n## Schema\n\n");
1310    out.push_str(&schema_text);
1311    if !curated.is_empty() {
1312        out.push_str("\n\n## Curated Tables\n\n");
1313        out.push_str(&curated);
1314    }
1315    out.push('\n');
1316    Ok(out)
1317}
1318
1319/// Alias for [`assemble_code_mode_prompt`] satisfying CONN-04's literal naming.
1320///
1321/// Identical behavior; both names are valid public surface. Per Phase 84 D-12 +
1322/// RESEARCH §"Open Questions" Q2 / Landmine #15 the recommendation is an
1323/// alias-next-to (no deprecation attribute on either name), matching the P83
1324/// dual-naming precedent (`register_code_mode_tools` vs
1325/// `code_mode_tools_from_executor`).
1326///
1327/// # Errors
1328///
1329/// Returns [`ToolkitError::CodeMode`] if `connector.schema_text()` fails —
1330/// surfaced verbatim from [`assemble_code_mode_prompt`].
1331///
1332/// # Example
1333///
1334/// ```no_run
1335/// use pmcp_server_toolkit::code_mode::build_code_mode_prompt;
1336/// use pmcp_server_toolkit::config::ServerConfig;
1337/// use pmcp_server_toolkit::sql::SqlConnector;
1338///
1339/// async fn assemble<C: SqlConnector>(connector: &C, config: &ServerConfig) {
1340///     let prompt = build_code_mode_prompt(connector, config).await.unwrap();
1341///     assert!(prompt.contains("# Code Mode"));
1342/// }
1343/// ```
1344pub async fn build_code_mode_prompt(
1345    connector: &(dyn SqlConnector + '_),
1346    config: &ServerConfig,
1347) -> Result<String> {
1348    assemble_code_mode_prompt(connector, config).await
1349}
1350
1351/// File-based counterpart to [`assemble_code_mode_prompt`] — assemble the
1352/// code-mode prompt body from a `--schema` file's text WITHOUT any live
1353/// connector introspection (Plan 85-02 Task 3 / D-04 / D-05).
1354///
1355/// This is a SYNC fn taking the [`Dialect`] + the already-loaded `schema_text`
1356/// directly, so it can NEVER trigger a [`SqlConnector::schema_text`] round-trip.
1357/// For lazy / network-backed non-SQLite connectors that matters: the
1358/// connector-based [`assemble_code_mode_prompt`] would hit the network at prompt
1359/// time (breaking SC-1), and it would surface the LIVE schema rather than the
1360/// admin-redacted `--schema` file. Routing the `--schema` file content through
1361/// THIS helper makes the file the single source of truth — what's in the file
1362/// is exactly what the client sees (the D-05 redaction guarantee).
1363///
1364/// # Output structure
1365///
1366/// Mirrors [`assemble_code_mode_prompt`] except the schema block is preceded by
1367/// a `# Database Schema` header (REVIEW FIX — Gemini LOW, folded here per D-05;
1368/// the header text is kept identical to the resource-surface
1369/// `merge_schema_resource` helper Plan 05 uses, so prompt + resource parity
1370/// holds):
1371///
1372/// ```text
1373/// # Code Mode — {dialect.name()}
1374///
1375/// {dialect.placeholder_guidance()}
1376///
1377/// ## Schema
1378///
1379/// # Database Schema
1380///
1381/// {schema_text}
1382///
1383/// ## Curated Tables
1384///
1385/// - `table_a`: description A
1386/// ```
1387///
1388/// An empty `schema_text` still produces a valid (non-panicking) prompt with
1389/// the `# Code Mode` header present.
1390#[must_use]
1391pub fn assemble_code_mode_prompt_with_schema(
1392    schema_text: &str,
1393    dialect: Dialect,
1394    config: &ServerConfig,
1395) -> String {
1396    const SCHEMA_HEADER: &str = "# Database Schema\n\n";
1397
1398    let curated = format_curated_tables(config);
1399
1400    let mut out = String::with_capacity(schema_text.len() + curated.len() + 256);
1401    out.push_str("# Code Mode — ");
1402    out.push_str(dialect.name());
1403    out.push_str("\n\n");
1404    out.push_str(dialect.placeholder_guidance());
1405    out.push_str("\n\n## Schema\n\n");
1406    out.push_str(SCHEMA_HEADER);
1407    out.push_str(schema_text);
1408    if !curated.is_empty() {
1409        out.push_str("\n\n## Curated Tables\n\n");
1410        out.push_str(&curated);
1411    }
1412    out.push('\n');
1413    out
1414}
1415
1416/// Format the `[[database.tables]]` curated descriptions as a Markdown list.
1417///
1418/// Entries with no `description` are skipped. Returns an empty string when no
1419/// described entries exist; callers use that as the signal to omit the whole
1420/// "Curated Tables" section (keeping the prompt body tight).
1421fn format_curated_tables(config: &ServerConfig) -> String {
1422    config
1423        .database
1424        .tables
1425        .iter()
1426        .filter_map(|t| {
1427            t.description
1428                .as_deref()
1429                .filter(|d| !d.is_empty())
1430                .map(|d| format!("- `{}`: {}", t.name, d))
1431        })
1432        .collect::<Vec<_>>()
1433        .join("\n")
1434}
1435
1436// =============================================================================
1437// Unit tests
1438// =============================================================================
1439
1440/// Process-global lock serializing every test that reads or mutates the shared
1441/// process environment via `std::env::{set_var, remove_var}`.
1442///
1443/// Those calls are process-global and not thread-safe, so under the default
1444/// multi-threaded test runner the env-touching tests in this file's `tests` and
1445/// `sql_code_executor_tests` modules otherwise interleave and corrupt each
1446/// other's variables (e.g. an executor build fails to read the `TEST_SECRET_VAR`
1447/// it just set). Acquire the guard around each synchronous env-op group; NEVER
1448/// hold it across an `.await` (the `std` `MutexGuard` is `!Send`, and tokio's
1449/// multi-thread runtime requires the test future to be `Send`).
1450#[cfg(test)]
1451mod test_env_guard {
1452    use std::sync::{Mutex, MutexGuard};
1453
1454    static ENV_LOCK: Mutex<()> = Mutex::new(());
1455
1456    /// Lock the process-env mutex, recovering from poisoning so a panicking
1457    /// test does not cascade-fail its siblings.
1458    pub(super) fn lock() -> MutexGuard<'static, ()> {
1459        ENV_LOCK
1460            .lock()
1461            .unwrap_or_else(|poisoned| poisoned.into_inner())
1462    }
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467    use super::*;
1468    use crate::config::{CodeModeLimits, CodeModeSection};
1469
1470    /// Compile-only assertion that the headline re-exports resolve at the
1471    /// `code_mode::*` path (TKIT-06 + D-16 + R3).
1472    #[allow(dead_code)]
1473    const _RE_EXPORTS_COMPILE: fn() = || {
1474        let _: Option<Box<dyn CodeExecutor>> = None;
1475        let _: Option<Box<dyn PolicyEvaluator>> = None;
1476        let _: Option<ApprovalToken> = None;
1477        let _: Option<HmacTokenGenerator> = None;
1478        let _: Option<TokenSecret> = None;
1479        let _: Option<NoopPolicyEvaluator> = None;
1480        let _: Option<ValidationPipeline> = None;
1481        let _: Option<ValidationContext> = None;
1482        let _: Option<CodeModeConfig> = None;
1483        let _: Option<AuthorizationDecision> = None;
1484        let _hash = canonicalize_code;
1485        let _ctx = compute_context_hash;
1486        let _h = hash_code;
1487    };
1488
1489    /// Lightweight test fixture: a `CodeModeSection` with all required fields
1490    /// populated for env-style secret resolution.
1491    fn env_section(var: &str) -> CodeModeSection {
1492        CodeModeSection {
1493            enabled: true,
1494            server_id: Some("test-server".to_string()),
1495            allow_writes: false,
1496            allow_deletes: false,
1497            allow_ddl: false,
1498            require_limit: false,
1499            max_limit: Some(1000),
1500            blocked_tables: vec![],
1501            sensitive_columns: vec![],
1502            auto_approve_levels: vec!["low".to_string()],
1503            token_ttl_seconds: Some(300),
1504            token_secret: Some(format!("env:{var}")),
1505            allow_inline_token_secret_for_dev: false,
1506            limits: Some(CodeModeLimits {
1507                max_tables_per_query: Some(5),
1508                max_join_depth: Some(3),
1509                max_subquery_depth: Some(2),
1510            }),
1511        }
1512    }
1513
1514    #[test]
1515    fn build_cm_config_maps_allow_writes() {
1516        let mut section = env_section("UNUSED");
1517        section.allow_writes = true;
1518        let cfg = build_cm_config(&section);
1519        assert!(
1520            cfg.sql_allow_writes,
1521            "unprefixed allow_writes=true must map to sql_allow_writes=true"
1522        );
1523        assert!(cfg.enabled);
1524        assert_eq!(cfg.server_id.as_deref(), Some("test-server"));
1525        // max_limit → sql_max_rows
1526        assert_eq!(cfg.sql_max_rows, 1000);
1527        // token_ttl_seconds → i64
1528        assert_eq!(cfg.token_ttl_seconds, 300);
1529    }
1530
1531    #[test]
1532    fn build_cm_config_maps_require_limit_true() {
1533        // VERIFICATION Gap 1: toolkit `require_limit` must flow to the enforced
1534        // pmcp-code-mode `sql_require_limit` (previously discarded).
1535        let mut section = env_section("UNUSED");
1536        section.require_limit = true;
1537        let cfg = build_cm_config(&section);
1538        assert!(
1539            cfg.sql_require_limit,
1540            "require_limit=true must map to sql_require_limit=true"
1541        );
1542    }
1543
1544    #[test]
1545    fn build_cm_config_maps_require_limit_false() {
1546        let mut section = env_section("UNUSED");
1547        section.require_limit = false;
1548        let cfg = build_cm_config(&section);
1549        assert!(
1550            !cfg.sql_require_limit,
1551            "require_limit=false must map to sql_require_limit=false"
1552        );
1553    }
1554
1555    #[test]
1556    fn build_cm_config_propagates_blocked_tables() {
1557        let mut section = env_section("UNUSED");
1558        section.blocked_tables = vec!["users".into(), "secrets".into()];
1559        section.sensitive_columns = vec!["users.password".into()];
1560        let cfg = build_cm_config(&section);
1561        assert!(cfg.sql_blocked_tables.contains("users"));
1562        assert!(cfg.sql_blocked_tables.contains("secrets"));
1563        assert!(cfg.sql_blocked_columns.contains("users.password"));
1564    }
1565
1566    #[test]
1567    fn resolve_token_secret_env_reference_succeeds() {
1568        let _env = super::test_env_guard::lock();
1569        const VAR: &str = "PMCP_TOOLKIT_CODE_MODE_TEST_RESOLVE_ENV";
1570        // Long enough to satisfy HmacTokenGenerator::MIN_SECRET_LEN (16 bytes).
1571        std::env::set_var(VAR, "a-test-secret-bytes-16-or-more");
1572        let section = env_section(VAR);
1573        let resolved = resolve_token_secret(&section).expect("env resolution must succeed");
1574        assert_eq!(resolved.expose_secret(), b"a-test-secret-bytes-16-or-more");
1575        std::env::remove_var(VAR);
1576    }
1577
1578    #[test]
1579    fn resolve_token_secret_inline_without_dev_flag_rejected() {
1580        // R9 — inline literal + flag absent → InlineSecretRejected.
1581        let mut section = env_section("UNUSED");
1582        section.token_secret = Some("raw-string-that-should-be-rejected".to_string());
1583        section.allow_inline_token_secret_for_dev = false;
1584        // SecretValue intentionally does not implement Debug (R5 invariant),
1585        // so we cannot use `expect_err` directly on Result<SecretValue, _>.
1586        match resolve_token_secret(&section) {
1587            Ok(_) => panic!("must reject inline literal"),
1588            Err(ToolkitError::Validation(ConfigValidationError::InlineSecretRejected)) => {},
1589            Err(other) => panic!("expected InlineSecretRejected, got {other:?}"),
1590        }
1591    }
1592
1593    #[test]
1594    fn resolve_token_secret_inline_with_dev_flag_accepted() {
1595        // R9 — inline literal + dev flag → accepted (with tracing::warn).
1596        let mut section = env_section("UNUSED");
1597        section.token_secret = Some("a-test-secret-bytes-16-or-more".to_string());
1598        section.allow_inline_token_secret_for_dev = true;
1599        let resolved = resolve_token_secret(&section).expect("dev flag must permit inline literal");
1600        assert_eq!(resolved.expose_secret(), b"a-test-secret-bytes-16-or-more");
1601    }
1602
1603    #[test]
1604    fn resolve_token_secret_empty_env_var_is_set_but_empty_error() {
1605        let _env = super::test_env_guard::lock();
1606        // 85-10 / T-85-10-03: a set-but-EMPTY env value must NOT flow to the
1607        // HMAC layer as a degenerate secret — it surfaces as a clear
1608        // "set but empty" CodeMode error (env: form).
1609        const VAR: &str = "PMCP_TOOLKIT_CODE_MODE_TEST_EMPTY_ENV";
1610        std::env::set_var(VAR, "");
1611        let section = env_section(VAR);
1612        let outcome = resolve_token_secret(&section);
1613        std::env::remove_var(VAR);
1614        match outcome {
1615            Ok(_) => panic!("empty env var must error, not yield an empty secret"),
1616            Err(ToolkitError::CodeMode(msg)) => {
1617                assert!(
1618                    msg.contains(VAR) && msg.contains("set but empty"),
1619                    "error must name the var as set-but-empty, got: {msg}"
1620                );
1621            },
1622            Err(other) => panic!("expected CodeMode 'set but empty', got {other:?}"),
1623        }
1624    }
1625
1626    #[test]
1627    fn resolve_token_secret_whitespace_env_var_is_set_but_empty_error() {
1628        let _env = super::test_env_guard::lock();
1629        // All-whitespace is treated the same as empty (${VAR} form).
1630        const VAR: &str = "PMCP_TOOLKIT_CODE_MODE_TEST_WS_ENV";
1631        std::env::set_var(VAR, "   ");
1632        let mut section = env_section("UNUSED");
1633        section.token_secret = Some(format!("${{{VAR}}}"));
1634        let outcome = resolve_token_secret(&section);
1635        std::env::remove_var(VAR);
1636        match outcome {
1637            Ok(_) => panic!("whitespace-only env var must error"),
1638            Err(ToolkitError::CodeMode(msg)) => {
1639                assert!(
1640                    msg.contains(VAR) && msg.contains("set but empty"),
1641                    "error must name the var as set-but-empty, got: {msg}"
1642                );
1643            },
1644            Err(other) => panic!("expected CodeMode 'set but empty', got {other:?}"),
1645        }
1646    }
1647
1648    #[test]
1649    fn variables_to_params_maps_object_stripping_colon_prefix() {
1650        // 85-10 WR-02: a JSON object of name→value becomes (name, value) pairs,
1651        // with a leading `:` stripped to match the connector's keying.
1652        let vars = serde_json::json!({ ":name": "Rock", "limit": 5 });
1653        let mut params = variables_to_params(Some(&vars));
1654        params.sort_by(|a, b| a.0.cmp(&b.0));
1655        assert_eq!(
1656            params,
1657            vec![
1658                ("limit".to_string(), serde_json::json!(5)),
1659                ("name".to_string(), serde_json::json!("Rock")),
1660            ]
1661        );
1662    }
1663
1664    #[test]
1665    fn variables_to_params_none_or_non_object_is_empty() {
1666        // None / non-object yields an empty slice — the parity execute_code
1667        // scenario (passes None) is unaffected.
1668        assert!(variables_to_params(None).is_empty());
1669        assert!(variables_to_params(Some(&serde_json::json!("not-an-object"))).is_empty());
1670        assert!(variables_to_params(Some(&serde_json::json!([1, 2, 3]))).is_empty());
1671    }
1672
1673    #[test]
1674    fn resolve_token_secret_missing_env_var_surfaces_error() {
1675        // Use a var name that is overwhelmingly unlikely to be set in CI.
1676        let section = env_section("PMCP_TOOLKIT_DEFINITELY_NOT_SET_FOR_TEST");
1677        // SecretValue has no Debug — pattern-match instead of expect_err.
1678        match resolve_token_secret(&section) {
1679            Ok(_) => panic!("missing env var must error"),
1680            Err(ToolkitError::CodeMode(msg)) => {
1681                assert!(
1682                    msg.contains("PMCP_TOOLKIT_DEFINITELY_NOT_SET_FOR_TEST"),
1683                    "error message must name the missing env var, got: {msg}"
1684                );
1685            },
1686            Err(other) => panic!("expected CodeMode error, got {other:?}"),
1687        }
1688    }
1689}
1690
1691// =============================================================================
1692// SHAP-A-01 — SqlCodeExecutor unit tests (Plan 85-02 Task 1)
1693// =============================================================================
1694
1695#[cfg(all(test, feature = "sqlite"))]
1696mod sql_code_executor_tests {
1697    use super::*;
1698    use crate::config::{CodeModeSection, ServerConfig, ServerSection};
1699    use crate::sql::SqliteConnector;
1700
1701    const TEST_SECRET_VAR: &str = "PMCP_TOOLKIT_SQL_EXECUTOR_TEST_SECRET";
1702
1703    fn ensure_secret() {
1704        std::env::set_var(TEST_SECRET_VAR, "executor-test-secret-16-or-more");
1705    }
1706
1707    /// A read-only `[code_mode]` config (no writes/deletes/DDL) plus an
1708    /// in-memory SQLite connector seeded with a single `Artist` row.
1709    async fn read_only_executor() -> SqlCodeExecutor {
1710        let connector = SqliteConnector::open_in_memory().expect("open in-memory sqlite");
1711        connector
1712            .execute(
1713                "CREATE TABLE Artist (ArtistId INTEGER PRIMARY KEY, Name TEXT)",
1714                &[],
1715            )
1716            .await
1717            .expect("create table");
1718        connector
1719            .execute(
1720                "INSERT INTO Artist (ArtistId, Name) VALUES (1, 'AC/DC')",
1721                &[],
1722            )
1723            .await
1724            .expect("seed row");
1725
1726        let config = ServerConfig {
1727            server: ServerSection {
1728                name: "executor-test".to_string(),
1729                version: "0.1.0".to_string(),
1730                ..Default::default()
1731            },
1732            code_mode: Some(CodeModeSection {
1733                enabled: true,
1734                server_id: Some("executor-test".to_string()),
1735                allow_writes: false,
1736                allow_deletes: false,
1737                allow_ddl: false,
1738                token_secret: Some(format!("env:{TEST_SECRET_VAR}")),
1739                ..Default::default()
1740            }),
1741            ..Default::default()
1742        };
1743        // Serialize set-secret + env-read (build) so a concurrent test cannot
1744        // corrupt the process environment between them. Synchronous — no
1745        // `.await` inside the locked section (the `std` guard is `!Send`).
1746        let _env = super::test_env_guard::lock();
1747        ensure_secret();
1748        SqlCodeExecutor::new(Arc::new(connector), config).expect("build executor")
1749    }
1750
1751    /// Same in-memory connector as [`read_only_executor`], but the `[code_mode]`
1752    /// config sets `require_limit = true` so a bare SELECT must reject on policy.
1753    async fn read_only_executor_with_require_limit() -> SqlCodeExecutor {
1754        let connector = SqliteConnector::open_in_memory().expect("open in-memory sqlite");
1755        connector
1756            .execute(
1757                "CREATE TABLE Artist (ArtistId INTEGER PRIMARY KEY, Name TEXT)",
1758                &[],
1759            )
1760            .await
1761            .expect("create table");
1762        connector
1763            .execute(
1764                "INSERT INTO Artist (ArtistId, Name) VALUES (1, 'AC/DC')",
1765                &[],
1766            )
1767            .await
1768            .expect("seed row");
1769
1770        let config = ServerConfig {
1771            server: ServerSection {
1772                name: "executor-test".to_string(),
1773                version: "0.1.0".to_string(),
1774                ..Default::default()
1775            },
1776            code_mode: Some(CodeModeSection {
1777                enabled: true,
1778                server_id: Some("executor-test".to_string()),
1779                allow_writes: false,
1780                allow_deletes: false,
1781                allow_ddl: false,
1782                require_limit: true,
1783                token_secret: Some(format!("env:{TEST_SECRET_VAR}")),
1784                ..Default::default()
1785            }),
1786            ..Default::default()
1787        };
1788        // Serialize set-secret + env-read (build) so a concurrent test cannot
1789        // corrupt the process environment between them. Synchronous — no
1790        // `.await` inside the locked section (the `std` guard is `!Send`).
1791        let _env = super::test_env_guard::lock();
1792        ensure_secret();
1793        SqlCodeExecutor::new(Arc::new(connector), config).expect("build executor")
1794    }
1795
1796    #[tokio::test]
1797    async fn read_only_select_returns_rows() {
1798        let executor = read_only_executor().await;
1799        let result = executor
1800            .execute("SELECT ArtistId, Name FROM Artist", None)
1801            .await
1802            .expect("read-only SELECT must succeed under a read-only policy");
1803        // Mirrors production's observable `"rows"` key (REVIEW FIX #6b).
1804        let rows = result.get("rows").expect("payload has a `rows` key");
1805        let arr = rows.as_array().expect("`rows` is an array");
1806        assert_eq!(arr.len(), 1, "one seeded row expected, got {arr:?}");
1807        assert_eq!(arr[0]["Name"], "AC/DC");
1808    }
1809
1810    #[tokio::test]
1811    async fn require_limit_rejects_bare_select_before_connector() {
1812        // VERIFICATION Gap 1: with require_limit=true, a no-LIMIT SELECT is
1813        // rejected on re-validation BEFORE the connector — even though the
1814        // single seeded row never exceeds any row-count limit.
1815        let executor = read_only_executor_with_require_limit().await;
1816        let err = executor
1817            .execute("SELECT * FROM Artist", None)
1818            .await
1819            .expect_err("bare SELECT must be rejected when require_limit=true");
1820        assert!(
1821            matches!(err, ExecutionError::BackendError(_)),
1822            "expected a policy-rejection BackendError, got {err:?}"
1823        );
1824        // The table is untouched — proving the rejection is the require_limit
1825        // policy, not a row-count failure.
1826        let count = executor
1827            .connector
1828            .execute("SELECT COUNT(*) AS n FROM Artist", &[])
1829            .await
1830            .expect("count query");
1831        assert_eq!(count[0]["n"], 1, "row count must be unchanged");
1832    }
1833
1834    #[tokio::test]
1835    async fn require_limit_allows_limited_select() {
1836        let executor = read_only_executor_with_require_limit().await;
1837        let result = executor
1838            .execute("SELECT ArtistId, Name FROM Artist LIMIT 5", None)
1839            .await
1840            .expect("a LIMITed SELECT must succeed under require_limit=true");
1841        let rows = result.get("rows").expect("payload has a `rows` key");
1842        let arr = rows.as_array().expect("`rows` is an array");
1843        assert_eq!(arr.len(), 1, "one seeded row expected, got {arr:?}");
1844    }
1845
1846    #[tokio::test]
1847    async fn delete_rejected_before_connector_under_read_only_policy() {
1848        // allow_deletes=false → re-validation rejects DELETE BEFORE the
1849        // connector is reached (threat T-85-02-01 / SC-3).
1850        let executor = read_only_executor().await;
1851        let err = executor
1852            .execute("DELETE FROM Artist WHERE ArtistId = 1", None)
1853            .await
1854            .expect_err("DELETE must be rejected when allow_deletes=false");
1855        assert!(
1856            matches!(err, ExecutionError::BackendError(_)),
1857            "expected a policy-rejection BackendError, got {err:?}"
1858        );
1859        // The row must still be present — proving the connector was never reached.
1860        let still_there = executor
1861            .connector
1862            .execute("SELECT COUNT(*) AS n FROM Artist", &[])
1863            .await
1864            .expect("count query");
1865        assert_eq!(still_there[0]["n"], 1, "DELETE must not have run");
1866    }
1867
1868    #[tokio::test]
1869    async fn ddl_rejected_under_read_only_policy() {
1870        // allow_ddl=false → re-validation rejects DROP TABLE.
1871        let executor = read_only_executor().await;
1872        let err = executor
1873            .execute("DROP TABLE Artist", None)
1874            .await
1875            .expect_err("DROP must be rejected when allow_ddl=false");
1876        assert!(matches!(err, ExecutionError::BackendError(_)));
1877    }
1878
1879    #[tokio::test]
1880    async fn malformed_sql_returns_err_never_panics() {
1881        let executor = read_only_executor().await;
1882        let result = executor.execute("SELEC nonsense FRM", None).await;
1883        assert!(
1884            result.is_err(),
1885            "malformed SQL must surface an Err, never panic"
1886        );
1887    }
1888
1889    #[tokio::test]
1890    async fn execute_binds_variables_input() {
1891        // 85-10 WR-02 / T-85-10-01: the schema-advertised `variables` input is
1892        // BOUND as named params (not silently dropped), so a `WHERE Name = :name`
1893        // resolves against the seeded row.
1894        let executor = read_only_executor().await;
1895        let vars = serde_json::json!({ ":name": "AC/DC" });
1896        let result = executor
1897            .execute(
1898                "SELECT ArtistId FROM Artist WHERE Name = :name",
1899                Some(&vars),
1900            )
1901            .await
1902            .expect("bound variable must resolve the WHERE clause");
1903        let rows = result.get("rows").expect("payload has a `rows` key");
1904        let arr = rows.as_array().expect("`rows` is an array");
1905        assert_eq!(arr.len(), 1, "the bound :name must match the seeded row");
1906        assert_eq!(arr[0]["ArtistId"], 1);
1907    }
1908
1909    #[tokio::test]
1910    async fn execute_empty_variables_is_unaffected() {
1911        // An empty variables map binds nothing — identical to today's None path.
1912        let executor = read_only_executor().await;
1913        let empty = serde_json::json!({});
1914        let result = executor
1915            .execute("SELECT ArtistId, Name FROM Artist", Some(&empty))
1916            .await
1917            .expect("empty variables must behave exactly like None");
1918        let arr = result["rows"].as_array().expect("`rows` array");
1919        assert_eq!(arr.len(), 1);
1920    }
1921
1922    #[tokio::test]
1923    async fn pipeline_cached_at_construction_not_reread_per_execute() {
1924        // 85-10 IN-01 / T-85-10-03: the pipeline is built ONCE in `new`, so a
1925        // SECOND execute does NOT re-resolve the token_secret env var. Remove the
1926        // env var after construction — the executor must STILL succeed (proving
1927        // it did not re-read the now-missing secret).
1928        let executor = read_only_executor().await;
1929        // First execute (baseline) succeeds.
1930        executor
1931            .execute("SELECT ArtistId FROM Artist LIMIT 1", None)
1932            .await
1933            .expect("first execute succeeds");
1934        // Remove the secret the pipeline was built from. Each discrete env
1935        // mutation is serialized under the shared lock (held only across the
1936        // synchronous call, never across the `.await`s above/below).
1937        {
1938            let _env = super::test_env_guard::lock();
1939            std::env::remove_var(TEST_SECRET_VAR);
1940        }
1941        // Second execute STILL succeeds — the cached pipeline never re-reads env.
1942        let result = executor
1943            .execute("SELECT ArtistId FROM Artist LIMIT 1", None)
1944            .await
1945            .expect("second execute must succeed from the cached pipeline");
1946        // Restore for any sibling tests sharing the process env.
1947        {
1948            let _env = super::test_env_guard::lock();
1949            ensure_secret();
1950        }
1951        assert!(result.get("rows").is_some());
1952    }
1953}
1954
1955// =============================================================================
1956// TKIT-10 — assemble_code_mode_prompt integration tests
1957// =============================================================================
1958
1959#[cfg(test)]
1960mod tkit10_tests {
1961    use super::*;
1962    use crate::config::{DatabaseSection, DatabaseTableDecl, ServerConfig, ServerSection};
1963    use crate::sql::{Dialect, MockSqlConnector};
1964
1965    fn make_cfg(tables: Vec<DatabaseTableDecl>) -> ServerConfig {
1966        ServerConfig {
1967            server: ServerSection {
1968                name: "test".to_string(),
1969                version: "0.1.0".to_string(),
1970                ..Default::default()
1971            },
1972            database: DatabaseSection {
1973                tables,
1974                ..Default::default()
1975            },
1976            ..Default::default()
1977        }
1978    }
1979
1980    #[tokio::test]
1981    async fn assemble_includes_schema_text_and_dialect_name() {
1982        let connector = MockSqlConnector {
1983            dialect: Dialect::Postgres,
1984            schema: "CREATE TABLE users (id SERIAL PRIMARY KEY);".to_string(),
1985        };
1986        let cfg = make_cfg(vec![]);
1987        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
1988        assert!(
1989            prompt.contains("# Code Mode — PostgreSQL"),
1990            "prompt missing dialect header: {prompt}"
1991        );
1992        assert!(
1993            prompt.contains("CREATE TABLE users"),
1994            "prompt missing schema body: {prompt}"
1995        );
1996        assert!(
1997            prompt.contains("$1"),
1998            "Postgres guidance should mention $1: {prompt}"
1999        );
2000    }
2001
2002    #[tokio::test]
2003    async fn assemble_includes_curated_descriptions() {
2004        let connector = MockSqlConnector {
2005            dialect: Dialect::Athena,
2006            schema: "(see Glue catalog)".to_string(),
2007        };
2008        let cfg = make_cfg(vec![
2009            DatabaseTableDecl {
2010                name: "users".to_string(),
2011                description: Some("App users".to_string()),
2012            },
2013            DatabaseTableDecl {
2014                name: "orders".to_string(),
2015                description: Some("Customer orders".to_string()),
2016            },
2017        ]);
2018        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
2019        assert!(
2020            prompt.contains("## Curated Tables"),
2021            "prompt missing curated header: {prompt}"
2022        );
2023        assert!(
2024            prompt.contains("`users`: App users"),
2025            "prompt missing users description: {prompt}"
2026        );
2027        assert!(
2028            prompt.contains("`orders`: Customer orders"),
2029            "prompt missing orders description: {prompt}"
2030        );
2031        // Athena uses ? placeholders, not $1
2032        assert!(
2033            prompt.contains("Amazon Athena"),
2034            "prompt missing Athena dialect name: {prompt}"
2035        );
2036    }
2037
2038    #[tokio::test]
2039    async fn assemble_omits_curated_section_when_tables_empty() {
2040        let connector = MockSqlConnector {
2041            dialect: Dialect::Sqlite,
2042            schema: "CREATE TABLE t (id INTEGER PRIMARY KEY);".to_string(),
2043        };
2044        let cfg = make_cfg(vec![]);
2045        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
2046        assert!(
2047            !prompt.contains("## Curated Tables"),
2048            "empty [[database.tables]] must omit curated section: {prompt}"
2049        );
2050        assert!(
2051            prompt.contains("SQLite"),
2052            "prompt missing SQLite dialect name: {prompt}"
2053        );
2054    }
2055
2056    #[tokio::test]
2057    async fn assemble_skips_tables_without_descriptions() {
2058        // A described entry mixed with an undescribed one — only the described
2059        // row should render. Curated section still emits because at least one
2060        // row qualifies.
2061        let connector = MockSqlConnector {
2062            dialect: Dialect::MySql,
2063            schema: "CREATE TABLE t (id INT);".to_string(),
2064        };
2065        let cfg = make_cfg(vec![
2066            DatabaseTableDecl {
2067                name: "with_desc".to_string(),
2068                description: Some("has description".to_string()),
2069            },
2070            DatabaseTableDecl {
2071                name: "no_desc".to_string(),
2072                description: None,
2073            },
2074        ]);
2075        let prompt = assemble_code_mode_prompt(&connector, &cfg).await.unwrap();
2076        assert!(prompt.contains("`with_desc`: has description"));
2077        assert!(
2078            !prompt.contains("`no_desc`"),
2079            "undescribed table must not appear in curated section: {prompt}"
2080        );
2081    }
2082
2083    // =========================================================================
2084    // assemble_code_mode_prompt_with_schema — file-based prompt seam (Task 3)
2085    // =========================================================================
2086
2087    #[test]
2088    fn with_schema_includes_header_dialect_schema_and_curated() {
2089        let cfg = make_cfg(vec![DatabaseTableDecl {
2090            name: "Artist".to_string(),
2091            description: Some("Musical artists".to_string()),
2092        }]);
2093        let schema = "CREATE TABLE Artist (ArtistId INTEGER PRIMARY KEY, Name TEXT);";
2094        let prompt = assemble_code_mode_prompt_with_schema(schema, Dialect::Sqlite, &cfg);
2095
2096        assert!(
2097            prompt.contains("# Code Mode"),
2098            "missing code-mode header: {prompt}"
2099        );
2100        assert!(prompt.contains("SQLite"), "missing dialect name: {prompt}");
2101        assert!(
2102            prompt.contains("# Database Schema"),
2103            "missing schema-resource header: {prompt}"
2104        );
2105        assert!(
2106            prompt.contains(schema),
2107            "schema text must appear verbatim: {prompt}"
2108        );
2109        assert!(
2110            prompt.contains("`Artist`: Musical artists"),
2111            "curated table description must appear: {prompt}"
2112        );
2113    }
2114
2115    /// The helper is a SYNC fn — this test calls it from a non-async context,
2116    /// which only compiles because it never awaits a connector (proving it
2117    /// cannot trigger a live `schema_text()`).
2118    #[test]
2119    fn with_schema_is_sync_and_uses_passed_dialect() {
2120        let cfg = make_cfg(vec![]);
2121        let prompt = assemble_code_mode_prompt_with_schema(
2122            "CREATE TABLE t (id INT);",
2123            Dialect::Postgres,
2124            &cfg,
2125        );
2126        assert!(
2127            prompt.contains("# Code Mode — PostgreSQL"),
2128            "passed dialect must drive the header: {prompt}"
2129        );
2130        // Postgres placeholder guidance mentions $1 — proves dialect param is used.
2131        assert!(prompt.contains("$1"), "Postgres guidance missing: {prompt}");
2132        // No curated section when [[database.tables]] is empty.
2133        assert!(
2134            !prompt.contains("## Curated Tables"),
2135            "empty tables must omit curated section: {prompt}"
2136        );
2137    }
2138
2139    #[test]
2140    fn with_schema_empty_text_still_has_header() {
2141        let cfg = make_cfg(vec![]);
2142        let prompt = assemble_code_mode_prompt_with_schema("", Dialect::MySql, &cfg);
2143        assert!(
2144            prompt.contains("# Code Mode — MySQL"),
2145            "empty schema must still produce a valid prompt with the header: {prompt}"
2146        );
2147        assert!(
2148            prompt.contains("# Database Schema"),
2149            "schema-resource header present even for empty schema: {prompt}"
2150        );
2151    }
2152}
2153
2154// =============================================================================
2155// Plan 90-10 — per-request executor seam + OpenAPI per-request wiring tests
2156// =============================================================================
2157
2158#[cfg(all(test, feature = "openapi-code-mode"))]
2159mod per_request_executor_tests {
2160    use super::*;
2161    use crate::config::{CodeModeSection, ServerConfig, ServerSection};
2162    use crate::http::auth::{create_passthrough_auth_provider, AuthConfig};
2163    use pmcp::server::auth::AuthContext;
2164
2165    /// A passthrough-configured `HttpCodeExecutor` over a fixed base_url.
2166    fn passthrough_base() -> HttpCodeExecutor {
2167        let auth = create_passthrough_auth_provider(
2168            &AuthConfig::OAuthPassthrough {
2169                target_header: "Authorization".to_string(),
2170                required: true,
2171            },
2172            None,
2173        )
2174        .expect("passthrough auth provider");
2175        HttpCodeExecutor::new(
2176            reqwest::Client::new(),
2177            "https://api.example".to_string(),
2178            auth,
2179        )
2180    }
2181
2182    fn extra_with_token(token: Option<&str>) -> pmcp::RequestHandlerExtra {
2183        let ctx = AuthContext {
2184            subject: "s".to_string(),
2185            scopes: vec![],
2186            claims: std::collections::HashMap::new(),
2187            token: token.map(str::to_string),
2188            client_id: None,
2189            expires_at: None,
2190            authenticated: token.is_some(),
2191        };
2192        pmcp::RequestHandlerExtra::default().with_auth_context(Some(ctx))
2193    }
2194
2195    #[test]
2196    fn request_executor_from_extra_threads_present_token() {
2197        // Plan 90-10 / OAPI-03 / OAPI-05: the captured inbound token reaches the
2198        // per-request executor's inbound_token field.
2199        let base = passthrough_base();
2200        assert_eq!(
2201            base.inbound_token_for_test(),
2202            None,
2203            "base executor starts with no inbound token"
2204        );
2205        let extra = extra_with_token(Some("Bearer client-tok"));
2206        let scoped = request_executor_from_extra(&base, &extra);
2207        assert_eq!(
2208            scoped.inbound_token_for_test(),
2209            Some("Bearer client-tok"),
2210            "the captured inbound token must be threaded into the per-request executor"
2211        );
2212    }
2213
2214    #[test]
2215    fn request_executor_from_extra_no_token_yields_none() {
2216        let base = passthrough_base();
2217        let extra = extra_with_token(None);
2218        let scoped = request_executor_from_extra(&base, &extra);
2219        assert_eq!(
2220            scoped.inbound_token_for_test(),
2221            None,
2222            "an extra carrying no token must yield an executor with inbound_token None"
2223        );
2224        // No auth context at all also yields None (never panics).
2225        let bare = request_executor_from_extra(&base, &pmcp::RequestHandlerExtra::default());
2226        assert_eq!(bare.inbound_token_for_test(), None);
2227    }
2228
2229    fn cfg_with_code_mode() -> ServerConfig {
2230        std::env::set_var(
2231            "PMCP_TOOLKIT_90_10_HTTP_SECRET",
2232            "per-request-test-secret-16-or-more",
2233        );
2234        ServerConfig {
2235            server: ServerSection {
2236                name: "http-cm".to_string(),
2237                version: "0.1.0".to_string(),
2238                ..Default::default()
2239            },
2240            code_mode: Some(CodeModeSection {
2241                enabled: true,
2242                server_id: Some("http-cm".to_string()),
2243                token_secret: Some("env:PMCP_TOOLKIT_90_10_HTTP_SECRET".to_string()),
2244                ..Default::default()
2245            }),
2246            ..Default::default()
2247        }
2248    }
2249
2250    #[test]
2251    fn http_tools_register_validate_and_execute_with_per_request_source() {
2252        // code_mode_http_tools_from_executor builds the ExecuteCodeHandler over
2253        // the PerRequestHttp source (constructed without panic over a passthrough
2254        // executor) and registers both Code-Mode tools.
2255        let _env = super::test_env_guard::lock();
2256        let cfg = cfg_with_code_mode();
2257        let builder = pmcp::Server::builder().name("http-cm").version("0.1.0");
2258        let builder = code_mode_http_tools_from_executor(
2259            builder,
2260            &cfg,
2261            passthrough_base(),
2262            ExecutionConfig::default(),
2263            ValidationFlavor::OpenApi,
2264        )
2265        .expect("OpenAPI per-request code-mode wiring must build");
2266        let server = builder.build().expect("server builds");
2267        assert!(
2268            server.get_tool("validate_code").is_some(),
2269            "validate_code registered"
2270        );
2271        assert!(
2272            server.get_tool("execute_code").is_some(),
2273            "execute_code registered"
2274        );
2275        std::env::remove_var("PMCP_TOOLKIT_90_10_HTTP_SECRET");
2276    }
2277
2278    #[test]
2279    fn http_tools_no_op_when_code_mode_absent() {
2280        let cfg = ServerConfig {
2281            server: ServerSection {
2282                name: "no-cm".to_string(),
2283                version: "0.1.0".to_string(),
2284                ..Default::default()
2285            },
2286            ..Default::default()
2287        };
2288        let builder = pmcp::Server::builder().name("no-cm").version("0.1.0");
2289        let builder = code_mode_http_tools_from_executor(
2290            builder,
2291            &cfg,
2292            passthrough_base(),
2293            ExecutionConfig::default(),
2294            ValidationFlavor::OpenApi,
2295        )
2296        .expect("no-op when [code_mode] absent");
2297        let server = builder.build().expect("server builds");
2298        assert!(
2299            server.get_tool("execute_code").is_none(),
2300            "no tools without [code_mode]"
2301        );
2302    }
2303}
2304
2305#[cfg(all(test, feature = "sqlite", feature = "openapi-code-mode"))]
2306mod sql_static_source_tests {
2307    use super::*;
2308    use crate::config::{CodeModeSection, ServerConfig, ServerSection};
2309    use crate::sql::SqliteConnector;
2310
2311    #[test]
2312    fn sql_path_registers_static_source_unchanged() {
2313        // The SQL path via code_mode_tools_from_executor still builds the
2314        // ExecuteCodeHandler with the Static source (SqlCodeExecutor) — Plan
2315        // 90-10 must not change the SQL wiring.
2316        let _env = super::test_env_guard::lock();
2317        std::env::set_var(
2318            "PMCP_TOOLKIT_90_10_SQL_SECRET",
2319            "sql-static-test-secret-16-or-more",
2320        );
2321        let connector = SqliteConnector::open_in_memory().expect("sqlite");
2322        let cfg = ServerConfig {
2323            server: ServerSection {
2324                name: "sql-cm".to_string(),
2325                version: "0.1.0".to_string(),
2326                ..Default::default()
2327            },
2328            code_mode: Some(CodeModeSection {
2329                enabled: true,
2330                server_id: Some("sql-cm".to_string()),
2331                token_secret: Some("env:PMCP_TOOLKIT_90_10_SQL_SECRET".to_string()),
2332                ..Default::default()
2333            }),
2334            ..Default::default()
2335        };
2336        let executor: Arc<dyn CodeExecutor> =
2337            Arc::new(SqlCodeExecutor::new(Arc::new(connector), cfg.clone()).expect("executor"));
2338        let builder = pmcp::Server::builder().name("sql-cm").version("0.1.0");
2339        let builder = code_mode_tools_from_executor(builder, &cfg, executor, ValidationFlavor::Sql)
2340            .expect("SQL code-mode wiring must build");
2341        let server = builder.build().expect("server builds");
2342        assert!(server.get_tool("validate_code").is_some());
2343        assert!(server.get_tool("execute_code").is_some());
2344        std::env::remove_var("PMCP_TOOLKIT_90_10_SQL_SECRET");
2345    }
2346}