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