bookmark-research 0.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.codex-plugin/plugin.json +46 -0
  3. package/LICENSE +674 -0
  4. package/README.md +128 -0
  5. package/README.zh.md +126 -0
  6. package/bin/bookmark-research.js +30 -0
  7. package/config/providers.json +98 -0
  8. package/docs/bookmark-research-architecture.md +124 -0
  9. package/docs/cli-launcher-plan.md +58 -0
  10. package/docs/details.en.md +272 -0
  11. package/docs/details.md +93 -0
  12. package/docs/harness-compatibility.md +304 -0
  13. package/docs/harness-sources.json +342 -0
  14. package/docs/host-validation.json +298 -0
  15. package/docs/install-design-0.2.0.md +57 -0
  16. package/docs/installation.en.md +197 -0
  17. package/docs/installation.md +316 -0
  18. package/docs/instructions.en.md +42 -0
  19. package/docs/instructions.md +42 -0
  20. package/docs/mcp-aggregation-0.2.0.md +92 -0
  21. package/docs/multi-host-installation-plan.md +163 -0
  22. package/docs/prompt-reference.en.md +104 -0
  23. package/docs/prompt-reference.md +104 -0
  24. package/docs/provider-smoke-0.2.0.json +235 -0
  25. package/docs/release-notes-0.5.0-beta.1.md +33 -0
  26. package/docs/research-0.2.0.md +140 -0
  27. package/docs/research-sources-0.2.0.json +9360 -0
  28. package/docs/user-guide.en.md +74 -0
  29. package/docs/user-guide.md +74 -0
  30. package/docs/validation-0.2.0.md +126 -0
  31. package/docs/validation-0.4.0.md +154 -0
  32. package/docs/wiki-quality.md +105 -0
  33. package/docs/wiki-quality.zh.md +105 -0
  34. package/hosts/claude/runtime.js +13 -0
  35. package/hosts/codex/delegate.md +14 -0
  36. package/hosts/codex/prepare.py +61 -0
  37. package/hosts/dsh/plugin.js +36 -0
  38. package/hosts/dsh/runtime.js +14 -0
  39. package/hosts/dsh/workflow-call.py +45 -0
  40. package/hosts/pi/register-workflow.py +72 -0
  41. package/hosts/pi/runtime.js +18 -0
  42. package/hosts/shared/research-call.py +76 -0
  43. package/hosts/shared/research-flow.js +208 -0
  44. package/hosts/shared/workflow.json +15 -0
  45. package/install.sh +310 -0
  46. package/package.json +12 -0
  47. package/scripts/build_zip.py +143 -0
  48. package/scripts/export_bundle.py +496 -0
  49. package/scripts/host_assets.py +70 -0
  50. package/scripts/host_clients.py +196 -0
  51. package/scripts/host_install.py +277 -0
  52. package/scripts/install.py +593 -0
  53. package/scripts/launcher.py +186 -0
  54. package/scripts/verify_fixture.py +220 -0
  55. package/scripts/verify_live.py +62 -0
  56. package/scripts/verify_quality.py +87 -0
  57. package/skills/bookmark-research/SKILL.md +80 -0
  58. package/skills/bookmark-research/agents/openai.yaml +3 -0
  59. package/skills/bookmark-research/references/cli.md +150 -0
  60. package/skills/bookmark-research/references/deep-research.md +164 -0
  61. package/skills/bookmark-research/references/github-and-sync.md +30 -0
  62. package/skills/bookmark-research/references/host-workflows.md +101 -0
  63. package/skills/bookmark-research/references/package-semantics.md +78 -0
  64. package/skills/bookmark-research/references/research-methods.md +51 -0
  65. package/skills/bookmark-research/references/research-services.md +72 -0
  66. package/skills/bookmark-research/references/research-workflow.md +57 -0
  67. package/skills/bookmark-research/references/settings-and-archive.md +104 -0
  68. package/skills/bookmark-research/references/source-lifecycle.md +75 -0
  69. package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
  70. package/skills/bookmark-research/references/zh/cli.md +150 -0
  71. package/skills/bookmark-research/references/zh/deep-research.md +164 -0
  72. package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
  73. package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
  74. package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
  75. package/skills/bookmark-research/references/zh/research-methods.md +51 -0
  76. package/skills/bookmark-research/references/zh/research-services.md +70 -0
  77. package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
  78. package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
  79. package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
  80. package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
  81. package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
  82. package/src/archive.py +302 -0
  83. package/src/bookmark_index.py +913 -0
  84. package/src/cli.py +435 -0
  85. package/src/credentials.py +82 -0
  86. package/src/evaluation.py +337 -0
  87. package/src/jina.py +96 -0
  88. package/src/mcp_server.py +641 -0
  89. package/src/onboarding.py +352 -0
  90. package/src/provider_adapters.py +152 -0
  91. package/src/readiness.py +283 -0
  92. package/src/remote_mcp.py +295 -0
  93. package/src/research.py +1334 -0
  94. package/src/research_coverage.py +160 -0
  95. package/src/research_services.py +684 -0
  96. package/src/routing.py +202 -0
  97. package/src/search_results.py +244 -0
  98. package/src/service_http.py +139 -0
  99. package/src/settings.py +247 -0
  100. package/src/source_inputs.py +201 -0
  101. package/src/source_manager.py +348 -0
  102. package/src/source_watcher.py +68 -0
  103. package/src/textio.py +9 -0
  104. package/src/tui.py +586 -0
  105. package/src/web_search.py +603 -0
  106. package/src/wiki.py +383 -0
@@ -0,0 +1,283 @@
1
+ """Bounded capability checks, honest auth status, and private expiring cache."""
2
+
3
+ import concurrent.futures
4
+ import copy
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import tempfile
11
+ import time
12
+
13
+ from credentials import Credentials
14
+ from remote_mcp import McpError, McpHttpClient
15
+ from service_http import ResearchHttp, ServiceError
16
+ from settings import Settings
17
+ from web_search import SearchProviders
18
+
19
+
20
+ class Readiness:
21
+ VERSION = 1
22
+
23
+ def __init__(self, settings=None, web=None, transport=None, cache_path=None):
24
+ self.settings = settings if settings is not None else Settings()
25
+ self.credentials = Credentials(self.settings)
26
+ timeout = self.settings.load()["readiness"]["timeout_seconds"]
27
+ self.web = web if web is not None else SearchProviders(settings=self.settings, timeout=timeout)
28
+ self.transport = transport if transport is not None else ResearchHttp(timeout, self.credentials)
29
+ identity = hashlib.sha256(str(self.settings.path).encode()).hexdigest()[:16]
30
+ self.cache_path = Path(cache_path or Settings.data_directory() / "readiness" / (identity + ".json"))
31
+
32
+ def _cache(self):
33
+ if self.cache_path.is_symlink():
34
+ raise ValueError("Readiness cache must not be a symlink")
35
+ try:
36
+ if self.cache_path.stat().st_size > 256000:
37
+ raise ValueError("Readiness cache exceeds size limit")
38
+ cache = json.loads(self.cache_path.read_text(encoding="utf-8"))
39
+ if (isinstance(cache, dict) and cache.get("version") == self.VERSION
40
+ and isinstance(cache.get("salt"), str) and isinstance(cache.get("checks"), dict)):
41
+ return cache
42
+ except (FileNotFoundError, ValueError, UnicodeError):
43
+ pass
44
+ return {"version": self.VERSION, "salt": os.urandom(32).hex(), "checks": {}}
45
+
46
+ def _save(self, cache):
47
+ if self.cache_path.is_symlink():
48
+ raise ValueError("Readiness cache must not be a symlink")
49
+ path = Settings.external_path(str(self.cache_path), "Readiness cache")
50
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
51
+ temporary = None
52
+ try:
53
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="", dir=path.parent,
54
+ prefix=".readiness-", delete=False) as stream:
55
+ temporary = Path(stream.name)
56
+ json.dump(cache, stream, ensure_ascii=False)
57
+ stream.flush()
58
+ os.fsync(stream.fileno())
59
+ temporary.replace(path)
60
+ finally:
61
+ if temporary is not None:
62
+ temporary.unlink(missing_ok=True)
63
+
64
+ def _signature(self, row, salt):
65
+ # Only a salted signature is persisted; no keys, headers, or signatures
66
+ # are included in CLI/MCP results. Rotation invalidates cached checks.
67
+ content = {"row": row, "settings": self.settings.load(),
68
+ "credential": self.credentials.get(row["credential_env"]),
69
+ "registry": self.web.registry, "version": self.VERSION}
70
+ return hmac.new(salt.encode(), json.dumps(content, sort_keys=True).encode(), hashlib.sha256).hexdigest()
71
+
72
+ @staticmethod
73
+ def _error(error):
74
+ if isinstance(error, (ServiceError, McpError)):
75
+ detail = error.details()
76
+ # Only typed error metadata is needed for remediation. Never retain
77
+ # response bodies or arbitrary exception text in the cache.
78
+ return {key: detail[key] for key in ("error_kind", "http_status", "retry_after_seconds") if key in detail}
79
+ return {"error_kind": "check_failed"}
80
+
81
+ def _retrieval_rows(self, providers):
82
+ preferences = self.settings.load()
83
+ names = providers if providers is not None else list(dict.fromkeys(
84
+ preferences["search"]["providers"] + preferences["search"]["fallback_providers"] +
85
+ Settings.fetch_providers(preferences)))
86
+ described = self.web.describe(names)["providers"]
87
+ return [{"id": "retrieval:" + row["provider"], "kind": "retrieval", "provider": row["provider"],
88
+ "credential_env": row["provider"].upper() + "_API_KEY",
89
+ "credential_configured": row["authentication_state"]["credential_configured"],
90
+ "authentication_mode": row["authentication_state"]["mode"],
91
+ "authentication_verified": False, "execution_verified": False,
92
+ "status": "unchecked", "docs": row["docs"], "operations": row["operations"],
93
+ "installation": "included_adapter", "network_checked": False,
94
+ "next_step": "Run readiness --refresh for connection checks; --test-retrieval also uses retrieval quota."}
95
+ for row in described]
96
+
97
+ def _service_rows(self, service):
98
+ config = self.settings.load()["professional_research"]
99
+ rows = []
100
+ for name in ([service] if service else ["openai", "parallel"]):
101
+ key = ResearchHttp.PROVIDERS[name]["key_env"]
102
+ enabled = config["enabled"] and (config["provider"] in (None, name) or service == name)
103
+ configured = bool(self.credentials.get(key))
104
+ rows.append({"id": "service:" + name, "kind": "professional_research", "provider": name,
105
+ "credential_env": key, "credential_configured": configured,
106
+ "authentication_verified": False, "execution_verified": False,
107
+ "network_checked": False, "enabled": enabled,
108
+ "status": "unchecked" if configured and enabled else "disabled" if not enabled else "missing_credentials",
109
+ "next_step": "Run setup to enable this optional service and configure " + key + ".",
110
+ "docs": "https://developers.openai.com/api/docs/guides/deep-research" if name == "openai"
111
+ else "https://docs.parallel.ai/task-api/guides/execute-task-run"})
112
+ return rows
113
+
114
+ def _probe_retrieval(self, row, test_retrieval):
115
+ name = row["provider"]
116
+ if test_retrieval:
117
+ operations = copy.deepcopy(row["operations"])
118
+ for purpose in ("search", "fetch"):
119
+ if operations[purpose]["missing"]:
120
+ operations[purpose]["status"] = "missing_credentials"
121
+ continue
122
+ if purpose == "search":
123
+ result = self.web.search([{"target": "readiness", "query": "example.com Example Domain"}],
124
+ providers=[name], limit_per_target=1)["batches"][0]
125
+ verified = result["status"] == "ok" and bool(result.get("results"))
126
+ else:
127
+ result = self.web.fetch(["https://example.com/"], provider=name, archive=False, max_characters=1000)
128
+ verified = result["status"] == "ok" and result.get("successful_url_count", 0) > 0
129
+ operations[purpose].update(status="verified" if verified else "failed",
130
+ error_kind=result.get("error_kind"))
131
+ verified = any(operation.get("status") == "verified" for operation in operations.values())
132
+ return {"status": "retrieval_verified" if verified else "failed", "operations": operations,
133
+ "network_checked": True, "execution_verified": verified,
134
+ "authentication_verified": verified and row["credential_configured"],
135
+ "verification_scope": "Only the successful search/fetch operations in this check.",
136
+ "next_step": "Use verified operations; configure missing credentials or address failed operations before relying on them."}
137
+ probe = self.web.probe([name])["providers"][0]
138
+ if probe["status"] == "ok":
139
+ return {"status": "catalog_reachable", "network_checked": True,
140
+ "capabilities": probe["capabilities"],
141
+ "next_step": "Search/fetch can be attempted. Catalog discovery does not verify retrieval authorization or quota."}
142
+ if probe["status"] == "not_probed":
143
+ return {"status": "http_unchecked", "network_checked": False,
144
+ "next_step": "Reader can be attempted anonymously; Search needs JINA_API_KEY. Use --test-retrieval for an actual check."}
145
+ return {"status": "failed", "network_checked": True,
146
+ "error_kind": probe.get("error_kind", "check_failed"),
147
+ "next_step": "Check credentials and connectivity, then refresh; another configured provider may remain available."}
148
+
149
+ def _probe_service(self, row):
150
+ name = row["provider"]
151
+ if name == "openai":
152
+ model = self.settings.load()["professional_research"]["openai"]["model"]
153
+ result = self.transport.request("openai", "GET", "/v1/models/" + model)
154
+ if result.get("id") != model or result.get("object") != "model":
155
+ raise ValueError("Unexpected model metadata")
156
+ return {"status": "model_access_verified", "authentication_verified": True,
157
+ "network_checked": True, "verification_scope": "GET /v1/models/" + model,
158
+ "next_step": "Model metadata is accessible. Research execution and billing quota remain untested; prepare the intended run before starting."}
159
+ endpoint = self.web.registry["providers"]["parallel"]["native_research"]["url"]
160
+ client = McpHttpClient(endpoint, headers={"Authorization": "Bearer " + self.credentials.get("PARALLEL_API_KEY")},
161
+ timeout=self.settings.load()["readiness"]["timeout_seconds"])
162
+ tools = client.list_tools()
163
+ required = self.web.registry["providers"]["parallel"]["native_research"]["required_tools"]
164
+ if not isinstance(tools, list) or not set(required) <= {tool.get("name") for tool in tools if isinstance(tool, dict)}:
165
+ raise ValueError("Task MCP catalog is incomplete")
166
+ return {"status": "task_mcp_access_verified", "network_checked": True,
167
+ "task_mcp_authentication_verified": True, "authentication_verified": False,
168
+ "verification_scope": "Authenticated Task MCP initialize/tools/list; Task API execution was not tested.",
169
+ "next_step": "Task MCP accepted this API key. Task API permission and billing quota still need verification on the intended run."}
170
+
171
+ def _probe(self, row, test_retrieval):
172
+ try:
173
+ update = self._probe_retrieval(row, test_retrieval) if row["kind"] == "retrieval" else self._probe_service(row)
174
+ return {**row, **update}
175
+ except (RuntimeError, ValueError, OSError) as error:
176
+ return {**row, "status": "failed", "network_checked": True, **self._error(error),
177
+ "next_step": "Check the selected provider's credential, permissions and connectivity; refresh after fixing it."}
178
+
179
+ def _host_integrations(self, host, observed_tools):
180
+ from routing import ResearchRouting
181
+ if host not in ResearchRouting.HOSTS:
182
+ raise ValueError("Unsupported host")
183
+ tools = ResearchRouting._names(observed_tools, "observed_tools")
184
+ rows = []
185
+ for name, info in self.web.registry["providers"].items():
186
+ native = info.get("native_research")
187
+ if not native:
188
+ continue
189
+ required = native.get("required_tools", [])
190
+ present = (all(ResearchRouting._has(tools, tool) for tool in required) if required else
191
+ any(ResearchRouting._has(tools, tool) for tool in native["tools"]))
192
+ alias = "bookmark-" + name + "-research"
193
+ if host == "codex":
194
+ setup = {"inspect_command": ["codex", "mcp", "list", "--json"],
195
+ "add_command": ["codex", "mcp", "add", alias, "--url", native["url"]],
196
+ "login_command": ["codex", "mcp", "login", alias],
197
+ "login_instruction": "Use the host's OAuth flow if this endpoint supports it, then inspect /mcp in a new session.",
198
+ "reference": "https://developers.openai.com/codex/mcp"}
199
+ elif host == "claude_code":
200
+ setup = {"inspect_command": ["claude", "mcp", "list"],
201
+ "add_command": ["claude", "mcp", "add", "--scope", "user", "--transport", "http", alias, native["url"]],
202
+ "login_instruction": "Open /mcp in Claude Code and authenticate this server if OAuth is supported. Choose the intended scope when adding it.",
203
+ "reference": "https://code.claude.com/docs/en/mcp"}
204
+ elif host == "dsh":
205
+ setup = {"mcp_config": {"serverName": alias, "transport": "streamable-http", "url": native["url"]},
206
+ "credential_env": name.upper() + "_API_KEY",
207
+ "credential_header": "x-api-key" if name == "exa" else "Authorization: Bearer",
208
+ "login_instruction": "Add this optional server to the selected DSH profile with an environment-backed authentication header. The documented DSH bridge supports headers; OAuth support is not established.",
209
+ "reference": "https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/mcp/mcp-client/README.md"}
210
+ else:
211
+ setup = {"login_instruction": "Pi uses the bundled CLI for ordinary research. Native research MCPs need a separately chosen MCP extension and its authentication flow. The plugin does not assume one is installed."
212
+ if host == "pi" else "Select --host when running setup, or inspect the MCP settings in your actual host.",
213
+ "reference": "skills/bookmark-research/references/host-workflows.md"}
214
+ rows.append({"provider": name, "optional": True, "endpoint": native["url"],
215
+ "status": "needs_host_session" if observed_tools is None else "observed" if present else "not_observed",
216
+ "authentication_verified": False, "docs": native.get("docs", info["docs"]),
217
+ "setup": setup,
218
+ "next_step": "Inspect the current host's MCP tools; add this optional endpoint and complete its host-managed login if needed. Tool presence alone does not verify authorization."})
219
+ collaboration_tools = {"codex": ("spawn_agent",), "claude_code": ("Agent", "Task", "Workflow"),
220
+ "pi": ("subagent", "pi_subagent_workflow"), "dsh": ("subagent", "workflow"), "unknown": ()}
221
+ collaboration = {"optional": True,
222
+ "status": "needs_host_session" if observed_tools is None or host == "unknown" else
223
+ "observed" if ResearchRouting._has(tools, *collaboration_tools[host]) else "not_observed",
224
+ "next_step": "Use the host's own search/read loop when collaboration is unavailable. Enable the host's supported subagent/workflow capability only when needed.",
225
+ "reference": "skills/bookmark-research/references/host-workflows.md"}
226
+ return {"host": host, "session_observed": observed_tools is not None,
227
+ "collaboration": collaboration, "native_research_mcps": rows}
228
+
229
+ def check(self, providers=None, service=None, refresh=False, offline=False, test_retrieval=False,
230
+ host="unknown", observed_tools=None):
231
+ if service not in (None, "openai", "parallel"):
232
+ raise ValueError("Select openai or parallel")
233
+ if any(type(value) is not bool for value in (refresh, offline, test_retrieval)):
234
+ raise ValueError("Readiness flags must be boolean")
235
+ if offline and (refresh or test_retrieval):
236
+ raise ValueError("Offline checks cannot refresh or test retrieval")
237
+ integrations = self._host_integrations(host, observed_tools)
238
+ policy = self.settings.load()["readiness"]
239
+ rows = self._retrieval_rows(providers) + self._service_rows(service)
240
+ cache_error = None
241
+ try:
242
+ cache = self._cache()
243
+ except (OSError, ValueError):
244
+ cache = {"version": self.VERSION, "salt": os.urandom(32).hex(), "checks": {}}
245
+ cache_error = "Readiness cache could not be read; current checks remain usable."
246
+ now, pending, output = time.time(), [], []
247
+ for row in rows:
248
+ if row["status"] in ("disabled", "missing_credentials"):
249
+ output.append(row)
250
+ continue
251
+ signature = self._signature(row, cache["salt"])
252
+ previous = cache["checks"].get(row["id"], {})
253
+ fresh = (isinstance(previous, dict) and previous.get("signature") == signature
254
+ and type(previous.get("expires_at")) in (int, float) and previous["expires_at"] > now
255
+ and isinstance(previous.get("result"), dict))
256
+ if fresh and not refresh and not test_retrieval and (offline or policy["mode"] != "always"):
257
+ output.append({**previous["result"], "cached": True, "network_checked_this_call": False})
258
+ elif offline or (policy["mode"] == "manual" and not refresh and not test_retrieval):
259
+ output.append({**row, "cached": False, "network_checked_this_call": False,
260
+ "next_step": "Run readiness --refresh when a network check is wanted."})
261
+ else:
262
+ pending.append((row, signature))
263
+ if pending:
264
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(pending)) as pool:
265
+ results = list(pool.map(lambda work: self._probe(work[0], test_retrieval), pending))
266
+ for (row, signature), result in zip(pending, results):
267
+ ttl = min(policy["ttl_seconds"], 60) if result["status"] == "failed" else policy["ttl_seconds"]
268
+ result.update(checked_at=now, expires_at=now + ttl, cached=False,
269
+ network_checked_this_call=result["network_checked"])
270
+ cache["checks"][row["id"]] = {"signature": signature, "expires_at": now + ttl, "result": result}
271
+ output.append(result)
272
+ if not cache_error:
273
+ try:
274
+ self._save(cache)
275
+ except (OSError, ValueError):
276
+ cache_error = "Readiness checks completed but their cache could not be saved."
277
+ order = {row["id"]: index for index, row in enumerate(rows)}
278
+ output.sort(key=lambda row: order[row["id"]])
279
+ return {"policy": policy, "research_preferences": self.settings.load()["research"],
280
+ "checks": output, "host_integrations": integrations,
281
+ "offline_ready": True, "network_checked": any(row.get("network_checked_this_call") for row in output),
282
+ "research_jobs_started": 0, "cache_error": cache_error,
283
+ "note": "Optional service failures do not block local queries or other providers. Cached status is evidence from its timestamp, not a guarantee. Host OAuth stays in the host; no login tokens are imported. MCP setup commands are guidance: inspect existing registrations and reuse their names before adding a server."}
@@ -0,0 +1,295 @@
1
+ """Small Streamable HTTP client for the selected public search MCPs.
2
+
3
+ This client declares no sampling/roots capabilities and does not implement OAuth.
4
+ Hosts remain responsible for interactive OAuth and general-purpose MCP sessions.
5
+ """
6
+
7
+ import json
8
+ import math
9
+ import socket
10
+ import threading
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ from datetime import datetime, timezone
15
+ from email.utils import parsedate_to_datetime
16
+ from urllib.parse import urlsplit
17
+
18
+
19
+ class McpError(RuntimeError):
20
+ """A safe, classified failure; upstream bodies and credentials are omitted."""
21
+
22
+ def __init__(self, message, kind="protocol_error", retryable=False,
23
+ http_status=None, rpc_code=None, retry_after=None):
24
+ super().__init__(message)
25
+ self.kind, self.retryable = kind, retryable
26
+ self.http_status, self.rpc_code = http_status, rpc_code
27
+ self.retry_after = retry_after
28
+
29
+ def details(self):
30
+ result = {"error": str(self), "error_kind": self.kind, "retryable": self.retryable}
31
+ for key, value in (("http_status", self.http_status), ("rpc_code", self.rpc_code),
32
+ ("retry_after_seconds", self.retry_after)):
33
+ if value is not None:
34
+ result[key] = value
35
+ return result
36
+
37
+
38
+ class McpHttpClient:
39
+ """A bounded initialize/list/call client accepting JSON or SSE responses."""
40
+
41
+ MAX_BYTES = 2_000_000
42
+ MAX_TOOL_PAGES = 8
43
+ MAX_TOOLS = 256
44
+ PROTOCOLS = {"2025-03-26", "2025-06-18", "2025-11-25"}
45
+
46
+ def __init__(self, url, headers=None, timeout=30):
47
+ if not isinstance(url, str) or any(c.isspace() or ord(c) < 32 for c in url):
48
+ raise ValueError("MCP endpoint must use HTTPS")
49
+ parsed = urlsplit(url)
50
+ if parsed.scheme != "https" or not parsed.hostname or parsed.username is not None or parsed.password is not None:
51
+ raise ValueError("MCP endpoint must use HTTPS without embedded credentials")
52
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not 1 <= timeout <= 60:
53
+ raise ValueError("timeout must be between 1 and 60 seconds")
54
+ self.url = url
55
+ self.headers = dict(headers or {})
56
+ self.timeout = timeout
57
+ self.session_id = None
58
+ self.protocol = "2025-03-26"
59
+ self.counter = 0
60
+ self.initialized = False
61
+ self.catalog_version = 0
62
+ self._lock = threading.RLock()
63
+ self._usage = {"http_requests": 0, "initialize_requests": 0,
64
+ "list_requests": 0, "tool_calls": 0, "session_recoveries": 0}
65
+
66
+ def snapshot_usage(self):
67
+ with self._lock:
68
+ return dict(self._usage)
69
+
70
+ def reset_session(self):
71
+ with self._lock:
72
+ self.initialized, self.session_id = False, None
73
+ self.catalog_version += 1
74
+
75
+ @staticmethod
76
+ def _retry_after(value):
77
+ if not isinstance(value, str) or len(value) > 100:
78
+ return None
79
+ try:
80
+ seconds = float(value)
81
+ except ValueError:
82
+ try:
83
+ stamp = parsedate_to_datetime(value)
84
+ if stamp.tzinfo is None:
85
+ stamp = stamp.replace(tzinfo=timezone.utc)
86
+ seconds = (stamp - datetime.now(timezone.utc)).total_seconds()
87
+ except (ValueError, TypeError, OverflowError):
88
+ return None
89
+ return min(86400, max(0, math.ceil(seconds))) if math.isfinite(seconds) else None
90
+
91
+ def _http_error(self, status, retry_after=None):
92
+ kind, retryable = "http_error", False
93
+ if status in (401, 403):
94
+ kind = "authentication_required"
95
+ elif status == 402:
96
+ kind = "quota_exhausted"
97
+ elif status == 404 and self.initialized and self.session_id:
98
+ kind, retryable = "session_expired", True
99
+ self.reset_session()
100
+ elif status == 429:
101
+ kind, retryable = "rate_limited", True
102
+ elif status in (408, 500, 502, 503, 504):
103
+ kind, retryable = "transient_http", True
104
+ return McpError("MCP HTTP %s" % status, kind, retryable,
105
+ http_status=status, retry_after=retry_after)
106
+
107
+ def _message(self, body):
108
+ headers = {"Content-Type": "application/json",
109
+ "Accept": "application/json, text/event-stream",
110
+ "User-Agent": "bookmark-research/0.5.0-beta.1"}
111
+ if self.initialized:
112
+ headers["MCP-Protocol-Version"] = self.protocol
113
+ request = urllib.request.Request(
114
+ self.url, json.dumps(body, ensure_ascii=False, allow_nan=False).encode("utf-8"),
115
+ headers=headers, method="POST")
116
+ # urllib follows redirects; credentials must stay on the configured endpoint.
117
+ for header, value in self.headers.items():
118
+ request.add_unredirected_header(header, value)
119
+ if self.session_id:
120
+ request.add_unredirected_header("Mcp-Session-Id", self.session_id)
121
+ self._usage["http_requests"] += 1
122
+ try:
123
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
124
+ session = response.headers.get("Mcp-Session-Id")
125
+ if session:
126
+ if len(session) > 4096 or any(not 0x21 <= ord(c) <= 0x7E for c in session):
127
+ raise McpError("MCP server returned an invalid session identifier")
128
+ self.session_id = session
129
+ if "id" not in body or "method" not in body:
130
+ if response.status not in (200, 202, 204):
131
+ raise McpError("MCP notification or response was not accepted")
132
+ return None
133
+ if "text/event-stream" in response.headers.get("Content-Type", ""):
134
+ return self._read_sse(response, body["id"])
135
+ data = response.read(self.MAX_BYTES + 1)
136
+ if len(data) > self.MAX_BYTES:
137
+ raise McpError("MCP response exceeds size limit", "response_too_large")
138
+ return self._result(json.loads(data), body["id"])
139
+ except urllib.error.HTTPError as error:
140
+ # Never echo response bodies/URLs: a service may reflect credentials.
141
+ failure = self._http_error(error.code, self._retry_after((error.headers or {}).get("Retry-After")))
142
+ if error.fp is not None:
143
+ error.close()
144
+ raise failure from None
145
+ except urllib.error.URLError as error:
146
+ kind = "timeout" if isinstance(error.reason, (TimeoutError, socket.timeout)) else "connection_error"
147
+ raise McpError("MCP connection failed (%s)" % type(error.reason).__name__, kind, True) from None
148
+ except (TimeoutError, OSError) as error:
149
+ kind = "timeout" if isinstance(error, (TimeoutError, socket.timeout)) else "connection_error"
150
+ raise McpError("MCP transport failed (%s)" % type(error).__name__, kind, True) from None
151
+ except (ValueError, UnicodeError, RecursionError):
152
+ raise McpError("MCP returned invalid JSON or text") from None
153
+
154
+ @staticmethod
155
+ def _result(message, request_id):
156
+ messages = message if isinstance(message, list) else [message]
157
+ for item in messages:
158
+ if not isinstance(item, dict):
159
+ continue
160
+ if isinstance(item.get("id"), bool) or item.get("id") != request_id:
161
+ continue
162
+ if item.get("jsonrpc") != "2.0" or ("result" in item and "error" in item):
163
+ raise McpError("MCP returned an invalid JSON-RPC response")
164
+ if "error" in item:
165
+ code = item["error"].get("code") if isinstance(item["error"], dict) else None
166
+ if type(code) is not int:
167
+ raise McpError("MCP returned an invalid JSON-RPC error")
168
+ kind = {-32602: "schema_mismatch", -32601: "tool_unavailable"}.get(code, "rpc_error")
169
+ raise McpError("MCP RPC error (%s)" % code, kind, rpc_code=code)
170
+ if "result" in item:
171
+ return item["result"]
172
+ raise McpError("MCP response did not match request ID")
173
+
174
+ def _read_sse(self, response, request_id):
175
+ deadline = time.monotonic() + self.timeout
176
+ event = []
177
+ consumed = 0
178
+ while time.monotonic() < deadline:
179
+ line = response.readline(self.MAX_BYTES + 1)
180
+ consumed += len(line)
181
+ if consumed > self.MAX_BYTES:
182
+ raise McpError("MCP response exceeds size limit", "response_too_large")
183
+ if not line and not event:
184
+ break
185
+ text = line.decode("utf-8").rstrip("\r\n")
186
+ if text.startswith("data:"):
187
+ event.append(text[5:].lstrip(" "))
188
+ if (not text or not line) and event:
189
+ data = "\n".join(event)
190
+ event = []
191
+ # 2025-11-25 servers may prime a resumable stream with empty data.
192
+ if not data:
193
+ continue
194
+ message = json.loads(data)
195
+ messages = message if isinstance(message, list) else [message]
196
+ for incoming in messages:
197
+ if (isinstance(incoming, dict) and incoming.get("jsonrpc") == "2.0"
198
+ and incoming.get("method") == "notifications/tools/list_changed"
199
+ and "id" not in incoming):
200
+ self.catalog_version += 1
201
+ if not isinstance(incoming, dict) or "method" not in incoming or "id" not in incoming:
202
+ continue
203
+ if (incoming.get("jsonrpc") != "2.0" or
204
+ type(incoming["id"]) not in (int, str)):
205
+ raise McpError("MCP returned an invalid JSON-RPC request")
206
+ # Ping does not depend on sampling, roots or other capabilities.
207
+ if incoming["method"] != "ping":
208
+ raise McpError("MCP server requested an unsupported client capability", "unsupported_capability")
209
+ self._message({"jsonrpc": "2.0", "id": incoming["id"], "result": {}})
210
+ if any(isinstance(m, dict) and m.get("id") == request_id
211
+ and ("result" in m or "error" in m) for m in messages):
212
+ return self._result(message, request_id)
213
+ if not line:
214
+ break
215
+ raise McpError("MCP SSE ended or timed out without a matching response", "incomplete_response", True)
216
+
217
+ def request(self, method, params=None):
218
+ with self._lock:
219
+ self.counter += 1
220
+ metric = {"initialize": "initialize_requests", "tools/list": "list_requests",
221
+ "tools/call": "tool_calls"}.get(method)
222
+ if metric:
223
+ self._usage[metric] += 1
224
+ body = {"jsonrpc": "2.0", "id": self.counter, "method": method}
225
+ if params is not None:
226
+ body["params"] = params
227
+ return self._message(body)
228
+
229
+ def initialize(self):
230
+ with self._lock:
231
+ self.reset_session()
232
+ try:
233
+ result = self.request("initialize", {
234
+ "protocolVersion": self.protocol, "capabilities": {},
235
+ "clientInfo": {"name": "bookmark-research", "version": "0.5.0-beta.1"}})
236
+ if (not isinstance(result, dict) or not isinstance(result.get("protocolVersion"), str)
237
+ or result["protocolVersion"] not in self.PROTOCOLS):
238
+ raise McpError("MCP server selected an unsupported protocol version")
239
+ self.protocol = result["protocolVersion"]
240
+ self.initialized = True
241
+ self._message({"jsonrpc": "2.0", "method": "notifications/initialized"})
242
+ except Exception:
243
+ self.reset_session()
244
+ raise
245
+ return result
246
+
247
+ def list_tools(self):
248
+ with self._lock:
249
+ # An expired session definitively rejects metadata requests. Recover
250
+ # once, discarding earlier pages; never splice two catalogs together.
251
+ for recovery in range(2):
252
+ try:
253
+ return self._tool_pages()
254
+ except McpError as error:
255
+ if error.kind != "session_expired" or recovery:
256
+ raise
257
+ self._usage["session_recoveries"] += 1
258
+
259
+ def _tool_pages(self):
260
+ if not self.initialized:
261
+ self.initialize()
262
+ tools, names, cursors, cursor, size = [], set(), set(), None, 0
263
+ for _ in range(self.MAX_TOOL_PAGES):
264
+ result = self.request("tools/list", {"cursor": cursor} if cursor else {})
265
+ if not isinstance(result, dict) or not isinstance(result.get("tools"), list):
266
+ raise McpError("MCP tools/list returned an invalid tool list")
267
+ size += len(json.dumps(result, ensure_ascii=False).encode("utf-8"))
268
+ if size > self.MAX_BYTES or len(tools) + len(result["tools"]) > self.MAX_TOOLS:
269
+ raise McpError("MCP tool catalog exceeds size limit", "response_too_large")
270
+ for tool in result["tools"]:
271
+ if (not isinstance(tool, dict) or not isinstance(tool.get("name"), str)
272
+ or not tool["name"] or len(tool["name"]) > 256 or tool["name"] in names):
273
+ raise McpError("MCP tools/list returned invalid or duplicate tool names")
274
+ names.add(tool["name"])
275
+ tools.append(tool)
276
+ cursor = result.get("nextCursor")
277
+ if cursor is None or cursor == "":
278
+ return tools
279
+ if not isinstance(cursor, str) or len(cursor) > 4096 or cursor in cursors:
280
+ raise McpError("MCP tools/list returned an invalid or repeated pagination cursor")
281
+ cursors.add(cursor)
282
+ raise McpError("MCP tool catalog exceeds pagination limit", "response_too_large")
283
+
284
+ def call_tool(self, name, arguments):
285
+ with self._lock:
286
+ if not self.initialized:
287
+ self.initialize()
288
+ # One semantic attempt, including 404/429/timeouts. A response loss
289
+ # does not prove a billable tool was not executed.
290
+ result = self.request("tools/call", {"name": name, "arguments": arguments})
291
+ if not isinstance(result, dict):
292
+ raise McpError("MCP tool returned an invalid result")
293
+ if result.get("isError"):
294
+ raise McpError("MCP tool returned an error", "tool_error")
295
+ return result