Skip to content
Merged
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
34 changes: 14 additions & 20 deletions mcp/src/responses_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

use openai_protocol::{
common::{Function, Tool},
responses::{generate_id, McpToolInfo, ResponseOutputItem, ResponseTool, ResponseToolType},
responses::{generate_id, FunctionTool, McpToolInfo, ResponseOutputItem, ResponseTool},
};
use serde_json::{json, Value};

Expand Down Expand Up @@ -73,36 +73,30 @@ pub fn build_chat_function_tools_with_names(
.collect()
}

/// Build Responses API MCP tools from MCP tool entries.
/// Build Responses API function tools from MCP tool entries.
///
/// These tools are exposed in Responses requests where MCP tools are represented
/// as `{"type": "mcp", ...}` tool entries.
/// MCP tools are exposed to the model as function tools in the Responses API,
/// so these serialize as `{"type": "function", ...}` tool entries.
pub fn build_response_tools(entries: &[ToolEntry]) -> Vec<ResponseTool> {
build_response_tools_with_names(entries, None)
}

/// Build Responses API MCP tools from MCP tool entries with optional exposed names.
/// Build Responses API function tools from MCP tool entries with optional exposed names.
pub fn build_response_tools_with_names(
entries: &[ToolEntry],
exposed_names: Option<&std::collections::HashMap<QualifiedToolName, String>>,
) -> Vec<ResponseTool> {
entries
.iter()
.map(|entry| ResponseTool {
r#type: ResponseToolType::Mcp,
function: Some(Function {
name: resolved_name_for_entry(entry, exposed_names).to_string(),
description: entry.tool.description.as_ref().map(|d| d.to_string()),
parameters: Value::Object((*entry.tool.input_schema).clone()),
strict: None,
}),
server_url: None,
authorization: None,
headers: None,
server_label: Some(entry.server_key().to_string()),
server_description: None,
require_approval: None,
allowed_tools: None,
.map(|entry| {
ResponseTool::Function(FunctionTool {
function: Function {
name: resolved_name_for_entry(entry, exposed_names).to_string(),
description: entry.tool.description.as_ref().map(|d| d.to_string()),
parameters: Value::Object((*entry.tool.input_schema).clone()),
strict: None,
},
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
.collect()
}
Expand Down
49 changes: 16 additions & 33 deletions model_gateway/src/routers/grpc/common/responses/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::sync::Arc;
use axum::response::Response;
use openai_protocol::{
common::Tool,
responses::{ResponseTool, ResponseToolType, ResponsesRequest, ResponsesResponse},
responses::{ResponseTool, ResponsesRequest, ResponsesResponse},
};
use serde_json::to_value;
use smg_data_connector::{ConversationItemStorage, ConversationStorage, ResponseStorage};
Expand All @@ -32,19 +32,16 @@ pub(crate) async fn ensure_mcp_connection(
) -> Result<(bool, Vec<McpServerBinding>), Response> {
// Check for explicit MCP tools (must error if connection fails)
let has_explicit_mcp_tools = tools
.map(|t| {
t.iter()
.any(|tool| matches!(tool.r#type, ResponseToolType::Mcp))
})
.map(|t| t.iter().any(|tool| matches!(tool, ResponseTool::Mcp(_))))
.unwrap_or(false);

// Check for builtin tools that MAY have MCP routing configured
let has_builtin_tools = tools
.map(|t| {
t.iter().any(|tool| {
matches!(
tool.r#type,
ResponseToolType::WebSearchPreview | ResponseToolType::CodeInterpreter
tool,
ResponseTool::WebSearchPreview(_) | ResponseTool::CodeInterpreter(_)
)
})
})
Expand Down Expand Up @@ -107,46 +104,32 @@ pub(crate) fn validate_worker_availability(
None
}

/// Extract function tools (and optionally MCP tools) from ResponseTools
/// Extract function tools from ResponseTools
///
/// This utility consolidates the logic for extracting tools with schemas from ResponseTools.
/// It's used by both Harmony and Regular routers for different purposes:
///
/// - **Harmony router**: Extracts both Function and MCP tools (with `include_mcp: true`)
/// because MCP schemas are populated by convert_mcp_tools_to_response_tools() before the
/// pipeline runs. These tools are used to generate structural constraints in the
/// Harmony preparation stage.
/// - **Harmony router**: Extracts function tools because MCP tools are exposed to the model as
/// function tools (via `convert_mcp_tools_to_response_tools()`), and those are used to
/// generate structural constraints in the Harmony preparation stage.
///
/// - **Regular router**: Extracts only Function tools (with `include_mcp: false`) during
/// the initial conversion from ResponsesRequest to ChatCompletionRequest. MCP tools
/// are merged later by the tool loop before being sent to the chat pipeline, where
/// tool_choice constraints are generated for ALL tools (function + MCP combined).
/// - **Regular router**: Extracts function tools during the initial conversion from
/// ResponsesRequest to ChatCompletionRequest. MCP tools are merged later by the tool loop.
pub(crate) fn extract_tools_from_response_tools(
response_tools: Option<&[ResponseTool]>,
include_mcp: bool,
) -> Vec<Tool> {
let Some(tools) = response_tools else {
return Vec::new();
};

tools
.iter()
.filter_map(|rt| {
match rt.r#type {
// Function tools: Schema in request
ResponseToolType::Function => rt.function.as_ref().map(|f| Tool {
tool_type: "function".to_string(),
function: f.clone(),
}),
// MCP tools: Schema populated by convert_mcp_tools_to_response_tools()
// Only include if requested (Harmony case)
ResponseToolType::Mcp if include_mcp => rt.function.as_ref().map(|f| Tool {
tool_type: "function".to_string(),
function: f.clone(),
}),
// Hosted tools: No schema available, skip
_ => None,
}
.filter_map(|rt| match rt {
ResponseTool::Function(ft) => Some(Tool {
tool_type: "function".to_string(),
function: ft.function.clone(),
}),
_ => None,
})
.collect()
}
Expand Down
40 changes: 19 additions & 21 deletions model_gateway/src/routers/grpc/harmony/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ use openai_protocol::{
common::{ChatLogProbs, ContentPart, Tool},
responses::{
ReasoningEffort as ResponsesReasoningEffort, ResponseContentPart, ResponseInput,
ResponseInputOutputItem, ResponseReasoningContent, ResponseTool, ResponseToolType,
ResponsesRequest, StringOrContentParts,
ResponseInputOutputItem, ResponseReasoningContent, ResponseTool, ResponsesRequest,
StringOrContentParts,
},
};
use tracing::{debug, trace, warn};
Expand Down Expand Up @@ -85,7 +85,7 @@ impl ToolLike for Tool {
}

fn is_custom(&self) -> bool {
matches!(self.tool_type.as_str(), "mcp" | "function")
matches!(self.tool_type.as_str(), "function")
}

fn to_tool_description(&self) -> Option<ToolDescription> {
Expand All @@ -101,26 +101,24 @@ impl ToolLike for Tool {
impl ToolLike for ResponseTool {
fn is_builtin(&self) -> bool {
matches!(
self.r#type,
ResponseToolType::WebSearchPreview | ResponseToolType::CodeInterpreter
self,
ResponseTool::WebSearchPreview(_) | ResponseTool::CodeInterpreter(_)
)
}

fn is_custom(&self) -> bool {
matches!(
self.r#type,
ResponseToolType::Mcp | ResponseToolType::Function
)
matches!(self, ResponseTool::Function(_))
}

fn to_tool_description(&self) -> Option<ToolDescription> {
self.function.as_ref().map(|func| {
ToolDescription::new(
func.name.clone(),
func.description.clone().unwrap_or_default(),
Some(func.parameters.clone()),
)
})
match self {
ResponseTool::Function(ft) => Some(ToolDescription::new(
ft.function.name.clone(),
ft.function.description.clone().unwrap_or_default(),
Some(ft.function.parameters.clone()),
)),
_ => None,
}
}
}

Expand Down Expand Up @@ -423,11 +421,11 @@ impl HarmonyBuilder {
.map(|tools| {
tools
.iter()
.map(|tool| match tool.r#type {
ResponseToolType::Function => "function",
ResponseToolType::WebSearchPreview => "web_search_preview",
ResponseToolType::CodeInterpreter => "code_interpreter",
ResponseToolType::Mcp => "mcp",
.map(|tool| match tool {
ResponseTool::Function(_) => "function",
ResponseTool::WebSearchPreview(_) => "web_search_preview",
ResponseTool::CodeInterpreter(_) => "code_interpreter",
ResponseTool::Mcp(_) => "mcp",
})
.collect()
})
Expand Down
17 changes: 1 addition & 16 deletions model_gateway/src/routers/grpc/harmony/responses/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ use openai_protocol::{
common::{ToolCall, ToolChoice, ToolChoiceValue},
responses::{
ResponseContentPart, ResponseInput, ResponseInputOutputItem, ResponseOutputItem,
ResponseReasoningContent, ResponseTool, ResponseToolType, ResponsesRequest,
ResponsesResponse, StringOrContentParts,
ResponseReasoningContent, ResponsesRequest, ResponsesResponse, StringOrContentParts,
},
};
use serde_json::{from_value, to_string, Value};
Expand Down Expand Up @@ -54,20 +53,6 @@ impl McpCallTracking {
}
}

/// Build a HashSet of MCP tool names for O(1) lookup
///
/// Creates a HashSet containing the names of all MCP tools in the request,
/// allowing for efficient O(1) lookups when partitioning tool calls.
pub(super) fn build_mcp_tool_names_set(
request_tools: &[ResponseTool],
) -> std::collections::HashSet<&str> {
request_tools
.iter()
.filter(|t| t.r#type == ResponseToolType::Mcp)
.filter_map(|t| t.function.as_ref().map(|f| f.name.as_str()))
.collect()
}

/// Build next request with tool results appended to history
///
/// Constructs a new ResponsesRequest with:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ use tracing::{debug, error, warn};

use super::{
common::{
build_mcp_tool_names_set, build_next_request_with_tools, inject_mcp_metadata,
load_previous_messages, McpCallTracking,
build_next_request_with_tools, inject_mcp_metadata, load_previous_messages, McpCallTracking,
},
execution::{convert_mcp_tools_to_response_tools, execute_mcp_tools, ToolResult},
};
Expand Down Expand Up @@ -168,12 +167,12 @@ async fn execute_with_mcp_loop(
"Tool calls found - separating MCP and function tools"
);

// Separate MCP and function tool calls based on tool type
let request_tools = current_request.tools.as_deref().unwrap_or(&[]);
let mcp_tool_names = build_mcp_tool_names_set(request_tools);
// Separate MCP and function tool calls based on session exposure.
// MCP tools are exposed to the model as function tools, so the only reliable
// discriminator is whether the name belongs to the MCP session.
let (mcp_tool_calls, function_tool_calls): (Vec<_>, Vec<_>) = tool_calls
.into_iter()
.partition(|tc| mcp_tool_names.contains(tc.function.name.as_str()));
.partition(|tc| session.has_exposed_tool(&tc.function.name));

Comment thread
zhaowenzi marked this conversation as resolved.
debug!(
mcp_calls = mcp_tool_calls.len(),
Expand Down
28 changes: 4 additions & 24 deletions model_gateway/src/routers/grpc/harmony/responses/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,15 @@ use std::time::{SystemTime, UNIX_EPOCH};

use axum::response::Response;
use bytes::Bytes;
use openai_protocol::responses::{ResponseToolType, ResponsesRequest};
use openai_protocol::responses::ResponsesRequest;
use serde_json::json;
use smg_mcp::{McpServerBinding, McpToolSession};
use tokio::sync::mpsc;
use tracing::{debug, warn};
use uuid::Uuid;

use super::{
common::{
build_mcp_tool_names_set, build_next_request_with_tools, load_previous_messages,
McpCallTracking,
},
common::{build_next_request_with_tools, load_previous_messages, McpCallTracking},
execution::{convert_mcp_tools_to_response_tools, execute_mcp_tools},
};
use crate::{
Expand Down Expand Up @@ -153,19 +150,6 @@ async fn execute_mcp_tool_loop_streaming(
);
}

// Build HashSet of MCP tool names for O(1) lookup during streaming
let mcp_tool_names: std::collections::HashSet<String> = current_request
.tools
.as_ref()
.map(|tools| {
tools
.iter()
.filter(|t| t.r#type == ResponseToolType::Mcp)
.filter_map(|t| t.function.as_ref().map(|f| f.name.clone()))
.collect()
})
.unwrap_or_default();

let mut mcp_tracking = McpCallTracking::new();

// Emit mcp_list_tools on first iteration
Expand Down Expand Up @@ -231,7 +215,6 @@ async fn execute_mcp_tool_loop_streaming(
emitter,
tx,
Some(&session),
Some(&mcp_tool_names),
)
.await
{
Expand All @@ -258,12 +241,10 @@ async fn execute_mcp_tool_loop_streaming(
"Tool calls found - separating MCP and function tools"
);

// Separate MCP and function tool calls based on tool type
let request_tools = current_request.tools.as_deref().unwrap_or(&[]);
let mcp_tool_names = build_mcp_tool_names_set(request_tools);
// Separate MCP and function tool calls based on session exposure.
let (mcp_tool_calls, function_tool_calls): (Vec<_>, Vec<_>) = tool_calls
.into_iter()
.partition(|tc| mcp_tool_names.contains(tc.function.name.as_str()));
.partition(|tc| session.has_exposed_tool(&tc.function.name));

debug!(
mcp_calls = mcp_tool_calls.len(),
Expand Down Expand Up @@ -434,7 +415,6 @@ async fn execute_without_mcp_streaming(
emitter,
tx,
None,
None,
)
.await
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ impl HarmonyPreparationStage {
ctx: &mut RequestContext,
request: &ResponsesRequest,
) -> Result<Option<Response>, Response> {
// Step 1: Extract function and MCP tools with schemas from ResponseTools
let mut function_tools = extract_tools_from_response_tools(request.tools.as_deref(), true);
// Step 1: Extract function tools with schemas from ResponseTools
let mut function_tools = extract_tools_from_response_tools(request.tools.as_deref());

// Step 2: Filter tools based on tool_choice (AllowedTools or Function)
// Note: Tool existence is already validated in ResponsesRequest::validate()
Expand Down
Loading