Skip to content

feat(filter): add mcp_tool_resolve filter for MCP tool discovery#331

Open
leseb wants to merge 5 commits into
praxis-proxy:mainfrom
leseb:leseb/issue-43-mcp_tool_resolve
Open

feat(filter): add mcp_tool_resolve filter for MCP tool discovery#331
leseb wants to merge 5 commits into
praxis-proxy:mainfrom
leseb:leseb/issue-43-mcp_tool_resolve

Conversation

@leseb

@leseb leseb commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Filter 18 (mcp_tool_resolve) which resolves MCP tool entries in Responses API tools arrays into concrete tool definitions by calling tools/list on upstream MCP servers.

Key capabilities

  • MCP client module (apis/src/mcp_client/): Streamable HTTP transport via rmcp, with SSRF validation (loopback, link-local, unspecified, cloud metadata IPs, AWS IMDS IPv6), DNS pinning to prevent rebinding, URL userinfo rejection, hop-by-hop/framing header blocking, and reserved prefix stripping (x-praxis-*, x-mcp-*, x-a2a-*)
  • Tool resolution: Paginated tools/list with configurable max_tools cap (default 128), max_servers limit (default 10), and per-server timeout (default 5s)
  • Cache layers: Within-request dedup by (label, url) and cross-request reuse via previous_tools from rehydrated responses, with label-only matching for real API shapes
  • Filtering: Supports both string-array and MCPToolFilter object forms of allowed_tools, including read_only annotation filtering
  • OAuth: Dedicated authorization field injected as Bearer token; Authorization in headers is stripped
  • Continuation safety: Skips connector_id-only entries, defer_loading: true entries, and entries without server_label

Files changed (23)

  • New mcp_client module with full SSRF/DNS/transport hardening
  • New mcp_tool_resolve filter with config, resolution logic, and 113 unit tests
  • Extended ResponsesState with mcp_tool_map and previous_tools fields
  • Extended rehydrate to persist server_url in mcp_list_tools items
  • Example config, integration tests, and generated filter docs

Closes #43

Resolves MCP tool entries in Responses API requests by calling
tools/list on upstream MCP servers. Includes SSRF validation,
MCPToolFilter object support, OAuth authorization token injection,
example config, and integration tests.

Closes praxis-proxy#43

Signed-off-by: Sébastien Han <seb@redhat.com>
@leseb leseb requested review from a team and crstrn13 July 10, 2026 14:46
@praxis-bot-app

Copy link
Copy Markdown

PR too large: 2449 lines added (limit: 750, excludes Cargo files, tests, docs, examples, and benchmarks). Please split into smaller PRs. Add skip/pr-conventions label to override.

CI requires MAJOR.MINOR.PATCH format for dependency versions.

Signed-off-by: Sébastien Han <seb@redhat.com>
Comment thread apis/src/openai/responses/mcp_tool_resolve/mod.rs
Comment thread apis/src/mcp_client/mod.rs
Comment thread tests/integration/tests/suite/mcp_tool_resolve.rs
Comment thread tests/integration/tests/suite/mcp_tool_resolve.rs
Comment thread apis/src/mcp_client/mod.rs Outdated
leseb added 2 commits July 10, 2026 19:33
- Add allow_loopback config option to permit connections to
  127.0.0.0/8, ::1, and localhost for development environments
- Make inject_authorization return explicit InvalidAuthorization error
  on invalid header characters instead of silently dropping
- Add integration tests against mock MCP server: tools/list succeeds,
  TooManyTools rejected, resolved body echoed through responses_proxy,
  invalid authorization rejected
- Document openai_responses_rehydrate dependency in example config
- Add mcp_tool_resolve to full-flow.yaml pipeline
- Regenerate filter docs with new allow_loopback field

Signed-off-by: Sébastien Han <seb@redhat.com>

@praxis-bot praxis-bot 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.

Review: mcp_tool_resolve filter (Filter 18)

Solid implementation with thorough SSRF hardening, DNS pinning, and extensive test coverage across unit, integration, and example-config layers. The security model is well-considered (hop-by-hop stripping, reserved prefix blocking, redirect prohibition, proxy env suppression).

Four issues identified below.

Severity Count
Critical 0
Large 0
Medium 4

Reviewed files: apis/src/mcp_client/mod.rs, apis/src/mcp_client/tests.rs, apis/src/openai/responses/mcp_tool_resolve/mod.rs, apis/src/openai/responses/mcp_tool_resolve/config.rs, apis/src/openai/responses/mcp_tool_resolve/tests.rs, apis/src/openai/responses/state.rs, apis/src/openai/responses/rehydrate/mod.rs, Cargo.toml, apis/Cargo.toml, server/src/lib.rs, tests/integration/tests/suite/mcp_tool_resolve.rs, tests/integration/tests/suite/examples/mcp_tool_resolve.rs, examples/configs/openai/responses/mcp-tool-resolve.yaml, examples/configs/openai/responses/full-flow.yaml

url: &str,
) -> Result<Vec<rmcp::model::Tool>, McpClientError> {
let mut all_tools = Vec::new();
let mut cursor = None;

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.

[Medium] paginate_tools has no maximum iteration count. A hostile MCP server can return empty pages (zero tools) with a valid next_cursor on every response, causing this loop to run indefinitely. The max_tools guard only fires when tools accumulate, so empty-page responses bypass it entirely. The per-page timeout bounds each individual request but not the total number of iterations.

Add a max_pages constant (e.g., const MAX_PAGES: usize = 100;) and break with a TooManyTools or new TooManyPages error after that many iterations:

let mut page_count = 0usize;
loop {
    page_count += 1;
    if page_count > MAX_PAGES {
        return Err(McpClientError::TooManyTools {
            url: url.to_owned(),
            count: all_tools.len(),
            max: max_tools,
        });
    }
    // ... existing page fetch ...
}

ctx: &mut HttpFilterContext<'_>,
body: &[u8],
) -> Result<FilterAction, ResolveError> {
let mcp_entries = extract_mcp_entries(body);

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.

[Medium] The request body is deserialized twice: once here in extract_mcp_entries and again in write_tool_map (line 338) via serde_json::from_slice. With max_body_bytes defaulting to 64 MiB, this doubles the parsing cost on large payloads.

Refactor resolve_mcp_tools to parse the body once and thread the parsed serde_json::Value through to both extract_mcp_entries (change it to accept &serde_json::Value) and write_tool_map (pass the already-parsed value instead of raw bytes).


/// Build tool map from all MCP entries, resolving each
/// server once and applying per-entry filters.
async fn build_tool_map(

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.

[Medium] build_tool_map resolves MCP servers sequentially. With max_servers: 10 and timeout_ms: 5000, a request referencing 10 slow servers blocks for up to 50 seconds of serial I/O. Since each server resolution is independent, use tokio::JoinSet (or futures::future::join_all) to resolve servers concurrently, bounded by max_servers.

This matters in production where users specify multiple MCP servers and tail latency on any single server currently gates the entire pipeline.

Comment thread Cargo.toml
regex = "1.12.4"
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls"] }
reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] }
rmcp = { version = "2.0.0", default-features = false, features = ["client", "transport-streamable-http-client-reqwest", "reqwest"] }

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.

[Medium] The workspace dependency specifies version = "2.0.0" but Cargo.lock resolves to 2.2.0. Project convention requires full semver with patch level matching the resolved version (e.g., reqwest on line 38 uses "0.13.4" exactly). Change to version = "2.2.0" to match the lockfile and follow the established pattern.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Filter 18: mcp_tool_resolve

3 participants