answer42 0.3.5__tar.gz → 0.4.2__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.
- {answer42-0.3.5 → answer42-0.4.2}/PKG-INFO +52 -16
- {answer42-0.3.5 → answer42-0.4.2}/README.md +50 -14
- answer42-0.4.2/credentials.example.json +23 -0
- {answer42-0.3.5 → answer42-0.4.2}/pyproject.toml +14 -2
- answer42-0.4.2/scripts/e2e_http_smoke.py +229 -0
- {answer42-0.3.5 → answer42-0.4.2}/scripts/e2e_stable.py +9 -9
- {answer42-0.3.5 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form/Module.bsl +155 -20
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/assets/MCPTestClient.cf +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/assets/MCPTestManager.cf +0 -0
- answer42-0.4.2/src/mcp_1c/auth.py +174 -0
- answer42-0.4.2/src/mcp_1c/credentials.py +365 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/os_support.py +13 -13
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/platform.py +2 -3
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/protocol.py +1 -1
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/parsers.py +1 -1
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/recorder.py +13 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/runtime.py +69 -69
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/server.py +447 -87
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/window_control.py +3 -2
- answer42-0.3.5/credentials.example.json +0 -14
- answer42-0.3.5/src/mcp_1c/credentials.py +0 -189
- {answer42-0.3.5 → answer42-0.4.2}/.gitignore +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/LICENSE +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/docs/agent-installation.md +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/docs/architecture.md +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/docs/assets/answer42-logo.png +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/docs/assets/platform42-logo.svg +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/docs/installation.md +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/scripts/build_cf.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/scripts/build_pages.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/scripts/openclaw_mcp_autoreload.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/scripts/rag_cli.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/cf/ConfigDumpInfo.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/cf/Configuration.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager/Ext/ObjectModule.bsl +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/cf/Ext/ManagedApplicationModule.bsl +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/Catalogs//320/237/320/241_/320/222/320/273/320/260/320/264/320/265/320/273/320/265/321/206.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/Catalogs//320/237/320/241_/320/241/320/277/321/200/320/260/320/262/320/276/321/207/320/275/320/270/320/2721.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/ConfigDumpInfo.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/Configuration.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Ext/ManagerModule.bsl +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Ext/ObjectModule.bsl +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Templates//320/236/321/201/320/275/320/276/320/262/320/275/320/260/321/217/320/241/321/205/320/265/320/274/320/260/320/232/320/276/320/274/320/277/320/276/320/275/320/276/320/262/320/272/320/270/320/224/320/260/320/275/320/275/321/213/321/205/Ext/Template.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Templates//320/236/321/201/320/275/320/276/320/262/320/275/320/260/321/217/320/241/321/205/320/265/320/274/320/260/320/232/320/276/320/274/320/277/320/276/320/275/320/276/320/262/320/272/320/270/320/224/320/260/320/275/320/275/321/213/321/205.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262.xml +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/__init__.py +1 -1
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/assets/__init__.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/assets/skills/answer42/SKILL.md +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/assets/skills/answer42-rag/SKILL.md +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/bridge.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/__init__.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/detect.py +1 -1
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/dump.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/model.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/service.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/store.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/release_helper.py +0 -0
- {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/skill_installer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: answer42
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Answer42 — The Answer to Life, Universe, and 1C — UI Driver. MCP-powered 1C:Enterprise UI automation: click, fill, navigate, test, and introspect managed forms through the test-client API
|
|
5
5
|
Author: Marvin (AI Assistant), 42Clouds, and contributors
|
|
6
6
|
Author-email: "Kosolapov Stanislav (proDOOMman)" <prodoomman@gmail.com>
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
17
|
Classifier: Topic :: Software Development :: Testing
|
|
18
18
|
Requires-Python: >=3.11
|
|
19
|
-
Requires-Dist: mcp
|
|
19
|
+
Requires-Dist: mcp<3,>=2.0.0
|
|
20
20
|
Requires-Dist: pydantic>=2.0
|
|
21
21
|
Requires-Dist: websockets>=12.0
|
|
22
22
|
Provides-Extra: dev
|
|
@@ -126,6 +126,31 @@ answer42 install-skills --dry-run --agent all
|
|
|
126
126
|
4. зарегистрируйте MCP-сервер в агентском клиенте;
|
|
127
127
|
5. проверьте установку через `credentials_check`, затем smoke-сессию `start_session` → `active_window` → `stop_session`.
|
|
128
128
|
|
|
129
|
+
## Транспорты: stdio и StreamableHTTP
|
|
130
|
+
|
|
131
|
+
По умолчанию Answer42 использует stdio MCP transport (запускается MCP-хостом как subprocess). Для удалённого deployment доступен **StreamableHTTP** режим с MCP auth:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
answer42 --http --http-host 0.0.0.0 --http-port 8080 \
|
|
135
|
+
--http-token "static-secret-token" \
|
|
136
|
+
--http-account-id "tenant-42"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Переменные окружения:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
export ONEC_MCP_HTTP=1
|
|
143
|
+
export ONEC_MCP_HTTP_HOST=0.0.0.0
|
|
144
|
+
export ONEC_MCP_HTTP_PORT=8080
|
|
145
|
+
export ONEC_MCP_HTTP_TOKEN="static-secret-token"
|
|
146
|
+
export ONEC_MCP_ACCOUNT_ID="tenant-42"
|
|
147
|
+
answer42
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- Каждый MCP-запрос должен содержать заголовок `Authorization: Bearer <token>`.
|
|
151
|
+
- `account_id` определяет namespace для credentials. Если не задан, вычисляется как стабильный хеш токена (`token-<16hex>`).
|
|
152
|
+
- Режим по умолчанию **stateless** (`--http-stateless`/`ONEC_MCP_HTTP_STATELESS` default `1`); для resumable sessions передай `--http-stateless`/`0`.
|
|
153
|
+
|
|
129
154
|
## Безопасное хранение логинов и паролей
|
|
130
155
|
|
|
131
156
|
Чтобы креды были доступны MCP-серверу, но не попадали в чат и аргументы tool-call, храните их в локальном файле за пределами репозитория и передайте путь в окружение процесса Answer42:
|
|
@@ -140,28 +165,39 @@ export ONEC_MCP_CREDENTIALS_FILE=/secure/path/credentials.json
|
|
|
140
165
|
~/.answer42-credentials.json
|
|
141
166
|
```
|
|
142
167
|
|
|
143
|
-
Формат
|
|
168
|
+
Формат файла v2 (аккаунт-bound):
|
|
144
169
|
|
|
145
170
|
```json
|
|
146
171
|
{
|
|
147
|
-
"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
"
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
172
|
+
"version": 2,
|
|
173
|
+
"accounts": {
|
|
174
|
+
"tenant-42": {
|
|
175
|
+
"entries": [
|
|
176
|
+
{
|
|
177
|
+
"url": "https://example.invalid/infobase",
|
|
178
|
+
"username": "<USERNAME>",
|
|
179
|
+
"password": "<PASSWORD>",
|
|
180
|
+
"title": "dev-example",
|
|
181
|
+
"aliases": ["dev42"]
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"url": "https://*.example.invalid/*",
|
|
185
|
+
"username": "<WILDCARD_USERNAME>",
|
|
186
|
+
"password": "<WILDCARD_PASSWORD>",
|
|
187
|
+
"title": "wildcard-example",
|
|
188
|
+
"aliases": ["we"]
|
|
189
|
+
}
|
|
190
|
+
]
|
|
157
191
|
}
|
|
158
|
-
|
|
192
|
+
}
|
|
159
193
|
}
|
|
160
194
|
```
|
|
161
195
|
|
|
196
|
+
Устаревший v1 формат (flat `entries`) автоматически загружается в account `legacy` и мигрирует в v2 при следующем сохранении. Рекомендуется явно задать `account_id` (через HTTP auth claim или `ONEC_MCP_ACCOUNT_ID` в stdio) и перенести записи в соответствующий account.
|
|
197
|
+
|
|
162
198
|
Рекомендуемые права на файл: `0600`.
|
|
163
199
|
|
|
164
|
-
Правила
|
|
200
|
+
Правила матчинга внутри аккаунта: сначала точное совпадение `url`, затем wildcard (`*`, `?`) через `fnmatch`; первое совпадение побеждает. Пароли не логируются и не возвращаются наружу.
|
|
165
201
|
|
|
166
202
|
Для сохранения или обновления записи можно использовать MCP-tool:
|
|
167
203
|
|
|
@@ -185,7 +221,7 @@ credentials_remove(url="<TARGET_INFOBASE_URL>")
|
|
|
185
221
|
credentials_check(base_url="<TARGET_INFOBASE_URL>")
|
|
186
222
|
```
|
|
187
223
|
|
|
188
|
-
Ответы этих tools содержат только факт наличия/изменения/удаления записи и URL; логины и пароли не возвращаются. Tool `credentials_list()` оставлен в коде для локальной диагностики, но в OpenClaw-конфигурации его рекомендуется скрывать через `toolFilter.exclude`, чтобы агент не мог получить список URL-шаблонов.
|
|
224
|
+
Ответы этих tools содержат только факт наличия/изменения/удаления записи и URL; логины и пароли не возвращаются. `credentials_list()` дополнительно показывает статус проверки (`verified` / `verification_status`): новые или изменённые записи сохраняются как `unverified`, после успешного `start_session` с этой учёткой помечаются как `verified`, а `unverified` запись удаляется при ошибке авторизации. Tool `credentials_list()` оставлен в коде для локальной диагностики, но в OpenClaw-конфигурации его рекомендуется скрывать через `toolFilter.exclude`, чтобы агент не мог получить список URL-шаблонов.
|
|
189
225
|
|
|
190
226
|
Остановка сессии:
|
|
191
227
|
|
|
@@ -92,6 +92,31 @@ answer42 install-skills --dry-run --agent all
|
|
|
92
92
|
4. зарегистрируйте MCP-сервер в агентском клиенте;
|
|
93
93
|
5. проверьте установку через `credentials_check`, затем smoke-сессию `start_session` → `active_window` → `stop_session`.
|
|
94
94
|
|
|
95
|
+
## Транспорты: stdio и StreamableHTTP
|
|
96
|
+
|
|
97
|
+
По умолчанию Answer42 использует stdio MCP transport (запускается MCP-хостом как subprocess). Для удалённого deployment доступен **StreamableHTTP** режим с MCP auth:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
answer42 --http --http-host 0.0.0.0 --http-port 8080 \
|
|
101
|
+
--http-token "static-secret-token" \
|
|
102
|
+
--http-account-id "tenant-42"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Переменные окружения:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
export ONEC_MCP_HTTP=1
|
|
109
|
+
export ONEC_MCP_HTTP_HOST=0.0.0.0
|
|
110
|
+
export ONEC_MCP_HTTP_PORT=8080
|
|
111
|
+
export ONEC_MCP_HTTP_TOKEN="static-secret-token"
|
|
112
|
+
export ONEC_MCP_ACCOUNT_ID="tenant-42"
|
|
113
|
+
answer42
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- Каждый MCP-запрос должен содержать заголовок `Authorization: Bearer <token>`.
|
|
117
|
+
- `account_id` определяет namespace для credentials. Если не задан, вычисляется как стабильный хеш токена (`token-<16hex>`).
|
|
118
|
+
- Режим по умолчанию **stateless** (`--http-stateless`/`ONEC_MCP_HTTP_STATELESS` default `1`); для resumable sessions передай `--http-stateless`/`0`.
|
|
119
|
+
|
|
95
120
|
## Безопасное хранение логинов и паролей
|
|
96
121
|
|
|
97
122
|
Чтобы креды были доступны MCP-серверу, но не попадали в чат и аргументы tool-call, храните их в локальном файле за пределами репозитория и передайте путь в окружение процесса Answer42:
|
|
@@ -106,28 +131,39 @@ export ONEC_MCP_CREDENTIALS_FILE=/secure/path/credentials.json
|
|
|
106
131
|
~/.answer42-credentials.json
|
|
107
132
|
```
|
|
108
133
|
|
|
109
|
-
Формат
|
|
134
|
+
Формат файла v2 (аккаунт-bound):
|
|
110
135
|
|
|
111
136
|
```json
|
|
112
137
|
{
|
|
113
|
-
"
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
"
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
138
|
+
"version": 2,
|
|
139
|
+
"accounts": {
|
|
140
|
+
"tenant-42": {
|
|
141
|
+
"entries": [
|
|
142
|
+
{
|
|
143
|
+
"url": "https://example.invalid/infobase",
|
|
144
|
+
"username": "<USERNAME>",
|
|
145
|
+
"password": "<PASSWORD>",
|
|
146
|
+
"title": "dev-example",
|
|
147
|
+
"aliases": ["dev42"]
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"url": "https://*.example.invalid/*",
|
|
151
|
+
"username": "<WILDCARD_USERNAME>",
|
|
152
|
+
"password": "<WILDCARD_PASSWORD>",
|
|
153
|
+
"title": "wildcard-example",
|
|
154
|
+
"aliases": ["we"]
|
|
155
|
+
}
|
|
156
|
+
]
|
|
123
157
|
}
|
|
124
|
-
|
|
158
|
+
}
|
|
125
159
|
}
|
|
126
160
|
```
|
|
127
161
|
|
|
162
|
+
Устаревший v1 формат (flat `entries`) автоматически загружается в account `legacy` и мигрирует в v2 при следующем сохранении. Рекомендуется явно задать `account_id` (через HTTP auth claim или `ONEC_MCP_ACCOUNT_ID` в stdio) и перенести записи в соответствующий account.
|
|
163
|
+
|
|
128
164
|
Рекомендуемые права на файл: `0600`.
|
|
129
165
|
|
|
130
|
-
Правила
|
|
166
|
+
Правила матчинга внутри аккаунта: сначала точное совпадение `url`, затем wildcard (`*`, `?`) через `fnmatch`; первое совпадение побеждает. Пароли не логируются и не возвращаются наружу.
|
|
131
167
|
|
|
132
168
|
Для сохранения или обновления записи можно использовать MCP-tool:
|
|
133
169
|
|
|
@@ -151,7 +187,7 @@ credentials_remove(url="<TARGET_INFOBASE_URL>")
|
|
|
151
187
|
credentials_check(base_url="<TARGET_INFOBASE_URL>")
|
|
152
188
|
```
|
|
153
189
|
|
|
154
|
-
Ответы этих tools содержат только факт наличия/изменения/удаления записи и URL; логины и пароли не возвращаются. Tool `credentials_list()` оставлен в коде для локальной диагностики, но в OpenClaw-конфигурации его рекомендуется скрывать через `toolFilter.exclude`, чтобы агент не мог получить список URL-шаблонов.
|
|
190
|
+
Ответы этих tools содержат только факт наличия/изменения/удаления записи и URL; логины и пароли не возвращаются. `credentials_list()` дополнительно показывает статус проверки (`verified` / `verification_status`): новые или изменённые записи сохраняются как `unverified`, после успешного `start_session` с этой учёткой помечаются как `verified`, а `unverified` запись удаляется при ошибке авторизации. Tool `credentials_list()` оставлен в коде для локальной диагностики, но в OpenClaw-конфигурации его рекомендуется скрывать через `toolFilter.exclude`, чтобы агент не мог получить список URL-шаблонов.
|
|
155
191
|
|
|
156
192
|
Остановка сессии:
|
|
157
193
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 2,
|
|
3
|
+
"accounts": {
|
|
4
|
+
"default": {
|
|
5
|
+
"entries": [
|
|
6
|
+
{
|
|
7
|
+
"url": "https://example.invalid/infobase",
|
|
8
|
+
"username": "<USERNAME>",
|
|
9
|
+
"password": "<PASSWORD>",
|
|
10
|
+
"verified": false
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"url": "https://*.example.invalid/*",
|
|
14
|
+
"username": "<WILDCARD_USERNAME>",
|
|
15
|
+
"password": "<WILDCARD_PASSWORD>",
|
|
16
|
+
"verified": true,
|
|
17
|
+
"title": "wildcard-example",
|
|
18
|
+
"aliases": ["we"]
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "answer42"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.2"
|
|
4
4
|
description = "Answer42 — The Answer to Life, Universe, and 1C — UI Driver. MCP-powered 1C:Enterprise UI automation: click, fill, navigate, test, and introspect managed forms through the test-client API"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
|
-
dependencies = ["mcp>=
|
|
7
|
+
dependencies = ["mcp>=2.0.0,<3", "websockets>=12.0", "pydantic>=2.0"]
|
|
8
8
|
keywords = ["mcp", "1c", "1c-enterprise", "ui-automation", "test-client"]
|
|
9
9
|
classifiers = [
|
|
10
10
|
"Development Status :: 3 - Alpha",
|
|
@@ -37,6 +37,18 @@ answer42 = "mcp_1c.server:main"
|
|
|
37
37
|
[tool.ruff]
|
|
38
38
|
line-length = 100
|
|
39
39
|
src = ["src", "tests"]
|
|
40
|
+
lint.ignore = [
|
|
41
|
+
"BLE001",
|
|
42
|
+
"DTZ003",
|
|
43
|
+
"DTZ005",
|
|
44
|
+
"DTZ007",
|
|
45
|
+
"DTZ901",
|
|
46
|
+
"LOG015",
|
|
47
|
+
"PLW1510",
|
|
48
|
+
"S110",
|
|
49
|
+
"S112",
|
|
50
|
+
"TRY004",
|
|
51
|
+
]
|
|
40
52
|
|
|
41
53
|
[build-system]
|
|
42
54
|
requires = ["hatchling"]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ruff: noqa: E402
|
|
3
|
+
"""Autonomous smoke E2E test for Answer42 StreamableHTTP transport.
|
|
4
|
+
|
|
5
|
+
Starts Answer42 in HTTP mode with a static Bearer token, connects via
|
|
6
|
+
mcp.client.streamable_http_client, and runs a minimal smoke flow:
|
|
7
|
+
- credentials_save / credentials_check scoped to the HTTP account
|
|
8
|
+
- start_session
|
|
9
|
+
- active_window
|
|
10
|
+
- stop_session
|
|
11
|
+
|
|
12
|
+
Requires the same environment variables as stdio smoke for the 1C target:
|
|
13
|
+
ONEC_MCP_BASE_URL, ONEC_MCP_USERNAME, ONEC_MCP_PASSWORD, ONEC_MCP_VERSION (optional)
|
|
14
|
+
Override the HTTP token/account with:
|
|
15
|
+
ONEC_MCP_HTTP_TOKEN (default: generated random token)
|
|
16
|
+
ONEC_MCP_HTTP_ACCOUNT_ID (default: "http-smoke-<random>")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import secrets
|
|
25
|
+
import socket
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
33
|
+
sys.path.insert(0, str(ROOT / "src"))
|
|
34
|
+
|
|
35
|
+
import httpx2
|
|
36
|
+
from mcp import ClientSession
|
|
37
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
38
|
+
from mcp.client._memory import InMemoryTransport
|
|
39
|
+
from mcp.client.client import Client
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_tool_result(result):
|
|
43
|
+
sc = result.structured_content
|
|
44
|
+
if isinstance(sc, dict) and "result" in sc:
|
|
45
|
+
return sc["result"]
|
|
46
|
+
if sc not in (None, {}):
|
|
47
|
+
return sc
|
|
48
|
+
texts = [getattr(item, "text", "") for item in result.content if getattr(item, "type", "") == "text"]
|
|
49
|
+
if len(texts) == 1:
|
|
50
|
+
try:
|
|
51
|
+
return json.loads(texts[0])
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
return texts[0]
|
|
54
|
+
parsed = []
|
|
55
|
+
for text in texts:
|
|
56
|
+
try:
|
|
57
|
+
parsed.append(json.loads(text))
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
parsed.append(text)
|
|
60
|
+
return parsed
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def call_ok(session: ClientSession, name: str, args: dict | None = None):
|
|
64
|
+
result = await session.call_tool(name, args or {})
|
|
65
|
+
if result.is_error:
|
|
66
|
+
raise AssertionError(f"{name} unexpectedly failed: {result.content}")
|
|
67
|
+
value = _parse_tool_result(result)
|
|
68
|
+
print(f"OK {name}", flush=True)
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def main() -> int:
|
|
73
|
+
base_url = os.environ.get("ONEC_MCP_BASE_URL", "")
|
|
74
|
+
username = os.environ.get("ONEC_MCP_USERNAME", "")
|
|
75
|
+
password = os.environ.get("ONEC_MCP_PASSWORD", "")
|
|
76
|
+
if not base_url or not username or not password:
|
|
77
|
+
print("Set ONEC_MCP_BASE_URL, ONEC_MCP_USERNAME and ONEC_MCP_PASSWORD", flush=True)
|
|
78
|
+
return 2
|
|
79
|
+
|
|
80
|
+
token = os.environ.get("ONEC_MCP_HTTP_TOKEN") or f"smoke-{secrets.token_urlsafe(24)}"
|
|
81
|
+
account_id = os.environ.get("ONEC_MCP_HTTP_ACCOUNT_ID") or f"http-smoke-{secrets.token_hex(6)}"
|
|
82
|
+
host = os.environ.get("ONEC_MCP_HTTP_HOST", "127.0.0.1")
|
|
83
|
+
port_env = os.environ.get("ONEC_MCP_HTTP_PORT")
|
|
84
|
+
if port_env:
|
|
85
|
+
port = int(port_env)
|
|
86
|
+
else:
|
|
87
|
+
# Pick a random free port to avoid collisions with OpenClaw gateway/other MCP servers.
|
|
88
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
89
|
+
s.bind((host, 0))
|
|
90
|
+
port = s.getsockname()[1]
|
|
91
|
+
http_url = f"http://{host}:{port}/mcp"
|
|
92
|
+
|
|
93
|
+
# Use a temp credentials file so the smoke does not mutate the user's store.
|
|
94
|
+
creds_file = Path(tempfile.gettempdir()) / f"answer42-http-smoke-{secrets.token_hex(6)}.json"
|
|
95
|
+
env = {
|
|
96
|
+
**os.environ,
|
|
97
|
+
"ONEC_MCP_CREDENTIALS_FILE": str(creds_file),
|
|
98
|
+
"ONEC_MCP_HTTP": "1",
|
|
99
|
+
"ONEC_MCP_HTTP_HOST": host,
|
|
100
|
+
"ONEC_MCP_HTTP_PORT": str(port),
|
|
101
|
+
"ONEC_MCP_HTTP_TOKEN": token,
|
|
102
|
+
"ONEC_MCP_ACCOUNT_ID": account_id,
|
|
103
|
+
"ONEC_MCP_LOG_LEVEL": "INFO",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
print(f"=== Answer42 StreamableHTTP smoke ===", flush=True)
|
|
107
|
+
print(f"endpoint={http_url} account_id={account_id}", flush=True)
|
|
108
|
+
|
|
109
|
+
server_cmd = [
|
|
110
|
+
sys.executable,
|
|
111
|
+
"-m",
|
|
112
|
+
"mcp_1c.server",
|
|
113
|
+
]
|
|
114
|
+
proc = subprocess.Popen(
|
|
115
|
+
server_cmd,
|
|
116
|
+
cwd=str(ROOT),
|
|
117
|
+
env=env,
|
|
118
|
+
stdout=subprocess.PIPE,
|
|
119
|
+
stderr=subprocess.STDOUT,
|
|
120
|
+
text=True,
|
|
121
|
+
bufsize=1,
|
|
122
|
+
)
|
|
123
|
+
print(f"server pid={proc.pid}", flush=True)
|
|
124
|
+
|
|
125
|
+
# Wait for the HTTP endpoint to become ready.
|
|
126
|
+
deadline = time.monotonic() + 30.0
|
|
127
|
+
ready = False
|
|
128
|
+
http_client = httpx2.AsyncClient(
|
|
129
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
130
|
+
timeout=httpx2.Timeout(10.0, read=120.0),
|
|
131
|
+
follow_redirects=True,
|
|
132
|
+
)
|
|
133
|
+
last_err = ""
|
|
134
|
+
while time.monotonic() < deadline:
|
|
135
|
+
try:
|
|
136
|
+
resp = await http_client.head(http_url)
|
|
137
|
+
if resp.status_code in (200, 202, 404, 405):
|
|
138
|
+
ready = True
|
|
139
|
+
break
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
last_err = str(exc)
|
|
142
|
+
await asyncio.sleep(0.2)
|
|
143
|
+
if not ready:
|
|
144
|
+
proc.terminate()
|
|
145
|
+
try:
|
|
146
|
+
proc.wait(timeout=5)
|
|
147
|
+
except subprocess.TimeoutExpired:
|
|
148
|
+
proc.kill()
|
|
149
|
+
print(f"Server did not become ready: {last_err}", flush=True)
|
|
150
|
+
if proc.stdout:
|
|
151
|
+
print(proc.stdout.read(), flush=True)
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
async with streamable_http_client(http_url, http_client=http_client, terminate_on_close=False) as (
|
|
156
|
+
read_stream,
|
|
157
|
+
write_stream,
|
|
158
|
+
):
|
|
159
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
160
|
+
init_result = await session.initialize()
|
|
161
|
+
print("OK initialize", flush=True)
|
|
162
|
+
if init_result:
|
|
163
|
+
server_info = getattr(init_result, "serverInfo", None)
|
|
164
|
+
if server_info:
|
|
165
|
+
print(f"serverInfo.name={getattr(server_info, 'name', None)} version={getattr(server_info, 'version', None)}", flush=True)
|
|
166
|
+
|
|
167
|
+
tools_result = await session.list_tools()
|
|
168
|
+
tool_names = [t.name for t in tools_result.tools]
|
|
169
|
+
print(f"OK list_tools ({len(tools_result.tools)} tools): {tool_names}", flush=True)
|
|
170
|
+
|
|
171
|
+
await call_ok(
|
|
172
|
+
session,
|
|
173
|
+
"credentials_save",
|
|
174
|
+
{
|
|
175
|
+
"url": base_url,
|
|
176
|
+
"username": username,
|
|
177
|
+
"password": password,
|
|
178
|
+
"title": "http-smoke-creds",
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
check = await call_ok(session, "credentials_check", {"base_url": base_url})
|
|
183
|
+
assert check.get("found") is True, f"credentials not found: {check}"
|
|
184
|
+
assert check.get("account_id") == account_id, f"wrong account_id: {check}"
|
|
185
|
+
print(f"OK credentials_check account_id={check.get('account_id')}", flush=True)
|
|
186
|
+
|
|
187
|
+
session_id = "http-smoke-session"
|
|
188
|
+
start_args = {
|
|
189
|
+
"session_id": session_id,
|
|
190
|
+
"base_url": base_url,
|
|
191
|
+
"idle_timeout_minutes": 10,
|
|
192
|
+
}
|
|
193
|
+
# Let the server auto-resolve credentials by not passing username/password.
|
|
194
|
+
start = await call_ok(session, "start_session", start_args)
|
|
195
|
+
assert start.get("session_id") == session_id, f"unexpected session start result: {start}"
|
|
196
|
+
|
|
197
|
+
await call_ok(session, "active_window", {"session_id": session_id})
|
|
198
|
+
await call_ok(session, "stop_session", {"session_id": session_id, "clean_data": True})
|
|
199
|
+
|
|
200
|
+
# Verify the credentials file was written in v2 account-bound format.
|
|
201
|
+
data = json.loads(creds_file.read_text(encoding="utf-8"))
|
|
202
|
+
assert data.get("version") == 2, f"expected v2 credentials file: {data}"
|
|
203
|
+
assert account_id in data.get("accounts", {}), f"account {account_id} missing: {data}"
|
|
204
|
+
print("OK credentials file v2 with correct account", flush=True)
|
|
205
|
+
|
|
206
|
+
print("=== Answer42 StreamableHTTP smoke PASSED ===", flush=True)
|
|
207
|
+
return 0
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
import traceback
|
|
210
|
+
traceback.print_exc()
|
|
211
|
+
print(f"FAIL: {exc}", flush=True)
|
|
212
|
+
return 1
|
|
213
|
+
finally:
|
|
214
|
+
creds_file.unlink(missing_ok=True)
|
|
215
|
+
if proc.stdout:
|
|
216
|
+
remaining = proc.stdout.read()
|
|
217
|
+
if remaining:
|
|
218
|
+
print(f"--- server stdout ---\n{remaining}\n--- end server stdout ---", flush=True)
|
|
219
|
+
proc.terminate()
|
|
220
|
+
try:
|
|
221
|
+
proc.wait(timeout=10)
|
|
222
|
+
except subprocess.TimeoutExpired:
|
|
223
|
+
proc.kill()
|
|
224
|
+
proc.wait(timeout=5)
|
|
225
|
+
await http_client.aclose()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
raise SystemExit(asyncio.run(main()))
|
|
@@ -92,7 +92,7 @@ def _safe_suffix(value: str) -> str:
|
|
|
92
92
|
|
|
93
93
|
|
|
94
94
|
def _parse_tool_result(result: Any):
|
|
95
|
-
sc = result.
|
|
95
|
+
sc = result.structured_content
|
|
96
96
|
if isinstance(sc, dict) and "result" in sc:
|
|
97
97
|
return sc["result"]
|
|
98
98
|
if sc not in (None, {}):
|
|
@@ -114,7 +114,7 @@ def _parse_tool_result(result: Any):
|
|
|
114
114
|
|
|
115
115
|
async def call_ok(session: ClientSession, name: str, args: dict | None = None):
|
|
116
116
|
result = await session.call_tool(name, args or {})
|
|
117
|
-
if result.
|
|
117
|
+
if result.is_error:
|
|
118
118
|
raise AssertionError(f"{name} unexpectedly failed: {result.content}")
|
|
119
119
|
value = _parse_tool_result(result)
|
|
120
120
|
print(f"OK {name}", flush=True)
|
|
@@ -125,7 +125,7 @@ async def call_ui_ok(session: ClientSession, name: str, args: dict | None = None
|
|
|
125
125
|
args = args or {}
|
|
126
126
|
read_timeout = timedelta(seconds=timeout) if timeout else None
|
|
127
127
|
result = await session.call_tool(name, args, read_timeout_seconds=read_timeout)
|
|
128
|
-
if result.
|
|
128
|
+
if result.is_error:
|
|
129
129
|
value = "\n".join(getattr(item, "text", "") for item in result.content)
|
|
130
130
|
await _record_manual_step(session, args.get("session_id", "e2e-mcp"), name, args, {"error": value})
|
|
131
131
|
raise AssertionError(f"{name} unexpectedly failed: {result.content}")
|
|
@@ -139,7 +139,7 @@ async def call_ui_ok(session: ClientSession, name: str, args: dict | None = None
|
|
|
139
139
|
async def call_ui_optional_error(session: ClientSession, name: str, args: dict | None = None, allowed_contains: str = ""):
|
|
140
140
|
args = args or {}
|
|
141
141
|
result = await session.call_tool(name, args)
|
|
142
|
-
if not result.
|
|
142
|
+
if not result.is_error:
|
|
143
143
|
value = _parse_tool_result(result)
|
|
144
144
|
print(f"OK {name}", flush=True)
|
|
145
145
|
return value
|
|
@@ -154,7 +154,7 @@ async def call_ui_optional_error(session: ClientSession, name: str, args: dict |
|
|
|
154
154
|
async def call_ui_expected_error(session: ClientSession, name: str, args: dict | None = None, contains: str = ""):
|
|
155
155
|
args = args or {}
|
|
156
156
|
result = await session.call_tool(name, args)
|
|
157
|
-
if not result.
|
|
157
|
+
if not result.is_error:
|
|
158
158
|
value = _parse_tool_result(result)
|
|
159
159
|
await _record_manual_step(session, args.get("session_id", "e2e-mcp"), name, args, value)
|
|
160
160
|
raise AssertionError(f"{name} unexpectedly succeeded: {value!r}")
|
|
@@ -181,7 +181,7 @@ async def _record_manual_step(session: ClientSession, sid: str, name: str, args:
|
|
|
181
181
|
f"Response: {_summarize_for_note(value, 1200)}"
|
|
182
182
|
)
|
|
183
183
|
rec = await session.call_tool("recording_capture", {"action": name, "note": note, "session_id": sid})
|
|
184
|
-
if rec.
|
|
184
|
+
if rec.is_error:
|
|
185
185
|
text = "\n".join(getattr(item, "text", "") for item in rec.content)
|
|
186
186
|
raise AssertionError(f"recording_capture failed after {name}: {text}")
|
|
187
187
|
|
|
@@ -283,7 +283,7 @@ async def run_smoke(session: ClientSession, sid: str, suffix: str, screenshot_di
|
|
|
283
283
|
for tool in ("goto_start_page", "goto_next_window", "goto_previous_window"):
|
|
284
284
|
res = await session.call_tool(tool, {"session_id": sid})
|
|
285
285
|
print(f"OK_OR_EXPECTED_ERROR {tool}", flush=True)
|
|
286
|
-
if not res.
|
|
286
|
+
if not res.is_error:
|
|
287
287
|
_parse_tool_result(res)
|
|
288
288
|
|
|
289
289
|
await call_ui_ok(session, "wait_window", {"title": windows.get("active_title", ""), "timeout": 3, "session_id": sid})
|
|
@@ -323,7 +323,7 @@ async def run_smoke(session: ClientSession, sid: str, suffix: str, screenshot_di
|
|
|
323
323
|
await call_ui_ok(session, "click_button", {"name": "ФормаЗаписатьИЗакрыть", "session_id": sid})
|
|
324
324
|
await call_ui_ok(session, "table_goto_row", {"name": "Список", "text": "Тестовый smoke", "session_id": sid})
|
|
325
325
|
await call_ui_ok(session, "click_button", {"name": "ФормаИзменить", "session_id": sid})
|
|
326
|
-
await call_ui_ok(session, "ui_tree", {"session_id": sid, "
|
|
326
|
+
await call_ui_ok(session, "ui_tree", {"session_id": sid, "include_rag_types": True})
|
|
327
327
|
await call_ui_ok(session, "command_bar", {"session_id": sid})
|
|
328
328
|
await call_ui_ok(session, "current_element", {"session_id": sid})
|
|
329
329
|
await call_ui_ok(session, "click_button", {"name": "ФормаЗаписатьИЗакрыть", "session_id": sid})
|
|
@@ -510,7 +510,7 @@ async def run_coverage(session: ClientSession, sid: str, suffix: str, screenshot
|
|
|
510
510
|
for tool, args, expected in negative_calls:
|
|
511
511
|
res = await session.call_tool(tool, args)
|
|
512
512
|
if expected:
|
|
513
|
-
if not res.
|
|
513
|
+
if not res.is_error:
|
|
514
514
|
value = _parse_tool_result(res)
|
|
515
515
|
if not (isinstance(value, dict) and value.get("found") is False):
|
|
516
516
|
raise AssertionError(f"{tool} unexpectedly succeeded: {value!r}")
|