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.
@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>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
franciscojavierarceo
left a comment
There was a problem hiding this comment.
I found a few issues that look worth addressing before this lands, mostly around approval defaults, credential handling, request-scoped filtering, and preserving MCP discovery errors.
ashwing
left a comment
There was a problem hiding this comment.
A few things from a fresh pass, beyond the inline notes:
SSRF via allowlisted hostnames (tool/mcp/pool.rs validate_request_server_url + tool/mcp/client.rs:283 pinned_http_client): the allowlist matches on the host string, but pinned_http_client then resolves the domain and pins whatever DNS returns without checking the resolved addresses aren't loopback-adjacent / RFC1918 / 169.254.169.254. The pinning stops rebinding between validate and connect, but an allowlisted name that resolves to an internal address still connects — the existing test only rejects the literal metadata IP, not a domain resolving to it. Since this is the trust boundary, worth rejecting resolved addresses in private/link-local ranges unless explicitly opted in.
#139 rename scope (types/io/output.rs McpToolCall): the item ships server/tool/result; OpenAI's mcp_call uses server_label/name/output. Flagging so #139 covers the field names too, not just the mcp_tool_call->mcp_call type tag — otherwise clients coding to the OpenAI schema still break after the rename.
Minor: the streaming-accumulator test fixture (executor/accumulator.rs:611-614) still emits tool:"read_mcp_resource" items — harmless test data, but it's the exact shape this PR removes, so worth updating so it doesn't imply the path survives.
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
This was addressed during the initial MCP support work, with subsequent SSRF hardening adding an explicit hostname allowlist as the operator trust boundary by fransico; restricting resolved address ranges would be a separate policy change because private MCP servers may be intentional. This PR does not change that behavior from
If is start touching renaming
removed this and some other tests that were referring to |
ashwing
left a comment
There was a problem hiding this comment.
Thanks — this all looks reasonable, approving.
- SSRF: fair — it predates this PR and restricting resolved address ranges is a separate policy call (private MCP servers are a legitimate use). Agreed it's out of scope here.
- Field rename / doc: makes sense to keep the
mcp_callfield+type rename in #139 and have the design doc describe the end-state contract with the sequential-PR note — reads clearly now. require_approval: the scalar"never"path is the right v1, and #145 covers the object/approval-filtering form. One breadcrumb for #145: the object form currently failsMcpToolParamdeserialization and falls through theother -> Unknownarm, so the server is dropped silently rather than erroring — worth making that a clear "approval not yet supported" error there rather than a silent drop.- Fixture cleanup +
tools/listerror propagation look good.
LGTM.
|
@franciscojavierarceo addressed the comments. Could you please check again. thanks |
franciscojavierarceo
left a comment
There was a problem hiding this comment.
Thanks for the updates — the earlier points look addressed. I noticed a few follow-ups while checking the new version; leaving them inline.
| // persistence mode builds and serializes effective tool metadata. | ||
| if let Some(tools) = ctx.enriched_request.tools.as_mut() { | ||
| for tool in tools { | ||
| tool.redact_runtime_credentials(); |
There was a problem hiding this comment.
I think this redaction can still be bypassed by the WebSocket generate: false path. That route forces store = true, then calls resp_handler.execute_turn() directly instead of going through persist_response(), so the MCP authorization and headers can still end up in effective_tools. Could we move the scrub down to where the response metadata is actually written and add a small WebSocket prewarm regression test?
There was a problem hiding this comment.
Fixed by moving credential redaction into TryFrom<&ResponseMetadata> for String, the shared storage serialization boundary used by all persistence paths. Added a WebSocket generate: false regression test confirming MCP headers and authorization are not stored.
franciscojavierarceo
left a comment
There was a problem hiding this comment.
One more thing I noticed in the MCP call path:
| )); | ||
| }; | ||
| let param = mcp_tool_param(&config)?; | ||
| let output = execute_tool_call(client, ¶m.server_label, ¶m.tool_name, &arguments).await?; |
There was a problem hiding this comment.
Could we make the argument handling fail closed before calling the MCP server here? execute_tool_call() currently turns malformed JSON into None, which call_tool treats as a valid no-argument request. That means a tool with optional/default arguments could still run even though the model gave us invalid input. I think we should parse strictly, require an object, and return a ToolError without making the call if parsing fails.
There was a problem hiding this comment.
Added parse_tool_arguments(), which is called first in execute_tool_call() to strictly parse and validate an argument object before any MCP server call is made.
franciscojavierarceo
left a comment
There was a problem hiding this comment.
And one small persistence cleanup:
| // persistence mode builds and serializes effective tool metadata. | ||
| if let Some(tools) = ctx.enriched_request.tools.as_mut() { | ||
| for tool in tools { | ||
| tool.redact_runtime_credentials(); |
There was a problem hiding this comment.
Small follow-up: should we clear discovered_tools here too? Those server-provided schemas get persisted in effective_tools, but the field is skip_deserializing, so we throw them away when reading the record back. Stripping them here would avoid storing potentially large request-scoped data that we can't reuse.
There was a problem hiding this comment.
Renamed redact_runtime_credentials() to sanitize_for_persistence(), which clears both MCP credentials and discovered_tools; it is called when ResponseMetadata is serialized for storage.
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