From 835ad41384b80f77808f30d25db1530899bd7b79 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 25 Sep 2026 21:52:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E8=B0=83=E6=95=B4hook=E3=80=81mcp=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E4=B8=BA=E7=9C=9F=E5=AE=9E=E7=9A=84=E5=B8=83=E5=B0=94?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/codingcode/src/client/contracts.ts | 6 +- .../codingcode/src/client/http/settings.ts | 26 +-- packages/codingcode/src/contracts/hooks.ts | 3 +- packages/codingcode/src/contracts/mcp.ts | 3 +- packages/codingcode/src/direct/settings.ts | 75 +++---- packages/codingcode/src/hooks/config.ts | 127 ++++++----- packages/codingcode/src/hooks/executor.ts | 11 - packages/codingcode/src/hooks/hooks.ts | 38 +--- packages/codingcode/src/mcp/config.ts | 135 +++++++----- packages/codingcode/src/mcp/mcp.ts | 54 ++--- packages/codingcode/src/mcp/port.ts | 2 - .../codingcode/src/server/routes/settings.ts | 74 ++----- .../test/hooks/config-merge.test.ts | 200 ++++++++--------- .../codingcode/test/hooks/executor.test.ts | 25 --- .../codingcode/test/mcp/config-merge.test.ts | 204 +++++++----------- packages/codingcode/test/mcp/service.test.ts | 1 - packages/desktop/src/lib/core-api.ts | 18 +- packages/desktop/src/settings/HooksPanel.tsx | 13 +- packages/desktop/src/settings/McpPanel.tsx | 13 +- .../desktop/test/rollback-usage-reset.test.ts | 3 +- packages/desktop/test/thread-delete.test.ts | 3 +- packages/infra/package.json | 3 +- packages/infra/src/disabled-store.ts | 121 ----------- packages/tui/src/components/App.tsx | 8 +- packages/tui/src/index.tsx | 4 +- packages/tui/src/types.ts | 2 +- packages/tui/test/components/App.test.tsx | 2 +- 27 files changed, 436 insertions(+), 738 deletions(-) delete mode 100644 packages/codingcode/test/hooks/executor.test.ts delete mode 100644 packages/infra/src/disabled-store.ts diff --git a/packages/codingcode/src/client/contracts.ts b/packages/codingcode/src/client/contracts.ts index 7cc51242..494268fe 100644 --- a/packages/codingcode/src/client/contracts.ts +++ b/packages/codingcode/src/client/contracts.ts @@ -133,8 +133,7 @@ export interface SettingsClient { getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number }>; setCompactionModel(compactionModel: string): Promise<{ compactionModel: string }>; getMcpStatus(input: { cwd: string }): Promise; - setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; - resetMcpDisabled(body: { name: string; cwd: string }): Promise; + setMcpEnabled(body: { name: string; enabled: boolean; cwd: string }): Promise; createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise; deleteMcpServer(input: { cwd: string; name: string }): Promise; @@ -143,8 +142,7 @@ export interface SettingsClient { createHook(input: { cwd: string; hook: UserHookConfig }): Promise; updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise; deleteHook(input: { cwd: string; name: string }): Promise; - setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise; - resetHookDisabled(body: { name: string; cwd: string }): Promise; + setHookEnabled(input: { cwd: string; name: string; enabled: boolean }): Promise; getGlobalPermissionMode(input: { sessionId: string; cwd: string }): Promise; setGlobalPermissionMode(input: { sessionId: string; diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index b6c6512c..763da432 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -42,19 +42,12 @@ export function createHttpSettingsClient( return apiGet(`/api/settings/mcp${qsCwd(cwd)}`); }, - async setMcpDisabled({ name, disabled, cwd }) { - await apiPost(`/api/settings/mcp/${encodeURIComponent(name)}/disabled${qsCwd(cwd)}`, { - disabled, + async setMcpEnabled({ name, enabled, cwd }) { + await apiPost(`/api/settings/mcp/${encodeURIComponent(name)}/enabled${qsCwd(cwd)}`, { + enabled, }); }, - async resetMcpDisabled({ name, cwd }) { - await apiPost( - `/api/settings/mcp/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, - {} - ); - }, - async createMcpServer({ cwd, server }) { await apiPost(`/api/settings/mcp${qsCwd(cwd)}`, server); }, @@ -87,19 +80,12 @@ export function createHttpSettingsClient( await apiDelete(`/api/settings/hooks/${encodeURIComponent(name)}${qsCwd(cwd)}`); }, - async setHookDisabled({ cwd, name, disabled }) { - await apiPost(`/api/settings/hooks/${encodeURIComponent(name)}/disabled${qsCwd(cwd)}`, { - disabled, + async setHookEnabled({ cwd, name, enabled }) { + await apiPost(`/api/settings/hooks/${encodeURIComponent(name)}/enabled${qsCwd(cwd)}`, { + enabled, }); }, - async resetHookDisabled({ name, cwd }) { - await apiPost( - `/api/settings/hooks/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, - {} - ); - }, - async getGlobalPermissionMode(input: { sessionId: string; cwd: string; diff --git a/packages/codingcode/src/contracts/hooks.ts b/packages/codingcode/src/contracts/hooks.ts index 6837773e..13e3e0ed 100644 --- a/packages/codingcode/src/contracts/hooks.ts +++ b/packages/codingcode/src/contracts/hooks.ts @@ -35,5 +35,6 @@ export interface UserHookConfig { args?: string[]; env?: Record; priority?: number; - enabled: boolean; + /** 开关:false 表示禁用。缺省(undefined)等同启用 */ + enabled?: boolean; } diff --git a/packages/codingcode/src/contracts/mcp.ts b/packages/codingcode/src/contracts/mcp.ts index 5f694653..d2d93a87 100644 --- a/packages/codingcode/src/contracts/mcp.ts +++ b/packages/codingcode/src/contracts/mcp.ts @@ -3,6 +3,8 @@ import type { AgentError } from '../core/error.js'; export interface McpServerConfig { name: string; + /** 开关:false 表示禁用。缺省(undefined)等同启用 */ + enabled?: boolean; /** stdio: executable command */ command?: string; /** stdio: command arguments */ @@ -22,7 +24,6 @@ export interface McpServerConfig { export interface McpStatus { name: string; connected: boolean; - disabled: boolean; toolCount: number; transport: 'stdio' | 'http'; reconnectAttempts: number; diff --git a/packages/codingcode/src/direct/settings.ts b/packages/codingcode/src/direct/settings.ts index 0c6d4b0f..17c718f0 100644 --- a/packages/codingcode/src/direct/settings.ts +++ b/packages/codingcode/src/direct/settings.ts @@ -10,10 +10,8 @@ import { writeMcpConfig, loadGlobalMcpConfig, writeGlobalMcpConfig, - getGlobalMcpDisabledState, - setGlobalMcpDisabledState, - setProjectMcpDisabledState, - resetProjectMcpDisabledState, + setGlobalMcpServerEnabled, + setProjectMcpServerEnabled, } from '../mcp/config.js'; import { loadHookConfigs, @@ -21,11 +19,9 @@ import { loadGlobalHookConfigs, writeGlobalHookConfigs, resolveHookConfigs, - setGlobalHookDisabledState, - setProjectHookDisabledState, - resetProjectHookDisabledState, + setGlobalHookEnabled, + setProjectHookEnabled, } from '../hooks/config.js'; -import { setHookRuntimeEnabled } from '../hooks/executor.js'; import { getMemoryConfig } from '../memory/config.js'; import { MemoryService } from '../memory/port.js'; import { AlreadyExistsError, NotFoundError } from '../contracts/error.js'; @@ -99,7 +95,11 @@ function hooksList( cwd: string ): Array { if (isGlobalCwd(cwd)) { - return loadGlobalHookConfigs().map((h) => ({ ...h, source: 'global' as const })); + return loadGlobalHookConfigs().map((h) => ({ + ...h, + enabled: h.enabled !== false, + source: 'global' as const, + })); } const globalHooks = loadGlobalHookConfigs(); const projectHooks = loadHookConfigs(cwd); @@ -112,6 +112,7 @@ function hooksList( const hasProjectOverride = isFromProject && isFromGlobal; return { ...h, + enabled: h.enabled !== false, source: (isFromProject ? 'project' : 'global') as 'global' | 'project', hasProjectOverride, }; @@ -173,14 +174,12 @@ function hooksDelete(cwd: string, name: string): void { ); } -function hooksSetDisabled(cwd: string, name: string, disabled: boolean): void { - setHookRuntimeEnabled(name, !disabled); - const hooks = loadHookConfigs(cwd); - const hook = hooks.find((h) => h.name === name); - if (hook) { - hook.enabled = !disabled; - writeHookConfigs(cwd, hooks); +function hooksSetEnabled(cwd: string, name: string, enabled: boolean): void { + if (isGlobalCwd(cwd)) { + setGlobalHookEnabled(name, enabled); + return; } + setProjectHookEnabled(resolveCwd(cwd), name, enabled); } export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { @@ -236,7 +235,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { return loadGlobalMcpConfig().map((s) => ({ ...runtimeByName.get(s.name), name: s.name, - disabled: getGlobalMcpDisabledState(s.name), + enabled: s.enabled !== false, source: 'global' as const, })) as McpStatus[]; } @@ -245,7 +244,11 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { const globalNames = new Set(globalServers.map((s) => s.name)); const seen = new Set(); const result: Array< - McpStatus & { source: 'global' | 'project'; hasProjectOverride?: boolean } + McpStatus & { + enabled: boolean; + source: 'global' | 'project'; + hasProjectOverride?: boolean; + } > = []; for (const s of projectServers) { seen.add(s.name); @@ -261,7 +264,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { toolCount: 0, }), name: s.name, - disabled: r?.disabled ?? false, + enabled: s.enabled !== false, source: 'project', hasProjectOverride: isFromGlobal, }); @@ -279,32 +282,19 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { toolCount: 0, }), name: s.name, - disabled: r?.disabled ?? false, + enabled: s.enabled !== false, source: 'global', }); } return result as McpStatus[]; }, - async setMcpDisabled({ name, disabled, cwd }) { - const projectCwd = resolveCwd(cwd); + async setMcpEnabled({ name, enabled, cwd }) { if (isGlobalCwd(cwd)) { - setGlobalMcpDisabledState(name, disabled); + setGlobalMcpServerEnabled(name, enabled); } else { - setProjectMcpDisabledState(projectCwd, name, disabled); + setProjectMcpServerEnabled(resolveCwd(cwd), name, enabled); } - await rt.runPromise( - Effect.gen(function* () { - const mcp = yield* McpService; - return yield* disabled - ? mcp.disable(projectCwd, name) - : mcp.enable(projectCwd, name); - }) - ); - }, - - async resetMcpDisabled({ name, cwd }) { - resetProjectMcpDisabledState(cwd, name); }, async createMcpServer({ cwd, server }) { @@ -344,17 +334,8 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { hooksDelete(cwd, name); }, - async setHookDisabled({ cwd, name, disabled }) { - if (isGlobalCwd(cwd)) { - setGlobalHookDisabledState(name, disabled); - } else { - setProjectHookDisabledState(cwd, name, disabled); - } - hooksSetDisabled(cwd, name, disabled); - }, - - async resetHookDisabled({ name, cwd }) { - resetProjectHookDisabledState(cwd, name); + async setHookEnabled({ cwd, name, enabled }) { + hooksSetEnabled(cwd, name, enabled); }, async getGlobalPermissionMode(input: { diff --git a/packages/codingcode/src/hooks/config.ts b/packages/codingcode/src/hooks/config.ts index 822dd11f..9c8b50dc 100644 --- a/packages/codingcode/src/hooks/config.ts +++ b/packages/codingcode/src/hooks/config.ts @@ -2,8 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import type { HookPoint, UserHookConfig } from '../contracts/hooks.js'; -import { createDisabledStore } from '@codingcode/infra/disabled-store'; +import type { UserHookConfig } from '../contracts/hooks.js'; let _globalConfigDirOverride: string | undefined; @@ -16,77 +15,95 @@ export function _setGlobalConfigDir(dir: string | undefined): void { _globalConfigDirOverride = dir; } -function mergeByName(global: T[], project: T[]): T[] { +/** 丢掉值为 undefined 的键,避免下层未显式写的字段被擦掉 */ +function definedOnly(obj: T): Partial { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial; +} + +/** 按 name 做字段级合并:项目层只覆盖它显式写出的字段,其余继承全局 */ +function mergeConfigs(global: T[], project: T[]): T[] { const map = new Map(); - for (const item of global) map.set(item.name, item); - for (const item of project) map.set(item.name, item); + for (const item of global) map.set(item.name, { ...item }); + for (const item of project) { + const base = map.get(item.name); + map.set(item.name, base ? { ...base, ...definedOnly(item) } : { ...item }); + } return Array.from(map.values()); } -export function loadHookConfigs(projectRoot: string): UserHookConfig[] { - const paths = [ - join(projectRoot, '.codingcode', 'hooks.yaml'), - join(projectRoot, '.codingcode', 'hooks.yml'), - ]; - for (const p of paths) { - if (existsSync(p)) { - const raw = readFileSync(p, 'utf8'); - const parsed = parseYaml(raw) as { hooks?: UserHookConfig[] }; - return parsed.hooks ?? []; - } +/** 该目录下实际生效的配置文件;都不存在时给出默认写入位置 */ +function hookConfigPath(dir: string): string { + const candidates = [join(dir, 'hooks.yaml'), join(dir, 'hooks.yml')]; + return candidates.find((p) => existsSync(p)) ?? candidates[0]!; +} + +/** 项目层的覆盖项可能只写了 name/enabled,故条目类型按部分字段读 */ +type RawHookConfig = Partial & { name: string }; + +function readRawHooks(dir: string): RawHookConfig[] { + const p = hookConfigPath(dir); + if (!existsSync(p)) return []; + try { + const parsed = parseYaml(readFileSync(p, 'utf8')) as { hooks?: RawHookConfig[] } | null; + return parsed?.hooks ?? []; + } catch { + return []; } - return []; } -export function writeHookConfigs(projectRoot: string, hooks: UserHookConfig[]): void { - const dir = join(projectRoot, '.codingcode'); +function writeHooks(dir: string, hooks: RawHookConfig[]): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'hooks.yaml'); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; + const p = hookConfigPath(dir); + let existing: Record = {}; + if (existsSync(p)) { + existing = (parseYaml(readFileSync(p, 'utf8')) as Record) ?? {}; + } existing.hooks = hooks; writeFileSync(p, stringifyYaml(existing), 'utf8'); } +export function loadHookConfigs(projectRoot: string): UserHookConfig[] { + return readRawHooks(join(projectRoot, '.codingcode')) as UserHookConfig[]; +} + +export function writeHookConfigs(projectRoot: string, hooks: UserHookConfig[]): void { + writeHooks(join(projectRoot, '.codingcode'), hooks); +} + export function loadGlobalHookConfigs(): UserHookConfig[] { - const paths = [join(getGlobalConfigDir(), 'hooks.yaml'), join(getGlobalConfigDir(), 'hooks.yml')]; - for (const p of paths) { - if (existsSync(p)) { - const raw = readFileSync(p, 'utf8'); - const parsed = parseYaml(raw) as { hooks?: UserHookConfig[] }; - return parsed.hooks ?? []; - } - } - return []; + return readRawHooks(getGlobalConfigDir()) as UserHookConfig[]; } export function writeGlobalHookConfigs(hooks: UserHookConfig[]): void { - const dir = getGlobalConfigDir(); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'hooks.yaml'); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; - existing.hooks = hooks; - writeFileSync(p, stringifyYaml(existing), 'utf8'); + writeHooks(getGlobalConfigDir(), hooks); } +/** 合并 global 与 project 两层,得到运行时真正生效的 hook 列表 */ export function resolveHookConfigs(projectRoot: string): UserHookConfig[] { - const globalHooks = loadGlobalHookConfigs(); - const projectHooks = loadHookConfigs(projectRoot); - return mergeByName(globalHooks, projectHooks); + return mergeConfigs(loadGlobalHookConfigs(), loadHookConfigs(projectRoot)) as UserHookConfig[]; } -// ---- Hook disabled state ---- - -const hookDisabledStore = createDisabledStore({ - globalKeyPath: ['hooks', 'disabledHooks'], - getGlobalConfigDir, -}); -export const getGlobalHookDisabledState = hookDisabledStore.getGlobal; -export const setGlobalHookDisabledState = hookDisabledStore.setGlobal; -export const getProjectHookDisabledState = hookDisabledStore.getProject; -export const setProjectHookDisabledState = hookDisabledStore.setProject; -export const resetProjectHookDisabledState = hookDisabledStore.resetProject; -export const resolveHookDisabled = hookDisabledStore.resolve; +// ---- Hook 开关 ---- +// 开关就是定义里的一个布尔字段 `enabled`,没有独立的开关状态存储。 +// 改开关 = 改写某一层 hooks.yaml 里该 hook 的 `enabled`。 + +/** + * 就地改写某一层配置文件里某个 hook 的 `enabled`。 + * 该层还没有这个 hook 时补一条最小覆盖 `{ name, enabled }`—— + * 字段级合并下这就足以关掉/打开上层定义的 hook,而不必复制 address/command 等字段。 + */ +function patchHookEnabled(dir: string, name: string, enabled: boolean): void { + const hooks = readRawHooks(dir); + const idx = hooks.findIndex((h) => h?.name === name); + if (idx === -1) hooks.push({ name, enabled }); + else hooks[idx] = { ...hooks[idx]!, enabled }; + writeHooks(dir, hooks); +} + +export function setGlobalHookEnabled(name: string, enabled: boolean): void { + patchHookEnabled(getGlobalConfigDir(), name, enabled); +} + +export function setProjectHookEnabled(projectRoot: string, name: string, enabled: boolean): void { + patchHookEnabled(join(projectRoot, '.codingcode'), name, enabled); +} diff --git a/packages/codingcode/src/hooks/executor.ts b/packages/codingcode/src/hooks/executor.ts index 6f76d8bf..75568958 100644 --- a/packages/codingcode/src/hooks/executor.ts +++ b/packages/codingcode/src/hooks/executor.ts @@ -1,16 +1,5 @@ import { spawn } from 'child_process'; -const _disabledHookNames = new Set(); - -export function setHookRuntimeEnabled(name: string, enabled: boolean): void { - if (enabled) _disabledHookNames.delete(name); - else _disabledHookNames.add(name); -} - -export function isHookRuntimeEnabled(name: string): boolean { - return !_disabledHookNames.has(name); -} - export async function executeHookCommand( config: { command: string; args?: string[]; env?: Record }, payload: Record diff --git a/packages/codingcode/src/hooks/hooks.ts b/packages/codingcode/src/hooks/hooks.ts index f1923901..1fa7024b 100644 --- a/packages/codingcode/src/hooks/hooks.ts +++ b/packages/codingcode/src/hooks/hooks.ts @@ -1,14 +1,10 @@ import { Layer, Effect } from 'effect'; -import { resolveHookConfigs, resolveHookDisabled } from './config.js'; -import { - executeHookCommand, - executeDecisionHookCommand, - isHookRuntimeEnabled, -} from './executor.js'; +import { resolveHookConfigs } from './config.js'; +import { executeHookCommand, executeDecisionHookCommand } from './executor.js'; import { createLogger } from '@codingcode/infra/logger'; import { HookService } from './port.js'; import type { HookPoint, HookDecision } from '../contracts/hooks.js'; -import type { ObserverHandler, DecisionHandler, HandlerEntry, ProjectPath, SessionId, HookName } from './types.js'; +import type { ObserverHandler, DecisionHandler, HandlerEntry, ProjectPath, SessionId } from './types.js'; const logger = createLogger(); @@ -17,8 +13,6 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { const globalHooks = new Map(); const hooksByProject = new Map>(); const hooksBySession = new Map>(); - const disabledHooksByProject = new Map>(); - const disabledHooksBySession = new Map>(); function getMapForScope( projectPath?: string, @@ -68,12 +62,6 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { return sortedEntries(point, result); } - function isHookDisabled(name: string, projectPath?: string, sessionId?: string): boolean { - if (sessionId && disabledHooksBySession.get(sessionId)?.has(name)) return true; - if (projectPath && resolveHookDisabled(projectPath, name)) return true; - return false; - } - return { register: ( point: HookPoint, @@ -125,8 +113,6 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { return Effect.gen(function* () { for (const entry of allHandlers(point, projectPath, sessionId)) { if (entry.type === 'observer') { - const name = entry.id; - if (isHookDisabled(name, projectPath, sessionId)) continue; const result = entry.handler(payload); if (result == null) { continue; @@ -157,8 +143,6 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { return Effect.promise(async () => { for (const entry of allHandlers(point, projectPath, sessionId)) { if (entry.type === 'decision') { - const name = entry.id; - if (isHookDisabled(name, projectPath, sessionId)) continue; try { const result = await (entry.handler as DecisionHandler)(payload); if (result != null) return result; @@ -182,25 +166,22 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { hooksByProject.delete(projectPath); const projectMap = new Map(); for (const hc of resolveHookConfigs(projectPath)) { - if (resolveHookDisabled(projectPath, hc.name)) continue; + // 开关就是配置里的 enabled 字段:被禁用的 hook 不注册 + if (hc.enabled === false) continue; const hookName = hc.name; - const observerHandler: ObserverHandler = (payload) => { - if (!isHookRuntimeEnabled(hookName)) return; - return Effect.tryPromise({ + const observerHandler: ObserverHandler = (payload) => + Effect.tryPromise({ try: () => executeHookCommand(hc, payload), catch: (e) => logger.error(`user hook ${hookName} error:`, e), }).pipe(Effect.ignore); - }; - const decisionHandler: DecisionHandler = (payload) => { - if (!isHookRuntimeEnabled(hookName)) return null; - return Effect.tryPromise({ + const decisionHandler: DecisionHandler = (payload) => + Effect.tryPromise({ try: () => executeDecisionHookCommand(hc, payload), catch: (e) => { logger.error(`user decision hook ${hookName} error:`, e); return null; }, }) as unknown as Promise; - }; const entry: HandlerEntry = { id: `${hc.type === 'observer' ? 'obs' : 'dec'}-${++entryCounter}`, handler: hc.type === 'observer' ? observerHandler : decisionHandler, @@ -218,7 +199,6 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { disposeSession: (sessionId: string): Effect.Effect => Effect.sync(() => { hooksBySession.delete(sessionId); - disabledHooksBySession.delete(sessionId); }), }; })); diff --git a/packages/codingcode/src/mcp/config.ts b/packages/codingcode/src/mcp/config.ts index 9a13bf65..568062ff 100644 --- a/packages/codingcode/src/mcp/config.ts +++ b/packages/codingcode/src/mcp/config.ts @@ -3,7 +3,6 @@ import { join } from 'path'; import { homedir } from 'os'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import type { McpServerConfig } from '../contracts/mcp.js'; -import { createDisabledStore } from '@codingcode/infra/disabled-store'; function resolveEnvVars(value: unknown): unknown { if (typeof value === 'string') { @@ -29,77 +28,105 @@ export function _setGlobalConfigDir(dir: string | undefined): void { _globalConfigDirOverride = dir; } -function mergeByName(global: T[], project: T[]): T[] { +/** 丢掉值为 undefined 的键,避免下层未显式写的字段被擦掉 */ +function definedOnly(obj: T): Partial { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial; +} + +/** 按 name 做字段级合并:项目层只覆盖它显式写出的字段,其余继承全局 */ +function mergeConfigs(global: T[], project: T[]): T[] { const map = new Map(); - for (const item of global) map.set(item.name, item); - for (const item of project) map.set(item.name, item); + for (const item of global) map.set(item.name, { ...item }); + for (const item of project) { + const base = map.get(item.name); + map.set(item.name, base ? { ...base, ...definedOnly(item) } : { ...item }); + } return Array.from(map.values()); } -export function loadMcpConfig(projectRoot: string): McpServerConfig[] { - const paths = [ - join(projectRoot, '.codingcode', 'mcp.yaml'), - join(projectRoot, '.codingcode', 'mcp.yml'), - ]; - for (const p of paths) { - if (existsSync(p)) { - const raw = readFileSync(p, 'utf8'); - const parsed = parseYaml(raw) as { servers?: McpServerConfig[] }; - return (parsed.servers ?? []).map((s) => resolveEnvVars(s) as McpServerConfig); - } +/** 该目录下实际生效的配置文件;都不存在时给出默认写入位置 */ +function mcpConfigPath(dir: string): string { + const candidates = [join(dir, 'mcp.yaml'), join(dir, 'mcp.yml')]; + return candidates.find((p) => existsSync(p)) ?? candidates[0]!; +} + +/** 读原始条目:不解析 ${VAR},写路径必须用这个,否则会把解析后的值固化回文件 */ +function readRawServers(dir: string): McpServerConfig[] { + const p = mcpConfigPath(dir); + if (!existsSync(p)) return []; + try { + const parsed = parseYaml(readFileSync(p, 'utf8')) as { servers?: McpServerConfig[] } | null; + return parsed?.servers ?? []; + } catch { + return []; } - return []; } -export function writeMcpConfig(projectRoot: string, servers: McpServerConfig[]): void { - const dir = join(projectRoot, '.codingcode'); +function writeServers(dir: string, servers: McpServerConfig[]): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'mcp.yaml'); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; + const p = mcpConfigPath(dir); + let existing: Record = {}; + if (existsSync(p)) { + existing = (parseYaml(readFileSync(p, 'utf8')) as Record) ?? {}; + } existing.servers = servers; writeFileSync(p, stringifyYaml(existing), 'utf8'); } +export function loadMcpConfig(projectRoot: string): McpServerConfig[] { + return readRawServers(join(projectRoot, '.codingcode')).map( + (s) => resolveEnvVars(s) as McpServerConfig + ); +} + +export function writeMcpConfig(projectRoot: string, servers: McpServerConfig[]): void { + writeServers(join(projectRoot, '.codingcode'), servers); +} + export function loadGlobalMcpConfig(): McpServerConfig[] { - const paths = [join(getGlobalConfigDir(), 'mcp.yaml'), join(getGlobalConfigDir(), 'mcp.yml')]; - for (const p of paths) { - if (existsSync(p)) { - const raw = readFileSync(p, 'utf8'); - const parsed = parseYaml(raw) as { servers?: McpServerConfig[] }; - return (parsed.servers ?? []).map((s) => resolveEnvVars(s) as McpServerConfig); - } - } - return []; + return readRawServers(getGlobalConfigDir()).map((s) => resolveEnvVars(s) as McpServerConfig); } export function writeGlobalMcpConfig(servers: McpServerConfig[]): void { - const dir = getGlobalConfigDir(); + writeServers(getGlobalConfigDir(), servers); +} + +export function resolveMcpConfig(projectRoot: string): McpServerConfig[] { + return mergeConfigs(loadGlobalMcpConfig(), loadMcpConfig(projectRoot)); +} + +// ---- MCP 开关 ---- +// 开关就是定义里的一个布尔字段 `enabled`,没有独立的开关状态存储。 +// 改开关 = 改写某一层 mcp.yaml 里该 server 的 `enabled`。 + +/** + * 就地改写某一层配置文件里某个 server 的 `enabled`。 + * 该层还没有这个 server 时补一条最小覆盖 `{ name, enabled }`—— + * 字段级合并下这就足以关掉/打开上层定义的 server,而不必复制它的其它字段。 + */ +function patchServerEnabled(dir: string, name: string, enabled: boolean): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'mcp.yaml'); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; + const p = mcpConfigPath(dir); + let existing: Record = {}; + if (existsSync(p)) { + existing = (parseYaml(readFileSync(p, 'utf8')) as Record) ?? {}; + } + const servers = (existing.servers as Array> | undefined) ?? []; + const idx = servers.findIndex((s) => s?.name === name); + if (idx === -1) servers.push({ name, enabled }); + else servers[idx] = { ...servers[idx], enabled }; existing.servers = servers; writeFileSync(p, stringifyYaml(existing), 'utf8'); } -export function resolveMcpConfig(projectRoot: string): McpServerConfig[] { - const globalServers = loadGlobalMcpConfig(); - const projectServers = loadMcpConfig(projectRoot); - return mergeByName(globalServers, projectServers); -} - -// ---- MCP disabled state ---- - -const mcpDisabledStore = createDisabledStore({ - globalKeyPath: ['mcp', 'disabledServers'], - getGlobalConfigDir, -}); -export const getGlobalMcpDisabledState = mcpDisabledStore.getGlobal; -export const setGlobalMcpDisabledState = mcpDisabledStore.setGlobal; -export const getProjectMcpDisabledState = mcpDisabledStore.getProject; -export const setProjectMcpDisabledState = mcpDisabledStore.setProject; -export const resetProjectMcpDisabledState = mcpDisabledStore.resetProject; -export const resolveMcpDisabled = mcpDisabledStore.resolve; +export function setGlobalMcpServerEnabled(name: string, enabled: boolean): void { + patchServerEnabled(getGlobalConfigDir(), name, enabled); +} + +export function setProjectMcpServerEnabled( + projectRoot: string, + name: string, + enabled: boolean +): void { + patchServerEnabled(join(projectRoot, '.codingcode'), name, enabled); +} diff --git a/packages/codingcode/src/mcp/mcp.ts b/packages/codingcode/src/mcp/mcp.ts index 4777f3f5..c7c1d313 100644 --- a/packages/codingcode/src/mcp/mcp.ts +++ b/packages/codingcode/src/mcp/mcp.ts @@ -1,5 +1,5 @@ import { Effect, Layer } from 'effect'; -import { resolveMcpConfig, resolveMcpDisabled } from './config.js'; +import { resolveMcpConfig } from './config.js'; import { McpClient } from './client.js'; import { McpService } from './port.js'; import type { McpServerConfig, McpStatus, McpToolSpec } from '../contracts/mcp.js'; @@ -32,16 +32,6 @@ type ServerName = string; export const McpLayer = Layer.effect(McpService, Effect.sync(() => { const clientsByProject = new Map>(); const leasesBySession = new Map>(); - const disabledMcpByProject = new Map>(); - const configCache = new Map(); - - function getConfig(projectPath: string): McpServerConfig[] { - const cached = configCache.get(projectPath); - if (cached) return cached; - const configs = resolveMcpConfig(projectPath); - configCache.set(projectPath, configs); - return configs; - } function getProjectClients(projectPath: string): Map { let map = clientsByProject.get(projectPath); @@ -52,8 +42,16 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { return map; } - function isDisabled(projectPath: string, serverName: string): boolean { - return resolveMcpDisabled(projectPath, serverName); + /** + * 被禁用的 server 名集合。 + * 开关就是 mcp.yaml 里的 `enabled` 字段,这里每次实时读配置:改开关即落盘,落盘即生效。 + */ + function disabledServerNames(projectPath: string): Set { + return new Set( + resolveMcpConfig(projectPath) + .filter((c) => c.enabled === false) + .map((c) => c.name) + ); } function doConnect( @@ -183,7 +181,6 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { syncConnections: (projectPath: string): Effect.Effect => Effect.gen(function* () { const configs = resolveMcpConfig(projectPath); - configCache.set(projectPath, configs); const configNames = new Set(configs.map((c) => c.name)); const projectClients = clientsByProject.get(projectPath); @@ -196,6 +193,10 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { } for (const cfg of configs) { + if (cfg.enabled === false) { + yield* doDisconnect(projectPath, cfg.name, true); + continue; + } yield* doConnect(cfg, projectPath, false); } }), @@ -206,7 +207,7 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { names: string[] ): Effect.Effect => Effect.gen(function* () { - const configs = getConfig(projectPath); + const configs = resolveMcpConfig(projectPath); const configMap = new Map(configs.map((c) => [c.name, c])); for (const name of names) { @@ -217,6 +218,7 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { ); continue; } + if (cfg.enabled === false) continue; yield* doConnect(cfg, projectPath, true, sessionId); } }), @@ -244,12 +246,14 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { Effect.sync(() => { const projectClients = clientsByProject.get(projectPath); if (!projectClients) return []; + const disabled = disabledServerNames(projectPath); const specs: McpToolSpec[] = []; for (const [serverName, entry] of projectClients) { + if (disabled.has(serverName)) continue; for (const raw of entry.rawTools) { specs.push( mcpToolToSpec(serverName, raw, entry.client, () => - isDisabled(projectPath, serverName) + disabledServerNames(projectPath).has(serverName) ) ); } @@ -264,7 +268,6 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { return Array.from(projectClients.entries()).map(([name, entry]) => ({ name, connected: entry.client.connected, - disabled: isDisabled(projectPath, name), toolCount: entry.rawTools.length, transport: entry.client.transportType, reconnectAttempts: 0, @@ -272,21 +275,6 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { })); }), - disable: (projectPath: string, name: string): Effect.Effect => - Effect.sync(() => { - let set = disabledMcpByProject.get(projectPath); - if (!set) { - set = new Set(); - disabledMcpByProject.set(projectPath, set); - } - set.add(name); - }), - - enable: (projectPath: string, name: string): Effect.Effect => - Effect.sync(() => { - disabledMcpByProject.get(projectPath)?.delete(name); - }), - disposeSession: (sessionId: string): Effect.Effect => Effect.gen(function* () { const leases = leasesBySession.get(sessionId); @@ -313,8 +301,6 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { yield* doDisconnect(projectPath, name, true); } clientsByProject.delete(projectPath); - disabledMcpByProject.delete(projectPath); - configCache.delete(projectPath); }), }; } diff --git a/packages/codingcode/src/mcp/port.ts b/packages/codingcode/src/mcp/port.ts index 7ded6c98..19f5e559 100644 --- a/packages/codingcode/src/mcp/port.ts +++ b/packages/codingcode/src/mcp/port.ts @@ -9,8 +9,6 @@ export interface McpShape { getServerToolNames(projectPath: string, name: string): string[]; listProjectMcpTools(projectPath: string): Effect.Effect; status(projectPath: string): Effect.Effect; - disable(projectPath: string, name: string): Effect.Effect; - enable(projectPath: string, name: string): Effect.Effect; disposeSession(sessionId: string): Effect.Effect; disposeProject(projectPath: string): Effect.Effect; } diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index f012b535..590bff41 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -11,11 +11,8 @@ import { loadGlobalMcpConfig, writeGlobalMcpConfig, resolveMcpConfig, - resolveMcpDisabled, - getGlobalMcpDisabledState, - setGlobalMcpDisabledState, - setProjectMcpDisabledState, - resetProjectMcpDisabledState, + setGlobalMcpServerEnabled, + setProjectMcpServerEnabled, } from '../../mcp/config.js'; import { loadHookConfigs, @@ -23,12 +20,9 @@ import { loadGlobalHookConfigs, writeGlobalHookConfigs, resolveHookConfigs, - resolveHookDisabled, - setGlobalHookDisabledState, - setProjectHookDisabledState, - resetProjectHookDisabledState, + setGlobalHookEnabled, + setProjectHookEnabled, } from '../../hooks/config.js'; -import { setHookRuntimeEnabled } from '../../hooks/executor.js'; import { discoverGlobalSkillDirs, discoverProjectSkillDirs } from '../../skills/source.js'; import { getMemoryConfig } from '../../memory/config.js'; import { @@ -171,6 +165,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi return c.json( loadGlobalHookConfigs().map((h) => ({ ...h, + enabled: h.enabled !== false, source: 'global' as const, })) ); @@ -188,9 +183,9 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi const hasProjectOverride = isFromProject && isFromGlobal; return { ...h, + enabled: h.enabled !== false, source: isFromProject ? 'project' : 'global', hasProjectOverride, - disabled: resolveHookDisabled(cwd, h.name), }; }) ); @@ -254,39 +249,15 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi return c.json({ ok: true }); }); - router.post('/api/settings/hooks/:name/disabled', async (c) => { + // 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 hooks.yaml + router.post('/api/settings/hooks/:name/enabled', async (c) => { const name = c.req.param('name'); - const body = (await c.req.json()) as { disabled: boolean }; + const body = (await c.req.json()) as { enabled: boolean }; const rawCwd = c.req.query('cwd'); if (isGlobalCwd(rawCwd)) { - setGlobalHookDisabledState(name, body.disabled); - setHookRuntimeEnabled(name, !body.disabled); - const hooks = loadGlobalHookConfigs(); - const hook = hooks.find((h) => h.name === name); - if (hook) { - hook.enabled = !body.disabled; - writeGlobalHookConfigs(hooks); - } + setGlobalHookEnabled(name, body.enabled); } else { - const cwd = resolveCwd(rawCwd); - setProjectHookDisabledState(cwd, name, body.disabled); - setHookRuntimeEnabled(name, !body.disabled); - const hooks = loadHookConfigs(cwd); - const hook = hooks.find((h) => h.name === name); - if (hook) { - hook.enabled = !body.disabled; - writeHookConfigs(cwd, hooks); - } - } - return c.json({ ok: true }); - }); - - router.post('/api/settings/hooks/:name/disabled/reset', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - // 全局态只有全局开关,没有项目覆盖可重置 - if (!isGlobalCwd(rawCwd)) { - resetProjectHookDisabledState(resolveCwd(rawCwd), name); + setProjectHookEnabled(resolveCwd(rawCwd), name, body.enabled); } return c.json({ ok: true }); }); @@ -298,7 +269,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi return c.json( loadGlobalMcpConfig().map((s) => ({ ...s, - disabled: getGlobalMcpDisabledState(s.name), + enabled: s.enabled !== false, source: 'global' as const, })) ); @@ -316,7 +287,7 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi const hasProjectOverride = isFromProject && isFromGlobal; return { ...s, - disabled: resolveMcpDisabled(cwd, s.name), + enabled: s.enabled !== false, source: isFromProject ? 'project' : 'global', hasProjectOverride, }; @@ -382,24 +353,15 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi return c.json({ ok: true }); }); - router.post('/api/settings/mcp/:name/disabled', async (c) => { + // 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 mcp.yaml + router.post('/api/settings/mcp/:name/enabled', async (c) => { const name = c.req.param('name'); const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as { disabled: boolean }; + const body = (await c.req.json()) as { enabled: boolean }; if (isGlobalCwd(rawCwd)) { - setGlobalMcpDisabledState(name, body.disabled); + setGlobalMcpServerEnabled(name, body.enabled); } else { - setProjectMcpDisabledState(resolveCwd(rawCwd), name, body.disabled); - } - return c.json({ ok: true }); - }); - - router.post('/api/settings/mcp/:name/disabled/reset', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - // 全局态只有全局开关,没有项目覆盖可重置 - if (!isGlobalCwd(rawCwd)) { - resetProjectMcpDisabledState(resolveCwd(rawCwd), name); + setProjectMcpServerEnabled(resolveCwd(rawCwd), name, body.enabled); } return c.json({ ok: true }); }); diff --git a/packages/codingcode/test/hooks/config-merge.test.ts b/packages/codingcode/test/hooks/config-merge.test.ts index 41b98c11..7eecc9da 100644 --- a/packages/codingcode/test/hooks/config-merge.test.ts +++ b/packages/codingcode/test/hooks/config-merge.test.ts @@ -1,153 +1,135 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, mkdirSync, rmSync } from 'fs'; +import { mkdtempSync, readFileSync, mkdirSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +import { parse as parseYaml } from 'yaml'; import { loadHookConfigs, writeHookConfigs, loadGlobalHookConfigs, writeGlobalHookConfigs, resolveHookConfigs, - getGlobalHookDisabledState, - setGlobalHookDisabledState, - getProjectHookDisabledState, - setProjectHookDisabledState, - resetProjectHookDisabledState, - resolveHookDisabled, + setGlobalHookEnabled, + setProjectHookEnabled, _setGlobalConfigDir, } from '../../src/hooks/config.js'; +import type { UserHookConfig } from '../../src/contracts/hooks.js'; let projectDir: string; let globalDir: string; -describe('Hooks config merge', () => { - beforeEach(() => { - projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-project-')); - globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-global-')); - mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); - mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); - _setGlobalConfigDir(globalDir); - }); +function readYaml(p: string): any { + return parseYaml(readFileSync(p, 'utf8')); +} - afterEach(() => { - _setGlobalConfigDir(undefined); - rmSync(projectDir, { recursive: true, force: true }); - rmSync(globalDir, { recursive: true, force: true }); - }); +function hook(name: string, command: string): UserHookConfig { + return { name, point: 'tool.execute.before', type: 'observer', command }; +} - it('should merge global and project hooks, project overrides global', () => { - // Write global hooks - const globalHooks = [ - { - name: 'global-hook', - point: 'tool.execute.before' as const, - type: 'observer' as const, - command: 'global-cmd', - enabled: true, - }, - { - name: 'shared-hook', - point: 'tool.execute.after' as const, - type: 'observer' as const, - command: 'global-shared-cmd', - enabled: true, - }, - ]; - writeGlobalHookConfigs(globalHooks); +beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-project-')); + globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-global-')); + mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); + mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); + _setGlobalConfigDir(globalDir); +}); - // Write project hooks - const projectHooks = [ - { - name: 'shared-hook', - point: 'tool.execute.after' as const, - type: 'observer' as const, - command: 'project-shared-cmd', - enabled: true, - }, - { - name: 'project-hook', - point: 'tool.execute.error' as const, - type: 'observer' as const, - command: 'project-cmd', - enabled: true, - }, - ]; - writeHookConfigs(projectDir, projectHooks); +afterEach(() => { + _setGlobalConfigDir(undefined); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); +}); + +describe('Hooks config merge', () => { + it('merges global and project by name, project wins', () => { + writeGlobalHookConfigs([hook('global-hook', 'global-cmd'), hook('shared-hook', 'global-shared')]); + writeHookConfigs(projectDir, [ + hook('shared-hook', 'project-shared'), + hook('project-hook', 'project-cmd'), + ]); const merged = resolveHookConfigs(projectDir); expect(merged).toHaveLength(3); + expect(merged.find((h) => h.name === 'global-hook')!.command).toBe('global-cmd'); + expect(merged.find((h) => h.name === 'shared-hook')!.command).toBe('project-shared'); + expect(merged.find((h) => h.name === 'project-hook')!.command).toBe('project-cmd'); + }); - const globalHook = merged.find((h) => h.name === 'global-hook'); - expect(globalHook).toBeDefined(); - expect(globalHook!.command).toBe('global-cmd'); + it('project layer only overrides the fields it declares', () => { + writeGlobalHookConfigs([hook('shared', 'global-cmd')]); + // 项目层只写了 name + enabled + writeHookConfigs(projectDir, [{ name: 'shared', enabled: false } as UserHookConfig]); - const sharedHook = merged.find((h) => h.name === 'shared-hook'); - expect(sharedHook).toBeDefined(); - expect(sharedHook!.command).toBe('project-shared-cmd'); // project overrides global + const merged = resolveHookConfigs(projectDir); - const projectHook = merged.find((h) => h.name === 'project-hook'); - expect(projectHook).toBeDefined(); - expect(projectHook!.command).toBe('project-cmd'); + expect(merged).toHaveLength(1); + expect(merged[0]!.command).toBe('global-cmd'); + expect(merged[0]!.point).toBe('tool.execute.before'); + expect(merged[0]!.enabled).toBe(false); }); }); -describe('Hook disabled state', () => { - const testHook = '__test_hook__'; +describe('Hook enabled switch (a plain boolean field in hooks.yaml)', () => { + it('absent field means enabled', () => { + writeGlobalHookConfigs([hook('a', 'x')]); - beforeEach(() => { - projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-project-')); - globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-global-')); - mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); - mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); - _setGlobalConfigDir(globalDir); - setGlobalHookDisabledState(testHook, false); + const merged = resolveHookConfigs(projectDir); + expect(merged[0]!.enabled).toBeUndefined(); + expect(merged[0]!.enabled !== false).toBe(true); }); - afterEach(() => { - _setGlobalConfigDir(undefined); - rmSync(projectDir, { recursive: true, force: true }); - rmSync(globalDir, { recursive: true, force: true }); - }); + it('writes the boolean to the global hooks.yaml', () => { + writeGlobalHookConfigs([hook('a', 'x')]); - it('should default to not disabled globally', () => { - expect(getGlobalHookDisabledState(testHook)).toBe(false); - }); + setGlobalHookEnabled('a', false); - it('should persist global disabled state', () => { - setGlobalHookDisabledState(testHook, true); - expect(getGlobalHookDisabledState(testHook)).toBe(true); + expect(readYaml(join(globalDir, 'hooks.yaml')).hooks[0]).toEqual({ + name: 'a', + point: 'tool.execute.before', + type: 'observer', + command: 'x', + enabled: false, + }); + expect(loadGlobalHookConfigs()[0]!.enabled).toBe(false); }); - it('should return undefined when project has no config', () => { - expect(getProjectHookDisabledState(projectDir, testHook)).toBe(undefined); - }); + it('project toggle writes a minimal override that wins over the global definition', () => { + writeGlobalHookConfigs([hook('a', 'x')]); - it('should persist project-level disabled state', () => { - setProjectHookDisabledState(projectDir, testHook, true); - expect(getProjectHookDisabledState(projectDir, testHook)).toBe(true); - }); + setProjectHookEnabled(projectDir, 'a', false); - it('should reset project-level disabled state', () => { - setProjectHookDisabledState(projectDir, testHook, true); - resetProjectHookDisabledState(projectDir, testHook); - expect(getProjectHookDisabledState(projectDir, testHook)).toBe(undefined); + expect(readYaml(join(projectDir, '.codingcode', 'hooks.yaml')).hooks).toEqual([ + { name: 'a', enabled: false }, + ]); + const merged = resolveHookConfigs(projectDir); + expect(merged).toHaveLength(1); + expect(merged[0]!.enabled).toBe(false); + expect(merged[0]!.command).toBe('x'); }); - it('resolveHookDisabled should use project-level when set', () => { - setGlobalHookDisabledState(testHook, false); - setProjectHookDisabledState(projectDir, testHook, true); - expect(resolveHookDisabled(projectDir, testHook)).toBe(true); - }); + it('patches an existing project entry in place', () => { + writeHookConfigs(projectDir, [hook('a', 'proj-cmd')]); + + setProjectHookEnabled(projectDir, 'a', false); - it('resolveHookDisabled should fall back to global when project not set', () => { - setGlobalHookDisabledState(testHook, true); - expect(resolveHookDisabled(projectDir, testHook)).toBe(true); + expect(readYaml(join(projectDir, '.codingcode', 'hooks.yaml')).hooks).toEqual([ + { + name: 'a', + point: 'tool.execute.before', + type: 'observer', + command: 'proj-cmd', + enabled: false, + }, + ]); }); - it('resolveHookDisabled should use project-level enabled over global disabled', () => { - setGlobalHookDisabledState(testHook, true); - setProjectHookDisabledState(projectDir, testHook, false); - expect(resolveHookDisabled(projectDir, testHook)).toBe(false); + it('round-trips back to enabled', () => { + writeGlobalHookConfigs([hook('a', 'x')]); + setGlobalHookEnabled('a', false); + setGlobalHookEnabled('a', true); + + expect(loadHookConfigs(projectDir)).toEqual([]); + expect(loadGlobalHookConfigs()[0]!.enabled).toBe(true); }); }); diff --git a/packages/codingcode/test/hooks/executor.test.ts b/packages/codingcode/test/hooks/executor.test.ts deleted file mode 100644 index a5a781f9..00000000 --- a/packages/codingcode/test/hooks/executor.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { setHookRuntimeEnabled, isHookRuntimeEnabled } from '../../src/hooks/executor.js'; - -describe('hook runtime enabled toggle', () => { - it('should default to enabled', () => { - expect(isHookRuntimeEnabled('my-hook')).toBe(true); - }); - - it('should disable a hook by name', () => { - setHookRuntimeEnabled('my-hook', false); - expect(isHookRuntimeEnabled('my-hook')).toBe(false); - }); - - it('should re-enable a hook', () => { - setHookRuntimeEnabled('my-hook', true); - expect(isHookRuntimeEnabled('my-hook')).toBe(true); - }); - - it('should not affect other hooks', () => { - setHookRuntimeEnabled('hook-a', false); - setHookRuntimeEnabled('hook-b', true); - expect(isHookRuntimeEnabled('hook-a')).toBe(false); - expect(isHookRuntimeEnabled('hook-b')).toBe(true); - }); -}); diff --git a/packages/codingcode/test/mcp/config-merge.test.ts b/packages/codingcode/test/mcp/config-merge.test.ts index eab433ab..cc0d4a75 100644 --- a/packages/codingcode/test/mcp/config-merge.test.ts +++ b/packages/codingcode/test/mcp/config-merge.test.ts @@ -1,121 +1,81 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'; +import { mkdtempSync, readFileSync, mkdirSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +import { parse as parseYaml } from 'yaml'; import { loadMcpConfig, writeMcpConfig, loadGlobalMcpConfig, writeGlobalMcpConfig, resolveMcpConfig, - getGlobalMcpDisabledState, - setGlobalMcpDisabledState, - getProjectMcpDisabledState, - setProjectMcpDisabledState, - resetProjectMcpDisabledState, - resolveMcpDisabled, + setGlobalMcpServerEnabled, + setProjectMcpServerEnabled, _setGlobalConfigDir, } from '../../src/mcp/config.js'; let projectDir: string; let globalDir: string; -describe('MCP config merge', () => { - beforeEach(() => { - projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-project-')); - globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-global-')); - mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); - mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); - _setGlobalConfigDir(globalDir); - }); +function readYaml(p: string): any { + return parseYaml(readFileSync(p, 'utf8')); +} - afterEach(() => { - _setGlobalConfigDir(undefined); - rmSync(projectDir, { recursive: true, force: true }); - rmSync(globalDir, { recursive: true, force: true }); - }); +beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-project-')); + globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-global-')); + mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); + mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); + _setGlobalConfigDir(globalDir); +}); - it('should merge global and project configs, project overrides global', () => { - // Write global config +afterEach(() => { + _setGlobalConfigDir(undefined); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); +}); + +describe('MCP config merge', () => { + it('merges global and project by name, project wins', () => { writeGlobalMcpConfig([ - { - name: 'global-server', - transport: 'stdio', - command: 'global-cmd', - disabled: false, - toolCount: 0, - } as any, - { - name: 'shared-server', - transport: 'stdio', - command: 'global-shared-cmd', - disabled: false, - toolCount: 0, - } as any, + { name: 'global-server', command: 'global-cmd' }, + { name: 'shared-server', command: 'global-shared-cmd' }, ]); - - // Write project config writeMcpConfig(projectDir, [ - { - name: 'shared-server', - transport: 'stdio', - command: 'project-shared-cmd', - disabled: false, - toolCount: 0, - } as any, - { - name: 'project-server', - transport: 'stdio', - command: 'project-cmd', - disabled: false, - toolCount: 0, - } as any, + { name: 'shared-server', command: 'project-shared-cmd' }, + { name: 'project-server', command: 'project-cmd' }, ]); const merged = resolveMcpConfig(projectDir); - // Should have 3 servers: global-server, shared-server (project override), project-server expect(merged).toHaveLength(3); + expect(merged.find((s) => s.name === 'global-server')!.command).toBe('global-cmd'); + expect(merged.find((s) => s.name === 'shared-server')!.command).toBe('project-shared-cmd'); + expect(merged.find((s) => s.name === 'project-server')!.command).toBe('project-cmd'); + }); - const globalServer = merged.find((s) => s.name === 'global-server'); - expect(globalServer).toBeDefined(); - expect((globalServer as any).command).toBe('global-cmd'); + it('project layer only overrides the fields it declares', () => { + writeGlobalMcpConfig([{ name: 'shared', command: 'global-cmd', args: ['--a'], concurrency: 5 }]); + writeMcpConfig(projectDir, [{ name: 'shared', command: 'project-cmd' }]); - const sharedServer = merged.find((s) => s.name === 'shared-server'); - expect(sharedServer).toBeDefined(); - expect((sharedServer as any).command).toBe('project-shared-cmd'); // project overrides global + const merged = resolveMcpConfig(projectDir); - const projectServer = merged.find((s) => s.name === 'project-server'); - expect(projectServer).toBeDefined(); - expect((projectServer as any).command).toBe('project-cmd'); + expect(merged).toHaveLength(1); + expect(merged[0]!.command).toBe('project-cmd'); + expect(merged[0]!.args).toEqual(['--a']); + expect(merged[0]!.concurrency).toBe(5); }); - it('should return only project config when no global config', () => { - writeMcpConfig(projectDir, [ - { - name: 'project-server', - transport: 'stdio', - command: 'project-cmd', - disabled: false, - toolCount: 0, - } as any, - ]); + it('returns only project config when no global config', () => { + writeMcpConfig(projectDir, [{ name: 'project-server', command: 'project-cmd' }]); const merged = resolveMcpConfig(projectDir); expect(merged).toHaveLength(1); expect(merged[0]!.name).toBe('project-server'); }); - it('should return only global config when no project config', () => { - writeGlobalMcpConfig([ - { - name: 'global-server', - transport: 'stdio', - command: 'global-cmd', - disabled: false, - toolCount: 0, - } as any, - ]); + it('returns only global config when no project config', () => { + writeGlobalMcpConfig([{ name: 'global-server', command: 'global-cmd' }]); const merged = resolveMcpConfig(projectDir); expect(merged).toHaveLength(1); @@ -123,62 +83,56 @@ describe('MCP config merge', () => { }); }); -describe('MCP disabled state', () => { - const testServer = '__test_mcp_server__'; +describe('MCP enabled switch (a plain boolean field in mcp.yaml)', () => { + it('absent field means enabled', () => { + writeGlobalMcpConfig([{ name: 'a', command: 'x' }]); - beforeEach(() => { - projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-project-')); - globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-global-')); - mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); - mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); - _setGlobalConfigDir(globalDir); - setGlobalMcpDisabledState(testServer, false); + const merged = resolveMcpConfig(projectDir); + expect(merged[0]!.enabled).toBeUndefined(); + expect(merged[0]!.enabled !== false).toBe(true); }); - afterEach(() => { - _setGlobalConfigDir(undefined); - rmSync(projectDir, { recursive: true, force: true }); - rmSync(globalDir, { recursive: true, force: true }); - }); + it('writes the boolean to the global mcp.yaml', () => { + writeGlobalMcpConfig([{ name: 'a', command: 'x' }]); - it('should default to not disabled globally', () => { - expect(getGlobalMcpDisabledState(testServer)).toBe(false); - }); + setGlobalMcpServerEnabled('a', false); - it('should persist global disabled state', () => { - setGlobalMcpDisabledState(testServer, true); - expect(getGlobalMcpDisabledState(testServer)).toBe(true); + expect(readYaml(join(globalDir, 'mcp.yaml')).servers).toEqual([ + { name: 'a', command: 'x', enabled: false }, + ]); + expect(loadGlobalMcpConfig()[0]!.enabled).toBe(false); }); - it('should return undefined when project has no config', () => { - expect(getProjectMcpDisabledState(projectDir, testServer)).toBe(undefined); - }); + it('project toggle writes a minimal override that wins over the global definition', () => { + writeGlobalMcpConfig([{ name: 'a', command: 'x' }]); - it('should persist project-level disabled state', () => { - setProjectMcpDisabledState(projectDir, testServer, true); - expect(getProjectMcpDisabledState(projectDir, testServer)).toBe(true); - }); + setProjectMcpServerEnabled(projectDir, 'a', false); - it('should reset project-level disabled state', () => { - setProjectMcpDisabledState(projectDir, testServer, true); - resetProjectMcpDisabledState(projectDir, testServer); - expect(getProjectMcpDisabledState(projectDir, testServer)).toBe(undefined); + expect(readYaml(join(projectDir, '.codingcode', 'mcp.yaml')).servers).toEqual([ + { name: 'a', enabled: false }, + ]); + const merged = resolveMcpConfig(projectDir); + expect(merged).toHaveLength(1); + expect(merged[0]!.enabled).toBe(false); + expect(merged[0]!.command).toBe('x'); }); - it('resolveMcpDisabled should use project-level when set', () => { - setGlobalMcpDisabledState(testServer, false); - setProjectMcpDisabledState(projectDir, testServer, true); - expect(resolveMcpDisabled(projectDir, testServer)).toBe(true); - }); + it('patches an existing project entry in place', () => { + writeMcpConfig(projectDir, [{ name: 'a', command: 'proj-cmd' }]); - it('resolveMcpDisabled should fall back to global when project not set', () => { - setGlobalMcpDisabledState(testServer, true); - expect(resolveMcpDisabled(projectDir, testServer)).toBe(true); + setProjectMcpServerEnabled(projectDir, 'a', false); + + expect(readYaml(join(projectDir, '.codingcode', 'mcp.yaml')).servers).toEqual([ + { name: 'a', command: 'proj-cmd', enabled: false }, + ]); }); - it('resolveMcpDisabled should use project-level enabled over global disabled', () => { - setGlobalMcpDisabledState(testServer, true); - setProjectMcpDisabledState(projectDir, testServer, false); - expect(resolveMcpDisabled(projectDir, testServer)).toBe(false); + it('round-trips back to enabled', () => { + writeGlobalMcpConfig([{ name: 'a', command: 'x' }]); + setGlobalMcpServerEnabled('a', false); + setGlobalMcpServerEnabled('a', true); + + expect(loadMcpConfig(projectDir)).toEqual([]); + expect(loadGlobalMcpConfig()[0]!.enabled).toBe(true); }); }); diff --git a/packages/codingcode/test/mcp/service.test.ts b/packages/codingcode/test/mcp/service.test.ts index aa12ff7d..8e45a979 100644 --- a/packages/codingcode/test/mcp/service.test.ts +++ b/packages/codingcode/test/mcp/service.test.ts @@ -37,7 +37,6 @@ vi.mock('../../src/mcp/client.js', () => { // Mock loadMcpConfig vi.mock('../../src/mcp/config.js', () => ({ resolveMcpConfig: vi.fn(() => []), - resolveMcpDisabled: vi.fn(() => false), })); function makeHookLayer() { diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index d5ef6b43..b8c89e0a 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -152,12 +152,8 @@ export function listMcpServers(cwd?: string): Promise { return clients.settings.getMcpStatus({ cwd: cwd ?? '' }); } -export function setMcpDisabled(name: string, disabled: boolean, cwd?: string): Promise { - return clients.settings.setMcpDisabled({ name, disabled, cwd: cwd ?? '' }); -} - -export function resetMcpDisabled(name: string, cwd: string): Promise { - return clients.settings.resetMcpDisabled({ name, cwd }); +export function setMcpEnabled(name: string, enabled: boolean, cwd?: string): Promise { + return clients.settings.setMcpEnabled({ name, enabled, cwd: cwd ?? '' }); } export function createMcpServer(cwd: string | undefined, server: McpServerConfig): Promise { @@ -212,16 +208,12 @@ export function deleteHook(cwd: string | undefined, name: string): Promise return clients.settings.deleteHook({ cwd: cwd ?? '', name }); } -export function setHookDisabled( +export function setHookEnabled( cwd: string | undefined, name: string, - disabled: boolean + enabled: boolean ): Promise { - return clients.settings.setHookDisabled({ cwd: cwd ?? '', name, disabled }); -} - -export function resetHookDisabled(name: string, cwd: string): Promise { - return clients.settings.resetHookDisabled({ name, cwd }); + return clients.settings.setHookEnabled({ cwd: cwd ?? '', name, enabled }); } // ---- Rollback / Checkpoint ---- diff --git a/packages/desktop/src/settings/HooksPanel.tsx b/packages/desktop/src/settings/HooksPanel.tsx index b3ec55be..b0bc7177 100644 --- a/packages/desktop/src/settings/HooksPanel.tsx +++ b/packages/desktop/src/settings/HooksPanel.tsx @@ -6,8 +6,7 @@ import { createHook, updateHook, deleteHook, - setHookDisabled, - resetHookDisabled, + setHookEnabled, } from '../lib/core-api'; import type { UserHookConfig, HookPoint } from '@codingcode/core/contracts/hooks'; @@ -20,7 +19,7 @@ interface HookEntry { args?: string[]; env?: Record; priority?: number; - enabled: boolean; + enabled?: boolean; source?: 'global' | 'project'; hasProjectOverride?: boolean; } @@ -150,7 +149,7 @@ export default function HooksPanel({ global: isGlobal }: { global?: boolean }) { .join('\n') : '', priority: (h.priority ?? 0).toString(), - enabled: h.enabled, + enabled: h.enabled !== false, }); setEditingName(h.name); setIsCreating(false); @@ -383,10 +382,10 @@ export default function HooksPanel({ global: isGlobal }: { global?: boolean }) { )} { - setHookDisabled(cwd, h.name, !v).catch((e) => { - console.error('Failed to set hook disabled:', e); + setHookEnabled(cwd, h.name, v).catch((e) => { + console.error('Failed to set hook enabled:', e); }); setHooks((prev) => prev.map((hh) => (hh.name === h.name ? { ...hh, enabled: v } : hh)) diff --git a/packages/desktop/src/settings/McpPanel.tsx b/packages/desktop/src/settings/McpPanel.tsx index cca4650a..df75b9a4 100644 --- a/packages/desktop/src/settings/McpPanel.tsx +++ b/packages/desktop/src/settings/McpPanel.tsx @@ -3,8 +3,7 @@ import Toggle from './Toggle'; import { useWorkspaceStore } from '../stores/workspace.store'; import { listMcpServers, - setMcpDisabled, - resetMcpDisabled, + setMcpEnabled, createMcpServer, updateMcpServer, deleteMcpServer, @@ -14,7 +13,7 @@ import type { McpServerConfig } from '@codingcode/core/contracts/mcp'; interface McpEntry { name: string; transport: 'stdio' | 'http'; - disabled: boolean; + enabled: boolean; toolCount: number; source?: 'global' | 'project'; hasProjectOverride?: boolean; @@ -70,9 +69,9 @@ export default function McpPanel({ global: isGlobal }: { global?: boolean }) { load(); }, []); - const toggle = async (name: string, disabled: boolean) => { - await setMcpDisabled(name, disabled, cwd); - setServers((prev) => prev.map((s) => (s.name === name ? { ...s, disabled } : s))); + const toggle = async (name: string, enabled: boolean) => { + await setMcpEnabled(name, enabled, cwd); + setServers((prev) => prev.map((s) => (s.name === name ? { ...s, enabled } : s))); }; const startCreate = () => { @@ -314,7 +313,7 @@ export default function McpPanel({ global: isGlobal }: { global?: boolean }) { )} - toggle(s.name, !v)} /> + toggle(s.name, v)} /> ); })} diff --git a/packages/desktop/test/rollback-usage-reset.test.ts b/packages/desktop/test/rollback-usage-reset.test.ts index fc284999..7de960e1 100644 --- a/packages/desktop/test/rollback-usage-reset.test.ts +++ b/packages/desktop/test/rollback-usage-reset.test.ts @@ -39,8 +39,7 @@ vi.mock('../src/lib/core-api', () => ({ getAgentConfig: vi.fn(), setCompactionModel: vi.fn(), listMcpServers: vi.fn(), - setMcpDisabled: vi.fn(), - resetMcpDisabled: vi.fn(), + setMcpEnabled: vi.fn(), createMcpServer: vi.fn(), updateMcpServer: vi.fn(), deleteMcpServer: vi.fn(), diff --git a/packages/desktop/test/thread-delete.test.ts b/packages/desktop/test/thread-delete.test.ts index 7d113c5c..0e357859 100644 --- a/packages/desktop/test/thread-delete.test.ts +++ b/packages/desktop/test/thread-delete.test.ts @@ -39,8 +39,7 @@ vi.mock('../src/lib/core-api', () => ({ getAgentConfig: vi.fn(), setCompactionModel: vi.fn(), listMcpServers: vi.fn(), - setMcpDisabled: vi.fn(), - resetMcpDisabled: vi.fn(), + setMcpEnabled: vi.fn(), createMcpServer: vi.fn(), updateMcpServer: vi.fn(), deleteMcpServer: vi.fn(), diff --git a/packages/infra/package.json b/packages/infra/package.json index 37a9517c..76715a1a 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -5,8 +5,7 @@ "main": "./src/config.ts", "exports": { "./config": "./src/config.ts", - "./logger": "./src/logger.ts", - "./disabled-store": "./src/disabled-store.ts" + "./logger": "./src/logger.ts" }, "dependencies": { "pino": "^9.6.0", diff --git a/packages/infra/src/disabled-store.ts b/packages/infra/src/disabled-store.ts deleted file mode 100644 index f55e5fff..00000000 --- a/packages/infra/src/disabled-store.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import { dirname, join } from 'path'; -import { homedir } from 'os'; -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; - -export interface DisabledStoreConfig { - globalKeyPath: string[]; - /** Optional function that returns the global config directory. Defaults to ~/.codingcode */ - getGlobalConfigDir?: () => string; -} - -export interface DisabledStore { - getGlobal(name: string): boolean; - setGlobal(name: string, disabled: boolean): void; - getProject(projectRoot: string, name: string): boolean | undefined; - setProject(projectRoot: string, name: string, disabled: boolean): void; - resetProject(projectRoot: string, name: string): void; - resolve(projectRoot: string, name: string): boolean; -} - -function deepSet(obj: Record, path: string[], name: string, value: unknown): void { - let target: any = obj; - for (let i = 0; i < path.length - 1; i++) { - const key = path[i]!; - if (!target[key]) target[key] = {}; - target = target[key]; - if (!target) return; - } - const lastKey = path[path.length - 1]!; - const map = (target[lastKey] as Record) ?? {}; - map[name] = value; - target[lastKey] = map; -} - -function deepGet(obj: any, path: string[]): any { - let value = obj; - for (const k of path) { - value = value?.[k]; - } - return value; -} - -function deepDelete(obj: any, path: string[], name: string): void { - const value = deepGet(obj, path); - if (value && typeof value === 'object') { - delete (value as Record)[name]; - } -} - -/** Parse YAML into an object, defaulting to `{}` for empty/comment-only files (`parseYaml` returns `null`). */ -function parseConfigFile(raw: string): Record { - const parsed = parseYaml(raw); - return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; -} - -export function createDisabledStore(cfg: DisabledStoreConfig): DisabledStore { - const globalConfigPath = () => - join(cfg.getGlobalConfigDir?.() ?? join(homedir(), '.codingcode'), 'config.yaml'); - - const getGlobal = (name: string): boolean => { - const p = globalConfigPath(); - if (!existsSync(p)) return false; - try { - const config = parseYaml(readFileSync(p, 'utf8')) as any; - const value = deepGet(config, cfg.globalKeyPath); - return (value as Record)?.[name] ?? false; - } catch { - return false; - } - }; - - const setGlobal = (name: string, disabled: boolean): void => { - const p = globalConfigPath(); - const dir = dirname(p); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const existing: Record = existsSync(p) - ? parseConfigFile(readFileSync(p, 'utf8')) - : {}; - deepSet(existing, cfg.globalKeyPath, name, disabled); - writeFileSync(p, stringifyYaml(existing), 'utf8'); - }; - - const getProject = (projectRoot: string, name: string): boolean | undefined => { - const p = join(projectRoot, '.codingcode', 'config.yaml'); - if (!existsSync(p)) return undefined; - try { - const config = parseYaml(readFileSync(p, 'utf8')) as Record; - const value = deepGet(config, cfg.globalKeyPath); - return (value as Record)?.[name]; - } catch { - return undefined; - } - }; - - const setProject = (projectRoot: string, name: string, disabled: boolean): void => { - const dir = join(projectRoot, '.codingcode'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'config.yaml'); - const existing: Record = existsSync(p) - ? parseConfigFile(readFileSync(p, 'utf8')) - : {}; - deepSet(existing, cfg.globalKeyPath, name, disabled); - writeFileSync(p, stringifyYaml(existing), 'utf8'); - }; - - const resetProject = (projectRoot: string, name: string): void => { - const p = join(projectRoot, '.codingcode', 'config.yaml'); - if (!existsSync(p)) return; - const existing: Record = parseConfigFile(readFileSync(p, 'utf8')); - deepDelete(existing, cfg.globalKeyPath, name); - writeFileSync(p, stringifyYaml(existing), 'utf8'); - }; - - const resolve = (projectRoot: string, name: string): boolean => { - const pv = getProject(projectRoot, name); - if (pv !== undefined) return pv; - return getGlobal(name); - }; - - return { getGlobal, setGlobal, getProject, setProject, resetProject, resolve }; -} diff --git a/packages/tui/src/components/App.tsx b/packages/tui/src/components/App.tsx index 51d99883..74c09560 100644 --- a/packages/tui/src/components/App.tsx +++ b/packages/tui/src/components/App.tsx @@ -414,7 +414,7 @@ export function App({ client }: AppProps) { panel.servers.length === 0 ? [{ label: '无已配置的 MCP 服务器', value: '' }] : panel.servers.map((s) => ({ - label: `${s.disabled ? '○' : '●'} ${s.name} (${s.disabled ? '已禁用' : `已连接, ${s.toolCount} 个工具`})`, + label: `${!s.enabled ? '○' : '●'} ${s.name} (${!s.enabled ? '已禁用' : `已连接, ${s.toolCount} 个工具`})`, value: s.name, })) } @@ -423,11 +423,7 @@ export function App({ client }: AppProps) { const server = panel.servers.find((s) => s.name === value); if (!server) return; try { - if (server.disabled) { - await client.setMcpDisabled({ name: value, disabled: false, cwd: '' }); - } else { - await client.setMcpDisabled({ name: value, disabled: true, cwd: '' }); - } + await client.setMcpEnabled({ name: value, enabled: !server.enabled, cwd: '' }); const updated = await client.getMcpStatus({ cwd: '' }); setPanel({ type: 'mcp', servers: updated }); } catch { diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index fe2bbe82..60937f5e 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -22,7 +22,7 @@ export interface TuiClient { switchModel(id: string): Promise; listSessions(): Promise; getMcpStatus(query: { cwd: string }): Promise; - setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; + setMcpEnabled(body: { name: string; enabled: boolean; cwd: string }): Promise; listSkills(): Promise>; getPermissionMode(input: { sessionId: string; @@ -63,7 +63,7 @@ export function createTuiClientFromFacades(llm: LLMClient, rt: AppRuntime): TuiC switchModel: (id) => models.switchModel({ id }), listSessions: () => sessions.listSessions({ cwd: '' }), getMcpStatus: (query) => settings.getMcpStatus(query), - setMcpDisabled: (body) => settings.setMcpDisabled(body), + setMcpEnabled: (body) => settings.setMcpEnabled(body), listSkills: () => settings.listSkills({ cwd: '' }), getPermissionMode: (input) => settings.getGlobalPermissionMode(input), setPermissionMode: (input) => settings.setGlobalPermissionMode(input), diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 21670246..4370d6b9 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -27,7 +27,7 @@ export interface PanelItem { export interface McpServerStatus { name: string; connected: boolean; - disabled: boolean; + enabled: boolean; toolCount: number; transport: 'stdio' | 'http'; reconnectAttempts: number; diff --git a/packages/tui/test/components/App.test.tsx b/packages/tui/test/components/App.test.tsx index d96913b7..6701dcd2 100644 --- a/packages/tui/test/components/App.test.tsx +++ b/packages/tui/test/components/App.test.tsx @@ -45,7 +45,7 @@ const client = { switchModel: vi.fn(), listSessions: vi.fn(), getMcpStatus: vi.fn(), - setMcpDisabled: vi.fn(), + setMcpEnabled: vi.fn(), listSkills: vi.fn(), getPermissionMode: vi.fn(), setPermissionMode: vi.fn(), From 7c3b7d9406d9cffc47c89bd97683426d677b7ec9 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 26 Sep 2026 14:39:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/codingcode/src/agent/agent.ts | 5 +- packages/codingcode/src/contracts/mcp.ts | 2 + packages/codingcode/src/contracts/tool.ts | 2 + packages/codingcode/src/mcp/client.ts | 8 +- packages/codingcode/src/mcp/mcp.ts | 5 +- packages/codingcode/src/tools/catalog.ts | 4 +- .../codingcode/src/tools/domains/bash/exec.ts | 1 + .../codingcode/src/tools/domains/fs/edit.ts | 1 + .../codingcode/src/tools/domains/fs/glob.ts | 1 + .../codingcode/src/tools/domains/fs/grep.ts | 1 + .../codingcode/src/tools/domains/fs/read.ts | 1 + .../codingcode/src/tools/domains/fs/write.ts | 1 + .../src/tools/domains/self/todo-write.ts | 1 + .../src/tools/domains/subagent/dispatch.ts | 1 + .../src/tools/domains/subagent/submit-plan.ts | 1 + .../codingcode/src/tools/domains/web/fetch.ts | 1 + .../src/tools/domains/web/search.ts | 1 + packages/codingcode/src/tools/tools.ts | 83 +++++++++---------- packages/codingcode/src/tools/types.ts | 2 + .../codingcode/test/tools/catalog.test.ts | 3 + 20 files changed, 74 insertions(+), 51 deletions(-) diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 03d8e802..e42b54ce 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -324,7 +324,10 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { }) : []; - const allResults = [...approvedResults, ...deniedResults]; + const resultsById = new Map(); + for (const r of approvedResults) resultsById.set(r.id, r); + for (const r of deniedResults) resultsById.set(r.id, r); + const allResults = toolCalls.map((tc) => resultsById.get(tc.id)); let todoPrinted = false; for (const r of allResults) { diff --git a/packages/codingcode/src/contracts/mcp.ts b/packages/codingcode/src/contracts/mcp.ts index d2d93a87..89c0b20b 100644 --- a/packages/codingcode/src/contracts/mcp.ts +++ b/packages/codingcode/src/contracts/mcp.ts @@ -36,5 +36,7 @@ export interface McpToolSpec { name: string; description: string; inputSchema: Record; + /** MCP 注解 readOnlyHint;缺省 false(fail-closed) */ + readOnlyHint: boolean; execute(args: Record): Effect.Effect; } diff --git a/packages/codingcode/src/contracts/tool.ts b/packages/codingcode/src/contracts/tool.ts index 2407af71..7d2746de 100644 --- a/packages/codingcode/src/contracts/tool.ts +++ b/packages/codingcode/src/contracts/tool.ts @@ -13,6 +13,8 @@ export type ToolResult = { readonly id: string; readonly name: string } & ToolOu export interface ToolRunner { readonly name: string; + /** 是否可与同批其它工具并发执行;「未声明」已由装配处收敛,故此处必填 */ + readonly concurrencySafe: boolean; parse(args: unknown): unknown; execute(args: unknown, ctx?: ToolExecCtx): Effect.Effect; } diff --git a/packages/codingcode/src/mcp/client.ts b/packages/codingcode/src/mcp/client.ts index fb7860a7..a995bc6b 100644 --- a/packages/codingcode/src/mcp/client.ts +++ b/packages/codingcode/src/mcp/client.ts @@ -100,7 +100,12 @@ export class McpClient { } async listTools(): Promise< - Array<{ name: string; description: string; inputSchema: Record }> + Array<{ + name: string; + description: string; + inputSchema: Record; + readOnlyHint: boolean; + }> > { const result = await this.client.listTools(); this.toolNames = result.tools.map((t) => t.name); @@ -108,6 +113,7 @@ export class McpClient { name: t.name, description: t.description ?? '', inputSchema: (t.inputSchema as Record) ?? {}, + readOnlyHint: t.annotations?.readOnlyHint ?? false, })); } diff --git a/packages/codingcode/src/mcp/mcp.ts b/packages/codingcode/src/mcp/mcp.ts index c7c1d313..1f8d2112 100644 --- a/packages/codingcode/src/mcp/mcp.ts +++ b/packages/codingcode/src/mcp/mcp.ts @@ -12,6 +12,7 @@ interface McpRawTool { name: string; description: string; inputSchema: Record; + readOnlyHint: boolean; } interface ServerEntry { @@ -90,6 +91,7 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { name: mt.name, description: mt.description ?? '', inputSchema: mt.inputSchema ?? {}, + readOnlyHint: mt.readOnlyHint ?? false, })); const registeredNames: string[] = rawTools.map((mt) => namespacedName(cfg.name, mt.name)); @@ -312,7 +314,7 @@ function namespacedName(serverName: string, toolName: string): string { function mcpToolToSpec( serverName: string, - mcpTool: { name: string; description: string; inputSchema: Record }, + mcpTool: McpRawTool, client: McpClient, isDisabledFn: () => boolean ): McpToolSpec { @@ -321,6 +323,7 @@ function mcpToolToSpec( name: mcpTool.name, description: mcpTool.description, inputSchema: mcpTool.inputSchema, + readOnlyHint: mcpTool.readOnlyHint, execute: (args) => { if (isDisabledFn()) return Effect.fail( diff --git a/packages/codingcode/src/tools/catalog.ts b/packages/codingcode/src/tools/catalog.ts index f4648986..29b85b2f 100644 --- a/packages/codingcode/src/tools/catalog.ts +++ b/packages/codingcode/src/tools/catalog.ts @@ -39,6 +39,7 @@ function specToDefinition(spec: McpToolSpec): ToolDefinition { name: `${spec.server}:${spec.name}`, description: `[MCP:${spec.server}] ${spec.description || spec.name}`, parameters: z.fromJSONSchema(spec.inputSchema), + concurrencySafe: spec.readOnlyHint, execute: (args) => spec.execute(args as Record), }; } @@ -61,9 +62,8 @@ export function createToolCatalog( if (!definition) return undefined; return { name: definition.name, + concurrencySafe: definition.concurrencySafe ?? false, parse: (args: unknown) => definition.parameters.parse(args), - // 装配点:工具声明的服务依赖(ToolDefinition)由执行器在运行时经 ToolEnv 注入, - // 故在此把 R 收敛为 never,与 ToolRunner 的可执行形态对齐 execute: (args: unknown, ctx?: ToolExecCtx) => definition.execute(args, ctx) as Effect.Effect, }; diff --git a/packages/codingcode/src/tools/domains/bash/exec.ts b/packages/codingcode/src/tools/domains/bash/exec.ts index a3d82fd4..eb627a14 100644 --- a/packages/codingcode/src/tools/domains/bash/exec.ts +++ b/packages/codingcode/src/tools/domains/bash/exec.ts @@ -7,6 +7,7 @@ import { AgentError } from '../../../core/error.js'; export const bashTool: ToolDefinition = { name: 'execute_command', + concurrencySafe: false, description: 'Execute a shell command and return its output. Use for running tests, git, npm, build, and other CLI operations.', parameters: z.object({ diff --git a/packages/codingcode/src/tools/domains/fs/edit.ts b/packages/codingcode/src/tools/domains/fs/edit.ts index 0f3c2ef3..c878cfae 100644 --- a/packages/codingcode/src/tools/domains/fs/edit.ts +++ b/packages/codingcode/src/tools/domains/fs/edit.ts @@ -7,6 +7,7 @@ import type { ToolDefinition } from '../../types.js'; export const editFileTool: ToolDefinition = { name: 'edit_file', + concurrencySafe: false, description: 'Perform exact string replacement in a file. Provide the exact text to replace (old_string) and the new text (new_string). If old_string is not unique in the file, the edit will fail — narrow the match to make it unique.', parameters: z.object({ diff --git a/packages/codingcode/src/tools/domains/fs/glob.ts b/packages/codingcode/src/tools/domains/fs/glob.ts index 1a3d41af..d691f028 100644 --- a/packages/codingcode/src/tools/domains/fs/glob.ts +++ b/packages/codingcode/src/tools/domains/fs/glob.ts @@ -7,6 +7,7 @@ import type { ToolDefinition } from '../../types.js'; export const globTool: ToolDefinition = { name: 'search_files', + concurrencySafe: true, description: 'Find files matching a glob pattern. Returns file paths sorted by modification time (newest first). Useful for locating files by name or extension across the project.', parameters: z.object({ diff --git a/packages/codingcode/src/tools/domains/fs/grep.ts b/packages/codingcode/src/tools/domains/fs/grep.ts index 1cc0efd8..93166d60 100644 --- a/packages/codingcode/src/tools/domains/fs/grep.ts +++ b/packages/codingcode/src/tools/domains/fs/grep.ts @@ -8,6 +8,7 @@ import type { ToolDefinition } from '../../types.js'; export const searchTool: ToolDefinition = { name: 'search_code', + concurrencySafe: true, description: 'Search for a text or regex pattern in project files and return matching file paths and line content.', parameters: z.object({ diff --git a/packages/codingcode/src/tools/domains/fs/read.ts b/packages/codingcode/src/tools/domains/fs/read.ts index 2e24d00f..c3aff2f5 100644 --- a/packages/codingcode/src/tools/domains/fs/read.ts +++ b/packages/codingcode/src/tools/domains/fs/read.ts @@ -7,6 +7,7 @@ import type { ToolDefinition } from '../../types.js'; export const readFileTool: ToolDefinition = { name: 'read_file', + concurrencySafe: true, description: 'Read the contents of a file and return it with line numbers.', parameters: z.object({ path: z.string().describe('Path to the file (absolute or relative)'), diff --git a/packages/codingcode/src/tools/domains/fs/write.ts b/packages/codingcode/src/tools/domains/fs/write.ts index 073307d4..93e0b6e2 100644 --- a/packages/codingcode/src/tools/domains/fs/write.ts +++ b/packages/codingcode/src/tools/domains/fs/write.ts @@ -7,6 +7,7 @@ import type { ToolDefinition } from '../../types.js'; export const writeFileTool: ToolDefinition = { name: 'write_file', + concurrencySafe: false, description: 'Write content to a file, creating parent directories if needed. Overwrites existing files.', parameters: z.object({ diff --git a/packages/codingcode/src/tools/domains/self/todo-write.ts b/packages/codingcode/src/tools/domains/self/todo-write.ts index e6f5887f..7a7ca581 100644 --- a/packages/codingcode/src/tools/domains/self/todo-write.ts +++ b/packages/codingcode/src/tools/domains/self/todo-write.ts @@ -23,6 +23,7 @@ const todoSchema = z.object({ export const todoWriteTool: ToolDefinition = { name: 'todo_write', + concurrencySafe: true, description: 'Replace the current task list. Use for multi-step work to track plan and progress. Pass the full updated plan; previous list is replaced entirely.', parameters: todoSchema, diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index f84c37b2..dc4cc5d9 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -11,6 +11,7 @@ export const dispatchAgentTool: ToolDefinition< HookService | McpService | SubagentRunnerService > = { name: 'dispatch_agent', + concurrencySafe: false, description: 'Spawn an isolated subagent to handle specialized tasks. See "Available Subagents" in the system prompt for available profiles and their capabilities.', parameters: z.object({ diff --git a/packages/codingcode/src/tools/domains/subagent/submit-plan.ts b/packages/codingcode/src/tools/domains/subagent/submit-plan.ts index 13c3e3c3..e2dc80a9 100644 --- a/packages/codingcode/src/tools/domains/subagent/submit-plan.ts +++ b/packages/codingcode/src/tools/domains/subagent/submit-plan.ts @@ -42,6 +42,7 @@ function warnMissingSections(content: string): void { export const submitPlanTool: ToolDefinition = { name: 'submit_plan', + concurrencySafe: false, description: 'Submit (or update) the implementation plan for the current session. The only write operation allowed in the plan profile. The file is written immediately and the tool returns synchronously; the user is then shown a plan approval modal in the UI. The user’s next message will contain their decision (implement / revised content / cancel).', parameters: z.object({ diff --git a/packages/codingcode/src/tools/domains/web/fetch.ts b/packages/codingcode/src/tools/domains/web/fetch.ts index d4f4bc0e..6631795e 100644 --- a/packages/codingcode/src/tools/domains/web/fetch.ts +++ b/packages/codingcode/src/tools/domains/web/fetch.ts @@ -5,6 +5,7 @@ import type { ToolDefinition } from '../../types.js'; export const webFetchTool: ToolDefinition = { name: 'fetch_url', + concurrencySafe: true, description: 'Fetch content from a URL and return its text. Supports GET requests only.', parameters: z.object({ url: z.string().url().describe('The URL to fetch (must be a valid absolute URL)'), diff --git a/packages/codingcode/src/tools/domains/web/search.ts b/packages/codingcode/src/tools/domains/web/search.ts index ef02bf75..f281b81c 100644 --- a/packages/codingcode/src/tools/domains/web/search.ts +++ b/packages/codingcode/src/tools/domains/web/search.ts @@ -144,6 +144,7 @@ export function parseBaiduHtml(html: string, maxResults: number): SearchResult[] export const webSearchTool: ToolDefinition = { name: 'web_search', + concurrencySafe: true, description: 'Search the web and return results with titles, URLs, and snippets. Use this when you need up-to-date information or to find documentation, references, or answers beyond your knowledge cutoff.', parameters: z.object({ diff --git a/packages/codingcode/src/tools/tools.ts b/packages/codingcode/src/tools/tools.ts index f6cebdc7..75ba16dd 100644 --- a/packages/codingcode/src/tools/tools.ts +++ b/packages/codingcode/src/tools/tools.ts @@ -28,7 +28,6 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu const finalArgs = args as Record; - // Notification hook — use callId for consistent pairing const callId = opts?.callId; yield* hooks.emit('tool.execute.before', { toolName: name, @@ -141,6 +140,24 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu ); } + function splitWaves(toolCalls: ToolCall[], toolLookup?: ToolLookup): ToolCall[][] { + const waves: ToolCall[][] = []; + let current: ToolCall[] | undefined; + for (const tc of toolCalls) { + if (toolLookup?.(tc.name)?.concurrencySafe) { + if (!current) { + current = []; + waves.push(current); + } + current.push(tc); + } else { + waves.push([tc]); + current = undefined; + } + } + return waves; + } + function executeBatch( toolCalls: ToolCall[], sessionId?: string, @@ -152,54 +169,28 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu } ): Effect.Effect { return Effect.gen(function* () { - // Separate safe & destructive tools: safe tools run in parallel, Bash runs serially - const safeTools: ToolCall[] = []; - const bashTools: ToolCall[] = []; - - for (const tc of toolCalls) { - if (tc.name === 'execute_command') { - bashTools.push(tc); - } else { - safeTools.push(tc); - } - } - // Safe tools — parallel - const safeResults = yield* Effect.forEach( - safeTools, - (tc) => { - // Check abort before each tool - if (opts?.signal?.aborted) { - return Effect.succeed({ - status: 'denied' as const, - id: tc.id, - name: tc.name, - reason: 'aborted', - }); - } - return execSingle(tc, sessionId, opts); - }, - { concurrency: 'unbounded' } - ); + const runTool = (tc: ToolCall): Effect.Effect => + Effect.suspend(() => + opts?.signal?.aborted + ? Effect.succeed({ + status: 'denied' as const, + id: tc.id, + name: tc.name, + reason: 'aborted', + }) + : execSingle(tc, sessionId, opts) + ); - // Bash tools — serial (avoid race conditions) - const bashResults: ToolResult[] = []; - for (const tc of bashTools) { - // Check abort before each tool - if (opts?.signal?.aborted) { - bashResults.push({ - status: 'denied' as const, - id: tc.id, - name: tc.name, - reason: 'aborted', - }); - continue; - } - const r = yield* execSingle(tc, sessionId, opts); - bashResults.push(r); - } + const waveResults = yield* Effect.forEach( + splitWaves(toolCalls, opts?.toolLookup), + (wave) => Effect.forEach(wave, runTool, { concurrency: 'unbounded' }), + { concurrency: 1 } + ); - return [...safeResults, ...bashResults]; + const byId = new Map(); + for (const wave of waveResults) for (const r of wave) byId.set(r.id, r); + return toolCalls.map((tc) => byId.get(tc.id)!); }); } diff --git a/packages/codingcode/src/tools/types.ts b/packages/codingcode/src/tools/types.ts index e54bffc5..a5f51705 100644 --- a/packages/codingcode/src/tools/types.ts +++ b/packages/codingcode/src/tools/types.ts @@ -5,6 +5,8 @@ import type { ToolExecCtx } from '../contracts/tool.js'; export interface ToolDefinition { name: string; + /** 是否可与同批其它工具并发;省略即 false(fail-closed) */ + concurrencySafe?: boolean; description: string; parameters: z.ZodTypeAny; execute: (args: unknown, ctx?: ToolExecCtx) => Effect.Effect; diff --git a/packages/codingcode/test/tools/catalog.test.ts b/packages/codingcode/test/tools/catalog.test.ts index dfd9e7ba..b5b99fc3 100644 --- a/packages/codingcode/test/tools/catalog.test.ts +++ b/packages/codingcode/test/tools/catalog.test.ts @@ -46,6 +46,7 @@ describe('createToolCatalog', () => { name: 'thing', description: 'a thing', inputSchema: {}, + readOnlyHint: false, execute: () => Effect.succeed('ok'), }; const { tools, lookup } = createToolCatalog(['read_file'], [spec]); @@ -63,6 +64,7 @@ describe('createToolCatalog', () => { properties: { text: { type: 'string' } }, required: ['text'], }, + readOnlyHint: false, execute: () => Effect.succeed('ok'), }; const { lookup } = createToolCatalog([], [spec]); @@ -77,6 +79,7 @@ describe('createToolCatalog', () => { name: 'thing', description: '', inputSchema: {}, + readOnlyHint: false, execute: (args) => Effect.succeed(`called:${String(args.n)}`), }; const { lookup } = createToolCatalog([], [spec]);