answer42 0.2.0__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.
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: "answer42"
3
+ description: "Работа с MCP Answer42 для 1С UI automation"
4
+ ---
5
+
6
+ # Answer42
7
+
8
+ Используй этот скилл, когда задача связана с управлением интерфейсом 1С **через MCP `Answer42`**: запуск менеджера тестирования, подключение тест-клиента, навигация по формам, выбор ссылочных полей, работа с таблицами/табличными частями, динамическими списками, скриншоты и диагностика 1С.
9
+
10
+ ## Scope
11
+
12
+ Этот skill — про **использование MCP** для UI automation.
13
+
14
+ Не смешивай с задачами разработки самого bridge:
15
+
16
+ - Репозиторий Answer42 — код MCP/проекта.
17
+ - Доработка `Answer42` — отдельная engineering-тема: правка Python/BSL, сборка CF/EPF, тесты bridge, публикация изменений.
18
+ - Обычная UI-задача должна пользоваться готовыми MCP tools, а не менять bridge.
19
+ - Историю разработки bridge не надо держать в `MEMORY.md`; durable правила живут в этом skill и workspace skill `skills/answer42`.
20
+
21
+ ## Когда применять
22
+
23
+ - Нужно выполнить действие в 1С UI на живом стенде.
24
+ - Нужно получить реальные данные через управляемый интерфейс 1С.
25
+ - Нужно сделать скриншот формы/документа/списка/PDF evidence.
26
+ - Нужно диагностировать запуск `/TESTMANAGER`, `/TESTCLIENT`, `ibsrv` или автономный сервер.
27
+ - Нужно добавлять/настраивать поля динамического списка, включая вложенные поля ссылочных объектов.
28
+
29
+ ## Базовый порядок
30
+
31
+ 1. Проверь, что MCP-сервер `Answer42` установлен и доступен.
32
+ 2. Посмотри активные/восстановимые сессии через `sessions_list`; не плодить сессии с тем же стендом без причины.
33
+ 3. Запусти сессию через `start_session` (one-shot). Low-level `launch_manager` использовать только для диагностики/разработки bridge.
34
+ 4. Для подключения укажи только target в `base_url`; режим запуска клиента тестирования автоопределяется:
35
+ - `https://...` / `http://...` → web/ws;
36
+ - `server/infobase` → серверная база `/S`;
37
+ - каталог с `1Cv8.1CD` → файловая база через `ibsrv`, а если `ibsrv` недоступен — внутренний `file-direct` (`/F`).
38
+ 5. Передавай логин/пароль из защищённого источника, а не из репозитория. Предпочтительно один раз сохранить их через `credentials_save(url, username, password)`, затем вызывать `start_session` без `username`/`password`; окно тест-клиента максимизируется автоматически внутри tool-call.
39
+ 6. Работай только через tools Answer42; не кликай X11/WinAPI руками.
40
+ 7. Скриншоты допускаются системно через `screenshot`/`recording_*`.
41
+ 8. При ошибках 1С выгружай журнал через `export_eventlog`; для сетевых ошибок серверной базы отдельно проверяй DNS/доступность портов кластера 1С.
42
+ 9. В финальном ответе укажи реальные результаты: скриншоты/PDF, данные, ошибки, формы/фильтры.
43
+ 10. Заверши сессию через `stop_session`.
44
+
45
+ ## Важные правила
46
+
47
+ - `/TESTMANAGER` обязателен: не заменяй его обычным запуском 1С без явного указания пользователя.
48
+ - 1С-клиенты запускай с русской локалью: `/Lru /VLru_RU`.
49
+ - Linux: X11/Xvfb использовать только для скриншотов и окна 1С, не для ввода/кликов/автоматизации.
50
+ - Windows: Answer42 должен запускаться в интерактивной desktop-сессии пользователя; Windows service/headless/заблокированный RDP-сеанс для UI automation и screenshot ненадёжен.
51
+ - Для ссылочных полей предпочитай стандартную форму выбора 1С (`choose_field_from_list`, `choose_field_first_row`, `choose_current_row`), а не ввод строки.
52
+ - `execute_window_command` — только для явных навигационных ссылок `e1cib/...`. Не передавай туда псевдокоманды/команды формы вроде `ФормаНайти`, `СледующаяСтрока`, `СписокВывестиСписок`; для них нужны отдельные first-class MCP tools.
53
+ - После каждого действия проверяй диагностику результата: `client_diagnostics.user_messages`, `error_info`, `modal_title`, `modal_buttons`, `modal_text`, `modal_texts`, `modal_is_error_window`.
54
+ - Не закрывай автоматически сложные диалоги с несколькими кнопками (`OK`, `Restart`, `Finish`, `Yes/No/Cancel`). Допустимо автозакрытие только простых предупреждений/ошибок с одной кнопкой закрытия.
55
+ - Для динамических списков предпочитай `table_move_row`, `table_current_row`, `table_selected_rows`, `table_cell_text`; `table_rows` использует навигационный fallback, но на больших списках может быть дорогим.
56
+ - Не заявляй выполнение UI-задачи без реального состояния/скриншота/PDF evidence.
57
+ - `base_url` — единый target подключения. `http(s)` трактуется как web/ws URL, строки вида `server/infobase` — как серверные базы (`/S`), каталоги с `1Cv8.1CD` — как файловые базы (`/F` или через `ibsrv`). Не передавай явные параметры режима запуска клиента тестирования.
58
+ - Если ошибка авторизации возникла при пароле из одних звёздочек (`***`, `********`), явно сообщи пользователю, что был передан плейсхолдер вместо реального пароля; попроси указать настоящий пароль или сохранить корректные креды через `credentials_save()`.
59
+ - Если кластер 1С возвращает короткое имя рабочего сервера (`server_addr=...`), оно должно резолвиться и быть доступным по TCP с хоста Answer42; это не проблема ws/http.
60
+
61
+ ## Типовой запуск
62
+
63
+ Web/ws база:
64
+
65
+ ```text
66
+ sessions_list()
67
+ start_session(
68
+ base_url="https://example.com/ib",
69
+ username="<USERNAME>",
70
+ password="<PASSWORD>",
71
+ version="8.5.1.1150"
72
+ )
73
+ active_window()
74
+ ```
75
+
76
+ Серверная база кластера 1С:
77
+
78
+ ```text
79
+ start_session(
80
+ base_url="server/infobase",
81
+ username="<USERNAME>",
82
+ password="<PASSWORD>",
83
+ version="8.5.1.1150"
84
+ )
85
+ ```
86
+
87
+ Файловая база:
88
+
89
+ ```text
90
+ start_session(
91
+ base_url="/path/to/file-infobase",
92
+ username="<USERNAME>",
93
+ password="<PASSWORD>",
94
+ version="8.5.1.1150"
95
+ )
96
+ ```
97
+
98
+ Режим клиента тестирования выбирается автоматически по `base_url`: `http(s)` → `ws`, каталог/`1Cv8.1CD` → `file`, прочее непустое `server/infobase` → `server`.
99
+
100
+ ## Навигация
101
+
102
+ - Открывай формы/списки через `execute_window_command` с `e1cib/list/...` или `e1cib/app/...`, когда доступно.
103
+ - Для поиска объектов используй `find_object`, `activate_object`, `ui_tree`. `ui_tree` по умолчанию показывает только видимые/активные для пользователя элементы и всегда добавляет `visible/available/enabled/read_only`; для полной диагностики скрытых страниц/элементов передавай `include_hidden=true`.
104
+ - Для команд формы используй специализированные MCP tools (`table_move_row`, `dynamic_list_*`, `click_button`, `activate_object` и т.п.). `command_bar` можно использовать для диагностики доступных команд, но не прокидывай имена команд в `execute_window_command`.
105
+ - `dynamic_list_output`, `dynamic_list_open_settings`, `dynamic_list_clear_settings`, `dynamic_list_open_form_settings` нажимают кнопки формы через test-client API (`НайтиКнопку` + `Нажать()`), а не выполняют имена команд через `ВыполнитьКоманду()`. Если авто-кандидат не подходит, передавай `button` — техническое имя или заголовок кнопки.
106
+
107
+ ## Динамические списки: добавление вложенных полей через «Изменить форму»
108
+
109
+ ⚠️ **Текущее ограничение**: форма пользовательской настройки **«Изменить форму»** частично недоступна для надёжной автоматизации через API клиента тестирования. Команда **«Добавить поля»** (кнопка с плюсом в верхней панели формы настройки) может быть видна на скриншоте и в диагностическом тексте, но не нажиматься как обычная `ТестируемаяКнопкаФормы`: `click_button` может не находить её, а `activate_object` может вернуть `activated=true` без открытия диалога добавления полей. Не используйте X11-клики как обходной путь.
110
+
111
+ Для проверяемых сценариев фильтрации динамических списков используйте **«Настроить список...»** и доступные поля компоновщика; для вывода вложенной колонки через **«Изменить форму»** текущая архитектура не гарантирует выполнение.
112
+
113
+ Когда нужно вывести в динамический список поле связанного объекта (например телефон пользователя, дата приглашения регистрации, реквизит контрагента/номенклатуры/абонента):
114
+
115
+ 1. Сначала проверь метаданные и типы полей через RAG MCP (`rag_lookup_object`, `rag_query`) или локальный metadata index. Нужно понять, какие поля динамического списка имеют ссылочный тип и к какому объекту они ведут.
116
+ 2. Если RAG не умеет вычислять поля динамического списка и их типы, доработай RAG/indexer, чтобы он извлекал поля динамических списков из форм/запросов/основной таблицы и сопоставлял ссылочные поля с объектами метаданных. Не угадывай цепочки ссылок без проверки.
117
+ 3. Открой форму списка и нажми `dynamic_list_open_form_settings(button="Изменить форму...")`.
118
+ 4. В форме **«Изменить форму»** выбери поле динамического списка ссылочного типа. Примеры: `Ссылка` для текущего объекта списка, `Контрагент`, `Номенклатура`, `Абонент`, `Пользователь` и т.п.
119
+ 5. После выбора ссылочного поля нажми кнопку **«Добавь поля»** (это кнопка с плюсом). Не путай с `ДобавитьСтроку()`/Insert: нужна именно команда добавления полей формы.
120
+ 6. В появившейся структуре добавляемых полей нужно **поставить флажки** возле нужных полей связанного объекта метаданных. Простого выделения строки недостаточно.
121
+ 7. Подтверди выбор и заверши редактирование формы. Выбранное вложенное поле появится в динамическом списке и станет доступно для просмотра/проверки.
122
+ 8. Операцию можно повторять цепочкой: например сначала из `Пользователь` вывести ссылку на `Приглашение регистрации`, затем вторым проходом из `Приглашение регистрации` вывести `Дата`.
123
+ 9. Для фильтрации используй `dynamic_list_open_settings(button="Настроить список...")` и добавляй отбор по уже известному/доступному полю; для вывода колонки именно в список используй **«Изменить форму»**, а не только настройку отбора.
124
+
125
+ ## Таблицы и табличные части
126
+
127
+ - `table_current_row(table_name)` — получить текущую строку как `{available, pairs:[{key,value}]}`.
128
+ - `table_selected_rows(table_name)` — получить выделенные строки в том же JSON-safe формате.
129
+ - `table_cell_text(table_name, cell)` — вызвать `ПолучитьТекстЯчейки(<Ячейка>)`; пустой `cell` возвращает текущую ячейку. Для 1С обычно нужны технические имена полей (`РазмерФайла`), а не видимые заголовки (`Размер файла`).
130
+ - `table_move_row(table_name, direction, steps)` — штатная навигация по строкам: `first`, `last`, `next`, `previous`; для иерархических таблиц также `expand`, `collapse`, `level_down`, `level_up`.
131
+ - `table_rows(table_name)` — получить строки, если объект поддерживает чтение; для динамических списков использует навигацию.
132
+ - `table_find_row(table_name, substring)` — найти строку.
133
+ - `table_goto_row(table_name, index)` — перейти к строке.
134
+ - `table_set_field(table_name, field, value)` — установить значение ячейки.
135
+ - `table_edit_info(table_name)` — понять доступные поля редактирования.
136
+
137
+ ## Диагностика
138
+
139
+ При сбоях запуска или непонятных ошибках:
140
+
141
+ ```text
142
+ session_status(session_id="...")
143
+ sessions_list()
144
+ export_eventlog(...)
145
+ ```
146
+
147
+ Журнал автономного сервера — основной источник диагностики 1С runtime ошибок.
148
+
149
+ Для серверной базы 1С (`/S server/infobase`) проверяй отдельно:
150
+
151
+ - DNS/FQDN/короткое имя рабочего сервера, которое возвращает кластер;
152
+ - доступность портов агента/рабочих процессов (`1540+`, `1560+` и фактический `server_addr=tcp://...` из лога клиента);
153
+ - соответствие версии платформы клиента версии кластера;
154
+ - если в логе/окне есть `Различаются версии клиента и сервера ( 8.3.27.1234 - 8.3.27.7890)`, `Несоответствие версий клиента и сервера 1С:Предприятие 8` или `Требуется установка новой версии 1С:Предприятия`, останови текущую сессию и перезапусти `start_session` с `version` версии сервера 1С;
155
+ - фактическую ошибку в `/tmp/mcp_test_client/test_client_*.log`.
156
+
157
+ ## Evidence recording
158
+
159
+ - Включай `recording_start` перед демонстрационным workflow и завершай `recording_stop`.
160
+ - Ожидаемые артефакты: `manifest.json` и `recording.pdf` при наличии Chrome/Chromium/Edge или ImageMagick.
161
+ - MP4/GIF/HTML evidence больше не поддерживаются как публичные артефакты; не обещай и не прикладывай их.
162
+
163
+ ## Разработка bridge
164
+
165
+ Если задача именно про доработку репозитория `Answer42`:
166
+
167
+ - CF собирать кроссплатформенно: `python scripts/build_cf.py src/cf build/MCPTestManager.cf` и аналогично для `src/client_cf`. Приоритетный способ — `ibcmd config import` (не требует Конфигуратора); если `ibcmd` отсутствует, скрипт автоматически переключается на Конфигуратор/DESIGNER через `1cv8`. Скрипт сам добавляет `src` в `sys.path`; Bash-синтаксис `PYTHONPATH=src python ...` в PowerShell не работает.
168
+ - Не использовать удалённые `.sh`-скрипты (`build_cf.sh`, `export_eventlog.sh`).
169
+ - Для E2E использовать `scripts/e2e_stable.py`; долгий `dynamic` сценарий разделён на `dynamic-tables`, `dynamic-lists`, `dynamic-reports`.
170
+ - `start_session` автоматически пересобирает `build/MCPTestManager.cf`, если он отсутствует или XML-исходники новее; demo-клиент аналогично пересобирает `build/MCPTestClient.cf`. В PyPI wheel при отсутствии XML/скрипта используются packaged `mcp_1c.assets/*.cf`, поэтому перед релизом обязательно пересобрать и обновить `src/mcp_1c/assets/MCPTestManager.cf` и `src/mcp_1c/assets/MCPTestClient.cf`. После изменения Python/BSL прогонять минимум `py_compile`, unit tests и сборку CF; для UI-изменений — smoke E2E с PDF evidence.
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: "answer42-rag"
3
+ description: "RAG-индекс 1С metadata через Answer42"
4
+ ---
5
+
6
+ # Answer42 RAG
7
+
8
+ Используй этот скилл, когда нужно искать по исходникам 1С через RAG/index MCP `Answer42`: EDT-исходники, XML-выгрузка Конфигуратора, base + extensions, регистры, формы, СКД отчётов.
9
+
10
+ ## Что индексируется
11
+
12
+ - Объекты метаданных: справочники, документы, обработки, отчёты, перечисления, общие модули, регистры сведений/накопления/бухгалтерии/расчёта.
13
+ - Реквизиты объектов.
14
+ - Регистры: роли полей `dimension`, `resource`, `attribute`, `standard_attribute`.
15
+ - Табличные части и колонки.
16
+ - Формы и элементы форм, где доступны XML-исходники.
17
+ - СКД отчётов: поля наборов данных, вычисляемые поля, итоговые поля (`dataPath`, `field`, `title`, `expression`).
18
+ - Навигационные ссылки `e1cib/list/...` и `e1cib/app/...`.
19
+
20
+ ## Типовой workflow
21
+
22
+ ```text
23
+ rag_source_detect(path)
24
+ rag_source_add(name="MS", path="/path/to/MS/src", kind="base", format="auto")
25
+ rag_source_add(name="Platform42", path="/path/to/Platform42/src", kind="extension", format="auto")
26
+ rag_snapshot_create(name="MS+Platform42", base="MS", extensions=["Platform42"])
27
+ rag_index_build(snapshot="MS+Platform42")
28
+ rag_lookup_object(object="РегистрСведений.РезервныеКопииОбластейДанных", snapshot="MS+Platform42")
29
+ rag_query(query="резервные копии длительность", snapshot="MS+Platform42", limit=10)
30
+ ```
31
+
32
+ ## CLI-эквивалент
33
+
34
+ ```bash
35
+ python3 scripts/rag_cli.py add-source MS /path/to/MS/src --kind base
36
+ python3 scripts/rag_cli.py add-source Platform42 /path/to/Platform42/src --kind extension
37
+ python3 scripts/rag_cli.py snapshot MS+Platform42 --base MS --extension Platform42
38
+ python3 scripts/rag_cli.py build --snapshot MS+Platform42
39
+ python3 scripts/rag_cli.py lookup РегистрСведений.РезервныеКопииОбластейДанных --snapshot MS+Platform42
40
+ python3 scripts/rag_cli.py query "резервные копии" --snapshot MS+Platform42
41
+ ```
42
+
43
+ ## Как читать результаты
44
+
45
+ - `matches[].source_name` показывает слой (`MS`, `Platform42`, и т.п.).
46
+ - `attributes[].role`:
47
+ - `dimension` — измерение регистра;
48
+ - `resource` — ресурс регистра;
49
+ - `attribute` — реквизит;
50
+ - `standard_attribute` — стандартное поле платформы.
51
+ - `data_composition_fields[]` содержит поля СКД отчётов; для вычисляемых/итоговых смотри `expression`.
52
+ - Если объект не найден в extension-слое, обязательно проверь base-слой перед выводом «не найдено».
53
+
54
+ ## Ограничения
55
+
56
+ - RAG — это индекс исходников, не live runtime; для фактических данных базы используй UI/test-client или отдельный контролируемый запрос.
57
+ - СКД индексируется минимум по полям и выражениям; тело запроса СКД может потребовать ручного чтения `Template.dcs`.
58
+ - SQLite/FTS — слой поиска. Источником истины остаются файлы `.mdo`, `.xml`, `.dcs`.
mcp_1c/bridge.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ import websockets
11
+ from websockets.exceptions import ConnectionClosed
12
+ from websockets.server import WebSocketServer, WebSocketServerProtocol
13
+
14
+ from .protocol import BridgeRequest, BridgeResponse
15
+
16
+ LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ class BridgeError(RuntimeError):
20
+ """Raised when the 1C bridge is not connected or returns an error."""
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class ConnectedBridge:
25
+ websocket: WebSocketServerProtocol
26
+ peer: str
27
+ pending: dict[str, asyncio.Future[BridgeResponse]] = field(default_factory=dict)
28
+
29
+
30
+ class OneCBridgeServer:
31
+ """JSON-RPC-like WebSocket server for the embedded 1C manager processor."""
32
+
33
+ def __init__(self, host: str = "127.0.0.1", port: int = 8765, request_timeout: float = 60.0):
34
+ self.host = host
35
+ self.port = port
36
+ self.request_timeout = request_timeout
37
+ self._server: WebSocketServer | None = None
38
+ self._bridge: ConnectedBridge | None = None
39
+ self._lock = asyncio.Lock()
40
+
41
+ @property
42
+ def url(self) -> str:
43
+ return f"ws://{self.host}:{self.port}/bridge"
44
+
45
+ async def start(self) -> None:
46
+ if self._server is not None:
47
+ return
48
+ self._server = await websockets.serve(self._handler, self.host, self.port)
49
+ LOGGER.info("1C WebSocket bridge is listening on %s", self.url)
50
+
51
+ async def stop(self) -> None:
52
+ if self._server is not None:
53
+ self._server.close()
54
+ await self._server.wait_closed()
55
+ self._server = None
56
+
57
+ def status(self) -> dict[str, Any]:
58
+ bridge = self._bridge
59
+ return {
60
+ "websocket_url": self.url,
61
+ "connected": bridge is not None,
62
+ "peer": bridge.peer if bridge else None,
63
+ "pending": len(bridge.pending) if bridge else 0,
64
+ }
65
+
66
+ async def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
67
+ bridge = self._bridge
68
+ if bridge is None:
69
+ raise BridgeError("1C bridge is not connected. Start the 1C manager first.")
70
+
71
+ request_id = str(uuid.uuid4())
72
+ future: asyncio.Future[BridgeResponse] = asyncio.get_running_loop().create_future()
73
+ bridge.pending[request_id] = future
74
+ request = BridgeRequest(id=request_id, method=method, params=params or {})
75
+
76
+ try:
77
+ await bridge.websocket.send(json.dumps(request.to_json(), ensure_ascii=False))
78
+ response = await asyncio.wait_for(future, timeout=self.request_timeout)
79
+ finally:
80
+ bridge.pending.pop(request_id, None)
81
+
82
+ if not response.ok:
83
+ raise BridgeError(response.error or f"1C bridge command {method!r} failed")
84
+ return response.result
85
+
86
+ async def _handler(self, websocket: WebSocketServerProtocol) -> None:
87
+ peer = f"{websocket.remote_address}"
88
+ connected = ConnectedBridge(websocket=websocket, peer=peer)
89
+
90
+ async with self._lock:
91
+ if self._bridge is not None:
92
+ await websocket.close(code=1013, reason="Only one 1C bridge connection is supported")
93
+ return
94
+ self._bridge = connected
95
+
96
+ try:
97
+ async for raw_message in websocket:
98
+ await self._process_message(connected, raw_message)
99
+ except ConnectionClosed:
100
+ # Normal during session teardown: 1C process may disappear before the
101
+ # websocket close frame is exchanged. Pending callers still get a
102
+ # BridgeError in finally below.
103
+ pass
104
+ finally:
105
+ async with self._lock:
106
+ if self._bridge is connected:
107
+ self._bridge = None
108
+ for future in connected.pending.values():
109
+ if not future.done():
110
+ future.set_exception(BridgeError("1C bridge disconnected"))
111
+ LOGGER.info("WebSocket bridge %s disconnected", peer)
112
+
113
+ async def _process_message(self, bridge: ConnectedBridge, raw_message: str | bytes) -> None:
114
+ if isinstance(raw_message, bytes):
115
+ raw_message = raw_message.decode("utf-8")
116
+ try:
117
+ data = json.loads(raw_message)
118
+ except json.JSONDecodeError:
119
+ LOGGER.warning("Ignoring non-JSON bridge message: %r", raw_message)
120
+ return
121
+
122
+ message_type = data.get("type")
123
+ if message_type in {"hello", "event"}:
124
+ LOGGER.info("1C bridge %s: %s", message_type, data)
125
+ return
126
+ if message_type != "response":
127
+ LOGGER.warning("Ignoring unknown bridge message: %s", data)
128
+ return
129
+
130
+ response = BridgeResponse.from_json(data)
131
+ future = bridge.pending.get(response.id)
132
+ if future is None:
133
+ LOGGER.warning("Response for unknown request id %s: %s", response.id, data)
134
+ return
135
+ if not future.done():
136
+ future.set_result(response)
mcp_1c/credentials.py ADDED
@@ -0,0 +1,147 @@
1
+ """Secure credential store for 1C infobase URLs.
2
+
3
+ Reads a JSON file mapping base URLs (exact or wildcard) to
4
+ {username, password}. The file is never committed to git;
5
+ a sample ``credentials.example.json`` is provided for reference.
6
+
7
+ Environment variable ``ONEC_MCP_CREDENTIALS_FILE`` overrides the
8
+ default path (``~/.onec-mcp-credentials.json``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import fnmatch
14
+ import json
15
+ import logging
16
+ import os
17
+ from pathlib import Path
18
+ LOGGER = logging.getLogger(__name__)
19
+
20
+ DEFAULT_CREDENTIALS_PATH = Path(
21
+ os.getenv("ONEC_MCP_CREDENTIALS_FILE",
22
+ str(Path.home() / ".onec-mcp-credentials.json"))
23
+ )
24
+
25
+
26
+ class CredentialsStore:
27
+ """Look up 1C credentials by base URL.
28
+
29
+ File format (JSON)::
30
+
31
+ {
32
+ "entries": [
33
+ {
34
+ "url": "https://example.invalid/infobase",
35
+ "username": "user",
36
+ "password": "secret"
37
+ },
38
+ {
39
+ "url": "https://*.example.invalid/*",
40
+ "username": "wildcard-user",
41
+ "password": "wildcard-secret"
42
+ }
43
+ ]
44
+ }
45
+
46
+ Matching rules (first match wins):
47
+ * exact ``url`` match
48
+ * ``fnmatch`` wildcard match (``*``, ``?``, ``[seq]``)
49
+ """
50
+
51
+ def __init__(self, path: Path | str | None = None) -> None:
52
+ self._path = Path(path or DEFAULT_CREDENTIALS_PATH)
53
+ self._entries: list[dict[str, str]] = []
54
+ self._load()
55
+
56
+ # ------------------------------------------------------------------
57
+ # public API
58
+ # ------------------------------------------------------------------
59
+
60
+ def lookup(self, base_url: str) -> dict[str, str] | None:
61
+ """Return {username, password} for *base_url*, or None."""
62
+ url = base_url.rstrip("/")
63
+ for entry in self._entries:
64
+ pattern = entry["url"].rstrip("/")
65
+ if url == pattern:
66
+ return {"username": entry["username"], "password": entry["password"]}
67
+ for entry in self._entries:
68
+ pattern = entry["url"].rstrip("/")
69
+ if fnmatch.fnmatch(url, pattern):
70
+ return {"username": entry["username"], "password": entry["password"]}
71
+ return None
72
+
73
+ def list_entries(self) -> list[dict[str, str]]:
74
+ """Return credential URL patterns without usernames or passwords."""
75
+ return [{"url": e["url"]} for e in self._entries]
76
+
77
+ def upsert(self, url: str, username: str, password: str) -> bool:
78
+ """Add or update a credential entry for *url*.
79
+
80
+ Returns True when an existing entry was updated, False when a new entry
81
+ was created. Usernames and passwords are intentionally not returned.
82
+ """
83
+ url = url.rstrip("/")
84
+ updated = False
85
+ for entry in self._entries:
86
+ if entry["url"].rstrip("/") == url:
87
+ entry["username"] = username
88
+ entry["password"] = password
89
+ updated = True
90
+ break
91
+ else:
92
+ self._entries.append({"url": url, "username": username, "password": password})
93
+ self._save()
94
+ return updated
95
+
96
+ def remove(self, url: str) -> bool:
97
+ """Remove the credential entry for *url* (exact match)."""
98
+ url = url.rstrip("/")
99
+ for i, entry in enumerate(self._entries):
100
+ if entry["url"].rstrip("/") == url:
101
+ del self._entries[i]
102
+ self._save()
103
+ return True
104
+ return False
105
+
106
+ def reload(self) -> None:
107
+ """Re-read the credentials file (useful after external edits)."""
108
+ self._load()
109
+
110
+ # ------------------------------------------------------------------
111
+ # internal
112
+ # ------------------------------------------------------------------
113
+
114
+ def _save(self) -> None:
115
+ """Persist entries to the JSON file atomically."""
116
+ self._path.parent.mkdir(parents=True, exist_ok=True)
117
+ tmp = self._path.with_suffix(self._path.suffix + ".tmp")
118
+ tmp.write_text(json.dumps({"entries": self._entries}, indent=2, ensure_ascii=False),
119
+ encoding="utf-8")
120
+ tmp.replace(self._path)
121
+ LOGGER.info("Saved %d credential entries to %s", len(self._entries), self._path)
122
+
123
+ def _load(self) -> None:
124
+ if not self._path.exists():
125
+ LOGGER.debug("Credentials file not found: %s", self._path)
126
+ self._entries = []
127
+ return
128
+ try:
129
+ data = json.loads(self._path.read_text(encoding="utf-8"))
130
+ self._entries = data.get("entries", [])
131
+ LOGGER.info("Loaded %d credential entries from %s",
132
+ len(self._entries), self._path)
133
+ except Exception as exc:
134
+ LOGGER.warning("Failed to load credentials from %s: %s",
135
+ self._path, exc)
136
+ self._entries = []
137
+
138
+
139
+ # Singleton for the MCP server process
140
+ _store: CredentialsStore | None = None
141
+
142
+
143
+ def get_store(path: Path | str | None = None) -> CredentialsStore:
144
+ global _store
145
+ if _store is None:
146
+ _store = CredentialsStore(path)
147
+ return _store