Add output schema to MCP tools and expose MCP tool results in code mode (#14236)

Summary
- drop `McpToolOutput` in favor of `CallToolResult`, moving its helpers
to keep MCP tooling focused on the final result shape
- wire the new schema definitions through code mode, context, handlers,
and spec modules so MCP tools serialize the exact output shape expected
by the model
- extend code mode tests to cover multiple MCP call scenarios and ensure
the serialized data matches the new schema
- refresh JS runner helpers and protocol models alongside the schema
changes

Testing
- Not run (not requested)
This commit is contained in:
pakrym-oai
2026-03-10 15:25:19 -07:00
committed by Michael Bolin
Unverified
parent d5694529ca
commit ee8f84153e
12 changed files with 659 additions and 137 deletions
+96 -38
View File
@@ -42,6 +42,8 @@ enum CodeModeToolKind {
#[derive(Clone, Debug, Serialize)]
struct EnabledTool {
tool_name: String,
namespace: Vec<String>,
name: String,
kind: CodeModeToolKind,
}
@@ -85,7 +87,7 @@ pub(crate) fn instructions(config: &Config) -> Option<String> {
section.push_str("- `code_mode` is a freeform/custom tool. Direct `code_mode` calls must send raw JavaScript tool input. Do not wrap code in JSON, quotes, or markdown code fences.\n");
section.push_str("- Direct tool calls remain available while `code_mode` is enabled.\n");
section.push_str("- `code_mode` uses the same Node runtime resolution as `js_repl`. If needed, point `js_repl_node_path` at the Node binary you want Codex to use.\n");
section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { tools } from \"tools.js\"`. `tools[name]` and identifier wrappers like `await exec_command(args)` remain available for compatibility. Nested tool calls resolve to their code-mode result values.\n");
section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { tools } from \"tools.js\"`. Namespaced tools are also available from `tools/<namespace...>.js`; MCP tools use `tools/mcp/<server>.js`, for example `import { append_notebook_logs_chart } from \"tools/mcp/ologs.js\"`. `tools[name]` and identifier wrappers like `await exec_command(args)` remain available for compatibility. Nested tool calls resolve to their code-mode result values.\n");
section.push_str(
"- Function tools require JSON object arguments. Freeform tools require raw strings.\n",
);
@@ -106,7 +108,7 @@ pub(crate) async fn execute(
turn,
tracker,
};
let enabled_tools = build_enabled_tools(&exec);
let enabled_tools = build_enabled_tools(&exec).await;
let source = build_source(&code, &enabled_tools).map_err(FunctionCallError::RespondToModel)?;
execute_node(exec, source, enabled_tools)
.await
@@ -259,26 +261,72 @@ fn build_source(user_code: &str, enabled_tools: &[EnabledTool]) -> Result<String
.replace("__CODE_MODE_USER_CODE_PLACEHOLDER__", user_code))
}
fn build_enabled_tools(exec: &ExecContext) -> Vec<EnabledTool> {
async fn build_enabled_tools(exec: &ExecContext) -> Vec<EnabledTool> {
let router = build_nested_router(exec).await;
let mcp_tool_names = exec
.session
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.await
.into_iter()
.map(|(qualified_name, tool_info)| {
(
qualified_name,
(
vec!["mcp".to_string(), tool_info.server_name],
tool_info.tool_name,
),
)
})
.collect::<std::collections::HashMap<_, _>>();
let mut out = Vec::new();
for spec in router.specs() {
let tool_name = spec.name().to_string();
if tool_name == "code_mode" {
continue;
}
let (namespace, name) = if let Some((namespace, name)) = mcp_tool_names.get(&tool_name) {
(namespace.clone(), name.clone())
} else {
(Vec::new(), tool_name.clone())
};
out.push(EnabledTool {
tool_name,
namespace,
name,
kind: tool_kind_for_spec(&spec),
});
}
out.sort_by(|left, right| left.tool_name.cmp(&right.tool_name));
out.dedup_by(|left, right| left.tool_name == right.tool_name);
out
}
async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
let nested_tools_config = exec.turn.tools_config.for_code_mode_nested_tools();
let router = ToolRouter::from_config(
let mcp_tools = exec
.session
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.await
.into_iter()
.map(|(name, tool_info)| (name, tool_info.tool))
.collect();
ToolRouter::from_config(
&nested_tools_config,
None,
Some(mcp_tools),
None,
exec.turn.dynamic_tools.as_slice(),
);
let mut out = router
.specs()
.into_iter()
.map(|spec| EnabledTool {
name: spec.name().to_string(),
kind: tool_kind_for_spec(&spec),
})
.filter(|tool| tool.name != "code_mode")
.collect::<Vec<_>>();
out.sort_by(|left, right| left.name.cmp(&right.name));
out.dedup_by(|left, right| left.name == right.name);
out
)
}
async fn call_nested_tool(
@@ -290,18 +338,23 @@ async fn call_nested_tool(
return JsonValue::String("code_mode cannot invoke itself".to_string());
}
let nested_config = exec.turn.tools_config.for_code_mode_nested_tools();
let router = ToolRouter::from_config(
&nested_config,
None,
None,
exec.turn.dynamic_tools.as_slice(),
);
let router = build_nested_router(&exec).await;
let specs = router.specs();
let payload = match build_nested_tool_payload(&specs, &tool_name, input) {
Ok(payload) => payload,
Err(error) => return JsonValue::String(error),
let payload = if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name).await {
match serialize_function_tool_arguments(&tool_name, input) {
Ok(raw_arguments) => ToolPayload::Mcp {
server,
tool,
raw_arguments,
},
Err(error) => return JsonValue::String(error),
}
} else {
match build_nested_tool_payload(&specs, &tool_name, input) {
Ok(payload) => payload,
Err(error) => return JsonValue::String(error),
}
};
let call = ToolCall {
@@ -357,19 +410,24 @@ fn build_function_tool_payload(
tool_name: &str,
input: Option<JsonValue>,
) -> Result<ToolPayload, String> {
let arguments = match input {
None => "{}".to_string(),
Some(JsonValue::Object(map)) => serde_json::to_string(&JsonValue::Object(map))
.map_err(|err| format!("failed to serialize tool `{tool_name}` arguments: {err}"))?,
Some(_) => {
return Err(format!(
"tool `{tool_name}` expects a JSON object for arguments"
));
}
};
let arguments = serialize_function_tool_arguments(tool_name, input)?;
Ok(ToolPayload::Function { arguments })
}
fn serialize_function_tool_arguments(
tool_name: &str,
input: Option<JsonValue>,
) -> Result<String, String> {
match input {
None => Ok("{}".to_string()),
Some(JsonValue::Object(map)) => serde_json::to_string(&JsonValue::Object(map))
.map_err(|err| format!("failed to serialize tool `{tool_name}` arguments: {err}")),
Some(_) => Err(format!(
"tool `{tool_name}` expects a JSON object for arguments"
)),
}
}
fn build_freeform_tool_payload(
tool_name: &str,
input: Option<JsonValue>,
+1 -1
View File
@@ -1,5 +1,5 @@
const __codexEnabledTools = __CODE_MODE_ENABLED_TOOLS_PLACEHOLDER__;
const __codexEnabledToolNames = __codexEnabledTools.map((tool) => tool.name);
const __codexEnabledToolNames = __codexEnabledTools.map((tool) => tool.tool_name);
const __codexContentItems = [];
function __codexCloneContentItem(item) {
+99 -15
View File
@@ -103,13 +103,13 @@ function isValidIdentifier(name) {
function createToolsNamespace(protocol, enabledTools) {
const tools = Object.create(null);
for (const { name } of enabledTools) {
for (const { tool_name } of enabledTools) {
const callTool = async (args) =>
protocol.request('tool_call', {
name: String(name),
name: String(tool_name),
input: args,
});
Object.defineProperty(tools, name, {
Object.defineProperty(tools, tool_name, {
value: callTool,
configurable: false,
enumerable: true,
@@ -124,9 +124,9 @@ function createToolsModule(context, protocol, enabledTools) {
const tools = createToolsNamespace(protocol, enabledTools);
const exportNames = ['tools'];
for (const { name } of enabledTools) {
if (name !== 'tools' && isValidIdentifier(name)) {
exportNames.push(name);
for (const { tool_name } of enabledTools) {
if (tool_name !== 'tools' && isValidIdentifier(tool_name)) {
exportNames.push(tool_name);
}
}
@@ -146,24 +146,108 @@ function createToolsModule(context, protocol, enabledTools) {
);
}
function namespacesMatch(left, right) {
if (left.length !== right.length) {
return false;
}
return left.every((segment, index) => segment === right[index]);
}
function createNamespacedToolsNamespace(protocol, enabledTools, namespace) {
const tools = Object.create(null);
for (const tool of enabledTools) {
const toolNamespace = Array.isArray(tool.namespace) ? tool.namespace : [];
if (!namespacesMatch(toolNamespace, namespace)) {
continue;
}
const callTool = async (args) =>
protocol.request('tool_call', {
name: String(tool.tool_name),
input: args,
});
Object.defineProperty(tools, tool.name, {
value: callTool,
configurable: false,
enumerable: true,
writable: false,
});
}
return Object.freeze(tools);
}
function createNamespacedToolsModule(context, protocol, enabledTools, namespace) {
const tools = createNamespacedToolsNamespace(protocol, enabledTools, namespace);
const exportNames = ['tools'];
for (const exportName of Object.keys(tools)) {
if (exportName !== 'tools' && isValidIdentifier(exportName)) {
exportNames.push(exportName);
}
}
const uniqueExportNames = [...new Set(exportNames)];
return new SyntheticModule(
uniqueExportNames,
function initNamespacedToolsModule() {
this.setExport('tools', tools);
for (const exportName of uniqueExportNames) {
if (exportName !== 'tools') {
this.setExport(exportName, tools[exportName]);
}
}
},
{ context }
);
}
function createModuleResolver(context, protocol, enabledTools) {
const toolsModule = createToolsModule(context, protocol, enabledTools);
const namespacedModules = new Map();
return function resolveModule(specifier) {
if (specifier === 'tools.js') {
return toolsModule;
}
const namespacedMatch = /^tools\/(.+)\.js$/.exec(specifier);
if (!namespacedMatch) {
throw new Error(`Unsupported import in code_mode: ${specifier}`);
}
const namespace = namespacedMatch[1]
.split('/')
.filter((segment) => segment.length > 0);
if (namespace.length === 0) {
throw new Error(`Unsupported import in code_mode: ${specifier}`);
}
const cacheKey = namespace.join('/');
if (!namespacedModules.has(cacheKey)) {
namespacedModules.set(
cacheKey,
createNamespacedToolsModule(context, protocol, enabledTools, namespace)
);
}
return namespacedModules.get(cacheKey);
};
}
async function runModule(context, protocol, request) {
const toolsModule = createToolsModule(context, protocol, request.enabled_tools ?? []);
const resolveModule = createModuleResolver(context, protocol, request.enabled_tools ?? []);
const mainModule = new SourceTextModule(request.source, {
context,
identifier: 'code_mode_main.mjs',
importModuleDynamically(specifier) {
if (specifier === 'tools.js') {
return toolsModule;
}
throw new Error(`Unsupported import in code_mode: ${specifier}`);
return resolveModule(specifier);
},
});
await mainModule.link(async (specifier) => {
if (specifier === 'tools.js') {
return toolsModule;
}
throw new Error(`Unsupported import in code_mode: ${specifier}`);
return resolveModule(specifier);
});
await mainModule.evaluate();
}
+56 -9
View File
@@ -7,10 +7,10 @@ use crate::truncate::TruncationPolicy;
use crate::truncate::formatted_truncate_text;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::unified_exec::resolve_max_tokens;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::McpToolOutput;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ShellToolCallParams;
use codex_protocol::models::function_call_output_content_items_to_text;
@@ -82,7 +82,7 @@ pub trait ToolOutput: Send {
}
}
impl ToolOutput for McpToolOutput {
impl ToolOutput for CallToolResult {
fn log_preview(&self) -> String {
let output = self.as_function_call_output_payload();
let preview = output.body.to_text().unwrap_or_else(|| output.to_string());
@@ -90,7 +90,7 @@ impl ToolOutput for McpToolOutput {
}
fn success_for_logging(&self) -> bool {
self.success
self.success()
}
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
@@ -99,6 +99,12 @@ impl ToolOutput for McpToolOutput {
output: self.clone(),
}
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
serde_json::to_value(self).unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize mcp result: {err}"))
})
}
}
pub struct FunctionToolOutput {
@@ -272,12 +278,11 @@ fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue
}
},
ResponseInputItem::McpToolCallOutput { output, .. } => {
match output.as_function_call_output_payload().body {
FunctionCallOutputBody::Text(text) => JsonValue::String(text),
FunctionCallOutputBody::ContentItems(items) => {
content_items_to_code_mode_result(&items)
}
}
output.code_mode_result(&ToolPayload::Mcp {
server: String::new(),
tool: String::new(),
raw_arguments: String::new(),
})
}
}
}
@@ -413,6 +418,48 @@ mod tests {
}
}
#[test]
fn mcp_code_mode_result_serializes_full_call_tool_result() {
let output = CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "ignored",
})],
structured_content: Some(serde_json::json!({
"threadId": "thread_123",
"content": "done",
})),
is_error: Some(false),
meta: Some(serde_json::json!({
"source": "mcp",
})),
};
let result = output.code_mode_result(&ToolPayload::Mcp {
server: "server".to_string(),
tool: "tool".to_string(),
raw_arguments: "{}".to_string(),
});
assert_eq!(
result,
serde_json::json!({
"content": [{
"type": "text",
"text": "ignored",
}],
"structuredContent": {
"threadId": "thread_123",
"content": "done",
},
"isError": false,
"_meta": {
"source": "mcp",
},
})
);
}
#[test]
fn custom_tool_calls_can_derive_text_from_content_items() {
let payload = ToolPayload::Custom {
+2 -2
View File
@@ -7,12 +7,12 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::models::McpToolOutput;
use codex_protocol::mcp::CallToolResult;
pub struct McpHandler;
#[async_trait]
impl ToolHandler for McpHandler {
type Output = McpToolOutput;
type Output = CallToolResult;
fn kind(&self) -> ToolKind {
ToolKind::Mcp
+2 -2
View File
@@ -622,7 +622,7 @@ impl JsReplManager {
}
ResponseInputItem::McpToolCallOutput { output, .. } => {
let function_output = output.as_function_call_output_payload();
let payload_kind = if output.success {
let payload_kind = if output.success() {
JsReplToolCallPayloadKind::McpResult
} else {
JsReplToolCallPayloadKind::McpErrorResult
@@ -634,7 +634,7 @@ impl JsReplManager {
);
summary.payload_item_count = Some(output.content.len());
summary.structured_content_present = Some(output.structured_content.is_some());
summary.result_is_error = Some(!output.success);
summary.result_is_error = Some(!output.success());
summary
}
}
+3 -3
View File
@@ -124,9 +124,9 @@ impl ToolCallRuntime {
},
ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput {
call_id: call.call_id.clone(),
output: codex_protocol::models::McpToolOutput::from_error_text(
Self::abort_message(call, secs),
),
output: codex_protocol::mcp::CallToolResult::from_error_text(Self::abort_message(
call, secs,
)),
},
_ => ResponseInputItem::FunctionCallOutput {
call_id: call.call_id.clone(),
+143 -7
View File
@@ -1771,6 +1771,7 @@ pub(crate) fn mcp_tool_to_openai_tool(
let rmcp::model::Tool {
description,
input_schema,
output_schema,
..
} = tool;
@@ -1795,13 +1796,19 @@ pub(crate) fn mcp_tool_to_openai_tool(
// `type`, so we coerce/sanitize here for compatibility.
sanitize_json_schema(&mut serialized_input_schema);
let input_schema = serde_json::from_value::<JsonSchema>(serialized_input_schema)?;
let structured_content_schema = output_schema
.map(|output_schema| serde_json::Value::Object(output_schema.as_ref().clone()))
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()));
let output_schema = Some(mcp_call_tool_result_output_schema(
structured_content_schema,
));
Ok(ResponsesApiTool {
name: fully_qualified_name,
description: description.map(Into::into).unwrap_or_default(),
strict: false,
parameters: input_schema,
output_schema: None,
output_schema,
})
}
@@ -1826,6 +1833,25 @@ pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, s
serde_json::from_value::<JsonSchema>(input_schema)
}
fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> JsonValue {
json!({
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {}
},
"structuredContent": structured_content_schema,
"isError": {
"type": "boolean"
},
"_meta": {}
},
"required": ["content"],
"additionalProperties": false
})
}
/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
/// JsonSchema enum. This function:
/// - Ensures every schema object has a "type". If missing, infers it from
@@ -2299,6 +2325,116 @@ mod tests {
assert_eq!(parameters.get("properties"), Some(&serde_json::json!({})));
}
#[test]
fn mcp_tool_to_openai_tool_preserves_top_level_output_schema() {
let mut input_schema = rmcp::model::JsonObject::new();
input_schema.insert("type".to_string(), serde_json::json!("object"));
let mut output_schema = rmcp::model::JsonObject::new();
output_schema.insert(
"properties".to_string(),
serde_json::json!({
"result": {
"properties": {
"nested": {}
}
}
}),
);
output_schema.insert("required".to_string(), serde_json::json!(["result"]));
let tool = rmcp::model::Tool {
name: "with_output".to_string().into(),
title: None,
description: Some("Has output schema".to_string().into()),
input_schema: std::sync::Arc::new(input_schema),
output_schema: Some(std::sync::Arc::new(output_schema)),
annotations: None,
execution: None,
icons: None,
meta: None,
};
let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_output".to_string(), tool)
.expect("convert tool");
assert_eq!(
openai_tool.output_schema,
Some(serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {}
},
"structuredContent": {
"properties": {
"result": {
"properties": {
"nested": {}
}
}
},
"required": ["result"]
},
"isError": {
"type": "boolean"
},
"_meta": {}
},
"required": ["content"],
"additionalProperties": false
}))
);
}
#[test]
fn mcp_tool_to_openai_tool_preserves_output_schema_without_inferred_type() {
let mut input_schema = rmcp::model::JsonObject::new();
input_schema.insert("type".to_string(), serde_json::json!("object"));
let mut output_schema = rmcp::model::JsonObject::new();
output_schema.insert("enum".to_string(), serde_json::json!(["ok", "error"]));
let tool = rmcp::model::Tool {
name: "with_enum_output".to_string().into(),
title: None,
description: Some("Has enum output schema".to_string().into()),
input_schema: std::sync::Arc::new(input_schema),
output_schema: Some(std::sync::Arc::new(output_schema)),
annotations: None,
execution: None,
icons: None,
meta: None,
};
let openai_tool =
mcp_tool_to_openai_tool("mcp__server__with_enum_output".to_string(), tool)
.expect("convert tool");
assert_eq!(
openai_tool.output_schema,
Some(serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {}
},
"structuredContent": {
"enum": ["ok", "error"]
},
"isError": {
"type": "boolean"
},
"_meta": {}
},
"required": ["content"],
"additionalProperties": false
}))
);
}
fn tool_name(tool: &ToolSpec) -> &str {
match tool {
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
@@ -3355,7 +3491,7 @@ mod tests {
},
description: "Do something cool".to_string(),
strict: false,
output_schema: None,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
})
);
}
@@ -3594,7 +3730,7 @@ mod tests {
},
description: "Search docs".to_string(),
strict: false,
output_schema: None,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
})
);
}
@@ -3646,7 +3782,7 @@ mod tests {
},
description: "Pagination".to_string(),
strict: false,
output_schema: None,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
})
);
}
@@ -3702,7 +3838,7 @@ mod tests {
},
description: "Tags".to_string(),
strict: false,
output_schema: None,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
})
);
}
@@ -3756,7 +3892,7 @@ mod tests {
},
description: "AnyOf Value".to_string(),
strict: false,
output_schema: None,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
})
);
}
@@ -4015,7 +4151,7 @@ Examples of valid command strings:
},
description: "Do something cool".to_string(),
strict: false,
output_schema: None,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
})
);
}