返回 last30days-skill
parallel_mcp.py
根目录 / skills / last30days / scripts / lib / parallel_mcp.py
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
233 lines PYTHON