Skip to content

feat(agent): add opt-in keyless parallel search mcp - #354

Merged
winlp4ever merged 6 commits into
vcmf:mainfrom
georgeatparallel:parallel-search-0007800d-cce0-4013-9a04-d71c14ec80cb
Oct 13, 2026
Merged

winlp4ever merged 6 commits into
vcmf:mainfrom
georgeatparallel:parallel-search-0007800d-cce0-4013-9a04-d71c14ec80cb

Conversation

@georgeatparallel

@georgeatparallel georgeatparallel commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What & why

Adds optional, free keyless Parallel Search MCP to native web search and the browser agent. Select Settings → Web search → Parallel (keyless). The browser and desktop app call Parallel directly; /ai/search is only a fallback (signed-in users on hosted). Existing defaults and saved keys stay intact. Anonymous rate limits apply.

I work at Parallel. For context: Artificial Analysis's Search API benchmarks.

Related issue

Closes #353.

How was it tested?

Clean installation, live native search and /ai/search, anonymous headers and User-Agent, errors, timeouts and cleanup. Browser selection, persistence and agent tool-result flow tested with a scripted model. No live LLM or database-backed full-stack run.

Checklist

  • Branched off main
  • PR title follows Conventional Commits (type(scope): message)
  • Ran the app locally and confirmed the change works
  • Change is focused on one thing
  • Updated docs/comments if behavior changed

@winlp4ever winlp4ever 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.

Hey George, thanks a lot for this, and for opening the issue first. A free search option with no key to set up is something I've wanted for self-hosters, so I'm happy you picked it up.

Some context that changes the shape a bit. We're moving the agent fully into the browser, and the server-side agent (backend/topix/agents/, WebSearchHandler, the YAML configs) is getting deleted soon. The parts of your PR that hook into the browser engine are in the right place: the keyless mode in resolve.ts, web-search.ts and the settings pane. Could you drop the server agent changes? That's the handler branch, the config.py validator bypass and the server agent paragraph in the README.

The bigger thing I'd like to talk through is where the call to Parallel happens. Right now every keyless search goes browser → our /ai/search → Parallel. On the hosted instance all our users would reach Parallel from one IP and share one anonymous allowance, and the endpoint becomes something anyone can call without signing in. Local boards are also meant to work with zero calls to our backend. Do you know if search.parallel.ai/mcp sends CORS headers? If it does, I'd prefer the browser to call it directly. Desktop can go direct in any case through Tauri's plugin-http, like BYOK search already does. /ai/search would then be a fallback, and we can require sign-in for it on hosted.

On the backend side I think we can drop the mcp SDK. Streamable HTTP is JSON-RPC over POST, so a small httpx call does the job in one round trip instead of about five, and it's easy to port to TS for the direct path.

Smaller point: Parallel is special-cased in five spots (resolveService, ai_search, meter_search, validate_engine, the handler). With search_parallel in _SEARCH_FNS and one shared set of keyless engines, the next keyless provider would be a one-line change.

I left a few inline notes. Thanks again, I'd love to get this merged.

Comment thread backend/topix/api/router/ai.py Outdated
x_provider_key: Annotated[str | None, Header()] = None,
) -> None:
"""Guard keyless search by IP; retain existing metering for other engines."""
if body.engine == "parallel":

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.

This branch skips auth and quota, so the only guard left is the per-IP window. _client_ip takes the first X-Forwarded-For hop, which the caller controls, so a script that rotates that header never hits the 120/min cap. The BYOK relay uses the same helper, but there you need a key to get in. Here nothing is needed, and since all the traffic leaves from our server, one abuser can burn Parallel's anonymous limit for every user.

If we keep the relay, I'd require a signed-in user on this branch (self-hosters still get it), or at least a much lower cap.

Comment thread backend/topix/api/router/ai.py Outdated
if not body.query.strip() or body.max_results < 1:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Query and positive max_results are required")
try:
output = await search_parallel(body.query, max_results=body.max_results, session_id=x_run_id)

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.

x_run_id ends up as the MCP session_id. That's our internal run id (it also keys airun:{user}:{run} for metering), so Parallel could group all the searches of a user's run. Since the point of this option is that it's anonymous, I'd leave session_id out or send a random one per request.

Comment thread backend/topix/api/router/ai.py Outdated
if retry_after:
response.headers["Retry-After"] = retry_after
raise HTTPException(code if code == 429 else 502, f"Parallel search failed (HTTP {code})") from error
except httpx.TimeoutException as error:

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.

Only HTTPStatusError and TimeoutException get mapped here. A ConnectError, an McpError from the session, the RuntimeError raised on isError, or a ValidationError / JSONDecodeError on an unexpected payload all fall through to with_standard_response as a 500, with the raw upstream message in details. Could you catch the rest as a 502 with a generic message?

url: str
title: str | None = None
publish_date: str | None = None
excerpts: list[str]

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.

If one source comes back with excerpts: null or without the field (a PDF with no extracted text, for example), model_validate fails for the whole payload and we lose the other results too. A default of [] that also accepts None would keep the good ones.

arguments["session_id"] = session_id
# A dedicated client has no auth, cookies, API keys or shared-client defaults.
try:
async with httpx.AsyncClient(

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.

Each search builds a fresh client and MCP session: TLS handshake, initialize, notifications/initialized, tools/call, then the DELETE. That's around five round trips per query, and they all count against the anonymous limit. A module-level client with the same _anonymous_request hook keeps credentials out just as well. A plain JSON-RPC POST (see my main comment) would also let us drop the mcp dependency.

event_hooks={"request": [_anonymous_request]},
) as client:
async with streamable_http_client(MCP_URL, http_client=client) as (read, write, _):
async with ClientSession(read, write, read_timeout_seconds=timedelta(seconds=60)) as session:

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.

The session timeout is fixed at 60s while the HTTP client uses timeout or 30s. A caller asking for a longer timeout won't get it, and depending on which one fires first we return a 500 (McpError) or a 504 (TimeoutException). I'd derive both from the same value.

wrapper: RunContextWrapper[Context],
query: str,
) -> WebSearchOutput:
if search_engine == WebSearchOption.PARALLEL:

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.

This file and the config.py validator are on the server agent path, which we're removing soon, so I'd drop these changes. The enable_summary change above also changes behaviour for every engine on that path, so I'd keep it out of this PR in any case.

kind: ServiceKind,
ctx: ResolveContext,
): ServiceResolution => {
if (kind === "search" && ctx.searchEngine === "parallel") {

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.

This makes Parallel count as available in every case, including desktop with no server URL set, and offline. Settings show it as ready, the agent gets web_search, and each call fails with a network error. The other engines resolve to off there, so the tool is hidden. Could we only return keyless when the relay (or, with a direct path, the network) is reachable?

const { provider, llm, searchEngine, search, codeKey, parseKey } = get()
try {
if (hasAnyKey(get())) {
if (hasAnyKey(get()) || searchEngine === "parallel") {

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.

Small one: clear() wipes localStorage but leaves searchEngine set to parallel in memory. The current session keeps using Parallel, then after a reload it falls back to perplexity, which is off for signed-out users, and search disappears with no explanation. Resetting searchEngine in clear() would line the two up.

@georgeatparallel

Copy link
Copy Markdown
Contributor Author

Thanks @winlp4ever, really helpful review.

Yes on CORS: https://search.parallel.ai/mcp returns Access-Control-Allow-Origin: * plus the MCP headers (Mcp-Session-Id, MCP-Protocol-Version, etc.) on OPTIONS/POST, so a browser/Tauri direct path should work.

I'll drop the server agent bits (handler, config.py validator, README server paragraph) and rework toward browser/desktop → Parallel direct, with /ai/search as a signed-in fallback on hosted. Also switching the remaining relay to plain JSON-RPC over httpx so we can drop the mcp SDK, and I'll hit the inline notes (session_id, error mapping, excerpts default, resolve reachability, clear() searchEngine reset, less special-casing).

Pushing that up shortly.

cursoragent and others added 2 commits October 11, 2026 18:04
…arch

Responds to review on vcmf#354.

- Revert the server-agent changes: `WebSearchHandler`'s parallel branch (and
  the `enable_summary` change it carried for every engine), the `config.py`
  validator bypass and the `WebSearchOption.PARALLEL` member. That tree is
  being deleted; keyless search belongs to the browser engine.
- Replace the MCP SDK session (initialize + notifications + tools/call +
  DELETE, ~5 round trips) with one plain JSON-RPC POST over httpx, on a
  reused module-level client whose request hook strips credential headers.
  Drops the `mcp` dependency, so pyproject and uv.lock match main again.
- Register `search_parallel` in `_SEARCH_FNS` and gate on one shared
  `KEYLESS_SEARCH_ENGINES` set instead of per-callsite `== "parallel"`.
- The relay is now a fallback, so require a signed-in user for it on a hosted
  deploy: a forgeable `X-Forwarded-For` window was its only guard. Self-hosted
  keeps it open behind a tighter per-IP cap.
- Never send the internal run id as an upstream session handle; use a
  throwaway request id.
- Map ConnectError / tool `isError` / ValidationError / JSONDecodeError to 502
  with a generic message rather than a 500 carrying the raw upstream reply,
  and derive the HTTP timeout from the caller's single `timeout` value.
- Default a missing or null `excerpts` to `[]` so one text-less source does
  not invalidate the whole payload.

Co-authored-by: George Pickett <georgeatparallel@users.noreply.github.com>
…ktop

Responds to review on vcmf#354.

`search.parallel.ai/mcp` sends `Access-Control-Allow-Origin: *` and allows the
MCP request headers, so the client can reach it cross-origin. The browser now
calls it directly and desktop goes through plugin-http like BYOK search, which
means a local board searches the web with no call to a Dim0 backend at all.
`/ai/search` stays only as the fallback when the direct call cannot get
through.

- Add `services/parallel-search.ts`: the same one-POST JSON-RPC exchange the
  backend relay speaks, with a throwaway request id and no credential headers.
- Resolve `keyless` through a shared `KEYLESS_SEARCH_ENGINES` set, and only
  while the network is reachable — offline it resolves `off` and `web_search`
  is hidden, like every other unreachable engine, instead of advertising a
  tool whose every call would fail.
- Reset `searchEngine` in `clear()` so the in-memory selection and
  localStorage agree instead of diverging until the next reload.
- Update the settings copy, README and ADR-AGENT-003 to the new shape, and
  cover the direct path, the relay fallback, offline resolution and `clear()`
  in unit and e2e tests.

Co-authored-by: George Pickett <georgeatparallel@users.noreply.github.com>
@georgeatparallel

Copy link
Copy Markdown
Contributor Author

Pushed e2aea28 with the review rework:

  • Dropped the server-agent path entirely (handler branch, config.py validator bypass, README server paragraph).
  • Browser and desktop now call https://search.parallel.ai/mcp directly (one JSON-RPC POST; desktop via plugin-http like BYOK). /ai/search is only a signed-in fallback on hosted.
  • Remaining Python relay is plain httpx JSON-RPC on a reused client (no mcp SDK / pyproject change vs main), random session id, broader 502 mapping, excerpts default [], shared KEYLESS_SEARCH_ENGINES / _SEARCH_FNS registration.
  • resolve.ts only returns keyless when the network is reachable; clear() resets in-memory searchEngine.

Happy to tweak further if anything still looks off.

A refusal from Parallel (a rate limit, a tool error) means the shared
anonymous allowance is already spent, so replaying the same query through
`/ai/search` would only spend it again from our IP. Distinguish that from a
transport failure — the case the relay fallback exists for — and let the
refusal surface to the agent instead.

Co-authored-by: George Pickett <georgeatparallel@users.noreply.github.com>
@georgeatparallel

Copy link
Copy Markdown
Contributor Author

One more small commit on top (bf706fc): if Parallel itself refuses a request (e.g. rate limit), the client no longer falls back to the /ai/search relay, since that would just spend the same anonymous allowance again from the server's IP. Only a transport failure falls back now. Also updated the PR description to drop the server-agent mention.

@winlp4ever

winlp4ever commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Thanks a lot George, the rework is really nice. Calling Parallel from the client is the direction I wanted, and you also took care of the refusal case so it doesn't get relayed twice. I ran the backend tests, the frontend tests and npm run check-all on your branch and they all pass.

There are two small things I'd like fixed before merging.

  1. Desktop can't reach Parallel yet. Tauri only lets plugin-http call the hosts listed in webui/src-tauri/capabilities/default.json, and search.parallel.ai isn't on that list. So every desktop search fails the direct call and falls back to /ai/search, and on a local desktop board with no server, search doesn't work. Adding { "url": "https://search.parallel.ai/*" } next to the other providers should do it.

  2. The relay's 30/min window uses _client_ip, which reads the first X-Forwarded-For hop, even for signed-in users. On hosted, one account could rotate that header and use up the shared allowance for everyone. Could you key the window on user_id when there is one, and keep the IP for signed-out self-hosts?

Apart from that the PR is in good shape. Once these two are in I'll try to merge it tomorrow or the day after.

cursoragent and others added 2 commits October 12, 2026 19:55
Co-authored-by: George Pickett <georgeatparallel@users.noreply.github.com>
Co-authored-by: George Pickett <georgeatparallel@users.noreply.github.com>
@georgeatparallel

Copy link
Copy Markdown
Contributor Author

Thanks @winlp4ever, good catches on both. Pushed 3bd87ef:

  1. Added { "url": "https://search.parallel.ai/*" } to the http allow list in webui/src-tauri/capabilities/default.json, so desktop can hit Parallel directly now.
  2. The keyless relay window is now keyed on user:<uid> when there's a signed-in user and only falls back to the IP for signed-out self-hosts. BYOK window is unchanged. Added two tests: a signed-in caller rotating X-Forwarded-For still counts against the same user key, and a signed-out caller is keyed on IP.

Backend unit tests, npm run check-all and the webui tests all pass on the branch. Let me know if you'd like anything else tweaked.

@winlp4ever winlp4ever 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.

LGTM

@winlp4ever
winlp4ever merged commit 48fbc32 into vcmf:main Oct 13, 2026
6 checks passed
@georgeatparallel

Copy link
Copy Markdown
Contributor Author

Thanks @winlp4ever for the careful review and for merging!

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(agent): add an opt-in keyless Parallel Search MCP provider

3 participants