answer42 0.4.1__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.4.1 → answer42-0.4.2}/PKG-INFO +2 -2
- {answer42-0.4.1 → answer42-0.4.2}/README.md +1 -1
- {answer42-0.4.1 → answer42-0.4.2}/credentials.example.json +3 -1
- {answer42-0.4.1 → answer42-0.4.2}/pyproject.toml +1 -1
- {answer42-0.4.1 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form/Module.bsl +1 -1
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/credentials.py +100 -11
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/server.py +169 -0
- {answer42-0.4.1 → answer42-0.4.2}/.gitignore +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/LICENSE +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/docs/agent-installation.md +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/docs/architecture.md +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/docs/assets/answer42-logo.png +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/docs/assets/platform42-logo.svg +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/docs/installation.md +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/scripts/build_cf.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/scripts/build_pages.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/scripts/e2e_http_smoke.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/scripts/e2e_stable.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/scripts/openclaw_mcp_autoreload.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/scripts/rag_cli.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/cf/ConfigDumpInfo.xml +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/cf/Configuration.xml +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager/Ext/ObjectModule.bsl +0 -0
- {answer42-0.4.1 → 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.4.1 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260.xml" +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager.xml +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/cf/Ext/ManagedApplicationModule.bsl +0 -0
- {answer42-0.4.1 → 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.4.1 → 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.4.1 → 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.4.1 → answer42-0.4.2}/src/client_cf/ConfigDumpInfo.xml +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/client_cf/Configuration.xml +0 -0
- {answer42-0.4.1 → 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.4.1 → 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.4.1 → 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.4.1 → 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.4.1 → 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.4.1 → 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.4.1 → answer42-0.4.2}/src/mcp_1c/__init__.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/assets/MCPTestClient.cf +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/assets/MCPTestManager.cf +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/assets/__init__.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/assets/skills/answer42/SKILL.md +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/assets/skills/answer42-rag/SKILL.md +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/auth.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/bridge.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/os_support.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/platform.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/protocol.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/__init__.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/detect.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/dump.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/model.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/parsers.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/service.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/store.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/recorder.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/release_helper.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/runtime.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/skill_installer.py +0 -0
- {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/window_control.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: answer42
|
|
3
|
-
Version: 0.4.
|
|
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>
|
|
@@ -221,7 +221,7 @@ credentials_remove(url="<TARGET_INFOBASE_URL>")
|
|
|
221
221
|
credentials_check(base_url="<TARGET_INFOBASE_URL>")
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
-
Ответы этих 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-шаблонов.
|
|
225
225
|
|
|
226
226
|
Остановка сессии:
|
|
227
227
|
|
|
@@ -187,7 +187,7 @@ credentials_remove(url="<TARGET_INFOBASE_URL>")
|
|
|
187
187
|
credentials_check(base_url="<TARGET_INFOBASE_URL>")
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
Ответы этих 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-шаблонов.
|
|
191
191
|
|
|
192
192
|
Остановка сессии:
|
|
193
193
|
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
{
|
|
7
7
|
"url": "https://example.invalid/infobase",
|
|
8
8
|
"username": "<USERNAME>",
|
|
9
|
-
"password": "<PASSWORD>"
|
|
9
|
+
"password": "<PASSWORD>",
|
|
10
|
+
"verified": false
|
|
10
11
|
},
|
|
11
12
|
{
|
|
12
13
|
"url": "https://*.example.invalid/*",
|
|
13
14
|
"username": "<WILDCARD_USERNAME>",
|
|
14
15
|
"password": "<WILDCARD_PASSWORD>",
|
|
16
|
+
"verified": true,
|
|
15
17
|
"title": "wildcard-example",
|
|
16
18
|
"aliases": ["we"]
|
|
17
19
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "answer42"
|
|
3
|
-
version = "0.4.
|
|
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"
|
|
@@ -1451,7 +1451,7 @@
|
|
|
1451
1451
|
КонецЕсли;
|
|
1452
1452
|
|
|
1453
1453
|
Попытка
|
|
1454
|
-
АктивноеОкно =
|
|
1454
|
+
АктивноеОкно = ТестируемоеПриложение.ПолучитьАктивноеОкно();
|
|
1455
1455
|
Форма = НайтиОбъектРекурсивно(АктивноеОкно, "", "", "ТестируемаяФорма", 0, 8);
|
|
1456
1456
|
Если Форма <> Неопределено Тогда
|
|
1457
1457
|
Форма.ПерейтиКСледующемуЭлементу();
|
|
@@ -62,7 +62,7 @@ class CredentialsStore:
|
|
|
62
62
|
def __init__(self, path: Path | str | None = None) -> None:
|
|
63
63
|
self._path = Path(path or DEFAULT_CREDENTIALS_PATH)
|
|
64
64
|
self._version = 2
|
|
65
|
-
self._accounts: dict[str, dict[str, list[dict[str,
|
|
65
|
+
self._accounts: dict[str, dict[str, list[dict[str, object]]]] = {}
|
|
66
66
|
self._load()
|
|
67
67
|
|
|
68
68
|
# ------------------------------------------------------------------
|
|
@@ -114,10 +114,14 @@ class CredentialsStore:
|
|
|
114
114
|
self,
|
|
115
115
|
account_id: str | None = None,
|
|
116
116
|
) -> list[dict[str, object]]:
|
|
117
|
-
"""Return credential titles
|
|
117
|
+
"""Return credential titles, URL patterns and verification status without secrets."""
|
|
118
118
|
result: list[dict[str, object]] = []
|
|
119
119
|
for entry in self._account_entries(account_id):
|
|
120
|
-
public: dict[str, object] = {
|
|
120
|
+
public: dict[str, object] = {
|
|
121
|
+
"url": entry.get("url", ""),
|
|
122
|
+
"verified": bool(entry.get("verified", False)),
|
|
123
|
+
"verification_status": "verified" if entry.get("verified", False) else "unverified",
|
|
124
|
+
}
|
|
121
125
|
if entry.get("title"):
|
|
122
126
|
public["title"] = entry["title"]
|
|
123
127
|
if entry.get("aliases"):
|
|
@@ -158,22 +162,32 @@ class CredentialsStore:
|
|
|
158
162
|
for entry in entries:
|
|
159
163
|
entry_url = str(entry.get("url", "")).rstrip("/")
|
|
160
164
|
entry_title = str(entry.get("title", "")).strip()
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
same_url_or_title = entry_url == url or (title and entry_title == title)
|
|
166
|
+
same_credentials = entry.get("username") == username and entry.get("password") == password
|
|
167
|
+
duplicate_match = same_credentials and self._credential_url_matches(entry_url, url)
|
|
168
|
+
if same_url_or_title or duplicate_match:
|
|
169
|
+
# Do not create a second exact URL when an existing wildcard already
|
|
170
|
+
# covers it (or vice versa) for the same username/password. Merge
|
|
171
|
+
# user-facing labels into the existing entry instead.
|
|
172
|
+
if not duplicate_match or not entry_url:
|
|
173
|
+
entry["url"] = url
|
|
163
174
|
entry["username"] = username
|
|
164
175
|
entry["password"] = password
|
|
176
|
+
entry["verified"] = False
|
|
165
177
|
if title:
|
|
166
178
|
entry["title"] = title
|
|
167
|
-
|
|
179
|
+
elif not duplicate_match:
|
|
168
180
|
entry.pop("title", None)
|
|
169
181
|
if aliases:
|
|
170
|
-
entry
|
|
171
|
-
|
|
182
|
+
existing_aliases = [str(a).strip() for a in entry.get("aliases", []) if str(a).strip()]
|
|
183
|
+
merged_aliases = list(dict.fromkeys(existing_aliases + aliases)) if duplicate_match else aliases
|
|
184
|
+
entry["aliases"] = merged_aliases
|
|
185
|
+
elif not duplicate_match:
|
|
172
186
|
entry.pop("aliases", None)
|
|
173
187
|
updated = True
|
|
174
188
|
break
|
|
175
189
|
else:
|
|
176
|
-
entry = {"url": url, "username": username, "password": password}
|
|
190
|
+
entry = {"url": url, "username": username, "password": password, "verified": False}
|
|
177
191
|
if title:
|
|
178
192
|
entry["title"] = title
|
|
179
193
|
if aliases:
|
|
@@ -199,6 +213,45 @@ class CredentialsStore:
|
|
|
199
213
|
return True
|
|
200
214
|
return False
|
|
201
215
|
|
|
216
|
+
def mark_verified(
|
|
217
|
+
self,
|
|
218
|
+
url: str,
|
|
219
|
+
username: str,
|
|
220
|
+
password: str,
|
|
221
|
+
account_id: str | None = None,
|
|
222
|
+
) -> bool:
|
|
223
|
+
"""Mark a matching credential as successfully verified."""
|
|
224
|
+
entry = self._find_entry(url, username, password, account_id)
|
|
225
|
+
if entry is None:
|
|
226
|
+
return False
|
|
227
|
+
if entry.get("verified") is True:
|
|
228
|
+
return True
|
|
229
|
+
entry["verified"] = True
|
|
230
|
+
self._save()
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
def remove_matching(
|
|
234
|
+
self,
|
|
235
|
+
url: str,
|
|
236
|
+
username: str,
|
|
237
|
+
password: str,
|
|
238
|
+
account_id: str | None = None,
|
|
239
|
+
) -> bool:
|
|
240
|
+
"""Remove a credential entry matching URL/pattern and username/password."""
|
|
241
|
+
entries = self._account_entries(account_id)
|
|
242
|
+
url = str(url or "").rstrip("/")
|
|
243
|
+
for i, entry in enumerate(entries):
|
|
244
|
+
entry_url = str(entry.get("url", "")).rstrip("/")
|
|
245
|
+
if (
|
|
246
|
+
entry.get("username") == username
|
|
247
|
+
and entry.get("password") == password
|
|
248
|
+
and self._credential_url_matches(entry_url, url)
|
|
249
|
+
):
|
|
250
|
+
del entries[i]
|
|
251
|
+
self._save()
|
|
252
|
+
return True
|
|
253
|
+
return False
|
|
254
|
+
|
|
202
255
|
def reload(self) -> None:
|
|
203
256
|
"""Re-read the credentials file (useful after external edits)."""
|
|
204
257
|
self._load()
|
|
@@ -211,16 +264,52 @@ class CredentialsStore:
|
|
|
211
264
|
# internal
|
|
212
265
|
# ------------------------------------------------------------------
|
|
213
266
|
|
|
214
|
-
def _ensure_account(self, account_id: str) -> list[dict[str,
|
|
267
|
+
def _ensure_account(self, account_id: str) -> list[dict[str, object]]:
|
|
215
268
|
account_id = _normalize_account(account_id)
|
|
216
269
|
if account_id not in self._accounts:
|
|
217
270
|
self._accounts[account_id] = {"entries": []}
|
|
218
271
|
return self._accounts[account_id]["entries"]
|
|
219
272
|
|
|
220
|
-
def _account_entries(self, account_id: str | None) -> list[dict[str,
|
|
273
|
+
def _account_entries(self, account_id: str | None) -> list[dict[str, object]]:
|
|
221
274
|
account_id = _normalize_account(account_id)
|
|
222
275
|
return self._accounts.get(account_id, {}).get("entries", [])
|
|
223
276
|
|
|
277
|
+
@staticmethod
|
|
278
|
+
def _has_wildcards(value: str) -> bool:
|
|
279
|
+
return any(ch in value for ch in "*?[")
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def _credential_url_matches(cls, stored_url: str, candidate_url: str) -> bool:
|
|
283
|
+
stored_url = str(stored_url or "").rstrip("/")
|
|
284
|
+
candidate_url = str(candidate_url or "").rstrip("/")
|
|
285
|
+
if not stored_url or not candidate_url:
|
|
286
|
+
return False
|
|
287
|
+
if stored_url == candidate_url:
|
|
288
|
+
return True
|
|
289
|
+
if cls._has_wildcards(stored_url) and fnmatch.fnmatch(candidate_url, stored_url):
|
|
290
|
+
return True
|
|
291
|
+
if cls._has_wildcards(candidate_url) and fnmatch.fnmatch(stored_url, candidate_url):
|
|
292
|
+
return True
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
def _find_entry(
|
|
296
|
+
self,
|
|
297
|
+
url: str,
|
|
298
|
+
username: str,
|
|
299
|
+
password: str,
|
|
300
|
+
account_id: str | None = None,
|
|
301
|
+
) -> dict[str, object] | None:
|
|
302
|
+
url = str(url or "").rstrip("/")
|
|
303
|
+
for entry in self._account_entries(account_id):
|
|
304
|
+
entry_url = str(entry.get("url", "")).rstrip("/")
|
|
305
|
+
if (
|
|
306
|
+
entry.get("username") == username
|
|
307
|
+
and entry.get("password") == password
|
|
308
|
+
and self._credential_url_matches(entry_url, url)
|
|
309
|
+
):
|
|
310
|
+
return entry
|
|
311
|
+
return None
|
|
312
|
+
|
|
224
313
|
def _save(self) -> None:
|
|
225
314
|
"""Persist entries to the JSON file atomically."""
|
|
226
315
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -1792,6 +1792,131 @@ async def _bridge_call_recorded(
|
|
|
1792
1792
|
return _record_tool(session_id, method, params, result)
|
|
1793
1793
|
|
|
1794
1794
|
|
|
1795
|
+
_TOOL_LOG_MAX_VALUE = int(os.getenv("ONEC_MCP_TOOL_LOG_MAX_VALUE", "4096"))
|
|
1796
|
+
_TOOL_LOG_REDACT_KEYS = {
|
|
1797
|
+
"password", "token", "api_token", "secret", "api_key", "auth",
|
|
1798
|
+
"authorization", "bearer", "access_token", "refresh_token",
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
def _redact_value(value: Any) -> Any:
|
|
1803
|
+
if isinstance(value, str) and len(value) > 6 and (
|
|
1804
|
+
any(kw in value.lower() for kw in ("password", "token", "secret", "api_key"))
|
|
1805
|
+
):
|
|
1806
|
+
return "***"
|
|
1807
|
+
if isinstance(value, dict):
|
|
1808
|
+
return {k: "***" if k.lower() in _TOOL_LOG_REDACT_KEYS else _redact_value(v) for k, v in value.items()}
|
|
1809
|
+
if isinstance(value, list):
|
|
1810
|
+
return [_redact_value(v) for v in value]
|
|
1811
|
+
return value
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
def _truncate_value(value: Any) -> str:
|
|
1815
|
+
try:
|
|
1816
|
+
text = json.dumps(value, ensure_ascii=False, default=str)
|
|
1817
|
+
except Exception:
|
|
1818
|
+
text = str(value)
|
|
1819
|
+
if len(text) > _TOOL_LOG_MAX_VALUE:
|
|
1820
|
+
return text[:_TOOL_LOG_MAX_VALUE] + f"... [truncated, total {len(text)}]"
|
|
1821
|
+
return text
|
|
1822
|
+
|
|
1823
|
+
|
|
1824
|
+
def _log_tool_call(
|
|
1825
|
+
session_id: str,
|
|
1826
|
+
method: str,
|
|
1827
|
+
params: dict[str, Any],
|
|
1828
|
+
result: Any,
|
|
1829
|
+
error: str | None,
|
|
1830
|
+
duration: float,
|
|
1831
|
+
) -> None:
|
|
1832
|
+
logger = logging.getLogger("mcp_1c.tools")
|
|
1833
|
+
if not logger.isEnabledFor(logging.DEBUG) and not logger.handlers:
|
|
1834
|
+
return
|
|
1835
|
+
status = "error" if error else "ok"
|
|
1836
|
+
message = (
|
|
1837
|
+
f"tool={method} status={status} duration_ms={duration * 1000:.2f} "
|
|
1838
|
+
f"params={_truncate_value(_redact_value(params))} "
|
|
1839
|
+
f"result={_truncate_value(_redact_value(result)) if error is None else '<none>'} "
|
|
1840
|
+
f"error={error or '<none>'}"
|
|
1841
|
+
)
|
|
1842
|
+
logger.debug(message, extra={"session_id": session_id, "tool_name": method})
|
|
1843
|
+
|
|
1844
|
+
|
|
1845
|
+
_TOOL_LOGGER_CONFIGURED_PATH: str | None = None
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def _configure_tool_logging(log_path: str | Path) -> None:
|
|
1849
|
+
"""Enable per-call logging of every tool invocation to a rotating file."""
|
|
1850
|
+
global _TOOL_LOGGER_CONFIGURED_PATH
|
|
1851
|
+
if not log_path:
|
|
1852
|
+
return
|
|
1853
|
+
path_str = str(log_path)
|
|
1854
|
+
if _TOOL_LOGGER_CONFIGURED_PATH == path_str:
|
|
1855
|
+
return
|
|
1856
|
+
path = Path(log_path)
|
|
1857
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1858
|
+
logger = logging.getLogger("mcp_1c.tools")
|
|
1859
|
+
logger.setLevel(logging.DEBUG)
|
|
1860
|
+
logger.propagate = False
|
|
1861
|
+
for handler in list(logger.handlers):
|
|
1862
|
+
if isinstance(handler, logging.handlers.RotatingFileHandler):
|
|
1863
|
+
logger.removeHandler(handler)
|
|
1864
|
+
handler.close()
|
|
1865
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
1866
|
+
path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
|
1867
|
+
)
|
|
1868
|
+
handler.setFormatter(
|
|
1869
|
+
logging.Formatter(
|
|
1870
|
+
"%(asctime)s [%(levelname)s] [session=%(session_id)s] [tool=%(tool_name)s] %(message)s"
|
|
1871
|
+
)
|
|
1872
|
+
)
|
|
1873
|
+
logger.addHandler(handler)
|
|
1874
|
+
_TOOL_LOGGER_CONFIGURED_PATH = path_str
|
|
1875
|
+
logger.info("Tool call logging enabled: %s", path, extra={"session_id": "-", "tool_name": "-"})
|
|
1876
|
+
|
|
1877
|
+
|
|
1878
|
+
_TOOL_CALL_WRAPPER_INSTALLED = False
|
|
1879
|
+
|
|
1880
|
+
|
|
1881
|
+
def _install_tool_call_logger() -> None:
|
|
1882
|
+
"""Wrap mcp.call_tool so every MCP tool invocation is logged."""
|
|
1883
|
+
global _TOOL_CALL_WRAPPER_INSTALLED
|
|
1884
|
+
if _TOOL_CALL_WRAPPER_INSTALLED:
|
|
1885
|
+
return
|
|
1886
|
+
original = mcp.call_tool
|
|
1887
|
+
|
|
1888
|
+
async def _logged_call_tool(*args: Any, **kwargs: Any) -> Any:
|
|
1889
|
+
# The SDK may call call_tool(name, arguments) or (ctx, name, arguments).
|
|
1890
|
+
# Extract tool name and arguments robustly.
|
|
1891
|
+
name = ""
|
|
1892
|
+
arguments: dict[str, Any] | None = None
|
|
1893
|
+
if len(args) >= 2 and isinstance(args[0], str):
|
|
1894
|
+
name = args[0]
|
|
1895
|
+
arguments = args[1]
|
|
1896
|
+
elif len(args) >= 3:
|
|
1897
|
+
name = args[1]
|
|
1898
|
+
arguments = args[2]
|
|
1899
|
+
else:
|
|
1900
|
+
name = kwargs.get("name", "")
|
|
1901
|
+
arguments = kwargs.get("arguments")
|
|
1902
|
+
|
|
1903
|
+
params = arguments or {}
|
|
1904
|
+
session_id = DEFAULT_SESSION
|
|
1905
|
+
if isinstance(params, dict):
|
|
1906
|
+
session_id = params.get("session_id", DEFAULT_SESSION)
|
|
1907
|
+
start = time.monotonic()
|
|
1908
|
+
try:
|
|
1909
|
+
result = await original(*args, **kwargs)
|
|
1910
|
+
_log_tool_call(session_id, name, params, result, None, time.monotonic() - start)
|
|
1911
|
+
return result
|
|
1912
|
+
except Exception as exc:
|
|
1913
|
+
_log_tool_call(session_id, name, params, None, str(exc), time.monotonic() - start)
|
|
1914
|
+
raise
|
|
1915
|
+
|
|
1916
|
+
mcp.call_tool = _logged_call_tool
|
|
1917
|
+
_TOOL_CALL_WRAPPER_INSTALLED = True
|
|
1918
|
+
|
|
1919
|
+
|
|
1795
1920
|
def _screenshot_for_recorder(
|
|
1796
1921
|
path: str,
|
|
1797
1922
|
window: bool,
|
|
@@ -2535,6 +2660,9 @@ async def start_session(
|
|
|
2535
2660
|
) -> dict[str, Any]:
|
|
2536
2661
|
"""One-shot session factory: detect version, start infrastructure and connect the 1C test client."""
|
|
2537
2662
|
credential_ref = _resolve_credential_reference(base_url)
|
|
2663
|
+
credential_account_id = _current_account_id()
|
|
2664
|
+
saved_credentials_used = False
|
|
2665
|
+
saved_credentials_unverified = False
|
|
2538
2666
|
input_looks_like_url = bool(re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", str(base_url or "")))
|
|
2539
2667
|
if credential_ref is not None:
|
|
2540
2668
|
# For saved titles/aliases, replace title -> real URL. For URL/wildcard matches,
|
|
@@ -2542,10 +2670,17 @@ async def start_session(
|
|
|
2542
2670
|
# would make the 1C client fail with invalid connection parameters.
|
|
2543
2671
|
if credential_ref.get("url") and not input_looks_like_url:
|
|
2544
2672
|
base_url = str(credential_ref["url"])
|
|
2673
|
+
original_username = username
|
|
2674
|
+
original_password = password
|
|
2545
2675
|
if not username:
|
|
2546
2676
|
username = str(credential_ref.get("username", ""))
|
|
2547
2677
|
if not password:
|
|
2548
2678
|
password = str(credential_ref.get("password", ""))
|
|
2679
|
+
saved_credentials_used = (
|
|
2680
|
+
(not original_username or original_username == credential_ref.get("username"))
|
|
2681
|
+
and (not original_password or original_password == credential_ref.get("password"))
|
|
2682
|
+
)
|
|
2683
|
+
saved_credentials_unverified = not bool(credential_ref.get("verified", False))
|
|
2549
2684
|
base_url = _platform.normalize_infobase_url(base_url)
|
|
2550
2685
|
connection_mode = "auto"
|
|
2551
2686
|
connection_value = ""
|
|
@@ -2773,8 +2908,12 @@ async def start_session(
|
|
|
2773
2908
|
connect_connection_mode, connect_connection_value or connect_base_url, connect_base_url,
|
|
2774
2909
|
)
|
|
2775
2910
|
diagnostic_error = _client_failure_diagnostics(sess, enriched, target_mode)
|
|
2911
|
+
if saved_credentials_used and saved_credentials_unverified and _looks_like_auth_failure(str(diagnostic_error)):
|
|
2912
|
+
_discard_unverified_credentials(connect_base_url or connect_connection_value, username, password, credential_account_id)
|
|
2776
2913
|
await _cleanup_failed_start_session(sess, "test-client startup failure")
|
|
2777
2914
|
raise diagnostic_error from exc
|
|
2915
|
+
if saved_credentials_used:
|
|
2916
|
+
_mark_credentials_verified(connect_base_url or connect_connection_value, username, password, credential_account_id)
|
|
2778
2917
|
_auto_maximize_test_client_result(connect_result, sess.client_display or sess.display)
|
|
2779
2918
|
|
|
2780
2919
|
if sess.idle_timeout_seconds > 0:
|
|
@@ -2975,6 +3114,26 @@ def _resolve_credential_reference(value: str) -> dict[str, str] | None:
|
|
|
2975
3114
|
return _credentials_store().resolve(value, account_id=_current_account_id())
|
|
2976
3115
|
|
|
2977
3116
|
|
|
3117
|
+
def _mark_credentials_verified(url: str, username: str, password: str, account_id: str | None) -> None:
|
|
3118
|
+
if not _CREDENTIAL_STORE_ENABLED or not url or not username:
|
|
3119
|
+
return
|
|
3120
|
+
try:
|
|
3121
|
+
_credentials_store().mark_verified(url, username, password, account_id=account_id)
|
|
3122
|
+
except Exception:
|
|
3123
|
+
logging.exception("Failed to mark credentials as verified for %s", url)
|
|
3124
|
+
|
|
3125
|
+
|
|
3126
|
+
def _discard_unverified_credentials(url: str, username: str, password: str, account_id: str | None) -> None:
|
|
3127
|
+
if not _CREDENTIAL_STORE_ENABLED or not url or not username:
|
|
3128
|
+
return
|
|
3129
|
+
try:
|
|
3130
|
+
removed = _credentials_store().remove_matching(url, username, password, account_id=account_id)
|
|
3131
|
+
if removed:
|
|
3132
|
+
logging.warning("Removed unverified credentials after authentication failure for %s", url)
|
|
3133
|
+
except Exception:
|
|
3134
|
+
logging.exception("Failed to remove unverified credentials for %s", url)
|
|
3135
|
+
|
|
3136
|
+
|
|
2978
3137
|
@mcp.tool()
|
|
2979
3138
|
def credentials_check(base_url: str) -> dict[str, object]:
|
|
2980
3139
|
"""Check whether credentials exist for a base URL or saved title without returning secrets."""
|
|
@@ -5984,6 +6143,11 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
5984
6143
|
default=os.getenv("ONEC_MCP_TOOL_PROFILE", "full"),
|
|
5985
6144
|
help="comma-separated tool profile groups: core, ui, sessions, windows, forms, tables, tabular, dynamic, rag, recording, full, all",
|
|
5986
6145
|
)
|
|
6146
|
+
parser.add_argument(
|
|
6147
|
+
"--tool-log",
|
|
6148
|
+
default=os.getenv("ONEC_MCP_TOOL_LOG", ""),
|
|
6149
|
+
help="path to a rotating log file for every MCP tool call (env: ONEC_MCP_TOOL_LOG).",
|
|
6150
|
+
)
|
|
5987
6151
|
parser.add_argument(
|
|
5988
6152
|
"--http",
|
|
5989
6153
|
action="store_true",
|
|
@@ -6024,6 +6188,8 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
6024
6188
|
)
|
|
6025
6189
|
args = parser.parse_args(argv)
|
|
6026
6190
|
|
|
6191
|
+
_configure_tool_logging(args.tool_log)
|
|
6192
|
+
|
|
6027
6193
|
_RAG_ENABLED = not args.disable_rag
|
|
6028
6194
|
_CREDENTIAL_STORE_ENABLED = not args.disable_credential_store
|
|
6029
6195
|
_DEV_TOOLS_ENABLED = bool(args.dev_tools)
|
|
@@ -6048,5 +6214,8 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
6048
6214
|
asyncio.run(_run(stale))
|
|
6049
6215
|
|
|
6050
6216
|
|
|
6217
|
+
_install_tool_call_logger()
|
|
6218
|
+
|
|
6219
|
+
|
|
6051
6220
|
if __name__ == "__main__":
|
|
6052
6221
|
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|