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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +46 -0
- package/LICENSE +674 -0
- package/README.md +128 -0
- package/README.zh.md +126 -0
- package/bin/bookmark-research.js +30 -0
- package/config/providers.json +98 -0
- package/docs/bookmark-research-architecture.md +124 -0
- package/docs/cli-launcher-plan.md +58 -0
- package/docs/details.en.md +272 -0
- package/docs/details.md +93 -0
- package/docs/harness-compatibility.md +304 -0
- package/docs/harness-sources.json +342 -0
- package/docs/host-validation.json +298 -0
- package/docs/install-design-0.2.0.md +57 -0
- package/docs/installation.en.md +197 -0
- package/docs/installation.md +316 -0
- package/docs/instructions.en.md +42 -0
- package/docs/instructions.md +42 -0
- package/docs/mcp-aggregation-0.2.0.md +92 -0
- package/docs/multi-host-installation-plan.md +163 -0
- package/docs/prompt-reference.en.md +104 -0
- package/docs/prompt-reference.md +104 -0
- package/docs/provider-smoke-0.2.0.json +235 -0
- package/docs/release-notes-0.5.0-beta.1.md +33 -0
- package/docs/research-0.2.0.md +140 -0
- package/docs/research-sources-0.2.0.json +9360 -0
- package/docs/user-guide.en.md +74 -0
- package/docs/user-guide.md +74 -0
- package/docs/validation-0.2.0.md +126 -0
- package/docs/validation-0.4.0.md +154 -0
- package/docs/wiki-quality.md +105 -0
- package/docs/wiki-quality.zh.md +105 -0
- package/hosts/claude/runtime.js +13 -0
- package/hosts/codex/delegate.md +14 -0
- package/hosts/codex/prepare.py +61 -0
- package/hosts/dsh/plugin.js +36 -0
- package/hosts/dsh/runtime.js +14 -0
- package/hosts/dsh/workflow-call.py +45 -0
- package/hosts/pi/register-workflow.py +72 -0
- package/hosts/pi/runtime.js +18 -0
- package/hosts/shared/research-call.py +76 -0
- package/hosts/shared/research-flow.js +208 -0
- package/hosts/shared/workflow.json +15 -0
- package/install.sh +310 -0
- package/package.json +12 -0
- package/scripts/build_zip.py +143 -0
- package/scripts/export_bundle.py +496 -0
- package/scripts/host_assets.py +70 -0
- package/scripts/host_clients.py +196 -0
- package/scripts/host_install.py +277 -0
- package/scripts/install.py +593 -0
- package/scripts/launcher.py +186 -0
- package/scripts/verify_fixture.py +220 -0
- package/scripts/verify_live.py +62 -0
- package/scripts/verify_quality.py +87 -0
- package/skills/bookmark-research/SKILL.md +80 -0
- package/skills/bookmark-research/agents/openai.yaml +3 -0
- package/skills/bookmark-research/references/cli.md +150 -0
- package/skills/bookmark-research/references/deep-research.md +164 -0
- package/skills/bookmark-research/references/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/host-workflows.md +101 -0
- package/skills/bookmark-research/references/package-semantics.md +78 -0
- package/skills/bookmark-research/references/research-methods.md +51 -0
- package/skills/bookmark-research/references/research-services.md +72 -0
- package/skills/bookmark-research/references/research-workflow.md +57 -0
- package/skills/bookmark-research/references/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
- package/skills/bookmark-research/references/zh/cli.md +150 -0
- package/skills/bookmark-research/references/zh/deep-research.md +164 -0
- package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
- package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
- package/skills/bookmark-research/references/zh/research-methods.md +51 -0
- package/skills/bookmark-research/references/zh/research-services.md +70 -0
- package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
- package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
- package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
- package/src/archive.py +302 -0
- package/src/bookmark_index.py +913 -0
- package/src/cli.py +435 -0
- package/src/credentials.py +82 -0
- package/src/evaluation.py +337 -0
- package/src/jina.py +96 -0
- package/src/mcp_server.py +641 -0
- package/src/onboarding.py +352 -0
- package/src/provider_adapters.py +152 -0
- package/src/readiness.py +283 -0
- package/src/remote_mcp.py +295 -0
- package/src/research.py +1334 -0
- package/src/research_coverage.py +160 -0
- package/src/research_services.py +684 -0
- package/src/routing.py +202 -0
- package/src/search_results.py +244 -0
- package/src/service_http.py +139 -0
- package/src/settings.py +247 -0
- package/src/source_inputs.py +201 -0
- package/src/source_manager.py +348 -0
- package/src/source_watcher.py +68 -0
- package/src/textio.py +9 -0
- package/src/tui.py +586 -0
- package/src/web_search.py +603 -0
- package/src/wiki.py +383 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""Minimal, synchronous stdio MCP tools server; no third-party SDK required.
|
|
2
|
+
|
|
3
|
+
Each stdin/stdout message is one JSON-RPC object on one line. This server
|
|
4
|
+
implements initialization, ping, tools/list and tools/call, not resources,
|
|
5
|
+
prompts, sampling, tasks, HTTP transports or arbitrary SQL/command execution.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from settings import Settings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
PROTOCOLS = ("2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25")
|
|
17
|
+
MAX_MESSAGE_CHARS = 256 * 1024
|
|
18
|
+
MAX_RESULT_CHARS = 2 * 1024 * 1024
|
|
19
|
+
PROVIDER_NAMES = Settings.PROVIDERS
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _text_schema(maximum=2048):
|
|
23
|
+
return {"type": "string", "minLength": 1, "maxLength": maximum}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _array_schema(item, maximum, minimum=0):
|
|
27
|
+
return {"type": "array", "items": item, "minItems": minimum, "maxItems": maximum}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _object_schema(properties, required=()):
|
|
31
|
+
return {"type": "object", "properties": properties, "required": list(required), "additionalProperties": False}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
IDENTIFIER = _text_schema(512)
|
|
35
|
+
PROVIDERS = dict(_array_schema({"type": "string", "enum": list(PROVIDER_NAMES)}, len(PROVIDER_NAMES), 1), uniqueItems=True)
|
|
36
|
+
SETTINGS_SCHEMA = _object_schema({
|
|
37
|
+
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60},
|
|
38
|
+
"search": _object_schema({"providers": PROVIDERS,
|
|
39
|
+
"fallback_providers": {**PROVIDERS, "minItems": 0},
|
|
40
|
+
"limit_per_target": {"type": "integer", "minimum": 1, "maximum": 20}}),
|
|
41
|
+
"fetch": _object_schema({"provider": {"type": "string", "enum": list(PROVIDER_NAMES)},
|
|
42
|
+
"providers": PROVIDERS,
|
|
43
|
+
"max_characters": {"type": "integer", "minimum": 100, "maximum": 100000}}),
|
|
44
|
+
"archive": _object_schema({"enabled": {"type": "boolean"}, "directory": _text_schema(4096)}),
|
|
45
|
+
"research": _object_schema({"depth": {"type": "string", "enum": ["auto", "quick", "agentic", "deep"]},
|
|
46
|
+
"prefer_host_workflows": {"type": "boolean"},
|
|
47
|
+
"response_language": {"type": "string", "enum": ["auto", "en", "zh"]},
|
|
48
|
+
"methods": dict(_array_schema({"type": "string", "enum": ["comparative_analysis", "fact_check", "benchmark_review", "wiki_synthesis"]}, 4, 1), uniqueItems=True)}),
|
|
49
|
+
"professional_research": _object_schema({"enabled": {"type": "boolean"},
|
|
50
|
+
"provider": {"type": ["string", "null"], "enum": ["openai", "parallel", None]},
|
|
51
|
+
"openai": _object_schema({"model": {"type": "string", "enum": ["o3-deep-research", "o4-mini-deep-research"]},
|
|
52
|
+
"max_tool_calls": {"type": "integer", "minimum": 1, "maximum": 1000}}),
|
|
53
|
+
"parallel": _object_schema({"processor": _text_schema(64)})}),
|
|
54
|
+
"wiki": _object_schema({"directory": _text_schema(4096)}),
|
|
55
|
+
"readiness": _object_schema({"mode": {"type": "string", "enum": ["cached", "always", "manual"]},
|
|
56
|
+
"ttl_seconds": {"type": "integer", "minimum": 60, "maximum": 86400},
|
|
57
|
+
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30}}),
|
|
58
|
+
})
|
|
59
|
+
TOOL_SCHEMAS = {
|
|
60
|
+
"get_settings": _object_schema({}),
|
|
61
|
+
"update_settings": _object_schema({"changes": SETTINGS_SCHEMA}, ["changes"]),
|
|
62
|
+
"research_readiness": _object_schema({"providers": PROVIDERS,
|
|
63
|
+
"service": {"type": "string", "enum": ["openai", "parallel"]},
|
|
64
|
+
"refresh": {"type": "boolean", "default": False}, "offline": {"type": "boolean", "default": False},
|
|
65
|
+
"test_retrieval": {"type": "boolean", "default": False},
|
|
66
|
+
"host": {"type": "string", "enum": ["codex", "claude_code", "pi", "dsh", "unknown"]},
|
|
67
|
+
"observed_tools": _array_schema(_text_schema(300), 1000)}),
|
|
68
|
+
"sync_package": _object_schema({"package_path": _text_schema(4096), "source_id": IDENTIFIER,
|
|
69
|
+
"mode": {"type": "string", "enum": ["snapshot", "live"]},
|
|
70
|
+
"completeness": {"type": "string", "enum": ["partial", "complete"]}}, ["package_path"]),
|
|
71
|
+
"source_history": _object_schema({"source_id": IDENTIFIER,
|
|
72
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
|
73
|
+
"offset": {"type": "integer", "minimum": 0, "maximum": 1000000000}}, ["source_id"]),
|
|
74
|
+
"search_bookmarks": _object_schema({
|
|
75
|
+
"source_id": IDENTIFIER, "targets": _array_schema(_text_schema(), 100),
|
|
76
|
+
"section": IDENTIFIER, "group_id": IDENTIFIER, "folder_id": IDENTIFIER,
|
|
77
|
+
"tags": _array_schema(_text_schema(), 100),
|
|
78
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 20},
|
|
79
|
+
"offset": {"type": "integer", "minimum": 0, "maximum": 1000000000, "default": 0},
|
|
80
|
+
"refresh": {"type": "boolean", "default": True},
|
|
81
|
+
}, ["source_id"]),
|
|
82
|
+
"get_context": _object_schema({
|
|
83
|
+
"source_id": IDENTIFIER, "section": IDENTIFIER, "group_id": IDENTIFIER,
|
|
84
|
+
"item_id": IDENTIFIER, "refresh": {"type": "boolean", "default": True},
|
|
85
|
+
}, ["source_id"]),
|
|
86
|
+
"index_status": _object_schema({"source_id": IDENTIFIER}),
|
|
87
|
+
"search_web": _object_schema({
|
|
88
|
+
"targets": _array_schema(_object_schema({"target": _text_schema(200), "query": _text_schema(2000)}, ["target", "query"]), 12, 1),
|
|
89
|
+
"providers": PROVIDERS,
|
|
90
|
+
"limit_per_target": {"type": "integer", "minimum": 1, "maximum": 20},
|
|
91
|
+
}, ["targets"]),
|
|
92
|
+
"fetch_web": _object_schema({
|
|
93
|
+
"urls": _array_schema(_text_schema(8192), 8, 1),
|
|
94
|
+
"provider": {"type": "string", "enum": list(PROVIDER_NAMES)},
|
|
95
|
+
"archive": {"type": "boolean"},
|
|
96
|
+
"max_characters": {"type": "integer", "minimum": 100, "maximum": 100000},
|
|
97
|
+
"raw": {"type": "boolean", "default": False,
|
|
98
|
+
"description": "Return full provider envelopes instead of one selected text per URL. Archives always retain actual responses when enabled."},
|
|
99
|
+
}, ["urls"]),
|
|
100
|
+
"search_providers": _object_schema({
|
|
101
|
+
"probe": {"type": "boolean", "default": False}, "providers": PROVIDERS,
|
|
102
|
+
}),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
TOOL_DESCRIPTIONS = {
|
|
106
|
+
"get_settings": "Read effective user preferences and their persistent config path without network access or creating files. Per-call options override saved preferences.",
|
|
107
|
+
"update_settings": "Persist requested preferences outside the plugin and canvas packages: retrieval, archiving, research routes/methods, optional professional services and Wiki storage. Applies to subsequent calls without restart. Does not store credentials or modify host integrations.",
|
|
108
|
+
"research_readiness": "Check retrieval and optional research service readiness before each new web research question. Respects cached/always/manual preferences; refresh forces checks and offline prevents network. Reports missing credentials, catalog reachability and the exact scope of verified auth separately. observed_tools establish host tool visibility, not OAuth login. test_retrieval additionally uses provider quota for a sample search/read; no professional research job is started.",
|
|
109
|
+
"sync_package": "Import a directory, ZIP or single section JSON, retaining a managed snapshot outside the original. Reuse source_id for a moved or partial export of the same canvas; path aliases are remembered. New exports default to snapshot; Git directories default to live monitoring. Existing sources retain their mode. completeness=partial preserves absent cards; complete reconciles a full mirror. Single cards must be partial. Source files are never modified.",
|
|
110
|
+
"source_history": "List saved source versions and their complete managed snapshot paths, including data retained from partial imports. Reimport a snapshot path to recover its data and recorded relationships. This does not refresh sources or change existing research inventories.",
|
|
111
|
+
"search_bookmarks": "Search bookmark metadata with literal title/URL/note/tag/folder-path matching and exact SQL scopes. Targets have independent totals and pages; this is not semantic webpage-body retrieval. Refresh checks live directories by default; snapshots remain usable after the original export disappears. Inspect source.state for pending changes or errors. refresh=false uses the last synchronized index, not an arbitrary historical version.",
|
|
112
|
+
"get_context": "Read section headers, bookmark metadata, folder ancestry, geometric group membership and directed canvas edges. Copy anchors share their primary tree. Refresh is enabled by default.",
|
|
113
|
+
"index_status": "Read source modes, original input availability, saved snapshots, pending files, last check/error, counts and local monitor status. Does not refresh or fetch webpages. unchecked means a live directory has not been checked recently.",
|
|
114
|
+
"search_web": "Search with saved primary providers concurrently, then saved fallback providers only for queries with no usable result (defaults: Exa + Parallel, then Tavily + keyed Jina). Missing fallback credentials are skipped. Explicit providers restrict the call to that list. Search snippets are not verified page evidence; result pages are not automatically fetched.",
|
|
115
|
+
"fetch_web": "Read known URLs; pages contains one selected text per URL, with attempt statuses and archive paths. Omit provider for the saved waterfall (initially Exa then concurrent Parallel/Jina on unresolved URLs); an explicit provider selects one service. raw=true returns full provider envelopes; archive=false disables saving. Review page identity and meaning before citing.",
|
|
116
|
+
"search_providers": "Describe Exa/Parallel/Tavily MCP and Jina HTTP capabilities and authentication per operation. Jina Reader supports anonymous access; Jina Search requires JINA_API_KEY. probe=true discovers MCP catalogs; HTTP mappings are local. Discovery does not prove successful retrieval.",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
RESEARCH_ID = _text_schema(80)
|
|
120
|
+
CLAIM_IDS = dict(_array_schema(RESEARCH_ID, 100, 1), uniqueItems=True)
|
|
121
|
+
RESEARCH_ENTRY = _object_schema({
|
|
122
|
+
"kind": {"type": "string", "enum": ["claim", "answer", "gap", "conflict", "resolution", "question", "interruption", "source_review", "retraction", "resume", "inventory_review", "external_run"]},
|
|
123
|
+
"question_id": RESEARCH_ID, "id": RESEARCH_ID, "question": _text_schema(4000),
|
|
124
|
+
"statement": _text_schema(4000), "answer": _text_schema(12000), "text": _text_schema(4000),
|
|
125
|
+
"claim_ids": dict(_array_schema(RESEARCH_ID, 100), uniqueItems=True), "conflict_id": RESEARCH_ID, "operation_id": RESEARCH_ID,
|
|
126
|
+
"claim_id": RESEARCH_ID, "source_id": RESEARCH_ID,
|
|
127
|
+
"verdict": {"type": "string", "enum": ["accepted", "rejected", "uncertain"]},
|
|
128
|
+
"confidence": {"type": "string", "enum": ["low", "medium", "high"]}, "inference": {"type": "boolean"},
|
|
129
|
+
"inventory_id": RESEARCH_ID, "disposition": {"type": "string", "enum": ["reviewed", "excluded", "blocked"]},
|
|
130
|
+
"question_ids": dict(_array_schema(RESEARCH_ID, 24), uniqueItems=True),
|
|
131
|
+
"source_ids": dict(_array_schema(RESEARCH_ID, 12), uniqueItems=True),
|
|
132
|
+
"reason_code": {"type": "string", "enum": ["out_of_scope", "non_content"]},
|
|
133
|
+
"provider": _text_schema(80), "run_id": _text_schema(512),
|
|
134
|
+
"status": {"type": "string", "enum": ["prepared", "pending", "queued", "in_progress", "completed", "cancelled", "error", "unknown_outcome"]},
|
|
135
|
+
"result": {"type": "object", "properties": {}, "additionalProperties": True},
|
|
136
|
+
"artifact_path": _text_schema(4096),
|
|
137
|
+
"citations": _array_schema(_object_schema({"source_id": RESEARCH_ID, "quote": _text_schema(4000)},
|
|
138
|
+
["source_id", "quote"]), 12),
|
|
139
|
+
}, ["kind"])
|
|
140
|
+
RESEARCH_BATCH_ENTRY = _object_schema({**RESEARCH_ENTRY["properties"], "kind": {
|
|
141
|
+
"type": "string", "enum": [kind for kind in RESEARCH_ENTRY["properties"]["kind"]["enum"]
|
|
142
|
+
if kind not in ("resume", "external_run")]}}, ["kind"])
|
|
143
|
+
TOOL_SCHEMAS.update({
|
|
144
|
+
"research_start": _object_schema({
|
|
145
|
+
"brief": _text_schema(12000), "scope": {"type": "string", "maxLength": 12000},
|
|
146
|
+
"questions": _array_schema(_object_schema({"id": RESEARCH_ID, "question": _text_schema(4000)},
|
|
147
|
+
["id", "question"]), 24, 1),
|
|
148
|
+
"providers": PROVIDERS, "source_ids": _array_schema(IDENTIFIER, 100),
|
|
149
|
+
"urls": {**_array_schema(_text_schema(8192), 10000, 1),
|
|
150
|
+
"description": "Original bookmark URL list instead of indexed source_ids. Freezes every URL and duplicate position for coverage; no package import required."},
|
|
151
|
+
"scope_mode": {"type": "string", "enum": ["whole", "subset"]},
|
|
152
|
+
"inventory_ids": dict(_array_schema(RESEARCH_ID, 10000), uniqueItems=True),
|
|
153
|
+
"bookmark_refs": _array_schema(_object_schema({"source_id": IDENTIFIER, "section_id": IDENTIFIER,
|
|
154
|
+
"item_id": IDENTIFIER}, ["source_id", "section_id", "item_id"]), 100),
|
|
155
|
+
"budget": _object_schema({"max_search_calls": {"type": "integer", "minimum": 0, "maximum": 120},
|
|
156
|
+
"max_fetch_calls": {"type": "integer", "minimum": 0, "maximum": 80},
|
|
157
|
+
"max_rounds": {"type": "integer", "minimum": 0, "maximum": 40}}),
|
|
158
|
+
}, ["brief", "questions"]),
|
|
159
|
+
"research_status": _object_schema({"research_id": RESEARCH_ID,
|
|
160
|
+
"section": {"type": "string", "enum": ["questions", "claims", "sources", "operations", "conflicts", "bookmark_context", "events", "inventory", "inventory_reviews", "external_runs"]},
|
|
161
|
+
"offset": {"type": "integer", "minimum": 0, "maximum": 10000000},
|
|
162
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 100}}),
|
|
163
|
+
"research_search": _object_schema({
|
|
164
|
+
"research_id": RESEARCH_ID, "operation_id": RESEARCH_ID, "providers": PROVIDERS,
|
|
165
|
+
"queries": _array_schema(_object_schema({"question_id": RESEARCH_ID, "query": _text_schema(2000)},
|
|
166
|
+
["question_id", "query"]), 12, 1),
|
|
167
|
+
"limit_per_target": {"type": "integer", "minimum": 1, "maximum": 20},
|
|
168
|
+
}, ["research_id", "operation_id", "queries"]),
|
|
169
|
+
"research_fetch": _object_schema({
|
|
170
|
+
"research_id": RESEARCH_ID, "operation_id": RESEARCH_ID, "question_id": RESEARCH_ID,
|
|
171
|
+
"urls": _array_schema(_text_schema(8192), 8, 1),
|
|
172
|
+
"provider": {"type": "string", "enum": list(PROVIDER_NAMES)},
|
|
173
|
+
"max_characters": {"type": "integer", "minimum": 100, "maximum": 100000},
|
|
174
|
+
}, ["research_id", "operation_id", "question_id", "urls"]),
|
|
175
|
+
"research_source": _object_schema({
|
|
176
|
+
"research_id": RESEARCH_ID, "source_id": RESEARCH_ID,
|
|
177
|
+
"offset": {"type": "integer", "minimum": 0, "maximum": 10000000},
|
|
178
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 50000},
|
|
179
|
+
}, ["research_id", "source_id"]),
|
|
180
|
+
"research_record": _object_schema({"research_id": RESEARCH_ID, "entry": RESEARCH_ENTRY,
|
|
181
|
+
"entries": _array_schema(RESEARCH_BATCH_ENTRY, 50, 1),
|
|
182
|
+
"batch_id": {**RESEARCH_ID, "description": "Optional stable ID for safe batch retries; reuse only with identical entries."}},
|
|
183
|
+
["research_id"]),
|
|
184
|
+
"research_inventory": _object_schema({"research_id": RESEARCH_ID,
|
|
185
|
+
"inventory_ids": dict(_array_schema(RESEARCH_ID, 100, 1), uniqueItems=True),
|
|
186
|
+
"offset": {"type": "integer", "minimum": 0, "maximum": 10000000},
|
|
187
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 100}}, ["research_id"]),
|
|
188
|
+
"research_coverage": _object_schema({"research_id": RESEARCH_ID,
|
|
189
|
+
"filter": {"type": "string", "enum": ["all", "missing", "unread", "unreviewed", "blocked", "excluded", "reviewed"]},
|
|
190
|
+
"offset": {"type": "integer", "minimum": 0, "maximum": 10000000},
|
|
191
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 100}}, ["research_id"]),
|
|
192
|
+
"research_import_evidence": _object_schema({"research_id": RESEARCH_ID, "operation_id": RESEARCH_ID,
|
|
193
|
+
"question_id": RESEARCH_ID, "url": _text_schema(8192), "text": _text_schema(200000),
|
|
194
|
+
"provenance": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["page", "archived_page", "local_document", "external_report"]}},
|
|
195
|
+
"required": ["kind"], "additionalProperties": True},
|
|
196
|
+
"inventory_ids": dict(_array_schema(RESEARCH_ID, 10000), uniqueItems=True),
|
|
197
|
+
"title": {"type": "string", "maxLength": 2000}},
|
|
198
|
+
["research_id", "operation_id", "question_id", "url", "text", "provenance"]),
|
|
199
|
+
"research_finish": _object_schema({
|
|
200
|
+
"research_id": RESEARCH_ID, "summary": _text_schema(16000),
|
|
201
|
+
"status": {"type": "string", "enum": ["completed", "incomplete", "cancelled"]},
|
|
202
|
+
"limitations": _array_schema(_text_schema(4000), 100),
|
|
203
|
+
}, ["research_id", "summary"]),
|
|
204
|
+
})
|
|
205
|
+
TOOL_DESCRIPTIONS.update({
|
|
206
|
+
"research_start": "Start host-led research with questions, budgets and frozen original inputs. Supply urls for ordinary bookmarks or indexed source_ids for Canvas packages; preserves every URL and duplicate instance by default. A subset requires scope_mode=subset explicitly. With neither input, tracks questions only. Makes no network calls or background worker.",
|
|
207
|
+
"research_status": "List saved research sessions or read a bounded progress overview, including source_freshness against the current indexed input. Requires review when the input changed; frozen evidence is not rewritten. For complete entries, select section and paginate; overview shows at most 20 previews per collection. No network requests. A pending intent is not proof that a research worker is running.",
|
|
208
|
+
"research_search": "Execute one search round using frozen primary providers and fallbacks for unresolved queries. Explicit providers disable fallback. Reserve each provider/question/query before access, including budget-limited fallbacks; inspect unresolved_queries and remaining_attempts. Identical operation_id and arguments replay the saved outcome without resubmission.",
|
|
209
|
+
"research_fetch": "Read URLs and retain each provider's actual response and identified text as session evidence. Omit provider to try session providers in a waterfall: first provider, then remaining providers concurrently for unresolved URLs, reserving each attempt within the fetch budget. Explicit provider selects one service. Inspect unresolved_urls and remaining_providers. Reuse operation_id for safe outcome lookup.",
|
|
210
|
+
"research_source": "Read a saved research source by ID with pagination and SHA-256 verification. This reads extracted text, which may be partial; it does not contact a provider.",
|
|
211
|
+
"research_record": "Prefer entries for 1-50 evidence records in one call. Provide exactly one of entry or entries. A batch validates in order and saves atomically; any invalid entry saves nothing. Returns compact IDs in input order. Use batch_id for safe retries with identical entries. Only single entry supports resume/external_run; resume preserves prior reports and budgets. Kinds have different required fields; see deep-research.md. Quote matching proves presence, not entailment.",
|
|
212
|
+
"research_inventory": "Read the frozen original URL scope with stable inventory IDs, all bookmark instances and context. Paginate until next_offset is null; a page is not the whole research scope.",
|
|
213
|
+
"research_coverage": "Compare substantive source reviews against the frozen input. Reports accounted, usable-text, reviewed and question coverage separately; paginate missing/unread/unreviewed IDs for follow-up. A provider or host run completing does not complete source coverage.",
|
|
214
|
+
"research_import_evidence": "Import explicitly supplied original text or an external report with provenance and idempotent operation_id. Evidence starts unreviewed. External reports never count as reading the original URLs they cite. Does not fetch remote content or modify packages.",
|
|
215
|
+
"research_finish": "Write a report, source manifest and coverage. Completed requires evidence-backed input reviews, supported answers and no unresolved conflicts, pending operations or active/unknown external runs. Explicit exclusions remain visible and do not increase substantive coverage. Use incomplete for material gaps.",
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
OPEN_OBJECT = {"type": "object", "properties": {}, "additionalProperties": True}
|
|
220
|
+
SERVICE_PROVIDER = {"type": "string", "enum": ["openai", "parallel"]}
|
|
221
|
+
SERVICE_OPTIONS = _object_schema({
|
|
222
|
+
"model": {"type": "string", "enum": ["o3-deep-research", "o4-mini-deep-research"]},
|
|
223
|
+
"max_tool_calls": {"type": "integer", "minimum": 1, "maximum": 1000},
|
|
224
|
+
"store": {"type": "boolean"},
|
|
225
|
+
"vector_store_ids": _array_schema(_text_schema(100), 2, 1),
|
|
226
|
+
"processor": _text_schema(64), "output_schema": _text_schema(12000),
|
|
227
|
+
})
|
|
228
|
+
SERVICE_INPUT = {"research_id": RESEARCH_ID, "input": _text_schema(50000), "provider": SERVICE_PROVIDER,
|
|
229
|
+
"question_id": RESEARCH_ID, "inventory_ids": dict(_array_schema(RESEARCH_ID, 10000), uniqueItems=True),
|
|
230
|
+
"options": SERVICE_OPTIONS}
|
|
231
|
+
WIKI_PAGE = _object_schema({
|
|
232
|
+
"title": _text_schema(300), "kind": {"type": "string", "enum": ["topic", "entity"]},
|
|
233
|
+
"sections": _array_schema(_object_schema({"heading": _text_schema(300), "text": _text_schema(12000),
|
|
234
|
+
"claims": _array_schema(_object_schema({"research_id": RESEARCH_ID, "claim_id": RESEARCH_ID},
|
|
235
|
+
["research_id", "claim_id"]), 12, 1)}, ["heading", "text", "claims"]), 16, 1),
|
|
236
|
+
"links": _array_schema(_object_schema({"page_id": RESEARCH_ID, "relation": _text_schema(500)}, ["page_id", "relation"]), 32),
|
|
237
|
+
"review": _object_schema({"method": {"type": "string", "enum": ["human", "model"]},
|
|
238
|
+
"reviewer": _text_schema(300), "note": _text_schema(4000)}, ["method", "reviewer", "note"]),
|
|
239
|
+
}, ["title", "kind", "sections", "links", "review"])
|
|
240
|
+
TOOL_SCHEMAS.update({
|
|
241
|
+
"research_route": _object_schema({
|
|
242
|
+
"depth": {"type": "string", "enum": ["auto", "quick", "agentic", "deep"]},
|
|
243
|
+
"host": {"type": "string", "enum": ["codex", "claude_code", "pi", "dsh", "unknown"]},
|
|
244
|
+
"task_shape": {"type": "string", "enum": ["lookup", "investigation", "batch_research"]},
|
|
245
|
+
"observed_tools": _array_schema(_text_schema(300), 1000),
|
|
246
|
+
"available_commands": _array_schema(_text_schema(300), 1000),
|
|
247
|
+
"installed_extensions": _array_schema(_text_schema(300), 1000),
|
|
248
|
+
"failed_routes": dict(_array_schema(_text_schema(300), 1000), uniqueItems=True),
|
|
249
|
+
"provider": SERVICE_PROVIDER,
|
|
250
|
+
}),
|
|
251
|
+
"research_services": _object_schema({"provider": SERVICE_PROVIDER}),
|
|
252
|
+
"research_service_prepare": _object_schema(SERVICE_INPUT, ["research_id", "input"]),
|
|
253
|
+
"research_service_start": _object_schema({**SERVICE_INPUT, "operation_id": RESEARCH_ID},
|
|
254
|
+
["research_id", "operation_id", "input"]),
|
|
255
|
+
"research_service_status": _object_schema({"external_id": RESEARCH_ID, "refresh": {"type": "boolean"}}, ["external_id"]),
|
|
256
|
+
"research_service_result": _object_schema({"external_id": RESEARCH_ID, "refresh": {"type": "boolean"},
|
|
257
|
+
"offset": {"type": "integer", "minimum": 0, "maximum": 10000000},
|
|
258
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 50000}}, ["external_id"]),
|
|
259
|
+
"research_service_cancel": _object_schema({"external_id": RESEARCH_ID}, ["external_id"]),
|
|
260
|
+
"research_service_attach": _object_schema({"research_id": RESEARCH_ID, "operation_id": RESEARCH_ID,
|
|
261
|
+
"provider": SERVICE_PROVIDER, "run_id": _text_schema(200), "question_id": RESEARCH_ID},
|
|
262
|
+
["research_id", "operation_id", "provider", "run_id"]),
|
|
263
|
+
"research_service_import": _object_schema({"external_id": RESEARCH_ID, "operation_id": RESEARCH_ID,
|
|
264
|
+
"question_id": RESEARCH_ID}, ["external_id", "operation_id"]),
|
|
265
|
+
"wiki_write": _object_schema({"page_id": RESEARCH_ID, "page": WIKI_PAGE, "change_note": _text_schema(4000),
|
|
266
|
+
"expected_revision": {"type": "integer", "minimum": 0, "maximum": 1000000}},
|
|
267
|
+
["page_id", "page", "change_note"]),
|
|
268
|
+
"wiki_get": _object_schema({"page_id": RESEARCH_ID, "revision": {"type": "integer", "minimum": 1, "maximum": 1000000}}, ["page_id"]),
|
|
269
|
+
"wiki_list": _object_schema({"offset": {"type": "integer", "minimum": 0, "maximum": 10000000},
|
|
270
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 100}}),
|
|
271
|
+
"wiki_search": _object_schema({"query": _text_schema(2000), "offset": {"type": "integer", "minimum": 0, "maximum": 10000000},
|
|
272
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 100}}, ["query"]),
|
|
273
|
+
"wiki_lint": _object_schema({"page_id": RESEARCH_ID}),
|
|
274
|
+
"evaluate_research": _object_schema({"suite": OPEN_OBJECT, "runs": _array_schema(OPEN_OBJECT, 1000, 1),
|
|
275
|
+
"judgments": _array_schema(OPEN_OBJECT, 1000)}, ["suite", "runs"]),
|
|
276
|
+
})
|
|
277
|
+
TOOL_DESCRIPTIONS.update({
|
|
278
|
+
"research_route": "Plan host-led quick, agentic or deep research. Ordinary agentic work starts with the current model's search/read/evaluate loop; deep or grouped work prefers available host collaboration. Exa Agent/Parallel Task MCPs and configured research APIs are optional support. Explicit provider is exclusive. Pass cumulative failed_routes only after confirmed failure; observe active/unknown runs. Returns the host process and tool steps; starts no work.",
|
|
279
|
+
"research_services": "Describe optional OpenAI Deep Research and Parallel Task API configuration. No network access; configured credentials are not proof of authentication. These APIs are separate from anonymous Search MCP access.",
|
|
280
|
+
"research_service_prepare": "Prepare the exact provider payload and source scope without network access. Only the explicit input and source URLs are shared; local bookmark notes, paths and files are not uploaded. Inspect this payload before starting a service run.",
|
|
281
|
+
"research_service_start": "Start one configured, authenticated provider research run and save its reference. operation_id prevents automatic duplicate submission, including lost responses. Remote execution belongs to the provider; the plugin starts no worker or polling loop.",
|
|
282
|
+
"research_service_status": "Read the saved provider-run reference. refresh=true observes the same remote run once. Observation timeouts preserve the last known state and never create another run.",
|
|
283
|
+
"research_service_result": "Read the archived provider report with pagination, raw result provenance and unverified citations. refresh=true makes one result request; Parallel result waits at most one server-side second. Does not mark original sources reviewed.",
|
|
284
|
+
"research_service_cancel": "Request cancellation through the provider's documented interface. OpenAI supports this; Parallel Task cancellation is not established by this adapter. Local observation failure never means the remote job was cancelled.",
|
|
285
|
+
"research_service_attach": "Attach an independently known provider run ID without starting remote work. Can recover a missing create response or reference a run started through an existing host integration. Status remains unverified until observed.",
|
|
286
|
+
"research_service_import": "Import a saved completed provider report into a research session as unreviewed external-report evidence. Original URL coverage is unchanged; read and assess cited originals separately.",
|
|
287
|
+
"wiki_write": "Publish an authored topic/entity page outside source packages, with reviewed active claims, source provenance, relationships and an immutable revision. Supply the current expected_revision when updating. Semantic support requires the named human/model review.",
|
|
288
|
+
"wiki_get": "Read a Wiki page or an immutable earlier revision with source/claim links and revision metadata. validation.status=needs_review signals changed bookmark input after the cited research; this does not automatically revise the page or refetch webpages.",
|
|
289
|
+
"wiki_list": "List paginated Wiki pages and current revision metadata without network access.",
|
|
290
|
+
"wiki_search": "Search authored Wiki body text and return provenance. Invalidated or retracted evidence is excluded; this is literal full-text retrieval, not vector search.",
|
|
291
|
+
"wiki_lint": "Check Wiki evidence hashes, active claims, source review states and cross-page links. Reports semantic review declarations separately from mechanical integrity checks.",
|
|
292
|
+
"evaluate_research": "Evaluate explicit benchmark outputs and reviewer labels against a supplied rubric. Computes coverage, labeled answer precision/recall/F1, semantic citation-label rates, report scores and measured cost/latency/variance. Missing judgments remain unknown; this tool runs no models or providers.",
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class RpcError(Exception):
|
|
297
|
+
def __init__(self, code, message):
|
|
298
|
+
super().__init__(message)
|
|
299
|
+
self.code = code
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _validate(schema, value, location="arguments"):
|
|
303
|
+
declared = schema["type"]
|
|
304
|
+
kinds = declared if isinstance(declared, list) else [declared]
|
|
305
|
+
matches = {"object": isinstance(value, dict), "array": isinstance(value, list),
|
|
306
|
+
"string": isinstance(value, str), "boolean": isinstance(value, bool),
|
|
307
|
+
"integer": isinstance(value, int) and not isinstance(value, bool),
|
|
308
|
+
"number": (isinstance(value, int) and not isinstance(value, bool)) or (isinstance(value, float) and math.isfinite(value)),
|
|
309
|
+
"null": value is None}
|
|
310
|
+
kind = next((name for name in kinds if matches.get(name, False)), None)
|
|
311
|
+
if kind is None:
|
|
312
|
+
raise RpcError(-32602, "%s must be %s" % (location, " or ".join(kinds)))
|
|
313
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
314
|
+
raise RpcError(-32602, "%s has an unsupported value" % location)
|
|
315
|
+
if kind == "object":
|
|
316
|
+
properties = schema.get("properties", {})
|
|
317
|
+
unknown = set(value) - set(properties)
|
|
318
|
+
if unknown and schema.get("additionalProperties") is False:
|
|
319
|
+
raise RpcError(-32602, "%s contains unknown fields: %s" % (location, ", ".join(sorted(unknown))))
|
|
320
|
+
for key in schema.get("required", []):
|
|
321
|
+
if key not in value:
|
|
322
|
+
raise RpcError(-32602, "%s.%s is required" % (location, key))
|
|
323
|
+
for key, child in value.items():
|
|
324
|
+
if key in properties:
|
|
325
|
+
_validate(properties[key], child, location + "." + key)
|
|
326
|
+
elif kind == "array":
|
|
327
|
+
if not schema.get("minItems", 0) <= len(value) <= schema.get("maxItems", MAX_MESSAGE_CHARS):
|
|
328
|
+
raise RpcError(-32602, "%s has too few or too many entries" % location)
|
|
329
|
+
if schema.get("uniqueItems") and len(set(json.dumps(item, sort_keys=True) for item in value)) != len(value):
|
|
330
|
+
raise RpcError(-32602, "%s must contain unique entries" % location)
|
|
331
|
+
for index, item in enumerate(value):
|
|
332
|
+
_validate(schema["items"], item, "%s[%s]" % (location, index))
|
|
333
|
+
elif kind == "string":
|
|
334
|
+
if "\x00" in value or not schema.get("minLength", 0) <= len(value) <= schema.get("maxLength", MAX_MESSAGE_CHARS):
|
|
335
|
+
raise RpcError(-32602, "%s has invalid length or contains NUL" % location)
|
|
336
|
+
if schema.get("minLength", 0) > 0 and not value.strip():
|
|
337
|
+
raise RpcError(-32602, "%s must not be blank" % location)
|
|
338
|
+
elif kind in ("integer", "number"):
|
|
339
|
+
if not schema.get("minimum", value) <= value <= schema.get("maximum", value):
|
|
340
|
+
raise RpcError(-32602, "%s is out of range" % location)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _params(value, allowed, required=()):
|
|
344
|
+
if not isinstance(value, dict):
|
|
345
|
+
raise RpcError(-32602, "params must be an object")
|
|
346
|
+
unknown = set(value) - set(allowed) - {"_meta"}
|
|
347
|
+
if unknown:
|
|
348
|
+
raise RpcError(-32602, "Unknown params: " + ", ".join(sorted(unknown)))
|
|
349
|
+
if "_meta" in value and not isinstance(value["_meta"], dict):
|
|
350
|
+
raise RpcError(-32602, "params._meta must be an object")
|
|
351
|
+
for key in required:
|
|
352
|
+
if key not in value:
|
|
353
|
+
raise RpcError(-32602, "Missing param: " + key)
|
|
354
|
+
return value
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _error(request_id, code, message):
|
|
358
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class StdioMcpServer:
|
|
362
|
+
"""One client connection, with lazy local-index and web-provider handles."""
|
|
363
|
+
|
|
364
|
+
def __init__(self, db_path, stderr=None, settings=None):
|
|
365
|
+
self.db_path = db_path
|
|
366
|
+
self.stderr = stderr if stderr is not None else sys.stderr
|
|
367
|
+
self.protocol = None
|
|
368
|
+
self.ready = False
|
|
369
|
+
self._index = None
|
|
370
|
+
self._source_manager = None
|
|
371
|
+
self._source_watcher = None
|
|
372
|
+
self._providers = None
|
|
373
|
+
self._research_sessions = None
|
|
374
|
+
self._research_services = None
|
|
375
|
+
self._wiki_store = None
|
|
376
|
+
self.settings = settings if settings is not None else Settings()
|
|
377
|
+
|
|
378
|
+
def close(self):
|
|
379
|
+
if self._source_watcher is not None:
|
|
380
|
+
self._source_watcher.close()
|
|
381
|
+
self._source_watcher = None
|
|
382
|
+
self._source_manager = None
|
|
383
|
+
if self._index is not None:
|
|
384
|
+
self._index.close()
|
|
385
|
+
self._index = None
|
|
386
|
+
|
|
387
|
+
def _local(self):
|
|
388
|
+
if self._index is None:
|
|
389
|
+
from bookmark_index import BookmarkIndex
|
|
390
|
+
self._index = BookmarkIndex(self.db_path)
|
|
391
|
+
return self._index
|
|
392
|
+
|
|
393
|
+
def _sources(self):
|
|
394
|
+
if self._source_manager is None:
|
|
395
|
+
from source_manager import SourceManager
|
|
396
|
+
self._source_manager = SourceManager(self._local())
|
|
397
|
+
return self._source_manager
|
|
398
|
+
|
|
399
|
+
def _start_watcher(self):
|
|
400
|
+
if self._source_watcher is None and str(self.db_path) != ":memory:" and Path(self.db_path).is_file():
|
|
401
|
+
from source_watcher import SourceWatcher
|
|
402
|
+
self._source_watcher = SourceWatcher(self.db_path).start()
|
|
403
|
+
|
|
404
|
+
def _web(self):
|
|
405
|
+
if self._providers is None:
|
|
406
|
+
from web_search import SearchProviders
|
|
407
|
+
self._providers = SearchProviders(settings=self.settings)
|
|
408
|
+
return self._providers
|
|
409
|
+
|
|
410
|
+
def _research(self):
|
|
411
|
+
if self._research_sessions is None:
|
|
412
|
+
from research import ResearchSessions
|
|
413
|
+
self._research_sessions = ResearchSessions(settings=self.settings, engine=self._web(), db_path=self.db_path)
|
|
414
|
+
return self._research_sessions
|
|
415
|
+
|
|
416
|
+
def _log(self, message):
|
|
417
|
+
self.stderr.write("bookmark-research MCP: " + message + "\n")
|
|
418
|
+
self.stderr.flush()
|
|
419
|
+
|
|
420
|
+
def _services(self):
|
|
421
|
+
if self._research_services is None:
|
|
422
|
+
from research_services import ResearchServices
|
|
423
|
+
self._research_services = ResearchServices(settings=self.settings, research_sessions=self._research())
|
|
424
|
+
return self._research_services
|
|
425
|
+
|
|
426
|
+
def _wiki(self):
|
|
427
|
+
if self._wiki_store is None:
|
|
428
|
+
from wiki import WikiStore
|
|
429
|
+
self._wiki_store = WikiStore(settings=self.settings, research_sessions=self._research())
|
|
430
|
+
return self._wiki_store
|
|
431
|
+
|
|
432
|
+
def _call(self, name, arguments):
|
|
433
|
+
# Explicit dispatch ensures input can never select a Python method,
|
|
434
|
+
# executable, SQL statement, or unadvertised capability.
|
|
435
|
+
research_methods = {"research_start": "start", "research_status": "status", "research_search": "search",
|
|
436
|
+
"research_fetch": "fetch", "research_source": "source", "research_record": "record",
|
|
437
|
+
"research_finish": "finish", "research_inventory": "inventory",
|
|
438
|
+
"research_coverage": "coverage", "research_import_evidence": "import_evidence"}
|
|
439
|
+
if name in research_methods:
|
|
440
|
+
return getattr(self._research(), research_methods[name])(**arguments)
|
|
441
|
+
service_methods = {"research_services": "describe", "research_service_prepare": "prepare",
|
|
442
|
+
"research_service_start": "start", "research_service_status": "status", "research_service_result": "result",
|
|
443
|
+
"research_service_cancel": "cancel", "research_service_attach": "attach", "research_service_import": "import_result"}
|
|
444
|
+
if name in service_methods:
|
|
445
|
+
return getattr(self._services(), service_methods[name])(**arguments)
|
|
446
|
+
wiki_methods = {"wiki_write": "write", "wiki_get": "get", "wiki_list": "list", "wiki_search": "search", "wiki_lint": "lint"}
|
|
447
|
+
if name in wiki_methods:
|
|
448
|
+
return getattr(self._wiki(), wiki_methods[name])(**arguments)
|
|
449
|
+
if name == "research_route":
|
|
450
|
+
from routing import ResearchRouting
|
|
451
|
+
return ResearchRouting(self.settings).route(**arguments)
|
|
452
|
+
if name == "evaluate_research":
|
|
453
|
+
from evaluation import evaluate
|
|
454
|
+
return evaluate(**arguments)
|
|
455
|
+
if name == "get_settings":
|
|
456
|
+
return self.settings.describe()
|
|
457
|
+
if name == "update_settings":
|
|
458
|
+
return self.settings.update(arguments["changes"])
|
|
459
|
+
if name == "research_readiness":
|
|
460
|
+
from readiness import Readiness
|
|
461
|
+
return Readiness(settings=self.settings).check(**arguments)
|
|
462
|
+
if name == "sync_package":
|
|
463
|
+
result = self._sources().sync(**arguments)
|
|
464
|
+
self._start_watcher()
|
|
465
|
+
return result
|
|
466
|
+
if name == "source_history":
|
|
467
|
+
return self._sources().history(**arguments)
|
|
468
|
+
if name in ("search_bookmarks", "get_context"):
|
|
469
|
+
options = dict(arguments)
|
|
470
|
+
refresh = options.pop("refresh", True)
|
|
471
|
+
index = self._local()
|
|
472
|
+
update = self._sources().refresh(options["source_id"]) if refresh else None
|
|
473
|
+
if update and update["state"] in ("error", "unavailable"):
|
|
474
|
+
raise ValueError(update["error"] + "; use refresh=false to read the saved index")
|
|
475
|
+
method = index.search if name == "search_bookmarks" else index.context
|
|
476
|
+
with index.read_snapshot():
|
|
477
|
+
result = method(**options)
|
|
478
|
+
result["source"] = self._sources().status(options["source_id"])["source"]
|
|
479
|
+
result["refresh"] = {"performed": bool(refresh and result["source"]["mode"] == "live"),
|
|
480
|
+
"index_updated": bool(update and update.get("performed")),
|
|
481
|
+
"requested": refresh, "mode": "checked_package" if refresh and result["source"]["mode"] == "live" else "saved_snapshot", "sync": update}
|
|
482
|
+
return result
|
|
483
|
+
if name == "index_status":
|
|
484
|
+
result = self._sources().status(**arguments)
|
|
485
|
+
result["monitor"] = self._source_watcher.status() if self._source_watcher else {"running": False, "mechanism": "poll"}
|
|
486
|
+
return result
|
|
487
|
+
if name == "search_web":
|
|
488
|
+
return self._web().search(**arguments)
|
|
489
|
+
if name == "fetch_web":
|
|
490
|
+
from archive import SourceArchive
|
|
491
|
+
options = dict(arguments)
|
|
492
|
+
raw = options.pop("raw", False)
|
|
493
|
+
result = self._web().fetch(**options)
|
|
494
|
+
return result if raw else SourceArchive.compact_fetch(result)
|
|
495
|
+
if name == "search_providers":
|
|
496
|
+
options = dict(arguments)
|
|
497
|
+
probe = options.pop("probe", False)
|
|
498
|
+
return self._web().probe(**options) if probe else self._web().describe(**options)
|
|
499
|
+
raise RpcError(-32602, "Unknown tool: " + name)
|
|
500
|
+
|
|
501
|
+
def _tool_result(self, value, is_error=False):
|
|
502
|
+
rendered = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
|
503
|
+
if len(rendered) > MAX_RESULT_CHARS:
|
|
504
|
+
raise ValueError("Tool result exceeds size limit; paginate with a lower result limit while preserving the research scope")
|
|
505
|
+
result = {"content": [{"type": "text", "text": rendered}], "isError": is_error}
|
|
506
|
+
# structuredContent was introduced after the 2025-03-26 protocol.
|
|
507
|
+
if self.protocol in ("2025-06-18", "2025-11-25"):
|
|
508
|
+
result["structuredContent"] = value if isinstance(value, dict) else {"data": value}
|
|
509
|
+
return result
|
|
510
|
+
|
|
511
|
+
def handle(self, message):
|
|
512
|
+
"""Return a response object, or None for a valid notification."""
|
|
513
|
+
if not isinstance(message, dict):
|
|
514
|
+
return _error(None, -32600, "Expected one JSON-RPC request object")
|
|
515
|
+
request_id = message.get("id")
|
|
516
|
+
notification = "id" not in message
|
|
517
|
+
if (not notification and (isinstance(request_id, bool) or not isinstance(request_id, (int, str)))):
|
|
518
|
+
return _error(None, -32600, "Request id must be a string or integer")
|
|
519
|
+
if (message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str)
|
|
520
|
+
or not message.get("method") or set(message) - {"jsonrpc", "id", "method", "params"}):
|
|
521
|
+
return _error(request_id, -32600, "Invalid JSON-RPC request")
|
|
522
|
+
method, params = message["method"], message.get("params", {})
|
|
523
|
+
try:
|
|
524
|
+
if notification:
|
|
525
|
+
if method == "notifications/initialized":
|
|
526
|
+
_params(params, ())
|
|
527
|
+
if self.protocol is None:
|
|
528
|
+
raise RpcError(-32600, "Initialize before notifications/initialized")
|
|
529
|
+
self.ready = True
|
|
530
|
+
self._start_watcher()
|
|
531
|
+
elif method in ("initialize", "tools/call", "tools/list", "ping"):
|
|
532
|
+
# These are request methods. Never execute a tool sent as
|
|
533
|
+
# a notification: it has no response/error channel.
|
|
534
|
+
self._log("Ignored request method without an id: " + method)
|
|
535
|
+
return None
|
|
536
|
+
if method == "initialize":
|
|
537
|
+
_params(params, ("protocolVersion", "capabilities", "clientInfo"), ("protocolVersion", "capabilities", "clientInfo"))
|
|
538
|
+
_validate(_text_schema(64), params["protocolVersion"], "protocolVersion")
|
|
539
|
+
if not isinstance(params["capabilities"], dict) or not isinstance(params["clientInfo"], dict):
|
|
540
|
+
raise RpcError(-32602, "capabilities and clientInfo must be objects")
|
|
541
|
+
for key in ("name", "version"):
|
|
542
|
+
_validate(_text_schema(256), params["clientInfo"].get(key), "clientInfo." + key)
|
|
543
|
+
if self.protocol is not None:
|
|
544
|
+
raise RpcError(-32600, "This connection is already initialized")
|
|
545
|
+
requested = params["protocolVersion"]
|
|
546
|
+
self.protocol = requested if requested in PROTOCOLS else PROTOCOLS[-1]
|
|
547
|
+
result = {"protocolVersion": self.protocol, "capabilities": {"tools": {}},
|
|
548
|
+
"serverInfo": {"name": "bookmark-research", "version": "0.5.0-beta.1"},
|
|
549
|
+
"instructions": "Before each new web research question, call research_readiness with the actual host and observed tools. It follows saved cached/always/manual preferences; reuse checks during that investigation. Local bookmark queries need no network check. Configured keys and visible tools do not prove authorization. Optional service failures leave other routes available. Use the bundled CLI setup for preferences and hidden credential entry; never request API keys in chat."}
|
|
550
|
+
elif method == "ping":
|
|
551
|
+
_params(params, ())
|
|
552
|
+
result = {}
|
|
553
|
+
elif method == "notifications/initialized":
|
|
554
|
+
raise RpcError(-32600, "notifications/initialized must not have an id")
|
|
555
|
+
elif method not in ("tools/list", "tools/call"):
|
|
556
|
+
raise RpcError(-32601, "Method not found: " + method)
|
|
557
|
+
elif not self.ready:
|
|
558
|
+
raise RpcError(-32002, "Server is not initialized")
|
|
559
|
+
elif method == "tools/list":
|
|
560
|
+
# All tools fit in one response; no pagination is advertised.
|
|
561
|
+
_params(params, ("cursor",))
|
|
562
|
+
if "cursor" in params:
|
|
563
|
+
raise RpcError(-32602, "This tool list has no pagination cursor")
|
|
564
|
+
result = {"tools": [{"name": name, "description": TOOL_DESCRIPTIONS[name], "inputSchema": schema}
|
|
565
|
+
for name, schema in TOOL_SCHEMAS.items()]}
|
|
566
|
+
else:
|
|
567
|
+
_params(params, ("name", "arguments"), ("name",))
|
|
568
|
+
name = params["name"]
|
|
569
|
+
_validate(_text_schema(128), name, "name")
|
|
570
|
+
if name not in TOOL_SCHEMAS:
|
|
571
|
+
raise RpcError(-32602, "Unknown tool: " + name)
|
|
572
|
+
arguments = params.get("arguments", {})
|
|
573
|
+
_validate(TOOL_SCHEMAS[name], arguments)
|
|
574
|
+
try:
|
|
575
|
+
value = self._call(name, arguments)
|
|
576
|
+
failed = ((name in ("fetch_web", "research_search", "research_fetch") and value.get("status") == "error")
|
|
577
|
+
or (name == "search_web" and value.get("successful_provider_count") == 0))
|
|
578
|
+
result = self._tool_result(value, is_error=failed)
|
|
579
|
+
except Exception as exc:
|
|
580
|
+
# No raw arguments, tracebacks, credentials or provider
|
|
581
|
+
# payloads are written to stderr.
|
|
582
|
+
self._log("Tool failed: %s (%s)" % (name, type(exc).__name__))
|
|
583
|
+
result = self._tool_result({"tool": name, "error": str(exc)[:4000], "error_type": type(exc).__name__}, is_error=True)
|
|
584
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
585
|
+
except RpcError as exc:
|
|
586
|
+
if notification:
|
|
587
|
+
self._log("Ignored invalid notification (%s)" % exc.code)
|
|
588
|
+
return None
|
|
589
|
+
return _error(request_id, exc.code, str(exc))
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _unique_object(pairs):
|
|
593
|
+
result = {}
|
|
594
|
+
for key, value in pairs:
|
|
595
|
+
if key in result:
|
|
596
|
+
raise ValueError("Duplicate JSON object key")
|
|
597
|
+
result[key] = value
|
|
598
|
+
return result
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _nonfinite(value):
|
|
602
|
+
raise ValueError("Non-finite JSON value")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def serve(db_path, stdin=None, stdout=None, stderr=None, settings=None):
|
|
606
|
+
"""Serve line-delimited JSON-RPC until EOF; stdout contains protocol only."""
|
|
607
|
+
incoming = stdin if stdin is not None else sys.stdin
|
|
608
|
+
outgoing = stdout if stdout is not None else sys.stdout
|
|
609
|
+
server = StdioMcpServer(db_path, stderr=stderr, settings=settings)
|
|
610
|
+
|
|
611
|
+
def emit(value):
|
|
612
|
+
outgoing.write(json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + "\n")
|
|
613
|
+
outgoing.flush()
|
|
614
|
+
|
|
615
|
+
try:
|
|
616
|
+
while True:
|
|
617
|
+
line = incoming.readline(MAX_MESSAGE_CHARS + 1)
|
|
618
|
+
if not line:
|
|
619
|
+
break
|
|
620
|
+
if len(line) > MAX_MESSAGE_CHARS:
|
|
621
|
+
emit(_error(None, -32700, "JSON-RPC message exceeds size limit; connection closed"))
|
|
622
|
+
break
|
|
623
|
+
if not line.strip():
|
|
624
|
+
continue
|
|
625
|
+
try:
|
|
626
|
+
request = json.loads(line, object_pairs_hook=_unique_object, parse_constant=_nonfinite)
|
|
627
|
+
except (ValueError, RecursionError):
|
|
628
|
+
emit(_error(None, -32700, "Parse error: invalid JSON"))
|
|
629
|
+
continue
|
|
630
|
+
try:
|
|
631
|
+
response = server.handle(request)
|
|
632
|
+
except Exception as exc:
|
|
633
|
+
server._log("Request handler failed (%s)" % type(exc).__name__)
|
|
634
|
+
request_id = request.get("id") if isinstance(request, dict) else None
|
|
635
|
+
response = _error(request_id, -32603, "Internal server error")
|
|
636
|
+
if response is not None:
|
|
637
|
+
emit(response)
|
|
638
|
+
except (BrokenPipeError, KeyboardInterrupt):
|
|
639
|
+
pass
|
|
640
|
+
finally:
|
|
641
|
+
server.close()
|