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}