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