dsh-edt-projects 0.9.1 → 0.11.0

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.
package/README.md CHANGED
@@ -6,6 +6,11 @@
6
6
  по требованию, добавляет настройки на проект, правила для модели и навыки
7
7
  SonarQube/GitLab.
8
8
 
9
+ Плагин включается только в выбранных проектах: сессия в каталоге с
10
+ `.dsh-1c.json` или в пути из `projects` файла `$DSH_HOME/dsh-1c.json`. В
11
+ остальных проектах в контексте остаётся только `edt_config`, которым проект и
12
+ включается.
13
+
9
14
  ## Установка
10
15
 
11
16
  ```text
@@ -18,11 +23,15 @@ plugin_manager { action: "install_bundle", target: "dsh-edt-projects" }
18
23
 
19
24
  ## Что внутри
20
25
 
21
- - `lib/` — хаб MCP-серверов: монтаж по требованию (`mcp_pin`), настройки на
22
- проект, правила для системного промпта, провайдер навыков.
26
+ - `lib/` — хаб MCP-серверов: активация по проекту, монтаж по требованию
27
+ (`edt_pin`), настройки на проект, правила для системного промпта, провайдер
28
+ навыков.
23
29
  - `skills/` — навыки `edt-mcp-project-sonar` и `edt-mcp-project-gitlab`,
24
- включаются флагами `sonar.enabled` и `gitlab.enabled`.
25
- - `cordis.patch.yml` — состав серверов, который применяет менеджер плагинов.
30
+ включаются флагами `sonar.enabled` и `gitlab.enabled` внутри включённого
31
+ проекта.
32
+ - `cordis.patch.yml` — состав серверов, который применяет менеджер плагинов:
33
+ EDT, BSL Language Server по требованию, MCP-трекер и `v8std` (стандарты 1С и
34
+ расшифровка диагностик EDT/BSL).
26
35
 
27
36
  ## Лицензия
28
37
 
package/cordis.patch.yml CHANGED
@@ -1,20 +1,29 @@
1
1
  # Хаб MCP-серверов для разработки на 1С.
2
2
  #
3
- # Серверы описываются здесь и подключаются плагином mcp-hub (lib/index.js):
4
- # - `lazy: true` — не занимать контекст: сервер монтируется по вызову mcp_pin
5
- # - `provision: true` — параметры запуска добираются на mcp_pin (BSL LS скачивается)
6
- # - `shared: true` — одна копия на процесс; остальные серверы монтируются
7
- # в скоуп агента (параллельные worktree не мешают друг другу)
8
- # - `unavailable` — сервер описан, но настроить его нечем (mcp_pin объяснит, чем)
3
+ # Плагин стоит в профиле один раз, но активируется только в выбранных проектах:
4
+ # - сессия в каталоге с `.dsh-1c.json` — включена;
5
+ # - каталог добавлен в `projects` файла $DSH_HOME/dsh-1c.json — включена;
6
+ # - `enabled: true`/`enabled: false` — явное решение сильнее всего.
7
+ # В выключенном проекте виден только `edt_config`; в остальных сессиях ни правил
8
+ # 1С, ни MCP-серверов нет.
9
+ #
10
+ # Серверы описываются здесь и подключаются плагином mcp-hub (lib/index.js) в
11
+ # скоупе сессии:
12
+ # - `lazy: true` — не занимать контекст: сервер монтируется по вызову edt_pin
13
+ # - `provision: true` — параметры запуска добираются на edt_pin (BSL LS скачивается)
14
+ # - `unavailable` — сервер описан, но настроить его нечем (edt_pin объяснит, чем)
15
+ # `shared` больше не работает: иначе инструменты сервера протекали бы в чужие
16
+ # проекты.
9
17
  #
10
18
  # Настройки можно переопределить без перезапуска DSH — файлами
11
19
  # $DSH_HOME/dsh-1c.json (пользователь) и <cwd>/.dsh-1c.json (проект):
12
- # { "wiki": { "paths": ["C:\\git\\dev-llm-wiki"], "entry": "wiki/index.md" },
20
+ # { "projects": ["C:\\git\\trade"],
21
+ # "wiki": { "paths": ["C:\\git\\dev-llm-wiki"], "entry": "wiki/index.md" },
13
22
  # "servers": { "edt": { "url": "http://127.0.0.1:8765/mcp" },
14
23
  # "bsl": { "root": "C:\\git\\trade\\src\\cf" } },
15
24
  # "tests": { "yaxunit": { "enabled": true, "path": "tests/yaxunit", "project": "cfe_yaxunit" },
16
25
  # "vanessa": { "enabled": true, "path": "features" } } }
17
- # После правки файла: mcp_reload (или mcp_pin заново).
26
+ # После правки файла: edt_reload (или edt_pin заново).
18
27
  # Флаги tests включают правила тестируемости: YAxUnit — закладывать тестируемость
19
28
  # и писать тесты в указанный проект, Vanessa — предлагать сценарные тесты.
20
29
  #
@@ -36,6 +45,8 @@
36
45
  # DSH_1C_BSL_MCP_URL адрес уже запущенного BSL LS по Streamable HTTP
37
46
  # TASKS_MCP_URL адрес MCP-сервера системы задач
38
47
  # TASKS_MCP_TOKEN токен системы задач
48
+ # V8STD_MCP_URL адрес MCP-сервера стандартов (по умолчанию https://ai.v8std.ru/mcp)
49
+ # V8STD_MCP_TOKEN токен своего инстанса v8std, если он нужен
39
50
  - insert:
40
51
  - id: mcp-hub
41
52
  name: 'dsh-edt-projects'
@@ -44,9 +55,9 @@
44
55
  paths: !!js 'process.env.DSH_1C_WIKI ? process.env.DSH_1C_WIKI.split(";").filter(Boolean) : []'
45
56
  entry: !!js 'process.env.DSH_1C_WIKI_ENTRY ?? ""'
46
57
  servers:
47
- # EDT один на процесс: одна копия, чтобы агенты не поднимали лишние сессии.
58
+ # EDT монтируется в каждой включённой сессии: это отдельное HTTP-
59
+ # подключение, а не отдельный workspace в EDT.
48
60
  edt:
49
- shared: true
50
61
  lazy: false
51
62
  transport: streamable-http
52
63
  url: !!js 'process.env.EDT_MCP_URL ?? "http://127.0.0.1:8765/mcp"'
@@ -62,7 +73,7 @@
62
73
  maxAttempts: 1000
63
74
 
64
75
  # BSL Language Server: 10 инструментов, нужны эпизодически.
65
- # provision: на mcp_pin берётся готовый jar из DSH_1C_BSL_LS_JAR, иначе из
76
+ # provision: на edt_pin берётся готовый jar из DSH_1C_BSL_LS_JAR, иначе из
66
77
  # кэша, иначе скачивается последний релиз с GitHub (sha256 сверяется).
67
78
  # Корень по умолчанию — servers.bsl.root из файла настроек проекта.
68
79
  bsl:
@@ -81,3 +92,17 @@
81
92
  headers: !!js 'process.env.TASKS_MCP_TOKEN ? { Authorization: `Bearer ${process.env.TASKS_MCP_TOKEN}` } : {}'
82
93
  toolCallTimeoutMs: 120000
83
94
  failOnStartupError: false
95
+
96
+ # Стандарты 1С и диагностики: публичная read-only база v8std.
97
+ # lazy: пять схем не занимают контекст, пока не нужны. Модель подключает
98
+ # сервер по edt_pin, когда EDT или BSL LS выдал код ошибки либо задан
99
+ # вопрос про стандарт.
100
+ # Свой инстанс: servers.v8std.url в файлах настроек (перекрывает это
101
+ # значение) или переменная V8STD_MCP_URL.
102
+ v8std:
103
+ lazy: true
104
+ transport: streamable-http
105
+ url: !!js 'process.env.V8STD_MCP_URL ?? "https://ai.v8std.ru/mcp"'
106
+ headers: !!js 'process.env.V8STD_MCP_TOKEN ? { Authorization: `Bearer ${process.env.V8STD_MCP_TOKEN}` } : {}'
107
+ toolCallTimeoutMs: 60000
108
+ failOnStartupError: false
package/lib/config.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // Слои настроек бандла: база из патча, пользовательский файл и файл проекта.
2
2
  //
3
3
  // Приоритет: проект > пользователь > база. Файлы перечитываются на каждый
4
- // mcp_pin/mcp_reload и на каждый рендер промпта, поэтому правки применяются
4
+ // edt_pin/edt_reload и на каждый рендер промпта, поэтому правки применяются
5
5
  // без перезапуска DSH; разбор кэшируется по mtimeMs.
6
6
  import { existsSync, readFileSync, statSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
@@ -92,6 +92,17 @@ export function resolveConfig({ cwd, env = process.env, base = {} } = {}) {
92
92
  return { config: mergeLayers(base, user.config, project.config), sources, errors };
93
93
  }
94
94
 
95
+ /**
96
+ * Есть ли у проекта разбираемый файл `.dsh-1c.json`.
97
+ * Наличие самого файла при включении плагина учитывается только вместе с
98
+ * успешным разбором: битый JSON не должен включать 1С-режим.
99
+ */
100
+ export function projectConfigLoaded(cwd) {
101
+ const path = projectConfigPath(cwd);
102
+ if (path === undefined) return false;
103
+ return readJsonConfig(path).config !== undefined;
104
+ }
105
+
95
106
  /** Сбрасывает кэш — используется тестами. */
96
107
  export function clearConfigCache() {
97
108
  cache.clear();
package/lib/enable.mjs ADDED
@@ -0,0 +1,91 @@
1
+ // Включён ли плагин для проекта сессии.
2
+ //
3
+ // Плагин установлен в профиль один раз, но его инструменты, правила, навыки и
4
+ // MCP-серверы нужны только там, где работают с 1С. Поэтому активация считается
5
+ // от исходного каталога сессии:
6
+ //
7
+ // 1. `enabled: true` — включён;
8
+ // 2. `enabled: false` — выключен (сильнее файла проекта и списка);
9
+ // 3. разбираемый `.dsh-1c.json` в каталоге проекта — включён;
10
+ // 4. каталог попадает в `projects` пользовательского файла — включён;
11
+ // 5. иначе выключен.
12
+ //
13
+ // Сравнение путей: абсолютные пути, `~` раскрывается, хвостовой разделитель
14
+ // снимается, существующие пути приводятся через realpath; на Windows регистр не
15
+ // важен. Совпадение — равенство или вложенность по границе каталога, поэтому
16
+ // `C:\git\trade2` не считается проектом `C:\git\trade`.
17
+ import { realpathSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ const implFor = (platform) => (platform === 'win32' ? path.win32 : path.posix);
22
+
23
+ /**
24
+ * Приводит путь проекта к сравнимому виду.
25
+ * @param value путь из настроек или каталог сессии.
26
+ * @param options.platform платформа для правил путей (по умолчанию текущая).
27
+ * @param options.home домашний каталог для `~`.
28
+ * @returns нормализованный абсолютный путь или undefined, если путь не абсолютный.
29
+ */
30
+ export function normalizeProjectPath(value, { platform = process.platform, home = homedir() } = {}) {
31
+ if (typeof value !== 'string') return undefined;
32
+ const impl = implFor(platform);
33
+ let text = value.trim();
34
+ if (text === '') return undefined;
35
+
36
+ if (text === '~') text = home;
37
+ else if (text.startsWith('~/') || text.startsWith('~\\')) text = impl.join(home, text.slice(2));
38
+
39
+ if (!impl.isAbsolute(text)) return undefined;
40
+ let resolved = impl.normalize(text);
41
+
42
+ // realpath имеет смысл только для реальной платформы: путь чужой платформы
43
+ // здесь не существует и был бы искажён.
44
+ if (platform === process.platform) {
45
+ try {
46
+ resolved = realpathSync.native(resolved);
47
+ } catch {
48
+ // Каталога может ещё не быть — сравниваем нормализованный путь.
49
+ }
50
+ }
51
+
52
+ // Хвостовой разделитель не должен делать «C:\git\trade\» другим проектом.
53
+ // Корень диска и «/» сохраняются как есть.
54
+ const trimmed = resolved.replace(/[\\/]+$/, '');
55
+ if (trimmed !== '') resolved = trimmed;
56
+ if (platform === 'win32') resolved = resolved.toLowerCase();
57
+ return resolved;
58
+ }
59
+
60
+ /** Совпадает ли каталог с записью списка проектов (сам каталог или его потомок). */
61
+ export function matchesProject(cwd, entry, options = {}) {
62
+ const platform = options.platform ?? process.platform;
63
+ const left = normalizeProjectPath(cwd, { ...options, platform });
64
+ const right = normalizeProjectPath(entry, { ...options, platform });
65
+ if (left === undefined || right === undefined) return false;
66
+ if (left === right) return true;
67
+ const impl = implFor(platform);
68
+ const base = right.endsWith(impl.sep) ? right : right + impl.sep;
69
+ return left.startsWith(base);
70
+ }
71
+
72
+ /**
73
+ * Решение об активации вместе с причиной — причина нужна edt_config show.
74
+ * @returns {{ enabled: boolean, reason: string, entry?: string }}
75
+ */
76
+ export function activationFor({ cwd, config, hasProjectFile = false } = {}, options = {}) {
77
+ const value = config ?? {};
78
+ if (value.enabled === true) return { enabled: true, reason: 'enabled' };
79
+ if (value.enabled === false) return { enabled: false, reason: 'disabled' };
80
+ if (hasProjectFile) return { enabled: true, reason: 'project-file' };
81
+
82
+ const projects = Array.isArray(value.projects) ? value.projects : [];
83
+ const hit = projects.find((entry) => matchesProject(cwd, entry, options));
84
+ if (hit !== undefined) return { enabled: true, reason: 'allowlist', entry: hit };
85
+ return { enabled: false, reason: 'not-selected' };
86
+ }
87
+
88
+ /** Короткая форма activationFor для гейтов в рантайме. */
89
+ export function isEnabledFor(input, options = {}) {
90
+ return activationFor(input, options).enabled;
91
+ }
package/lib/index.js CHANGED
@@ -1,30 +1,36 @@
1
1
  // Хаб MCP-серверов для DSH: подключение по требованию, настройки на проект
2
2
  // и правила для модели.
3
3
  //
4
+ // Плагин стоит в профиле один раз, но активируется только в выбранных проектах:
5
+ // правила 1С, собственные инструменты и MCP-серверы появляются лишь там, где
6
+ // проект отмечен `.dsh-1c.json`, `enabled: true` или попадает в `projects`
7
+ // пользовательского файла (см. lib/enable.mjs). В остальных сессиях виден
8
+ // только `edt_config`, которым проект и включается.
9
+ //
4
10
  // Зачем: каждый подключённый сервер отдаёт свои схемы инструментов в КАЖДЫЙ
5
- // запрос к модели. EDT (99 инструментов) нужен постоянно, а BSL Language Server
6
- // и система задач — эпизодически. Поэтому сервер помечается `lazy: true` и
7
- // монтируется только по вызову `mcp_pin`; `mcp_unpin` возвращает контекст.
11
+ // запрос к модели. EDT (99 инструментов) нужен постоянно в 1С-проекте, а BSL
12
+ // Language Server и система задач — эпизодически. Поэтому серверы монтируются в
13
+ // скоуп сессии: `lazy: true` — только по вызову `edt_pin`; незаленивые — при
14
+ // создании сессии включённого проекта. `edt_unpin` возвращает контекст.
8
15
  //
9
16
  // Настройки: база из cordis.patch.yml <- $DSH_HOME/dsh-1c.json <- <cwd>/.dsh-1c.json.
10
- // Файлы перечитываются на каждый pin/рендер промпта, поэтому правки применяются
11
- // через mcp_reload без перезапуска DSH.
12
- //
13
- // Серверы описываются в config.servers (см. cordis.patch.yml):
14
- // edt: { shared: true, transport: streamable-http, url, headers, lazy: false }
15
- // bsl: { lazy: true, provision: true, root } — jar берётся из env/кэша
16
- // tasks: { lazy: true, transport: streamable-http, url, headers }
17
+ // Файлы перечитываются на каждый pin/рендер промпта и на каждый шаг (для
18
+ // включения на лету), поэтому правки применяются через edt_reload без
19
+ // перезапуска DSH.
17
20
  //
18
- // `shared: true` — одна копия на процесс (EDT один workspace); остальные серверы
19
- // монтируются в скоуп агента, поэтому параллельные агенты в разных worktree
20
- // получают каждый свой BSL LS со своим корнем.
21
+ // `shared: true` больше не работает: сервер всегда живёт в скоупе сессии, иначе
22
+ // его инструменты протекали бы в чужие проекты.
21
23
  export const name = 'mcp-hub';
22
24
 
23
25
  export const inject = ['tools', 'workingDirectory'];
24
26
 
27
+ /** Собственные инструменты плагина; префикс edt_ отделяет их от `mcp__edt__*`. */
28
+ export const TOOL_NAMES = ['edt_servers', 'edt_pin', 'edt_unpin', 'edt_reload', 'edt_config'];
29
+
25
30
  import { watch } from 'node:fs';
26
31
  import { bslAvailable, bslSettings, ensureJar, resolveBsl, startBslSupervisor } from './bsl.mjs';
27
- import { projectConfigPath, resolveConfig, userConfigPath } from './config.mjs';
32
+ import { projectConfigLoaded, projectConfigPath, resolveConfig, userConfigPath } from './config.mjs';
33
+ import { activationFor } from './enable.mjs';
28
34
  import { normalizeWiki, targetPathOf, writeDenialReason } from './wiki.mjs';
29
35
  import { normalizeTestTooling } from './tests.mjs';
30
36
  import { initFile, upgradeFile } from './config-file.mjs';
@@ -33,7 +39,7 @@ import { createSkillsProvider } from './skills.mjs';
33
39
 
34
40
  const HEADER = `## Работа с 1С через MCP
35
41
 
36
- К этому профилю подключены MCP-серверы для работы с конфигурацией 1С. Правила:
42
+ К этому проекту подключены MCP-серверы для работы с конфигурацией 1С. Правила:
37
43
 
38
44
  - Структуру, метаданные и код ищи через MCP, а не файловым поиском. Чтение файлов и \`grep\` — запасной путь, когда MCP-инструмент недоступен или уже вернул ошибку.
39
45
  - Проверяй утверждения о коде тем же инструментом, которым исследовал. Не выдавай непроверенное за проверенное.
@@ -43,12 +49,12 @@ const HEADER = `## Работа с 1С через MCP
43
49
  const PIN_RULES = `
44
50
  ### Серверы по требованию
45
51
 
46
- Часть серверов не подключена заранее, чтобы не занимать контекст их схемами. Посмотреть состояние — \`mcp_servers\`; подключить — \`mcp_pin(server)\`, отключить — \`mcp_unpin(server)\`.
52
+ Часть серверов не подключена заранее, чтобы не занимать контекст их схемами. Посмотреть состояние — \`edt_servers\`; подключить — \`edt_pin(server)\`, отключить — \`edt_unpin(server)\`.
47
53
 
48
- - Подключай сервер сам, когда задача без него не решается: нужна диагностика или типы — \`bsl\`, нужны задачи или статусы — \`tasks\`.
49
- - Для \`bsl\` передавай корень: \`mcp_pin("bsl", root: "<каталог конфигурации>")\`. Без корня берётся корень из настроек проекта, иначе каталог запуска DSH.
50
- - Первый \`mcp_pin("bsl")\` может занять время: при отсутствии готового jar сервер скачивается (~130 МБ). Предупреди пользователя, если это долго.
51
- - После \`mcp_unpin\` инструменты сервера исчезают из запроса. Не отключай сервер, пока работа с ним не закончена.
54
+ - Подключай сервер сам, когда задача без него не решается: нужна диагностика или типы — \`bsl\`, нужны стандарты или расшифровка кода ошибки — \`v8std\`, нужны задачи или статусы — \`tasks\`.
55
+ - Для \`bsl\` передавай корень: \`edt_pin("bsl", root: "<каталог конфигурации>")\`. Без корня берётся корень из настроек проекта, иначе каталог запуска DSH.
56
+ - Первый \`edt_pin("bsl")\` может занять время: при отсутствии готового jar сервер скачивается (~130 МБ). Предупреди пользователя, если это долго.
57
+ - После \`edt_unpin\` инструменты сервера исчезают из запроса. Не отключай сервер, пока работа с ним не закончена.
52
58
  - Если сервер помечен как не настроенный, не пытайся его подключить: скажи пользователю, чего не хватает.`;
53
59
 
54
60
  const EDT_EDIT_RULES = `
@@ -82,7 +88,7 @@ const BSL_RULES = `
82
88
  - \`type_info\` и \`global_member_info\` — свойства, методы и сигнатуры типов и глобального контекста платформы. Сверяйся с ними, а не по памяти.
83
89
  - \`definition\`, \`find_references\`, \`call_hierarchy\`, \`hover\`, \`type_at_position\`, \`document_symbols\` — навигация. Перед изменением метода посмотри \`call_hierarchy\` или \`find_references\`.
84
90
  - Позиции в \`line\`/\`character\` нумеруются с нуля.
85
- - Файлы вне рабочего каталога сервера дают \`File is not part of any registered workspace\`. Тогда переподключи сервер с нужным корнем: \`mcp_pin("bsl", root: "<каталог конфигурации>")\` — путь проекта видно в \`edt__list_projects\`.`;
91
+ - Файлы вне рабочего каталога сервера дают \`File is not part of any registered workspace\`. Тогда переподключи сервер с нужным корнем: \`edt_pin("bsl", root: "<каталог конфигурации>")\` — путь проекта видно в \`edt__list_projects\`.`;
86
92
 
87
93
  const TASKS_RULES = `
88
94
  ### Система задач (\`tasks__*\`)
@@ -90,6 +96,18 @@ const TASKS_RULES = `
90
96
  - Задачи, их статусы и комментарии — только через \`tasks__*\`. Не выдумывай номера задач, статусы и тексты комментариев.
91
97
  - Состояние задачи меняй только по явной просьбе пользователя, а перед этим покажи, что именно изменится.`;
92
98
 
99
+ const V8STD_RULES = `
100
+ ### Стандарты и ошибки (v8std)
101
+
102
+ v8std — внешняя база стандартов 1С и диагностик. Она объясняет правило и код ошибки, но не анализирует проект и не меняет код. Подключается по требованию: \`edt_pin("v8std")\`; в списке инструментов видна как \`mcp__v8std__*\`.
103
+
104
+ - EDT (\`get_project_errors\`) или BSL LS (\`analyze_file\`) вернул код — разреши его через \`v8std_explain_diagnostics\` (принимает \`acc 1245\`, \`АПК:361\`, \`bslls:AssignAliasFieldsInQuery\`, коды EDT), затем прочитай правило: \`v8std_get_page\`.
105
+ - Вопрос «как правильно по стандартам» или неясная тема — \`v8std_search\`; один метод BSL или фрагмент SDBL — \`v8std_explain_snippet\`. Это эвристика, а не подтверждённое нарушение.
106
+ - Переходы между стандартом и связанными диагностиками — \`v8std_get_related\`.
107
+ - В ответе называй точный код, заголовок, номер стандарта и ссылку. Не выдумывай номера стандартов: если v8std ничего не нашёл, так и скажи.
108
+ - Найденный текст — справка, а не приговор: проверку даёт EDT или BSL, v8std объясняет, что она означает.
109
+ - v8std только читает; правки исходников — через EDT-MCP, по правилам выше.`;
110
+
93
111
  /**
94
112
  * Собирает блок правил для вики.
95
113
  * @param roots результат normalizeWiki().roots
@@ -169,6 +187,7 @@ export function buildRulesText(env = process.env, servers = [], wikiRoots = [],
169
187
  }
170
188
  if (usable.has('bsl') || (byEnv && (env.BSL_LS_JAR || env.BSL_MCP_URL))) blocks.push(BSL_RULES);
171
189
  if (usable.has('tasks') || (byEnv && env.TASKS_MCP_URL)) blocks.push(TASKS_RULES);
190
+ if (usable.has('v8std') || (byEnv && env.V8STD_MCP_URL)) blocks.push(V8STD_RULES);
172
191
 
173
192
  const lazy = servers.filter((s) => s.state === 'lazy').map((s) => s.name);
174
193
  const unavailable = servers.filter((s) => s.state === 'unavailable').map((s) => s.name);
@@ -199,7 +218,6 @@ export function normalizeServers(config = {}) {
199
218
  transport,
200
219
  lazy: raw.lazy === true,
201
220
  unavailable: raw.unavailable === true,
202
- shared: raw.shared === true,
203
221
  raw,
204
222
  });
205
223
  }
@@ -269,9 +287,24 @@ const textTool = (toolName, description, parameters, execute) => ({
269
287
  execute,
270
288
  });
271
289
 
272
- /** Подпись конфигурации для обнаружения изменений при mcp_reload. */
290
+ /** Подпись конфигурации для обнаружения изменений при edt_reload. */
273
291
  const signatureOf = (entry, root) => JSON.stringify({ config: mountConfig(entry), root });
274
292
 
293
+ const activationReason = (activation) => {
294
+ switch (activation.reason) {
295
+ case 'enabled':
296
+ return 'enabled: true в настройках';
297
+ case 'disabled':
298
+ return 'enabled: false в настройках';
299
+ case 'project-file':
300
+ return 'есть .dsh-1c.json проекта';
301
+ case 'allowlist':
302
+ return `путь в projects (${activation.entry})`;
303
+ default:
304
+ return 'проект не выбран: нет .dsh-1c.json и его нет в projects';
305
+ }
306
+ };
307
+
275
308
  /**
276
309
  * Каталог, от которого считаются настройки проекта, корень BSL и пути вики.
277
310
  * Исходный каталог сессии (`session.header.cwd`) неизменяем, поэтому `cd`
@@ -299,11 +332,12 @@ export async function apply(ctx, config) {
299
332
  const agentStates = new WeakMap();
300
333
  const watchedProjects = new Set();
301
334
  const loggedErrors = new Set();
335
+ let skillsControl;
302
336
 
303
337
  const cwdOf = (agent) => sessionCwd(agent, ctx.workingDirectory);
304
338
 
305
- const effectiveFor = (agent) => {
306
- const effective = resolveConfig({ cwd: cwdOf(agent), env: process.env, base });
339
+ const effectiveForCwd = (cwd) => {
340
+ const effective = resolveConfig({ cwd, env: process.env, base });
307
341
  for (const error of effective.errors) {
308
342
  if (loggedErrors.has(error)) continue;
309
343
  loggedErrors.add(error);
@@ -312,6 +346,13 @@ export async function apply(ctx, config) {
312
346
  return effective;
313
347
  };
314
348
 
349
+ const effectiveFor = (agent) => effectiveForCwd(cwdOf(agent));
350
+
351
+ const activationOfCwd = (cwd) =>
352
+ activationFor({ cwd, config: effectiveForCwd(cwd).config, hasProjectFile: projectConfigLoaded(cwd) });
353
+
354
+ const activationOf = (agent) => activationOfCwd(cwdOf(agent));
355
+
315
356
  const entriesFor = (effective) => {
316
357
  const bsl = bslSettingsFor(effective);
317
358
  return normalizeServers(effective.config?.servers).map((entry) => {
@@ -324,22 +365,23 @@ export async function apply(ctx, config) {
324
365
  });
325
366
  };
326
367
 
327
- const mapFor = (agent, shared) => {
328
- if (shared) return globalState;
329
- if (agent === undefined) return globalState;
330
- let map = agentStates.get(agent);
331
- if (map === undefined) {
332
- map = new Map();
333
- agentStates.set(agent, map);
368
+ /** Состояние активации одного агента (сессии): инструменты и смонтированные серверы. */
369
+ const stateOf = (agent) => {
370
+ let state = agentStates.get(agent);
371
+ if (state === undefined) {
372
+ state = { active: false, disposers: [], items: new Map() };
373
+ agentStates.set(agent, state);
334
374
  }
335
- return map;
375
+ return state;
336
376
  };
337
377
 
378
+ const mapFor = (agent) => (agent === undefined ? globalState : stateOf(agent).items);
379
+
338
380
  const itemFor = (agent, entry) => {
339
- const map = mapFor(agent, entry.shared);
381
+ const map = mapFor(agent);
340
382
  let item = map.get(entry.name);
341
383
  if (item === undefined) {
342
- item = { entry, fiber: null, error: null, supervisor: null, signature: undefined };
384
+ item = { entry, fiber: null, error: null, supervisor: null, signature: undefined, suppressed: false };
343
385
  map.set(entry.name, item);
344
386
  } else {
345
387
  item.entry = entry;
@@ -347,10 +389,13 @@ export async function apply(ctx, config) {
347
389
  return item;
348
390
  };
349
391
 
350
- const toolNamesOf = (serverName) => {
392
+ const toolNamesOf = (agent, serverName) => {
351
393
  const prefix = `mcp__${serverName}__`;
352
394
  try {
353
- return [...ctx.tools.view(undefined).visible.keys()].filter((toolName) => toolName.startsWith(prefix));
395
+ return ctx.tools
396
+ .schemas(agent === undefined ? undefined : agent)
397
+ .map((schema) => schema.name)
398
+ .filter((toolName) => toolName.startsWith(prefix));
354
399
  } catch {
355
400
  return [];
356
401
  }
@@ -359,8 +404,7 @@ export async function apply(ctx, config) {
359
404
  const snapshot = (agent) => {
360
405
  const effective = effectiveFor(agent);
361
406
  return entriesFor(effective).map((entry) => {
362
- const map = mapFor(agent, entry.shared);
363
- const item = map.get(entry.name);
407
+ const item = mapFor(agent).get(entry.name);
364
408
  let value = 'lazy';
365
409
  if (entry.unavailable) value = 'unavailable';
366
410
  if (item?.fiber && !item.error) value = 'mounted';
@@ -368,11 +412,11 @@ export async function apply(ctx, config) {
368
412
  return {
369
413
  name: entry.name,
370
414
  state: value,
371
- scope: entry.shared ? 'shared' : agent === undefined ? 'global' : 'agent',
415
+ scope: agent === undefined ? 'global' : 'session',
372
416
  transport: item?.supervisor ? 'lsp+mcp' : entry.transport,
373
417
  root: item?.supervisor?.root ?? entry.raw?.root,
374
418
  endpoint: item?.supervisor?.url ?? entry.raw?.url,
375
- tools: toolNamesOf(entry.name).length,
419
+ tools: toolNamesOf(agent, entry.name).length,
376
420
  error: item?.error,
377
421
  };
378
422
  });
@@ -383,7 +427,7 @@ export async function apply(ctx, config) {
383
427
  await item.fiber.dispose().catch(() => {});
384
428
  item.fiber = null;
385
429
  }
386
- await item.supervisor?.stop();
430
+ await Promise.resolve(item.supervisor?.stop?.()).catch(() => {});
387
431
  item.supervisor = null;
388
432
  item.error = null;
389
433
  item.signature = undefined;
@@ -406,7 +450,10 @@ export async function apply(ctx, config) {
406
450
 
407
451
  if (item.fiber && !item.error) {
408
452
  const sameRoot = item.supervisor === null || item.supervisor?.root === root;
409
- if (sameRoot) return { already: true, tools: toolNamesOf(serverName).length };
453
+ if (sameRoot) {
454
+ item.suppressed = false;
455
+ return { already: true, tools: toolNamesOf(agent, serverName).length };
456
+ }
410
457
  await disposeItem(item);
411
458
  }
412
459
 
@@ -425,6 +472,13 @@ export async function apply(ctx, config) {
425
472
  const jar = await ensureJar(bsl, { log, warn });
426
473
  const supervisor = await startBslSupervisor({ jar, java: bsl.java, root, port: bsl.port, log });
427
474
  item.supervisor = supervisor;
475
+ // Процесс BSL LS должен умереть вместе со скоупом агента, даже если
476
+ // disposeItem не вызвали (сессия закрыта, плагин выгружен).
477
+ if (agent !== undefined) {
478
+ agent.ctx.effect(() => () => {
479
+ Promise.resolve(supervisor.stop()).catch(() => {});
480
+ });
481
+ }
428
482
  resolved = { transport: 'streamable-http', url: supervisor.url };
429
483
  }
430
484
  resolvedEntry = {
@@ -439,33 +493,39 @@ export async function apply(ctx, config) {
439
493
  };
440
494
  } catch (error) {
441
495
  item.error = error instanceof Error ? error.message : String(error);
442
- await item.supervisor?.stop();
496
+ await Promise.resolve(item.supervisor?.stop?.()).catch(() => {});
443
497
  item.supervisor = null;
444
498
  return { already: false, tools: 0, error: item.error };
445
499
  }
446
500
  }
447
501
 
448
502
  const McpClient = await import('@deepseek-ai/dsh-mcp-client');
449
- const scopeCtx = entry.shared || agent === undefined ? ctx : agent.ctx;
503
+ const scopeCtx = agent === undefined ? ctx : agent.ctx;
450
504
  const fiber = scopeCtx.plugin(McpClient, mountConfig(resolvedEntry));
451
505
  item.fiber = fiber;
452
506
  item.error = null;
453
507
  item.signature = signatureOf(resolvedEntry, root);
508
+ item.suppressed = false;
454
509
  try {
455
510
  await fiber;
456
511
  } catch (error) {
457
512
  item.error = error instanceof Error ? error.message : String(error);
458
513
  }
459
- return { already: false, tools: toolNamesOf(serverName).length, error: item.error };
514
+ return { already: false, tools: toolNamesOf(agent, serverName).length, error: item.error };
460
515
  };
461
516
 
462
517
  const unmount = async (agent, serverName) => {
463
518
  const effective = effectiveFor(agent);
464
519
  const entry = entriesFor(effective).find((candidate) => candidate.name === serverName);
465
- const map = entry === undefined ? mapFor(agent, false) : mapFor(agent, entry.shared);
520
+ const map = mapFor(agent);
466
521
  const item = map.get(serverName);
467
- if (item === undefined || !item.fiber) return false;
522
+ if (item === undefined || !item.fiber) {
523
+ if (entry !== undefined && item !== undefined) item.suppressed = true;
524
+ return false;
525
+ }
468
526
  await disposeItem(item);
527
+ // Незаленивый сервер иначе смонтируется снова на следующем шаге.
528
+ item.suppressed = true;
469
529
  return true;
470
530
  };
471
531
 
@@ -480,8 +540,7 @@ export async function apply(ctx, config) {
480
540
 
481
541
  const changed = [];
482
542
  for (const entry of targets) {
483
- const map = mapFor(agent, entry.shared);
484
- const item = map.get(entry.name);
543
+ const item = mapFor(agent).get(entry.name);
485
544
  if (item === undefined || !item.fiber) continue;
486
545
  const bsl = bslSettingsFor(effective);
487
546
  const root = entry.raw.root ?? bsl.root;
@@ -496,7 +555,98 @@ export async function apply(ctx, config) {
496
555
 
497
556
  // Если плагин выгружают, процессы серверов не должны остаться висеть.
498
557
  ctx.effect(() => () => {
499
- for (const item of globalState.values()) item.supervisor?.stop();
558
+ for (const item of globalState.values()) Promise.resolve(item.supervisor?.stop?.()).catch(() => {});
559
+ });
560
+
561
+ // --- активация на проект ----------------------------------------------------
562
+ const invalidateSkills = () => {
563
+ try {
564
+ skillsControl?.invalidate?.();
565
+ } catch {
566
+ // Инвалидация каталога — вспомогательная операция, не валим шаг.
567
+ }
568
+ };
569
+
570
+ const watchFile = (path) => {
571
+ if (path === undefined || watchedProjects.has(path)) return;
572
+ watchedProjects.add(path);
573
+ try {
574
+ const watcher = watch(path, { persistent: false }, () => {
575
+ invalidateSkills();
576
+ ctx.logger?.warn?.(`mcp-hub: настройки изменились (${path}). Примените их через edt_reload.`);
577
+ });
578
+ watcher.on('error', () => {});
579
+ ctx.effect(() => () => watcher.close());
580
+ } catch {
581
+ // Файла может ещё не быть — наблюдение не критично.
582
+ }
583
+ };
584
+
585
+ const activate = async (agent) => {
586
+ const state = stateOf(agent);
587
+ if (state.active) return;
588
+ state.active = true;
589
+ for (const definition of [serversTool(), pinTool(), unpinTool(), reloadTool()]) {
590
+ state.disposers.push(agent.ctx.tools.register(definition));
591
+ }
592
+ watchFile(projectConfigPath(cwdOf(agent)));
593
+
594
+ const effective = effectiveFor(agent);
595
+ for (const entry of entriesFor(effective)) {
596
+ if (entry.lazy || entry.unavailable) continue;
597
+ try {
598
+ await mount(agent, entry.name);
599
+ } catch (error) {
600
+ ctx.logger?.warn?.(`mcp-hub: ${entry.name} failed to mount: ${error instanceof Error ? error.message : String(error)}`);
601
+ }
602
+ }
603
+ };
604
+
605
+ const deactivate = async (agent) => {
606
+ const state = agentStates.get(agent);
607
+ if (state === undefined || !state.active) return;
608
+ state.active = false;
609
+ for (const dispose of state.disposers.splice(0)) {
610
+ try {
611
+ dispose();
612
+ } catch {
613
+ // Инструменты могли быть уже сняты выгрузкой скоупа.
614
+ }
615
+ }
616
+ for (const item of state.items.values()) await disposeItem(item);
617
+ state.items.clear();
618
+ };
619
+
620
+ /**
621
+ * Сводит состояние сессии с текущими настройками: включает проект на лету
622
+ * после edt_config init/правки файла и выключает после enabled:false.
623
+ */
624
+ const reconcile = async (agent) => {
625
+ if (agent === undefined) return;
626
+ const { enabled } = activationOf(agent);
627
+ const state = agentStates.get(agent);
628
+ if (enabled) {
629
+ if (state === undefined || !state.active) await activate(agent);
630
+ return;
631
+ }
632
+ await deactivate(agent);
633
+ };
634
+
635
+ ctx.on('agent/created', async ({ agent }) => {
636
+ try {
637
+ await reconcile(agent);
638
+ } catch (error) {
639
+ ctx.logger?.warn?.(`mcp-hub: активация для сессии не удалась: ${error instanceof Error ? error.message : String(error)}`);
640
+ }
641
+ });
642
+
643
+ ctx.on('agent/pre-step', async ({ agent }, next) => {
644
+ try {
645
+ await reconcile(agent);
646
+ } catch (error) {
647
+ ctx.logger?.warn?.(`mcp-hub: активация для сессии не удалась: ${error instanceof Error ? error.message : String(error)}`);
648
+ }
649
+ return next();
500
650
  });
501
651
 
502
652
  // --- вики: чтение разрешено, запись отклоняется ---
@@ -509,10 +659,10 @@ export async function apply(ctx, config) {
509
659
  return writeDenialReason(exec.name, target, roots, { cwd });
510
660
  });
511
661
 
512
- ctx.tools.register(
662
+ const serversTool = () =>
513
663
  textTool(
514
- 'mcp_servers',
515
- 'List MCP servers: state (mounted, lazy, unavailable, failed), scope, transport, tool count, workspace root and endpoint. Settings are read from $DSH_HOME/dsh-1c.json and <cwd>/.dsh-1c.json.',
664
+ 'edt_servers',
665
+ 'List MCP servers of the 1C plugin: state (mounted, lazy, unavailable, failed), scope, transport, tool count, workspace root and endpoint. Settings are read from $DSH_HOME/dsh-1c.json and <cwd>/.dsh-1c.json.',
516
666
  {},
517
667
  async (_args, exec) => {
518
668
  const rows = snapshot(exec?.agent).map((item) => {
@@ -527,11 +677,10 @@ export async function apply(ctx, config) {
527
677
  });
528
678
  return { text: rows.length > 0 ? rows.join('\n') : 'No MCP servers are configured.' };
529
679
  },
530
- ),
531
- );
680
+ );
532
681
 
533
682
  const serverParam = {
534
- server: { type: 'string', description: 'Server name as reported by mcp_servers.', required: true },
683
+ server: { type: 'string', description: 'Server name as reported by edt_servers.', required: true },
535
684
  root: {
536
685
  type: 'string',
537
686
  description:
@@ -540,50 +689,48 @@ export async function apply(ctx, config) {
540
689
  mode: { type: 'string', description: 'For a provisioned server: "hybrid" (LSP+MCP with workspace roots, default) or "stdio".' },
541
690
  };
542
691
 
543
- ctx.tools.register(
692
+ const pinTool = () =>
544
693
  textTool(
545
- 'mcp_pin',
694
+ 'edt_pin',
546
695
  'Mount a configured MCP server now, so its tools appear in this session. Re-reads the settings files first, so edited ports or roots apply without restarting DSH.',
547
696
  serverParam,
548
697
  async (args, exec) => {
549
698
  const result = await mount(exec?.agent, args.server, { root: args.root, mode: args.mode });
550
- if (result.error) throw new Error(`mcp_pin(${args.server}) failed: ${result.error}`);
699
+ if (result.error) throw new Error(`edt_pin(${args.server}) failed: ${result.error}`);
551
700
  if (result.already) return { text: `${args.server} is already mounted (${result.tools} tools).` };
552
701
  return { text: `${args.server} mounted (${result.tools} tools). They are available from the next step.` };
553
702
  },
554
- ),
555
- );
703
+ );
556
704
 
557
- ctx.tools.register(
705
+ const unpinTool = () =>
558
706
  textTool(
559
- 'mcp_unpin',
707
+ 'edt_unpin',
560
708
  'Unmount a mounted MCP server and remove its tools from the session, freeing prompt space. Do not unmount a server while work with it is in progress.',
561
709
  { server: serverParam.server },
562
710
  async (args, exec) => {
563
711
  const wasMounted = await unmount(exec?.agent, args.server);
564
712
  return { text: wasMounted ? `${args.server} unmounted.` : `${args.server} was not mounted.` };
565
713
  },
566
- ),
567
- );
714
+ );
568
715
 
569
- ctx.tools.register(
716
+ const reloadTool = () =>
570
717
  textTool(
571
- 'mcp_reload',
718
+ 'edt_reload',
572
719
  'Re-read the settings files ($DSH_HOME/dsh-1c.json and <cwd>/.dsh-1c.json) and restart mounted servers whose url, headers, command or root changed. Use after editing a settings file; no DSH restart needed.',
573
720
  { server: { type: 'string', description: 'Only this server; omit to check every configured server.' } },
574
721
  async (args, exec) => {
722
+ invalidateSkills();
575
723
  const changed = await reload(exec?.agent, args.server);
576
724
  if (changed.length === 0) return { text: 'Настройки перечитаны, изменений в подключённых серверах нет.' };
577
725
  const rows = changed.map((item) => `${item.name}: ${item.error === undefined ? 'перезапущен' : `ошибка — ${item.error}`}`);
578
726
  return { text: `Настройки перечитаны:\n${rows.join('\n')}` };
579
727
  },
580
- ),
581
- );
728
+ );
582
729
 
583
- ctx.tools.register(
730
+ const configTool = () =>
584
731
  textTool(
585
- 'mcp_config',
586
- 'Inspect or maintain the settings file. action=show prints the effective settings, their sources and validation warnings; init creates the file with defaults (refuses when it exists); upgrade adds only missing keys, keeping your values; validate reports type errors and unknown keys.',
732
+ 'edt_config',
733
+ 'Manage dsh-edt-projects settings: which projects enable the 1C plugin (enabled/projects), wiki paths, test tooling, MCP servers and bundled skills. action=show prints the effective settings, their sources, whether this project is enabled and validation warnings; init creates the project .dsh-1c.json (which enables the plugin for it); upgrade adds only missing keys, keeping your values; validate reports type errors and unknown keys.',
587
734
  {
588
735
  action: {
589
736
  type: 'string',
@@ -597,12 +744,14 @@ export async function apply(ctx, config) {
597
744
  async (args, exec) => {
598
745
  const cwd = cwdOf(exec?.agent);
599
746
  const path = args.scope === 'user' ? userConfigPath(process.env) : projectConfigPath(cwd);
600
- if (path === undefined) throw new Error('mcp_config: cannot resolve the settings path');
747
+ if (path === undefined) throw new Error('edt_config: cannot resolve the settings path');
601
748
 
602
749
  if (args.action === 'show') {
603
750
  const effective = effectiveFor(exec?.agent);
604
751
  const { errors, warnings } = validateConfig(effective.config);
752
+ const activation = activationOf(exec?.agent);
605
753
  const head = [
754
+ `Проект: ${activation.enabled ? 'включён' : 'выключен'} — ${activationReason(activation)}`,
606
755
  `Источники: ${effective.sources.length > 0 ? effective.sources.join(', ') : 'только cordis.patch.yml'}`,
607
756
  `Действующие настройки:\n${stringifyConfig(effective.config)}`,
608
757
  ];
@@ -614,15 +763,21 @@ export async function apply(ctx, config) {
614
763
  if (args.action === 'init') {
615
764
  const created = initFile(path);
616
765
  if (created.status === 'exists') return { text: `${path} уже существует — используйте action=upgrade, чтобы дополнить его.` };
617
- if (created.status === 'error') throw new Error(`mcp_config init: ${created.error}`);
618
- return { text: `Создан ${path} со значениями по умолчанию. Дальше впишите пути вики, корень конфигурации и флаги тестов.` };
766
+ if (created.status === 'error') throw new Error(`edt_config init: ${created.error}`);
767
+ invalidateSkills();
768
+ const hint =
769
+ args.scope === 'user'
770
+ ? 'Дальше впишите пути проектов в projects, чтобы включить плагин там.'
771
+ : 'Проект включён со следующего шага: появятся правила 1С и mcp__edt__*.';
772
+ return { text: `Создан ${path}. ${hint}` };
619
773
  }
620
774
 
621
775
  if (args.action === 'upgrade') {
622
776
  const dryRun = args.dryRun !== false;
623
777
  const result = upgradeFile(path, { dryRun });
624
- if (result.status === 'error') throw new Error(`mcp_config upgrade: ${result.error}`);
778
+ if (result.status === 'error') throw new Error(`edt_config upgrade: ${result.error}`);
625
779
  if (result.status === 'noop') return { text: `${path}: актуален, добавлять нечего.` };
780
+ if (!dryRun) invalidateSkills();
626
781
  const lines = [`${path}: ${dryRun ? 'будет добавлено' : 'добавлено'} ${result.added.length} ключ(ей):`];
627
782
  for (const key of result.added) lines.push(` + ${key}`);
628
783
  if (dryRun) lines.push('Это был предварительный просмотр. Повторите с dryRun: false, чтобы записать.');
@@ -640,35 +795,25 @@ export async function apply(ctx, config) {
640
795
  for (const warning of warnings) lines.push(` ! ${warning}`);
641
796
  return { text: lines.join('\n') };
642
797
  },
643
- ),
644
- );
798
+ );
799
+
800
+ // edt_config остаётся глобальным: это единственная точка входа, которой
801
+ // включают проект, поэтому он не может зависеть от активации.
802
+ ctx.tools.register(configTool());
645
803
 
646
804
  // --- навыки, поставляемые с бандлом ---
647
- // Навык появляется в каталоге только когда включён в конфиге проекта:
648
- // list() провайдера возвращает пустой список при выключенном флаге.
805
+ // Провайдер глобальный, но отдаёт навыки только для включённого проекта;
806
+ // инвалидация каталога при правке настроек — через control.invalidate().
649
807
  ctx.inject(['skills'], (inner) => {
650
- inner.skills.registerProvider(() =>
651
- createSkillsProvider({
808
+ inner.skills.registerProvider((control) => {
809
+ skillsControl = control;
810
+ return createSkillsProvider({
652
811
  resolveConfig: (cwd) => resolveConfig({ cwd: cwd ?? process.cwd(), env: process.env, base }).config,
812
+ isEnabled: (cwd, config) => activationFor({ cwd, config, hasProjectFile: projectConfigLoaded(cwd) }).enabled,
653
813
  logger: ctx.logger,
654
- }),
655
- );
656
- });
657
-
658
- // --- наблюдение за файлами настроек ---
659
- const watchFile = (path) => {
660
- if (path === undefined || watchedProjects.has(path)) return;
661
- watchedProjects.add(path);
662
- try {
663
- const watcher = watch(path, { persistent: false }, () => {
664
- ctx.logger?.warn?.(`mcp-hub: настройки изменились (${path}). Примените их через mcp_reload.`);
665
814
  });
666
- watcher.on('error', () => {});
667
- ctx.effect(() => () => watcher.close());
668
- } catch {
669
- // Файла может ещё не быть — наблюдение не критично.
670
- }
671
- };
815
+ });
816
+ });
672
817
 
673
818
  watchFile(userConfigPath(process.env));
674
819
  watchFile(projectConfigPath(cwdOf(undefined)));
@@ -680,25 +825,13 @@ export async function apply(ctx, config) {
680
825
  interpolate: false,
681
826
  text: (context) => {
682
827
  const agent = context?.agent;
828
+ // В выключенном проекте секция пустая: renderPrompt выбрасывает её,
829
+ // поэтому в промпте не остаётся ни строчки про 1С.
830
+ if (agent === undefined || !activationOf(agent).enabled) return '';
683
831
  const effective = effectiveFor(agent);
684
832
  const { roots } = normalizeWiki(effective.config?.wiki, { cwd: cwdOf(agent) });
685
833
  return buildRulesText(process.env, snapshot(agent), roots, effective.config?.tests);
686
834
  },
687
835
  });
688
836
  });
689
-
690
- // Серверы без `lazy` подключаются на старте и ЖДУТСЯ: иначе их инструменты
691
- // опаздывают к первому запросу и модель видит пустой набор MCP.
692
- // Ошибка подключения не валит загрузку: сервер останется в состоянии failed,
693
- // а mcp-client продолжит реконнект в фоне.
694
- const eager = entriesFor(effectiveFor(undefined)).filter((entry) => !entry.lazy && !entry.unavailable);
695
- await Promise.all(
696
- eager.map(async (entry) => {
697
- try {
698
- await mount(undefined, entry.name);
699
- } catch (error) {
700
- ctx.logger?.warn?.(`mcp-hub: ${entry.name} failed to mount: ${error instanceof Error ? error.message : String(error)}`);
701
- }
702
- }),
703
- );
704
837
  }
package/lib/schema.mjs CHANGED
@@ -3,12 +3,15 @@
3
3
  //
4
4
  // Правила апгрейда: существующие значения не меняются, недостающие ключи
5
5
  // добавляются, неизвестные ключи сохраняются, версия поднимается.
6
- export const CONFIG_VERSION = 1;
6
+ export const CONFIG_VERSION = 2;
7
7
 
8
8
  /** Значения по умолчанию для нового файла. Пустые строки и массивы
9
- * нормализаторы игнорируют, поэтому init ничего не включает сам по себе. */
9
+ * нормализаторы игнорируют, поэтому init ничего не включает сам по себе.
10
+ * `enabled` намеренно не входит в defaults: `upgrade` не должен выключать
11
+ * проект, который включён самим фактом своего `.dsh-1c.json`. */
10
12
  export const DEFAULTS = {
11
13
  version: CONFIG_VERSION,
14
+ projects: [],
12
15
  wiki: { paths: [], entry: '' },
13
16
  servers: {
14
17
  edt: { url: 'http://127.0.0.1:8765/mcp' },
@@ -26,15 +29,17 @@ export const DEFAULTS = {
26
29
  /** Известные ключи: путь -> описание и тип. Используются валидацией и README. */
27
30
  export const KNOWN = {
28
31
  version: { type: 'number', description: 'Версия схемы настроек.' },
32
+ enabled: { type: 'boolean', description: 'Включить или выключить плагин для этого проекта (сильнее файла проекта и списка projects).' },
33
+ projects: { type: 'array', description: 'Каталоги проектов, где плагин включён (пользовательский файл).' },
29
34
  'wiki.paths': { type: 'array', description: 'Каталоги вики (абсолютные или относительно проекта).' },
30
35
  'wiki.entry': { type: 'string', description: 'Входная точка вики, например wiki/index.md.' },
31
36
  'servers': { type: 'object', description: 'Настройки MCP-серверов по именам.' },
32
37
  'servers.*.url': { type: 'string', description: 'Адрес MCP-сервера (streamable-http).' },
33
38
  'servers.*.headers': { type: 'object', description: 'Заголовки запросов, например Authorization.' },
34
39
  'servers.*.root': { type: 'string', description: 'Корень рабочего пространства (для bsl).' },
35
- 'servers.*.lazy': { type: 'boolean', description: 'Не подключать на старте, только по mcp_pin.' },
36
- 'servers.*.shared': { type: 'boolean', description: 'Одна копия на процесс вместо скоупа агента.' },
37
- 'servers.*.provision': { type: 'boolean', description: 'Параметры запуска добираются на mcp_pin.' },
40
+ 'servers.*.lazy': { type: 'boolean', description: 'Не подключать на старте, только по edt_pin.' },
41
+ 'servers.*.shared': { type: 'boolean', description: 'Устарело: серверы всегда монтируются в скоупе сессии.' },
42
+ 'servers.*.provision': { type: 'boolean', description: 'Параметры запуска добираются на edt_pin.' },
38
43
  'servers.*.toolCallTimeoutMs': { type: 'number', description: 'Таймаут вызова инструмента.' },
39
44
  'servers.*.failOnStartupError': { type: 'boolean', description: 'Падать, если стартовое подключение не удалось.' },
40
45
  'servers.*.reconnect': { type: 'object', description: 'Политика переподключения.' },
@@ -171,6 +176,12 @@ export function validateConfig(config) {
171
176
  if (!config.wiki.paths.every((item) => typeof item === 'string')) errors.push('wiki.paths: ожидался массив строк');
172
177
  }
173
178
 
179
+ if (config.projects !== undefined && !Array.isArray(config.projects)) {
180
+ errors.push(`projects: ожидался массив путей, получено ${typeof config.projects}`);
181
+ } else if (Array.isArray(config.projects) && !config.projects.every((item) => typeof item === 'string')) {
182
+ errors.push('projects: ожидался массив строк');
183
+ }
184
+
174
185
  return { errors, warnings };
175
186
  }
176
187
 
package/lib/skills.mjs CHANGED
@@ -63,15 +63,24 @@ export function skillFile(name) {
63
63
  /**
64
64
  * Создаёт провайдер навыков.
65
65
  * @param options.resolveConfig вызывается с cwd и возвращает действующий конфиг.
66
+ * @param options.isEnabled вызывается с (cwd, config) и говорит, активирован ли
67
+ * плагин для этого проекта; для выключенного проекта навыков нет.
66
68
  * @param options.logger необязательный логгер для предупреждений.
67
69
  */
68
- export function createSkillsProvider({ resolveConfig, logger } = {}) {
70
+ export function createSkillsProvider({ resolveConfig, isEnabled, logger } = {}) {
69
71
  const read = (name) => {
70
72
  const path = skillFile(name);
71
73
  if (!existsSync(path)) return undefined;
72
74
  return { path, ...parseFrontmatter(readFileSync(path, 'utf8')) };
73
75
  };
74
76
 
77
+ const configFor = (cwd) => (typeof resolveConfig === 'function' ? resolveConfig(cwd) : {}) ?? {};
78
+
79
+ const allowed = (cwd) => {
80
+ if (typeof isEnabled !== 'function') return true;
81
+ return isEnabled(cwd, configFor(cwd)) === true;
82
+ };
83
+
75
84
  const toCandidate = (name) => {
76
85
  const parsed = read(name);
77
86
  if (parsed === undefined || parsed.meta.description === undefined) return undefined;
@@ -91,7 +100,9 @@ export function createSkillsProvider({ resolveConfig, logger } = {}) {
91
100
  name: 'edt-mcp-bundle-skills',
92
101
  /** Отдаёт только навыки, включённые в конфиге проекта. */
93
102
  list(options = {}) {
94
- const config = typeof resolveConfig === 'function' ? resolveConfig(options.cwd) : {};
103
+ const cwd = options.cwd ?? process.cwd();
104
+ if (!allowed(cwd)) return [];
105
+ const config = configFor(cwd);
95
106
  const candidates = [];
96
107
  for (const skill of SKILLS) {
97
108
  if (!skill.enabled(config)) continue;
@@ -105,7 +116,8 @@ export function createSkillsProvider({ resolveConfig, logger } = {}) {
105
116
  return candidates;
106
117
  },
107
118
  /** Возвращает тело навыка без frontmatter. */
108
- get(candidate) {
119
+ get(candidate, options = {}) {
120
+ if (!allowed(options.cwd ?? process.cwd())) return undefined;
109
121
  const parsed = read(candidate?.name);
110
122
  if (parsed === undefined) return undefined;
111
123
  return {
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "1C:EDT + BSL MCP",
4
- "description": "MCP hub for 1C development: EDT is always connected; BSL Language Server (diagnostics, types, navigation) and a task tracker are mounted on demand via mcp_pin. Adds tool-selection rules to the prompt."
4
+ "description": "MCP hub for 1C development: active only in selected projects (.dsh-1c.json or the projects list). There EDT is connected up front, while BSL Language Server (diagnostics, types, navigation) and a task tracker are mounted on demand via edt_pin; 1C rules enter the prompt only there."
5
5
  }
6
6
  }
package/locale/ru.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "1C:EDT + BSL MCP",
4
- "description": "Хаб MCP-серверов для разработки на 1С: EDT подключён всегда, BSL Language Server (диагностика, типы, навигация) и система задач — по требованию через mcp_pin. Добавляет в промпт правила выбора инструментов."
4
+ "description": "Хаб MCP-серверов для разработки на 1С: включается только в выбранных проектах (файл .dsh-1c.json или список projects). В них EDT подключён сразу, BSL Language Server (диагностика, типы, навигация) и система задач — по требованию через edt_pin; правила 1С появляются в промпте только там."
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-edt-projects",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
- "description": "MCP hub for 1C development: EDT always on, BSL Language Server started in LSP+MCP mode with workspace roots, task tracker via mcp_pin, bundled SonarQube and GitLab skills, with model-facing usage rules",
5
+ "description": "Project-scoped MCP hub for 1C development: active only in selected projects, EDT connected up front there, BSL Language Server, a task tracker and the v8std standards MCP mounted on demand, bundled SonarQube and GitLab skills, with model-facing usage rules",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",