mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
d5694529ca
commit
ee8f84153e
12 files changed
+659
-137
No files matched your search
@@ -58,7 +58,6 @@ use codex_app_server_protocol::AppInfo;
|
|||||||
use codex_otel::TelemetryAuthMode;
|
use codex_otel::TelemetryAuthMode;
|
||||||
use codex_protocol::models::BaseInstructions;
|
use codex_protocol::models::BaseInstructions;
|
||||||
use codex_protocol::models::ContentItem;
|
use codex_protocol::models::ContentItem;
|
||||||
use codex_protocol::models::McpToolOutput;
|
|
||||||
use codex_protocol::models::ResponseInputItem;
|
use codex_protocol::models::ResponseInputItem;
|
||||||
use codex_protocol::models::ResponseItem;
|
use codex_protocol::models::ResponseItem;
|
||||||
use codex_protocol::openai_models::ModelsResponse;
|
use codex_protocol::openai_models::ModelsResponse;
|
||||||
@@ -1628,7 +1627,7 @@ fn prefers_structured_content_when_present() {
|
|||||||
meta: None,
|
meta: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let got = McpToolOutput::from(&ctr).into_function_call_output_payload();
|
let got = ctr.into_function_call_output_payload();
|
||||||
let expected = FunctionCallOutputPayload {
|
let expected = FunctionCallOutputPayload {
|
||||||
body: FunctionCallOutputBody::Text(
|
body: FunctionCallOutputBody::Text(
|
||||||
serde_json::to_string(&json!({
|
serde_json::to_string(&json!({
|
||||||
@@ -1710,7 +1709,7 @@ fn falls_back_to_content_when_structured_is_null() {
|
|||||||
meta: None,
|
meta: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let got = McpToolOutput::from(&ctr).into_function_call_output_payload();
|
let got = ctr.into_function_call_output_payload();
|
||||||
let expected = FunctionCallOutputPayload {
|
let expected = FunctionCallOutputPayload {
|
||||||
body: FunctionCallOutputBody::Text(
|
body: FunctionCallOutputBody::Text(
|
||||||
serde_json::to_string(&vec![text_block("hello"), text_block("world")]).unwrap(),
|
serde_json::to_string(&vec![text_block("hello"), text_block("world")]).unwrap(),
|
||||||
@@ -1730,7 +1729,7 @@ fn success_flag_reflects_is_error_true() {
|
|||||||
meta: None,
|
meta: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let got = McpToolOutput::from(&ctr).into_function_call_output_payload();
|
let got = ctr.into_function_call_output_payload();
|
||||||
let expected = FunctionCallOutputPayload {
|
let expected = FunctionCallOutputPayload {
|
||||||
body: FunctionCallOutputBody::Text(
|
body: FunctionCallOutputBody::Text(
|
||||||
serde_json::to_string(&json!({ "message": "bad" })).unwrap(),
|
serde_json::to_string(&json!({ "message": "bad" })).unwrap(),
|
||||||
@@ -1750,7 +1749,7 @@ fn success_flag_true_with_no_error_and_content_used() {
|
|||||||
meta: None,
|
meta: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let got = McpToolOutput::from(&ctr).into_function_call_output_payload();
|
let got = ctr.into_function_call_output_payload();
|
||||||
let expected = FunctionCallOutputPayload {
|
let expected = FunctionCallOutputPayload {
|
||||||
body: FunctionCallOutputBody::Text(
|
body: FunctionCallOutputBody::Text(
|
||||||
serde_json::to_string(&vec![text_block("alpha")]).unwrap(),
|
serde_json::to_string(&vec![text_block("alpha")]).unwrap(),
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ use crate::protocol::McpToolCallBeginEvent;
|
|||||||
use crate::protocol::McpToolCallEndEvent;
|
use crate::protocol::McpToolCallEndEvent;
|
||||||
use crate::state_db;
|
use crate::state_db;
|
||||||
use codex_protocol::mcp::CallToolResult;
|
use codex_protocol::mcp::CallToolResult;
|
||||||
use codex_protocol::models::McpToolOutput;
|
|
||||||
use codex_protocol::openai_models::InputModality;
|
use codex_protocol::openai_models::InputModality;
|
||||||
use codex_protocol::protocol::AskForApproval;
|
use codex_protocol::protocol::AskForApproval;
|
||||||
use codex_protocol::protocol::ReviewDecision;
|
use codex_protocol::protocol::ReviewDecision;
|
||||||
@@ -59,7 +58,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
|||||||
server: String,
|
server: String,
|
||||||
tool_name: String,
|
tool_name: String,
|
||||||
arguments: String,
|
arguments: String,
|
||||||
) -> McpToolOutput {
|
) -> CallToolResult {
|
||||||
// Parse the `arguments` as JSON. An empty string is OK, but invalid JSON
|
// Parse the `arguments` as JSON. An empty string is OK, but invalid JSON
|
||||||
// is not.
|
// is not.
|
||||||
let arguments_value = if arguments.trim().is_empty() {
|
let arguments_value = if arguments.trim().is_empty() {
|
||||||
@@ -69,7 +68,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
|||||||
Ok(value) => Some(value),
|
Ok(value) => Some(value),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("failed to parse tool call arguments: {e}");
|
error!("failed to parse tool call arguments: {e}");
|
||||||
return McpToolOutput::from_error_text(format!("err: {e}"));
|
return CallToolResult::from_error_text(format!("err: {e}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -113,7 +112,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
|||||||
turn_context
|
turn_context
|
||||||
.session_telemetry
|
.session_telemetry
|
||||||
.counter("codex.mcp.call", 1, &[("status", status)]);
|
.counter("codex.mcp.call", 1, &[("status", status)]);
|
||||||
return McpToolOutput::from_result(result);
|
return CallToolResult::from_result(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(decision) = maybe_request_mcp_tool_approval(
|
if let Some(decision) = maybe_request_mcp_tool_approval(
|
||||||
@@ -217,7 +216,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
|||||||
.session_telemetry
|
.session_telemetry
|
||||||
.counter("codex.mcp.call", 1, &[("status", status)]);
|
.counter("codex.mcp.call", 1, &[("status", status)]);
|
||||||
|
|
||||||
return McpToolOutput::from_result(result);
|
return CallToolResult::from_result(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
|
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
|
||||||
@@ -263,7 +262,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
|||||||
.session_telemetry
|
.session_telemetry
|
||||||
.counter("codex.mcp.call", 1, &[("status", status)]);
|
.counter("codex.mcp.call", 1, &[("status", status)]);
|
||||||
|
|
||||||
McpToolOutput::from_result(result)
|
CallToolResult::from_result(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn maybe_mark_thread_memory_mode_polluted(sess: &Session, turn_context: &TurnContext) {
|
async fn maybe_mark_thread_memory_mode_polluted(sess: &Session, turn_context: &TurnContext) {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ enum CodeModeToolKind {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
struct EnabledTool {
|
struct EnabledTool {
|
||||||
|
tool_name: String,
|
||||||
|
namespace: Vec<String>,
|
||||||
name: String,
|
name: String,
|
||||||
kind: CodeModeToolKind,
|
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("- `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("- 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("- `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(
|
section.push_str(
|
||||||
"- Function tools require JSON object arguments. Freeform tools require raw strings.\n",
|
"- Function tools require JSON object arguments. Freeform tools require raw strings.\n",
|
||||||
);
|
);
|
||||||
@@ -106,7 +108,7 @@ pub(crate) async fn execute(
|
|||||||
turn,
|
turn,
|
||||||
tracker,
|
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)?;
|
let source = build_source(&code, &enabled_tools).map_err(FunctionCallError::RespondToModel)?;
|
||||||
execute_node(exec, source, enabled_tools)
|
execute_node(exec, source, enabled_tools)
|
||||||
.await
|
.await
|
||||||
@@ -259,26 +261,72 @@ fn build_source(user_code: &str, enabled_tools: &[EnabledTool]) -> Result<String
|
|||||||
.replace("__CODE_MODE_USER_CODE_PLACEHOLDER__", user_code))
|
.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 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,
|
&nested_tools_config,
|
||||||
None,
|
Some(mcp_tools),
|
||||||
None,
|
None,
|
||||||
exec.turn.dynamic_tools.as_slice(),
|
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(
|
async fn call_nested_tool(
|
||||||
@@ -290,18 +338,23 @@ async fn call_nested_tool(
|
|||||||
return JsonValue::String("code_mode cannot invoke itself".to_string());
|
return JsonValue::String("code_mode cannot invoke itself".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let nested_config = exec.turn.tools_config.for_code_mode_nested_tools();
|
let router = build_nested_router(&exec).await;
|
||||||
let router = ToolRouter::from_config(
|
|
||||||
&nested_config,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
exec.turn.dynamic_tools.as_slice(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let specs = router.specs();
|
let specs = router.specs();
|
||||||
let payload = match build_nested_tool_payload(&specs, &tool_name, input) {
|
let payload = if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name).await {
|
||||||
Ok(payload) => payload,
|
match serialize_function_tool_arguments(&tool_name, input) {
|
||||||
Err(error) => return JsonValue::String(error),
|
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 {
|
let call = ToolCall {
|
||||||
@@ -357,19 +410,24 @@ fn build_function_tool_payload(
|
|||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
input: Option<JsonValue>,
|
input: Option<JsonValue>,
|
||||||
) -> Result<ToolPayload, String> {
|
) -> Result<ToolPayload, String> {
|
||||||
let arguments = match input {
|
let arguments = serialize_function_tool_arguments(tool_name, 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"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(ToolPayload::Function { arguments })
|
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(
|
fn build_freeform_tool_payload(
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
input: Option<JsonValue>,
|
input: Option<JsonValue>,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const __codexEnabledTools = __CODE_MODE_ENABLED_TOOLS_PLACEHOLDER__;
|
const __codexEnabledTools = __CODE_MODE_ENABLED_TOOLS_PLACEHOLDER__;
|
||||||
const __codexEnabledToolNames = __codexEnabledTools.map((tool) => tool.name);
|
const __codexEnabledToolNames = __codexEnabledTools.map((tool) => tool.tool_name);
|
||||||
const __codexContentItems = [];
|
const __codexContentItems = [];
|
||||||
|
|
||||||
function __codexCloneContentItem(item) {
|
function __codexCloneContentItem(item) {
|
||||||
|
|||||||
@@ -103,13 +103,13 @@ function isValidIdentifier(name) {
|
|||||||
function createToolsNamespace(protocol, enabledTools) {
|
function createToolsNamespace(protocol, enabledTools) {
|
||||||
const tools = Object.create(null);
|
const tools = Object.create(null);
|
||||||
|
|
||||||
for (const { name } of enabledTools) {
|
for (const { tool_name } of enabledTools) {
|
||||||
const callTool = async (args) =>
|
const callTool = async (args) =>
|
||||||
protocol.request('tool_call', {
|
protocol.request('tool_call', {
|
||||||
name: String(name),
|
name: String(tool_name),
|
||||||
input: args,
|
input: args,
|
||||||
});
|
});
|
||||||
Object.defineProperty(tools, name, {
|
Object.defineProperty(tools, tool_name, {
|
||||||
value: callTool,
|
value: callTool,
|
||||||
configurable: false,
|
configurable: false,
|
||||||
enumerable: true,
|
enumerable: true,
|
||||||
@@ -124,9 +124,9 @@ function createToolsModule(context, protocol, enabledTools) {
|
|||||||
const tools = createToolsNamespace(protocol, enabledTools);
|
const tools = createToolsNamespace(protocol, enabledTools);
|
||||||
const exportNames = ['tools'];
|
const exportNames = ['tools'];
|
||||||
|
|
||||||
for (const { name } of enabledTools) {
|
for (const { tool_name } of enabledTools) {
|
||||||
if (name !== 'tools' && isValidIdentifier(name)) {
|
if (tool_name !== 'tools' && isValidIdentifier(tool_name)) {
|
||||||
exportNames.push(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) {
|
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, {
|
const mainModule = new SourceTextModule(request.source, {
|
||||||
context,
|
context,
|
||||||
identifier: 'code_mode_main.mjs',
|
identifier: 'code_mode_main.mjs',
|
||||||
importModuleDynamically(specifier) {
|
importModuleDynamically(specifier) {
|
||||||
if (specifier === 'tools.js') {
|
return resolveModule(specifier);
|
||||||
return toolsModule;
|
|
||||||
}
|
|
||||||
throw new Error(`Unsupported import in code_mode: ${specifier}`);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await mainModule.link(async (specifier) => {
|
await mainModule.link(async (specifier) => {
|
||||||
if (specifier === 'tools.js') {
|
return resolveModule(specifier);
|
||||||
return toolsModule;
|
|
||||||
}
|
|
||||||
throw new Error(`Unsupported import in code_mode: ${specifier}`);
|
|
||||||
});
|
});
|
||||||
await mainModule.evaluate();
|
await mainModule.evaluate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ use crate::truncate::TruncationPolicy;
|
|||||||
use crate::truncate::formatted_truncate_text;
|
use crate::truncate::formatted_truncate_text;
|
||||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||||
use crate::unified_exec::resolve_max_tokens;
|
use crate::unified_exec::resolve_max_tokens;
|
||||||
|
use codex_protocol::mcp::CallToolResult;
|
||||||
use codex_protocol::models::FunctionCallOutputBody;
|
use codex_protocol::models::FunctionCallOutputBody;
|
||||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||||
use codex_protocol::models::FunctionCallOutputPayload;
|
use codex_protocol::models::FunctionCallOutputPayload;
|
||||||
use codex_protocol::models::McpToolOutput;
|
|
||||||
use codex_protocol::models::ResponseInputItem;
|
use codex_protocol::models::ResponseInputItem;
|
||||||
use codex_protocol::models::ShellToolCallParams;
|
use codex_protocol::models::ShellToolCallParams;
|
||||||
use codex_protocol::models::function_call_output_content_items_to_text;
|
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 {
|
fn log_preview(&self) -> String {
|
||||||
let output = self.as_function_call_output_payload();
|
let output = self.as_function_call_output_payload();
|
||||||
let preview = output.body.to_text().unwrap_or_else(|| output.to_string());
|
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 {
|
fn success_for_logging(&self) -> bool {
|
||||||
self.success
|
self.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
||||||
@@ -99,6 +99,12 @@ impl ToolOutput for McpToolOutput {
|
|||||||
output: self.clone(),
|
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 {
|
pub struct FunctionToolOutput {
|
||||||
@@ -272,12 +278,11 @@ fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
ResponseInputItem::McpToolCallOutput { output, .. } => {
|
ResponseInputItem::McpToolCallOutput { output, .. } => {
|
||||||
match output.as_function_call_output_payload().body {
|
output.code_mode_result(&ToolPayload::Mcp {
|
||||||
FunctionCallOutputBody::Text(text) => JsonValue::String(text),
|
server: String::new(),
|
||||||
FunctionCallOutputBody::ContentItems(items) => {
|
tool: String::new(),
|
||||||
content_items_to_code_mode_result(&items)
|
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]
|
#[test]
|
||||||
fn custom_tool_calls_can_derive_text_from_content_items() {
|
fn custom_tool_calls_can_derive_text_from_content_items() {
|
||||||
let payload = ToolPayload::Custom {
|
let payload = ToolPayload::Custom {
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ use crate::tools::context::ToolInvocation;
|
|||||||
use crate::tools::context::ToolPayload;
|
use crate::tools::context::ToolPayload;
|
||||||
use crate::tools::registry::ToolHandler;
|
use crate::tools::registry::ToolHandler;
|
||||||
use crate::tools::registry::ToolKind;
|
use crate::tools::registry::ToolKind;
|
||||||
use codex_protocol::models::McpToolOutput;
|
use codex_protocol::mcp::CallToolResult;
|
||||||
|
|
||||||
pub struct McpHandler;
|
pub struct McpHandler;
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ToolHandler for McpHandler {
|
impl ToolHandler for McpHandler {
|
||||||
type Output = McpToolOutput;
|
type Output = CallToolResult;
|
||||||
|
|
||||||
fn kind(&self) -> ToolKind {
|
fn kind(&self) -> ToolKind {
|
||||||
ToolKind::Mcp
|
ToolKind::Mcp
|
||||||
|
|||||||
@@ -622,7 +622,7 @@ impl JsReplManager {
|
|||||||
}
|
}
|
||||||
ResponseInputItem::McpToolCallOutput { output, .. } => {
|
ResponseInputItem::McpToolCallOutput { output, .. } => {
|
||||||
let function_output = output.as_function_call_output_payload();
|
let function_output = output.as_function_call_output_payload();
|
||||||
let payload_kind = if output.success {
|
let payload_kind = if output.success() {
|
||||||
JsReplToolCallPayloadKind::McpResult
|
JsReplToolCallPayloadKind::McpResult
|
||||||
} else {
|
} else {
|
||||||
JsReplToolCallPayloadKind::McpErrorResult
|
JsReplToolCallPayloadKind::McpErrorResult
|
||||||
@@ -634,7 +634,7 @@ impl JsReplManager {
|
|||||||
);
|
);
|
||||||
summary.payload_item_count = Some(output.content.len());
|
summary.payload_item_count = Some(output.content.len());
|
||||||
summary.structured_content_present = Some(output.structured_content.is_some());
|
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
|
summary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,9 +124,9 @@ impl ToolCallRuntime {
|
|||||||
},
|
},
|
||||||
ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput {
|
ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput {
|
||||||
call_id: call.call_id.clone(),
|
call_id: call.call_id.clone(),
|
||||||
output: codex_protocol::models::McpToolOutput::from_error_text(
|
output: codex_protocol::mcp::CallToolResult::from_error_text(Self::abort_message(
|
||||||
Self::abort_message(call, secs),
|
call, secs,
|
||||||
),
|
)),
|
||||||
},
|
},
|
||||||
_ => ResponseInputItem::FunctionCallOutput {
|
_ => ResponseInputItem::FunctionCallOutput {
|
||||||
call_id: call.call_id.clone(),
|
call_id: call.call_id.clone(),
|
||||||
|
|||||||
@@ -1771,6 +1771,7 @@ pub(crate) fn mcp_tool_to_openai_tool(
|
|||||||
let rmcp::model::Tool {
|
let rmcp::model::Tool {
|
||||||
description,
|
description,
|
||||||
input_schema,
|
input_schema,
|
||||||
|
output_schema,
|
||||||
..
|
..
|
||||||
} = tool;
|
} = tool;
|
||||||
|
|
||||||
@@ -1795,13 +1796,19 @@ pub(crate) fn mcp_tool_to_openai_tool(
|
|||||||
// `type`, so we coerce/sanitize here for compatibility.
|
// `type`, so we coerce/sanitize here for compatibility.
|
||||||
sanitize_json_schema(&mut serialized_input_schema);
|
sanitize_json_schema(&mut serialized_input_schema);
|
||||||
let input_schema = serde_json::from_value::<JsonSchema>(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 {
|
Ok(ResponsesApiTool {
|
||||||
name: fully_qualified_name,
|
name: fully_qualified_name,
|
||||||
description: description.map(Into::into).unwrap_or_default(),
|
description: description.map(Into::into).unwrap_or_default(),
|
||||||
strict: false,
|
strict: false,
|
||||||
parameters: input_schema,
|
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)
|
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
|
/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
|
||||||
/// JsonSchema enum. This function:
|
/// JsonSchema enum. This function:
|
||||||
/// - Ensures every schema object has a "type". If missing, infers it from
|
/// - 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!({})));
|
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 {
|
fn tool_name(tool: &ToolSpec) -> &str {
|
||||||
match tool {
|
match tool {
|
||||||
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
|
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
|
||||||
@@ -3355,7 +3491,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
description: "Do something cool".to_string(),
|
description: "Do something cool".to_string(),
|
||||||
strict: false,
|
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(),
|
description: "Search docs".to_string(),
|
||||||
strict: false,
|
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(),
|
description: "Pagination".to_string(),
|
||||||
strict: false,
|
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(),
|
description: "Tags".to_string(),
|
||||||
strict: false,
|
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(),
|
description: "AnyOf Value".to_string(),
|
||||||
strict: false,
|
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(),
|
description: "Do something cool".to_string(),
|
||||||
strict: false,
|
strict: false,
|
||||||
output_schema: None,
|
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use codex_core::config::types::McpServerConfig;
|
||||||
|
use codex_core::config::types::McpServerTransportConfig;
|
||||||
use codex_core::features::Feature;
|
use codex_core::features::Feature;
|
||||||
use core_test_support::responses;
|
use core_test_support::responses;
|
||||||
use core_test_support::responses::ResponseMock;
|
use core_test_support::responses::ResponseMock;
|
||||||
@@ -11,11 +13,14 @@ use core_test_support::responses::ev_custom_tool_call;
|
|||||||
use core_test_support::responses::ev_response_created;
|
use core_test_support::responses::ev_response_created;
|
||||||
use core_test_support::responses::sse;
|
use core_test_support::responses::sse;
|
||||||
use core_test_support::skip_if_no_network;
|
use core_test_support::skip_if_no_network;
|
||||||
|
use core_test_support::stdio_server_bin;
|
||||||
use core_test_support::test_codex::TestCodex;
|
use core_test_support::test_codex::TestCodex;
|
||||||
use core_test_support::test_codex::test_codex;
|
use core_test_support::test_codex::test_codex;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::time::Duration;
|
||||||
use wiremock::MockServer;
|
use wiremock::MockServer;
|
||||||
|
|
||||||
fn custom_tool_output_text_and_success(
|
fn custom_tool_output_text_and_success(
|
||||||
@@ -63,6 +68,70 @@ async fn run_code_mode_turn(
|
|||||||
Ok((test, second_mock))
|
Ok((test, second_mock))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_code_mode_turn_with_rmcp(
|
||||||
|
server: &MockServer,
|
||||||
|
prompt: &str,
|
||||||
|
code: &str,
|
||||||
|
) -> Result<(TestCodex, ResponseMock)> {
|
||||||
|
let rmcp_test_server_bin = stdio_server_bin()?;
|
||||||
|
let mut builder = test_codex().with_config(move |config| {
|
||||||
|
let _ = config.features.enable(Feature::CodeMode);
|
||||||
|
|
||||||
|
let mut servers = config.mcp_servers.get().clone();
|
||||||
|
servers.insert(
|
||||||
|
"rmcp".to_string(),
|
||||||
|
McpServerConfig {
|
||||||
|
transport: McpServerTransportConfig::Stdio {
|
||||||
|
command: rmcp_test_server_bin,
|
||||||
|
args: Vec::new(),
|
||||||
|
env: Some(HashMap::from([(
|
||||||
|
"MCP_TEST_VALUE".to_string(),
|
||||||
|
"propagated-env".to_string(),
|
||||||
|
)])),
|
||||||
|
env_vars: Vec::new(),
|
||||||
|
cwd: None,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
required: false,
|
||||||
|
disabled_reason: None,
|
||||||
|
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||||
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
|
scopes: None,
|
||||||
|
oauth_resource: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
config
|
||||||
|
.mcp_servers
|
||||||
|
.set(servers)
|
||||||
|
.expect("test mcp servers should accept any configuration");
|
||||||
|
});
|
||||||
|
let test = builder.build(server).await?;
|
||||||
|
|
||||||
|
responses::mount_sse_once(
|
||||||
|
server,
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-1"),
|
||||||
|
ev_custom_tool_call("call-1", "code_mode", code),
|
||||||
|
ev_completed("resp-1"),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let second_mock = responses::mount_sse_once(
|
||||||
|
server,
|
||||||
|
sse(vec![
|
||||||
|
ev_assistant_message("msg-1", "done"),
|
||||||
|
ev_completed("resp-2"),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
test.submit_turn(prompt).await?;
|
||||||
|
Ok((test, second_mock))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
|
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn code_mode_can_return_exec_command_output() -> Result<()> {
|
async fn code_mode_can_return_exec_command_output() -> Result<()> {
|
||||||
@@ -135,3 +204,170 @@ async fn code_mode_can_apply_patch_via_nested_tool() -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn code_mode_can_print_structured_mcp_tool_result_fields() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let server = responses::start_mock_server().await;
|
||||||
|
let code = r#"
|
||||||
|
import { echo } from "tools/mcp/rmcp.js";
|
||||||
|
|
||||||
|
const { content, structuredContent, isError } = await echo({
|
||||||
|
message: "ping",
|
||||||
|
});
|
||||||
|
add_content(
|
||||||
|
`echo=${structuredContent?.echo ?? "missing"}\n` +
|
||||||
|
`env=${structuredContent?.env ?? "missing"}\n` +
|
||||||
|
`isError=${String(isError)}\n` +
|
||||||
|
`contentLength=${content.length}`
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let (_test, second_mock) =
|
||||||
|
run_code_mode_turn_with_rmcp(&server, "use code_mode to run the rmcp echo tool", code)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let req = second_mock.single_request();
|
||||||
|
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
|
||||||
|
assert_ne!(
|
||||||
|
success,
|
||||||
|
Some(false),
|
||||||
|
"code_mode rmcp echo call failed unexpectedly: {output}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
output,
|
||||||
|
"echo=ECHOING: ping
|
||||||
|
env=propagated-env
|
||||||
|
isError=false
|
||||||
|
contentLength=0"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn code_mode_can_access_namespaced_mcp_tool_from_flat_tools_namespace() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let server = responses::start_mock_server().await;
|
||||||
|
let code = r#"
|
||||||
|
import { tools } from "tools.js";
|
||||||
|
|
||||||
|
const { structuredContent, isError } = await tools["mcp__rmcp__echo"]({
|
||||||
|
message: "ping",
|
||||||
|
});
|
||||||
|
add_content(
|
||||||
|
`echo=${structuredContent?.echo ?? "missing"}\n` +
|
||||||
|
`env=${structuredContent?.env ?? "missing"}\n` +
|
||||||
|
`isError=${String(isError)}`
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let (_test, second_mock) =
|
||||||
|
run_code_mode_turn_with_rmcp(&server, "use code_mode to run the rmcp echo tool", code)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let req = second_mock.single_request();
|
||||||
|
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
|
||||||
|
assert_ne!(
|
||||||
|
success,
|
||||||
|
Some(false),
|
||||||
|
"code_mode rmcp echo call failed unexpectedly: {output}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
output,
|
||||||
|
"echo=ECHOING: ping
|
||||||
|
env=propagated-env
|
||||||
|
isError=false"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn code_mode_can_print_content_only_mcp_tool_result_fields() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let server = responses::start_mock_server().await;
|
||||||
|
let code = r#"
|
||||||
|
import { image_scenario } from "tools/mcp/rmcp.js";
|
||||||
|
|
||||||
|
const { content, structuredContent, isError } = await image_scenario({
|
||||||
|
scenario: "text_only",
|
||||||
|
caption: "caption from mcp",
|
||||||
|
});
|
||||||
|
add_content(
|
||||||
|
`firstType=${content[0]?.type ?? "missing"}\n` +
|
||||||
|
`firstText=${content[0]?.text ?? "missing"}\n` +
|
||||||
|
`structuredContent=${String(structuredContent ?? null)}\n` +
|
||||||
|
`isError=${String(isError)}`
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let (_test, second_mock) = run_code_mode_turn_with_rmcp(
|
||||||
|
&server,
|
||||||
|
"use code_mode to run the rmcp image scenario tool",
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let req = second_mock.single_request();
|
||||||
|
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
|
||||||
|
assert_ne!(
|
||||||
|
success,
|
||||||
|
Some(false),
|
||||||
|
"code_mode rmcp image scenario call failed unexpectedly: {output}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
output,
|
||||||
|
"firstType=text
|
||||||
|
firstText=caption from mcp
|
||||||
|
structuredContent=null
|
||||||
|
isError=false"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn code_mode_can_print_error_mcp_tool_result_fields() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let server = responses::start_mock_server().await;
|
||||||
|
let code = r#"
|
||||||
|
import { echo } from "tools/mcp/rmcp.js";
|
||||||
|
|
||||||
|
const { content, structuredContent, isError } = await echo({});
|
||||||
|
const firstText = content[0]?.text ?? "";
|
||||||
|
const mentionsMissingMessage =
|
||||||
|
firstText.includes("missing field") && firstText.includes("message");
|
||||||
|
add_content(
|
||||||
|
`isError=${String(isError)}\n` +
|
||||||
|
`contentLength=${content.length}\n` +
|
||||||
|
`mentionsMissingMessage=${String(mentionsMissingMessage)}\n` +
|
||||||
|
`structuredContent=${String(structuredContent ?? null)}`
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let (_test, second_mock) =
|
||||||
|
run_code_mode_turn_with_rmcp(&server, "use code_mode to call rmcp echo badly", code)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let req = second_mock.single_request();
|
||||||
|
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
|
||||||
|
assert_ne!(
|
||||||
|
success,
|
||||||
|
Some(false),
|
||||||
|
"code_mode rmcp error call failed unexpectedly: {output}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
output,
|
||||||
|
"isError=true
|
||||||
|
contentLength=1
|
||||||
|
mentionsMissingMessage=true
|
||||||
|
structuredContent=null"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -206,7 +206,7 @@ pub enum ResponseInputItem {
|
|||||||
},
|
},
|
||||||
McpToolCallOutput {
|
McpToolCallOutput {
|
||||||
call_id: String,
|
call_id: String,
|
||||||
output: McpToolOutput,
|
output: CallToolResult,
|
||||||
},
|
},
|
||||||
CustomToolCallOutput {
|
CustomToolCallOutput {
|
||||||
call_id: String,
|
call_id: String,
|
||||||
@@ -1184,20 +1184,10 @@ impl<'de> Deserialize<'de> for FunctionCallOutputPayload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
impl CallToolResult {
|
||||||
#[serde(rename_all = "camelCase")]
|
pub fn from_result(result: Result<Self, String>) -> Self {
|
||||||
pub struct McpToolOutput {
|
|
||||||
pub content: Vec<serde_json::Value>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
#[ts(optional)]
|
|
||||||
pub structured_content: Option<serde_json::Value>,
|
|
||||||
pub success: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpToolOutput {
|
|
||||||
pub fn from_result(result: Result<CallToolResult, String>) -> Self {
|
|
||||||
match result {
|
match result {
|
||||||
Ok(result) => Self::from(&result),
|
Ok(result) => result,
|
||||||
Err(error) => Self::from_error_text(error),
|
Err(error) => Self::from_error_text(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1209,23 +1199,13 @@ impl McpToolOutput {
|
|||||||
"text": text,
|
"text": text,
|
||||||
})],
|
})],
|
||||||
structured_content: None,
|
structured_content: None,
|
||||||
success: false,
|
is_error: Some(true),
|
||||||
|
meta: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_call_tool_result(self) -> CallToolResult {
|
pub fn success(&self) -> bool {
|
||||||
let Self {
|
self.is_error != Some(true)
|
||||||
content,
|
|
||||||
structured_content,
|
|
||||||
success,
|
|
||||||
} = self;
|
|
||||||
|
|
||||||
CallToolResult {
|
|
||||||
content,
|
|
||||||
structured_content,
|
|
||||||
is_error: Some(!success),
|
|
||||||
meta: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_function_call_output_payload(&self) -> FunctionCallOutputPayload {
|
pub fn as_function_call_output_payload(&self) -> FunctionCallOutputPayload {
|
||||||
@@ -1236,7 +1216,7 @@ impl McpToolOutput {
|
|||||||
Ok(serialized_structured_content) => {
|
Ok(serialized_structured_content) => {
|
||||||
return FunctionCallOutputPayload {
|
return FunctionCallOutputPayload {
|
||||||
body: FunctionCallOutputBody::Text(serialized_structured_content),
|
body: FunctionCallOutputBody::Text(serialized_structured_content),
|
||||||
success: Some(self.success),
|
success: Some(self.success()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -1267,7 +1247,7 @@ impl McpToolOutput {
|
|||||||
|
|
||||||
FunctionCallOutputPayload {
|
FunctionCallOutputPayload {
|
||||||
body,
|
body,
|
||||||
success: Some(self.success),
|
success: Some(self.success()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1276,23 +1256,6 @@ impl McpToolOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&CallToolResult> for McpToolOutput {
|
|
||||||
fn from(call_tool_result: &CallToolResult) -> Self {
|
|
||||||
let CallToolResult {
|
|
||||||
content,
|
|
||||||
structured_content,
|
|
||||||
is_error,
|
|
||||||
meta: _,
|
|
||||||
} = call_tool_result;
|
|
||||||
|
|
||||||
Self {
|
|
||||||
content: content.clone(),
|
|
||||||
structured_content: structured_content.clone(),
|
|
||||||
success: is_error != &Some(true),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_mcp_content_to_items(
|
fn convert_mcp_content_to_items(
|
||||||
contents: &[serde_json::Value],
|
contents: &[serde_json::Value],
|
||||||
) -> Option<Vec<FunctionCallOutputContentItem>> {
|
) -> Option<Vec<FunctionCallOutputContentItem>> {
|
||||||
@@ -1882,7 +1845,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = McpToolOutput::from(&call_tool_result).into_function_call_output_payload();
|
let payload = call_tool_result.into_function_call_output_payload();
|
||||||
assert_eq!(payload.success, Some(true));
|
assert_eq!(payload.success, Some(true));
|
||||||
let Some(items) = payload.content_items() else {
|
let Some(items) = payload.content_items() else {
|
||||||
panic!("expected content items");
|
panic!("expected content items");
|
||||||
@@ -1949,7 +1912,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = McpToolOutput::from(&call_tool_result).into_function_call_output_payload();
|
let payload = call_tool_result.into_function_call_output_payload();
|
||||||
let Some(items) = payload.content_items() else {
|
let Some(items) = payload.content_items() else {
|
||||||
panic!("expected content items");
|
panic!("expected content items");
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user