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.
Files changed (63) hide show
  1. {answer42-0.3.5 → answer42-0.4.2}/PKG-INFO +52 -16
  2. {answer42-0.3.5 → answer42-0.4.2}/README.md +50 -14
  3. answer42-0.4.2/credentials.example.json +23 -0
  4. {answer42-0.3.5 → answer42-0.4.2}/pyproject.toml +14 -2
  5. answer42-0.4.2/scripts/e2e_http_smoke.py +229 -0
  6. {answer42-0.3.5 → answer42-0.4.2}/scripts/e2e_stable.py +9 -9
  7. {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
  8. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/assets/MCPTestClient.cf +0 -0
  9. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/assets/MCPTestManager.cf +0 -0
  10. answer42-0.4.2/src/mcp_1c/auth.py +174 -0
  11. answer42-0.4.2/src/mcp_1c/credentials.py +365 -0
  12. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/os_support.py +13 -13
  13. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/platform.py +2 -3
  14. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/protocol.py +1 -1
  15. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/parsers.py +1 -1
  16. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/recorder.py +13 -0
  17. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/runtime.py +69 -69
  18. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/server.py +447 -87
  19. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/window_control.py +3 -2
  20. answer42-0.3.5/credentials.example.json +0 -14
  21. answer42-0.3.5/src/mcp_1c/credentials.py +0 -189
  22. {answer42-0.3.5 → answer42-0.4.2}/.gitignore +0 -0
  23. {answer42-0.3.5 → answer42-0.4.2}/LICENSE +0 -0
  24. {answer42-0.3.5 → answer42-0.4.2}/docs/agent-installation.md +0 -0
  25. {answer42-0.3.5 → answer42-0.4.2}/docs/architecture.md +0 -0
  26. {answer42-0.3.5 → answer42-0.4.2}/docs/assets/answer42-logo.png +0 -0
  27. {answer42-0.3.5 → answer42-0.4.2}/docs/assets/platform42-logo.svg +0 -0
  28. {answer42-0.3.5 → answer42-0.4.2}/docs/installation.md +0 -0
  29. {answer42-0.3.5 → answer42-0.4.2}/scripts/build_cf.py +0 -0
  30. {answer42-0.3.5 → answer42-0.4.2}/scripts/build_pages.py +0 -0
  31. {answer42-0.3.5 → answer42-0.4.2}/scripts/openclaw_mcp_autoreload.py +0 -0
  32. {answer42-0.3.5 → answer42-0.4.2}/scripts/rag_cli.py +0 -0
  33. {answer42-0.3.5 → answer42-0.4.2}/src/cf/ConfigDumpInfo.xml +0 -0
  34. {answer42-0.3.5 → answer42-0.4.2}/src/cf/Configuration.xml +0 -0
  35. {answer42-0.3.5 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager/Ext/ObjectModule.bsl +0 -0
  36. {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
  37. {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
  38. {answer42-0.3.5 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager.xml +0 -0
  39. {answer42-0.3.5 → answer42-0.4.2}/src/cf/Ext/ManagedApplicationModule.bsl +0 -0
  40. {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
  41. {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
  42. {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
  43. {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/ConfigDumpInfo.xml +0 -0
  44. {answer42-0.3.5 → answer42-0.4.2}/src/client_cf/Configuration.xml +0 -0
  45. {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
  46. {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
  47. {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
  48. {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
  49. {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
  50. {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
  51. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/__init__.py +1 -1
  52. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/assets/__init__.py +0 -0
  53. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/assets/skills/answer42/SKILL.md +0 -0
  54. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/assets/skills/answer42-rag/SKILL.md +0 -0
  55. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/bridge.py +0 -0
  56. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/__init__.py +0 -0
  57. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/detect.py +1 -1
  58. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/dump.py +0 -0
  59. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/model.py +0 -0
  60. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/service.py +0 -0
  61. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/rag/store.py +0 -0
  62. {answer42-0.3.5 → answer42-0.4.2}/src/mcp_1c/release_helper.py +0 -0
  63. {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.5
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>=1.9.0
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
- "entries": [
148
- {
149
- "url": "https://example.invalid/infobase",
150
- "username": "<USERNAME>",
151
- "password": "<PASSWORD>"
152
- },
153
- {
154
- "url": "https://*.example.invalid/*",
155
- "username": "<WILDCARD_USERNAME>",
156
- "password": "<WILDCARD_PASSWORD>"
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
- Правила матчинга: сначала точное совпадение `url`, затем wildcard (`*`, `?`) через `fnmatch`; первое совпадение побеждает. Пароли не логируются и не возвращаются наружу.
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
- "entries": [
114
- {
115
- "url": "https://example.invalid/infobase",
116
- "username": "<USERNAME>",
117
- "password": "<PASSWORD>"
118
- },
119
- {
120
- "url": "https://*.example.invalid/*",
121
- "username": "<WILDCARD_USERNAME>",
122
- "password": "<WILDCARD_PASSWORD>"
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
- Правила матчинга: сначала точное совпадение `url`, затем wildcard (`*`, `?`) через `fnmatch`; первое совпадение побеждает. Пароли не логируются и не возвращаются наружу.
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.5"
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>=1.9.0", "websockets>=12.0", "pydantic>=2.0"]
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.structuredContent
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.isError:
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.isError:
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.isError:
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.isError:
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.isError:
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.isError:
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, "max_depth": 3, "include_rag_types": True})
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.isError:
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}")