Skip to main content

pmcp_code_mode/
code_executor.rs

1//! High-level code execution trait for MCP servers.
2//!
3//! This module provides the [`CodeExecutor`] trait, which is the primary public API
4//! for implementing code execution in MCP servers. It replaces the internal
5//! `HttpExecutor`, `SdkExecutor`, and `McpExecutor` traits for external server
6//! developers (those traits remain available for advanced use behind the
7//! `js-runtime` feature flag).
8
9use crate::types::ExecutionError;
10
11/// High-level trait for executing validated code.
12///
13/// Implementations handle the execution of code that has already passed
14/// validation and token verification. This is the primary public API
15/// for code execution -- it replaces the internal `HttpExecutor`,
16/// `SdkExecutor`, and `McpExecutor` traits for external server developers.
17///
18/// # Execution Patterns
19///
20/// The four supported patterns (all implemented via this single trait):
21/// - **Pattern A (SQL):** Direct SQL execution, no JS runtime
22/// - **Pattern B (JS+HTTP):** JavaScript plan compiled and executed via HTTP calls
23/// - **Pattern C (JS+SDK):** JavaScript plan executed via AWS SDK calls
24/// - **Pattern D (JS+MCP):** JavaScript plan executed via MCP tool calls
25///
26/// # API Stability Note
27///
28/// **\[Addresses divergent review concern: CodeExecutor trait surface area\]**
29/// This v0.1.0 API uses a simple `(code, variables)` signature per D-04.
30/// A future v0.2.0 may add an `ExecutionContext` parameter carrying timeout,
31/// cancellation token, and request metadata. The `(code, variables)` signature
32/// will be preserved as a default-method wrapper for backward compatibility.
33///
34/// # Example
35///
36/// ```rust,ignore
37/// use pmcp_code_mode::{CodeExecutor, ExecutionError};
38/// use serde_json::Value;
39///
40/// struct MyExecutor { /* database pool, http client, etc. */ }
41///
42/// #[pmcp_code_mode::async_trait]
43/// impl CodeExecutor for MyExecutor {
44///     async fn execute(
45///         &self,
46///         code: &str,
47///         variables: Option<&Value>,
48///     ) -> Result<Value, ExecutionError> {
49///         // Execute validated code against your backend
50///         todo!()
51///     }
52/// }
53/// ```
54#[async_trait::async_trait]
55pub trait CodeExecutor: Send + Sync {
56    /// Execute validated code and return the result.
57    ///
58    /// `code` has already passed validation and token verification.
59    /// `variables` are optional user-provided parameters (e.g., GraphQL variables).
60    ///
61    /// Implementations should NOT re-verify the token -- that is handled
62    /// by the Code Mode framework before calling this method.
63    async fn execute(
64        &self,
65        code: &str,
66        variables: Option<&serde_json::Value>,
67    ) -> Result<serde_json::Value, ExecutionError>;
68}
69
70// ---------------------------------------------------------------------------
71// Standard adapters: bridge low-level executor traits to CodeExecutor
72// ---------------------------------------------------------------------------
73//
74// These adapters solve the &mut self vs &self mismatch: PlanExecutor::execute
75// requires &mut self, but CodeExecutor::execute takes &self. Each adapter
76// creates a fresh PlanCompiler + PlanExecutor per call (cheap — both are small
77// structs, and the caller's HttpExecutor/SdkExecutor holds Arc'd state).
78
79/// Compile JavaScript code and execute the plan, returning the result value.
80///
81/// Shared implementation for all three adapters. The `setup` closure configures
82/// the `PlanExecutor` with the appropriate backend (HTTP, SDK, or MCP) before
83/// execution begins.
84#[cfg(feature = "js-runtime")]
85async fn compile_and_execute<H: crate::executor::HttpExecutor + 'static>(
86    config: &crate::executor::ExecutionConfig,
87    http: H,
88    code: &str,
89    variables: Option<&serde_json::Value>,
90    setup: impl FnOnce(&mut crate::executor::PlanExecutor<H>),
91    adapter: &str,
92) -> Result<serde_json::Value, ExecutionError> {
93    let mut compiler = crate::executor::PlanCompiler::with_config(config);
94    let plan = compiler
95        .compile_code(code)
96        .map_err(|e| ExecutionError::InvalidScript {
97            message: e.caller_message(),
98        })?;
99    let mut executor = crate::executor::PlanExecutor::new(http, config.clone());
100    if let Some(vars) = variables {
101        executor.set_variable("args", vars.clone());
102    }
103    setup(&mut executor);
104    let result = executor.execute(&plan).await?;
105    tracing::debug!(
106        adapter,
107        api_calls = result.api_calls.len(),
108        execution_time_ms = result.execution_time_ms,
109        "plan executed"
110    );
111    Ok(result.value)
112}
113
114/// Adapter bridging [`HttpExecutor`] to [`CodeExecutor`] for JavaScript/OpenAPI
115/// servers (Pattern B: JS+HTTP).
116///
117/// Compiles JavaScript code into an execution plan, then runs it against an
118/// HTTP backend. The executor holds its own [`ExecutionConfig`] for limits
119/// (`max_api_calls`, `timeout_seconds`, `max_loop_iterations`).
120///
121/// # Example
122///
123/// ```rust,ignore
124/// use pmcp_code_mode::{JsCodeExecutor, ExecutionConfig};
125///
126/// let http = CostExplorerHttpExecutor::new(clients.clone());
127/// let config = ExecutionConfig::default();
128/// let executor = Arc::new(JsCodeExecutor::new(http, config));
129/// // Pass executor to #[derive(CodeMode)] struct as code_executor field
130/// ```
131#[cfg(feature = "js-runtime")]
132pub struct JsCodeExecutor<H> {
133    http: H,
134    config: crate::executor::ExecutionConfig,
135}
136
137#[cfg(feature = "js-runtime")]
138impl<H: crate::executor::HttpExecutor + Clone> JsCodeExecutor<H> {
139    /// Create a new JS code executor with the given HTTP backend and config.
140    pub fn new(http: H, config: crate::executor::ExecutionConfig) -> Self {
141        Self { http, config }
142    }
143}
144
145#[cfg(feature = "js-runtime")]
146#[async_trait::async_trait]
147impl<H: crate::executor::HttpExecutor + Clone + 'static> CodeExecutor for JsCodeExecutor<H> {
148    async fn execute(
149        &self,
150        code: &str,
151        variables: Option<&serde_json::Value>,
152    ) -> Result<serde_json::Value, ExecutionError> {
153        compile_and_execute(
154            &self.config,
155            self.http.clone(),
156            code,
157            variables,
158            |_| {},
159            "js",
160        )
161        .await
162    }
163}
164
165/// Adapter bridging [`SdkExecutor`] to [`CodeExecutor`] for SDK-backed servers
166/// (Pattern C: JS+SDK).
167///
168/// Uses a no-op HTTP executor stub since SDK plans route through
169/// `PlanExecutor::set_sdk_executor` instead of HTTP calls.
170///
171/// # Example
172///
173/// ```rust,ignore
174/// use pmcp_code_mode::{SdkCodeExecutor, ExecutionConfig};
175///
176/// let sdk = MyCostExplorerSdk::new(credentials);
177/// let config = ExecutionConfig::default();
178/// let executor = Arc::new(SdkCodeExecutor::new(sdk, config));
179/// ```
180#[cfg(feature = "js-runtime")]
181pub struct SdkCodeExecutor<S> {
182    sdk: S,
183    config: crate::executor::ExecutionConfig,
184}
185
186#[cfg(feature = "js-runtime")]
187impl<S: crate::executor::SdkExecutor + Clone + 'static> SdkCodeExecutor<S> {
188    /// Create a new SDK code executor with the given SDK backend and config.
189    pub fn new(sdk: S, config: crate::executor::ExecutionConfig) -> Self {
190        Self { sdk, config }
191    }
192}
193
194#[cfg(feature = "js-runtime")]
195#[async_trait::async_trait]
196impl<S: crate::executor::SdkExecutor + Clone + 'static> CodeExecutor for SdkCodeExecutor<S> {
197    async fn execute(
198        &self,
199        code: &str,
200        variables: Option<&serde_json::Value>,
201    ) -> Result<serde_json::Value, ExecutionError> {
202        let sdk = self.sdk.clone();
203        compile_and_execute(
204            &self.config,
205            NoopHttpExecutor,
206            code,
207            variables,
208            move |ex| {
209                ex.set_sdk_executor(sdk);
210            },
211            "sdk",
212        )
213        .await
214    }
215}
216
217/// Adapter bridging [`McpExecutor`] to [`CodeExecutor`] for MCP composition
218/// servers (Pattern D: JS+MCP).
219///
220/// Uses a no-op HTTP executor stub since MCP plans route through
221/// `PlanExecutor::set_mcp_executor` instead of HTTP calls.
222///
223/// # Example
224///
225/// ```rust,ignore
226/// use pmcp_code_mode::{McpCodeExecutor, ExecutionConfig};
227///
228/// let mcp = MyMcpRouter::new(foundation_servers);
229/// let config = ExecutionConfig::default();
230/// let executor = Arc::new(McpCodeExecutor::new(mcp, config));
231/// ```
232///
233/// Note: `mcp-code-mode` feature implies `js-runtime` in Cargo.toml,
234/// which provides `PlanCompiler`, `PlanExecutor`, and `NoopHttpExecutor`.
235#[cfg(feature = "mcp-code-mode")]
236pub struct McpCodeExecutor<M> {
237    mcp: M,
238    config: crate::executor::ExecutionConfig,
239}
240
241#[cfg(feature = "mcp-code-mode")]
242impl<M: crate::executor::McpExecutor + Clone + 'static> McpCodeExecutor<M> {
243    /// Create a new MCP composition code executor.
244    pub fn new(mcp: M, config: crate::executor::ExecutionConfig) -> Self {
245        Self { mcp, config }
246    }
247}
248
249#[cfg(feature = "mcp-code-mode")]
250#[async_trait::async_trait]
251impl<M: crate::executor::McpExecutor + Clone + 'static> CodeExecutor for McpCodeExecutor<M> {
252    async fn execute(
253        &self,
254        code: &str,
255        variables: Option<&serde_json::Value>,
256    ) -> Result<serde_json::Value, ExecutionError> {
257        let mcp = self.mcp.clone();
258        compile_and_execute(
259            &self.config,
260            NoopHttpExecutor,
261            code,
262            variables,
263            move |ex| {
264                ex.set_mcp_executor(mcp);
265            },
266            "mcp",
267        )
268        .await
269    }
270}
271
272/// No-op HTTP executor for SDK and MCP adapters that don't use HTTP calls.
273/// Gated on `js-runtime`; `mcp-code-mode` implies `js-runtime` in Cargo.toml.
274#[cfg(feature = "js-runtime")]
275#[derive(Clone)]
276struct NoopHttpExecutor;
277
278#[cfg(feature = "js-runtime")]
279#[async_trait::async_trait]
280impl crate::executor::HttpExecutor for NoopHttpExecutor {
281    async fn execute_request(
282        &self,
283        method: &str,
284        _path: crate::executor::ResolvedPath<'_>,
285        _body: Option<serde_json::Value>,
286    ) -> Result<serde_json::Value, ExecutionError> {
287        // The path is deliberately NOT formatted into this message (T-128-21a).
288        // After Phase 128 D-09 the incoming `path` is the RESOLVED path rather
289        // than the template the script wrote, so echoing it here would hand a
290        // refused or attacker-shaped path straight back to the MCP client — and
291        // this would be the one surviving path-echo site in the crate while the
292        // phase claimed the Pitfall 7 class closed.
293        Err(ExecutionError::RuntimeError {
294            message: format!(
295                "HTTP calls not supported in this executor mode (attempted a {method} request). \
296                 Use JsCodeExecutor for HTTP-based execution."
297            ),
298        })
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use serde_json::json;
306
307    struct EchoExecutor;
308
309    #[async_trait::async_trait]
310    impl CodeExecutor for EchoExecutor {
311        async fn execute(
312            &self,
313            code: &str,
314            variables: Option<&serde_json::Value>,
315        ) -> Result<serde_json::Value, ExecutionError> {
316            Ok(json!({
317                "code": code,
318                "variables": variables,
319            }))
320        }
321    }
322
323    /// Compile and run `code` against a dry-run backend, through the same function
324    /// every adapter uses.
325    #[cfg(feature = "js-runtime")]
326    async fn compile_and_run(code: &str) -> Result<serde_json::Value, ExecutionError> {
327        use crate::executor::{ExecutionConfig, MockHttpExecutor};
328        compile_and_execute(
329            &ExecutionConfig::default(),
330            MockHttpExecutor::new_dry_run(),
331            code,
332            None,
333            |_| {},
334            "test",
335        )
336        .await
337    }
338
339    /// A script that cannot be compiled is the CALLER's mistake: it must be its own
340    /// error variant, never `RuntimeError` (a server fault), so a tool handler can
341    /// report it as a tool-level rejection. (pmcp-code-mode 0.7.2, F-31.)
342    #[cfg(feature = "js-runtime")]
343    #[tokio::test]
344    async fn a_script_that_does_not_compile_is_invalid_script_not_a_runtime_fault() {
345        // String concatenation where a template literal is required.
346        let err = compile_and_run("const r = await api.get('/search/' + args.q); return r;")
347            .await
348            .expect_err("a concatenated path does not compile");
349        assert!(
350            matches!(err, ExecutionError::InvalidScript { .. }),
351            "a compile error is the caller's, got: {err:?}"
352        );
353        assert!(
354            err.to_string().contains("template literal"),
355            "the model must read what to change: {err}"
356        );
357    }
358
359    /// A syntax error's parser message quotes the token it choked on, which repeats
360    /// the caller's code. The reported text is fixed instead.
361    #[cfg(feature = "js-runtime")]
362    #[tokio::test]
363    async fn a_syntax_error_does_not_echo_the_submitted_code() {
364        let err = compile_and_run("const SECRETTOKEN = = 1;")
365            .await
366            .expect_err("a syntax error");
367        match err {
368            ExecutionError::InvalidScript { message } => {
369                assert!(
370                    !message.contains("SECRETTOKEN"),
371                    "echoed the code: {message}"
372                );
373                assert!(message.contains("syntax error"), "{message}");
374            },
375            other => panic!("expected InvalidScript, got {other:?}"),
376        }
377    }
378
379    #[tokio::test]
380    async fn code_executor_echo() {
381        let executor = EchoExecutor;
382        let result = executor.execute("SELECT 1", None).await.unwrap();
383        assert_eq!(result["code"], "SELECT 1");
384    }
385
386    #[tokio::test]
387    async fn code_executor_with_variables() {
388        let executor = EchoExecutor;
389        let vars = json!({"limit": 10});
390        let result = executor
391            .execute("query { users }", Some(&vars))
392            .await
393            .unwrap();
394        assert_eq!(result["variables"]["limit"], 10);
395    }
396
397    #[tokio::test]
398    async fn code_executor_returns_error() {
399        struct FailingExecutor;
400
401        #[async_trait::async_trait]
402        impl CodeExecutor for FailingExecutor {
403            async fn execute(
404                &self,
405                _code: &str,
406                _variables: Option<&serde_json::Value>,
407            ) -> Result<serde_json::Value, ExecutionError> {
408                Err(ExecutionError::BackendError(
409                    "database unavailable".to_string(),
410                ))
411            }
412        }
413
414        let executor = FailingExecutor;
415        let result = executor.execute("SELECT 1", None).await;
416        assert!(result.is_err());
417        let err = result.unwrap_err();
418        assert!(err.to_string().contains("database unavailable"));
419    }
420
421    // Compile-time test: CodeExecutor requires Send + Sync
422    fn _assert_send_sync<T: Send + Sync>() {}
423    fn _code_executor_is_send_sync() {
424        _assert_send_sync::<EchoExecutor>();
425    }
426
427    /// T-128-21a — `NoopHttpExecutor`'s refusal names the METHOD and never the
428    /// path. After Phase 128 D-09 the incoming `path` is the RESOLVED path rather
429    /// than the template the script wrote, so this would otherwise be the one
430    /// surviving path-echo site in the crate.
431    #[cfg(feature = "js-runtime")]
432    #[tokio::test]
433    async fn noop_http_executor_error_names_the_method_and_not_the_path() {
434        use crate::executor::{HttpExecutor, ResolvedPath};
435
436        let path = ResolvedPath::from_checked("/secret/inventory/endpoint")
437            .expect("a clean resolved path");
438        let rendered = NoopHttpExecutor
439            .execute_request("GET", path, None)
440            .await
441            .expect_err("the noop executor always refuses")
442            .to_string();
443        assert!(rendered.contains("GET"), "must name the method: {rendered}");
444        assert!(
445            !rendered.contains("/secret/inventory/endpoint"),
446            "must NOT echo the resolved path: {rendered}"
447        );
448    }
449}