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.
- answer42-0.2.0.dist-info/METADATA +388 -0
- answer42-0.2.0.dist-info/RECORD +28 -0
- answer42-0.2.0.dist-info/WHEEL +4 -0
- answer42-0.2.0.dist-info/entry_points.txt +2 -0
- answer42-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_1c/__init__.py +4 -0
- mcp_1c/assets/MCPTestClient.cf +0 -0
- mcp_1c/assets/MCPTestManager.cf +0 -0
- mcp_1c/assets/__init__.py +1 -0
- mcp_1c/assets/skills/answer42/SKILL.md +170 -0
- mcp_1c/assets/skills/answer42-rag/SKILL.md +58 -0
- mcp_1c/bridge.py +136 -0
- mcp_1c/credentials.py +147 -0
- mcp_1c/os_support.py +224 -0
- mcp_1c/platform.py +187 -0
- mcp_1c/protocol.py +35 -0
- mcp_1c/rag/__init__.py +5 -0
- mcp_1c/rag/detect.py +23 -0
- mcp_1c/rag/model.py +114 -0
- mcp_1c/rag/parsers.py +387 -0
- mcp_1c/rag/service.py +375 -0
- mcp_1c/rag/store.py +228 -0
- mcp_1c/recorder.py +239 -0
- mcp_1c/release_helper.py +83 -0
- mcp_1c/runtime.py +636 -0
- mcp_1c/server.py +3285 -0
- mcp_1c/skill_installer.py +127 -0
- mcp_1c/window_control.py +276 -0
|
@@ -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
|