Skip to content

Commit b0d1c8e

Browse files
cschleidenCopilotstephentoub
authored
feat(rust): support binary tool results (#1222)
* feat(rust): support binary tool results Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: default empty MCP resource MIME types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Stephen Toub <stoub@microsoft.com>
1 parent 671b50a commit b0d1c8e

6 files changed

Lines changed: 219 additions & 6 deletions

File tree

nodejs/src/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,9 +322,13 @@ export function convertMcpCallToolResult(callResult: McpCallToolResult): ToolRes
322322
textParts.push(block.resource.text);
323323
}
324324
if (block.resource?.blob) {
325+
const mimeType = block.resource.mimeType;
325326
binaryResults.push({
326327
data: block.resource.blob,
327-
mimeType: block.resource.mimeType ?? "application/octet-stream",
328+
mimeType:
329+
typeof mimeType === "string" && mimeType
330+
? mimeType
331+
: "application/octet-stream",
328332
type: "resource",
329333
description: block.resource.uri,
330334
});

nodejs/test/call-tool-result.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,17 @@ describe("convertMcpCallToolResult", () => {
130130
type: "resource",
131131
resource: { uri: "file:///data.bin", blob: "binarydata" },
132132
},
133+
{
134+
type: "resource",
135+
resource: { uri: "file:///empty-mime.bin", blob: "binarydata2", mimeType: "" },
136+
},
133137
],
134138
};
135139

136140
const result = convertMcpCallToolResult(input);
137141

138142
expect(result.binaryResultsForLlm![0]!.mimeType).toBe("application/octet-stream");
143+
expect(result.binaryResultsForLlm![1]!.mimeType).toBe("application/octet-stream");
139144
});
140145

141146
it("handles text block with missing text field without corrupting output", () => {

python/copilot/tools.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,14 +307,14 @@ def convert_mcp_call_tool_result(call_result: dict[str, Any]) -> ToolResult:
307307
text_parts.append(text)
308308
blob = resource.get("blob")
309309
if isinstance(blob, str) and blob:
310-
mime_type = resource.get("mimeType", "application/octet-stream")
310+
mime_type = resource.get("mimeType")
311+
if not isinstance(mime_type, str) or not mime_type:
312+
mime_type = "application/octet-stream"
311313
uri = resource.get("uri", "")
312314
binary_results.append(
313315
ToolBinaryResult(
314316
data=blob,
315-
mime_type=mime_type
316-
if isinstance(mime_type, str)
317-
else "application/octet-stream",
317+
mime_type=mime_type,
318318
type="resource",
319319
description=uri if isinstance(uri, str) else "",
320320
)

python/test_tools.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,8 +373,34 @@ def test_resource_blob_to_binary(self):
373373
assert result.binary_results_for_llm is not None
374374
assert len(result.binary_results_for_llm) == 1
375375
assert result.binary_results_for_llm[0].data == "blobdata"
376+
assert result.binary_results_for_llm[0].mime_type == "image/png"
376377
assert result.binary_results_for_llm[0].description == "file:///img.png"
377378

379+
def test_resource_blob_defaults_missing_or_empty_mime_type(self):
380+
result = convert_mcp_call_tool_result(
381+
{
382+
"content": [
383+
{
384+
"type": "resource",
385+
"resource": {"uri": "file:///data.bin", "blob": "binarydata"},
386+
},
387+
{
388+
"type": "resource",
389+
"resource": {
390+
"uri": "file:///empty-mime.bin",
391+
"blob": "binarydata2",
392+
"mimeType": "",
393+
},
394+
},
395+
],
396+
}
397+
)
398+
399+
assert result.binary_results_for_llm is not None
400+
assert len(result.binary_results_for_llm) == 2
401+
assert result.binary_results_for_llm[0].mime_type == "application/octet-stream"
402+
assert result.binary_results_for_llm[1].mime_type == "application/octet-stream"
403+
378404
def test_empty_content_array(self):
379405
result = convert_mcp_call_tool_result({"content": []})
380406
assert result.text_result_for_llm == ""

rust/src/tool.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ pub fn convert_mcp_call_tool_result(value: &serde_json::Value) -> Option<ToolRes
139139
let mime_type = resource
140140
.get("mimeType")
141141
.and_then(serde_json::Value::as_str)
142+
.filter(|s| !s.is_empty())
142143
.unwrap_or("application/octet-stream");
143144
let description = resource
144145
.get("uri")
@@ -523,12 +524,130 @@ mod tests {
523524
.expect("binary results should be captured");
524525
assert_eq!(binary_results.len(), 2);
525526
assert_eq!(binary_results[0].r#type, "image");
527+
assert_eq!(binary_results[0].data, "aW1n");
528+
assert_eq!(binary_results[0].mime_type, "image/png");
526529
assert_eq!(
527530
binary_results[1].description.as_deref(),
528531
Some("file:///tmp/data.bin")
529532
);
530533
}
531534

535+
#[test]
536+
fn convert_mcp_call_tool_result_converts_image_content() {
537+
let result = convert_mcp_call_tool_result(&serde_json::json!({
538+
"content": [
539+
{ "type": "image", "data": "aW1hZ2U=", "mimeType": "image/jpeg" }
540+
]
541+
}))
542+
.expect("valid CallToolResult should convert");
543+
544+
let ToolResult::Expanded(expanded) = result else {
545+
panic!("expected expanded tool result");
546+
};
547+
548+
assert_eq!(expanded.text_result_for_llm, "");
549+
assert_eq!(expanded.result_type, "success");
550+
let binary_results = expanded
551+
.binary_results_for_llm
552+
.expect("image result should be captured");
553+
assert_eq!(binary_results.len(), 1);
554+
assert_eq!(binary_results[0].data, "aW1hZ2U=");
555+
assert_eq!(binary_results[0].mime_type, "image/jpeg");
556+
assert_eq!(binary_results[0].r#type, "image");
557+
assert!(binary_results[0].description.is_none());
558+
}
559+
560+
#[test]
561+
fn convert_mcp_call_tool_result_converts_resource_blob_content() {
562+
let result = convert_mcp_call_tool_result(&serde_json::json!({
563+
"content": [
564+
{
565+
"type": "resource",
566+
"resource": {
567+
"uri": "file:///tmp/report.pdf",
568+
"blob": "cGRm",
569+
"mimeType": "application/pdf"
570+
}
571+
}
572+
]
573+
}))
574+
.expect("valid CallToolResult should convert");
575+
576+
let ToolResult::Expanded(expanded) = result else {
577+
panic!("expected expanded tool result");
578+
};
579+
580+
let binary_results = expanded
581+
.binary_results_for_llm
582+
.expect("resource result should be captured");
583+
assert_eq!(binary_results.len(), 1);
584+
assert_eq!(binary_results[0].data, "cGRm");
585+
assert_eq!(binary_results[0].mime_type, "application/pdf");
586+
assert_eq!(binary_results[0].r#type, "resource");
587+
assert_eq!(
588+
binary_results[0].description.as_deref(),
589+
Some("file:///tmp/report.pdf")
590+
);
591+
}
592+
593+
#[test]
594+
fn convert_mcp_call_tool_result_defaults_resource_blob_mime_type() {
595+
let result = convert_mcp_call_tool_result(&serde_json::json!({
596+
"content": [
597+
{
598+
"type": "resource",
599+
"resource": {
600+
"uri": "file:///tmp/data.bin",
601+
"blob": "Ymlu"
602+
}
603+
},
604+
{
605+
"type": "resource",
606+
"resource": {
607+
"blob": "YmluMg==",
608+
"mimeType": ""
609+
}
610+
}
611+
]
612+
}))
613+
.expect("valid CallToolResult should convert");
614+
615+
let ToolResult::Expanded(expanded) = result else {
616+
panic!("expected expanded tool result");
617+
};
618+
619+
let binary_results = expanded
620+
.binary_results_for_llm
621+
.expect("resource blobs should be captured");
622+
assert_eq!(binary_results.len(), 2);
623+
assert_eq!(binary_results[0].mime_type, "application/octet-stream");
624+
assert_eq!(binary_results[1].mime_type, "application/octet-stream");
625+
}
626+
627+
#[test]
628+
fn convert_mcp_call_tool_result_omits_binary_results_without_binary_content() {
629+
let result = convert_mcp_call_tool_result(&serde_json::json!({
630+
"content": [
631+
{ "type": "text", "text": "hello" },
632+
{
633+
"type": "resource",
634+
"resource": {
635+
"uri": "file:///tmp/readme.md",
636+
"text": "resource text"
637+
}
638+
}
639+
]
640+
}))
641+
.expect("valid CallToolResult should convert");
642+
643+
let ToolResult::Expanded(expanded) = result else {
644+
panic!("expected expanded tool result");
645+
};
646+
647+
assert_eq!(expanded.text_result_for_llm, "hello\nresource text");
648+
assert!(expanded.binary_results_for_llm.is_none());
649+
}
650+
532651
#[tokio::test]
533652
async fn tool_handler_call_returns_result() {
534653
let tool = EchoTool;

rust/src/types.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3125,7 +3125,8 @@ mod tests {
31253125
Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange,
31263126
ConnectionState, CustomAgentConfig, DeliveryMode, GitHubReferenceType,
31273127
InfiniteSessionConfig, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionEvent,
3128-
SessionId, SystemMessageConfig, Tool, ensure_attachment_display_names,
3128+
SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded,
3129+
ToolResultResponse, ensure_attachment_display_names,
31293130
};
31303131
use crate::generated::session_events::TypedSessionEvent;
31313132

@@ -3157,6 +3158,64 @@ mod tests {
31573158
assert!(tool.parameters.is_empty());
31583159
}
31593160

3161+
#[test]
3162+
fn tool_result_expanded_serializes_binary_results_for_llm() {
3163+
let response = ToolResultResponse {
3164+
result: ToolResult::Expanded(ToolResultExpanded {
3165+
text_result_for_llm: "rendered chart".to_string(),
3166+
result_type: "success".to_string(),
3167+
binary_results_for_llm: Some(vec![ToolBinaryResult {
3168+
data: "aW1n".to_string(),
3169+
mime_type: "image/png".to_string(),
3170+
r#type: "image".to_string(),
3171+
description: Some("chart preview".to_string()),
3172+
}]),
3173+
session_log: None,
3174+
error: None,
3175+
tool_telemetry: None,
3176+
}),
3177+
};
3178+
3179+
let wire = serde_json::to_value(&response).unwrap();
3180+
3181+
assert_eq!(
3182+
wire,
3183+
json!({
3184+
"result": {
3185+
"textResultForLlm": "rendered chart",
3186+
"resultType": "success",
3187+
"binaryResultsForLlm": [
3188+
{
3189+
"data": "aW1n",
3190+
"mimeType": "image/png",
3191+
"type": "image",
3192+
"description": "chart preview"
3193+
}
3194+
]
3195+
}
3196+
})
3197+
);
3198+
}
3199+
3200+
#[test]
3201+
fn tool_result_expanded_omits_binary_results_for_llm_when_none() {
3202+
let response = ToolResultResponse {
3203+
result: ToolResult::Expanded(ToolResultExpanded {
3204+
text_result_for_llm: "ok".to_string(),
3205+
result_type: "success".to_string(),
3206+
binary_results_for_llm: None,
3207+
session_log: None,
3208+
error: None,
3209+
tool_telemetry: None,
3210+
}),
3211+
};
3212+
3213+
let wire = serde_json::to_value(&response).unwrap();
3214+
3215+
assert_eq!(wire["result"]["textResultForLlm"], "ok");
3216+
assert!(wire["result"].get("binaryResultsForLlm").is_none());
3217+
}
3218+
31603219
#[test]
31613220
fn session_config_default_enables_permission_flow_flags() {
31623221
let cfg = SessionConfig::default();

0 commit comments

Comments
 (0)