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