| 1 | """Opt-in Parallel Search MCP adapter using stdlib Streamable HTTP.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | import urllib.error |
| 7 | import urllib.request |
| 8 | from typing import Any |
| 9 | from urllib.parse import urlparse |
| 10 | |
| 11 | from . import dates, http |
| 12 | |
| 13 | PARALLEL_MCP_URL = "https://search.parallel.ai/mcp" |
| 14 | _PROTOCOL_VERSION = "2025-03-26" |
| 15 | _MAX_RESPONSE_BYTES = 4 * 1024 * 1024 |
| 16 | |
| 17 | |
| 18 | class _NoRedirect(urllib.request.HTTPRedirectHandler): |
| 19 | def redirect_request(self, req, fp, code, msg, headers, newurl): |
| 20 | # The endpoint is fixed. Never forward credentials, session IDs, or |
| 21 | # search data to a redirect target, including an HTTPS downgrade. |
| 22 | return None |
| 23 | |
| 24 | |
| 25 | def _matching_response(payload: bytes, request_id: int | None) -> dict[str, Any] | None: |
| 26 | value = json.loads(payload) |
| 27 | # The negotiated 2025-03-26 protocol permits batched SSE messages. |
| 28 | for message in value if isinstance(value, list) else [value]: |
| 29 | if not isinstance(message, dict) or message.get("jsonrpc") != "2.0": |
| 30 | raise RuntimeError("Parallel MCP returned an invalid JSON-RPC response") |
| 31 | if message.get("id") == request_id and ("result" in message or "error" in message): |
| 32 | return message |
| 33 | return None |
| 34 | |
| 35 | |
| 36 | def _read_response(response: Any, request_id: int | None) -> dict[str, Any]: |
| 37 | if "text/event-stream" not in response.headers.get("Content-Type", "").lower(): |
| 38 | payload = response.read(_MAX_RESPONSE_BYTES + 1) |
| 39 | if len(payload) > _MAX_RESPONSE_BYTES: |
| 40 | raise RuntimeError("Parallel MCP response exceeded 4 MiB") |
| 41 | if request_id is None and not payload: |
| 42 | return {} |
| 43 | result = _matching_response(payload, request_id) if payload else None |
| 44 | if result is not None: |
| 45 | return result |
| 46 | else: |
| 47 | data = [] |
| 48 | total = 0 |
| 49 | while True: |
| 50 | line = response.readline(_MAX_RESPONSE_BYTES - total + 1) |
| 51 | total += len(line) |
| 52 | if total > _MAX_RESPONSE_BYTES: |
| 53 | raise RuntimeError("Parallel MCP response exceeded 4 MiB") |
| 54 | if not line: |
| 55 | break |
| 56 | line = line.rstrip(b"\r\n") |
| 57 | if not line and data: |
| 58 | result = _matching_response(b"\n".join(data), request_id) |
| 59 | if result is not None: |
| 60 | # A valid response completes the request, even if the |
| 61 | # server keeps the stream open or sends more notifications. |
| 62 | return result |
| 63 | data = [] |
| 64 | elif line.startswith(b"data:"): |
| 65 | value = line[5:] |
| 66 | data.append(value[1:] if value.startswith(b" ") else value) |
| 67 | raise RuntimeError("Parallel MCP response is missing the requested JSON-RPC result") |
| 68 | |
| 69 | |
| 70 | def _request( |
| 71 | message: dict[str, Any] | None, api_key: str | None, session_id: str | None = None, |
| 72 | ) -> tuple[dict[str, Any], str | None]: |
| 73 | headers = { |
| 74 | "Accept": "application/json, text/event-stream", |
| 75 | "Content-Type": "application/json", |
| 76 | "User-Agent": http.USER_AGENT, |
| 77 | } |
| 78 | if api_key: |
| 79 | headers["Authorization"] = f"Bearer {api_key}" |
| 80 | if session_id: |
| 81 | headers["Mcp-Session-Id"] = session_id |
| 82 | if message is None or message.get("method") != "initialize": |
| 83 | headers["MCP-Protocol-Version"] = _PROTOCOL_VERSION |
| 84 | request = urllib.request.Request( |
| 85 | PARALLEL_MCP_URL, |
| 86 | data=json.dumps(message, separators=(",", ":")).encode("utf-8") if message is not None else None, |
| 87 | headers=headers, |
| 88 | method="POST" if message is not None else "DELETE", |
| 89 | ) |
| 90 | with urllib.request.build_opener(_NoRedirect()).open(request, timeout=http.DEFAULT_TIMEOUT) as response: |
| 91 | result = _read_response(response, message.get("id")) if message is not None else {} |
| 92 | return result, response.headers.get("Mcp-Session-Id") or session_id |
| 93 | |
| 94 | |
| 95 | def _result(response: dict[str, Any]) -> dict[str, Any]: |
| 96 | if response.get("error"): |
| 97 | error = response["error"] |
| 98 | detail = error.get("message") if isinstance(error, dict) else str(error) |
| 99 | raise RuntimeError(f"Parallel MCP error: {detail}") |
| 100 | result = response.get("result") |
| 101 | if not isinstance(result, dict): |
| 102 | raise RuntimeError("Parallel MCP response is missing its result") |
| 103 | return result |
| 104 | |
| 105 | |
| 106 | def _search_rows(tool_result: dict[str, Any]) -> list[dict[str, Any]]: |
| 107 | payloads = [tool_result.get("structuredContent")] |
| 108 | for content in tool_result.get("content") or []: |
| 109 | if not isinstance(content, dict) or content.get("type") != "text": |
| 110 | continue |
| 111 | try: |
| 112 | payloads.append(json.loads(content.get("text") or "")) |
| 113 | except (TypeError, ValueError): |
| 114 | continue |
| 115 | for candidates in payloads: |
| 116 | if isinstance(candidates, dict) and "data" in candidates and "results" not in candidates: |
| 117 | candidates = candidates["data"] |
| 118 | if isinstance(candidates, dict): |
| 119 | candidates = candidates.get("results") |
| 120 | if isinstance(candidates, list): |
| 121 | return [row for row in candidates if isinstance(row, dict)] |
| 122 | raise RuntimeError("Parallel MCP web_search returned no results array") |
| 123 | |
| 124 | |
| 125 | def search( |
| 126 | query: str, date_range: tuple[str, str], api_key: str | None = None, count: int = 5, |
| 127 | ) -> tuple[list[dict[str, Any]], dict[str, Any]]: |
| 128 | """Discover and invoke hosted ``web_search`` after explicit dispatcher opt-in.""" |
| 129 | initialized, session_id = _request( |
| 130 | { |
| 131 | "jsonrpc": "2.0", |
| 132 | "id": 1, |
| 133 | "method": "initialize", |
| 134 | "params": { |
| 135 | "protocolVersion": _PROTOCOL_VERSION, |
| 136 | "capabilities": {}, |
| 137 | "clientInfo": {"name": "last30days-skill", "version": "3"}, |
| 138 | }, |
| 139 | }, |
| 140 | api_key, |
| 141 | ) |
| 142 | try: |
| 143 | if _result(initialized).get("protocolVersion") != _PROTOCOL_VERSION: |
| 144 | raise RuntimeError("Parallel MCP negotiated an unsupported protocol version") |
| 145 | _request( |
| 146 | {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, |
| 147 | api_key, |
| 148 | session_id, |
| 149 | ) |
| 150 | request_id = 2 |
| 151 | params = {} |
| 152 | seen_cursors = set() |
| 153 | while True: |
| 154 | tools_response, _ = _request( |
| 155 | {"jsonrpc": "2.0", "id": request_id, "method": "tools/list", "params": params}, |
| 156 | api_key, |
| 157 | session_id, |
| 158 | ) |
| 159 | request_id += 1 |
| 160 | page = _result(tools_response) |
| 161 | tools = page.get("tools") or [] |
| 162 | if any(isinstance(tool, dict) and tool.get("name") == "web_search" for tool in tools): |
| 163 | break |
| 164 | cursor = page.get("nextCursor") |
| 165 | if not isinstance(cursor, str) or not cursor or cursor in seen_cursors or len(seen_cursors) >= 20: |
| 166 | raise RuntimeError("Parallel MCP did not advertise web_search") |
| 167 | seen_cursors.add(cursor) |
| 168 | params = {"cursor": cursor} |
| 169 | objective = f"Find useful public web evidence about {query} from {date_range[0]} through {date_range[1]}." |
| 170 | called, _ = _request( |
| 171 | { |
| 172 | "jsonrpc": "2.0", |
| 173 | "id": request_id, |
| 174 | "method": "tools/call", |
| 175 | "params": { |
| 176 | "name": "web_search", |
| 177 | "arguments": {"objective": objective, "search_queries": [query]}, |
| 178 | }, |
| 179 | }, |
| 180 | api_key, |
| 181 | session_id, |
| 182 | ) |
| 183 | finally: |
| 184 | if session_id: |
| 185 | try: |
| 186 | _request(None, api_key, session_id) |
| 187 | except urllib.error.HTTPError as error: |
| 188 | error.close() |
| 189 | except (OSError, RuntimeError, ValueError): |
| 190 | # Cleanup is best-effort: unsupported DELETEs or a network |
| 191 | # failure must not hide search results or the original error. |
| 192 | pass |
| 193 | tool_result = _result(called) |
| 194 | if tool_result.get("isError"): |
| 195 | raise RuntimeError("Parallel MCP web_search reported an error") |
| 196 | items = [] |
| 197 | for row in _search_rows(tool_result): |
| 198 | if len(items) >= count: |
| 199 | break |
| 200 | url = str(row.get("url") or "") |
| 201 | try: |
| 202 | parsed_url = urlparse(url) |
| 203 | except ValueError: |
| 204 | continue |
| 205 | if parsed_url.scheme not in ("http", "https") or not parsed_url.netloc: |
| 206 | continue |
| 207 | raw_date = row.get("publish_date") |
| 208 | parsed_date = dates.parse_date(raw_date[:10]) if isinstance(raw_date, str) else None |
| 209 | pub_date = parsed_date.date().isoformat() if parsed_date else None |
| 210 | # Match the paid grounding backends: only dated evidence inside the |
| 211 | # requested window qualifies, including when --as-of is historical. |
| 212 | if not pub_date or not date_range[0] <= pub_date <= date_range[1]: |
| 213 | continue |
| 214 | excerpts = row.get("excerpts") or [] |
| 215 | if isinstance(excerpts, str): |
| 216 | excerpts = [excerpts] |
| 217 | snippet = "\n".join(str(value) for value in excerpts if value) if isinstance(excerpts, list) else "" |
| 218 | items.append({ |
| 219 | "id": f"WPM{len(items) + 1}", |
| 220 | "title": row.get("title") or parsed_url.netloc, |
| 221 | "url": url, |
| 222 | "source_domain": parsed_url.netloc.strip().lower(), |
| 223 | "snippet": snippet[:500], |
| 224 | "date": pub_date, |
| 225 | "relevance": 0.8, |
| 226 | "why_relevant": "Parallel Search MCP result", |
| 227 | }) |
| 228 | return items, { |
| 229 | "label": "parallel-mcp", |
| 230 | "webSearchQueries": [query], |
| 231 | "resultCount": len(items), |
| 232 | } |
| 233 |