Skip to main content

agentic_core/tool/mcp/
handler.rs

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/// Request-scoped mapping from model-visible internal names to public MCP
38/// server and tool identities.
39#[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
108/// Executes one tool discovered from an MCP server.
109///
110/// A handler with no client is used only while normalizing the discovered tool
111/// metadata stored on `McpToolParam` into model-visible function tools.
112pub 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    /// Validates request-level MCP server identities before any discovery I/O.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`ToolError::Config`] when multiple MCP declarations use the
140    /// same `server_label`.
141    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    /// Discovers and normalizes the tools exposed by one MCP server.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`ToolError::Execution`] when the server's `tools/list`
172    /// operation fails or times out.
173    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    /// Returns the spec-only MCP tool handler used during request normalization.
238    #[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 = &param.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, &param.server_label, &param.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(&param.internal_name, &param.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(&param);
487
488        assert!(handler.normalize(&param).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(&param);
628        let mut map = McpToolMap::default();
629
630        map.record(param.internal_name.clone(), tool_ref.clone());
631
632        assert_eq!(map.tool_ref(&param.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(&param);
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}