Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
321a103
fix(openai-bridge): mirror format on alias key + clear stale entries …
slin1237 May 4, 2026
d078b01
fix(openai-bridge): preserve tool-execution failure state in transfor…
slin1237 May 4, 2026
3aed900
fix(openai-bridge): narrow mcp_list_tools annotations back to {read_o…
slin1237 May 4, 2026
5c28cac
fix(persistence): preserve replayed structural input items verbatim
slin1237 May 4, 2026
bb31349
fix(openai-router): scope format-registry fail-fast to MCP-laden requ…
slin1237 May 4, 2026
cde0858
fix(grpc-streaming): emit transformed item for output_item.done
slin1237 May 4, 2026
b6bf495
test(mcp): route web_search regression assertions through transform_t…
slin1237 May 4, 2026
9bbdc28
style: trim narrative comments, keep only the load-bearing ones
slin1237 May 4, 2026
46a72a8
fix(mcp): lift rmcp tool annotations into ToolEntry
slin1237 May 4, 2026
69b7f9b
fix(openai-bridge): preserve MCP error text when error_message is None
slin1237 May 4, 2026
4ce01c1
fix(persistence): hoist top-level fields when reading whole-item rows
slin1237 May 4, 2026
579fad9
fix(mcp-utils): derive request_uses_mcp_routing from extract_builtin_…
slin1237 May 4, 2026
a5b6d24
fix(openai-router): gate orchestrator lookup behind MCP-routing arm
slin1237 May 4, 2026
e9cb555
fix(openai-bridge): read read_only_hint from rmcp directly, don't lif…
slin1237 May 4, 2026
4ef4bcd
fix(openai-bridge): hosted-builtin formats keep status=completed on i…
slin1237 May 5, 2026
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
36 changes: 35 additions & 1 deletion crates/mcp/src/core/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ pub struct ToolExecutionInput {
/// Output from batch tool execution.
///
/// `#[non_exhaustive]` so additive fields don't break consumers; only
/// `smg-mcp` constructs this.
/// `smg-mcp` constructs this in production. External tests use
/// [`ToolExecutionOutput::new_for_test`].
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ToolExecutionOutput {
Expand All @@ -184,6 +185,39 @@ pub struct ToolExecutionOutput {
pub duration: Duration,
}

impl ToolExecutionOutput {
/// Test-only constructor for downstream crates blocked by
/// `#[non_exhaustive]`. One positional arg per public field is
/// intentional so adding a field forces every test to consider it.
#[expect(
clippy::too_many_arguments,
reason = "Test fixture constructor; one arg per field is intentional."
)]
pub fn new_for_test(
call_id: impl Into<String>,
tool_name: impl Into<String>,
server_key: impl Into<String>,
server_label: impl Into<String>,
arguments_str: impl Into<String>,
output: Value,
is_error: bool,
error_message: Option<String>,
duration: Duration,
) -> Self {
Self {
call_id: call_id.into(),
tool_name: tool_name.into(),
server_key: server_key.into(),
server_label: server_label.into(),
arguments_str: arguments_str.into(),
output,
is_error,
error_message,
duration,
}
}
}

/// Result from resolved tool execution that preserves interactive approval state.
#[derive(Debug, Clone)]
pub enum ToolExecutionResult {
Expand Down
14 changes: 14 additions & 0 deletions model_gateway/src/routers/common/mcp_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,20 @@ pub fn extract_builtin_types(tools: &[ResponseTool]) -> Vec<BuiltinToolType> {
.collect()
}

/// True if `tools` carries any MCP-routed entry (declared MCP server or a
/// builtin family that the gateway intercepts via MCP).
pub fn request_uses_mcp_routing(tools: &[ResponseTool]) -> bool {
tools.iter().any(|t| {
matches!(
t,
ResponseTool::Mcp(_)
| ResponseTool::WebSearchPreview(_)
| ResponseTool::CodeInterpreter(_)
| ResponseTool::ImageGeneration(_)
)
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Collect user-declared function tool names from a Responses request.
pub(crate) fn collect_user_function_names(request: &ResponsesRequest) -> HashSet<String> {
request
Expand Down
129 changes: 86 additions & 43 deletions model_gateway/src/routers/common/openai_bridge/format_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,54 +52,56 @@ impl FormatRegistry {
self.formats.insert(qualified, format);
}

/// Populate from a server config: per-tool overrides + builtin defaults.
/// Safe to call repeatedly β€” entries for non-Passthrough formats are
/// overwritten. Downgrading a format back to `Passthrough` requires a
/// separate registry rebuild (no production caller mutates configs in
/// place today).
fn remove(&self, qualified: &QualifiedToolName) {
self.formats.remove(qualified);
}

/// Populate from a server config. Safe to call repeatedly.
///
/// Mirrors `McpOrchestrator::apply_tool_configs`:
/// - When a tool has an `alias`, the format is attached **only** to the
/// alias entry (under `("alias", alias_name)`), matching the orchestrator's
/// `register_alias` qualified-name shape. The underlying `(server, tool)`
/// stays at the `Passthrough` default so direct calls aren't transformed.
/// - When a tool has no alias but a non-default format, attach to
/// `(server, tool)` directly.
/// - When the per-tool stanza omits `response_format` entirely
/// (`None`), the builtin default still applies. This lets users add an
/// `alias` or `arg_mapping` to a builtin tool without disabling its
/// hosted-format wire shape. An explicit `Some(Passthrough)` *does*
/// block the builtin default β€” that is the documented escape hatch
/// for opting out of the hosted shape.
/// `McpToolSession::collect_visible_mcp_tools` replaces a direct tool
/// entry with its alias entry, so production session lookup of an
/// aliased tool resolves through `("alias", alias_name)`. Direct
/// dispatch still uses `(server_key, tool_name)`. Both keys must carry
/// the same format, so non-Passthrough formats are mirrored on both.
pub fn populate_from_server_config(&self, config: &McpServerConfig) {
if let Some(tools) = &config.tools {
for (tool_name, tool_config) in tools {
let direct_key = QualifiedToolName::new(&config.name, tool_name);
let alias_key = tool_config
.alias
.as_deref()
.map(|alias| QualifiedToolName::new(ALIAS_SERVER_KEY, alias));

let Some(format_config) = tool_config.response_format else {
continue;
};
let format: ResponseFormat = format_config.into();
if format == ResponseFormat::Passthrough {
self.remove(&direct_key);
if let Some(alias_key) = &alias_key {
self.remove(alias_key);
}
Comment thread
slin1237 marked this conversation as resolved.
continue;
}
if let Some(alias) = &tool_config.alias {
self.insert(QualifiedToolName::new(ALIAS_SERVER_KEY, alias), format);
} else {
self.insert(QualifiedToolName::new(&config.name, tool_name), format);

self.insert(direct_key, format);
if let Some(alias_key) = alias_key {
self.insert(alias_key, format);
}
}
}

if let (Some(builtin_type), Some(tool_name)) =
(&config.builtin_type, &config.builtin_tool_name)
{
let has_explicit_format = config
.tools
.as_ref()
.and_then(|tools| tools.get(tool_name))
.is_some_and(|cfg| cfg.response_format.is_some());
let stanza = config.tools.as_ref().and_then(|tools| tools.get(tool_name));
let has_explicit_format = stanza.is_some_and(|cfg| cfg.response_format.is_some());
if !has_explicit_format {
let format: ResponseFormat = builtin_type.response_format().into();
self.insert(QualifiedToolName::new(&config.name, tool_name), format);
if let Some(alias) = stanza.and_then(|cfg| cfg.alias.as_deref()) {
self.insert(QualifiedToolName::new(ALIAS_SERVER_KEY, alias), format);
}
}
}
}
Expand Down Expand Up @@ -142,9 +144,7 @@ mod tests {
}

#[test]
fn alias_format_stored_under_alias_server_key() {
// Mirrors orchestrator::register_alias which uses
// QualifiedToolName::new("alias", alias_name).
fn alias_format_mirrored_on_both_keys() {
let mut tools = HashMap::new();
tools.insert(
"brave_web_search".to_string(),
Expand All @@ -163,12 +163,10 @@ mod tests {
assert_eq!(
r.lookup_by_names("alias", "web_search"),
ResponseFormat::WebSearchCall,
"alias entry must use the literal `alias` server_key prefix"
);
assert_eq!(
r.lookup_by_names("brave", "brave_web_search"),
ResponseFormat::Passthrough,
"underlying tool entry must NOT receive the format when an alias exists"
ResponseFormat::WebSearchCall,
);
}

Expand Down Expand Up @@ -217,7 +215,6 @@ mod tests {
"do_search".to_string(),
ToolConfig {
alias: None,
// Explicit override differs from the builtin default.
response_format: Some(ResponseFormatConfig::Passthrough),
arg_mapping: None,
},
Expand All @@ -230,21 +227,14 @@ mod tests {
let r = FormatRegistry::new();
r.populate_from_server_config(&cfg);

// Explicit Some(Passthrough) override means "no entry inserted" AND
// the builtin default is NOT applied on top.
assert_eq!(
r.lookup_by_names("search", "do_search"),
ResponseFormat::Passthrough
);
}

#[test]
fn alias_only_stanza_preserves_builtin_default() {
// Regression: a per-tool stanza that only aliases a builtin tool
// (or only sets arg_mapping) used to suppress the builtin default,
// collapsing the hosted format to plain mcp_call. With
// `response_format: None` meaning "inherit context", the builtin
// default must still apply.
fn alias_only_stanza_preserves_builtin_default_on_both_keys() {
let mut tools = HashMap::new();
tools.insert(
"do_search".to_string(),
Expand All @@ -262,10 +252,63 @@ mod tests {
let r = FormatRegistry::new();
r.populate_from_server_config(&cfg);

// Alias key β€” what production session lookup hits.
assert_eq!(
r.lookup_by_names("alias", "web_search"),
ResponseFormat::WebSearchCall,
);
// Direct key β€” what direct dispatch hits.
assert_eq!(
r.lookup_by_names("search", "do_search"),
ResponseFormat::WebSearchCall,
"alias-only stanza must not disable the builtin's hosted format"
);
}

#[test]
fn explicit_passthrough_downgrade_clears_prior_hosted_entry() {
let r = FormatRegistry::new();

let mut hosted = HashMap::new();
hosted.insert(
"brave_web_search".to_string(),
ToolConfig {
alias: Some("web_search".to_string()),
response_format: Some(ResponseFormatConfig::WebSearchCall),
arg_mapping: None,
},
);
let mut hosted_cfg = server("brave");
hosted_cfg.tools = Some(hosted);
r.populate_from_server_config(&hosted_cfg);
assert_eq!(
r.lookup_by_names("alias", "web_search"),
ResponseFormat::WebSearchCall,
);
assert_eq!(
r.lookup_by_names("brave", "brave_web_search"),
ResponseFormat::WebSearchCall,
);

let mut downgraded = HashMap::new();
downgraded.insert(
"brave_web_search".to_string(),
ToolConfig {
alias: Some("web_search".to_string()),
response_format: Some(ResponseFormatConfig::Passthrough),
arg_mapping: None,
},
);
let mut downgraded_cfg = server("brave");
downgraded_cfg.tools = Some(downgraded);
r.populate_from_server_config(&downgraded_cfg);

assert_eq!(
r.lookup_by_names("alias", "web_search"),
ResponseFormat::Passthrough,
);
assert_eq!(
r.lookup_by_names("brave", "brave_web_search"),
ResponseFormat::Passthrough,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,21 @@ pub fn response_tools(session: &McpToolSession<'_>) -> Vec<ResponseTool> {
}

/// `McpToolInfo` records used inside `mcp_list_tools` output items.
///
/// `annotations` is narrowed to `{"read_only": …}` to match OpenAI's
/// Responses API shape. The richer `ToolAnnotations` (with
/// `destructive`/`idempotent`/`open_world`) is read internally off
/// `ToolEntry::annotations` by the approval pipeline.
pub fn build_mcp_tool_infos(entries: &[smg_mcp::ToolEntry]) -> Vec<McpToolInfo> {
entries
.iter()
.map(|entry| McpToolInfo {
name: entry.tool_name().to_string(),
description: entry.tool.description.as_ref().map(|d| d.to_string()),
input_schema: schema_to_value(&entry.tool.input_schema),
annotations: entry
.tool
.annotations
.as_ref()
.and_then(|a| serde_json::to_value(a).ok()),
annotations: Some(json!({
"read_only": entry.annotations.read_only,
})),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve wire read_only from MCP tool annotations

build_mcp_tool_infos now emits annotations.read_only from entry.annotations, but ToolEntry::from_server_tool initializes that field with defaults and the server-loading path never copies tool.annotations into it. This means mcp_list_tools can report read_only: false even when the MCP server advertised a read-only tool, regressing client-visible metadata accuracy for every tool loaded via load_server_inventory.

Useful? React with πŸ‘Β / πŸ‘Ž.

})
.collect()
}
Expand Down Expand Up @@ -311,3 +314,43 @@ fn is_client_visible_output_item(
| ResponseOutputItem::LocalShellCallOutput { .. } => true,
}
}

#[cfg(test)]
mod tests {
use std::{borrow::Cow, sync::Arc};

use rmcp::model::Tool;
use smg_mcp::{ToolAnnotations, ToolEntry};

use super::*;

fn entry_with_annotations(annotations: ToolAnnotations) -> ToolEntry {
let tool = Tool {
name: Cow::Owned("widget".to_string()),
title: None,
description: Some(Cow::Owned("widget description".to_string())),
input_schema: Arc::new(serde_json::Map::new()),
output_schema: None,
annotations: None,
icons: None,
};
ToolEntry::from_server_tool("srv", tool).with_annotations(annotations)
}

#[test]
fn build_mcp_tool_infos_emits_only_read_only_annotation() {
let annotations = ToolAnnotations::default()
.with_read_only(true)
.with_destructive(true);
let entries = vec![entry_with_annotations(annotations)];

let infos = build_mcp_tool_infos(&entries);
assert_eq!(infos.len(), 1);
let serialized = serde_json::to_value(&infos[0])
.expect("McpToolInfo must serialize")
.get("annotations")
.cloned()
.expect("annotations must serialize");
assert_eq!(serialized, json!({ "read_only": true }));
}
}
Loading
Loading