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