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