diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 0cba45d7e210..ac18fafd896f 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -39,6 +39,83 @@ const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` return - `wait` returns only the new output since the last yield, or the final completion or termination result for that cell. - If the cell is still running, `wait` may yield again with the same `cell_id`. - If the cell has already finished, `wait` returns the completed result and closes the cell."#; +// Based off of https://modelcontextprotocol.io/specification/draft/schema#calltoolresult +const MCP_TYPESCRIPT_PREAMBLE: &str = r#"type Role = "user" | "assistant"; +type MetaObject = Record; +type Annotations = { + audience?: Role[]; + priority?: number; + lastModified?: string; +}; +type Icon = { + src: string; + mimeType?: string; + sizes?: string[]; + theme?: "light" | "dark"; +}; +type TextResourceContents = { + uri: string; + mimeType?: string; + _meta?: MetaObject; + text: string; +}; +type BlobResourceContents = { + uri: string; + mimeType?: string; + _meta?: MetaObject; + blob: string; +}; +type TextContent = { + type: "text"; + text: string; + annotations?: Annotations; + _meta?: MetaObject; +}; +type ImageContent = { + type: "image"; + data: string; + mimeType: string; + annotations?: Annotations; + _meta?: MetaObject; +}; +type AudioContent = { + type: "audio"; + data: string; + mimeType: string; + annotations?: Annotations; + _meta?: MetaObject; +}; +type ResourceLink = { + icons?: Icon[]; + name: string; + title?: string; + uri: string; + description?: string; + mimeType?: string; + annotations?: Annotations; + size?: number; + _meta?: MetaObject; + type: "resource_link"; +}; +type EmbeddedResource = { + type: "resource"; + resource: TextResourceContents | BlobResourceContents; + annotations?: Annotations; + _meta?: MetaObject; +}; +type ContentBlock = + | TextContent + | ImageContent + | AudioContent + | ResourceLink + | EmbeddedResource; +type CallToolResult = { + _meta?: MetaObject; + content: ContentBlock[]; + isError?: boolean; + structuredContent?: TStructured; + [key: string]: unknown; +};"#; pub const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:"; @@ -169,7 +246,7 @@ pub fn is_code_mode_nested_tool(tool_name: &str) -> bool { } pub fn build_exec_tool_description( - enabled_tools: &[(String, String)], + enabled_tools: &[ToolDefinition], namespace_descriptions: &BTreeMap, code_mode_only: bool, ) -> String { @@ -185,8 +262,13 @@ pub fn build_exec_tool_description( if !enabled_tools.is_empty() { let mut current_namespace: Option<&str> = None; let mut nested_tool_sections = Vec::with_capacity(enabled_tools.len()); + let has_mcp_tools = enabled_tools + .iter() + .any(|tool| mcp_structured_content_schema(tool.output_schema.as_ref()).is_some()); - for (name, nested_description) in enabled_tools { + for tool in enabled_tools { + let name = tool.name.as_str(); + let nested_description = render_code_mode_sample_for_definition(tool); let next_namespace = namespace_descriptions .get(name) .map(|namespace_description| namespace_description.name.as_str()); @@ -206,14 +288,20 @@ pub fn build_exec_tool_description( let global_name = normalize_code_mode_identifier(name); let nested_description = nested_description.trim(); if nested_description.is_empty() { - nested_tool_sections.push(format!("### `{global_name}` (`{name}`)")); + nested_tool_sections.push(render_tool_heading(&global_name, name)); } else { nested_tool_sections.push(format!( - "### `{global_name}` (`{name}`)\n{nested_description}" + "{}\n{nested_description}", + render_tool_heading(&global_name, name) )); } } + if has_mcp_tools { + sections.push(format!( + "Shared MCP Types:\n```ts\n{MCP_TYPESCRIPT_PREAMBLE}\n```" + )); + } let nested_tool_reference = nested_tool_sections.join("\n\n"); sections.push(nested_tool_reference); } @@ -251,7 +339,7 @@ pub fn normalize_code_mode_identifier(tool_key: &str) -> String { pub fn augment_tool_definition(mut definition: ToolDefinition) -> ToolDefinition { if definition.name != PUBLIC_TOOL_NAME { - definition.description = append_code_mode_sample_for_definition(&definition); + definition.description = render_code_mode_sample_for_definition(&definition); } definition } @@ -273,7 +361,7 @@ pub struct EnabledToolMetadata { pub kind: CodeModeToolKind, } -pub fn append_code_mode_sample( +pub fn render_code_mode_sample( description: &str, tool_name: &str, input_name: &str, @@ -287,7 +375,7 @@ pub fn append_code_mode_sample( format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```") } -fn append_code_mode_sample_for_definition(definition: &ToolDefinition) -> String { +fn render_code_mode_sample_for_definition(definition: &ToolDefinition) -> String { let input_name = match definition.kind { CodeModeToolKind::Function => "args", CodeModeToolKind::Freeform => "input", @@ -300,12 +388,23 @@ fn append_code_mode_sample_for_definition(definition: &ToolDefinition) -> String .unwrap_or_else(|| "unknown".to_string()), CodeModeToolKind::Freeform => "string".to_string(), }; - let output_type = definition - .output_schema - .as_ref() - .map(render_json_schema_to_typescript) - .unwrap_or_else(|| "unknown".to_string()); - append_code_mode_sample( + let output_type = if let Some(structured_content_schema) = + mcp_structured_content_schema(definition.output_schema.as_ref()) + { + let structured_content_type = render_json_schema_to_typescript(structured_content_schema); + if structured_content_type == "unknown" { + "CallToolResult".to_string() + } else { + format!("CallToolResult<{structured_content_type}>") + } + } else { + definition + .output_schema + .as_ref() + .map(render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()) + }; + render_code_mode_sample( &definition.description, &definition.name, input_name, @@ -324,10 +423,59 @@ fn render_code_mode_tool_declaration( format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;") } +fn render_tool_heading(global_name: &str, raw_name: &str) -> String { + if global_name == raw_name { + format!("### `{global_name}`") + } else { + format!("### `{global_name}` (`{raw_name}`)") + } +} + pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String { render_json_schema_to_typescript_inner(schema) } +fn mcp_structured_content_schema(output_schema: Option<&JsonValue>) -> Option<&JsonValue> { + let output_schema = output_schema?; + let properties = output_schema + .get("properties") + .and_then(JsonValue::as_object)?; + let content_schema = properties.get("content").and_then(JsonValue::as_object)?; + if content_schema.get("type").and_then(JsonValue::as_str) != Some("array") { + return None; + } + + if content_schema + .get("items") + .and_then(JsonValue::as_object) + .is_none_or(|items| items.get("type").and_then(JsonValue::as_str) != Some("object")) + { + return None; + } + + if properties + .get("isError") + .and_then(JsonValue::as_object) + .is_none_or(|schema| schema.get("type").and_then(JsonValue::as_str) != Some("boolean")) + { + return None; + } + + if properties + .get("_meta") + .and_then(JsonValue::as_object) + .is_none_or(|schema| schema.get("type").and_then(JsonValue::as_str) != Some("object")) + { + return None; + } + + Some( + properties + .get("structuredContent") + .unwrap_or(&JsonValue::Bool(true)), + ) +} + fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String { match schema { JsonValue::Bool(true) => "unknown".to_string(), @@ -559,9 +707,29 @@ mod tests { use super::normalize_code_mode_identifier; use super::parse_exec_source; use pretty_assertions::assert_eq; + use serde_json::Value as JsonValue; use serde_json::json; use std::collections::BTreeMap; + fn mcp_call_tool_result_schema(structured_content_schema: JsonValue) -> JsonValue { + json!({ + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "structuredContent": structured_content_schema, + "isError": { "type": "boolean" }, + "_meta": { "type": "object" } + }, + "required": ["content"], + "additionalProperties": false + }) + } + #[test] fn parse_exec_source_without_pragma() { assert_eq!( @@ -676,11 +844,20 @@ mod tests { #[test] fn code_mode_only_description_includes_nested_tools() { let description = build_exec_tool_description( - &[("foo".to_string(), "bar".to_string())], + &[ToolDefinition { + name: "foo".to_string(), + description: "bar".to_string(), + kind: CodeModeToolKind::Function, + input_schema: None, + output_schema: None, + }], &BTreeMap::new(), /*code_mode_only*/ true, ); - assert!(description.contains("### `foo` (`foo`)")); + assert!(description.contains( + "### `foo` +bar" + )); } #[test] @@ -711,22 +888,47 @@ mod tests { ]); let description = build_exec_tool_description( &[ - ("mcp__sample__alpha".to_string(), "First tool".to_string()), - ("mcp__sample__beta".to_string(), "Second tool".to_string()), + ToolDefinition { + name: "mcp__sample__alpha".to_string(), + description: "First tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })), + output_schema: Some(mcp_call_tool_result_schema(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }))), + }, + ToolDefinition { + name: "mcp__sample__beta".to_string(), + description: "Second tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })), + output_schema: Some(mcp_call_tool_result_schema(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }))), + }, ], &namespace_descriptions, /*code_mode_only*/ true, ); assert_eq!(description.matches("## mcp__sample").count(), 1); + assert!(description.contains("## mcp__sample\nShared namespace guidance.")); assert!(description.contains( - r#"## mcp__sample -Shared namespace guidance. - -### `mcp__sample__alpha` (`mcp__sample__alpha`) -First tool - -### `mcp__sample__beta` (`mcp__sample__beta`) -Second tool"# + "declare const tools: { mcp__sample__alpha(args: {}): Promise>; };" + )); + assert!(description.contains( + "declare const tools: { mcp__sample__beta(args: {}): Promise>; };" )); } @@ -740,12 +942,125 @@ Second tool"# }, )]); let description = build_exec_tool_description( - &[("mcp__sample__alpha".to_string(), "First tool".to_string())], + &[ToolDefinition { + name: "mcp__sample__alpha".to_string(), + description: "First tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })), + output_schema: Some(mcp_call_tool_result_schema(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }))), + }], &namespace_descriptions, /*code_mode_only*/ true, ); assert!(!description.contains("## mcp__sample")); - assert!(description.contains("### `mcp__sample__alpha` (`mcp__sample__alpha`)")); + assert!(description.contains("### `mcp__sample__alpha`")); + } + + #[test] + fn code_mode_only_description_renders_shared_mcp_types_once() { + let first_tool = augment_tool_definition(ToolDefinition { + name: "mcp__sample__alpha".to_string(), + description: "First tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })), + output_schema: Some(json!({ + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "structuredContent": { + "type": "object", + "properties": { + "echo": { "type": "string" } + }, + "required": ["echo"], + "additionalProperties": false + }, + "isError": { "type": "boolean" }, + "_meta": { "type": "object" } + }, + "required": ["content"], + "additionalProperties": false + })), + }); + let second_tool = augment_tool_definition(ToolDefinition { + name: "mcp__sample__beta".to_string(), + description: "Second tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })), + output_schema: Some(json!({ + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "structuredContent": { + "type": "object", + "properties": { + "count": { "type": "integer" } + }, + "required": ["count"], + "additionalProperties": false + }, + "isError": { "type": "boolean" }, + "_meta": { "type": "object" } + }, + "required": ["content"], + "additionalProperties": false + })), + }); + + let description = build_exec_tool_description( + &[ + ToolDefinition { + name: first_tool.name, + description: "First tool".to_string(), + kind: first_tool.kind, + input_schema: first_tool.input_schema, + output_schema: first_tool.output_schema, + }, + ToolDefinition { + name: second_tool.name, + description: "Second tool".to_string(), + kind: second_tool.kind, + input_schema: second_tool.input_schema, + output_schema: second_tool.output_schema, + }, + ], + &BTreeMap::new(), + /*code_mode_only*/ true, + ); + + assert_eq!( + description + .matches("type CallToolResult") + .count(), + 1 + ); + assert_eq!(description.matches("Shared MCP Types:").count(), 1); } } diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index f7ab0d48e074..880e84ef4a23 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -7,13 +7,13 @@ pub use description::CODE_MODE_PRAGMA_PREFIX; pub use description::CodeModeToolKind; pub use description::ToolDefinition; pub use description::ToolNamespaceDescription; -pub use description::append_code_mode_sample; pub use description::augment_tool_definition; pub use description::build_exec_tool_description; pub use description::build_wait_tool_description; pub use description::is_code_mode_nested_tool; pub use description::normalize_code_mode_identifier; pub use description::parse_exec_source; +pub use description::render_code_mode_sample; pub use description::render_json_schema_to_typescript; pub use response::FunctionCallOutputContentItem; pub use response::ImageDetail; diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9f0dc5635845..54da2f4fe4ed 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2280,7 +2280,14 @@ text(JSON.stringify(tool)); parsed, serde_json::json!({ "name": "mcp__rmcp__echo", - "description": "Echo back the provided message and include environment data.\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__rmcp__echo(args: { env_var?: string; message: string; }): Promise<{ _meta?: unknown; content: Array; isError?: boolean; structuredContent?: unknown; }>; };\n```", + "description": concat!( + "Echo back the provided message and include environment data.\n\n", + "exec tool declaration:\n", + "```ts\n", + "declare const tools: { mcp__rmcp__echo(args: { env_var?: string; message: string; }): ", + "Promise>; };\n", + "```", + ), }) ); diff --git a/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs index 81898b0bc778..e513f12780e2 100644 --- a/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs +++ b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs @@ -45,11 +45,29 @@ impl TestToolServer { })) .expect("echo tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("echo"), Cow::Borrowed("Echo back the provided message and include environment data."), Arc::new(schema), - ) + ); + #[expect(clippy::expect_used)] + let output_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": { + "echo": { "type": "string" }, + "env": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + }, + "required": ["echo", "env"], + "additionalProperties": false + })) + .expect("echo tool output schema should deserialize"); + tool.output_schema = Some(Arc::new(output_schema)); + tool } } diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index d9202babae62..efee52041c89 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -91,6 +91,23 @@ impl TestToolServer { Cow::Borrowed(description), Arc::new(schema), ); + #[expect(clippy::expect_used)] + let output_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": { + "echo": { "type": "string" }, + "env": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + }, + "required": ["echo", "env"], + "additionalProperties": false + })) + .expect("echo tool output schema should deserialize"); + tool.output_schema = Some(Arc::new(output_schema)); tool.annotations = Some(ToolAnnotations::new().read_only(true)); tool } diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index 50ea06c7fa23..93958804986d 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -90,6 +90,23 @@ impl TestToolServer { Cow::Borrowed("Echo back the provided message and include environment data."), Arc::new(schema), ); + #[expect(clippy::expect_used)] + let output_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": { + "echo": { "type": "string" }, + "env": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + }, + "required": ["echo", "env"], + "additionalProperties": false + })) + .expect("echo tool output schema should deserialize"); + tool.output_schema = Some(Arc::new(output_schema)); tool.annotations = Some(ToolAnnotations::new().read_only(true)); tool } diff --git a/codex-rs/tools/src/code_mode.rs b/codex-rs/tools/src/code_mode.rs index c2b82e1da7d8..35d95b271ab4 100644 --- a/codex-rs/tools/src/code_mode.rs +++ b/codex-rs/tools/src/code_mode.rs @@ -49,6 +49,19 @@ pub fn collect_code_mode_tool_definitions<'a>( tool_definitions } +pub fn collect_code_mode_exec_prompt_tool_definitions<'a>( + specs: impl IntoIterator, +) -> Vec { + let mut tool_definitions = specs + .into_iter() + .filter_map(code_mode_tool_definition_for_spec) + .filter(|definition| codex_code_mode::is_code_mode_nested_tool(&definition.name)) + .collect::>(); + tool_definitions.sort_by(|left, right| left.name.cmp(&right.name)); + tool_definitions.dedup_by(|left, right| left.name == right.name); + tool_definitions +} + pub fn create_wait_tool() -> ToolSpec { let properties = BTreeMap::from([ ( @@ -95,7 +108,7 @@ pub fn create_wait_tool() -> ToolSpec { } pub fn create_code_mode_tool( - enabled_tools: &[(String, String)], + enabled_tools: &[CodeModeToolDefinition], namespace_descriptions: &BTreeMap, code_mode_only_enabled: bool, ) -> ToolSpec { diff --git a/codex-rs/tools/src/code_mode_tests.rs b/codex-rs/tools/src/code_mode_tests.rs index a38acacf6e53..ec30c6e8bfae 100644 --- a/codex-rs/tools/src/code_mode_tests.rs +++ b/codex-rs/tools/src/code_mode_tests.rs @@ -182,7 +182,13 @@ fn create_wait_tool_matches_expected_spec() { #[test] fn create_code_mode_tool_matches_expected_spec() { - let enabled_tools = vec![("update_plan".to_string(), "Update the plan".to_string())]; + let enabled_tools = vec![codex_code_mode::ToolDefinition { + name: "update_plan".to_string(), + description: "Update the plan".to_string(), + kind: codex_code_mode::CodeModeToolKind::Function, + input_schema: None, + output_schema: None, + }]; assert_eq!( create_code_mode_tool( diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 07d044521398..9a3107dd666b 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -44,6 +44,7 @@ pub use apply_patch_tool::ApplyPatchToolArgs; pub use apply_patch_tool::create_apply_patch_freeform_tool; pub use apply_patch_tool::create_apply_patch_json_tool; pub use code_mode::augment_tool_spec_for_code_mode; +pub use code_mode::collect_code_mode_exec_prompt_tool_definitions; pub use code_mode::collect_code_mode_tool_definitions; pub use code_mode::create_code_mode_tool; pub use code_mode::create_wait_tool; diff --git a/codex-rs/tools/src/mcp_tool.rs b/codex-rs/tools/src/mcp_tool.rs index 3fd3c5e751a3..337a8e42adbb 100644 --- a/codex-rs/tools/src/mcp_tool.rs +++ b/codex-rs/tools/src/mcp_tool.rs @@ -42,13 +42,17 @@ pub fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) "properties": { "content": { "type": "array", - "items": {} + "items": { + "type": "object" + } }, "structuredContent": structured_content_schema, "isError": { "type": "boolean" }, - "_meta": {} + "_meta": { + "type": "object" + } }, "required": ["content"], "additionalProperties": false diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index 1377324e637d..72be7843ccea 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -13,7 +13,7 @@ use crate::ToolSpec; use crate::ToolsConfig; use crate::ViewImageToolOptions; use crate::WebSearchToolOptions; -use crate::collect_code_mode_tool_definitions; +use crate::collect_code_mode_exec_prompt_tool_definitions; use crate::collect_tool_search_source_infos; use crate::collect_tool_suggest_entries; use crate::create_apply_patch_freeform_tool; @@ -93,17 +93,14 @@ pub fn build_tool_registry_plan( ..params }, ); - let mut enabled_tools = collect_code_mode_tool_definitions( + let mut enabled_tools = collect_code_mode_exec_prompt_tool_definitions( nested_plan .specs .iter() .map(|configured_tool| &configured_tool.spec), - ) - .into_iter() - .map(|tool| (tool.name, tool.description)) - .collect::>(); - enabled_tools.sort_by(|(left_name, _), (right_name, _)| { - compare_code_mode_tool_names(left_name, right_name, &namespace_descriptions) + ); + enabled_tools.sort_by(|left, right| { + compare_code_mode_tool_names(&left.name, &right.name, &namespace_descriptions) }); plan.push_spec( create_code_mode_tool( diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 13ef18495e5f..3454a2299664 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -1608,7 +1608,7 @@ fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() { exec tool declaration: ```ts -declare const tools: { mcp__sample__echo(args: { message: string; }): Promise<{ _meta?: unknown; content: Array; isError?: boolean; structuredContent?: unknown; }>; }; +declare const tools: { mcp__sample__echo(args: { message: string; }): Promise; }; ```"# ); } @@ -1694,7 +1694,7 @@ fn code_mode_preserves_nullable_and_literal_mcp_input_shapes() { assert!(description.contains( r#"exec tool declaration: ```ts -declare const tools: { mcp__sample__fn(args: { open?: Array<{ lineno?: number | null; ref_id: string; }> | null; response_length?: "short" | "medium" | "long"; tagged_list?: Array<{ kind: "tagged"; scope: "one" | "two"; variant: "alpha" | "beta"; }> | null; }): Promise<{ _meta?: unknown; content: Array; isError?: boolean; structuredContent?: unknown; }>; }; +declare const tools: { mcp__sample__fn(args: { open?: Array<{ lineno?: number | null; ref_id: string; }> | null; response_length?: "short" | "medium" | "long"; tagged_list?: Array<{ kind: "tagged"; scope: "one" | "two"; variant: "alpha" | "beta"; }> | null; }): Promise; }; ```"# )); } @@ -1769,8 +1769,8 @@ fn code_mode_only_exec_description_includes_full_nested_tool_details() { assert!(description.starts_with( "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly" )); - assert!(description.contains("### `update_plan` (`update_plan`)")); - assert!(description.contains("### `view_image` (`view_image`)")); + assert!(description.contains("### `update_plan`")); + assert!(description.contains("### `view_image`")); } #[test] @@ -1804,8 +1804,8 @@ fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only( assert!(!description.starts_with( "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly" )); - assert!(!description.contains("### `update_plan` (`update_plan`)")); - assert!(!description.contains("### `view_image` (`view_image`)")); + assert!(!description.contains("### `update_plan`")); + assert!(!description.contains("### `view_image`")); } fn model_info() -> ModelInfo { @@ -1919,6 +1919,78 @@ fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> r } } +#[test] +fn code_mode_augments_mcp_tool_descriptions_with_structured_output_sample() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + features.enable(Feature::CodeModeOnly); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + + let mut tool = mcp_tool( + "echo", + "Echo text", + serde_json::json!({ + "type": "object", + "properties": { + "message": {"type": "string"} + }, + "required": ["message"], + "additionalProperties": false + }), + ); + tool.output_schema = Some(std::sync::Arc::new(rmcp::model::object( + serde_json::json!({ + "type": "object", + "properties": { + "echo": {"type": "string"}, + "env": { + "anyOf": [ + {"type": "string"}, + {"type": "null"} + ] + } + }, + "required": ["echo", "env"], + "additionalProperties": false + }), + ))); + + let (tools, _) = build_specs( + &tools_config, + Some(HashMap::from([("mcp__sample__echo".to_string(), tool)])), + /*deferred_mcp_tools*/ None, + &[], + ); + + let ToolSpec::Function(ResponsesApiTool { description, .. }) = + &find_tool(&tools, "mcp__sample__echo").spec + else { + panic!("expected function tool"); + }; + + assert_eq!( + description, + r#"Echo text + +exec tool declaration: +```ts +declare const tools: { mcp__sample__echo(args: { message: string; }): Promise>; }; +```"# + ); +} + fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool { let slug = name.replace(' ', "-").to_lowercase(); DiscoverableTool::Connector(Box::new(AppInfo {