- Python 91.1%
- Nix 8.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Ad-hoc инстанс из параметра `instance` принимается только как публичный https-адрес: loopback, приватные, link-local и зарезервированные адреса отклоняются, включая случай, когда на них указывает имя хоста или ведёт редирект. Редиректы поэтому разбираются вручную — адрес проверяется на каждом шаге. Снять проверку для своего инстанса в локальной сети можно переменной LYNXCHAN_MCP_ALLOW_PRIVATE. Кривая запись в каталоге доски больше не роняет поиск целиком: каталог и список досок разбираются так же защищённо, как тред. Числовые поля переживают нечисловое значение, таймстамп без смещения считается UTC. Бюджет времени поиска проверяется и в фазе каталогов — раньше он покрывал только дозагрузку тел тредов, и поиск мог идти вдвое дольше заявленного. Слово запроса матчится по началу слова: `ru` больше не находит `true`, а словоформы ловятся по-прежнему. Бонус за фразу не зависит от пунктуации. Кэш ограничен объёмом, а не числом записей, тело больше 8 МБ не скачивается; переменные окружения валидируются — MAX_CONCURRENT=0 больше не вешает сервер. HTTP-клиент закрывается при остановке. Пол mcp поднят до 1.19.0 — первый релиз с аргументом meta у декоратора FastMCP.tool, без которого сервер падал на импорте. Тесты: 89 штук, без сети, гоняются и в nix build. Co-Authored-By: Eva |
||
| nix | ||
| src/lynxchan_mcp | ||
| tests | ||
| .gitignore | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| flake.lock | ||
| flake.nix | ||
| pyproject.toml | ||
| README.md | ||
LynxChan MCP Server
MCP (Model Context Protocol) server for searching and reading threads on imageboards powered by the LynxChan engine (endchan.org by default; any other via config or a plain URL).
LynxChan has no server-side full-text search, and boards have no archive — so this server finds "where was X discussed" by scanning board catalogs and, when needed, fetching thread bodies to match reply text. Read-only: it never posts, reports or logs in.
Tools
| Tool | What it does |
|---|---|
search_threads |
Full-text search across boards and instances. All words of the query must appear somewhere in the thread (OP or replies); a verbatim phrase scores higher. Words match at the start of a word, so cat finds cats but not concat. Filters: boards, instance, since_days, op_only, has_files, pinned, locked; sort by relevance or date; limit. Returns ranked threads — board, id, title, dates, reply/file counts, flags, URL — with snippets showing each match in context, plus coverage stats (boards/threads scanned, reply-scan budget). |
read_thread |
Reads a whole thread by board + thread_id: OP and replies in order with dates, plain-text bodies and attachments (original name, MIME, size, direct URL). max_posts caps the replies shown. |
get_catalog |
Lists a board's live threads — pinned first, then by last bump — with counters, flags and URLs. |
list_boards |
Lists boards of the configured instances: URI, name, description, posts per hour. |
list_instances |
Lists the configured instances (names usable as the instance parameter). |
All tools are read-only (readOnlyHint) and call out to the open web
(openWorldHint).
How the search works
- Board catalogs (
/<board>/catalog.json) are fetched in parallel — the OP text of every live thread comes free with the catalog. - Threads whose OP already covers all query words are scored immediately.
- For the rest, thread bodies (
/<board>/res/<id>.json) are fetched in parallel — newest first — until every word is matched or the budget runs out. Budgets: a wall-clock limit (default 45 s) and a cap on fetched thread bodies (default 250 per instance). Both phases check the clock between batches of requests, so the budget bounds the whole search, not just the reply scan. When a budget truncates the scan, the answer says so. - Scoring: a word in the subject > in the OP > in a reply; extra bonus for the verbatim multi-word phrase and for recent bumps. Results are sorted by relevance (then freshness) or by date.
A query word matches at the start of a word: поиск finds поиска and
cat finds cats, but neither matches concat — word forms are caught
without a stemmer while accidental substrings are not.
Cached catalogs/threads make repeated and follow-up searches cheap.
Instances
Instances come from the LYNXCHAN_MCP_INSTANCES environment variable — a
JSON list of {"name", "url"}:
[
{"name": "endchan", "url": "https://endchan.org"},
{"name": "kohlchan", "url": "https://kohlchan.net"}
]
Without the variable, endchan.org is the built-in default (public and
verified against this server). The instance parameter of every tool
accepts either a configured name or a base URL (https://…, a bare host
works too) for an ad-hoc instance — no config change needed. Note that some
LynxChan sites sit behind anti-bot interstitials (e.g. 8chan.moe) and
cannot be scraped.
Ad-hoc instances must be public https:// addresses. The instance
argument is chosen by a model that reads untrusted text from imageboards,
so a thread cannot talk it into pointing the server at 127.0.0.1 or a
private network: loopback, private, link-local and reserved addresses are
refused, including after a redirect and including hostnames that resolve
to them. To run against a LynxChan instance inside your own network, list
it in LYNXCHAN_MCP_INSTANCES and set LYNXCHAN_MCP_ALLOW_PRIVATE=1.
Environment variables
| Variable | Default | Meaning |
|---|---|---|
LYNXCHAN_MCP_INSTANCES |
endchan | JSON list of instances (name, url) |
LYNXCHAN_MCP_USER_AGENT |
lynxchan-mcp/<ver> (+<repo>) |
User-Agent sent to instances |
LYNXCHAN_MCP_TIMEOUT |
20 |
Total request timeout, seconds |
LYNXCHAN_MCP_CONNECT_TIMEOUT |
8 |
Connect timeout, seconds |
LYNXCHAN_MCP_MAX_CONCURRENT |
4 |
Max simultaneous requests per host |
LYNXCHAN_MCP_MAX_RESPONSE_BYTES |
8388608 |
Largest response body accepted from an instance |
LYNXCHAN_MCP_CACHE_MAX_BYTES |
67108864 |
Total size of the response cache |
LYNXCHAN_MCP_MAX_REDIRECTS |
5 |
Redirect hops followed per request |
LYNXCHAN_MCP_ALLOW_PRIVATE |
0 |
Allow private/loopback addresses (own instance in a LAN) |
LYNXCHAN_MCP_CACHE_TTL |
300 |
Cache TTL for board lists and catalogs, seconds |
LYNXCHAN_MCP_THREAD_CACHE_TTL |
600 |
Cache TTL for full threads, seconds |
LYNXCHAN_MCP_SEARCH_BUDGET |
45 |
Wall-clock search budget per call, seconds |
LYNXCHAN_MCP_SEARCH_MAX_FETCHES |
250 |
Max thread bodies fetched per instance per search |
LYNXCHAN_MCP_SEARCH_MAX_BOARDS |
20 |
Max boards scanned per instance when boards is unset |
LYNXCHAN_MCP_MAX_LIMIT |
50 |
Hard cap on the limit parameter |
LYNXCHAN_MCP_RETRIES |
1 |
Retries on network errors (never on HTTP errors) |
LYNXCHAN_MCP_TRANSPORT |
stdio |
Default transport for the CLI |
LYNXCHAN_MCP_HOST / LYNXCHAN_MCP_PORT |
127.0.0.1 / 8080 |
Default bind for HTTP transports |
Running
From a checkout (uv / pip)
uv sync # or: pip install .
lynxchan-mcp # stdio transport (default)
lynxchan-mcp --transport streamable-http --host 127.0.0.1 --port 8080
# equivalent: python -m lynxchan_mcp [args]
Register the stdio variant with your MCP client, e.g.:
{
"mcpServers": {
"lynxchan": {
"command": "lynxchan-mcp"
}
}
}
Nix flake
nix run github:your/lynxchan-mcp # stdio
nix build github:your/lynxchan-mcp # package in ./result, runs the tests
nix develop # dev shell: python, uv, ruff, pytest
Tests
nix develop -c pytest # or: pytest, with the package installed
nix develop -c ruff check .
NixOS module
The flake exports nixosModules.default (alias nixosModules.lynxchan-mcp):
{
inputs.lynxchan-mcp.url = "github:your/lynxchan-mcp";
outputs = { nixpkgs, lynxchan-mcp, ... }: {
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
modules = [
lynxchan-mcp.nixosModules.default
{
services.lynxchan-mcp = {
enable = true;
host = "127.0.0.1";
port = 8080;
# memoryMax = "512M";
# instances = [ { name = "endchan"; url = "https://endchan.org"; } ];
# environmentFile = config.sops.secrets.lynxchan-mcp-env.path;
# extraEnvironment.LYNXCHAN_MCP_SEARCH_BUDGET = "60";
# dataDir = "lynxchan-mcp"; # only if you want /var/lib/lynxchan-mcp
};
}
];
};
};
}
The service runs as a dynamic user with strict sandboxing (no capabilities,
ProtectSystem=strict, no devices, AF_INET/AF_INET6 only) under a memory
limit (memoryMax, 512M by default). It keeps no on-disk state;
StateDirectory is created only when dataDir is set.
Politeness towards the boards
We are guests on other people's servers. Deliberate choices, enforced in
http_client.py:
- Honest User-Agent with the project URL, so admins can identify and
contact us (
LYNXCHAN_MCP_USER_AGENToverrides). - Hard timeouts — total 20 s, connect 8 s — so a dead instance never stalls a search.
- Per-host concurrency cap of 4 simultaneous requests, however wide the search fans out.
- TTL cache — board lists and catalogs for 5 minutes, thread bodies for 10 — repeated searches don't re-hit the server.
- One retry, and only on network errors. HTTP errors (4xx/5xx) are reported, never retried — no retry storms.
- Search budgets (45 s / 250 bodies per instance), checked between batches in both search phases, bound the total load one query can cause.
- Size caps — a response body above 8 MB is refused rather than pulled in full, and the cache is bounded by total size.
LynxChan API notes
- There is no JSON endpoint listing boards — the list is parsed from
the
/boards.jspage (a.linkBoard,span.divDescription,span.labelPostsPerHour), with/index.jsontopBoardsas a fallback. /<board>/catalog.json— array of thread summaries. Older engines (e.g. endchan) omitfileCount; counters then show reply counts only./<board>/res/<id>.json— the full thread: OP fields at the top level, replies inposts[]. The OP has nopostId— it equals the thread id.
License
MIT