Skip to main content

pmcp_code_mode/
handler.rs

1//! Code Mode Handler trait for unified soft-disable and tool management.
2//!
3//! This module provides the `CodeModeHandler` trait that all Code Mode implementations
4//! should implement. It provides:
5//!
6//! - **Policy check**: Requires a policy evaluator to be configured
7//! - **Pre-handle hook**: Extensible hook for soft-disable and other checks
8//! - **Standard tool definitions**: Consistent `validate_code` and `execute_code` tools
9//! - **Response formatting**: Consistent JSON responses across server types
10
11use pmcp::types::{ToolAnnotations, ToolInfo};
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14
15use crate::types::{
16    PolicyViolation, RiskLevel, UnifiedAction, ValidationMetadata, ValidationResult,
17};
18
19/// Response from `validate_code_impl` containing validation results plus
20/// handler-specific metadata.
21///
22/// Wraps [`ValidationResult`] from the validation pipeline and adds fields
23/// for handler-level concerns (auto-approval, unified action, code hash).
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ValidationResponse {
26    /// Core validation result from the pipeline.
27    #[serde(flatten)]
28    pub result: ValidationResult,
29
30    /// Whether this was auto-approved based on risk level.
31    pub auto_approved: bool,
32
33    /// Unified action (Read, Write, Delete, Admin).
34    pub action: Option<UnifiedAction>,
35
36    /// SHA-256 hash of the canonicalized code that was validated.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub validated_code_hash: Option<String>,
39}
40
41impl ValidationResponse {
42    /// Create a successful validation response.
43    pub fn success(
44        explanation: String,
45        risk_level: RiskLevel,
46        approval_token: String,
47        metadata: ValidationMetadata,
48    ) -> Self {
49        Self {
50            result: ValidationResult::success(explanation, risk_level, approval_token, metadata),
51            auto_approved: false,
52            action: None,
53            validated_code_hash: None,
54        }
55    }
56
57    /// Create a failed validation response.
58    pub fn failure(violations: Vec<PolicyViolation>, metadata: ValidationMetadata) -> Self {
59        Self {
60            result: ValidationResult::failure(violations, metadata),
61            auto_approved: false,
62            action: None,
63            validated_code_hash: None,
64        }
65    }
66
67    /// Create from an existing `ValidationResult`.
68    pub fn from_result(result: ValidationResult) -> Self {
69        Self {
70            result,
71            auto_approved: false,
72            action: None,
73            validated_code_hash: None,
74        }
75    }
76
77    /// Set the validated code hash (SHA-256 of canonicalized code).
78    pub fn with_code_hash(mut self, hash: String) -> Self {
79        self.validated_code_hash = Some(hash);
80        self
81    }
82
83    /// Set the action for this response.
84    pub fn with_action(mut self, action: UnifiedAction) -> Self {
85        self.action = Some(action);
86        self
87    }
88
89    /// Set auto_approved flag.
90    pub fn with_auto_approved(mut self, auto_approved: bool) -> Self {
91        self.auto_approved = auto_approved;
92        self
93    }
94
95    /// Add warnings to the response.
96    pub fn with_warnings(mut self, warnings: Vec<String>) -> Self {
97        self.result.warnings = warnings;
98        self
99    }
100
101    /// Convert to JSON response format.
102    ///
103    /// Returns a tuple of (json_value, is_error).
104    ///
105    /// # A rejected validation carries no echo of the code
106    ///
107    /// When the validation failed, `explanation` is empty and
108    /// `metadata.accessed_types` / `accessed_fields` are empty. Those fields are
109    /// derived from the submitted code (`"API calls: Get /search?string=..."`, the
110    /// literal path), and a refusal must not repeat a value the caller wrote: a path
111    /// can carry a sensitive literal, and the refusal's message is already value-free.
112    /// The `violations` (rule, message, suggestion) are what the caller needs to fix
113    /// the script and are kept. An accepted validation keeps all three, because the
114    /// explanation is how the model confirms what it is about to approve.
115    pub fn to_json_response(&self) -> (Value, bool) {
116        let is_valid = self.result.is_valid;
117        let explanation = if is_valid {
118            self.result.explanation.as_str()
119        } else {
120            ""
121        };
122        let (accessed_types, accessed_fields) = if is_valid {
123            (
124                self.result.metadata.accessed_types.clone(),
125                self.result.metadata.accessed_fields.clone(),
126            )
127        } else {
128            (Default::default(), Default::default())
129        };
130        let response = json!({
131            "valid": self.result.is_valid,
132            "explanation": explanation,
133            "risk_level": format!("{}", self.result.risk_level),
134            "approval_token": self.result.approval_token,
135            "action": self.action.as_ref().map(|a| a.to_string()),
136            "auto_approved": self.auto_approved,
137            "warnings": self.result.warnings,
138            "violations": self.result.violations.iter().map(|v| json!({
139                "policy": v.policy_name,
140                "rule": v.rule,
141                "message": v.message,
142                "suggestion": v.suggestion
143            })).collect::<Vec<_>>(),
144            "validated_code_hash": self.validated_code_hash,
145            "metadata": {
146                "is_read_only": self.result.metadata.is_read_only,
147                "accessed_types": accessed_types,
148                "accessed_fields": accessed_fields,
149                "validation_time_ms": self.result.metadata.validation_time_ms
150            }
151        });
152
153        (response, !self.result.is_valid)
154    }
155}
156
157/// Code Mode handler trait with policy check and standard tool handling.
158#[async_trait::async_trait]
159pub trait CodeModeHandler: Send + Sync {
160    /// Get the server name/ID for identification.
161    fn server_name(&self) -> &str;
162
163    /// Check if Code Mode is enabled in the configuration.
164    fn is_enabled(&self) -> bool;
165
166    /// Get the code format for this server (e.g., "graphql", "javascript", "sql").
167    fn code_format(&self) -> &str;
168
169    /// Validate code and return a validation response.
170    async fn validate_code_impl(
171        &self,
172        code: &str,
173        variables: Option<&Value>,
174        dry_run: bool,
175        user_id: &str,
176        session_id: &str,
177    ) -> Result<ValidationResponse, String>;
178
179    /// Execute validated code and return the result.
180    async fn execute_code_impl(
181        &self,
182        code: &str,
183        approval_token: &str,
184        variables: Option<&Value>,
185    ) -> Result<Value, String>;
186
187    /// Check if a policy evaluator is configured.
188    ///
189    /// Defaults to `false` (safe default). When `false`, `handle_tool` rejects
190    /// all requests with a "policy evaluator required" error. Implementations
191    /// that have configured a policy evaluator MUST override this to return `true`.
192    fn is_policy_configured(&self) -> bool {
193        false
194    }
195
196    /// Deprecated alias for `is_policy_configured()`.
197    fn is_avp_configured(&self) -> bool {
198        self.is_policy_configured()
199    }
200
201    /// Pre-handle hook for checks before tool execution.
202    ///
203    /// Override this to implement soft-disable checks (e.g., DynamoDB toggle).
204    /// Return `Ok(Some((response, is_error)))` to short-circuit with a response.
205    /// Return `Ok(None)` to proceed normally.
206    async fn pre_handle_hook(&self) -> Result<Option<(Value, bool)>, String> {
207        Ok(None)
208    }
209
210    // =========================================================================
211    // Provided methods with default implementations
212    // =========================================================================
213
214    /// Check if this is a Code Mode tool.
215    fn is_code_mode_tool(&self, name: &str) -> bool {
216        name == "validate_code" || name == "execute_code"
217    }
218
219    /// Get the standard Code Mode tool definitions.
220    fn get_tools(&self) -> Vec<ToolInfo> {
221        if !self.is_enabled() {
222            return vec![];
223        }
224
225        CodeModeToolBuilder::new(self.code_format()).build_tools()
226    }
227
228    /// Handle a Code Mode tool call with policy and pre-handle checks.
229    async fn handle_tool(
230        &self,
231        name: &str,
232        arguments: Value,
233        user_id: &str,
234        session_id: &str,
235    ) -> Result<(Value, bool), String> {
236        // Policy enforcement: require a policy evaluator to be configured
237        if !self.is_policy_configured() {
238            return Ok((
239                json!({
240                    "error": "Code Mode requires a policy evaluator to be configured. \
241                              Configure AVP, local Cedar, or another policy backend.",
242                    "valid": false
243                }),
244                true,
245            ));
246        }
247
248        // Pre-handle hook (soft-disable, etc.)
249        if let Some(response) = self.pre_handle_hook().await? {
250            return Ok(response);
251        }
252
253        match name {
254            "validate_code" => {
255                self.handle_validate_code(arguments, user_id, session_id)
256                    .await
257            },
258            "execute_code" => self.handle_execute_code(arguments).await,
259            _ => Err(format!("Unknown Code Mode tool: {}", name)),
260        }
261    }
262
263    /// Handle validate_code tool call.
264    async fn handle_validate_code(
265        &self,
266        arguments: Value,
267        user_id: &str,
268        session_id: &str,
269    ) -> Result<(Value, bool), String> {
270        let mut input: ValidateCodeInput =
271            serde_json::from_value(arguments).map_err(|e| format!("Invalid arguments: {}", e))?;
272
273        input.code = input.code.trim().to_string();
274
275        let response = self
276            .validate_code_impl(
277                &input.code,
278                input.variables.as_ref(),
279                input.dry_run.unwrap_or(false),
280                user_id,
281                session_id,
282            )
283            .await?;
284
285        Ok(response.to_json_response())
286    }
287
288    /// Handle execute_code tool call.
289    async fn handle_execute_code(&self, arguments: Value) -> Result<(Value, bool), String> {
290        let mut input: ExecuteCodeInput =
291            serde_json::from_value(arguments).map_err(|e| format!("Invalid arguments: {}", e))?;
292
293        input.code = input.code.trim().to_string();
294
295        let result = self
296            .execute_code_impl(&input.code, &input.approval_token, input.variables.as_ref())
297            .await?;
298
299        Ok((result, false))
300    }
301}
302
303/// Input for validate_code tool.
304#[derive(Debug, Deserialize)]
305pub struct ValidateCodeInput {
306    pub code: String,
307    #[serde(default)]
308    pub variables: Option<Value>,
309    #[serde(default)]
310    pub format: Option<String>,
311    #[serde(default)]
312    pub dry_run: Option<bool>,
313}
314
315/// Input for execute_code tool.
316#[derive(Debug, Deserialize)]
317pub struct ExecuteCodeInput {
318    pub code: String,
319    pub approval_token: String,
320    #[serde(default)]
321    pub variables: Option<Value>,
322}
323
324/// Builder for standard Code Mode tool definitions.
325pub struct CodeModeToolBuilder {
326    code_format: String,
327}
328
329impl CodeModeToolBuilder {
330    /// Create a new tool builder for the given code format.
331    pub fn new(code_format: &str) -> Self {
332        Self {
333            code_format: code_format.to_string(),
334        }
335    }
336
337    /// Build the standard Code Mode tools.
338    pub fn build_tools(&self) -> Vec<ToolInfo> {
339        vec![self.build_validate_tool(), self.build_execute_tool()]
340    }
341
342    /// The annotations for `validate_code`, and ONLY for `validate_code`: a pure
343    /// static analysis that touches no external state and can be retried freely.
344    ///
345    /// **Do not hand these to [`Self::build_execute_tool`].** They were shared by
346    /// both tools briefly and that was wrong in the one direction that matters:
347    /// `readOnlyHint`/`destructiveHint` are the hints a host reads to decide
348    /// whether a call needs human confirmation, so declaring them on a tool that
349    /// EXECUTES caller-supplied code tells every host it may auto-approve
350    /// arbitrary execution. The claim was only ever true for apps whose declared
351    /// op surface is all reads (`OperationEntry { category: "read" }`, and for
352    /// GraphQL `allow_mutations = false`), and nothing enforces that — so the
353    /// truthful answer for `execute_code` is to declare NOTHING and let the MCP
354    /// defaults (`readOnlyHint = false`, `destructiveHint = true`) stand until
355    /// the hints can be DERIVED per app.
356    fn safe_read_annotations() -> ToolAnnotations {
357        ToolAnnotations::new()
358            .with_read_only(true)
359            .with_destructive(false)
360            .with_open_world(false)
361            .with_idempotent(true)
362    }
363
364    pub fn build_validate_tool(&self) -> ToolInfo {
365        ToolInfo::with_annotations(
366            "validate_code",
367            Some(
368                "Validates code and returns a business-language explanation with an approval token. \
369                 The code is analyzed for security, complexity, and data access patterns. \
370                 You MUST call this before execute_code."
371                    .to_string(),
372            ),
373            json!({
374                "type": "object",
375                "properties": {
376                    "code": {
377                        "type": "string",
378                        "description": "The code to validate"
379                    },
380                    "variables": {
381                        "type": "object",
382                        "description": "Optional variables for the query"
383                    },
384                    "format": {
385                        "type": "string",
386                        "enum": [&self.code_format],
387                        "description": format!("Code format. Defaults to '{}' for this server.", self.code_format)
388                    },
389                    "dry_run": {
390                        "type": "boolean",
391                        "description": "If true, validate without generating approval token"
392                    }
393                },
394                "required": ["code"]
395            }),
396            Self::safe_read_annotations(),
397        )
398    }
399
400    /// Build the execute_code tool definition.
401    ///
402    /// Deliberately carries NO annotations. `readOnlyHint`/`destructiveHint` are
403    /// the hints a host reads to decide whether a call needs human confirmation,
404    /// and this tool runs caller-supplied code against the app's op surface — a
405    /// hardcoded "read-only, non-destructive, idempotent" would be a false safety
406    /// claim for any app that exposes a single mutating op, and nothing here
407    /// enforces that it does not.
408    ///
409    /// The durable fix is to DERIVE the hints rather than hardcode them, and the
410    /// pieces already converge: `OperationRegistry::lookup_category` (config.rs),
411    /// the `ValidationPipeline` the generated handler already owns at its
412    /// `metadata()` call site (pmcp-code-mode-derive), and the per-script fold
413    /// precedent in javascript.rs (`is_read_only`); the full Cedar/AVP design
414    /// layers on policy_annotations.rs. Until then the MCP defaults
415    /// (`readOnlyHint = false`, `destructiveHint = true`) are the honest answer.
416    pub fn build_execute_tool(&self) -> ToolInfo {
417        ToolInfo::new(
418            "execute_code",
419            Some(
420                "Executes validated code using an approval token. \
421                 The token must be obtained from validate_code and the code must match exactly."
422                    .into(),
423            ),
424            json!({
425                "type": "object",
426                "properties": {
427                    "code": {
428                        "type": "string",
429                        "description": "The code to execute (must match validated code)"
430                    },
431                    "approval_token": {
432                        "type": "string",
433                        "description": "The approval token from validate_code"
434                    },
435                    "variables": {
436                        "type": "object",
437                        "description": "Optional variables for the query"
438                    }
439                },
440                "required": ["code", "approval_token"]
441            }),
442        )
443    }
444}
445
446/// Format an error as a JSON response.
447pub fn format_error_response(error: &str) -> (Value, bool) {
448    (
449        json!({
450            "error": error,
451            "valid": false
452        }),
453        true,
454    )
455}
456
457/// Format an execution error as a JSON response.
458pub fn format_execution_error(error: &str) -> (Value, bool) {
459    (
460        json!({
461            "error": error
462        }),
463        true,
464    )
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn test_validation_response_to_json() {
473        let response = ValidationResponse::success(
474            "Test explanation".into(),
475            RiskLevel::Low,
476            "token123".into(),
477            ValidationMetadata::default(),
478        )
479        .with_action(UnifiedAction::Read)
480        .with_auto_approved(true);
481
482        let (json, is_error) = response.to_json_response();
483
484        assert!(!is_error);
485        assert_eq!(json["valid"], true);
486        assert_eq!(json["explanation"], "Test explanation");
487        assert_eq!(json["risk_level"], "LOW");
488        assert_eq!(json["approval_token"], "token123");
489        assert_eq!(json["action"], "Read");
490        assert_eq!(json["auto_approved"], true);
491    }
492
493    #[test]
494    fn test_validation_response_failure() {
495        let violations = vec![PolicyViolation::new("policy", "rule", "message")];
496        let response = ValidationResponse::failure(violations, ValidationMetadata::default());
497
498        let (json, is_error) = response.to_json_response();
499
500        assert!(is_error);
501        assert_eq!(json["valid"], false);
502    }
503
504    /// A rejection must not repeat the code the caller wrote. `explanation` and the
505    /// accessed types/fields are derived from it; the violations are kept.
506    #[test]
507    fn test_rejected_response_does_not_echo_the_code() {
508        let metadata = ValidationMetadata {
509            accessed_types: vec!["/secret/path?q=synthetic".into()],
510            accessed_fields: vec!["GET".into()],
511            ..ValidationMetadata::default()
512        };
513        let mut response = ValidationResponse::failure(
514            vec![PolicyViolation::new("policy", "rule", "message")],
515            metadata,
516        );
517        response.result.explanation = "API calls: Get /secret/path?q=synthetic".into();
518
519        let (json, is_error) = response.to_json_response();
520        assert!(is_error);
521        assert!(
522            !json.to_string().contains("secret/path"),
523            "a rejection must not echo the path: {json}"
524        );
525        assert_eq!(json["explanation"], "");
526        assert_eq!(json["metadata"]["accessed_types"], serde_json::json!([]));
527        assert_eq!(json["metadata"]["accessed_fields"], serde_json::json!([]));
528        assert_eq!(json["violations"][0]["rule"], "rule", "violations survive");
529    }
530
531    #[test]
532    fn test_tool_builder() {
533        let builder = CodeModeToolBuilder::new("graphql");
534        let tools = builder.build_tools();
535
536        assert_eq!(tools.len(), 2);
537        assert_eq!(tools[0].name, "validate_code");
538        assert_eq!(tools[1].name, "execute_code");
539    }
540}