Skip to content

feat(openai): support deferred resolution for configured MCP connectors - #933

Open
mkoushni wants to merge 39 commits into
praxis-proxy:mainfrom
mkoushni:feat/703-deferred-mcp-connectors
Open

mkoushni wants to merge 39 commits into
praxis-proxy:mainfrom
mkoushni:feat/703-deferred-mcp-connectors

Conversation

@mkoushni

@mkoushni mkoushni commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Praxis connector_id values are pipeline-local. Accepting defer_loading: true without an ownership model would have forwarded those IDs to the inference backend.

  • openai_mcp_tool_resolve accepts deferred configured connectors when the request includes tool_search, injects the configured URL in-memory only, skips eager tools/list, and rewrites the backend body to a sanitized type: mcp stub (no connector_id, URL, authorization, or headers). Sanitization keeps server_description and allowed_callers.
  • openai_agentic_loop extracts completed tool_search_call items into request state on both buffered and streaming rounds (not backend messages). Incomplete searches stay in accumulated output and do not trigger listing.
  • openai_mcp_dispatch loads pending deferred connectors on tool_search_call, lists them concurrently, then dispatches through the existing tools/call path. Successful listings emit Responses input_schema (not MCP inputSchema).
  • Discovery is transactional: list all connectors first, then apply name-collision and max_rewritten_body_bytes checks before mutating mcp_tool_map or output. Failures restore pending connectors.
  • Streaming discovery failures stay on the canonical HTTP 200 SSE lifecycle (response.mcp_list_tools.failed then response.failed) instead of a non-200 error body.
  • Unknown IDs and connector_id + server_url still 400 before outbound activity. Eager connectors and direct server_url are unchanged.

The first inference round still sends the sanitized type: mcp stub plus tool_search; the backend must accept those OpenAI hosted-tool shapes. Any completed tool_search_call loads every pending deferred connector.

Related issue

Closes #703

Validation

  • cargo test -p praxis-ai-apis --lib -- openai_mcp_tool_resolve agentic_loop mcp_dispatch
  • cargo test -p praxis-tests-integration --test suite -- deferred_connector mixed_eager streamed_deferred_connector
  • cargo xtask check-inference and make test-inference-fixtures
  • cargo clippy -p praxis-ai-apis --all-targets -- -D warnings
  • make lint

Checklist

  • I reviewed every changed line and can explain the change.
  • New capabilities include an example config and functional example test.
  • User-facing behavior and generated documentation are updated.
  • Commits are signed and include a Signed-off-by trailer.

Breaking changes

None. Deferred connector_id was previously rejected; it is now accepted only with tool_search and an agentic MCP dispatch loop.

@mkoushni
mkoushni requested review from a team and leseb September 6, 2026 15:41
@praxis-bot-app

praxis-bot-app Bot commented Sep 6, 2026

Copy link
Copy Markdown

Missing Signed-off-by: 8998839. All commits require sign-off (via git commit --signoff).

Praxis connector_id values are pipeline-local, so deferred MCP entries
are resolved in-memory, stripped from the backend body, and listed only
after a tool_search_call instead of forwarding internal IDs to inference.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@mkoushni
mkoushni force-pushed the feat/703-deferred-mcp-connectors branch from 8998839 to c3779ff Compare September 6, 2026 15:43

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

praxis-bot review: feat(openai): support deferred resolution for configured MCP connectors

The security boundary is well-implemented: connector_id, server_url, authorization, and headers are stripped from the outbound body via an allowlist sanitizer (sanitize_deferred_connector_tool), and the transactional discovery design (list all connectors, check collisions and size, then commit) prevents partial state corruption on failure.

Pipeline ordering between openai_mcp_dispatch (runs before openai_agentic_loop in request phase) is load-bearing for correctness -- mcp_dispatch must observe tool_search_calls before prepare_iteration clears them. The YAML config comments document this, which is good.

One finding below.

Comment thread apis/src/openai/responses/agentic_loop/mod.rs
Adding deferred_mcp to the Default inventory pushed cognitive_complexity over 25.

Signed-off-by: mkoushni <mkoushni@redhat.com>
Keep the three call-type vectors reset together so stale tool_search_calls cannot survive a non-dispatchable or error stream round.

Signed-off-by: mkoushni <mkoushni@redhat.com>
…-connectors

Signed-off-by: mkoushni <mkoushni@redhat.com>

# Conflicts:
#	apis/src/openai/responses/openai_mcp_tool_resolve/mod.rs
#	docs/filters/openai_mcp_tool_resolve.md

@leseb leseb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P1 — Deferred discovery is broken for streaming requests.
collect_streaming_output_items:531 never routes tool_search_call into state.tool_search_calls. It falls through as ordinary output, so stream: true exits without running discovery. Add the streaming branch and an end-to-end streaming test.

P1 — Generated mcp_list_tools items violate the OpenAI schema.
mcp_list_tools_item:2052 exposes raw MCP tools containing inputSchema; Responses requires input_schema:42614. Every successful deferred listing is therefore nonconformant and may fail typed SDK parsing.

P1 — Streaming discovery failures bypass the canonical terminal lifecycle.
Deferred discovery calls resolve_error_rejection:293, producing a non-200 SSE error with sequence zero. It does not emit response.mcp_list_tools.failed followed by response.failed, nor persist the failed response object. The later-round path needs the same header-phase finalization used by the initial resolver.

P2 — Non-completed searches can cause network side effects.
The buffered collector queues every tool_search_call:514, including in_progress and incomplete. Only status: completed should trigger tools/list, matching function-call handling.

P2 — Connector discovery is serialized.
prepare_deferred_listings:1903 awaits each server sequentially. Total latency becomes the sum of all server timeouts and can exhaust the iterative-router budget. Independent listings should run concurrently while retaining transactional commit semantics.

P2 — Sanitization silently removes safe, meaningful fields.
The allowlist at sanitize_deferred_connector_tool:1499 drops standard fields such as server_description and allowed_callers. server_description specifically gives the model context needed to choose whether to search.

P2 — Required inference fixture coverage is absent.
This changes backend-facing inference transformation behavior but does not update coverage.yaml or add controlled provider evidence, as required by this repository’s AGENTS.md.

Streaming tool_search_call items never triggered tools/list, and successful
listings plus streaming failures were nonconformant. Queue completed searches,
emit input_schema, use the canonical SSE failure lifecycle, and cover the
first-round sanitization with an inference fixture.

Signed-off-by: mkoushni <mkoushni@redhat.com>
…-connectors

Signed-off-by: mkoushni <mkoushni@redhat.com>
Keep deferred MCP coverage alongside main's chat-tool-echo feature in the committed inventory counts.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@mkoushni

mkoushni commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

P1 — Streaming discovery. collect_streaming_output_items now queues completed tool_search_call items the same way the buffered collector does. Covered by unit tests plus streamed_deferred_connector_loads_on_tool_search.

P1 — Listing schema. Public mcp_list_tools items map MCP inputSchema to Responses input_schema (plus name / description / annotations).

P1 — Streaming failure lifecycle. Deferred listing failures use resolve_error_action during the body pre-read. openai_mcp_dispatch::on_request then emits HTTP 200 SSE with response.mcp_list_tools.failed and response.failed, matching the initial resolver.

P2 — Completed-only searches. Only status: "completed" queues tools/list. In-progress / incomplete items stay in accumulated output and do not trigger network calls.

P2 — Concurrent listings. Independent connectors are listed with try_join_all. Commit is still all-or-nothing: any listing or collision/size failure restores deferred_mcp.

P2 — Sanitization. server_description and allowed_callers are kept. Credentials, URLs, connector_id, and headers are still stripped.

P2 — Inference fixtures. Added responses.agentic.deferred_mcp_connectors with a synthetic first-round sanitization recording (defer_loading so replay never calls tools/list). check-inference and make test-inference-fixtures both pass.

…-connectors

Signed-off-by: mkoushni <mkoushni@redhat.com>

# Conflicts:
#	apis/src/openai/responses/openai_mcp_tool_resolve/mod.rs
…-connectors

Signed-off-by: mkoushni <mkoushni@redhat.com>
@leseb

leseb commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

@mkoushni please rebase

…-connectors

Signed-off-by: mkoushni <mkoushni@redhat.com>

# Conflicts:
#	apis/src/openai/responses/mcp_dispatch/mod.rs
#	apis/src/openai/responses/openai_mcp_tool_resolve/tests.rs
#	apis/src/openai/responses/state.rs
#	tests/integration/fixtures/inference/README.md
#	tests/integration/tests/suite/examples/openai_agentic_loop.rs
#	tests/utils/src/inference_fixture/coverage.rs
@mkoushni
mkoushni requested a review from leseb September 9, 2026 13:43
Keep deferred MCP coverage alongside main's streaming web_search fixture.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@mkoushni
mkoushni requested a review from a team as a code owner September 10, 2026 08:10
…-connectors

Signed-off-by: mkoushni <mkoushni@redhat.com>
Keep deferred MCP discovery on the batched tool-call dispatch path from praxis-proxy#1027.

Signed-off-by: mkoushni <mkoushni@redhat.com>
After praxis-proxy#1027 the agentic loop no longer forces parallel_tool_calls=false,
so the first-round upstream body keeps string input. Point the file-url
SSRF example at TEST-NET instead of live cloud IMDS.

Signed-off-by: mkoushni <mkoushni@redhat.com>
Signed-off-by: mkoushni <mkoushni@redhat.com>

@leseb leseb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocker: a round mixing tool_search_call with a client-owned function call bypasses the mixed-ownership guard and sends the unresolved client call back to inference. See agentic_loop/mod.rs:591.

Streaming correctness: successful locally generated mcp_list_tools items appear only in the final snapshot; their incremental added/in_progress/completed/done events are never emitted. See openai_mcp_tool_resolve/mod.rs:2040.

Small harness hardening: replay now unconditionally considers openai_mcp_tool_resolve safe, which could permit eager tools/list calls in future fixtures. See replay.rs:431.

Signed-off-by: mkoushni <mkoushni@redhat.com>
Hosted tool_search_call mixed with a client function_call must fail
before deferred discovery can send the unresolved client call back to
inference. Successful mcp_list_tools items now record stream-events
provenance so their incremental lifecycle is synthesized, and replay
rejects eager MCP tools/list even when the connector URL is replay-owned.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@mkoushni

Copy link
Copy Markdown
Contributor Author

Blocker — mixed ownership. Hosted tool_search_call now counts as server-owned in has_mixed_function_call_ownership. A completed search mixed with a client function_call is rejected with 502 before deferred discovery can send the unresolved client call back to inference. A search by itself still loops.

Streaming — mcp_list_tools lifecycle. Successful deferred listings are recorded in locally_executed_output_items, and openai_stream_events treats mcp_list_tools as a local item. Clients now get added → mcp_list_tools.in_progress → completed → done, not only the final snapshot.

Harness — replay gating. openai_mcp_tool_resolve is no longer on the unconditional allowlist. Replay still admits it for deferred sanitization, but any scenario request with an MCP tool that would eager-list (defer_loading missing or false) fails with scenario MCP listing is not replay-contained.

@leseb leseb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

almost there;

  • A completed tool_search_call with execution: "client" is still queued for server-side connector discovery. It should be returned to the caller without tools/list or another inference round. See agentic_loop/mod.rs:595.

  • Deferred discovery bypasses max_tool_calls. Even with the response-wide built-in-tool budget exhausted, it performs tools/list and continues inference. See openai_mcp_tool_resolve/mod.rs:1935.

Signed-off-by: mkoushni <mkoushni@redhat.com>
Return client-executed tool_search_call items to the caller without
tools/list, and gate deferred connector discovery on the shared
built-in tool budget so an exhausted max_tool_calls cap cannot list
or continue inference.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@mkoushni

Copy link
Copy Markdown
Contributor Author

Client-owned tool_search_call: a completed search with execution: "client" is still returned to the caller (accumulated output + persisted messages), but it is no longer queued for connector discovery. The loop exits without tools/list or another inference round. Hosted completed searches still queue and loop as before.

max_tool_calls: deferred discovery now shares the response-wide built-in budget. If that budget is already exhausted — including when an earlier call in the same round took the last slot — has_pending_deferred_discovery is false, discover_deferred_connectors does not call tools/list, and both the agentic loop and MCP dispatch return done instead of continuing inference.

@leseb leseb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please fix the CI failures, also as a follow up please do those two small correctness cleanups: over-budget searches remain completed, and deferred listings omit error: null.

Thanks!

@leseb
leseb enabled auto-merge September 14, 2026 09:50
The deferred MCP fixture was recorded but the snapshot test and generated
README still expected 24 features, failing lint, test, and coverage CI.
Ratchet those to 25/25/30. Mark hosted tool_search_call items incomplete
when max_tool_calls is exhausted, and omit error:null from successful
mcp_list_tools listings.

Signed-off-by: mkoushni <mkoushni@redhat.com>
auto-merge was automatically disabled September 14, 2026 11:26

Head branch was pushed to by a user without write access

Keep deferred MCP discovery on the praxis-proxy#1046 agentic-loop owner: hosted
tool_search_call items loop only when connectors remain to list, and
over-budget searches surface as incomplete.

Signed-off-by: mkoushni <mkoushni@redhat.com>
The OpenResponses CLI launches supported templates concurrently, and IRR's
hardcoded 30s idle timeout drops a starved SSE stream on Qwen CPU. Run
streaming-response alone first, and regenerate stale mcp_dispatch docs.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@mkoushni
mkoushni requested a review from leseb September 14, 2026 12:59
@leseb

leseb commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

@mkoushni you have conflicts

@leseb
leseb enabled auto-merge September 14, 2026 14:19
…-connectors

Signed-off-by: mkoushni <mkoushni@redhat.com>

# Conflicts:
#	apis/src/openai/responses/openai_mcp_tool_resolve/mod.rs
auto-merge was automatically disabled September 14, 2026 16:56

Head branch was pushed to by a user without write access

cargo audit fails on RUSTSEC-2026-0285; 0.23.45 rejects TLS 1.3
handshake messages sent at the wrong encryption level.

Signed-off-by: mkoushni <mkoushni@redhat.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(openai): support deferred resolution for configured MCP connectors

3 participants