1use std::collections::{HashMap, HashSet};
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::Arc;
5
6use serde::Deserialize;
7use serde_json::Value;
8
9use crate::tool::{GatewayExecutor, ToolError, ToolHandler, ToolOutput, ToolType};
10use crate::types::io::FunctionTool;
11use crate::types::io::output::{
12 FunctionToolCall, GatewayCallStatus, McpCall, McpCallError, McpCallStatus, McpListTool, McpListTools, OutputItem,
13};
14use crate::types::tools::{McpDiscoveredToolParam, ResponsesTool};
15use crate::utils::common::{
16 deserialize_from_str, deserialize_from_str_opt, deserialize_from_value, serialize_to_string,
17};
18use crate::utils::uuid7_str;
19
20use super::{McpClient, McpError};
21
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub(crate) struct McpToolRef {
24 server_label: String,
25 tool_name: String,
26}
27
28impl From<&McpDiscoveredToolParam> for McpToolRef {
29 fn from(param: &McpDiscoveredToolParam) -> Self {
30 Self {
31 server_label: param.server_label.clone(),
32 tool_name: param.tool_name.clone(),
33 }
34 }
35}
36
37#[derive(Clone, Debug, Default)]
40pub(crate) struct McpToolMap {
41 calls: HashMap<String, McpToolRef>,
42}
43
44impl McpToolMap {
45 pub(crate) fn record(&mut self, internal_name: String, tool_ref: McpToolRef) {
46 debug_assert!(self.calls.insert(internal_name, tool_ref).is_none());
47 }
48
49 pub(crate) fn tool_ref(&self, internal_name: &str) -> Option<&McpToolRef> {
50 self.calls.get(internal_name)
51 }
52
53 pub(crate) fn contains_server_label(&self, server_label: &str) -> bool {
54 self.calls
55 .values()
56 .any(|tool_ref| tool_ref.server_label == server_label)
57 }
58}
59
60#[must_use]
61pub(crate) fn output_item(
62 call: &FunctionToolCall,
63 output: &ToolOutput,
64 status: GatewayCallStatus,
65 tool_ref: &McpToolRef,
66) -> OutputItem {
67 let error = if status == GatewayCallStatus::Failed {
68 Some(McpCallError::tool_execution(error_text_from_output(&output.output)))
69 } else {
70 None
71 };
72 let successful_output = (status == GatewayCallStatus::Completed).then(|| output.output.clone());
73
74 OutputItem::McpCall(McpCall::new(
75 call_output_id(call),
76 tool_ref.server_label.clone(),
77 tool_ref.tool_name.clone(),
78 call.arguments.clone(),
79 status.into(),
80 successful_output,
81 error,
82 ))
83}
84
85#[must_use]
86pub(crate) fn started_output_item(call: &FunctionToolCall, tool_ref: &McpToolRef) -> OutputItem {
87 OutputItem::McpCall(McpCall::new(
88 call_output_id(call),
89 tool_ref.server_label.clone(),
90 tool_ref.tool_name.clone(),
91 "",
92 McpCallStatus::InProgress,
93 None,
94 None,
95 ))
96}
97
98#[must_use]
99pub(crate) fn list_tools_output_item(item: &McpListTools) -> OutputItem {
100 OutputItem::McpListTools(item.clone())
101}
102
103#[must_use]
104pub(crate) fn started_list_tools_output_item(item: &McpListTools) -> OutputItem {
105 OutputItem::McpListTools(McpListTools::new(item.id.clone(), item.server_label.clone(), vec![]))
106}
107
108pub struct McpHandler {
113 client: Option<Arc<McpClient>>,
114}
115
116#[derive(Deserialize)]
117struct McpToolNormalizationParams {
118 #[serde(rename = "_agentic_discovered_tools", default)]
119 discovered_tools: Vec<McpDiscoveredToolParam>,
120}
121
122#[derive(Clone)]
123pub struct McpDiscoveredHandler {
124 pub param: McpDiscoveredToolParam,
125 pub handler: Arc<McpHandler>,
126}
127
128#[derive(Clone)]
129pub(crate) struct McpServerToolSet {
130 pub discovered_handlers: Vec<McpDiscoveredHandler>,
131 pub list_tools_item: McpListTools,
132}
133
134impl McpHandler {
135 pub(crate) fn validate_server_labels(tools: &[ResponsesTool]) -> Result<(), ToolError> {
142 let mut server_labels = HashSet::new();
143 for param in tools.iter().filter_map(|tool| match tool {
144 ResponsesTool::Mcp(param) => Some(param),
145 _ => None,
146 }) {
147 if !server_labels.insert(param.server_label.clone()) {
148 return Err(ToolError::Config(format!(
149 "duplicate MCP declarations are not allowed for server_label '{}'",
150 param.server_label
151 )));
152 }
153 }
154 Ok(())
155 }
156
157 #[must_use]
158 pub const fn discovered_tool_spec_only() -> Self {
159 Self { client: None }
160 }
161
162 #[must_use]
163 pub fn tool_call(client: Arc<McpClient>) -> Self {
164 Self { client: Some(client) }
165 }
166
167 pub async fn discovered_tool_handlers(
174 server_label: &str,
175 client: Arc<McpClient>,
176 allowed_tools: Option<&[String]>,
177 ) -> Result<Vec<McpDiscoveredHandler>, ToolError> {
178 let tools = client
179 .list_tools()
180 .await
181 .map_err(|error| mcp_discovery_error(server_label, &error))?;
182
183 let mut discovered_handlers = Vec::new();
184 let mut internal_names = HashMap::new();
185 for tool in tools {
186 let tool_name = tool.name.to_string();
187 if allowed_tools.is_some_and(|allowed| !allowed.iter().any(|name| name == &tool_name)) {
188 continue;
189 }
190 let internal_name = internal_mcp_tool_name(server_label, &tool_name, &mut internal_names);
191 discovered_handlers.push(McpDiscoveredHandler {
192 param: McpDiscoveredToolParam {
193 server_label: server_label.to_owned(),
194 tool_name,
195 internal_name,
196 tool,
197 },
198 handler: Arc::new(Self::tool_call(Arc::clone(&client))),
199 });
200 }
201
202 Ok(discovered_handlers)
203 }
204
205 pub(crate) async fn discover_tools(
206 server_label: &str,
207 client: Arc<McpClient>,
208 allowed_tools: Option<&[String]>,
209 ) -> Result<McpServerToolSet, ToolError> {
210 let handlers = Self::discovered_tool_handlers(server_label, client, allowed_tools).await?;
211 Ok(Self::server_tool_set_from_handlers(server_label, handlers))
212 }
213
214 #[must_use]
215 pub(crate) fn server_tool_set_from_handlers(
216 server_label: &str,
217 discovered_handlers: Vec<McpDiscoveredHandler>,
218 ) -> McpServerToolSet {
219 let tools = discovered_handlers
220 .iter()
221 .map(|discovered| mcp_list_tool(&discovered.param))
222 .collect();
223
224 McpServerToolSet {
225 discovered_handlers,
226 list_tools_item: McpListTools::new(uuid7_str("mcpl_"), server_label, tools),
227 }
228 }
229
230 #[must_use]
231 pub(crate) fn failed_list_tools_item(server_label: &str, error: &ToolError) -> McpListTools {
232 let mut item = McpListTools::new(uuid7_str("mcpl_"), server_label, Vec::new());
233 item.error = Some(error.to_string());
234 item
235 }
236
237 #[must_use]
239 pub const fn spec_from_param(_param: &Value) -> Self {
240 Self::discovered_tool_spec_only()
241 }
242}
243
244fn mcp_discovery_error(server_label: &str, error: &McpError) -> ToolError {
245 ToolError::Execution(format!("tools/list failed for MCP server '{server_label}': {error}"))
246}
247
248fn mcp_list_tool(param: &McpDiscoveredToolParam) -> McpListTool {
249 let tool = ¶m.tool;
250 let read_only = tool
251 .annotations
252 .as_ref()
253 .and_then(|annotations| annotations.read_only_hint)
254 .unwrap_or(false);
255 let annotations = Value::Object([("read_only".to_owned(), Value::Bool(read_only))].into_iter().collect());
256
257 McpListTool::new(
258 param.tool_name.clone(),
259 tool.description.as_deref().map(str::to_owned),
260 Value::Object(tool.input_schema.as_ref().clone()),
261 Some(annotations),
262 )
263}
264
265impl ToolHandler for McpHandler {
266 fn tool_type(&self) -> ToolType {
267 ToolType::Mcp
268 }
269
270 fn validate(&self, _param: &Value) -> Result<(), ToolError> {
271 Ok(())
272 }
273
274 fn normalize(&self, param: &Value) -> Vec<FunctionTool> {
275 match deserialize_from_value::<McpToolNormalizationParams>(param.clone()) {
276 Ok(params) => params
277 .discovered_tools
278 .iter()
279 .map(discovered_mcp_function_tool)
280 .collect(),
281 Err(error) => {
282 tracing::warn!(error = %error, "invalid MCP tool param");
283 Vec::new()
284 }
285 }
286 }
287}
288
289impl GatewayExecutor for McpHandler {
290 fn execute(
291 &self,
292 call_id: &str,
293 _tool_name: &str,
294 arguments: &str,
295 config: &Value,
296 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
297 let call_id = call_id.to_owned();
298 let arguments = arguments.to_owned();
299 let config = config.clone();
300
301 Box::pin(async move {
302 let Some(client) = &self.client else {
303 return Err(ToolError::Config(
304 "MCP tool spec-only handler cannot execute tools".to_owned(),
305 ));
306 };
307 let param = mcp_tool_param(&config)?;
308 let output = execute_tool_call(client, ¶m.server_label, ¶m.tool_name, &arguments).await?;
309
310 Ok(ToolOutput { call_id, output })
311 })
312 }
313}
314
315async fn execute_tool_call(
316 client: &McpClient,
317 server_label: &str,
318 mcp_tool_name: &str,
319 arguments: &str,
320) -> Result<String, ToolError> {
321 let args = parse_tool_arguments(arguments)?;
322
323 let result = client
324 .call_tool(mcp_tool_name, Some(args))
325 .await
326 .map_err(|error| ToolError::Execution(format!("tools/call failed for MCP server '{server_label}': {error}")))?;
327
328 mcp_tool_result_text(&result)
329}
330
331fn parse_tool_arguments(arguments: &str) -> Result<Value, ToolError> {
332 let arguments = deserialize_from_str::<Value>(arguments)
333 .map_err(|error| ToolError::Execution(format!("invalid MCP tool arguments: {error}")))?;
334 if !arguments.is_object() {
335 return Err(ToolError::Execution(
336 "MCP tool arguments must be a JSON object".to_owned(),
337 ));
338 }
339 Ok(arguments)
340}
341
342fn mcp_tool_result_text(result: &rmcp::model::CallToolResult) -> Result<String, ToolError> {
343 let text = result
344 .content
345 .iter()
346 .filter_map(|content| content.as_text().map(|text| text.text.as_str()))
347 .collect::<Vec<_>>()
348 .join("\n");
349 let output = if !text.is_empty() {
350 text
351 } else if let Some(structured_content) = &result.structured_content {
352 serialize_to_string(structured_content)
353 .map_err(|error| ToolError::Execution(format!("failed to serialize MCP structured content: {error}")))?
354 } else {
355 serialize_to_string(&result.content)
356 .map_err(|error| ToolError::Execution(format!("failed to serialize MCP tool content: {error}")))?
357 };
358
359 if result.is_error == Some(true) {
360 Err(ToolError::Execution(output))
361 } else {
362 Ok(output)
363 }
364}
365
366fn mcp_tool_param(value: &Value) -> Result<McpDiscoveredToolParam, ToolError> {
367 deserialize_from_value::<McpDiscoveredToolParam>(value.clone())
368 .map_err(|error| ToolError::Config(format!("invalid MCP tool config: {error}")))
369}
370
371pub(crate) fn discovered_mcp_function_tool(param: &McpDiscoveredToolParam) -> FunctionTool {
372 mcp_tool_to_function_tool(¶m.internal_name, ¶m.tool)
373}
374
375#[cfg(test)]
376const INTERNAL_DISCOVERED_TOOLS_KEY: &str = "_agentic_discovered_tools";
377const INTERNAL_MCP_PREFIX: &str = "mcp__";
378const MAX_INTERNAL_TOOL_NAME_LEN: usize = 64;
379
380fn internal_mcp_tool_name(server_label: &str, tool_name: &str, used: &mut HashMap<String, (String, String)>) -> String {
381 let identity = (server_label.to_owned(), tool_name.to_owned());
382 let base = sanitize_internal_tool_name(&format!("{INTERNAL_MCP_PREFIX}{server_label}__{tool_name}"));
383 if base.len() <= MAX_INTERNAL_TOOL_NAME_LEN && used.get(&base).is_none_or(|existing| existing == &identity) {
384 used.insert(base.clone(), identity);
385 return base;
386 }
387
388 let mut attempt = 0_u32;
389 loop {
390 let hash_input = if attempt == 0 {
391 format!("{server_label}:{tool_name}")
392 } else {
393 format!("{server_label}:{tool_name}:{attempt}")
394 };
395 let suffix = format!("__{:010x}", stable_name_hash(&hash_input) & 0xff_ffff_ffff);
396 let prefix_len = MAX_INTERNAL_TOOL_NAME_LEN.saturating_sub(suffix.len());
397 let candidate = format!("{}{}", &base[..base.len().min(prefix_len)], suffix);
398 if used.get(&candidate).is_none_or(|existing| existing == &identity) {
399 used.insert(candidate.clone(), identity);
400 return candidate;
401 }
402 attempt = attempt.saturating_add(1);
403 }
404}
405
406fn sanitize_internal_tool_name(value: &str) -> String {
407 value
408 .chars()
409 .map(|ch| {
410 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') {
411 ch
412 } else {
413 '_'
414 }
415 })
416 .collect()
417}
418
419fn stable_name_hash(value: &str) -> u64 {
420 value.as_bytes().iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
421 (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
422 })
423}
424
425fn mcp_tool_to_function_tool(name: &str, tool: &rmcp::model::Tool) -> FunctionTool {
426 let mut parameters = Value::Object(tool.input_schema.as_ref().clone());
427
428 if let Value::Object(object) = &mut parameters
429 && object.get("properties").is_none_or(Value::is_null)
430 {
431 object.insert("properties".to_owned(), Value::Object(serde_json::Map::new()));
432 }
433
434 FunctionTool {
435 type_: "function".to_owned(),
436 name: name.to_owned(),
437 description: tool.description.as_ref().map(ToString::to_string),
438 parameters: Some(parameters),
439 strict: Some(false),
440 }
441}
442
443fn error_text_from_output(output: &str) -> String {
444 deserialize_from_str_opt::<Value>(output)
445 .and_then(|value| value.get("error").and_then(Value::as_str).map(str::to_owned))
446 .filter(|error| !error.trim().is_empty())
447 .unwrap_or_else(|| output.to_owned())
448}
449
450fn call_output_id(call: &FunctionToolCall) -> String {
451 if let Some(suffix) = call.id.strip_prefix("fc_").filter(|suffix| !suffix.is_empty()) {
452 return format!("mcp_{suffix}");
453 }
454 if let Some(suffix) = call.call_id.strip_prefix("call_").filter(|suffix| !suffix.is_empty()) {
455 return format!("mcp_{suffix}");
456 }
457 let source_identity = format!("{}\0{}", call.id, call.call_id);
458 format!("mcp_{:016x}", stable_name_hash(&source_identity))
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 fn discovered_param() -> McpDiscoveredToolParam {
466 McpDiscoveredToolParam {
467 server_label: "counter".to_owned(),
468 tool_name: "increment".to_owned(),
469 internal_name: "mcp__counter__increment".to_owned(),
470 tool: serde_json::from_value(serde_json::json!({
471 "name": "increment",
472 "description": "Increment the counter",
473 "inputSchema": {"type": "object"}
474 }))
475 .expect("valid MCP tool"),
476 }
477 }
478
479 #[test]
480 fn native_mcp_param_without_discovery_normalizes_to_no_functions() {
481 let param = serde_json::json!({
482 "server_label": "counter",
483 "server_url": "http://127.0.0.1:8000/mcp"
484 });
485
486 let handler = McpHandler::spec_from_param(¶m);
487
488 assert!(handler.normalize(¶m).is_empty());
489 }
490
491 #[test]
492 fn discovered_tool_normalizes_to_function_tool() {
493 let handler = McpHandler::discovered_tool_spec_only();
494 let config = serde_json::json!({
495 (INTERNAL_DISCOVERED_TOOLS_KEY): [discovered_param()]
496 });
497
498 let normalized = handler.normalize(&config);
499
500 assert_eq!(normalized.len(), 1);
501 assert_eq!(normalized[0].name, "mcp__counter__increment");
502 assert_eq!(
503 normalized[0].parameters.as_ref().unwrap()["properties"],
504 serde_json::json!({})
505 );
506 }
507
508 #[test]
509 fn discovery_builds_openai_list_tools_item_from_mcp_tools() {
510 let mut read_only_param = discovered_param();
511 read_only_param.tool_name = "get_value".to_owned();
512 read_only_param.internal_name = "mcp__counter__get_value".to_owned();
513 read_only_param.tool.name = "stale_raw_name".to_owned().into();
514 read_only_param.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(true));
515
516 let handlers = vec![discovered_param(), read_only_param]
517 .into_iter()
518 .map(|param| McpDiscoveredHandler {
519 param,
520 handler: Arc::new(McpHandler::discovered_tool_spec_only()),
521 })
522 .collect();
523
524 let tool_set = McpHandler::server_tool_set_from_handlers("counter", handlers);
525
526 assert!(tool_set.list_tools_item.id.starts_with("mcpl_"));
527 assert_eq!(tool_set.list_tools_item.server_label, "counter");
528 assert_eq!(tool_set.discovered_handlers.len(), 2);
529 assert_eq!(
530 tool_set
531 .list_tools_item
532 .tools
533 .iter()
534 .map(|tool| tool.name.as_str())
535 .collect::<Vec<_>>(),
536 ["increment", "get_value"]
537 );
538 assert_eq!(
539 tool_set.list_tools_item.tools[0].input_schema,
540 serde_json::json!({"type": "object"})
541 );
542 assert_eq!(
543 tool_set.list_tools_item.tools[0].annotations,
544 Some(serde_json::json!({"read_only": false}))
545 );
546 assert_eq!(
547 tool_set.list_tools_item.tools[1].annotations,
548 Some(serde_json::json!({"read_only": true}))
549 );
550 }
551
552 #[test]
553 fn list_tools_output_items_share_identity_across_lifecycle() {
554 let list_tools = McpListTools::new(
555 "mcpl_1",
556 "counter",
557 vec![McpListTool::new(
558 "increment",
559 Some("Increment the counter".to_owned()),
560 serde_json::json!({"type": "object", "properties": {}}),
561 Some(serde_json::json!({"read_only": false})),
562 )],
563 );
564
565 let OutputItem::McpListTools(started) = started_list_tools_output_item(&list_tools) else {
566 panic!("expected started mcp_list_tools");
567 };
568 let OutputItem::McpListTools(completed) = list_tools_output_item(&list_tools) else {
569 panic!("expected completed mcp_list_tools");
570 };
571
572 assert_eq!(started.id, "mcpl_1");
573 assert_eq!(started.server_label, "counter");
574 assert!(started.tools.is_empty());
575 assert!(started.error.is_none());
576 assert_eq!(completed.id, started.id);
577 assert_eq!(completed.server_label, started.server_label);
578 assert_eq!(completed.tools.len(), 1);
579 assert_eq!(completed.tools[0].name, "increment");
580 }
581
582 #[test]
583 fn tools_list_failure_preserves_upstream_cause_as_execution_error() {
584 let upstream_error = super::super::McpError::Timeout {
585 operation: super::super::McpOperation::ListTools,
586 };
587
588 let error = mcp_discovery_error("counter", &upstream_error);
589
590 assert!(matches!(error, ToolError::Execution(_)));
591 assert!(error.to_string().contains("tools/list failed for MCP server 'counter'"));
592 assert!(error.to_string().contains("timed out during tools/list"));
593 }
594
595 #[test]
596 fn mcp_tool_arguments_require_valid_json_object() {
597 assert_eq!(
598 parse_tool_arguments(r#"{"amount":1}"#).unwrap(),
599 serde_json::json!({"amount": 1})
600 );
601
602 let malformed = parse_tool_arguments(r#"{"amount":"#).unwrap_err();
603 assert!(matches!(
604 malformed,
605 ToolError::Execution(message) if message.contains("invalid MCP tool arguments")
606 ));
607
608 let non_object = parse_tool_arguments("null").unwrap_err();
609 assert!(matches!(
610 non_object,
611 ToolError::Execution(message) if message == "MCP tool arguments must be a JSON object"
612 ));
613 }
614
615 #[test]
616 fn internal_tool_names_include_server_and_tool_identity() {
617 let mut used = HashMap::new();
618
619 let name = internal_mcp_tool_name("counter server", "increment/value", &mut used);
620
621 assert_eq!(name, "mcp__counter_server__increment_value");
622 }
623
624 #[test]
625 fn tool_map_resolves_internal_name_to_public_mcp_identity() {
626 let param = discovered_param();
627 let tool_ref = McpToolRef::from(¶m);
628 let mut map = McpToolMap::default();
629
630 map.record(param.internal_name.clone(), tool_ref.clone());
631
632 assert_eq!(map.tool_ref(¶m.internal_name), Some(&tool_ref));
633 assert!(map.contains_server_label("counter"));
634 assert!(!map.contains_server_label("missing"));
635 }
636
637 #[test]
638 fn discovered_tool_output_uses_public_mcp_identity() {
639 let call = FunctionToolCall {
640 id: "fc_1".to_owned(),
641 call_id: "call_1".to_owned(),
642 name: "mcp__counter__increment".to_owned(),
643 arguments: "{}".to_owned(),
644 status: crate::types::event::MessageStatus::Completed,
645 namespace: None,
646 };
647 let output = ToolOutput {
648 call_id: call.call_id.clone(),
649 output: "1".to_owned(),
650 };
651 let tool_ref = McpToolRef::from(&discovered_param());
652
653 let OutputItem::McpCall(item) = output_item(&call, &output, GatewayCallStatus::Completed, &tool_ref) else {
654 panic!("expected mcp_call");
655 };
656
657 assert_eq!(item.server_label, "counter");
658 assert_eq!(item.name, "increment");
659 assert_eq!(item.arguments, "{}");
660 assert_eq!(item.output.as_deref(), Some("1"));
661 }
662
663 #[test]
664 fn prefixless_function_ids_reuse_public_mcp_id_across_lifecycle() {
665 let call = FunctionToolCall {
666 id: "provider-item-1".to_owned(),
667 call_id: "provider-call-1".to_owned(),
668 name: "mcp__counter__increment".to_owned(),
669 arguments: "{}".to_owned(),
670 status: crate::types::event::MessageStatus::Completed,
671 namespace: None,
672 };
673 let output = ToolOutput {
674 call_id: call.call_id.clone(),
675 output: "1".to_owned(),
676 };
677 let tool_ref = McpToolRef::from(&discovered_param());
678
679 let OutputItem::McpCall(started) = started_output_item(&call, &tool_ref) else {
680 panic!("expected started mcp_call");
681 };
682 let OutputItem::McpCall(completed) = output_item(&call, &output, GatewayCallStatus::Completed, &tool_ref)
683 else {
684 panic!("expected completed mcp_call");
685 };
686
687 assert!(started.id.starts_with("mcp_"));
688 assert_eq!(started.id, completed.id);
689 }
690
691 #[test]
692 fn idless_parallel_calls_receive_distinct_public_mcp_ids() {
693 let calls = [
694 serde_json::from_value::<FunctionToolCall>(serde_json::json!({
695 "name": "mcp__counter__increment",
696 "arguments": "{}"
697 }))
698 .expect("valid first function call"),
699 serde_json::from_value::<FunctionToolCall>(serde_json::json!({
700 "name": "mcp__counter__increment",
701 "arguments": "{}"
702 }))
703 .expect("valid second function call"),
704 ];
705 let tool_ref = McpToolRef::from(&discovered_param());
706
707 let public_ids = calls
708 .iter()
709 .map(|call| {
710 let OutputItem::McpCall(started) = started_output_item(call, &tool_ref) else {
711 panic!("expected started mcp_call");
712 };
713 let output = ToolOutput {
714 call_id: call.call_id.clone(),
715 output: "1".to_owned(),
716 };
717 let OutputItem::McpCall(completed) =
718 output_item(call, &output, GatewayCallStatus::Completed, &tool_ref)
719 else {
720 panic!("expected completed mcp_call");
721 };
722
723 assert_eq!(started.id, completed.id);
724 started.id
725 })
726 .collect::<Vec<_>>();
727
728 assert_ne!(public_ids[0], public_ids[1]);
729 }
730
731 #[test]
732 fn successful_mcp_result_exposes_text_instead_of_protocol_envelope() {
733 let result = serde_json::from_value::<rmcp::model::CallToolResult>(serde_json::json!({
734 "content": [{"type": "text", "text": "42"}],
735 "isError": false
736 }))
737 .expect("valid MCP result");
738
739 assert_eq!(mcp_tool_result_text(&result).unwrap(), "42");
740 }
741
742 #[test]
743 fn mcp_error_result_becomes_execution_failure() {
744 let result = serde_json::from_value::<rmcp::model::CallToolResult>(serde_json::json!({
745 "content": [{"type": "text", "text": "missing field `b`"}],
746 "isError": true
747 }))
748 .expect("valid MCP result");
749
750 let error = mcp_tool_result_text(&result).unwrap_err();
751 assert!(matches!(error, ToolError::Execution(message) if message == "missing field `b`"));
752 }
753
754 #[test]
755 fn failed_mcp_output_uses_openai_structured_error() {
756 let call = FunctionToolCall {
757 id: "fc_1".to_owned(),
758 call_id: "call_1".to_owned(),
759 name: "mcp__counter__sum".to_owned(),
760 arguments: r#"{"a":40}"#.to_owned(),
761 status: crate::types::event::MessageStatus::Completed,
762 namespace: None,
763 };
764 let output = ToolOutput {
765 call_id: call.call_id.clone(),
766 output: r#"{"error":"missing field `b`"}"#.to_owned(),
767 };
768 let mut param = discovered_param();
769 param.tool_name = "sum".to_owned();
770 let tool_ref = McpToolRef::from(¶m);
771
772 let item = output_item(&call, &output, GatewayCallStatus::Failed, &tool_ref);
773 let json = serde_json::to_value(item).expect("serializable mcp_call");
774
775 assert_eq!(json["status"], "failed");
776 assert!(json["output"].is_null());
777 assert_eq!(json["error"]["type"], "mcp_tool_execution_error");
778 assert_eq!(json["error"]["content"][0]["type"], "text");
779 assert_eq!(json["error"]["content"][0]["text"], "missing field `b`");
780 assert!(json["error"]["content"][0]["annotations"].is_null());
781 assert!(json["error"]["content"][0]["meta"].is_null());
782 }
783}