fix: revisit MCP support, remove MCP resources, support only discover and execute native tools [1/2]#138
Conversation
…ute native tools Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
There was a problem hiding this comment.
Went through this end-to-end against the OpenAI type:mcp contract and our Messages side. Direction is right — rebasing on server_label + tools/list + allowed_tools + tools/call and dropping the synthetic read_mcp_resource bridge is the correct contract, and the executor came out protocol-neutral (our Messages stream loop already consumes ToolRegistry::dispatch). A few things worth resolving before merge; details inline plus one below.
Output item type vs the design doc (output.rs is outside this diff so noting here): the code emits type: "mcp_tool_call" (fields server/tool/result), but the design doc specifies OpenAI's mcp_call (server_label/name/output) and states "The current gateway work implements the mcp_call lifecycle" (mcp-gateway-integration.md:59) — which doesn't match what ships. Either reconcile the doc to call mcp_tool_call the interim shape pending #139, or land the mcp_call rename here. With no external consumers yet, renaming now avoids shipping a non-conformant item type that clients parse and then breaks under them in #139.
| @@ -29,7 +29,6 @@ pub enum McpOperation { | |||
| Connect, | |||
There was a problem hiding this comment.
MCP discovery (tools/list) runs inside build_with_handlers on the request hot path with a 30s connect + 60s list timeout, and a failure propagates via ? (engine.rs), failing the entire turn — including any healthy servers declared alongside. That's asymmetric with the tool-call path, whose contract is "a hung/failed tool becomes an error fed back to the model, never a whole-request failure." A client can declare a black-hole server_url (e.g. an allowlisted localhost that accepts the socket but never handshakes) and hold a handler slot ~90s per dead server. Worth deciding explicitly: degrade (drop the unreachable server, surface it as an error item) vs fail-fast with a tighter discovery deadline + documented rationale. Servers are also discovered sequentially in registry.rs, so N dead servers stack additively — try_join_all would bound it.
| .is_some() | ||
| { | ||
| tracing::warn!(name = %param.name, "duplicate MCP tool name — previous definition overwritten"); | ||
| tracing::warn!(name = %internal_name, "duplicate discovered MCP tool name — previous definition overwritten"); |
There was a problem hiding this comment.
Cross-server internal-name collision is silently resolved by overwrite. internal_mcp_tool_name's dedup map is created fresh per server (handler.rs:113), so it only guards collisions within one server. __ is legal in both server_label and tool names and is also the mcp__{server}__{tool} delimiter, so two distinct (server, tool) pairs can collide — e.g. label foo + tool bar__baz and label foo__bar + tool baz both normalize to mcp__foo__bar__baz. Here that just warns and overwrites, so the model sees one tool routing to whichever server registered last — a silent misroute that sends arguments to the wrong server. You already hard-reject duplicate server_labels a few lines up; extending that to derived names (a shared dedup map across servers, or escalating this warn to a Config error) would close it.
There was a problem hiding this comment.
Fixed cross-server MCP tool-name collisions.
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub allowed_tools: Option<Vec<String>>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub require_approval: Option<String>, |
There was a problem hiding this comment.
require_approval is parsed but never read in execution, so a client sending require_approval: "always" gets silent auto-execution of remote MCP tools — the inverse of what they asked for. The full approval loop (mcp_approval_request/response) can wait for a later PR, but accept-and-ignore on a safety-gating field shouldn't ship: I'd reject with a clear "approval gating not yet supported" until it's honored. Same accept-and-ignore class applies to connector_id (modeled, never consumed).
There was a problem hiding this comment.
fixed rejected unsupported MCP approval and connector options.
| } | ||
|
|
||
| #[derive(Deserialize)] | ||
| #[serde(deny_unknown_fields)] |
There was a problem hiding this comment.
deny_unknown_fields with no extra capture makes this the strictest of the tool params — FunctionToolParam/CustomToolParam use #[serde(flatten)] extra. MCP is the one tool type OpenAI extends most often (authorization, connector_id, read_only all arrived incrementally), so this is the wrong place to be strictest: server_description (a currently-valid OpenAI field) already 4xxes here, and the object forms of allowed_tools ({tool_names:[...]}) and require_approval are rejected rather than tolerated. For a gateway claiming OpenAI as ground truth I'd flip to capture-and-preserve so a new OpenAI field degrades to ignore instead of hard-fail.
There was a problem hiding this comment.
Removed McpToolParamWire and deny_unknown_fields; McpToolParam now ignores unknown fields, preventing new OpenAI MCP fields from causing 4xx errors.
| }, | ||
| handler: Arc::new(factory.tool_call(Arc::clone(client))), | ||
| }); | ||
| let mut internal_names = HashMap::new(); |
There was a problem hiding this comment.
Forward-looking note, not a blocker: the MCP executor here (McpClient/McpHandler/dispatch) is nicely protocol-neutral and our Messages stream loop already calls ToolRegistry::dispatch — but discovery is triggered from a ResponsesTool::Mcp walk with a &mut write-back into the payload, and public-item construction lives only in gateway.rs (Responses). When #139 does the mcp_call rename, could the identity/serialization land in tool/mcp/handler.rs (neutral) rather than only gateway.rs? Keeps it reusable if the Messages path eventually grows gateway-owned MCP, and avoids a second hand-parallel construction.
There was a problem hiding this comment.
Absolutely, the follow-up MCP call PR keeps identity resolution and McpCall output-item construction in tool/mcp/handler.rs. executor/gateway.rs only handles the Responses-specific lifecycle and SSE envelope. This keeps the MCP execution and public-item construction reusable if the Messages path later supports gateway-owned MCP tools.
@ashwing I would still prefer to keep the I will address your following comments in a bit and push commit. Thanks ashwin |
…leanup Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Summary
This PR revisits the gateway's MCP support following a comparison with the OpenAI Responses MCP contract.
The previous implementation was largely based on Codex behavior. Codex maintains its own MCP client lifecycle and supports application-controlled MCP resources through client-side operations such as
read_mcp_resource. That behavior does not map directly to the gateway's OpenAI-compatibletype: "mcp"contract.For simplicity and a clearer responsibility boundary, the gateway will not support MCP resources for now. Resource discovery, selection, and reading should remain client-controlled behavior in applications such as Codex and Claude Code. Resource support can be revisited later as a separate host-facing context design.
The updated architecture is documented in
docs/design/mcp-gateway-integration.md.This PR:
read_mcp_resourcefunction bridge andresources/readexecution.read_mcp_resourceas an ordinary client-executed function.type: "mcp"declarations usingserver_labeland connection information without a client-supplied tool name.tools/list.allowed_toolsfiltering to discovered tools.ToolRegistry.server_label.tools/call.This PR intentionally retains the existing
mcp_tool_callpublic output item and streaming lifecycle. A follow-up PR will align public output items and SSE events with OpenAI'smcp_callcontract.Important Note:
MCP name collision handling: MCP declarations no longer expose the non-standard
mcp.namefield. MCP internal names are derived only after tool discovery, so Codex namespace resolution does not inspect MCP names. During registry construction, discovered MCP entries are checked against all registered tools, rejecting collisions across MCP servers and with existing tool entries regardless of declaration order.Test Plan
cargo check --workspacecargo test --workspacecargo clippy --all-targets -- -D warningscargo fmt -- --check