Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions crates/mcp/src/core/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ impl<'a> McpToolSession<'a> {
server_label, name, ..
} => !self.should_hide_mcp_call_like_by_label(name, server_label),
ResponseOutputItem::FunctionToolCall { name, .. } => {
!self.should_hide_function_call_like(name, user_function_names)
!self.should_hide_internal_non_builtin_function_like(name, user_function_names)
}
ResponseOutputItem::WebSearchCall { .. }
| ResponseOutputItem::CodeInterpreterCall { .. }
Expand Down Expand Up @@ -621,8 +621,9 @@ impl<'a> McpToolSession<'a> {
user_function_names: &HashSet<String>,
) -> bool {
match tool.get("type").and_then(|value| value.as_str()) {
Some("function") => Self::function_tool_name_json(tool)
.is_some_and(|name| self.should_hide_function_call_like(name, user_function_names)),
Some("function") => Self::function_tool_name_json(tool).is_some_and(|name| {
self.should_hide_internal_non_builtin_function_like(name, user_function_names)
}),
// MCP tool entries are keyed by server metadata, so function-name collision
// handling does not apply to this arm.
Some("mcp") => tool
Expand Down Expand Up @@ -671,7 +672,9 @@ impl<'a> McpToolSession<'a> {
Some("function_call") | Some("function_tool_call") => item
.get("name")
.and_then(|value| value.as_str())
.is_some_and(|name| self.should_hide_function_call_like(name, user_function_names)),
.is_some_and(|name| {
self.should_hide_internal_non_builtin_function_like(name, user_function_names)
}),
_ => false,
}
}
Expand All @@ -695,12 +698,12 @@ impl<'a> McpToolSession<'a> {
}
}

fn should_hide_function_call_like(
fn should_hide_internal_non_builtin_function_like(
&self,
name: &str,
user_function_names: &HashSet<String>,
) -> bool {
self.is_internal_tool(name) && !user_function_names.contains(name)
self.is_internal_non_builtin_tool(name) && !user_function_names.contains(name)
}

fn function_tool_name_json(tool: &serde_json::Value) -> Option<&str> {
Expand Down
15 changes: 8 additions & 7 deletions docs/reference/mcp-internal-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ servers:
```

In the current implementation, `internal: true` applies only to self-provided
MCP servers declared under `servers:`. It affects final assembled,
non-streaming MCP responses by allowing higher layers to strip internal server
tool lists and tool-call trace items before the response is returned to the
client.
MCP servers declared under `servers:`. The model may still see and call these
tools during gateway-managed tool loops, but OpenAI Responses client-facing
output hides internal non-builtin tool details before returning data to the
client. That includes final non-streaming responses, final streaming
`response.completed` events, live streaming tool-call events, live
`mcp_list_tools` events, and response envelope `tools` / `tool_choice` fields.

This flag does not currently hide streaming output, and it does not apply to
builtin-routed MCP results such as `web_search_call`, `code_interpreter_call`,
or `file_search_call`.
This flag does not apply to builtin-routed MCP results such as
`web_search_call`, `code_interpreter_call`, or `file_search_call`.

This flag is generic. It does not imply any vendor-specific behavior and does
not change transport setup or tool execution on its own.
120 changes: 105 additions & 15 deletions model_gateway/src/routers/openai/mcp/tool_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ pub(crate) async fn execute_streaming_tool_calls(

let response_format = session.tool_response_format(&call.name);
let server_label = session.resolve_tool_server_label(&call.name);
let emit_tool_events = !session.is_internal_non_builtin_tool(&call.name);
if !emit_tool_events && tx.is_closed() {
return false;
}

let mut arguments: Value = match serde_json::from_str(args_str) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Ok(v) => v,
Expand All @@ -221,13 +225,15 @@ pub(crate) async fn execute_streaming_tool_calls(
Value::String(stable_streaming_tool_item_id(&call, &response_format)),
);
}
if !send_tool_call_completion_events(
tx,
&call,
&mcp_call_item,
&response_format,
sequence_number,
) {
if emit_tool_events
&& !send_tool_call_completion_events(
tx,
&call,
&mcp_call_item,
&response_format,
sequence_number,
)
{
return false;
}
state.record_call(
Expand All @@ -242,7 +248,9 @@ pub(crate) async fn execute_streaming_tool_calls(
}
};

if !send_tool_call_intermediate_event(tx, &call, &response_format, sequence_number) {
if emit_tool_events
&& !send_tool_call_intermediate_event(tx, &call, &response_format, sequence_number)
{
return false;
}

Expand All @@ -266,6 +274,9 @@ pub(crate) async fn execute_streaming_tool_calls(
// Log the effective (post-merge) args so the log reflects what the
// MCP server actually receives, not the pre-merge string from the model.
debug!("Calling MCP tool '{}' with args: {}", call.name, arguments);
if !emit_tool_events && tx.is_closed() {
return false;
}
let tool_output = session
.execute_tool(ToolExecutionInput {
call_id: call.call_id.clone(),
Expand All @@ -284,6 +295,9 @@ pub(crate) async fn execute_streaming_tool_calls(
metrics_labels::RESULT_SUCCESS
},
);
if !emit_tool_events && tx.is_closed() {
return false;
}

let output_str = tool_output.output.to_string();
let mut mcp_call_item = to_value(tool_output.to_response_item()).unwrap_or_else(|e| {
Expand All @@ -297,13 +311,15 @@ pub(crate) async fn execute_streaming_tool_calls(
);
}

if !send_tool_call_completion_events(
tx,
&call,
&mcp_call_item,
&response_format,
sequence_number,
) {
if emit_tool_events
&& !send_tool_call_completion_events(
tx,
&call,
&mcp_call_item,
&response_format,
sequence_number,
)
{
return false;
}

Expand Down Expand Up @@ -1440,6 +1456,80 @@ mod tests {
));
}

#[tokio::test]
async fn streaming_tool_execution_suppresses_events_for_internal_non_builtin_tools() {
let orchestrator = McpOrchestrator::new(McpConfig {
servers: vec![McpServerConfig {
name: "internal-server".to_string(),
transport: McpTransport::Sse {
url: "http://localhost:3000/sse".to_string(),
token: None,
headers: Default::default(),
},
proxy: None,
required: false,
tools: None,
builtin_type: None,
builtin_tool_name: None,
internal: true,
}],
..Default::default()
})
.await
.expect("orchestrator");
orchestrator
.tool_inventory()
.insert_entry(ToolEntry::from_server_tool(
"internal-server",
test_tool("internal_search"),
));
let session = McpToolSession::new(
&orchestrator,
vec![McpServerBinding {
label: "internal-label".to_string(),
server_key: "internal-server".to_string(),
allowed_tools: None,
}],
"test-request",
);
let pending_call = super::FunctionCallInProgress {
call_id: "call_internal".to_string(),
name: "internal_search".to_string(),
arguments_buffer: "{not-json".to_string(),
item_id: Some("fc_internal".to_string()),
output_index: 0,
last_obfuscation: None,
assigned_output_index: Some(0),
};
let (tx, mut rx) = mpsc::unbounded_channel();
let mut state = ToolLoopState::new(ResponseInput::Text("hello".to_string()), Vec::new());
let mut sequence_number = 0;

let ok = super::execute_streaming_tool_calls(
vec![pending_call],
&session,
&tx,
&mut state,
&mut sequence_number,
"gpt-5.4",
&[],
None,
)
.await;
drop(tx);

assert!(ok);
assert_eq!(
drain_channel(&mut rx),
Comment thread
zhoug9127 marked this conversation as resolved.
Vec::<String>::new(),
"internal tool execution must not emit streaming tool events"
);
assert_eq!(state.mcp_call_items.len(), 1);
assert!(state.mcp_call_items[0]
.to_string()
.contains("internal_search"));
}

#[test]
fn emits_only_new_binding_when_resume_adds_second_tool_block() {
let existing_labels = HashSet::from(["deepwiki_ask".to_string()]);
Expand Down
Loading
Loading