Skip to content

feat(web-search): SearXNG-backed web_search tool (slice 1 of MCP/A2A gateway ADR) - #1009

Merged
seonghobae merged 4 commits into
mainfrom
feat/web-search-mcp-a2a-foundation
Sep 3, 2026
Merged

seonghobae merged 4 commits into
mainfrom
feat/web-search-mcp-a2a-foundation

Conversation

@seonghobae

@seonghobae seonghobae commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

The product owner asked for contextual-orchestrator to become an MCP
Gateway
and an A2A Gateway so internal agents (Strix, Noema) can search
the web on another agent's behalf, decide when external search is needed,
and verify claims — a "Perplexity-search-like emulation" for Strix, a "Web
Search Tool" for Noema — backed by SearXNG plus other self-hostable
metasearch engines, with Camoufox for browsing, isolated via
ContextualWisdomLab/quarantine-sandbox-runtime.

That request bundles four separable pieces. This PR ships the one piece that
can run today for real, and records the rest as an honest, reconstructable
design rather than faking it:

  • Web-search tool (this PR): contextual_orchestrator/web_search.py
    web_search(query, ...) -> list[WebSearchResult], backed by a self-hosted
    SearXNG(-compatible) instance's GET /search?format=json API.
    KV-credential-configured (SEARXNG_URL, optional SEARXNG_TOKEN), same
    pattern as every other provider secret in this repo. Reuses
    ModelClient._validate_provider / _open_provider for its SSRF-safe HTTP
    boundary — the same primitive privacy_policy_analysis.crawl_policy_document
    already uses for Wardnet — rather than adding a new HTTP dependency or a
    second hand-rolled egress check.
  • 📄 MCP Gateway, A2A Gateway, Camoufox browsing + isolation — design
    only, in docs/adr/0123-web-search-mcp-a2a-gateway-foundation.md. Not
    built here.

Why not build the rest now

  • quarantine-sandbox-runtime isn't ready. Verified live
    (gh api repos/ContextualWisdomLab/quarantine-sandbox-runtime/pulls):
    develop is a stub; real work is an unmerged Draft PR stack (#1#6#9#10#13,
    all open); the crate has no HTTP/CLI entrypoint or container backend, only
    a Rust library with a newly-added CommandExecutionBackend contract not
    yet wired to anything real; real container isolation is externally blocked
    on ContextualWisdomLab/.github#1590 (no LSM-capable CI runner).
  • Camoufox session isolation already ships, today, via Wardnet — not
    quarantine-sandbox-runtime.
    compose.camoufox-wardnet.yaml +
    privacy_policy_analysis._render_policy_document_with_camoufox is a real,
    reviewed, tested MCP client that renders JS-heavy pages through a
    DNS-pinned Wardnet egress proxy, for one purpose (privacy-policy ZDR
    discovery). The ADR records this and recommends extending that boundary
    when Camoufox browsing is built for search fact-checking, rather than
    silently picking quarantine-sandbox-runtime per the letter of the
    original request when a working answer to the actual problem (network
    isolation for a browser) already exists and is deployed.
  • No concrete MCP/A2A caller exists yet. Strix and Noema have no
    MCP-resolving harness (unlike OpenCode Review, which resolves
    opencode.jsonc's MCP servers directly) — that's why this request is
    "add it to contextual-orchestrator," not duplicate work. But building
    protocol-gateway plumbing before a real caller is wired up to use it would
    be exactly the speculative abstraction this repo's own conventions reject.

Duplicate-work check

gh pr list --repo ContextualWisdomLab/contextual-orchestrator --state open --search "mcp OR a2a OR search OR searxng OR camoufox" and a repo-wide grep
for mcp, a2a, searxng, web_search, perplexity found no open PR and
no existing product code for this capability. The only web_search hits are
server.py's honesty gate, which rejects a caller-supplied OpenAI-style
web_search_options chat-completions parameter — unrelated, and unaffected
by this change (this tool is a separate, explicitly-invoked function, never
an implicit chat-completions parameter).

Research (full detail: docs/library_research.md)

  • SearXNG (AGPL-3.0, verified live) — implemented.
  • Whoogle (MIT) — evaluated, rejected: archived 2026-08-14, defunct since
    Google closed its scraping workaround in 2024.
  • YaCy (GPL-2.0-or-later, verified live) — documented as the next
    self-hosted engine (own crawled P2P index, JSON/XML API); not implemented
    for lack of a deployment to test against.
  • Brave Search API — documented commercial fallback; not implemented.
  • Camoufox (MPL-2.0, verified live) — Playwright-compatible Firefox
    fork, no official MCP server (the camofox-mcp this repo already pins
    by digest is a third-party wrapper).

ADR 0123 (DDD per docs/product-goal-directive.md §5)

Records Subdomain classification (core/supporting/generic), Bounded
Contexts (Web Search / Provider Routing / Privacy Policy Analysis / future
MCP Gateway / A2A Gateway / Camoufox Browsing), a Context Map (Anti-Corruption
Layer to SearXNG, minimal Shared Kernel to ModelClient's transport
primitives, Conformist relationships to the external MCP and A2A specs),
Ubiquitous Language (disambiguating "MCP Gateway" from the existing
per-model "tool calling" capability detection in chat_capability.py), and
the Value-Object/Domain-Service/Aggregate/Invariant mapping for
web_search() — plus an explicit "why no Aggregate/Repository/Domain Event
yet" note (nothing persists a search; premature to model one).

Test plan

  • pytest tests/test_web_search.py — 21 tests, all pass; no real
    network calls (mocked ModelClient._open_provider / _resolve_addresses,
    matching test_privacy_policy_analysis.py's existing convention).
  • coverage run --branch --source=contextual_orchestrator.web_search -m pytest tests/test_web_search.py
    → 100% statement, 100% branch.
  • interrogate contextual_orchestrator/web_search.py → 100%.
  • Full suite: pytest tests -q → 3327 passed, 2 skipped, 0 failed.
  • interrogate . (whole repo) → 100% docstring coverage maintained.
  • py_compile on the new module and test file.

🤖 Generated with Claude Code


Devin Review

…A2A gateway ADR)

Ships the first real slice of the MCP Gateway / A2A Gateway / web-search
request: a KV-credential-configured web_search() client for a self-hosted
SearXNG(-compatible) metasearch instance's JSON API, reusing the existing
SSRF-safe transport boundary (ModelClient._validate_provider/_open_provider)
rather than adding a new HTTP dependency. 100% statement/branch/docstring
coverage on the new module; full suite (3327 tests) still passes.

The MCP gateway, A2A gateway, and Camoufox-browsing pieces of the original
request are recorded as a full-vision design in ADR 0123 (DDD Bounded
Context / Context Map / Ubiquitous Language per product-goal-directive.md
§5) but deliberately not built here: quarantine-sandbox-runtime (the
requested isolation backend) is verified still stub-only (develop branch has
no HTTP/CLI entrypoint, real work lives on unmerged Draft PR stack #1-#13,
blocked on .github#1590), and there is no concrete Strix/Noema caller yet to
justify building protocol-gateway plumbing against. The ADR also documents
that Camoufox session isolation already ships today via Wardnet for one
narrow use (privacy_policy_analysis.py), a fact worth surfacing before
committing to a different isolation backend for the same problem.

Research: SearXNG (AGPL-3.0) implemented; Whoogle (MIT) evaluated and
rejected as archived/defunct since 2024; YaCy (GPL-2.0-or-later) documented
as the next self-hosted engine; Brave Search API documented as the
commercial fallback. Camoufox confirmed MPL-2.0, Playwright-compatible, no
official MCP server (the camofox-mcp this repo already pins is third-party).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 54 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 1678c8e6-cf0f-4e6c-9975-d882015508f9

📥 Commits

Reviewing files that changed from the base of the PR and between 212ff43 and 16107c5.

📒 Files selected for processing (9)
  • CHANGELOG.d/web-search-searxng-client.md
  • contextual_orchestrator/web_search.py
  • docs/adr/0123-web-search-mcp-a2a-gateway-foundation.md
  • docs/adr/README.md
  • docs/kv-credentials.md
  • docs/library_research.md
  • fuzz/targets.py
  • tests/fuzz/test_fuzz_properties.py
  • tests/test_web_search.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 4 potential issues.

Devin Review

Comment thread contextual_orchestrator/web_search.py Outdated
Comment thread contextual_orchestrator/web_search.py
Comment thread contextual_orchestrator/web_search.py
headers=headers,
method="GET",
)
with client._open_provider(request, destination, timeout=timeout) as response:

@devin-ai-integration devin-ai-integration Bot Sep 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟨 HTTP errors bypass response limits

A SearXNG endpoint can return an oversized HTTP error body. _open_provider buffers it without a limit, allowing remote memory exhaustion.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed real, but not fixed in this PR — it's out of scope for this diff. The unbounded read is inside ModelClient._open_provider itself (orchestrator.py): on any HTTP status >= 400 it does a plain body = response.read() with no size bound before raising HTTPError, for every caller of _open_provider across the codebase, not something specific to web_search.py. This PR's diff never touches orchestrator.py, and _open_provider has no way for a caller like web_search() to intervene — the unbounded read already happened by the time control returns.

Fixing it properly means bounding that read inside _open_provider itself (mirroring the MAX_DISCOVERY_RESPONSE_BYTES bounded-read-then-check pattern model_discovery.py already uses), which is shared transport infrastructure several other currently-open PRs are also editing. Doing that here would be scope creep beyond this PR's stated "slice 1, web-search tool only" contract and adds avoidable merge-conflict risk. Leaving this thread open/unresolved for a follow-up PR against _open_provider directly rather than fixing it unilaterally in this one.


Generated by Claude Code

@seonghobae seonghobae added area: api API, protocol, event, or external contract enhancement New feature or request priority: high status: needs-review Open pull request requiring current-head review or checks type: feature New or expanded product capability labels Sep 2, 2026 — with ChatGPT Codex Connector
…2a-foundation

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4

Copy link
Copy Markdown
Contributor Author

Rebased onto current main

This PR was stuck mergeable_state: behind against a stale base (8839081) while main had advanced to 212ff437 (fix(admin): refresh audit after model-group mutations, plus the rater-observation/criterion-binding/review-gateway credential-array work). Merged origin/main into this branch — clean, no conflicts (the touched files don't overlap).

noema-review failure cause (not a code issue)

The failed noema-review check on the previous head was a review-infrastructure hiccup, not a finding against this diff:

Noema model-output repair remained invalid; initial failure: HTTP Error 502: Bad Gateway;
repair failure: Noema request_changes requires a confirmed probe on a published finding

i.e. the orchestrator backend returned a 502 during the review call, and the deterministic repair path correctly refused to synthesize a finding without a probed, source-line-bound observation. Every other check on that head (Full unit and contract suite, coverage/docstrings, Hypothesis, Atheris, Semgrep, CodeQL, Trivy, Scorecard, dependency-review, osv-scan) was green.

Local verification after the merge (Python 3.12 venv, requirements.lock + pip install --no-deps -e .)

  • pytest tests/test_web_search.py → 21 passed
  • coverage run --branch --source=contextual_orchestrator.web_search -m pytest tests/test_web_search.py100% statement, 100% branch
  • interrogate contextual_orchestrator/web_search.py and interrogate . (whole repo) → 100% docstring coverage
  • py_compile on contextual_orchestrator/web_search.py and tests/test_web_search.py → OK
  • Merge-diff-touched test files also re-run: test_rater_observation.py, test_rater_observation_criterion_binding.py, test_rater_observation_trusted_binding.py, test_review_gateway.py, test_review_gateway_credential_array.py, test_admin_contract.py, test_chat_model_capability_isolation.py → 88 passed, 1 failed. The one failure, test_admin_contract.py::test_model_group_mutations_refresh_audit_events, is the known pre-existing sandbox-only NameError: name 'json' is not defined on current main (already fixed separately in PR fix(admin): repair test_model_group_mutations_refresh_audit_events #1029) — not a regression from this merge.

Pushed directly to feat/web-search-mcp-a2a-foundation (no force-push; fast-forward merge commit on top of the existing single feature commit).


Generated by Claude Code

…the parser

Three unresolved Devin review findings on this PR:

- _searxng_origins() discarded SEARXNG_URL's path component when building
  request_base_url, so an instance reverse-proxied under a prefix (e.g.
  https://host/searxng) was queried at the host root (https://host/search)
  instead of its own /search endpoint. Preserve parsed.path.

- web_search()'s timeout parameter had no validation: a bool, non-positive,
  NaN, or infinite value reached socket setup and failed inconsistently
  instead of a stable ValueError. Validate it the same way
  orchestrator._validate_provider_probe_timeout() already does (reject
  bool/non-numeric, require a finite value in a bounded range).

- _parse_results(), an untrusted-JSON parsing seam (SearXNG's response
  body), had no fuzz coverage, violating this repo's "new parsing seams
  get a fuzz/targets.py target" convention (AGENTS.md/CLAUDE.md). Added
  exercise_web_search_results() plus shaped and arbitrary-JSON Hypothesis
  properties.

The fourth finding on this PR ("HTTP errors bypass response limits" --
ModelClient._open_provider does an unbounded response.read() on any >=400
status) is real but lives entirely in shared orchestrator.py transport
code this PR's diff never touches, not in web_search.py; replied on that
thread rather than fixing it here to avoid scope creep into a file several
other concurrently open PRs are also editing.

Verification:
- RED: confirmed the subpath bug manually against unfixed _searxng_origins()
  (returned the host root, dropping /searxng).
- GREEN: tests/test_web_search.py (33 tests, up from 21) and
  tests/fuzz/test_fuzz_properties.py (21 tests, up from 19) all pass.
- ruff clean on all four touched files.
- interrogate on web_search.py + fuzz/targets.py: 100%.
- py_compile clean; git diff --check clean.
- tests/test_conventions.py and the broader web_search/fuzz-keyword slice
  of the full suite (65 tests) pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4

Copy link
Copy Markdown
Contributor Author

Triage: fixed three of four unresolved Devin findings

No merge conflict against main (git merge-tree clean) and no required check is actually failing — every queued job matches this org's known CI-runner-capacity backlog (queued since the last push; strix cancelled by the supersede-job as expected). Four Devin findings on contextual_orchestrator/web_search.py were unresolved and unaddressed; fixed three, left one open with an explanation.

Fixed (89413390)

  1. Subpath deployments query host root. _searxng_origins() dropped SEARXNG_URL's path component, so https://host/searxng was queried at https://host/search instead of https://host/searxng/search. Now preserves parsed.path.
  2. Timeout validation was missing. timeout accepted booleans, non-positive values, NaN, and infinity, reaching socket setup and failing inconsistently. Now validated the same way orchestrator._validate_provider_probe_timeout() validates provider probe timeouts (bounded, finite, non-bool).
  3. Search parser lacked fuzz coverage. Added exercise_web_search_results() to fuzz/targets.py (item 12) driving _parse_results() — an untrusted-JSON parsing seam — over arbitrary decoded values, plus shaped + arbitrary Hypothesis properties in tests/fuzz/test_fuzz_properties.py, matching this repo's existing convention for new parsing seams.

Left open, with a reply explaining why

  1. HTTP errors bypass response limits. Real: ModelClient._open_provider does an unbounded response.read() on any HTTP status >= 400, for every caller across the codebase — but that's shared transport code in orchestrator.py this PR's diff never touches (and web_search.py has no way to intervene; the unbounded read already happens inside _open_provider before control returns). Fixing it properly belongs in _open_provider itself and touches a file several other concurrently open PRs are also editing, so it's flagged for a dedicated follow-up rather than folded into this "slice 1, web-search only" PR.

Verification

  • Manually confirmed the subpath bug against the unfixed code first (_searxng_origins() returned the host root for a configured .../searxng URL).
  • tests/test_web_search.py: 33 passed (up from 21). tests/fuzz/test_fuzz_properties.py: 21 passed (up from 19).
  • ruff check clean on all four touched files; interrogate -c pyproject.toml on web_search.py + fuzz/targets.py: 100%.
  • py_compile clean; git diff --check clean.
  • test_conventions.py (naming) passes; a -k "web_search or fuzz" slice of the full suite (65 tests) passes.

Pushed as a normal, non-force commit on top of the existing head; no history rewritten.


Generated by Claude Code

devin-ai-integration[bot]

This comment was marked as resolved.

An integer timeout too large for float() (e.g. 10**400) made
math.isfinite(timeout) raise OverflowError instead of the ValueError the
function promises, per Devin Review on this PR. The bounds check against
finite MIN_TIMEOUT_SECONDS/MAX_TIMEOUT_SECONDS already implies finiteness
(NaN and +/-infinity fail every comparison) and, unlike math.isfinite(),
Python's int/float comparison never raises OverflowError on an
arbitrarily large int -- so dropping the now-redundant isfinite() call
both fixes the bug and removes a branch instead of adding one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@seonghobae

Copy link
Copy Markdown
Contributor Author

Fixed the last open finding on this PR's own diff at 16107c51: "Huge integer timeouts escape validation"math.isfinite(timeout) raises OverflowError (not the promised ValueError) for an int too large to convert to a C double (e.g. 10**400).

Fix: dropped math.isfinite() entirely rather than wrapping it in a try/except. The existing bounds check (MIN_TIMEOUT_SECONDS <= timeout <= MAX_TIMEOUT_SECONDS, both finite) already implies finiteness — NaN and ±infinity fail every comparison against a finite bound — and Python's int/float rich comparison (unlike float() conversion) never raises OverflowError on an arbitrarily large int. So the isfinite check was fully redundant and was also the actual bug. Added 10**400 to the existing test_rejects_invalid_timeout parametrize list. Full tests/test_web_search.py (34 tests) and the web-search fuzz property tests pass; ruff check shows the same 6 pre-existing findings with or without this diff (none on the lines touched here), so nothing new introduced.

That leaves the one remaining finding on this PR — "HTTP errors bypass response limits" / unbounded error-body buffering — confirmed real but out of scope for this diff, as already noted: the unbounded response.read() lives in ModelClient._open_provider (orchestrator.py), shared by every caller across the codebase, not something web_search.py introduced or can fix on its own without either a shared code change (bigger, affects every other _open_provider caller) or _open_provider growing a caller-settable size-limit parameter. Leaving that for whoever picks up orchestrator.py's own hardening rather than scope-creeping this PR.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +192 to +196
# A bounds check against finite MIN/MAX already excludes NaN and
# +/-infinity (every comparison with them is False), and unlike
# math.isfinite() it never raises OverflowError on an int too large
# to convert to a C double.
or not MIN_TIMEOUT_SECONDS <= timeout <= MAX_TIMEOUT_SECONDS

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 Info: Large timeout validation remains total

The bounds comparison rejects NaN, infinities, and arbitrarily large integers without float conversion. Valid finite timeouts retain the same accepted range.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@seonghobae
seonghobae merged commit d25f8b3 into main Sep 3, 2026
23 of 26 checks passed
@seonghobae
seonghobae deleted the feat/web-search-mcp-a2a-foundation branch September 3, 2026 02:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: api API, protocol, event, or external contract enhancement New feature or request priority: high status: needs-review Open pull request requiring current-head review or checks type: feature New or expanded product capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants