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