answer42 0.2.24__tar.gz → 0.2.26__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 (58) hide show
  1. {answer42-0.2.24 → answer42-0.2.26}/PKG-INFO +1 -1
  2. {answer42-0.2.24 → answer42-0.2.26}/pyproject.toml +1 -1
  3. {answer42-0.2.24 → answer42-0.2.26}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form/Module.bsl +81 -19
  4. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/assets/MCPTestManager.cf +0 -0
  5. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/credentials.py +53 -11
  6. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/protocol.py +2 -0
  7. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/release_helper.py +52 -7
  8. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/server.py +77 -20
  9. {answer42-0.2.24 → answer42-0.2.26}/.gitignore +0 -0
  10. {answer42-0.2.24 → answer42-0.2.26}/LICENSE +0 -0
  11. {answer42-0.2.24 → answer42-0.2.26}/README.md +0 -0
  12. {answer42-0.2.24 → answer42-0.2.26}/credentials.example.json +0 -0
  13. {answer42-0.2.24 → answer42-0.2.26}/docs/agent-installation.md +0 -0
  14. {answer42-0.2.24 → answer42-0.2.26}/docs/architecture.md +0 -0
  15. {answer42-0.2.24 → answer42-0.2.26}/docs/assets/answer42-logo.png +0 -0
  16. {answer42-0.2.24 → answer42-0.2.26}/docs/assets/platform42-logo.svg +0 -0
  17. {answer42-0.2.24 → answer42-0.2.26}/docs/installation.md +0 -0
  18. {answer42-0.2.24 → answer42-0.2.26}/scripts/build_cf.py +0 -0
  19. {answer42-0.2.24 → answer42-0.2.26}/scripts/build_pages.py +0 -0
  20. {answer42-0.2.24 → answer42-0.2.26}/scripts/e2e_stable.py +0 -0
  21. {answer42-0.2.24 → answer42-0.2.26}/scripts/openclaw_mcp_autoreload.py +0 -0
  22. {answer42-0.2.24 → answer42-0.2.26}/scripts/rag_cli.py +0 -0
  23. {answer42-0.2.24 → answer42-0.2.26}/src/cf/ConfigDumpInfo.xml +0 -0
  24. {answer42-0.2.24 → answer42-0.2.26}/src/cf/Configuration.xml +0 -0
  25. {answer42-0.2.24 → answer42-0.2.26}/src/cf/DataProcessors/MCPTestManager/Ext/ObjectModule.bsl +0 -0
  26. {answer42-0.2.24 → answer42-0.2.26}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form.xml" +0 -0
  27. {answer42-0.2.24 → answer42-0.2.26}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260.xml" +0 -0
  28. {answer42-0.2.24 → answer42-0.2.26}/src/cf/DataProcessors/MCPTestManager.xml +0 -0
  29. {answer42-0.2.24 → answer42-0.2.26}/src/cf/Ext/ManagedApplicationModule.bsl +0 -0
  30. {answer42-0.2.24 → answer42-0.2.26}/src/cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml" +0 -0
  31. {answer42-0.2.24 → answer42-0.2.26}/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
  32. {answer42-0.2.24 → answer42-0.2.26}/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
  33. {answer42-0.2.24 → answer42-0.2.26}/src/client_cf/ConfigDumpInfo.xml +0 -0
  34. {answer42-0.2.24 → answer42-0.2.26}/src/client_cf/Configuration.xml +0 -0
  35. {answer42-0.2.24 → answer42-0.2.26}/src/client_cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml" +0 -0
  36. {answer42-0.2.24 → answer42-0.2.26}/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
  37. {answer42-0.2.24 → answer42-0.2.26}/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
  38. {answer42-0.2.24 → answer42-0.2.26}/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
  39. {answer42-0.2.24 → answer42-0.2.26}/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
  40. {answer42-0.2.24 → answer42-0.2.26}/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
  41. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/__init__.py +0 -0
  42. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/assets/MCPTestClient.cf +0 -0
  43. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/assets/__init__.py +0 -0
  44. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/assets/skills/answer42/SKILL.md +0 -0
  45. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/assets/skills/answer42-rag/SKILL.md +0 -0
  46. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/bridge.py +0 -0
  47. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/os_support.py +0 -0
  48. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/platform.py +0 -0
  49. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/rag/__init__.py +0 -0
  50. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/rag/detect.py +0 -0
  51. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/rag/model.py +0 -0
  52. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/rag/parsers.py +0 -0
  53. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/rag/service.py +0 -0
  54. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/rag/store.py +0 -0
  55. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/recorder.py +0 -0
  56. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/runtime.py +0 -0
  57. {answer42-0.2.24 → answer42-0.2.26}/src/mcp_1c/skill_installer.py +0 -0
  58. {answer42-0.2.24 → answer42-0.2.26}/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.2.24
3
+ Version: 0.2.26
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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "answer42"
3
- version = "0.2.24"
3
+ version = "0.2.26"
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"
@@ -238,11 +238,33 @@
238
238
 
239
239
  #Область КомандыMCP
240
240
 
241
+ &НаКлиенте
242
+ Функция ВерсияПротоколаМенеджераMCP()
243
+ Возврат "2026.07.02.2";
244
+ КонецФункции
245
+
246
+ &НаКлиенте
247
+ Функция ИнформацияОМенеджереMCP()
248
+ Возврат Новый Структура("manager_protocol_version", ВерсияПротоколаМенеджераMCP());
249
+ КонецФункции
250
+
251
+ &НаКлиенте
252
+ Процедура ПроверитьВерсиюПротоколаМенеджераMCP(Параметры)
253
+ ОжидаемаяВерсия = СвойствоСтруктуры(Параметры, "expected_manager_protocol_version", "");
254
+ Если Не ПустаяСтрока(ОжидаемаяВерсия) И ОжидаемаяВерсия <> ВерсияПротоколаМенеджераMCP() Тогда
255
+ ВызватьИсключение "Несовместимая версия MCPTestManager.cf: Python ожидает протокол "
256
+ + ОжидаемаяВерсия + ", обработка поддерживает " + ВерсияПротоколаМенеджераMCP()
257
+ + ". Пересоберите MCPTestManager.cf из актуальных исходников и перезапустите Answer42 MCP.";
258
+ КонецЕсли;
259
+ КонецПроцедуры
260
+
241
261
  &НаКлиенте
242
262
  Функция ВыполнитьКоманду(Метод, Параметры)
243
263
  Если Метод = "ping" Тогда
244
264
  Лог("ВыполнитьКоманду: ping");
245
- Возврат Новый Структура("pong", Истина);
265
+ Возврат Новый Структура("pong,manager_protocol_version", Истина, ВерсияПротоколаМенеджераMCP());
266
+ ИначеЕсли Метод = "manager_info" Тогда
267
+ Возврат ИнформацияОМенеджереMCP();
246
268
  ИначеЕсли Метод = "connect_test_client" Тогда
247
269
  Лог("ВыполнитьКоманду: connect_test_client");
248
270
  Возврат ПодключитьКлиентТестирования(Параметры);
@@ -462,6 +484,7 @@
462
484
 
463
485
  &НаКлиенте
464
486
  Функция ПодключитьКлиентТестирования(Параметры)
487
+ ПроверитьВерсиюПротоколаМенеджераMCP(Параметры);
465
488
  URLБазы = СвойствоСтруктуры(Параметры, "base_url", "");
466
489
  ТипПодключения = НРег(СвойствоСтруктуры(Параметры, "connection_mode", "ws"));
467
490
  СтрокаПодключения = СвойствоСтруктуры(Параметры, "connection_value", URLБазы);
@@ -504,6 +527,7 @@
504
527
 
505
528
  &НаКлиенте
506
529
  Функция ПодключитьЗапущенныйКлиентТестирования(Параметры)
530
+ ПроверитьВерсиюПротоколаМенеджераMCP(Параметры);
507
531
  URLБазы = СвойствоСтруктуры(Параметры, "base_url", "");
508
532
  ТипПодключения = НРег(СвойствоСтруктуры(Параметры, "connection_mode", "ws"));
509
533
  СтрокаПодключения = СвойствоСтруктуры(Параметры, "connection_value", URLБазы);
@@ -2926,12 +2950,12 @@
2926
2950
  Глубина = СвойствоСтруктуры(Параметры, "max_depth", 8);
2927
2951
  ВключатьТекст = СвойствоСтруктуры(Параметры, "include_text", Ложь);
2928
2952
  Результат = Новый Массив;
2929
- НайтиТабличныеДокументыРекурсивно(АктивноеОкно, Результат, ВключатьТекст, 0, Глубина);
2953
+ НайтиТабличныеДокументыРекурсивно(АктивноеОкно, Результат, ВключатьТекст, Параметры, 0, Глубина);
2930
2954
  Возврат Новый Структура("count,items", Результат.Количество(), Результат);
2931
2955
  КонецФункции
2932
2956
 
2933
2957
  &НаКлиенте
2934
- Процедура НайтиТабличныеДокументыРекурсивно(Объект, Результат, ВключатьТекст, ТекущаяГлубина, МаксимальнаяГлубина)
2958
+ Процедура НайтиТабличныеДокументыРекурсивно(Объект, Результат, ВключатьТекст, ПараметрыТекста, ТекущаяГлубина, МаксимальнаяГлубина)
2935
2959
  Если ТекущаяГлубина > МаксимальнаяГлубина Тогда
2936
2960
  Возврат;
2937
2961
  КонецЕсли;
@@ -2944,20 +2968,31 @@
2944
2968
  Элемент.Вставить("title", БезопасныйЗаголовок(Подчиненный));
2945
2969
  Элемент.Вставить("type", Строка(ТипЗнч(Подчиненный)));
2946
2970
  Элемент.Вставить("data", БезопасноеПредставлениеДанныхПоля(Подчиненный));
2971
+ РазмерыДокумента = РазмерыТабличногоДокументаИзПоля(Подчиненный);
2972
+ Если РазмерыДокумента.Свойство("error") Тогда
2973
+ Элемент.Вставить("dimensions_error", РазмерыДокумента.error);
2974
+ Иначе
2975
+ Элемент.Вставить("rows", РазмерыДокумента.rows);
2976
+ Элемент.Вставить("columns", РазмерыДокумента.columns);
2977
+ КонецЕсли;
2947
2978
  Если ВключатьТекст Тогда
2948
- ТекстДокумента = ТекстТабличногоДокументаИзПоля(Подчиненный);
2979
+ ТекстДокумента = ТекстТабличногоДокументаИзПоля(Подчиненный, ПараметрыТекста);
2949
2980
  Если ТекстДокумента.Свойство("error") Тогда
2950
2981
  Элемент.Вставить("text_error", ТекстДокумента.error);
2951
2982
  Иначе
2952
2983
  Элемент.Вставить("rows", ТекстДокумента.rows);
2953
2984
  Элемент.Вставить("columns", ТекстДокумента.columns);
2954
2985
  Элемент.Вставить("address", ТекстДокумента.address);
2955
- Элемент.Вставить("text", ТекстДокумента.text);
2986
+ Элемент.Вставить("row_start", ТекстДокумента.row_start);
2987
+ Элемент.Вставить("row_end", ТекстДокумента.row_end);
2988
+ Элемент.Вставить("column_start", ТекстДокумента.column_start);
2989
+ Элемент.Вставить("column_end", ТекстДокумента.column_end);
2990
+ Элемент.Вставить("cells", ТекстДокумента.cells);
2956
2991
  КонецЕсли;
2957
2992
  КонецЕсли;
2958
2993
  Результат.Добавить(Элемент);
2959
2994
  КонецЕсли;
2960
- НайтиТабличныеДокументыРекурсивно(Подчиненный, Результат, ВключатьТекст, ТекущаяГлубина + 1, МаксимальнаяГлубина);
2995
+ НайтиТабличныеДокументыРекурсивно(Подчиненный, Результат, ВключатьТекст, ПараметрыТекста, ТекущаяГлубина + 1, МаксимальнаяГлубина);
2961
2996
  КонецЦикла;
2962
2997
  Исключение
2963
2998
  Инфо = ИнформацияОбОшибке();
@@ -2990,29 +3025,56 @@
2990
3025
  Если Поле = Неопределено Тогда
2991
3026
  Возврат Новый Структура("found", Ложь);
2992
3027
  КонецЕсли;
2993
- ДанныеТекста = ТекстТабличногоДокументаИзПоля(Поле);
3028
+ ДанныеТекста = ТекстТабличногоДокументаИзПоля(Поле, Параметры);
2994
3029
  Если ДанныеТекста.Свойство("error") Тогда
2995
3030
  Возврат Новый Структура("found,error", Истина, ДанныеТекста.error);
2996
3031
  КонецЕсли;
2997
- Возврат Новый Структура("found,rows,columns,address,text,cells", Истина, ДанныеТекста.rows, ДанныеТекста.columns, ДанныеТекста.address, ДанныеТекста.text, ДанныеТекста.cells);
3032
+ Возврат Новый Структура("found,rows,columns,address,row_start,row_end,column_start,column_end,cells", Истина, ДанныеТекста.rows, ДанныеТекста.columns, ДанныеТекста.address, ДанныеТекста.row_start, ДанныеТекста.row_end, ДанныеТекста.column_start, ДанныеТекста.column_end, ДанныеТекста.cells);
2998
3033
  КонецФункции
2999
3034
 
3000
3035
  &НаКлиенте
3001
- Функция ТекстТабличногоДокументаИзПоля(Поле)
3036
+ Функция РазмерыТабличногоДокументаИзПоля(Поле)
3002
3037
  Попытка
3003
- КоличествоСтрок = Поле.ПолучитьРазмерОбластиДанныхДокументаПоВертикали();
3004
- КоличествоКолонок = Поле.ПолучитьРазмерОбластиДанныхДокументаПоГоризонтали();
3038
+ Возврат Новый Структура("rows,columns", Поле.ПолучитьРазмерОбластиДанныхДокументаПоВертикали(), Поле.ПолучитьРазмерОбластиДанныхДокументаПоГоризонтали());
3039
+ Исключение
3040
+ Возврат Новый Структура("error", ОписаниеОшибки());
3041
+ КонецПопытки;
3042
+ КонецФункции
3043
+
3044
+ &НаКлиенте
3045
+ Функция ТекстТабличногоДокументаИзПоля(Поле, Параметры)
3046
+ Попытка
3047
+ Размеры = РазмерыТабличногоДокументаИзПоля(Поле);
3048
+ Если Размеры.Свойство("error") Тогда
3049
+ Возврат Новый Структура("error", Размеры.error);
3050
+ КонецЕсли;
3051
+ КоличествоСтрок = Размеры.rows;
3052
+ КоличествоКолонок = Размеры.columns;
3005
3053
  Если КоличествоСтрок = 0 Или КоличествоКолонок = 0 Тогда
3006
- Возврат Новый Структура("rows,columns,address,text,cells", 0, 0, "", "", Новый Массив);
3054
+ Возврат Новый Структура("rows,columns,address,row_start,row_end,column_start,column_end,cells", 0, 0, "", 0, 0, 0, 0, Новый Массив);
3055
+ КонецЕсли;
3056
+ НачальнаяСтрока = Макс(1, СвойствоСтруктуры(Параметры, "row_start", 1));
3057
+ НачальнаяКолонка = Макс(1, СвойствоСтруктуры(Параметры, "column_start", 1));
3058
+ КонечнаяСтрока = СвойствоСтруктуры(Параметры, "row_end", 0);
3059
+ КонечнаяКолонка = СвойствоСтруктуры(Параметры, "column_end", 0);
3060
+ МаксСтрок = СвойствоСтруктуры(Параметры, "max_rows", 50);
3061
+ МаксКолонок = СвойствоСтруктуры(Параметры, "max_columns", 20);
3062
+ Если КонечнаяСтрока <= 0 Тогда
3063
+ КонечнаяСтрока = ?(МаксСтрок > 0, НачальнаяСтрока + МаксСтрок - 1, КоличествоСтрок);
3064
+ КонецЕсли;
3065
+ Если КонечнаяКолонка <= 0 Тогда
3066
+ КонечнаяКолонка = ?(МаксКолонок > 0, НачальнаяКолонка + МаксКолонок - 1, КоличествоКолонок);
3067
+ КонецЕсли;
3068
+ КонечнаяСтрока = Мин(КоличествоСтрок, КонечнаяСтрока);
3069
+ КонечнаяКолонка = Мин(КоличествоКолонок, КонечнаяКолонка);
3070
+ Если НачальнаяСтрока > КоличествоСтрок Или НачальнаяКолонка > КоличествоКолонок Или КонечнаяСтрока < НачальнаяСтрока Или КонечнаяКолонка < НачальнаяКолонка Тогда
3071
+ Возврат Новый Структура("rows,columns,address,row_start,row_end,column_start,column_end,cells", КоличествоСтрок, КоличествоКолонок, "", НачальнаяСтрока, КонечнаяСтрока, НачальнаяКолонка, КонечнаяКолонка, Новый Массив);
3007
3072
  КонецЕсли;
3008
- АдресВсейОбласти = АдресОбластиТабличногоДокументаMCP(1, 1, КоличествоСтрок, КоличествоКолонок);
3009
- ТекстВсейОбласти = Поле.ПолучитьТекстОбласти(АдресВсейОбласти);
3010
- МаксСтрок = Мин(КоличествоСтрок, 50);
3011
- МаксКолонок = Мин(КоличествоКолонок, 20);
3073
+ АдресОбласти = АдресОбластиТабличногоДокументаMCP(НачальнаяСтрока, НачальнаяКолонка, КонечнаяСтрока, КонечнаяКолонка);
3012
3074
  Ячейки = Новый Массив;
3013
- Для НомерСтроки = 1 По МаксСтрок Цикл
3075
+ Для НомерСтроки = НачальнаяСтрока По КонечнаяСтрока Цикл
3014
3076
  СтрокаТекстов = Новый Массив;
3015
- Для НомерКолонки = 1 По МаксКолонок Цикл
3077
+ Для НомерКолонки = НачальнаяКолонка По КонечнаяКолонка Цикл
3016
3078
  Попытка
3017
3079
  АдресЯчейки = АдресОбластиТабличногоДокументаMCP(НомерСтроки, НомерКолонки, НомерСтроки, НомерКолонки);
3018
3080
  СтрокаТекстов.Добавить(Поле.ПолучитьТекстОбласти(АдресЯчейки));
@@ -3022,7 +3084,7 @@
3022
3084
  КонецЦикла;
3023
3085
  Ячейки.Добавить(СтрокаТекстов);
3024
3086
  КонецЦикла;
3025
- Возврат Новый Структура("rows,columns,address,text,cells", КоличествоСтрок, КоличествоКолонок, АдресВсейОбласти, ТекстВсейОбласти, Ячейки);
3087
+ Возврат Новый Структура("rows,columns,address,row_start,row_end,column_start,column_end,cells", КоличествоСтрок, КоличествоКолонок, АдресОбласти, НачальнаяСтрока, КонечнаяСтрока, НачальнаяКолонка, КонечнаяКолонка, Ячейки);
3026
3088
  Исключение
3027
3089
  Возврат Новый Структура("error", ОписаниеОшибки());
3028
3090
  КонецПопытки;
@@ -58,38 +58,80 @@ class CredentialsStore:
58
58
  # ------------------------------------------------------------------
59
59
 
60
60
  def lookup(self, base_url: str) -> dict[str, str] | None:
61
- """Return {username, password} for *base_url*, or None."""
62
- url = base_url.rstrip("/")
61
+ """Return {username, password} for *base_url*, title or alias, or None."""
62
+ resolved = self.resolve(base_url)
63
+ if resolved is None:
64
+ return None
65
+ return {"username": resolved["username"], "password": resolved["password"]}
66
+
67
+ def resolve(self, base_url_or_title: str) -> dict[str, str] | None:
68
+ """Return full credential entry for URL, title or alias, without mutating it."""
69
+ value = str(base_url_or_title or "").strip()
70
+ url = value.rstrip("/")
71
+ normalized_title = value.lower()
72
+ for entry in self._entries:
73
+ title = str(entry.get("title", "")).strip().lower()
74
+ aliases = [str(a).strip().lower() for a in entry.get("aliases", [])]
75
+ if title and title == normalized_title or normalized_title in aliases:
76
+ return dict(entry)
63
77
  for entry in self._entries:
64
78
  pattern = entry["url"].rstrip("/")
65
79
  if url == pattern:
66
- return {"username": entry["username"], "password": entry["password"]}
80
+ return dict(entry)
67
81
  for entry in self._entries:
68
82
  pattern = entry["url"].rstrip("/")
69
83
  if fnmatch.fnmatch(url, pattern):
70
- return {"username": entry["username"], "password": entry["password"]}
84
+ return dict(entry)
71
85
  return None
72
86
 
73
- def list_entries(self) -> list[dict[str, str]]:
74
- """Return credential URL patterns without usernames or passwords."""
75
- return [{"url": e["url"]} for e in self._entries]
76
-
77
- def upsert(self, url: str, username: str, password: str) -> bool:
87
+ def list_entries(self) -> list[dict[str, object]]:
88
+ """Return credential titles and URL patterns without usernames or passwords."""
89
+ result: list[dict[str, str]] = []
90
+ for entry in self._entries:
91
+ public = {"url": entry["url"]}
92
+ if entry.get("title"):
93
+ public["title"] = entry["title"]
94
+ if entry.get("aliases"):
95
+ public["aliases"] = list(entry["aliases"])
96
+ result.append(public)
97
+ return result
98
+
99
+ def titles(self) -> list[dict[str, object]]:
100
+ """Return named credential entries only, without secrets."""
101
+ return [e for e in self.list_entries() if e.get("title")]
102
+
103
+ def upsert(self, url: str, username: str, password: str, title: str = "", aliases: list[str] | None = None) -> bool:
78
104
  """Add or update a credential entry for *url*.
79
105
 
80
106
  Returns True when an existing entry was updated, False when a new entry
81
107
  was created. Usernames and passwords are intentionally not returned.
82
108
  """
83
109
  url = url.rstrip("/")
110
+ title = str(title or "").strip()
111
+ aliases = [str(alias).strip() for alias in (aliases or []) if str(alias).strip()]
84
112
  updated = False
85
113
  for entry in self._entries:
86
- if entry["url"].rstrip("/") == url:
114
+ if entry["url"].rstrip("/") == url or (title and str(entry.get("title", "")).strip() == title):
115
+ entry["url"] = url
87
116
  entry["username"] = username
88
117
  entry["password"] = password
118
+ if title:
119
+ entry["title"] = title
120
+ else:
121
+ entry.pop("title", None)
122
+ if aliases:
123
+ entry["aliases"] = aliases
124
+ else:
125
+ entry.pop("aliases", None)
89
126
  updated = True
90
127
  break
91
128
  else:
92
- self._entries.append({"url": url, "username": username, "password": password})
129
+ entry = {"url": url, "username": username, "password": password}
130
+ if title:
131
+ entry["title"] = title
132
+ if aliases:
133
+ entry["aliases"] = aliases
134
+ self._entries.append(entry)
93
135
  self._save()
94
136
  return updated
95
137
 
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass
4
4
  from typing import Any
5
5
 
6
+ MANAGER_PROTOCOL_VERSION = "2026.07.02.2"
7
+
6
8
 
7
9
  @dataclass(slots=True)
8
10
  class BridgeRequest:
@@ -35,9 +35,46 @@ def _replace_version(pyproject: Path, version: str) -> bool:
35
35
  return True
36
36
 
37
37
 
38
+ def _commits_since_last_tag() -> str:
39
+ previous_tag = _git_output(["describe", "--tags", "--abbrev=0", "--match", "v*"])
40
+ if previous_tag:
41
+ return _git_output(["log", "--pretty=format:- %s (%h)", f"{previous_tag}..HEAD"])
42
+ return _git_output(["log", "--pretty=format:- %s (%h)", "HEAD"])
43
+
44
+
45
+ def _default_release_notes(tag: str) -> str:
46
+ try:
47
+ commits = _commits_since_last_tag()
48
+ except subprocess.CalledProcessError:
49
+ commits = _git_output(["log", "--pretty=format:- %s (%h)", "-n", "20"])
50
+ if not commits.strip():
51
+ commits = "- Maintenance release"
52
+ return (
53
+ f"# Answer42 {tag}\n\n"
54
+ "## Changes\n"
55
+ f"{commits}\n\n"
56
+ "## Verification\n"
57
+ "- GitLab CI runs unit tests and package checks on branch and tag pipelines.\n"
58
+ "- PyPI publishing and GitLab Release creation are performed by the tag pipeline.\n"
59
+ )
60
+
61
+
62
+ def _ensure_release_notes(repo_root: Path, tag: str, *, dry_run: bool, overwrite: bool) -> Path:
63
+ notes_path = repo_root / "release-notes" / f"{tag}.md"
64
+ if notes_path.exists() and notes_path.stat().st_size > 0 and not overwrite:
65
+ print(f"Release notes already exist: {notes_path.relative_to(repo_root)}")
66
+ return notes_path
67
+ text = _default_release_notes(tag)
68
+ print(f"+ write {notes_path.relative_to(repo_root)}")
69
+ if not dry_run:
70
+ notes_path.parent.mkdir(parents=True, exist_ok=True)
71
+ notes_path.write_text(text, encoding="utf-8")
72
+ return notes_path
73
+
74
+
38
75
  def main(argv: list[str] | None = None) -> int:
39
76
  parser = argparse.ArgumentParser(
40
- description="Bump pyproject.toml version, commit, tag and push to trigger PyPI release CI"
77
+ description="Bump version, create release notes, commit, tag and push to trigger Answer42 release CI"
41
78
  )
42
79
  parser.add_argument("version", help="Version without leading v, e.g. 0.2.1")
43
80
  parser.add_argument("--remote", default="origin", help="Git remote to push. Default: origin")
@@ -45,6 +82,7 @@ def main(argv: list[str] | None = None) -> int:
45
82
  parser.add_argument("--dry-run", action="store_true", help="Print commands without executing them")
46
83
  parser.add_argument("--no-push", action="store_true", help="Create commit/tag locally but do not push")
47
84
  parser.add_argument("--allow-dirty", action="store_true", help="Allow unrelated uncommitted changes")
85
+ parser.add_argument("--overwrite-notes", action="store_true", help="Overwrite existing release notes")
48
86
  args = parser.parse_args(argv)
49
87
 
50
88
  version = args.version.removeprefix("v")
@@ -63,21 +101,28 @@ def main(argv: list[str] | None = None) -> int:
63
101
  if existing_tags:
64
102
  raise SystemExit(f"Tag already exists: {tag}")
65
103
 
66
- print(f"Bumping {pyproject} to {version}")
104
+ print(f"Preparing {tag}")
67
105
  changed = True
68
106
  if not args.dry_run:
69
107
  changed = _replace_version(pyproject, version)
70
108
  else:
71
- print(f"+ update {pyproject}: version = {version!r}")
109
+ print(f"+ update {pyproject.relative_to(repo_root)}: version = {version!r}")
110
+ notes_path = _ensure_release_notes(
111
+ repo_root, tag, dry_run=args.dry_run, overwrite=args.overwrite_notes
112
+ )
72
113
 
114
+ files_to_add = [str(notes_path.relative_to(repo_root))]
73
115
  if changed:
74
- _run(["git", "add", str(pyproject.relative_to(repo_root))], dry_run=args.dry_run)
75
- _run(["git", "commit", "-m", f"Release {tag}"], dry_run=args.dry_run)
76
- else:
77
- print(f"Version is already {version}; tagging current HEAD without a version commit.")
116
+ files_to_add.insert(0, str(pyproject.relative_to(repo_root)))
117
+ _run(["git", "add", *files_to_add], dry_run=args.dry_run)
118
+ _run(["git", "commit", "-m", f"Release {tag}"], dry_run=args.dry_run)
78
119
  _run(["git", "tag", "-a", tag, "-m", f"Release {tag}"], dry_run=args.dry_run)
79
120
  if not args.no_push:
80
121
  _run(["git", "push", args.remote, args.branch], dry_run=args.dry_run)
81
122
  _run(["git", "push", args.remote, tag], dry_run=args.dry_run)
82
123
  print("CI will publish PyPI package and create GitLab Release from the pushed tag.")
83
124
  return 0
125
+
126
+
127
+ if __name__ == "__main__": # pragma: no cover
128
+ raise SystemExit(main())
@@ -27,6 +27,7 @@ from mcp.server.fastmcp import FastMCP
27
27
 
28
28
  from . import __version__
29
29
  from . import os_support
30
+ from .protocol import MANAGER_PROTOCOL_VERSION
30
31
  from . import platform as _platform
31
32
  from . import runtime
32
33
  from . import skill_installer
@@ -1697,6 +1698,13 @@ async def start_session(
1697
1698
  rag_enabled: Annotated[bool, "Enable built-in RAG for this session"] = True,
1698
1699
  ) -> dict[str, Any]:
1699
1700
  """One-shot session factory: detect version, start infrastructure and connect the 1C test client."""
1701
+ credential_ref = _resolve_credential_reference(base_url)
1702
+ if credential_ref is not None and credential_ref.get("url"):
1703
+ base_url = str(credential_ref["url"])
1704
+ if not username:
1705
+ username = str(credential_ref.get("username", ""))
1706
+ if not password:
1707
+ password = str(credential_ref.get("password", ""))
1700
1708
  base_url = _platform.normalize_infobase_url(base_url)
1701
1709
  connection_mode = "auto"
1702
1710
  connection_value = ""
@@ -2116,18 +2124,24 @@ def _lookup_credentials(base_url: str) -> dict[str, str] | None:
2116
2124
  return _credentials_store().lookup(base_url)
2117
2125
 
2118
2126
 
2127
+ def _resolve_credential_reference(value: str) -> dict[str, str] | None:
2128
+ if not _CREDENTIAL_STORE_ENABLED or not value:
2129
+ return None
2130
+ return _credentials_store().resolve(value)
2131
+
2132
+
2119
2133
  @mcp.tool()
2120
2134
  def credentials_check(base_url: str) -> dict[str, object]:
2121
- """Check whether credentials exist for a base URL without returning secrets."""
2135
+ """Check whether credentials exist for a base URL or saved title without returning secrets."""
2122
2136
  if not _CREDENTIAL_STORE_ENABLED:
2123
2137
  return {"base_url": base_url, "found": False, "disabled": True}
2124
- credentials = _credentials_store().lookup(base_url)
2138
+ credentials = _credentials_store().resolve(base_url)
2125
2139
  if credentials is None:
2126
2140
  return {"base_url": base_url, "found": False}
2127
- return {
2128
- "base_url": base_url,
2129
- "found": True,
2130
- }
2141
+ result: dict[str, object] = {"base_url": base_url, "found": True, "url": credentials.get("url", "")}
2142
+ if credentials.get("title"):
2143
+ result["title"] = credentials["title"]
2144
+ return result
2131
2145
 
2132
2146
 
2133
2147
  @mcp.tool()
@@ -2135,6 +2149,8 @@ def credentials_save(
2135
2149
  url: Annotated[str, "Base URL pattern (exact or wildcard like https://*.example.com/*)"],
2136
2150
  username: Annotated[str, "1C username"],
2137
2151
  password: Annotated[str, "1C password"],
2152
+ title: Annotated[str, "Optional saved base title/alias, e.g. bp27932"] = "",
2153
+ aliases: Annotated[str, "Optional comma-separated aliases for the same entry"] = "",
2138
2154
  ) -> dict[str, object]:
2139
2155
  """Save or update credentials for a URL pattern in the local credentials store.
2140
2156
 
@@ -2144,12 +2160,14 @@ def credentials_save(
2144
2160
  """
2145
2161
  if not _CREDENTIAL_STORE_ENABLED:
2146
2162
  raise _credential_store_disabled_error()
2147
- updated = _credentials_store().upsert(url, username, password)
2148
- return {
2149
- "url": url,
2150
- "saved": True,
2151
- "updated": updated,
2152
- }
2163
+ alias_list = [part.strip() for part in aliases.split(",") if part.strip()]
2164
+ updated = _credentials_store().upsert(url, username, password, title=title, aliases=alias_list)
2165
+ result: dict[str, object] = {"url": url, "saved": True, "updated": updated}
2166
+ if title:
2167
+ result["title"] = title
2168
+ if alias_list:
2169
+ result["aliases"] = alias_list
2170
+ return result
2153
2171
 
2154
2172
 
2155
2173
  @mcp.tool()
@@ -2164,13 +2182,21 @@ def credentials_remove(
2164
2182
 
2165
2183
 
2166
2184
  @mcp.tool()
2167
- def credentials_list() -> list[dict[str, str]]:
2168
- """List configured credential URL patterns (no usernames or passwords)."""
2185
+ def credentials_list() -> list[dict[str, object]]:
2186
+ """List configured credential URL patterns/titles (no usernames or passwords)."""
2169
2187
  if not _CREDENTIAL_STORE_ENABLED:
2170
2188
  return []
2171
2189
  return _credentials_store().list_entries()
2172
2190
 
2173
2191
 
2192
+ @mcp.tool()
2193
+ def credentials_titles() -> list[dict[str, object]]:
2194
+ """List saved credential titles/aliases that can be used as start_session base_url."""
2195
+ if not _CREDENTIAL_STORE_ENABLED:
2196
+ return []
2197
+ return _credentials_store().titles()
2198
+
2199
+
2174
2200
  _PLACEHOLDER_PASSWORD_RE = re.compile(r"^\*{3,}$")
2175
2201
  _AUTH_FAILURE_MARKERS = (
2176
2202
  "пользователь иб не идентифицирован",
@@ -2448,6 +2474,23 @@ async def _wait_for_test_client_ready(sess: SessionState, host: str, port: int,
2448
2474
  raise RuntimeError(message)
2449
2475
 
2450
2476
 
2477
+ async def _verify_manager_protocol(sess: SessionState) -> dict[str, Any]:
2478
+ info = await sess.bridge.call("manager_info", {})
2479
+ if not isinstance(info, dict):
2480
+ raise RuntimeError(
2481
+ "MCPTestManager.cf is too old or incompatible: manager_info returned an unexpected response. "
2482
+ f"Expected protocol {MANAGER_PROTOCOL_VERSION}. Rebuild/reload MCPTestManager.cf."
2483
+ )
2484
+ actual = str(info.get("manager_protocol_version") or "")
2485
+ if actual != MANAGER_PROTOCOL_VERSION:
2486
+ raise RuntimeError(
2487
+ "MCPTestManager.cf protocol mismatch: "
2488
+ f"Python expects {MANAGER_PROTOCOL_VERSION}, but 1C manager reports {actual or '<missing>'}. "
2489
+ "Rebuild MCPTestManager.cf from current sources and restart/reload Answer42 MCP."
2490
+ )
2491
+ return info
2492
+
2493
+
2451
2494
  async def _connect_test_client_impl(
2452
2495
  sess: SessionState,
2453
2496
  *,
@@ -2465,6 +2508,7 @@ async def _connect_test_client_impl(
2465
2508
  ) -> Any:
2466
2509
  """Launch 1cv8c /TESTCLIENT from Python and attach the test manager to it."""
2467
2510
  await _wait_for_bridge_connected(sess, timeout=60.0)
2511
+ manager_info = await _verify_manager_protocol(sess)
2468
2512
  r_base = _platform.normalize_infobase_url(base_url or sess.base_url)
2469
2513
  r_value = connection_value or r_base
2470
2514
  mode = _normalize_connection_mode(connection_mode, r_value, r_base)
@@ -2594,7 +2638,8 @@ async def _connect_test_client_impl(
2594
2638
  "port": r_port, "host": host, "client_pid": sess.test_client_process.pid,
2595
2639
  "client_log": str(client_out_log), "client_stdout_log": str(sess.client_log_dir / client_stdout_log),
2596
2640
  "connection_mode": mode, "connection_value": r_value,
2597
- "target_connection_mode": target_connection_mode}
2641
+ "target_connection_mode": target_connection_mode,
2642
+ "expected_manager_protocol_version": MANAGER_PROTOCOL_VERSION}
2598
2643
  _touch_session(sess)
2599
2644
  monitor_task: asyncio.Task[int | None] | None = None
2600
2645
  try:
@@ -3236,13 +3281,19 @@ async def table_cell_text(
3236
3281
 
3237
3282
  @mcp.tool()
3238
3283
  async def tabular_documents(
3239
- include_text: Annotated[bool, "Include tabular document text"] = False,
3284
+ include_text: Annotated[bool, "Include tabular document cells"] = False,
3240
3285
  max_depth: Annotated[int, "Maximum search depth"] = 8,
3286
+ row_start: Annotated[int, "First row to read when include_text=true (1-based)"] = 1,
3287
+ row_end: Annotated[int, "Last row to read when include_text=true; 0 = row_start + max_rows - 1"] = 0,
3288
+ column_start: Annotated[int, "First column to read when include_text=true (1-based)"] = 1,
3289
+ column_end: Annotated[int, "Last column to read when include_text=true; 0 = column_start + max_columns - 1"] = 0,
3290
+ max_rows: Annotated[int, "Maximum rows to read when row_end=0; 0 = all rows"] = 50,
3291
+ max_columns: Annotated[int, "Maximum columns to read when column_end=0; 0 = all columns"] = 20,
3241
3292
  *,
3242
3293
  session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
3243
3294
  ) -> Any:
3244
- """List tabular document fields in the active window; optionally include their text."""
3245
- return await _bridge_call_recorded(session_id, "tabular_documents", {"include_text": include_text, "max_depth": max_depth})
3295
+ """List tabular document fields with dimensions; optionally include a cells range."""
3296
+ return await _bridge_call_recorded(session_id, "tabular_documents", {"include_text": include_text, "max_depth": max_depth, "row_start": row_start, "row_end": row_end, "column_start": column_start, "column_end": column_end, "max_rows": max_rows, "max_columns": max_columns})
3246
3297
 
3247
3298
 
3248
3299
  @mcp.tool()
@@ -3250,11 +3301,17 @@ async def tabular_document_text(
3250
3301
  name: Annotated[str, "Tabular document field technical name"] = "",
3251
3302
  title: Annotated[str, "Tabular document field visible title"] = "",
3252
3303
  max_depth: Annotated[int, "Maximum search depth"] = 8,
3304
+ row_start: Annotated[int, "First row to read (1-based)"] = 1,
3305
+ row_end: Annotated[int, "Last row to read; 0 = row_start + max_rows - 1"] = 0,
3306
+ column_start: Annotated[int, "First column to read (1-based)"] = 1,
3307
+ column_end: Annotated[int, "Last column to read; 0 = column_start + max_columns - 1"] = 0,
3308
+ max_rows: Annotated[int, "Maximum rows to read when row_end=0; 0 = all rows"] = 50,
3309
+ max_columns: Annotated[int, "Maximum columns to read when column_end=0; 0 = all columns"] = 20,
3253
3310
  *,
3254
3311
  session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
3255
3312
  ) -> Any:
3256
- """Return text and dimensions of a tabular document field in the active window."""
3257
- return await _bridge_call_recorded(session_id, "tabular_document_text", {"name": name, "title": title, "max_depth": max_depth})
3313
+ """Return dimensions and a cells range of a tabular document field in the active window."""
3314
+ return await _bridge_call_recorded(session_id, "tabular_document_text", {"name": name, "title": title, "max_depth": max_depth, "row_start": row_start, "row_end": row_end, "column_start": column_start, "column_end": column_end, "max_rows": max_rows, "max_columns": max_columns})
3258
3315
 
3259
3316
 
3260
3317
  @mcp.tool()
File without changes
File without changes
File without changes
File without changes
File without changes