bathys 0.6.1__py3-none-any.whl

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.
bathys/searx.py ADDED
@@ -0,0 +1,149 @@
1
+ """SearXNG client. Expects JSON format enabled on the backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
10
+
11
+ import httpx
12
+
13
+ from .config import Config
14
+
15
+ _TAG_RE = re.compile(r"<[^>]+>")
16
+ _WS_RE = re.compile(r"\s+")
17
+ _DROP_PARAMS = {
18
+ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
19
+ "gclid", "fbclid", "ref", "referrer", "irclickid", "irgwc",
20
+ }
21
+
22
+
23
+ class SearxError(RuntimeError):
24
+ pass
25
+
26
+
27
+ def clean_text(s: str, limit: int) -> str:
28
+ s = html.unescape(_TAG_RE.sub(" ", s or ""))
29
+ s = _WS_RE.sub(" ", s).strip()
30
+ if len(s) > limit:
31
+ s = s[: limit - 1].rsplit(" ", 1)[0].rstrip(",;:—-") + "…"
32
+ return s
33
+
34
+
35
+ def normalize_url(u: str) -> str:
36
+ try:
37
+ parts = urlsplit(u or "")
38
+ except ValueError:
39
+ return u or ""
40
+ if not parts.scheme:
41
+ return u or ""
42
+ query = [
43
+ (k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
44
+ if k.lower() not in _DROP_PARAMS
45
+ ]
46
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), ""))
47
+
48
+
49
+ @dataclass
50
+ class SearchHit:
51
+ title: str
52
+ url: str
53
+ snippet: str
54
+ engines: list[str] = field(default_factory=list)
55
+ score: float = 0.0
56
+ published: str = ""
57
+
58
+ @property
59
+ def dedupe_key(self) -> str:
60
+ p = urlsplit(self.url)
61
+ return (p.netloc.removeprefix("www.") + p.path.rstrip("/")).lower()
62
+
63
+
64
+ @dataclass
65
+ class SearchOutcome:
66
+ query: str
67
+ hits: list[SearchHit]
68
+ answers: list[str]
69
+ suggestions: list[str]
70
+ seconds: float
71
+ raw_chars: int
72
+ unresponsive: list[str] = field(default_factory=list)
73
+
74
+
75
+ def _answer_text(a: object) -> str:
76
+ if isinstance(a, dict):
77
+ return str(a.get("answer") or a.get("title") or "")
78
+ return str(a)
79
+
80
+
81
+ async def search(
82
+ cfg: Config,
83
+ http: httpx.AsyncClient,
84
+ query: str,
85
+ *,
86
+ categories: str | None = None,
87
+ engines: str | None = None,
88
+ language: str | None = None,
89
+ time_range: str | None = None,
90
+ safesearch: int = 1,
91
+ ) -> SearchOutcome:
92
+ started = time.monotonic()
93
+ params: dict[str, str | int] = {"q": query, "format": "json", "safesearch": safesearch}
94
+ if categories:
95
+ params["categories"] = categories
96
+ if engines:
97
+ params["engines"] = engines
98
+ if language:
99
+ params["language"] = language
100
+ if time_range:
101
+ params["time_range"] = time_range
102
+ try:
103
+ resp = await http.get(cfg.searxng_url + "/search", params=params)
104
+ except httpx.HTTPError as e:
105
+ raise SearxError(
106
+ f"searxng unreachable at {cfg.searxng_url}: {e.__class__.__name__}: {e}"
107
+ ) from e
108
+ if resp.status_code != 200:
109
+ hint = ""
110
+ if resp.status_code == 403:
111
+ hint = " — enable JSON in searxng settings: search.formats: [html, json]"
112
+ raise SearxError(f"searxng http {resp.status_code}{hint}")
113
+
114
+ data = resp.json()
115
+ hits: list[SearchHit] = []
116
+ seen: set[str] = set()
117
+ for item in data.get("results", []):
118
+ url = normalize_url(item.get("url", ""))
119
+ if not url or url.startswith(("mailto:", "javascript:")):
120
+ continue
121
+ hit = SearchHit(
122
+ title=clean_text(item.get("title", ""), 140),
123
+ url=url,
124
+ snippet=clean_text(item.get("content", ""), 280),
125
+ engines=[str(e) for e in item.get("engines", [])],
126
+ score=float(item.get("score", 0) or 0),
127
+ published=str(item.get("publishedDate") or "")[:10],
128
+ )
129
+ dk = hit.dedupe_key
130
+ if dk in seen:
131
+ continue
132
+ seen.add(dk)
133
+ hits.append(hit)
134
+ hits.sort(key=lambda h: -h.score)
135
+ unresponsive: list[str] = []
136
+ for item in data.get("unresponsive_engines", []):
137
+ if isinstance(item, (list, tuple)) and item:
138
+ unresponsive.append(f"{item[0]}:{item[1]}" if len(item) > 1 else str(item[0]))
139
+ elif item:
140
+ unresponsive.append(str(item))
141
+ return SearchOutcome(
142
+ query=query,
143
+ hits=hits,
144
+ answers=[clean_text(_answer_text(a), 300) for a in data.get("answers", []) if _answer_text(a)],
145
+ suggestions=[str(s) for s in data.get("suggestions", [])][:6],
146
+ seconds=round(time.monotonic() - started, 2),
147
+ raw_chars=len(resp.text),
148
+ unresponsive=unresponsive,
149
+ )
@@ -0,0 +1,13 @@
1
+ # Bathys-managed SearXNG settings: localhost-only, JSON API on, limiter off.
2
+ # Used for both the native subprocess backend and the docker backend.
3
+ use_default_settings: true
4
+ server:
5
+ secret_key: "bathys-local-3f9c2a7e51d84b60"
6
+ bind_address: "127.0.0.1"
7
+ port: 8888
8
+ limiter: false
9
+ image_proxy: false
10
+ search:
11
+ safe_search: 1
12
+ default_lang: "auto"
13
+ formats: ["html", "json", "csv", "rss"]
bathys/server.py ADDED
@@ -0,0 +1,399 @@
1
+ """Bathys — единый локальный поисковый сервис глубокого ресёрча (MCP, stdio).
2
+
3
+ Owns the whole pipeline (search -> extraction -> query distillation -> cache);
4
+ SearXNG and Crawl4AI are swappable internal engines, not the product.
5
+
6
+ Four tools, deliberately small surface:
7
+ deep_research(query) — search + read top sources + query-distilled digest;
8
+ web_search(query) — ranked links only;
9
+ read_url(url, query) — distilled text of one page;
10
+ read_urls(urls, …) — batch read of known URLs under one shared budget.
11
+
12
+ Plus three prompts (bathys_deep_research, bathys_source_audit,
13
+ bathys_fresh_scan) — built-in research strategies the harness can render.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import sys
20
+
21
+ from mcp.server.fastmcp import Context, FastMCP
22
+ from mcp.types import ToolAnnotations
23
+
24
+ from . import batch
25
+ from .config import Config
26
+ from .core import Engine
27
+
28
+
29
+ @contextlib.asynccontextmanager
30
+ async def _lifespan(_: FastMCP):
31
+ engine = Engine(Config.load())
32
+ await engine.start()
33
+ try:
34
+ yield engine
35
+ finally:
36
+ await engine.stop()
37
+
38
+
39
+ mcp = FastMCP(
40
+ "bathys",
41
+ instructions=(
42
+ "Bathys — единый локальный поисковый сервис глубокого ресёрча: конвейер "
43
+ "поиск → извлечение → дистилляция → кэш целиком на вашей машине, ноль "
44
+ "облачных квот и API-ключей.\n\n"
45
+ "Матрица выбора инструмента:\n"
46
+ "- исследовательский вопрос («что/как/почему/сравни») → "
47
+ "deep_research(query): ищет, читает топ-источники, возвращает "
48
+ "дистиллят под запрос;\n"
49
+ "- нужны только ссылки → web_search(query); для программы/скрипта — "
50
+ "web_search(…, as_json=true);\n"
51
+ "- один известный URL → read_url(url, query=…);\n"
52
+ "- несколько известных URL (до 10) → read_urls(urls, query=…): один "
53
+ "вызов, общий бюджет, битая страница стоит строку.\n\n"
54
+ "Правила:\n"
55
+ "- запрос — вопрос, а не мешок ключевых слов: дистиллятор отбирает "
56
+ "пассажи по смыслу запроса;\n"
57
+ "- максимум 3 итерации по одной формулировке, дальше — смена угла "
58
+ "(web_search + read_urls по иным доменам), не шестой заход;\n"
59
+ "- подтверждённое утверждение — два независимых источника (разные "
60
+ "домены); один источник — «по данным одного источника»; конфликт "
61
+ "источников фиксируй явно, не выбирай молча сторону;\n"
62
+ "- свежесть — time_range (\"day\"/\"week\"/\"month\"/\"year\"); "
63
+ "refresh=true дорог (ломает кэш): только при протухшем кэше — cache "
64
+ "HIT при устаревших данных в дистилляте — и один раз на главный "
65
+ "источник.\n\n"
66
+ "Семантика сигналов:\n"
67
+ "- \"(not fetched — …)\" — источник не прочитан, причина рядом; это не "
68
+ "провал, используй остальные секции;\n"
69
+ "- robots-refused — сайт запретил краулер: уважай запрет, ищи другой "
70
+ "источник;\n"
71
+ "- \"No results … tried N engine sets\" — поисковики капризничали, "
72
+ "Bathys уже ретраился: смягчи формулировку (синонимы, язык, "
73
+ "category);\n"
74
+ "- \"Answer:\" — мгновенный справочный ответ: годится как факт, "
75
+ "спорное подтверди источником;\n"
76
+ "- футер \"[bathys: …]\" — статистика (cache/secs/ch), не для "
77
+ "цитирования.\n\n"
78
+ "Готовые стратегии — промпты bathys_deep_research, "
79
+ "bathys_source_audit, bathys_fresh_scan. Цитируй URL источников из "
80
+ "секций ответов — это твой след аудита."
81
+ ),
82
+ lifespan=_lifespan,
83
+ )
84
+
85
+
86
+ def _engine(ctx: Context) -> Engine:
87
+ return ctx.request_context.lifespan_context
88
+
89
+
90
+ @mcp.tool(annotations=ToolAnnotations(
91
+ title="Глубокое исследование: поиск + чтение + дистиллят",
92
+ readOnlyHint=True,
93
+ openWorldHint=True,
94
+ ))
95
+ async def deep_research(
96
+ query: str,
97
+ max_sources: int = 3,
98
+ max_results: int = 10,
99
+ per_source_chars: int = 3500,
100
+ time_range: str | None = None,
101
+ category: str | None = None,
102
+ language: str | None = None,
103
+ refresh: bool = False,
104
+ ctx: Context = None,
105
+ ) -> str:
106
+ """Search the web AND read the top sources in one shot.
107
+
108
+ Runs SearXNG metasearch, dives into the top max_sources pages with a real
109
+ browser, distills each page down to passages relevant to `query`, and
110
+ returns one merged digest. Best first call for any research question.
111
+ Args:
112
+ query: research question or keywords (RU/EN both fine)
113
+ max_sources: how many top hits to read in full (1-6)
114
+ max_results: how many search hits to consider (1-20)
115
+ per_source_chars: per-source character budget (300-8000)
116
+ time_range: "day" | "week" | "month" | "year"
117
+ category: searxng category, e.g. "general", "news", "science", "it"
118
+ language: result language, e.g. "ru", "en", "ru-RU"
119
+ refresh: ignore cache and re-fetch search results and pages
120
+ """
121
+ return await _engine(ctx).research(
122
+ query, max_sources=max_sources, max_results=max_results,
123
+ per_source_chars=per_source_chars, category=category,
124
+ engines=None, language=language, time_range=time_range, refresh=refresh,
125
+ )
126
+
127
+
128
+ @mcp.tool(annotations=ToolAnnotations(
129
+ title="Веб-поиск: ранжированные ссылки",
130
+ readOnlyHint=True,
131
+ openWorldHint=True,
132
+ ))
133
+ async def web_search(
134
+ query: str,
135
+ max_results: int = 8,
136
+ time_range: str | None = None,
137
+ category: str | None = None,
138
+ engines: str | None = None,
139
+ language: str | None = None,
140
+ refresh: bool = False,
141
+ as_json: bool = False,
142
+ ctx: Context = None,
143
+ ) -> str:
144
+ """Search the web via SearXNG metasearch; return a compact ranked link list.
145
+
146
+ Returns title, URL and a short snippet per hit — no page content. Empty or
147
+ blocked results are retried automatically with other engine sets.
148
+ To actually read pages, call read_url; to do both at once, call deep_research.
149
+ Args:
150
+ query: search query (natural language or keywords)
151
+ max_results: 1-20
152
+ time_range: "day" | "week" | "month" | "year"
153
+ category: e.g. "general", "news", "science", "it", "files"
154
+ engines: comma-separated engine names, e.g. "google,bing,duckduckgo"
155
+ language: e.g. "ru", "en", "ru-RU"
156
+ refresh: ignore cache and re-run the search
157
+ as_json: return pure machine-readable JSON {query, count, hits[], answer?}
158
+ instead of the human-friendly list (no footer line)
159
+ """
160
+ return await _engine(ctx).search(
161
+ query, max_results=max_results, category=category,
162
+ engines=engines, language=language, time_range=time_range,
163
+ refresh=refresh, as_json=as_json,
164
+ )
165
+
166
+
167
+ @mcp.tool(annotations=ToolAnnotations(
168
+ title="Чтение одной страницы",
169
+ readOnlyHint=True,
170
+ openWorldHint=True,
171
+ ))
172
+ async def read_url(
173
+ url: str,
174
+ query: str | None = None,
175
+ max_chars: int = 8000,
176
+ refresh: bool = False,
177
+ ctx: Context = None,
178
+ ) -> str:
179
+ """Read one web page; return its main content as clean, budgeted markdown.
180
+
181
+ JS-rendered pages are handled by a real headless browser. Boilerplate
182
+ (nav, footer, ads) is stripped; if `query` is given, only passages relevant
183
+ to it are returned. Pages are cached — re-reads with a different query are
184
+ instant and cost no network.
185
+ Args:
186
+ url: absolute http(s) URL
187
+ query: optional focus; return only passages relevant to it
188
+ max_chars: output character budget (300-50000)
189
+ refresh: ignore cache and re-fetch the page
190
+ """
191
+ return await _engine(ctx).read(url, query=query, max_chars=max_chars, refresh=refresh)
192
+
193
+
194
+ @mcp.tool(annotations=ToolAnnotations(
195
+ title="Пакетное чтение до 10 страниц",
196
+ readOnlyHint=True,
197
+ openWorldHint=True,
198
+ ))
199
+ async def read_urls(
200
+ urls: list[str],
201
+ query: str | None = None,
202
+ total_chars: int = 12000,
203
+ refresh: bool = False,
204
+ ctx: Context = None,
205
+ ) -> str:
206
+ """Read several known web pages in one call under one shared character budget.
207
+
208
+ Pages are fetched in parallel (JS-rendered, boilerplate-stripped) and the
209
+ combined total_chars budget is split evenly between the pages that came
210
+ back. Prefer this over N read_url calls when you already hold the URLs:
211
+ one round-trip, one budget, and a failed page costs one line instead of
212
+ a failed call.
213
+ Args:
214
+ urls: 1-10 absolute http(s) URLs; duplicates (after utm/fragment
215
+ cleanup) are merged, extras beyond 10 are reported in a Skipped line
216
+ query: optional focus; each page is distilled to passages relevant to it
217
+ total_chars: combined output budget across all sections (300-30000)
218
+ refresh: ignore cache and re-fetch every page
219
+ """
220
+ return await batch.read_many(
221
+ _engine(ctx), urls, query=query, total_chars=total_chars, refresh=refresh,
222
+ )
223
+
224
+
225
+ # --- Built-in strategy prompts (rendered via get_prompt, see agents/skills/) ---
226
+
227
+ @mcp.prompt(description="Глубокое исследование одного вопроса за 1–3 итерации: "
228
+ "заход, разбор, уточнение терминами источников, верификация, синтез.")
229
+ def bathys_deep_research(question: str, time_range: str = "") -> str:
230
+ """Стратегия глубокого исследования одного вопроса за 1–3 итерации.
231
+
232
+ Args:
233
+ question: исследовательский вопрос (не мешок ключевых слов)
234
+ time_range: свежесть day|week|month|year; пусто — без фильтра
235
+ """
236
+ tr = f', time_range="{time_range}"' if time_range else ""
237
+ return f"""# Глубокое исследование: {question}
238
+
239
+ Процедура — максимум 3 итерации, дальше смена угла, не шестой заход.
240
+
241
+ 1. Заход: deep_research(query="{question}"{tr}, max_sources=3) — без
242
+ предварительного web_search: первый вызов уже ищет и читает
243
+ топ-источники.
244
+ 2. Разбор дистиллята: из секций ответа выпиши (a) что отвечает на вопрос,
245
+ (b) что противоречит, (c) каких данных не хватает.
246
+ 3. Уточнение: переформулируй запрос терминами из (a)–(c) — терминами
247
+ предметной области, найденными в источниках, не своими синонимами — и
248
+ повтори deep_research. Не хватает конкретики: web_search по узкому
249
+ термину, затем read_urls по 2–4 лучшим URL с query="{question}".
250
+ 4. Верификация: ключевое утверждение подтверждено при двух независимых
251
+ доменах; конфликт — покажи обе версии с URL, не выбирай молча сторону.
252
+ 5. Синтез: вывод — первым предложением; затем 3–7 пунктов, у каждого
253
+ нетривиального факта — URL; в конце — «что не удалось проверить».
254
+
255
+ Сигналы: «(not fetched — …)» — не провал, работай с остальными секциями;
256
+ robots-refused — уважай запрет, ищи другой источник; футер [bathys: …] —
257
+ статистика, не цитируй. Дистиллят — сырьё: твой результат — синтез, а не
258
+ пересказ."""
259
+
260
+
261
+ @mcp.prompt(description="Аудит утверждения по списку URL: чтение под тезис, "
262
+ "кросс-поиск опровержений, вердикты по каждому пункту.")
263
+ def bathys_source_audit(claim: str, urls: str) -> str:
264
+ """Аудит утверждения по списку URL: чтение под тезис, кросс-поиск опровержений, вердикты.
265
+
266
+ Args:
267
+ claim: проверяемое утверждение словами
268
+ urls: 1–10 URL через запятую или пробел
269
+ """
270
+ url_list = "\n".join(
271
+ f"- {u}" for u in urls.replace(",", " ").split() if u
272
+ ) or "- (пусто: сначала добери URL через web_search)"
273
+ return f"""# Аудит утверждения: {claim}
274
+
275
+ Источники на проверку:
276
+ {url_list}
277
+
278
+ Процедура:
279
+
280
+ 1. Пакетное чтение: read_urls(urls, query="{claim}") — дистилляция
281
+ вплотную к проверяемому утверждению, не «вообще о чём статья». Битые
282
+ URL отвалятся строкой «(not fetched — …)» — это не провал.
283
+ 2. Контекст каждого источника: домен/автор, дата публикации,
284
+ первоисточник или пересказ. Два пересказа одного пресс-релиза —
285
+ один источник, не два.
286
+ 3. Кросс-поиск опровержений: deep_research("{claim}" + «критика /
287
+ опровержение / альтернативы») — ищи расхождения, а не подтверждения;
288
+ при споре об актуальности добавь time_range.
289
+ 4. Свежесть: проверяешь «как сейчас» — один refresh=true на главный
290
+ источник, не на весь пакет.
291
+ 5. Вердикт по каждому тезису: подтверждено (≥2 независимых домена) /
292
+ частично (1 источник) / опровергнуто (опровержение с URL) /
293
+ непроверяемо (404, robots-refused — указать причину).
294
+
295
+ Выход: таблица «тезис → вердикт → источники (URL)» плюс абзац общего
296
+ вывода; каждый вердикт обязан содержать хотя бы один URL либо явную
297
+ причину его отсутствия."""
298
+
299
+
300
+ @mcp.prompt(description="Свежий срез по теме за окно времени: отбор значимых "
301
+ "событий, пакетное чтение, сводка с датами и URL.")
302
+ def bathys_fresh_scan(topic: str, window: str = "week") -> str:
303
+ """Свежий срез по теме: поиск в окне, отбор значимого, пакетное чтение, сводка с датами.
304
+
305
+ Args:
306
+ topic: тема среза
307
+ window: окно свежести day|week|month|year
308
+ """
309
+ return f"""# Свежий срез: {topic}
310
+
311
+ Процедура:
312
+
313
+ 1. web_search(query="{topic}", time_range="{window}", max_results=8) —
314
+ только свежая выдача; не расширяй окно без необходимости.
315
+ 2. Отбери 3–5 значимых событий/изменений; отсей дубли — пересказы одного
316
+ релиза в разных изданиях считаются одним событием.
317
+ 3. read_urls(выбранные URL, query="{topic}") — один вызов, общий бюджет;
318
+ читай только то, что реально поясняет срез.
319
+ 4. Сводка: события и изменения по убыванию значимости; каждый пункт —
320
+ с датой и URL. Конфликт дат или версий между источниками — покажи обе
321
+ версии, не выбирай молча сторону.
322
+ 5. Подозрение протухшего кэша — cache HIT при устаревшей дате в
323
+ дистилляте — единственный refresh=true на главный источник, не на
324
+ весь пакет.
325
+
326
+ Выход: плотная сводка «что произошло / что изменилось» с датами и URL,
327
+ без пересказа дистиллятов целиком; спорный пункт на одном источнике
328
+ помечай «по данным одного источника»."""
329
+
330
+
331
+ def _describe_prompt_args(*names: str) -> None:
332
+ """Fill PromptArgument.description from each prompt docstring's Args section.
333
+
334
+ mcp 1.29 func_metadata does not carry docstring Args into the argument
335
+ schema, so @prompt() alone yields description=None on the wire; FastMCP
336
+ has no public accessor for a registered Prompt, hence _prompt_manager.
337
+ Fails loudly if the internals ever move.
338
+ """
339
+ for name in names:
340
+ prompt = mcp._prompt_manager.get_prompt(name)
341
+ docs: dict[str, str] = {}
342
+ in_args = False
343
+ for line in (prompt.fn.__doc__ or "").splitlines():
344
+ stripped = line.strip()
345
+ if stripped == "Args:":
346
+ in_args = True
347
+ elif in_args and stripped:
348
+ key, sep, value = stripped.partition(":")
349
+ if sep and value:
350
+ docs[key.strip()] = value.strip()
351
+ prompt.arguments = [
352
+ a.model_copy(update={"description": docs.get(a.name)})
353
+ for a in prompt.arguments or []
354
+ ]
355
+
356
+
357
+ _describe_prompt_args(
358
+ "bathys_deep_research", "bathys_source_audit", "bathys_fresh_scan",
359
+ )
360
+
361
+
362
+ def _hush_stdout_logs() -> None:
363
+ """MCP stdio owns stdout; any library handler pointed at it moves to stderr."""
364
+ import logging
365
+ import sys
366
+
367
+ for name in ("", "crawl4ai", "playwright"):
368
+ for h in logging.getLogger(name).handlers:
369
+ if getattr(h, "stream", None) is sys.stdout:
370
+ h.stream = sys.stderr
371
+
372
+
373
+ def main() -> None:
374
+ argv = sys.argv[1:]
375
+ if argv and argv[0] in ("install", "doctor"):
376
+ # `bathys install …` routes to the installer/doctor CLI so a single
377
+ # console entry-point covers both the MCP server and setup commands.
378
+ from . import doctor, installer
379
+
380
+ if argv[0] == "install":
381
+ sys.argv = ["bathys install", *argv[1:]]
382
+ raise SystemExit(installer.main())
383
+ sys.argv = ["bathys doctor", *argv[1:]]
384
+ raise SystemExit(doctor.main())
385
+ if argv and argv[0] in ("-h", "--help", "help"):
386
+ print("bathys — единый локальный поисковый сервис глубокого ресёрча (MCP, stdio)\n"
387
+ "\n"
388
+ "Использование:\n"
389
+ " bathys запустить MCP-сервер (stdio; для харнесса)\n"
390
+ " bathys install автоподключение к найденным харнессам "
391
+ "(--dry-run, --print-config, --with-agent)\n"
392
+ " bathys doctor диагностика стека (--full)\n")
393
+ return
394
+ _hush_stdout_logs()
395
+ mcp.run(transport="stdio")
396
+
397
+
398
+ if __name__ == "__main__":
399
+ main()