Skip to content

Commit 1d6291a

Browse files
committed
Add codemode image support
1 parent b6a94f2 commit 1d6291a

File tree

6 files changed

+305
-18
lines changed

6 files changed

+305
-18
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pcb-mcp/src/codemoder.rs

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@ pub trait ToolCaller: Send + Sync {
1212
fn tools(&self) -> Vec<ToolInfo>;
1313
}
1414

15+
#[derive(Debug, Clone)]
16+
pub struct ImageData {
17+
pub data: String,
18+
pub mime_type: String,
19+
}
20+
1521
#[derive(Debug, Clone, Default)]
1622
pub struct ExecutionResult {
1723
pub value: JsonValue,
1824
pub logs: Vec<String>,
25+
pub images: Vec<ImageData>,
1926
pub is_error: bool,
2027
pub error_message: Option<String>,
2128
}
@@ -67,6 +74,9 @@ impl JsRuntime {
6774

6875
let logs: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
6976
let logs_clone = logs.clone();
77+
let images: Arc<std::sync::Mutex<Vec<ImageData>>> =
78+
Arc::new(std::sync::Mutex::new(Vec::new()));
79+
let images_clone = images.clone();
7080

7181
let context = JsContext::full(&self.runtime)?;
7282

@@ -108,6 +118,7 @@ impl JsRuntime {
108118
for tool_name in &tool_names {
109119
let name = tool_name.clone();
110120
let caller_clone = caller.clone();
121+
let images_for_closure = images_clone.clone();
111122

112123
let func = Function::new(ctx.clone(), move |args: String| {
113124
let tool_name = name.clone();
@@ -117,7 +128,10 @@ impl JsRuntime {
117128
let result = caller.call_tool(&tool_name, args_value);
118129

119130
match result {
120-
Ok(call_result) => format_call_result(&call_result),
131+
Ok(call_result) => {
132+
collect_images(&call_result, &images_for_closure);
133+
format_call_result(&call_result)
134+
}
121135
Err(e) => serde_json::json!({"error": e.to_string()}).to_string(),
122136
}
123137
})?;
@@ -183,36 +197,66 @@ impl JsRuntime {
183197
})
184198
.map(|(value, error)| {
185199
let captured_logs = logs.lock().map(|l| l.clone()).unwrap_or_default();
200+
let captured_images = images.lock().map(|i| i.clone()).unwrap_or_default();
186201
ExecutionResult {
187202
value,
188203
logs: captured_logs,
204+
images: captured_images,
189205
is_error: error.is_some(),
190206
error_message: error,
191207
}
192208
})
193209
}
194210
}
195211

212+
fn collect_images(result: &CallToolResult, images: &Arc<std::sync::Mutex<Vec<ImageData>>>) {
213+
for content in &result.content {
214+
if let crate::CallToolResultContent::Image { data, mime_type } = content {
215+
if let Ok(mut collected) = images.lock() {
216+
collected.push(ImageData {
217+
data: data.clone(),
218+
mime_type: mime_type.clone(),
219+
});
220+
}
221+
}
222+
}
223+
}
224+
196225
fn format_call_result(result: &CallToolResult) -> String {
197226
// Prefer structured_content if available
198227
if let Some(structured) = &result.structured_content {
199228
return serde_json::to_string(structured).unwrap_or_else(|_| "null".to_string());
200229
}
201230

202-
// Otherwise, extract text content
231+
// Otherwise, convert all content blocks to JSON-friendly values
203232
let contents: Vec<JsonValue> = result
204233
.content
205234
.iter()
206-
.filter_map(|c| {
207-
if let crate::CallToolResultContent::Text { text } = c {
208-
Some(JsonValue::String(text.clone()))
209-
} else {
210-
None
211-
}
235+
.map(|content| match content {
236+
crate::CallToolResultContent::Text { text } => JsonValue::String(text.clone()),
237+
crate::CallToolResultContent::Image { data, mime_type } => serde_json::json!({
238+
"type": "image",
239+
"data": data,
240+
"mimeType": mime_type,
241+
}),
242+
crate::CallToolResultContent::ResourceLink {
243+
uri,
244+
name,
245+
description,
246+
mime_type,
247+
annotations,
248+
} => serde_json::json!({
249+
"type": "resource_link",
250+
"uri": uri,
251+
"name": name,
252+
"description": description,
253+
"mimeType": mime_type,
254+
"annotations": annotations,
255+
}),
212256
})
213257
.collect();
214258

215-
// If single text content, try to parse as JSON or return as string
259+
// If there's a single content block, unwrap it for convenience.
216260
if contents.len() == 1 {
217261
if let Some(s) = contents[0].as_str() {
218262
// Try to parse as JSON first
@@ -221,6 +265,7 @@ fn format_call_result(result: &CallToolResult) -> String {
221265
}
222266
return s.to_string();
223267
}
268+
return serde_json::to_string(&contents[0]).unwrap_or_else(|_| "null".to_string());
224269
}
225270

226271
serde_json::to_string(&contents).unwrap_or_else(|_| "[]".to_string())
@@ -339,6 +384,19 @@ mod tests {
339384
&serde_json::json!({"message": format!("Hello, {}!", name)}),
340385
))
341386
}
387+
"render" => Ok(CallToolResult {
388+
content: vec![
389+
crate::CallToolResultContent::Image {
390+
data: "AA==".to_string(),
391+
mime_type: "image/png".to_string(),
392+
},
393+
crate::CallToolResultContent::Text {
394+
text: "Rendered".to_string(),
395+
},
396+
],
397+
structured_content: None,
398+
is_error: false,
399+
}),
342400
_ => Err(anyhow::anyhow!("Unknown tool: {}", name)),
343401
}
344402
}
@@ -375,6 +433,15 @@ mod tests {
375433
}),
376434
output_schema: None,
377435
},
436+
ToolInfo {
437+
name: "render",
438+
description: "Render a PNG image",
439+
input_schema: serde_json::json!({
440+
"type": "object",
441+
"properties": {}
442+
}),
443+
output_schema: None,
444+
},
378445
],
379446
});
380447

@@ -413,6 +480,32 @@ mod tests {
413480
assert_eq!(result.value["greeting"], "Hello, Alice!");
414481
}
415482

483+
#[test]
484+
fn test_image_content_preserved() {
485+
let caller = Arc::new(MockToolCaller {
486+
tools: vec![ToolInfo {
487+
name: "render",
488+
description: "Render a PNG image",
489+
input_schema: serde_json::json!({
490+
"type": "object",
491+
"properties": {}
492+
}),
493+
output_schema: None,
494+
}],
495+
});
496+
497+
let runtime = JsRuntime::new().unwrap();
498+
let result = runtime
499+
.execute_with_tools("tools.render({})", caller)
500+
.unwrap();
501+
502+
assert!(!result.is_error, "Error: {:?}", result.error_message);
503+
assert_eq!(result.images.len(), 1);
504+
assert_eq!(result.images[0].mime_type, "image/png");
505+
assert_eq!(result.value[0]["type"], "image");
506+
assert_eq!(result.value[0]["mimeType"], "image/png");
507+
}
508+
416509
#[test]
417510
fn test_console_log_capture() {
418511
let caller = Arc::new(MockToolCaller { tools: vec![] });

crates/pcb-mcp/src/lib.rs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ pub mod discovery;
1010
pub mod proxy;
1111

1212
pub use aggregator::McpAggregator;
13-
pub use codemoder::{ExecutionResult, JsRuntime, ToolCaller};
13+
pub use codemoder::{ExecutionResult, ImageData, JsRuntime, ToolCaller};
1414
pub use discovery::find_pcb_binaries;
1515
pub use proxy::ExternalMcpServer;
1616

@@ -370,20 +370,28 @@ fn handle_execute_tools(
370370

371371
// Build response with execution result
372372
let mut response = serde_json::Map::new();
373-
response.insert("value".to_string(), result.value);
373+
response.insert("value".to_string(), result.value.clone());
374374
response.insert("logs".to_string(), json!(result.logs));
375375

376376
if result.is_error {
377377
response.insert("isError".to_string(), json!(true));
378-
if let Some(msg) = result.error_message {
378+
if let Some(msg) = &result.error_message {
379379
response.insert("errorMessage".to_string(), json!(msg));
380380
}
381381
}
382382

383+
let mut content = vec![CallToolResultContent::Text {
384+
text: serde_json::to_string_pretty(&response)?,
385+
}];
386+
for image in &result.images {
387+
content.push(CallToolResultContent::Image {
388+
data: image.data.clone(),
389+
mime_type: image.mime_type.clone(),
390+
});
391+
}
392+
383393
Ok(CallToolResult {
384-
content: vec![CallToolResultContent::Text {
385-
text: serde_json::to_string_pretty(&response)?,
386-
}],
394+
content,
387395
structured_content: Some(Value::Object(response)),
388396
is_error: result.is_error,
389397
})

crates/pcb/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ starlark_syntax = { workspace = true }
6060
comfy-table = { workspace = true }
6161
serde = { workspace = true }
6262
serde_json = { workspace = true }
63+
base64 = { workspace = true }
6364
toml = { workspace = true }
6465
walkdir = { workspace = true }
6566
zip = { workspace = true }

0 commit comments

Comments
 (0)