footnote-mcp 1.0.0__tar.gz
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.
- footnote_mcp-1.0.0/LICENSE +21 -0
- footnote_mcp-1.0.0/PKG-INFO +286 -0
- footnote_mcp-1.0.0/README.md +262 -0
- footnote_mcp-1.0.0/pyproject.toml +49 -0
- footnote_mcp-1.0.0/setup.cfg +4 -0
- footnote_mcp-1.0.0/src/footnote_mcp/__init__.py +13 -0
- footnote_mcp-1.0.0/src/footnote_mcp/__main__.py +6 -0
- footnote_mcp-1.0.0/src/footnote_mcp/confidence.py +65 -0
- footnote_mcp-1.0.0/src/footnote_mcp/core.py +65 -0
- footnote_mcp-1.0.0/src/footnote_mcp/diagnostics.py +21 -0
- footnote_mcp-1.0.0/src/footnote_mcp/extract.py +216 -0
- footnote_mcp-1.0.0/src/footnote_mcp/fetch.py +114 -0
- footnote_mcp-1.0.0/src/footnote_mcp/pipeline.py +177 -0
- footnote_mcp-1.0.0/src/footnote_mcp/rerank.py +326 -0
- footnote_mcp-1.0.0/src/footnote_mcp/scraper.py +519 -0
- footnote_mcp-1.0.0/src/footnote_mcp/search.py +372 -0
- footnote_mcp-1.0.0/src/footnote_mcp/semantic.py +89 -0
- footnote_mcp-1.0.0/src/footnote_mcp/server.py +770 -0
- footnote_mcp-1.0.0/src/footnote_mcp/tools_browser.py +491 -0
- footnote_mcp-1.0.0/src/footnote_mcp/tools_data/__init__.py +47 -0
- footnote_mcp-1.0.0/src/footnote_mcp/tools_data/cache.py +51 -0
- footnote_mcp-1.0.0/src/footnote_mcp/tools_data/classify.py +144 -0
- footnote_mcp-1.0.0/src/footnote_mcp/tools_data/dates.py +116 -0
- footnote_mcp-1.0.0/src/footnote_mcp/tools_data/entailment.py +197 -0
- footnote_mcp-1.0.0/src/footnote_mcp/tools_data/files.py +331 -0
- footnote_mcp-1.0.0/src/footnote_mcp/tools_data/health.py +68 -0
- footnote_mcp-1.0.0/src/footnote_mcp/tools_data/sandbox.py +353 -0
- footnote_mcp-1.0.0/src/footnote_mcp/tools_extra.py +563 -0
- footnote_mcp-1.0.0/src/footnote_mcp/tools_search.py +182 -0
- footnote_mcp-1.0.0/src/footnote_mcp.egg-info/PKG-INFO +286 -0
- footnote_mcp-1.0.0/src/footnote_mcp.egg-info/SOURCES.txt +43 -0
- footnote_mcp-1.0.0/src/footnote_mcp.egg-info/dependency_links.txt +1 -0
- footnote_mcp-1.0.0/src/footnote_mcp.egg-info/entry_points.txt +2 -0
- footnote_mcp-1.0.0/src/footnote_mcp.egg-info/requires.txt +13 -0
- footnote_mcp-1.0.0/src/footnote_mcp.egg-info/top_level.txt +1 -0
- footnote_mcp-1.0.0/tests/test_benchmark.py +34 -0
- footnote_mcp-1.0.0/tests/test_mcp_smoke.py +361 -0
- footnote_mcp-1.0.0/tests/test_scraper.py +185 -0
- footnote_mcp-1.0.0/tests/test_search_providers.py +118 -0
- footnote_mcp-1.0.0/tests/test_semantic.py +85 -0
- footnote_mcp-1.0.0/tests/test_server_dispatch.py +201 -0
- footnote_mcp-1.0.0/tests/test_tools_browser.py +173 -0
- footnote_mcp-1.0.0/tests/test_tools_data.py +333 -0
- footnote_mcp-1.0.0/tests/test_tools_extra.py +279 -0
- footnote_mcp-1.0.0/tests/test_tools_search.py +115 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KazKozDev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: footnote-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MCP server for source-grounded web research: search, structured-data extraction, evidence verification, and browser automation.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: mcp,model-context-protocol,web-research,scraping,fact-checking,browser-automation
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: mcp>=1.0.0
|
|
11
|
+
Requires-Dist: ollama>=0.4
|
|
12
|
+
Requires-Dist: playwright>=1.49
|
|
13
|
+
Requires-Dist: curl_cffi>=0.5.10
|
|
14
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
15
|
+
Requires-Dist: trafilatura>=1.12
|
|
16
|
+
Requires-Dist: rank-bm25>=0.2
|
|
17
|
+
Requires-Dist: openpyxl>=3.1
|
|
18
|
+
Requires-Dist: pypdf>=4.0
|
|
19
|
+
Requires-Dist: xlrd>=2.0
|
|
20
|
+
Requires-Dist: pdfplumber>=0.11
|
|
21
|
+
Requires-Dist: pytesseract>=0.3
|
|
22
|
+
Requires-Dist: holidays>=0.60
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
<p align="center">
|
|
26
|
+
<img src=".github/banner.png" alt="footnote-mcp" width="350">
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
An MCP server for source-grounded web research. It searches the web, fetches and
|
|
30
|
+
extracts pages, pulls structured data out of tables/files/APIs, and — the part that
|
|
31
|
+
sets it apart — **verifies that a claim is actually supported by its source** instead of
|
|
32
|
+
trusting a snippet. 42 tools over stdio MCP, driven by any MCP client (Claude Desktop,
|
|
33
|
+
Cursor) or by the companion [Scholiast](https://github.com/KazKozDev/scholiast) research agent.
|
|
34
|
+
|
|
35
|
+
The design priority is *trustworthiness over convenience*: search snippets are treated as
|
|
36
|
+
discovery only, every fetched page is cached with provenance, and claims are checked
|
|
37
|
+
against the source text before they count. It also degrades gracefully — with no API keys
|
|
38
|
+
and no config it still works (scraped search + an automatic headless-browser fallback +
|
|
39
|
+
an offline verification heuristic); keys and env vars only make it better.
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
From source (Python ≥ 3.10):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
47
|
+
pip install -e . # installs the `footnote-mcp` console script + deps
|
|
48
|
+
python -m playwright install chromium # the headless browser used by the fetch fallback
|
|
49
|
+
footnote-mcp # start the server (speaks MCP over stdio)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`footnote-mcp` now waits for an MCP client on stdio. Point a client at it by dropping this
|
|
53
|
+
into its MCP settings (Claude Desktop: `claude_desktop_config.json`; Cursor: `~/.cursor/mcp.json`):
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"footnote": { "command": "footnote-mcp" }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
No API keys are required to start — search falls back to scraping Bing + DuckDuckGo. Add
|
|
64
|
+
keys later under `"env"` (see [Search backends](#search-backends)). Pass `--headed` to watch
|
|
65
|
+
the browser tier work.
|
|
66
|
+
|
|
67
|
+
To run without installing, straight from the source tree:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
PYTHONPATH=src python -m footnote_mcp
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Verifying claims — the differentiator
|
|
74
|
+
|
|
75
|
+
The reason to use this over a plain search tool is `evidence_entailment` and friends:
|
|
76
|
+
they tell a claim a source *supports* from one it does not. `benchmarks/run_benchmark.py`
|
|
77
|
+
measures that on a labeled set of claim/source pairs (and demos `corroborate_claim` and
|
|
78
|
+
`locate_claim_span`):
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
python benchmarks/run_benchmark.py # offline heuristic (deterministic)
|
|
82
|
+
python benchmarks/run_benchmark.py --backend ollama # LLM judge (needs ollama)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Offline-heuristic result on the labeled set ([benchmarks/REPORT.md](benchmarks/REPORT.md)):
|
|
86
|
+
|
|
87
|
+
| Set | n | Accuracy | Unsupported-claim catch rate | Precision on "supported" |
|
|
88
|
+
|-----|---|----------|------------------------------|--------------------------|
|
|
89
|
+
| Data domain (numeric + factual) | 15 | 100% | 100% | 100% |
|
|
90
|
+
| Overall (incl. semantic) | 18 | 83% | 78% | 80% |
|
|
91
|
+
|
|
92
|
+
On its design domain — numeric and factual data claims — the offline heuristic never
|
|
93
|
+
blesses an unsupported claim and never misses one. Its blind spot is purely-semantic
|
|
94
|
+
negation/paraphrase; for those, `evidence_entailment` with `backend="ollama"` (a local LLM
|
|
95
|
+
judge) closes the gap. Run the `--backend ollama` line above to score that path on your own
|
|
96
|
+
machine.
|
|
97
|
+
|
|
98
|
+
## Tool surface (42 tools)
|
|
99
|
+
|
|
100
|
+
<details>
|
|
101
|
+
<summary><b>Discovery and reading</b> (9 tools)</summary>
|
|
102
|
+
|
|
103
|
+
| Tool | Description |
|
|
104
|
+
|------|-------------|
|
|
105
|
+
| `web_search` | Keyed provider (Tavily/Brave/Google) when available, else scraped Bing + DuckDuckGo. Snippets are discovery only. |
|
|
106
|
+
| `web_search_recent` | Search restricted to a recency window (day/week/month/year). |
|
|
107
|
+
| `web_deep_search` | Search, fetch, extract, rerank, and return source context. |
|
|
108
|
+
| `web_read` | Fetch one URL, extract text, classify source quality, persist cache metadata. |
|
|
109
|
+
| `scholarly_search` | Search arXiv (papers) or Wikipedia (encyclopedic) corpora. |
|
|
110
|
+
| `web_archive_fetch` | Find the closest Wayback Machine snapshot for a dead/changed URL. |
|
|
111
|
+
| `web_fetch_authenticated` | Fetch a page that needs cookies or custom headers. |
|
|
112
|
+
| `web_crawl` | Breadth-first crawl from a start URL, on-host by default (≤ 50 pages). |
|
|
113
|
+
| `generate_search_queries` | Generate operator queries (`site:`, `filetype:csv`, API/data-table variants). |
|
|
114
|
+
|
|
115
|
+
</details>
|
|
116
|
+
|
|
117
|
+
<details>
|
|
118
|
+
<summary><b>Structured data</b> (9 tools)</summary>
|
|
119
|
+
|
|
120
|
+
| Tool | Description |
|
|
121
|
+
|------|-------------|
|
|
122
|
+
| `web_extract_tables` | Parse HTML tables into `columns`/`rows` with source-URL provenance. |
|
|
123
|
+
| `web_detect_downloads` | Detect linked CSV/TSV/XLS/XLSX/PDF/JSON/XML files. |
|
|
124
|
+
| `web_parse_file` | Download and parse CSV/TSV/XLS/XLSX/PDF/JSON. |
|
|
125
|
+
| `web_fetch_json` | Fetch direct API/JSON endpoints into parsed JSON. |
|
|
126
|
+
| `check_date_completeness` | Validate required date coverage (day/week/month). |
|
|
127
|
+
| `resolve_units` | Detect currencies, currency pairs, measurement units. |
|
|
128
|
+
| `validate_unit_rows` | Reject rows with incompatible units or currency pairs. |
|
|
129
|
+
| `reconcile_time_series` | Align series on a key, compute deltas, flag missing keys/outliers. |
|
|
130
|
+
| `export_dataset` | Write consolidated rows to a `csv`/`xlsx`/`json` file. |
|
|
131
|
+
|
|
132
|
+
</details>
|
|
133
|
+
|
|
134
|
+
<details>
|
|
135
|
+
<summary><b>Source quality and verification</b> (8 tools)</summary>
|
|
136
|
+
|
|
137
|
+
| Tool | Description |
|
|
138
|
+
|------|-------------|
|
|
139
|
+
| `classify_source` | Classify official / aggregator / blog / forum / interactive / blocked / error. |
|
|
140
|
+
| `evidence_entailment` | Strict claim-vs-source checker: `heuristic`, `auto`, `ollama`, optional `local_nli`. |
|
|
141
|
+
| `corroborate_claim` | Triangulate a claim across excerpts (corroborated / conflicting / single_source / …). |
|
|
142
|
+
| `locate_claim_span` | Locate supporting sentence(s) with char offsets and a containment score. |
|
|
143
|
+
| `source_cache_get` / `source_cache_put` | Inspect and write persistent source-cache entries. |
|
|
144
|
+
| `build_research_debug_report` | Compact report of queries, URLs, source quality, verification gaps. |
|
|
145
|
+
| `startup_health_check` | Check parser, OCR, browser, and cache dependencies. |
|
|
146
|
+
|
|
147
|
+
</details>
|
|
148
|
+
|
|
149
|
+
<details>
|
|
150
|
+
<summary><b>Controlled extraction recipes</b> (6 tools)</summary>
|
|
151
|
+
|
|
152
|
+
When generic parsers fail, synthesize a sandboxed parser:
|
|
153
|
+
|
|
154
|
+
| Tool | Description |
|
|
155
|
+
|------|-------------|
|
|
156
|
+
| `tool_spec_propose` | Propose a task-specific extraction recipe spec. |
|
|
157
|
+
| `tool_code_generate` | Generate a starter `extract(source_text, input_payload)` recipe. |
|
|
158
|
+
| `tool_code_validate` | Validate recipe code against a static safety allowlist. |
|
|
159
|
+
| `tool_code_run_sandboxed` | Run validated code in a limited subprocess (JSON output only). |
|
|
160
|
+
| `tool_promote` | Save a validated recipe as reusable memory (no server edit). |
|
|
161
|
+
| `recipe_registry` | Manage promoted recipes: `list` / `get` / `run` / `delete`. |
|
|
162
|
+
|
|
163
|
+
</details>
|
|
164
|
+
|
|
165
|
+
<details>
|
|
166
|
+
<summary><b>Browser fallback</b> (10 tools)</summary>
|
|
167
|
+
|
|
168
|
+
A controlled Chromium session for JS-heavy or interactive pages:
|
|
169
|
+
|
|
170
|
+
| Tool | Description |
|
|
171
|
+
|------|-------------|
|
|
172
|
+
| `web_navigate` · `web_snapshot` · `web_click` · `web_type` · `web_extract` · `web_scroll` | Drive a page via stable element refs. |
|
|
173
|
+
| `browser_set_date_range` · `browser_extract_tables` · `browser_extract_tables_for_date_range` | Set a date range, submit, extract visible tables. |
|
|
174
|
+
| `web_screenshot` | Save a PNG and optionally OCR text locked inside the image. |
|
|
175
|
+
|
|
176
|
+
</details>
|
|
177
|
+
|
|
178
|
+
## Search backends
|
|
179
|
+
|
|
180
|
+
`web_search` (and everything built on it) routes through a provider layer. With an API key
|
|
181
|
+
it uses that provider; otherwise it scrapes Bing + DuckDuckGo. Results are normalized to one
|
|
182
|
+
shape regardless of backend.
|
|
183
|
+
|
|
184
|
+
| Provider | Env vars | Notes |
|
|
185
|
+
|----------|----------|-------|
|
|
186
|
+
| Tavily | `TAVILY_API_KEY` | LLM-oriented search API. |
|
|
187
|
+
| Brave | `BRAVE_API_KEY` | Independent web index. |
|
|
188
|
+
| Google | `GOOGLE_API_KEY` + `GOOGLE_CSE_ID` | Programmable Search (Custom Search JSON API). |
|
|
189
|
+
| Bing + DuckDuckGo | none | Default fallback; scraped, no key. |
|
|
190
|
+
|
|
191
|
+
`auto` (default) tries each keyed provider in order Tavily → Brave → Google, then scrapes.
|
|
192
|
+
Force one with the `provider` argument (`tavily`/`brave`/`google`/`scrape`).
|
|
193
|
+
|
|
194
|
+
**Semantic reranking.** Pass `semantic: true` to `web_search` to reorder by meaning rather
|
|
195
|
+
than keyword overlap: it over-fetches, embeds query and results with a local ollama model,
|
|
196
|
+
and sorts by cosine similarity (each result gains `semantic_score`). Best-effort — if ollama
|
|
197
|
+
is unavailable the original order is returned. Model: `FOOTNOTE_EMBED_MODEL` (default `bge-m3`).
|
|
198
|
+
|
|
199
|
+
## Fetching & anti-bot ladder
|
|
200
|
+
|
|
201
|
+
`web_read` fetches through an escalation ladder ([scraper.py](src/footnote_mcp/scraper.py)):
|
|
202
|
+
the cheapest method runs first and escalates only when a result looks blocked or empty. A
|
|
203
|
+
block/quality detector decides when to escalate; a per-domain rate limiter, circuit breaker,
|
|
204
|
+
and negative cache keep it polite. The tier used and the full attempt trace come back in
|
|
205
|
+
`fetch_tier` / `scrape_tiers`.
|
|
206
|
+
|
|
207
|
+
| Tier | Method | Enabled by |
|
|
208
|
+
|------|--------|-----------|
|
|
209
|
+
| 1 | HTTP (curl_cffi TLS impersonation) | always |
|
|
210
|
+
| 2 | HTTP through a rotating proxy | `FOOTNOTE_PROXIES` set |
|
|
211
|
+
| 3 | Headless Chromium (runs JavaScript) | `FOOTNOTE_BROWSER_FALLBACK=1` (default on) |
|
|
212
|
+
| 4 | Chromium through a proxy | proxies + browser |
|
|
213
|
+
| 5 | Hosted scrape API (Firecrawl / ScrapingBee) | `FOOTNOTE_SCRAPE_API` set |
|
|
214
|
+
|
|
215
|
+
With nothing configured it is the plain HTTP path plus an automatic browser fallback for
|
|
216
|
+
JavaScript-rendered pages.
|
|
217
|
+
|
|
218
|
+
| Env var | Default | Purpose |
|
|
219
|
+
|---------|---------|---------|
|
|
220
|
+
| `FOOTNOTE_BROWSER_FALLBACK` | `1` | Escalate blocked/JS pages to headless Chromium. |
|
|
221
|
+
| `FOOTNOTE_PROXIES` | _(none)_ | Comma-separated proxy URLs; sticky per domain with health tracking. |
|
|
222
|
+
| `FOOTNOTE_SCRAPE_API` | _(none)_ | `firecrawl` or `scrapingbee` (needs the matching API key). |
|
|
223
|
+
| `FOOTNOTE_DOMAIN_RPS` / `_BURST` | `3` / `5` | Per-domain rate limit (token bucket). |
|
|
224
|
+
| `FOOTNOTE_BREAKER_THRESHOLD` / `_COOLDOWN` | `5` / `120` | Per-domain circuit breaker. |
|
|
225
|
+
| `FOOTNOTE_NEGCACHE_TTL` | `300` | Seconds to remember a blocked URL. |
|
|
226
|
+
| `FOOTNOTE_THIN_CONTENT_CHARS` | `200` | Below this extracted length, a script-heavy page counts as a JS shell. |
|
|
227
|
+
|
|
228
|
+
## Runtime data
|
|
229
|
+
|
|
230
|
+
```text
|
|
231
|
+
~/.footnote-mcp/source_cache/ # persistent page cache (with provenance)
|
|
232
|
+
~/.footnote-mcp/research_memory.json # persistent research memory
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Override the cache location with `FOOTNOTE_SOURCE_CACHE=/path/to/cache footnote-mcp`.
|
|
236
|
+
|
|
237
|
+
`check_date_completeness` supports the calendars `calendar`, `business_day`, `crypto_24_7`,
|
|
238
|
+
`forex_weekday`, `us_business_day`, and `ru_business_day` (pass explicit `holidays` for
|
|
239
|
+
source-specific ones; the `us_`/`ru_` variants use the optional `holidays` package).
|
|
240
|
+
|
|
241
|
+
## Other install paths
|
|
242
|
+
|
|
243
|
+
**Docker** bundles Chromium and tesseract — nothing else to install:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
docker build -t footnote-mcp .
|
|
247
|
+
docker run -i --rm footnote-mcp # the client launches this; see MCP config below
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
```json
|
|
251
|
+
{ "mcpServers": { "footnote": { "command": "docker", "args": ["run", "-i", "--rm", "footnote-mcp"] } } }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**pipx / uvx** (isolated install of the entry point):
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
pipx install /path/to/footnote-mcp # or: pipx install git+<repo-url>
|
|
258
|
+
uvx --from /path/to/footnote-mcp footnote-mcp # ad-hoc, no install
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**OCR.** PDF/image OCR uses `pytesseract` + the system `tesseract` binary (`brew install
|
|
262
|
+
tesseract` on macOS). **Local NLI backend** for `evidence_entailment` `backend="local_nli"`:
|
|
263
|
+
`pip install -r requirements-nli.txt` (model via `FOOTNOTE_NLI_MODEL`). Either way,
|
|
264
|
+
`startup_health_check` reports what is actually available. Runtime dependency ranges
|
|
265
|
+
are declared in `pyproject.toml` and mirrored in `requirements.txt`.
|
|
266
|
+
|
|
267
|
+
## Tests
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
pip install -r requirements-dev.txt
|
|
271
|
+
python -m pytest -q # offline unit + smoke tests; no network or keys needed
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`tests/test_mcp_smoke.py` launches the server over real MCP stdio and exercises the tools
|
|
275
|
+
end to end against a local HTTP fixture; the rest are offline unit tests of the parsers,
|
|
276
|
+
fetch ladder, search providers, and dispatch. The live search test is opt-in:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
RUN_LIVE_WEB_TESTS=1 python -m pytest -m live
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
CI runs the same suite (`.github/workflows/tests.yml`).
|
|
283
|
+
|
|
284
|
+
## License
|
|
285
|
+
|
|
286
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src=".github/banner.png" alt="footnote-mcp" width="350">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
An MCP server for source-grounded web research. It searches the web, fetches and
|
|
6
|
+
extracts pages, pulls structured data out of tables/files/APIs, and — the part that
|
|
7
|
+
sets it apart — **verifies that a claim is actually supported by its source** instead of
|
|
8
|
+
trusting a snippet. 42 tools over stdio MCP, driven by any MCP client (Claude Desktop,
|
|
9
|
+
Cursor) or by the companion [Scholiast](https://github.com/KazKozDev/scholiast) research agent.
|
|
10
|
+
|
|
11
|
+
The design priority is *trustworthiness over convenience*: search snippets are treated as
|
|
12
|
+
discovery only, every fetched page is cached with provenance, and claims are checked
|
|
13
|
+
against the source text before they count. It also degrades gracefully — with no API keys
|
|
14
|
+
and no config it still works (scraped search + an automatic headless-browser fallback +
|
|
15
|
+
an offline verification heuristic); keys and env vars only make it better.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
From source (Python ≥ 3.10):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
23
|
+
pip install -e . # installs the `footnote-mcp` console script + deps
|
|
24
|
+
python -m playwright install chromium # the headless browser used by the fetch fallback
|
|
25
|
+
footnote-mcp # start the server (speaks MCP over stdio)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`footnote-mcp` now waits for an MCP client on stdio. Point a client at it by dropping this
|
|
29
|
+
into its MCP settings (Claude Desktop: `claude_desktop_config.json`; Cursor: `~/.cursor/mcp.json`):
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"footnote": { "command": "footnote-mcp" }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
No API keys are required to start — search falls back to scraping Bing + DuckDuckGo. Add
|
|
40
|
+
keys later under `"env"` (see [Search backends](#search-backends)). Pass `--headed` to watch
|
|
41
|
+
the browser tier work.
|
|
42
|
+
|
|
43
|
+
To run without installing, straight from the source tree:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
PYTHONPATH=src python -m footnote_mcp
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Verifying claims — the differentiator
|
|
50
|
+
|
|
51
|
+
The reason to use this over a plain search tool is `evidence_entailment` and friends:
|
|
52
|
+
they tell a claim a source *supports* from one it does not. `benchmarks/run_benchmark.py`
|
|
53
|
+
measures that on a labeled set of claim/source pairs (and demos `corroborate_claim` and
|
|
54
|
+
`locate_claim_span`):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python benchmarks/run_benchmark.py # offline heuristic (deterministic)
|
|
58
|
+
python benchmarks/run_benchmark.py --backend ollama # LLM judge (needs ollama)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Offline-heuristic result on the labeled set ([benchmarks/REPORT.md](benchmarks/REPORT.md)):
|
|
62
|
+
|
|
63
|
+
| Set | n | Accuracy | Unsupported-claim catch rate | Precision on "supported" |
|
|
64
|
+
|-----|---|----------|------------------------------|--------------------------|
|
|
65
|
+
| Data domain (numeric + factual) | 15 | 100% | 100% | 100% |
|
|
66
|
+
| Overall (incl. semantic) | 18 | 83% | 78% | 80% |
|
|
67
|
+
|
|
68
|
+
On its design domain — numeric and factual data claims — the offline heuristic never
|
|
69
|
+
blesses an unsupported claim and never misses one. Its blind spot is purely-semantic
|
|
70
|
+
negation/paraphrase; for those, `evidence_entailment` with `backend="ollama"` (a local LLM
|
|
71
|
+
judge) closes the gap. Run the `--backend ollama` line above to score that path on your own
|
|
72
|
+
machine.
|
|
73
|
+
|
|
74
|
+
## Tool surface (42 tools)
|
|
75
|
+
|
|
76
|
+
<details>
|
|
77
|
+
<summary><b>Discovery and reading</b> (9 tools)</summary>
|
|
78
|
+
|
|
79
|
+
| Tool | Description |
|
|
80
|
+
|------|-------------|
|
|
81
|
+
| `web_search` | Keyed provider (Tavily/Brave/Google) when available, else scraped Bing + DuckDuckGo. Snippets are discovery only. |
|
|
82
|
+
| `web_search_recent` | Search restricted to a recency window (day/week/month/year). |
|
|
83
|
+
| `web_deep_search` | Search, fetch, extract, rerank, and return source context. |
|
|
84
|
+
| `web_read` | Fetch one URL, extract text, classify source quality, persist cache metadata. |
|
|
85
|
+
| `scholarly_search` | Search arXiv (papers) or Wikipedia (encyclopedic) corpora. |
|
|
86
|
+
| `web_archive_fetch` | Find the closest Wayback Machine snapshot for a dead/changed URL. |
|
|
87
|
+
| `web_fetch_authenticated` | Fetch a page that needs cookies or custom headers. |
|
|
88
|
+
| `web_crawl` | Breadth-first crawl from a start URL, on-host by default (≤ 50 pages). |
|
|
89
|
+
| `generate_search_queries` | Generate operator queries (`site:`, `filetype:csv`, API/data-table variants). |
|
|
90
|
+
|
|
91
|
+
</details>
|
|
92
|
+
|
|
93
|
+
<details>
|
|
94
|
+
<summary><b>Structured data</b> (9 tools)</summary>
|
|
95
|
+
|
|
96
|
+
| Tool | Description |
|
|
97
|
+
|------|-------------|
|
|
98
|
+
| `web_extract_tables` | Parse HTML tables into `columns`/`rows` with source-URL provenance. |
|
|
99
|
+
| `web_detect_downloads` | Detect linked CSV/TSV/XLS/XLSX/PDF/JSON/XML files. |
|
|
100
|
+
| `web_parse_file` | Download and parse CSV/TSV/XLS/XLSX/PDF/JSON. |
|
|
101
|
+
| `web_fetch_json` | Fetch direct API/JSON endpoints into parsed JSON. |
|
|
102
|
+
| `check_date_completeness` | Validate required date coverage (day/week/month). |
|
|
103
|
+
| `resolve_units` | Detect currencies, currency pairs, measurement units. |
|
|
104
|
+
| `validate_unit_rows` | Reject rows with incompatible units or currency pairs. |
|
|
105
|
+
| `reconcile_time_series` | Align series on a key, compute deltas, flag missing keys/outliers. |
|
|
106
|
+
| `export_dataset` | Write consolidated rows to a `csv`/`xlsx`/`json` file. |
|
|
107
|
+
|
|
108
|
+
</details>
|
|
109
|
+
|
|
110
|
+
<details>
|
|
111
|
+
<summary><b>Source quality and verification</b> (8 tools)</summary>
|
|
112
|
+
|
|
113
|
+
| Tool | Description |
|
|
114
|
+
|------|-------------|
|
|
115
|
+
| `classify_source` | Classify official / aggregator / blog / forum / interactive / blocked / error. |
|
|
116
|
+
| `evidence_entailment` | Strict claim-vs-source checker: `heuristic`, `auto`, `ollama`, optional `local_nli`. |
|
|
117
|
+
| `corroborate_claim` | Triangulate a claim across excerpts (corroborated / conflicting / single_source / …). |
|
|
118
|
+
| `locate_claim_span` | Locate supporting sentence(s) with char offsets and a containment score. |
|
|
119
|
+
| `source_cache_get` / `source_cache_put` | Inspect and write persistent source-cache entries. |
|
|
120
|
+
| `build_research_debug_report` | Compact report of queries, URLs, source quality, verification gaps. |
|
|
121
|
+
| `startup_health_check` | Check parser, OCR, browser, and cache dependencies. |
|
|
122
|
+
|
|
123
|
+
</details>
|
|
124
|
+
|
|
125
|
+
<details>
|
|
126
|
+
<summary><b>Controlled extraction recipes</b> (6 tools)</summary>
|
|
127
|
+
|
|
128
|
+
When generic parsers fail, synthesize a sandboxed parser:
|
|
129
|
+
|
|
130
|
+
| Tool | Description |
|
|
131
|
+
|------|-------------|
|
|
132
|
+
| `tool_spec_propose` | Propose a task-specific extraction recipe spec. |
|
|
133
|
+
| `tool_code_generate` | Generate a starter `extract(source_text, input_payload)` recipe. |
|
|
134
|
+
| `tool_code_validate` | Validate recipe code against a static safety allowlist. |
|
|
135
|
+
| `tool_code_run_sandboxed` | Run validated code in a limited subprocess (JSON output only). |
|
|
136
|
+
| `tool_promote` | Save a validated recipe as reusable memory (no server edit). |
|
|
137
|
+
| `recipe_registry` | Manage promoted recipes: `list` / `get` / `run` / `delete`. |
|
|
138
|
+
|
|
139
|
+
</details>
|
|
140
|
+
|
|
141
|
+
<details>
|
|
142
|
+
<summary><b>Browser fallback</b> (10 tools)</summary>
|
|
143
|
+
|
|
144
|
+
A controlled Chromium session for JS-heavy or interactive pages:
|
|
145
|
+
|
|
146
|
+
| Tool | Description |
|
|
147
|
+
|------|-------------|
|
|
148
|
+
| `web_navigate` · `web_snapshot` · `web_click` · `web_type` · `web_extract` · `web_scroll` | Drive a page via stable element refs. |
|
|
149
|
+
| `browser_set_date_range` · `browser_extract_tables` · `browser_extract_tables_for_date_range` | Set a date range, submit, extract visible tables. |
|
|
150
|
+
| `web_screenshot` | Save a PNG and optionally OCR text locked inside the image. |
|
|
151
|
+
|
|
152
|
+
</details>
|
|
153
|
+
|
|
154
|
+
## Search backends
|
|
155
|
+
|
|
156
|
+
`web_search` (and everything built on it) routes through a provider layer. With an API key
|
|
157
|
+
it uses that provider; otherwise it scrapes Bing + DuckDuckGo. Results are normalized to one
|
|
158
|
+
shape regardless of backend.
|
|
159
|
+
|
|
160
|
+
| Provider | Env vars | Notes |
|
|
161
|
+
|----------|----------|-------|
|
|
162
|
+
| Tavily | `TAVILY_API_KEY` | LLM-oriented search API. |
|
|
163
|
+
| Brave | `BRAVE_API_KEY` | Independent web index. |
|
|
164
|
+
| Google | `GOOGLE_API_KEY` + `GOOGLE_CSE_ID` | Programmable Search (Custom Search JSON API). |
|
|
165
|
+
| Bing + DuckDuckGo | none | Default fallback; scraped, no key. |
|
|
166
|
+
|
|
167
|
+
`auto` (default) tries each keyed provider in order Tavily → Brave → Google, then scrapes.
|
|
168
|
+
Force one with the `provider` argument (`tavily`/`brave`/`google`/`scrape`).
|
|
169
|
+
|
|
170
|
+
**Semantic reranking.** Pass `semantic: true` to `web_search` to reorder by meaning rather
|
|
171
|
+
than keyword overlap: it over-fetches, embeds query and results with a local ollama model,
|
|
172
|
+
and sorts by cosine similarity (each result gains `semantic_score`). Best-effort — if ollama
|
|
173
|
+
is unavailable the original order is returned. Model: `FOOTNOTE_EMBED_MODEL` (default `bge-m3`).
|
|
174
|
+
|
|
175
|
+
## Fetching & anti-bot ladder
|
|
176
|
+
|
|
177
|
+
`web_read` fetches through an escalation ladder ([scraper.py](src/footnote_mcp/scraper.py)):
|
|
178
|
+
the cheapest method runs first and escalates only when a result looks blocked or empty. A
|
|
179
|
+
block/quality detector decides when to escalate; a per-domain rate limiter, circuit breaker,
|
|
180
|
+
and negative cache keep it polite. The tier used and the full attempt trace come back in
|
|
181
|
+
`fetch_tier` / `scrape_tiers`.
|
|
182
|
+
|
|
183
|
+
| Tier | Method | Enabled by |
|
|
184
|
+
|------|--------|-----------|
|
|
185
|
+
| 1 | HTTP (curl_cffi TLS impersonation) | always |
|
|
186
|
+
| 2 | HTTP through a rotating proxy | `FOOTNOTE_PROXIES` set |
|
|
187
|
+
| 3 | Headless Chromium (runs JavaScript) | `FOOTNOTE_BROWSER_FALLBACK=1` (default on) |
|
|
188
|
+
| 4 | Chromium through a proxy | proxies + browser |
|
|
189
|
+
| 5 | Hosted scrape API (Firecrawl / ScrapingBee) | `FOOTNOTE_SCRAPE_API` set |
|
|
190
|
+
|
|
191
|
+
With nothing configured it is the plain HTTP path plus an automatic browser fallback for
|
|
192
|
+
JavaScript-rendered pages.
|
|
193
|
+
|
|
194
|
+
| Env var | Default | Purpose |
|
|
195
|
+
|---------|---------|---------|
|
|
196
|
+
| `FOOTNOTE_BROWSER_FALLBACK` | `1` | Escalate blocked/JS pages to headless Chromium. |
|
|
197
|
+
| `FOOTNOTE_PROXIES` | _(none)_ | Comma-separated proxy URLs; sticky per domain with health tracking. |
|
|
198
|
+
| `FOOTNOTE_SCRAPE_API` | _(none)_ | `firecrawl` or `scrapingbee` (needs the matching API key). |
|
|
199
|
+
| `FOOTNOTE_DOMAIN_RPS` / `_BURST` | `3` / `5` | Per-domain rate limit (token bucket). |
|
|
200
|
+
| `FOOTNOTE_BREAKER_THRESHOLD` / `_COOLDOWN` | `5` / `120` | Per-domain circuit breaker. |
|
|
201
|
+
| `FOOTNOTE_NEGCACHE_TTL` | `300` | Seconds to remember a blocked URL. |
|
|
202
|
+
| `FOOTNOTE_THIN_CONTENT_CHARS` | `200` | Below this extracted length, a script-heavy page counts as a JS shell. |
|
|
203
|
+
|
|
204
|
+
## Runtime data
|
|
205
|
+
|
|
206
|
+
```text
|
|
207
|
+
~/.footnote-mcp/source_cache/ # persistent page cache (with provenance)
|
|
208
|
+
~/.footnote-mcp/research_memory.json # persistent research memory
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Override the cache location with `FOOTNOTE_SOURCE_CACHE=/path/to/cache footnote-mcp`.
|
|
212
|
+
|
|
213
|
+
`check_date_completeness` supports the calendars `calendar`, `business_day`, `crypto_24_7`,
|
|
214
|
+
`forex_weekday`, `us_business_day`, and `ru_business_day` (pass explicit `holidays` for
|
|
215
|
+
source-specific ones; the `us_`/`ru_` variants use the optional `holidays` package).
|
|
216
|
+
|
|
217
|
+
## Other install paths
|
|
218
|
+
|
|
219
|
+
**Docker** bundles Chromium and tesseract — nothing else to install:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
docker build -t footnote-mcp .
|
|
223
|
+
docker run -i --rm footnote-mcp # the client launches this; see MCP config below
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{ "mcpServers": { "footnote": { "command": "docker", "args": ["run", "-i", "--rm", "footnote-mcp"] } } }
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**pipx / uvx** (isolated install of the entry point):
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
pipx install /path/to/footnote-mcp # or: pipx install git+<repo-url>
|
|
234
|
+
uvx --from /path/to/footnote-mcp footnote-mcp # ad-hoc, no install
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**OCR.** PDF/image OCR uses `pytesseract` + the system `tesseract` binary (`brew install
|
|
238
|
+
tesseract` on macOS). **Local NLI backend** for `evidence_entailment` `backend="local_nli"`:
|
|
239
|
+
`pip install -r requirements-nli.txt` (model via `FOOTNOTE_NLI_MODEL`). Either way,
|
|
240
|
+
`startup_health_check` reports what is actually available. Runtime dependency ranges
|
|
241
|
+
are declared in `pyproject.toml` and mirrored in `requirements.txt`.
|
|
242
|
+
|
|
243
|
+
## Tests
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
pip install -r requirements-dev.txt
|
|
247
|
+
python -m pytest -q # offline unit + smoke tests; no network or keys needed
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`tests/test_mcp_smoke.py` launches the server over real MCP stdio and exercises the tools
|
|
251
|
+
end to end against a local HTTP fixture; the rest are offline unit tests of the parsers,
|
|
252
|
+
fetch ladder, search providers, and dispatch. The live search test is opt-in:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
RUN_LIVE_WEB_TESTS=1 python -m pytest -m live
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
CI runs the same suite (`.github/workflows/tests.yml`).
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "footnote-mcp"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "MCP server for source-grounded web research: search, structured-data extraction, evidence verification, and browser automation."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = ["mcp", "model-context-protocol", "web-research", "scraping", "fact-checking", "browser-automation"]
|
|
14
|
+
|
|
15
|
+
dependencies = [
|
|
16
|
+
"mcp>=1.0.0",
|
|
17
|
+
"ollama>=0.4",
|
|
18
|
+
"playwright>=1.49",
|
|
19
|
+
"curl_cffi>=0.5.10",
|
|
20
|
+
"beautifulsoup4>=4.12",
|
|
21
|
+
"trafilatura>=1.12",
|
|
22
|
+
"rank-bm25>=0.2",
|
|
23
|
+
"openpyxl>=3.1",
|
|
24
|
+
"pypdf>=4.0",
|
|
25
|
+
"xlrd>=2.0",
|
|
26
|
+
"pdfplumber>=0.11",
|
|
27
|
+
"pytesseract>=0.3",
|
|
28
|
+
"holidays>=0.60",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
footnote-mcp = "footnote_mcp:cli"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
pythonpath = ["src"]
|
|
40
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
41
|
+
markers = [
|
|
42
|
+
"live: requires external network/search engines",
|
|
43
|
+
]
|
|
44
|
+
filterwarnings = [
|
|
45
|
+
"ignore:builtin type SwigPyPacked has no __module__ attribute:DeprecationWarning",
|
|
46
|
+
"ignore:builtin type SwigPyObject has no __module__ attribute:DeprecationWarning",
|
|
47
|
+
"ignore:builtin type swigvarlink has no __module__ attribute:DeprecationWarning",
|
|
48
|
+
"ignore:`torch\\.jit\\.interface` is deprecated.*:DeprecationWarning",
|
|
49
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""footnote-mcp: an MCP server for source-grounded web research.
|
|
2
|
+
|
|
3
|
+
The runtime lives as submodules of this package (``server``, ``core``,
|
|
4
|
+
``search``, ...). ``cli`` is the console-script entry point; the import of the
|
|
5
|
+
heavy ``server`` module is deferred into the call so ``import footnote_mcp`` stays
|
|
6
|
+
cheap.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cli():
|
|
11
|
+
from .server import cli as _cli
|
|
12
|
+
|
|
13
|
+
_cli()
|