cablegram-mcp 0.1.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.
Files changed (45) hide show
  1. cablegram_mcp-0.1.0/.github/workflows/publish.yml +28 -0
  2. cablegram_mcp-0.1.0/.github/workflows/tests.yml +21 -0
  3. cablegram_mcp-0.1.0/.gitignore +20 -0
  4. cablegram_mcp-0.1.0/.python-version +1 -0
  5. cablegram_mcp-0.1.0/LICENSE +21 -0
  6. cablegram_mcp-0.1.0/PKG-INFO +152 -0
  7. cablegram_mcp-0.1.0/README.md +138 -0
  8. cablegram_mcp-0.1.0/deploy/README.md +18 -0
  9. cablegram_mcp-0.1.0/deploy/cablegram-poll.service +11 -0
  10. cablegram_mcp-0.1.0/deploy/cablegram-poll.timer +10 -0
  11. cablegram_mcp-0.1.0/docs/design.md +134 -0
  12. cablegram_mcp-0.1.0/pyproject.toml +30 -0
  13. cablegram_mcp-0.1.0/src/cablegram/__init__.py +0 -0
  14. cablegram_mcp-0.1.0/src/cablegram/archive.py +324 -0
  15. cablegram_mcp-0.1.0/src/cablegram/cli.py +105 -0
  16. cablegram_mcp-0.1.0/src/cablegram/cls.py +136 -0
  17. cablegram_mcp-0.1.0/src/cablegram/fetch.py +197 -0
  18. cablegram_mcp-0.1.0/src/cablegram/hn.py +98 -0
  19. cablegram_mcp-0.1.0/src/cablegram/poll.py +165 -0
  20. cablegram_mcp-0.1.0/src/cablegram/render.py +370 -0
  21. cablegram_mcp-0.1.0/src/cablegram/rss.py +245 -0
  22. cablegram_mcp-0.1.0/src/cablegram/server.py +268 -0
  23. cablegram_mcp-0.1.0/src/cablegram/sources.py +173 -0
  24. cablegram_mcp-0.1.0/src/cablegram/store.py +556 -0
  25. cablegram_mcp-0.1.0/src/cablegram/telegram.py +200 -0
  26. cablegram_mcp-0.1.0/src/cablegram/urls.py +169 -0
  27. cablegram_mcp-0.1.0/tests/samples/cls_subject.json +36 -0
  28. cablegram_mcp-0.1.0/tests/samples/hn_search.json +55 -0
  29. cablegram_mcp-0.1.0/tests/samples/telegram_channel.html +126 -0
  30. cablegram_mcp-0.1.0/tests/test_archive.py +183 -0
  31. cablegram_mcp-0.1.0/tests/test_cli.py +71 -0
  32. cablegram_mcp-0.1.0/tests/test_cls.py +158 -0
  33. cablegram_mcp-0.1.0/tests/test_fetch.py +223 -0
  34. cablegram_mcp-0.1.0/tests/test_hn.py +126 -0
  35. cablegram_mcp-0.1.0/tests/test_identity.py +259 -0
  36. cablegram_mcp-0.1.0/tests/test_poll.py +224 -0
  37. cablegram_mcp-0.1.0/tests/test_queries.py +172 -0
  38. cablegram_mcp-0.1.0/tests/test_render.py +265 -0
  39. cablegram_mcp-0.1.0/tests/test_rss.py +436 -0
  40. cablegram_mcp-0.1.0/tests/test_server.py +221 -0
  41. cablegram_mcp-0.1.0/tests/test_sources.py +61 -0
  42. cablegram_mcp-0.1.0/tests/test_store.py +647 -0
  43. cablegram_mcp-0.1.0/tests/test_telegram.py +235 -0
  44. cablegram_mcp-0.1.0/tests/test_urls.py +176 -0
  45. cablegram_mcp-0.1.0/uv.lock +716 -0
@@ -0,0 +1,28 @@
1
+ name: publish
2
+
3
+ # Publishes to PyPI when a version tag is pushed. No API token anywhere:
4
+ # PyPI's trusted publishing verifies the workflow's identity directly, so
5
+ # there is no secret to leak, rotate, or accidentally commit.
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ permissions:
15
+ id-token: write # this is what trusted publishing checks
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v5
19
+ with:
20
+ python-version: "3.12"
21
+
22
+ # The same suite the tests workflow runs. A release that cannot pass it
23
+ # should not exist, and a tag is the last place to find that out.
24
+ - run: uv sync --locked
25
+ - run: uv run pytest -q
26
+
27
+ - run: uv build
28
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,21 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ # 3.12 is the floor the project declares; 3.14 is what a fresh install
13
+ # gets. The identity of an archived item must not differ between them.
14
+ python-version: ["3.12", "3.14"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: uv sync --locked
21
+ - run: uv run pytest -q
@@ -0,0 +1,20 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+ .env
12
+ *.db
13
+ *.db-wal
14
+ *.db-shm
15
+ .coverage
16
+ htmlcov/
17
+ .claude/
18
+ .vscode/
19
+ .idea/
20
+ .DS_Store
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Levon Gabrielyan
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,152 @@
1
+ Metadata-Version: 2.5
2
+ Name: cablegram-mcp
3
+ Version: 0.1.0
4
+ Summary: Raw dispatches from 19 tech, AI and Chinese/Russian sources. Date-filtered only, never ranked.
5
+ Project-URL: Homepage, https://github.com/levongabrielyan/cablegram-mcp
6
+ Project-URL: Repository, https://github.com/levongabrielyan/cablegram-mcp
7
+ Author: Levon Gabrielyan
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,mcp,model-context-protocol,news,rss,tech-signals
11
+ Requires-Python: >=3.12
12
+ Requires-Dist: mcp>=2.1.1
13
+ Description-Content-Type: text/markdown
14
+
15
+ # cablegram-mcp
16
+
17
+ Raw dispatches from tech, AI and Chinese/Russian sources — filtered by date,
18
+ never ranked.
19
+
20
+ A *cablegram* was the unedited message that arrived over the submarine cables,
21
+ before an editor turned it into a story. This server is the cable: it brings the
22
+ dispatches. Your model is the editor.
23
+
24
+ **The point is the cable, not the news.** A launch discussed in Chinese or
25
+ Russian today reaches English-language coverage days later, filtered through
26
+ whoever decided it was worth translating — and often it never arrives at all.
27
+ Nineteen sources in three languages, read directly, put a reader in California
28
+ in the same week as a reader in Shanghai or Moscow.
29
+
30
+ Headlines are never translated. Each dispatch carries its language, and the
31
+ model reading it has more context for that than any translation step would.
32
+ It also means the same story is kept in every language that carried it: OpenAI
33
+ titles a post *"Pacing model development in an era of cyber-critical
34
+ capabilities"* while a Russian channel titles the same URL *"OpenAI stopped RL
35
+ for two weeks on its latest models"*. Both are stored against the same id, and
36
+ either can be searched.
37
+
38
+ A full day of all nineteen costs a few thousand tokens. Six hours of them,
39
+ grouped by source with the cuts declared, is around 700.
40
+
41
+ ```
42
+ CABLEGRAM v0.1 | 2026-08-30T09:09Z..2026-08-30T14:09Z | 11 items | 19/19 sources
43
+ CUT habr=2/4 hn=2/159 kr36=2/3 (newest kept)
44
+ COLS id hh:mm title times UTC | body: wire_read(ids=[...])
45
+ ---
46
+
47
+ ## cls zh early,finance 1/1
48
+ -- 08-30
49
+ e60e27faa7fc 10:04 AI数据中心扩张“限制性因素”浮现 马斯克:SpaceX正铸造燃气轮机叶片
50
+
51
+ ## data_secrets ru telegram 1/1
52
+ -- 08-30
53
+ a50d137e3147 10:49 Агенты OpenAI одну за одной автономно создали три цивилизации
54
+
55
+ ## hn en community,searchable 2/159
56
+ -- 08-30
57
+ 2929f114895f 14:06 METR and Redwood Offer Postmortem of the HuggingFace Hack (thezvi.wordpress.com)
58
+ 7901d3fb4e1c 14:04 Google removed the URLs. Only for the people who resell them (scraping.club)
59
+ ```
60
+
61
+ *Five hours of nineteen sources, verbatim. `CUT` says what was left out and how
62
+ much there was; `19/19` is how many answered.*
63
+
64
+
65
+ ## Status
66
+
67
+ v0.1 — the nineteen sources work; the tool API may still move. All nineteen sources have an adapter and were verified
68
+ against the live endpoints: eleven RSS feeds, Hacker News through its search
69
+ index, a signed Chinese financial API, and six public Telegram channels.
70
+
71
+ Two of them are worth knowing about before you rely on them:
72
+
73
+ * **cls.cn is reverse-engineered.** An undocumented internal API with a signed
74
+ request. It holds 3.34 days at most and cannot page backwards, so a gap in
75
+ polling is permanent. `wire_sources` marks it `fragile`.
76
+ * **Telegram is HTML with no contract.** The public preview view can change
77
+ without a version number to notice it by.
78
+
79
+ Both are declared in the output rather than explained afterwards.
80
+
81
+ ## Install
82
+
83
+ Requires Python 3.12+.
84
+
85
+ ```bash
86
+ git clone https://github.com/levongabrielyan/cablegram-mcp
87
+ cd cablegram-mcp
88
+ uv sync
89
+ ```
90
+
91
+ Register it with an MCP client — for Claude Code:
92
+
93
+ ```bash
94
+ claude mcp add cablegram --scope user -- \
95
+ /path/to/cablegram-mcp/.venv/bin/python -m cablegram.cli serve
96
+ ```
97
+
98
+ Then fill the archive, and keep filling it:
99
+
100
+ ```bash
101
+ cablegram poll # once, now
102
+ cablegram sources # what it knows about
103
+ ```
104
+
105
+ Feeds expose a window of days, so put `cablegram poll` on a timer — an hour
106
+ nobody polls is an hour no endpoint will serve again. A systemd user unit is in
107
+ [`deploy/`](deploy/).
108
+
109
+ ## The four tools
110
+
111
+ | Tool | Question |
112
+ | --- | --- |
113
+ | `wire_latest` | What did the sources publish in the last N hours? |
114
+ | `wire_read` | Give me the stored text of these ids |
115
+ | `wire_search` | When did this term start appearing? |
116
+ | `wire_sources` | What exists, and what is currently broken? |
117
+
118
+ All four are read-only and return plain text. The same information as JSON with
119
+ indent costs roughly six times the tokens and truncates.
120
+
121
+ ## The local archive
122
+
123
+ RSS feeds expose only their last few dozen entries; last week is unrecoverable.
124
+ So everything fetched is stored in a SQLite file under your platform's data
125
+ directory (`~/.local/share/cablegram/archive.db` on Linux; override with
126
+ `CABLEGRAM_DB`).
127
+
128
+ It is created on first run and grows by a few megabytes a year. Nothing is
129
+ uploaded anywhere, and no seed database ships with this repository — the server
130
+ fetches on your behalf and does not redistribute anyone's content.
131
+
132
+ **`wire_search` only reads what this archive holds, which starts the day you
133
+ first ran it.** Zero hits means "not in what we can search", never "nobody is
134
+ talking about it", and the output says so on every reply.
135
+
136
+ ## Design
137
+
138
+ [`docs/design.md`](docs/design.md) covers why the identity of an item is a pure
139
+ function of its URL, why a failure is a value rather than an exception, why the
140
+ full-text index needs a trigram tokenizer for Chinese to work at all, and what
141
+ this server deliberately does not do.
142
+
143
+ ## Notes
144
+
145
+ Only public endpoints are used: no credentials, no authentication bypass,
146
+ no scraping behind a login. Conditional requests (`ETag`, `If-Modified-Since`)
147
+ mean an unchanged feed is not re-downloaded. Intended for personal research —
148
+ respect each source's terms of service.
149
+
150
+ ## Licence
151
+
152
+ MIT. Built by [Levon Gabrielyan](https://github.com/levongabrielyan).
@@ -0,0 +1,138 @@
1
+ # cablegram-mcp
2
+
3
+ Raw dispatches from tech, AI and Chinese/Russian sources — filtered by date,
4
+ never ranked.
5
+
6
+ A *cablegram* was the unedited message that arrived over the submarine cables,
7
+ before an editor turned it into a story. This server is the cable: it brings the
8
+ dispatches. Your model is the editor.
9
+
10
+ **The point is the cable, not the news.** A launch discussed in Chinese or
11
+ Russian today reaches English-language coverage days later, filtered through
12
+ whoever decided it was worth translating — and often it never arrives at all.
13
+ Nineteen sources in three languages, read directly, put a reader in California
14
+ in the same week as a reader in Shanghai or Moscow.
15
+
16
+ Headlines are never translated. Each dispatch carries its language, and the
17
+ model reading it has more context for that than any translation step would.
18
+ It also means the same story is kept in every language that carried it: OpenAI
19
+ titles a post *"Pacing model development in an era of cyber-critical
20
+ capabilities"* while a Russian channel titles the same URL *"OpenAI stopped RL
21
+ for two weeks on its latest models"*. Both are stored against the same id, and
22
+ either can be searched.
23
+
24
+ A full day of all nineteen costs a few thousand tokens. Six hours of them,
25
+ grouped by source with the cuts declared, is around 700.
26
+
27
+ ```
28
+ CABLEGRAM v0.1 | 2026-08-30T09:09Z..2026-08-30T14:09Z | 11 items | 19/19 sources
29
+ CUT habr=2/4 hn=2/159 kr36=2/3 (newest kept)
30
+ COLS id hh:mm title times UTC | body: wire_read(ids=[...])
31
+ ---
32
+
33
+ ## cls zh early,finance 1/1
34
+ -- 08-30
35
+ e60e27faa7fc 10:04 AI数据中心扩张“限制性因素”浮现 马斯克:SpaceX正铸造燃气轮机叶片
36
+
37
+ ## data_secrets ru telegram 1/1
38
+ -- 08-30
39
+ a50d137e3147 10:49 Агенты OpenAI одну за одной автономно создали три цивилизации
40
+
41
+ ## hn en community,searchable 2/159
42
+ -- 08-30
43
+ 2929f114895f 14:06 METR and Redwood Offer Postmortem of the HuggingFace Hack (thezvi.wordpress.com)
44
+ 7901d3fb4e1c 14:04 Google removed the URLs. Only for the people who resell them (scraping.club)
45
+ ```
46
+
47
+ *Five hours of nineteen sources, verbatim. `CUT` says what was left out and how
48
+ much there was; `19/19` is how many answered.*
49
+
50
+
51
+ ## Status
52
+
53
+ v0.1 — the nineteen sources work; the tool API may still move. All nineteen sources have an adapter and were verified
54
+ against the live endpoints: eleven RSS feeds, Hacker News through its search
55
+ index, a signed Chinese financial API, and six public Telegram channels.
56
+
57
+ Two of them are worth knowing about before you rely on them:
58
+
59
+ * **cls.cn is reverse-engineered.** An undocumented internal API with a signed
60
+ request. It holds 3.34 days at most and cannot page backwards, so a gap in
61
+ polling is permanent. `wire_sources` marks it `fragile`.
62
+ * **Telegram is HTML with no contract.** The public preview view can change
63
+ without a version number to notice it by.
64
+
65
+ Both are declared in the output rather than explained afterwards.
66
+
67
+ ## Install
68
+
69
+ Requires Python 3.12+.
70
+
71
+ ```bash
72
+ git clone https://github.com/levongabrielyan/cablegram-mcp
73
+ cd cablegram-mcp
74
+ uv sync
75
+ ```
76
+
77
+ Register it with an MCP client — for Claude Code:
78
+
79
+ ```bash
80
+ claude mcp add cablegram --scope user -- \
81
+ /path/to/cablegram-mcp/.venv/bin/python -m cablegram.cli serve
82
+ ```
83
+
84
+ Then fill the archive, and keep filling it:
85
+
86
+ ```bash
87
+ cablegram poll # once, now
88
+ cablegram sources # what it knows about
89
+ ```
90
+
91
+ Feeds expose a window of days, so put `cablegram poll` on a timer — an hour
92
+ nobody polls is an hour no endpoint will serve again. A systemd user unit is in
93
+ [`deploy/`](deploy/).
94
+
95
+ ## The four tools
96
+
97
+ | Tool | Question |
98
+ | --- | --- |
99
+ | `wire_latest` | What did the sources publish in the last N hours? |
100
+ | `wire_read` | Give me the stored text of these ids |
101
+ | `wire_search` | When did this term start appearing? |
102
+ | `wire_sources` | What exists, and what is currently broken? |
103
+
104
+ All four are read-only and return plain text. The same information as JSON with
105
+ indent costs roughly six times the tokens and truncates.
106
+
107
+ ## The local archive
108
+
109
+ RSS feeds expose only their last few dozen entries; last week is unrecoverable.
110
+ So everything fetched is stored in a SQLite file under your platform's data
111
+ directory (`~/.local/share/cablegram/archive.db` on Linux; override with
112
+ `CABLEGRAM_DB`).
113
+
114
+ It is created on first run and grows by a few megabytes a year. Nothing is
115
+ uploaded anywhere, and no seed database ships with this repository — the server
116
+ fetches on your behalf and does not redistribute anyone's content.
117
+
118
+ **`wire_search` only reads what this archive holds, which starts the day you
119
+ first ran it.** Zero hits means "not in what we can search", never "nobody is
120
+ talking about it", and the output says so on every reply.
121
+
122
+ ## Design
123
+
124
+ [`docs/design.md`](docs/design.md) covers why the identity of an item is a pure
125
+ function of its URL, why a failure is a value rather than an exception, why the
126
+ full-text index needs a trigram tokenizer for Chinese to work at all, and what
127
+ this server deliberately does not do.
128
+
129
+ ## Notes
130
+
131
+ Only public endpoints are used: no credentials, no authentication bypass,
132
+ no scraping behind a login. Conditional requests (`ETag`, `If-Modified-Since`)
133
+ mean an unchanged feed is not re-downloaded. Intended for personal research —
134
+ respect each source's terms of service.
135
+
136
+ ## Licence
137
+
138
+ MIT. Built by [Levon Gabrielyan](https://github.com/levongabrielyan).
@@ -0,0 +1,18 @@
1
+ # Running the poller on a timer
2
+
3
+ The archive is only worth what has been put in it, and feeds expose a window of
4
+ days: an hour nobody polls is an hour no endpoint will serve again. So the
5
+ poller belongs on a timer, independently of whether an MCP client is running.
6
+
7
+ Copy both files into `~/.config/systemd/user/`, adjust the path in the service,
8
+ and:
9
+
10
+ systemctl --user daemon-reload
11
+ systemctl --user enable --now cablegram-poll.timer
12
+ systemctl --user list-timers cablegram-poll.timer
13
+
14
+ `cablegram poll` exits non-zero when every source failed, so a failure is
15
+ visible to `systemctl --user status` rather than silent.
16
+
17
+ Any scheduler works — this is just the one with no daemon to install. Hourly is
18
+ comfortable for the RSS sources.
@@ -0,0 +1,11 @@
1
+ [Unit]
2
+ Description=cablegram: fetch every source once and archive it
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ # Adjust to where you cloned it. `uv run` is used rather than a bare
7
+ # `cablegram` because the documented install (git clone + uv sync) leaves the
8
+ # entry point inside the project's own .venv and never on PATH — copying a
9
+ # %h/.local/bin path here earns a status=203/EXEC on the first run.
10
+ WorkingDirectory=%h/cablegram-mcp
11
+ ExecStart=/usr/bin/env uv run cablegram poll
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Poll the cablegram sources hourly
3
+
4
+ [Timer]
5
+ OnCalendar=hourly
6
+ Persistent=true
7
+ RandomizedDelaySec=300
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -0,0 +1,134 @@
1
+ # Design notes
2
+
3
+ Why this server is shaped the way it is. The code says what it does; this says
4
+ what it refused to do, and what went wrong on the way.
5
+
6
+ ## The one assumption everything follows from
7
+
8
+ **A model reads this output. A person does not.**
9
+
10
+ That single fact decides most of what follows. A human operator notices when a
11
+ tool returns an empty list on a busy day, or when a summary reads oddly. Nobody
12
+ is watching here, so a wrong answer that looks well-formed is never caught. The
13
+ design is therefore biased, everywhere, towards making a failure *visible in the
14
+ payload itself* rather than towards making the payload tidy.
15
+
16
+ Concretely, the output states:
17
+
18
+ | Marking | Without it |
19
+ | --- | --- |
20
+ | `DOWN cls=HTTP403` | An absent source cannot be known to exist; its silence reads as "nothing happened there" |
21
+ | `CUT hn=25/57` | An undeclared cut is indistinguishable from a source with little to say |
22
+ | `~a3f9c2e1` | A capture time presented as a publication time files the item under the wrong day |
23
+ | `PENDING` | A source with no adapter reported as broken buries the one that is actually broken |
24
+ | `body=description 240c` | Reporting *how much* of an article arrived, without guessing whether it is all of it |
25
+ | `"0 hits" does NOT mean nobody is talking about it` | The single most likely false conclusion this server can cause |
26
+
27
+ ## Date-filtered, never ranked
28
+
29
+ The server brings dispatches; the model is the editor. Ranking would mean
30
+ deciding what matters, with far less context than the model has — and a ranked
31
+ list cannot be un-ranked by the reader, while an unranked one can be sorted.
32
+
33
+ Depth is a different matter: `detail="headlines"` trims how much of each item is
34
+ returned, uniformly and on request. Trimming depth is not selecting.
35
+
36
+ ## Identity
37
+
38
+ `id = sha1(normalise(url))[:12]`, a pure function of the URL: no clock, no
39
+ network, no state. Two calls hours apart agree without the server remembering
40
+ anything.
41
+
42
+ Normalisation errs, everywhere, towards keeping URLs apart:
43
+
44
+ * **Merging two articles is permanent.** `url_norm` is UNIQUE, so the second one
45
+ never enters the archive and nothing reports it.
46
+ * **Splitting one article is a duplicate** — recoverable, though not harmless:
47
+ each split quietly lowers the cross-source count.
48
+
49
+ Both are bad; only one cannot be undone. Hence a *denylist* of tracking
50
+ parameters rather than an allowlist: an allowlist drops any key it has not heard
51
+ of, so `?sid=1` and `?sid=2` became the same id and the second article was
52
+ rejected in silence.
53
+
54
+ Twelve hex digits, not eight. At eight, a 50% chance of collision arrives at
55
+ 77,000 items — about five months of these feeds — and the id is a PRIMARY KEY,
56
+ so a collision is a real article silently refused.
57
+
58
+ The archive records the recipe that produced its ids and refuses to open if it
59
+ changed. Otherwise every stored id would stop matching, every item would be
60
+ inserted again, and the archive would double while reporting nothing wrong.
61
+
62
+ ## The archive is the point
63
+
64
+ Feeds expose a window of days and no history of their own. Everything else here
65
+ can be rewritten in an afternoon; this file is the only part whose value depends
66
+ on when it was started. It lives outside the project directory, under the
67
+ platform's data directory and never under a cache directory — caches are
68
+ declared deletable and cleaners act on that.
69
+
70
+ `sighting` is a separate table because `item` can only name whichever source got
71
+ there first. Without it the cross-source count reads 1 for everything, which
72
+ looks like a story nobody else picked up rather than a missing feature. Each
73
+ sighting keeps the headline that source used: one outlet writes 智谱 where
74
+ another writes Zhipu for the same link, and that pairing is the only bridge
75
+ between a Chinese story and an English query.
76
+
77
+ Full-text search uses `tokenize='trigram'`. With SQLite's default tokenizer
78
+ every Chinese query returns zero hits, silently — Chinese has no spaces, so a
79
+ whole headline becomes one token. Terms shorter than three characters cannot use
80
+ a trigram index at all, and the common Chinese company names are exactly two, so
81
+ those fall back to a substring scan. The output says which engine answered,
82
+ because their recall differs.
83
+
84
+ ## Failure handling
85
+
86
+ * **A failure is a value, not an exception.** One source failing must not cost
87
+ the other eighteen, and the caller receives one result per source in the order
88
+ given — a source never disappears from the list because it failed.
89
+ * **The deadline cancels only what is still in flight.** Cancelling the batch
90
+ turned one slow feed into eleven dead ones, which nothing downstream could
91
+ tell apart from a real outage.
92
+ * **A 304 is a success.** The source answered and had nothing new. Counting it
93
+ as a failure turns a quiet week into a fake outage; it also must not overwrite
94
+ the last real result with a zero.
95
+ * **A failed poll never touches `last_ok` or the stored validators.** `last_ok`
96
+ is how anyone notices a source has been mute for days, and wiping an ETag
97
+ re-downloads the whole feed for good, with no error to show for it.
98
+ * **One entry, one transaction.** A single unparseable entry used to roll back
99
+ everything already written for that source.
100
+ * **A feed that parses to zero entries has its own state.** That is what a feed
101
+ looks like the day it changes format, and as a plain success it is
102
+ indistinguishable from a source with no news.
103
+
104
+ ## Third-party input is hostile
105
+
106
+ Feeds are fetched from servers nobody here controls.
107
+
108
+ Entity expansion is bounded by measurement, not by inspecting declarations.
109
+ Three earlier guards each looked for the *shape* of the last attack — nested
110
+ declarations, a padded prologue, a comment hiding the DOCTYPE — and each left
111
+ the property open; a flat bomb (one entity, no nesting, referenced seven hundred
112
+ times) walked through all three. Fields are capped as a second line of defence,
113
+ the one that reaches the database and the index.
114
+
115
+ Responses are capped on bytes received rather than on `Content-Length`, which
116
+ can lie.
117
+
118
+ ## Zero dependencies beyond the MCP SDK
119
+
120
+ Nineteen fixed sources and eleven RSS feeds do not justify a parser dependency:
121
+ a dependency earns its place when it encapsulates knowledge that shifts or that
122
+ fails silently. The SDK ships an HTTP client, and the standard library parses
123
+ XML and RFC-822 dates.
124
+
125
+ ## What this server does not do
126
+
127
+ * It does not translate. Each dispatch carries its language, and the model
128
+ reading it has more context for that than any translation step would.
129
+ * It does not rank, score or deduplicate across sources beyond counting.
130
+ * It does not search the web. `wire_search` reads only what this archive holds,
131
+ which starts the day the server was first run.
132
+ * It does not fetch article bodies. What a feed ships is what is stored.
133
+ * Sources are fixed, not configurable. A configurable aggregator is a different
134
+ product; this one is tuned for one question.
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "cablegram-mcp"
3
+ version = "0.1.0"
4
+ description = "Raw dispatches from 19 tech, AI and Chinese/Russian sources. Date-filtered only, never ranked."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ authors = [{ name = "Levon Gabrielyan" }]
9
+ keywords = ["mcp", "model-context-protocol", "rss", "news", "ai", "tech-signals"]
10
+
11
+ dependencies = [
12
+ "mcp>=2.1.1",
13
+ ]
14
+
15
+ [project.scripts]
16
+ cablegram = "cablegram.cli:main"
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/levongabrielyan/cablegram-mcp"
20
+ Repository = "https://github.com/levongabrielyan/cablegram-mcp"
21
+
22
+ [dependency-groups]
23
+ dev = ["pytest>=8.0"]
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/cablegram"]
File without changes