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.
Files changed (61) hide show
  1. {answer42-0.4.1 → answer42-0.4.2}/PKG-INFO +2 -2
  2. {answer42-0.4.1 → answer42-0.4.2}/README.md +1 -1
  3. {answer42-0.4.1 → answer42-0.4.2}/credentials.example.json +3 -1
  4. {answer42-0.4.1 → answer42-0.4.2}/pyproject.toml +1 -1
  5. {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
  6. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/credentials.py +100 -11
  7. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/server.py +169 -0
  8. {answer42-0.4.1 → answer42-0.4.2}/.gitignore +0 -0
  9. {answer42-0.4.1 → answer42-0.4.2}/LICENSE +0 -0
  10. {answer42-0.4.1 → answer42-0.4.2}/docs/agent-installation.md +0 -0
  11. {answer42-0.4.1 → answer42-0.4.2}/docs/architecture.md +0 -0
  12. {answer42-0.4.1 → answer42-0.4.2}/docs/assets/answer42-logo.png +0 -0
  13. {answer42-0.4.1 → answer42-0.4.2}/docs/assets/platform42-logo.svg +0 -0
  14. {answer42-0.4.1 → answer42-0.4.2}/docs/installation.md +0 -0
  15. {answer42-0.4.1 → answer42-0.4.2}/scripts/build_cf.py +0 -0
  16. {answer42-0.4.1 → answer42-0.4.2}/scripts/build_pages.py +0 -0
  17. {answer42-0.4.1 → answer42-0.4.2}/scripts/e2e_http_smoke.py +0 -0
  18. {answer42-0.4.1 → answer42-0.4.2}/scripts/e2e_stable.py +0 -0
  19. {answer42-0.4.1 → answer42-0.4.2}/scripts/openclaw_mcp_autoreload.py +0 -0
  20. {answer42-0.4.1 → answer42-0.4.2}/scripts/rag_cli.py +0 -0
  21. {answer42-0.4.1 → answer42-0.4.2}/src/cf/ConfigDumpInfo.xml +0 -0
  22. {answer42-0.4.1 → answer42-0.4.2}/src/cf/Configuration.xml +0 -0
  23. {answer42-0.4.1 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager/Ext/ObjectModule.bsl +0 -0
  24. {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
  25. {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
  26. {answer42-0.4.1 → answer42-0.4.2}/src/cf/DataProcessors/MCPTestManager.xml +0 -0
  27. {answer42-0.4.1 → answer42-0.4.2}/src/cf/Ext/ManagedApplicationModule.bsl +0 -0
  28. {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
  29. {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
  30. {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
  31. {answer42-0.4.1 → answer42-0.4.2}/src/client_cf/ConfigDumpInfo.xml +0 -0
  32. {answer42-0.4.1 → answer42-0.4.2}/src/client_cf/Configuration.xml +0 -0
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
  39. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/__init__.py +0 -0
  40. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/assets/MCPTestClient.cf +0 -0
  41. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/assets/MCPTestManager.cf +0 -0
  42. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/assets/__init__.py +0 -0
  43. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/assets/skills/answer42/SKILL.md +0 -0
  44. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/assets/skills/answer42-rag/SKILL.md +0 -0
  45. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/auth.py +0 -0
  46. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/bridge.py +0 -0
  47. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/os_support.py +0 -0
  48. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/platform.py +0 -0
  49. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/protocol.py +0 -0
  50. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/__init__.py +0 -0
  51. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/detect.py +0 -0
  52. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/dump.py +0 -0
  53. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/model.py +0 -0
  54. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/parsers.py +0 -0
  55. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/service.py +0 -0
  56. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/rag/store.py +0 -0
  57. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/recorder.py +0 -0
  58. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/release_helper.py +0 -0
  59. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/runtime.py +0 -0
  60. {answer42-0.4.1 → answer42-0.4.2}/src/mcp_1c/skill_installer.py +0 -0
  61. {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.1
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.1"
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, 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 and URL patterns without secrets."""
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] = {"url": entry.get("url", "")}
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
- if entry_url == url or (title and entry_title == title):
162
- entry["url"] = url
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
- else:
179
+ elif not duplicate_match:
168
180
  entry.pop("title", None)
169
181
  if aliases:
170
- entry["aliases"] = aliases
171
- else:
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, 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, 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