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