feat: platform extension for code execution

This commit is contained in:
Alex Hancock
2025-12-08 15:00:46 -05:00
parent 02189f5555
commit fce3e6c07d
6 changed files with 1524 additions and 172 deletions
Generated
+904 -162
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -111,6 +111,8 @@ shellexpand = "3.1.1"
indexmap = "2.12.0"
ignore = "0.4.25"
which = "8.0.0"
boa_engine = "0.21.0"
boa_gc = "0.21"
[target.'cfg(target_os = "windows")'.dependencies]
@@ -0,0 +1,566 @@
use crate::agents::extension::PlatformExtensionContext;
use crate::agents::extension_manager::get_parameter_names;
use crate::agents::mcp_client::{Error, McpClientTrait};
use anyhow::Result;
use async_trait::async_trait;
use boa_engine::builtins::promise::PromiseState;
use boa_engine::object::builtins::JsPromise;
use boa_engine::{js_string, Context, JsNativeError, JsValue, NativeFunction, Source};
use indoc::indoc;
use rmcp::model::{
CallToolRequestParam, CallToolResult, Content, GetPromptResult, Implementation,
InitializeResult, JsonObject, ListPromptsResult, ListResourcesResult, ListToolsResult,
ProtocolVersion, RawContent, ReadResourceResult, ServerCapabilities, ServerNotification, Tool,
ToolAnnotations, ToolsCapability,
};
use schemars::{schema_for, JsonSchema};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fmt::Write;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub static EXTENSION_NAME: &str = "code_execution";
type ToolCallRequest = (
String,
String,
tokio::sync::oneshot::Sender<Result<String, String>>,
);
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ExecuteCodeParams {
code: String,
}
fn run_js_code(
code: &str,
preamble: Option<String>,
call_tx: tokio::sync::mpsc::UnboundedSender<ToolCallRequest>,
) -> Result<String, String> {
let mut ctx = Context::default();
thread_local! {
static CALL_TX: std::cell::RefCell<Option<tokio::sync::mpsc::UnboundedSender<ToolCallRequest>>> =
const { std::cell::RefCell::new(None) };
}
CALL_TX.with(|tx| *tx.borrow_mut() = Some(call_tx));
let call_tool_fn = NativeFunction::from_fn_ptr(|_this, args, ctx| {
let tool_name = args
.first()
.and_then(|v| v.as_string())
.ok_or_else(|| JsNativeError::typ().with_message("First argument must be tool name"))?
.to_std_string_escaped();
let args_json = args
.get(1)
.cloned()
.unwrap_or(JsValue::undefined())
.to_json(ctx)
.map_err(|e| JsNativeError::error().with_message(e.to_string()))?
.unwrap_or(Value::Null);
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
CALL_TX
.with(|tx| {
tx.borrow().as_ref().and_then(|sender| {
let args_str = serde_json::to_string(&args_json).unwrap_or_default();
sender.send((tool_name, args_str, response_tx)).ok()
})
})
.ok_or_else(|| {
JsNativeError::error().with_message("Tool call channel not available")
})?;
response_rx
.blocking_recv()
.map_err(|e| e.to_string())
.and_then(|r| r)
.map(|result| JsValue::from(js_string!(result.as_str())))
.map_err(|e| JsNativeError::error().with_message(e).into())
});
ctx.register_global_builtin_callable(js_string!("__call_tool__"), 2, call_tool_fn)
.expect("Failed to register __call_tool__");
if let Some(ref p) = preamble {
if let Err(e) = ctx.eval(Source::from_bytes(p)) {
return Err(format!("Failed to load preamble: {e}"));
}
}
match ctx.eval(Source::from_bytes(code)) {
Ok(result) => {
let _ = ctx.run_jobs();
if let Some(obj) = result.as_object() {
if let Ok(promise) = JsPromise::from_object(obj.clone()) {
return match promise.state() {
PromiseState::Fulfilled(value) => Ok(value.display().to_string()),
PromiseState::Rejected(err) => {
Err(format!("Promise rejected: {}", err.display()))
}
PromiseState::Pending => {
Err("Promise still pending after execution".to_string())
}
};
}
}
Ok(result.display().to_string())
}
Err(e) => Err(e.to_string()),
}
}
pub struct CodeExecutionClient {
info: InitializeResult,
context: PlatformExtensionContext,
}
impl CodeExecutionClient {
pub fn new(context: PlatformExtensionContext) -> Result<Self> {
let info = InitializeResult {
protocol_version: ProtocolVersion::V_2025_03_26,
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: Some(false),
}),
resources: None,
prompts: None,
completions: None,
experimental: None,
logging: None,
},
server_info: Implementation {
name: EXTENSION_NAME.to_string(),
title: Some("Code Execution".to_string()),
version: "1.0.0".to_string(),
icons: None,
website_url: None,
},
instructions: Some(
indoc! {r#"
JavaScript code execution environment with live MCP tool bindings.
IMPORTANT: Prefer execute_code over making individual tool calls when:
- You need to call multiple tools and combine their results
- You want to process tool outputs with JavaScript logic
- You need conditional tool execution based on previous results
This reduces round-trips and allows complex workflows in a single call.
See the execute_code tool for detailed usage instructions.
"#}
.to_string(),
),
};
Ok(Self { info, context })
}
async fn build_preamble(&self) -> Option<String> {
let manager = self.context.extension_manager.as_ref()?.upgrade()?;
let tools = manager
.get_prefixed_tools_excluding(EXTENSION_NAME)
.await
.ok()
.filter(|t| !t.is_empty())?;
Some(Self::generate_preamble_js(&tools))
}
fn generate_preamble_js(tools: &[Tool]) -> String {
let mut by_ext: HashMap<&str, Vec<&Tool>> = HashMap::new();
for tool in tools {
if let Some((prefix, _)) = tool.name.as_ref().split_once("__") {
by_ext.entry(prefix).or_default().push(tool);
}
}
let mut js = String::from(
"// Auto-generated tool bindings for MCP extensions\n\
// Each extension is a namespace object containing its tools as methods\n\
// Tool calls block until completion and return the result directly\n\n",
);
for (ext, ext_tools) in &by_ext {
writeln!(js, "const {ext} = {{").unwrap();
for (i, tool) in ext_tools.iter().enumerate() {
let name = tool.name.as_ref().split_once("__").unwrap().1;
let params = get_parameter_names(tool);
let trailing = if i < ext_tools.len() - 1 { "," } else { "" };
js.push_str(" /**\n");
if let Some(desc) = tool.description.as_ref() {
for line in desc.as_ref().lines() {
writeln!(js, " * {line}").unwrap();
}
}
for param in &params {
let (ty, desc) = Self::get_param_info(&tool.input_schema, param);
writeln!(js, " * @param {{{ty}}} {param}{desc}").unwrap();
}
js.push_str(" * @returns {string} Tool result\n */\n");
let args_obj = if params.is_empty() {
"{}".to_string()
} else {
format!(
"{{ {} }}",
params
.iter()
.map(|p| format!("{p}: {p}"))
.collect::<Vec<_>>()
.join(", ")
)
};
writeln!(
js,
" {name}: function({}) {{ return __call_tool__(\"{}\", {args_obj}); }}{trailing}",
params.join(", "),
tool.name.as_ref()
).unwrap();
}
js.push_str("};\n\n");
}
writeln!(
js,
"const __extensions__ = [{}];",
by_ext
.keys()
.map(|k| format!("\"{k}\""))
.collect::<Vec<_>>()
.join(", ")
)
.unwrap();
js
}
fn get_param_info(schema: &JsonObject, param: &str) -> (String, String) {
let prop = schema
.get("properties")
.and_then(|p| p.get(param))
.and_then(|v| v.as_object());
let required = schema
.get("required")
.and_then(|r| r.as_array())
.is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some(param)));
let base = prop.and_then(|p| p.get("type")?.as_str()).unwrap_or("*");
let ty = if required {
base.to_string()
} else {
format!("{base}=")
};
let desc = prop
.and_then(|p| p.get("description")?.as_str())
.map(|d| format!(" - {d}"))
.unwrap_or_default();
(ty, desc)
}
async fn handle_execute_code(
&self,
arguments: Option<JsonObject>,
) -> Result<Vec<Content>, String> {
let code = arguments
.as_ref()
.and_then(|a| a.get("code"))
.and_then(|v| v.as_str())
.ok_or("Missing required parameter: code")?
.to_string();
let preamble = self.build_preamble().await;
// Boa's Context is !Send, so we use channels to bridge the blocking JS thread
// and the async runtime for tool calls.
let (call_tx, call_rx) = tokio::sync::mpsc::unbounded_channel();
let tool_handler = tokio::spawn(Self::run_tool_handler(
call_rx,
self.context.extension_manager.clone(),
));
let js_result = tokio::task::spawn_blocking(move || run_js_code(&code, preamble, call_tx))
.await
.map_err(|e| format!("JS execution task failed: {e}"))?;
tool_handler.abort();
js_result.map(|r| vec![Content::text(format!("Result: {r}"))])
}
async fn run_tool_handler(
mut call_rx: tokio::sync::mpsc::UnboundedReceiver<ToolCallRequest>,
extension_manager: Option<std::sync::Weak<crate::agents::ExtensionManager>>,
) {
while let Some((tool_name, arguments, response_tx)) = call_rx.recv().await {
let result = match extension_manager.as_ref().and_then(|w| w.upgrade()) {
Some(manager) => {
let tool_call = CallToolRequestParam {
name: tool_name.into(),
arguments: serde_json::from_str(&arguments).ok(),
};
match manager
.dispatch_tool_call(tool_call, CancellationToken::new())
.await
{
Ok(dispatch_result) => match dispatch_result.result.await {
Ok(content) => Ok(content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")),
Err(e) => Err(format!("Tool execution error: {}", e.message)),
},
Err(e) => Err(format!("Tool dispatch error: {e}")),
}
}
None => Err("Extension manager not available".to_string()),
};
let _ = response_tx.send(result);
}
}
fn get_tools() -> Vec<Tool> {
let execute_schema = serde_json::to_value(schema_for!(ExecuteCodeParams))
.expect("schema")
.as_object()
.expect("object")
.clone();
vec![Tool::new(
"execute_code".to_string(),
indoc! {r#"
Execute JavaScript code with live MCP tool bindings.
## Environment
Sandboxed JavaScript (Boa engine) with synchronous access to all enabled MCP tools.
Tool calls block until completion, allowing chaining and result passing.
## Tool API
Tools are organized by extension namespace. Each extension becomes a JavaScript
object with methods for its tools. Parameters are positional, matching the tool schema order.
Example namespaces (when extensions are enabled):
developer.shell(command) // run shell commands
developer.text_editor(path, command, ...) // file operations
developer.analyze(path, ...) // code analysis
## Return Value
The last expression becomes the result. Do NOT use top-level return statements.
## Examples
Simple expression:
code: "2 + 2"
result: "4"
Read and process a file:
code: `
const content = developer.text_editor("/path/to/file.json", "view");
JSON.parse(content)
`
Chain multiple tool calls:
code: `
const files = developer.shell("ls -la");
const readme = developer.text_editor("README.md", "view");
const analysis = developer.analyze("src/");
{ files, readme, analysis }
`
Conditional execution:
code: `
const result = developer.shell("git status --porcelain");
if (result.trim()) {
developer.shell("git add -A && git commit -m 'auto-commit'");
} else {
"No changes to commit"
}
`
"#}
.to_string(),
execute_schema,
)
.annotate(ToolAnnotations {
title: Some("Execute JavaScript code".to_string()),
read_only_hint: Some(false),
destructive_hint: Some(true),
idempotent_hint: Some(false),
open_world_hint: Some(true),
})]
}
}
#[async_trait]
impl McpClientTrait for CodeExecutionClient {
async fn list_resources(
&self,
_next_cursor: Option<String>,
_cancellation_token: CancellationToken,
) -> Result<ListResourcesResult, Error> {
Err(Error::TransportClosed)
}
async fn read_resource(
&self,
_uri: &str,
_cancellation_token: CancellationToken,
) -> Result<ReadResourceResult, Error> {
Err(Error::TransportClosed)
}
async fn list_tools(
&self,
_next_cursor: Option<String>,
_cancellation_token: CancellationToken,
) -> Result<ListToolsResult, Error> {
Ok(ListToolsResult {
tools: Self::get_tools(),
next_cursor: None,
})
}
async fn call_tool(
&self,
name: &str,
arguments: Option<JsonObject>,
_cancellation_token: CancellationToken,
) -> Result<CallToolResult, Error> {
let content = match name {
"execute_code" => self.handle_execute_code(arguments).await,
_ => Err(format!("Unknown tool: {name}")),
};
match content {
Ok(content) => Ok(CallToolResult::success(content)),
Err(error) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error: {error}"
))])),
}
}
async fn list_prompts(
&self,
_next_cursor: Option<String>,
_cancellation_token: CancellationToken,
) -> Result<ListPromptsResult, Error> {
Err(Error::TransportClosed)
}
async fn get_prompt(
&self,
_name: &str,
_arguments: Value,
_cancellation_token: CancellationToken,
) -> Result<GetPromptResult, Error> {
Err(Error::TransportClosed)
}
async fn subscribe(&self) -> mpsc::Receiver<ServerNotification> {
mpsc::channel(1).1
}
fn get_info(&self) -> Option<&InitializeResult> {
Some(&self.info)
}
async fn get_moim(&self) -> Option<String> {
let preamble = self.build_preamble().await?;
Some(format!(
"The execute_code tool has the following JavaScript API available:\n\n```javascript\n{}\n```",
preamble
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn test_preamble_generation() {
let tools = vec![
Tool::new(
"developer__shell".to_string(),
"Execute shell commands".to_string(),
Arc::new(
serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "The command to run" }
},
"required": ["command"]
})
.as_object()
.unwrap()
.clone(),
),
),
Tool::new(
"developer__text_editor".to_string(),
"Edit text files".to_string(),
Arc::new(
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path" },
"content": { "type": "string" }
},
"required": ["path"]
})
.as_object()
.unwrap()
.clone(),
),
),
];
let js = CodeExecutionClient::generate_preamble_js(&tools);
assert!(js.contains("const developer = {"));
assert!(js.contains("shell: function(command)"));
assert!(js.contains("@param {string} command - The command to run"));
assert!(js.contains("@param {string} path - File path"));
assert!(js.contains("@param {string=} content")); // optional param
assert!(js.contains("__call_tool__"));
assert!(js.contains("__extensions__"));
assert!(js.contains("return __call_tool__"));
}
#[tokio::test]
async fn test_call_execute_code() {
use rmcp::model::RawContent;
let context = PlatformExtensionContext {
session_id: None,
extension_manager: None,
tool_route_manager: None,
};
let client = CodeExecutionClient::new(context).unwrap();
let mut args = JsonObject::new();
args.insert("code".to_string(), Value::String("2 + 2".to_string()));
let result = client
.call_tool("execute_code", Some(args), CancellationToken::new())
.await
.unwrap();
assert!(!result.is_error.unwrap_or(false));
let content = &result.content[0];
if let RawContent::Text(text) = &content.raw {
assert_eq!(text.text, "Result: 4");
} else {
panic!("Expected text content");
}
}
}
+13
View File
@@ -1,4 +1,5 @@
use crate::agents::chatrecall_extension;
use crate::agents::code_execution_extension;
use crate::agents::extension_manager_extension;
use crate::agents::skills_extension;
use crate::agents::todo_extension;
@@ -87,6 +88,18 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
},
);
map.insert(
code_execution_extension::EXTENSION_NAME,
PlatformExtensionDef {
name: code_execution_extension::EXTENSION_NAME,
description: "Execute JavaScript code in a sandboxed environment",
default_enabled: false,
client_factory: |ctx| {
Box::new(code_execution_extension::CodeExecutionClient::new(ctx).unwrap())
},
},
);
map
},
);
+38 -10
View File
@@ -650,6 +650,14 @@ impl ExtensionManager {
pub async fn get_prefixed_tools(
&self,
extension_name: Option<String>,
) -> ExtensionResult<Vec<Tool>> {
self.get_prefixed_tools_impl(extension_name, None).await
}
async fn get_prefixed_tools_impl(
&self,
extension_name: Option<String>,
exclude: Option<&str>,
) -> ExtensionResult<Vec<Tool>> {
// Filter clients based on the provided extension_name or include all if None
let filtered_clients: Vec<_> = self
@@ -658,6 +666,12 @@ impl ExtensionManager {
.await
.iter()
.filter(|(name, _ext)| {
if let Some(excluded) = exclude {
if name.as_str() == excluded {
return false;
}
}
if let Some(ref name_filter) = extension_name {
*name == name_filter
} else {
@@ -723,6 +737,10 @@ impl ExtensionManager {
Ok(tools)
}
pub async fn get_prefixed_tools_excluding(&self, exclude: &str) -> ExtensionResult<Vec<Tool>> {
self.get_prefixed_tools_impl(None, Some(exclude)).await
}
/// Get the extension prompt including client instructions
pub async fn get_planning_prompt(&self, tools_info: Vec<ToolInfo>) -> String {
let mut context: HashMap<&str, Value> = HashMap::new();
@@ -1183,16 +1201,26 @@ impl ExtensionManager {
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut content = format!("<info-msg>\nDatetime: {}\n", timestamp);
let extensions = self.extensions.lock().await;
for (name, extension) in extensions.iter() {
if let ExtensionConfig::Platform { .. } = &extension.config {
let client = extension.get_client();
let client_guard = client.lock().await;
if let Some(moim_content) = client_guard.get_moim().await {
tracing::debug!("MOIM content from {}: {} chars", name, moim_content.len());
content.push('\n');
content.push_str(&moim_content);
}
let platform_clients: Vec<(String, McpClientBox)> = {
let extensions = self.extensions.lock().await;
extensions
.iter()
.filter_map(|(name, extension)| {
if let ExtensionConfig::Platform { .. } = &extension.config {
Some((name.clone(), extension.get_client()))
} else {
None
}
})
.collect()
};
for (name, client) in platform_clients {
let client_guard = client.lock().await;
if let Some(moim_content) = client_guard.get_moim().await {
tracing::debug!("MOIM content from {}: {} chars", name, moim_content.len());
content.push('\n');
content.push_str(&moim_content);
}
}
+1
View File
@@ -1,5 +1,6 @@
mod agent;
pub(crate) mod chatrecall_extension;
pub(crate) mod code_execution_extension;
pub mod extension;
pub mod extension_malware_check;
pub mod extension_manager;