pi-codemcp 1.2.1 → 1.2.2

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
@@ -211,7 +211,7 @@ Rendered Pi output is separately truncated by `outputLimitKiB`; the full oversiz
211
211
 
212
212
  FastMCP owns MCP transports, runtime validation, and OAuth. [Pydantic Monty](https://github.com/pydantic/monty) type-checks and executes agent-written Python without host filesystem, environment, network, or subprocess access. Code Mode can only call the typed MCP tool and saved-chain facades exposed in the generated stubs.
213
213
 
214
- `/codemcp` configures servers, saved chains, per-tool policy, timeouts, call limits, output limits, cache TTL, and warmup, and shows bounded lifetime/recent telemetry in its Stats tab. Server, chain, tool-policy, and setting toggles stay local and instantaneous until one `Ctrl+S` batch save/reload. Discovery, revalidation, and deletion remain explicit immediate actions. The sandbox also has a fixed memory ceiling; executions are serialized per Pi session. There are no automatic retries or cross-service rollback.
214
+ `/codemcp` configures servers, saved chains, per-tool policy, timeouts, call limits, output limits, cache TTL, and warmup, and shows bounded lifetime/recent telemetry in its Stats tab. Server, chain, tool-policy, and setting changes are persisted immediately. Discovery, revalidation, and deletion remain explicit immediate actions. The sandbox also has a fixed memory ceiling; executions are serialized per Pi session. There are no automatic retries or cross-service rollback.
215
215
 
216
216
  Enabled tools retain their upstream permissions. Saved chains never bypass server or per-tool policy and are checked against the current enabled catalog whenever they run. Preflight safety does not make upstream tools transactional: if a later call fails after earlier calls succeeded, pi-codemcp does not roll those upstream side effects back.
217
217
 
@@ -5,21 +5,23 @@ import {
5
5
  type ExtensionCommandContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { SavedChainManager } from "../src/chains.js";
8
- import { setMcpServersEnabled } from "../src/config.js";
8
+ import { setMcpServerEnabled } from "../src/config.js";
9
9
  import { summarizeError } from "../src/errors.js";
10
10
  import { CodeMcpLifecycle } from "../src/lifecycle.js";
11
11
  import type { SidecarClientOptions } from "../src/mcp-client.js";
12
12
  import {
13
- type ChainModalState,
14
13
  chainStatesFromViews,
15
- type ChainEnabledChange as ModalChainEnabledChange,
16
- type ServerEnabledChange,
17
14
  type ServerModalState,
18
15
  serverStatesFromStatus,
19
16
  showServerManagerModal,
20
17
  statsStateFromSnapshot,
21
18
  } from "../src/modal.js";
22
- import { type CodeMcpSettings, saveCodeMcpSettings } from "../src/settings.js";
19
+ import {
20
+ type CodeMcpSettings,
21
+ saveCodeMcpSettings,
22
+ setEditableSetting,
23
+ setToolEnabled,
24
+ } from "../src/settings.js";
23
25
  import { registerCodeMcpTools } from "../src/tools.js";
24
26
 
25
27
  export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
@@ -50,22 +52,31 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
50
52
  chains: chainStatesFromViews(savedChains),
51
53
  settings,
52
54
  stats: statsStateFromSnapshot(stats),
53
- onDiscover: async (server) =>
54
- requireServerStatus(
55
- await lifecycle.request("discover", { server: server.name }),
55
+ onSetServerEnabled: (server, enabled) =>
56
+ setServerEnabledFromManager(lifecycle, server, enabled),
57
+ onDiscover: (server) => discoverServerFromManager(lifecycle, server),
58
+ onSetToolEnabled: async (server, tool, enabled) => {
59
+ const updated = setToolEnabled(
60
+ lifecycle.loadSettings(),
61
+ server.name,
62
+ tool.name,
63
+ enabled,
64
+ );
65
+ saveCodeMcpSettings(lifecycle.settingsPath, updated);
66
+ return requireServerStatus(
67
+ await lifecycle.request("reload_settings", {}),
56
68
  server.name,
57
- ),
58
- onSaveChanges: (updated, serverChanges, chainChanges) =>
59
- saveManagerChanges(lifecycle, chains, updated, serverChanges, chainChanges),
60
- onResolveUnsaved: async () => {
61
- const choice = await ctx.ui.select("Unsaved CodeMCP changes", [
62
- "Save",
63
- "Discard",
64
- "Cancel",
65
- ]);
66
- if (choice === "Save") return "save";
67
- if (choice === "Discard") return "discard";
68
- return "cancel";
69
+ );
70
+ },
71
+ onSetSetting: async (key, value) => {
72
+ const updated = setEditableSetting(lifecycle.loadSettings(), key, value);
73
+ saveCodeMcpSettings(lifecycle.settingsPath, updated);
74
+ await lifecycle.request("reload_settings", {});
75
+ return updated;
76
+ },
77
+ onSetChainEnabled: async (chain, enabled) => {
78
+ await chains.setEnabled(chain.name, chain.scope, enabled);
79
+ return chainStatesFromViews(await chains.list());
69
80
  },
70
81
  onRevalidateChain: async (chain) => {
71
82
  await chains.revalidate(chain.name, chain.scope);
@@ -104,61 +115,36 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
104
115
  };
105
116
  }
106
117
 
107
- export async function saveManagerChanges(
108
- lifecycle: Pick<CodeMcpLifecycle, "configPath" | "settingsPath" | "loadSettings" | "reload">,
109
- chains: Pick<SavedChainManager, "applyEnabled">,
110
- updated: CodeMcpSettings,
111
- serverChanges: readonly ServerEnabledChange[],
112
- chainChanges: readonly ModalChainEnabledChange[],
113
- ): Promise<{
114
- settings: CodeMcpSettings;
115
- servers: ServerModalState[];
116
- chains: ChainModalState[];
117
- }> {
118
- const previousSettings = lifecycle.loadSettings();
119
- let serverConfigChanged = false;
118
+ export async function discoverServerFromManager(
119
+ lifecycle: Pick<CodeMcpLifecycle, "configPath" | "reload" | "request">,
120
+ server: ServerModalState,
121
+ ): Promise<ServerModalState> {
122
+ if (!server.enabled) return setServerEnabledFromManager(lifecycle, server, true);
123
+ return requireServerStatus(
124
+ await lifecycle.request("discover", { server: server.name }),
125
+ server.name,
126
+ );
127
+ }
128
+
129
+ export async function setServerEnabledFromManager(
130
+ lifecycle: Pick<CodeMcpLifecycle, "configPath" | "reload" | "request">,
131
+ previous: ServerModalState,
132
+ enabled: boolean,
133
+ ): Promise<ServerModalState> {
134
+ setMcpServerEnabled(lifecycle.configPath, previous.name, enabled);
135
+ await lifecycle.reload();
120
136
  try {
121
- saveCodeMcpSettings(lifecycle.settingsPath, updated);
122
- if (serverChanges.length > 0) {
123
- setMcpServersEnabled(
124
- lifecycle.configPath,
125
- serverChanges.map((change) => ({ name: change.name, enabled: change.enabled })),
126
- );
127
- serverConfigChanged = true;
128
- await lifecycle.reload();
129
- }
130
- const applied = await chains.applyEnabled(
131
- chainChanges.map((change) => ({
132
- name: change.name,
133
- scope: change.scope,
134
- enabled: change.enabled,
135
- })),
136
- );
137
- return {
138
- settings: lifecycle.loadSettings(),
139
- servers: serverStatesFromStatus(applied.status),
140
- chains: chainStatesFromViews(applied.chains),
141
- };
137
+ const status = enabled
138
+ ? await lifecycle.request("discover", { server: previous.name })
139
+ : await lifecycle.request("status", {});
140
+ return requireServerStatus(status, previous.name);
142
141
  } catch (error) {
143
- saveCodeMcpSettings(lifecycle.settingsPath, previousSettings);
144
- if (serverConfigChanged) {
145
- setMcpServersEnabled(
146
- lifecycle.configPath,
147
- serverChanges.map((change) => ({
148
- name: change.name,
149
- enabled: change.previousEnabled,
150
- })),
151
- );
152
- }
153
142
  try {
154
- await lifecycle.reload();
155
- } catch (rollbackError) {
156
- throw new AggregateError(
157
- [error, rollbackError],
158
- "CodeMCP save failed and runtime rollback also failed",
159
- );
143
+ const current = requireServerStatus(await lifecycle.request("status", {}), previous.name);
144
+ return { ...current, error: summarizeError(error) };
145
+ } catch {
146
+ throw error;
160
147
  }
161
- throw error;
162
148
  }
163
149
  }
164
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codemcp",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "Typed, sandboxed Code Mode access to configured MCP servers for Pi",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
package/sidecar/chains.py CHANGED
@@ -5,18 +5,15 @@ import json
5
5
  import os
6
6
  import re
7
7
  import time
8
- from contextlib import contextmanager, suppress
8
+ from contextlib import suppress
9
9
  from pathlib import Path
10
- from typing import TYPE_CHECKING, Literal, NamedTuple
10
+ from typing import Literal, NamedTuple
11
11
  from uuid import uuid4
12
12
 
13
13
  from pydantic import BaseModel, ConfigDict, Field, field_validator
14
14
 
15
15
  from .json_types import JSON_OBJECT_ADAPTER, JsonObject
16
16
 
17
- if TYPE_CHECKING:
18
- from collections.abc import Iterator
19
-
20
17
  CHAIN_NAME_PATTERN = r"^[a-z][a-z0-9_]{0,63}$"
21
18
  CHAIN_STORE_VERSION: Literal[1] = 1
22
19
  type ChainScope = Literal["global", "project"]
@@ -76,14 +73,6 @@ class SavedChainManifest(BaseModel):
76
73
  return f"mcp_chain_{self.name}"
77
74
 
78
75
 
79
- class ChainEnabledChange(BaseModel):
80
- model_config = ConfigDict(extra="forbid", strict=True)
81
-
82
- name: str = Field(pattern=CHAIN_NAME_PATTERN)
83
- scope: ChainScope
84
- enabled: bool
85
-
86
-
87
76
  class ChainStatusView(BaseModel):
88
77
  model_config = ConfigDict(extra="forbid", strict=True)
89
78
 
@@ -187,6 +176,12 @@ class ChainStore:
187
176
  temporary.chmod(0o600)
188
177
  temporary.replace(path)
189
178
 
179
+ def set_enabled(self, name: str, enabled: bool) -> SavedChainManifest:
180
+ current = self.get(name)
181
+ updated = current.model_copy(update={"enabled": enabled, "updated_at": time.time()})
182
+ self.save(updated)
183
+ return updated
184
+
190
185
  def delete(self, name: str) -> None:
191
186
  self._validate_name(name)
192
187
  try:
@@ -256,38 +251,16 @@ class ScopedChainStore:
256
251
  def save(self, scope: ChainScope, chain: SavedChainManifest) -> None:
257
252
  self._store(scope).save(chain)
258
253
 
259
- @contextmanager
260
- def enabled_transaction(self, changes: list[ChainEnabledChange]) -> Iterator[None]:
261
- keys = [(change.scope, change.name) for change in changes]
262
- if len(keys) != len(set(keys)):
263
- raise ValueError("Duplicate scoped saved-chain enable change")
264
- previous = [self.get(change.name, change.scope) for change in changes]
265
- applied: list[ScopedChain] = []
266
- try:
267
- self._apply_enabled_changes(changes, previous, applied)
268
- yield
269
- except BaseException:
270
- self._restore(applied)
271
- raise
272
-
273
- def _apply_enabled_changes(
254
+ def set_enabled(
274
255
  self,
275
- changes: list[ChainEnabledChange],
276
- previous: list[ScopedChain],
277
- applied: list[ScopedChain],
278
- ) -> None:
279
- for change, current in zip(changes, previous, strict=True):
280
- if current.chain.enabled == change.enabled:
281
- continue
282
- updated = current.chain.model_copy(
283
- update={"enabled": change.enabled, "updated_at": time.time()}
284
- )
285
- self.save(change.scope, updated)
286
- applied.append(current)
287
-
288
- def _restore(self, chains: list[ScopedChain]) -> None:
289
- for item in reversed(chains):
290
- self.save(item.scope, item.chain)
256
+ scope: ChainScope,
257
+ name: str,
258
+ enabled: bool,
259
+ ) -> ScopedChain:
260
+ return ScopedChain(
261
+ scope=scope,
262
+ chain=self._store(scope).set_enabled(name, enabled),
263
+ )
291
264
 
292
265
  def delete(self, scope: ChainScope, name: str) -> None:
293
266
  self._store(scope).delete(name)
@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Literal, NamedTuple, Protocol, cast
10
10
  import pydantic_monty
11
11
  from fastmcp import Client, FastMCP
12
12
  from fastmcp.mcp_config import RemoteMCPServer, StdioMCPServer
13
- from pydantic import BaseModel, ConfigDict
14
13
  from pydantic_core import to_json
15
14
  from rapidfuzz import fuzz, process
16
15
 
@@ -18,7 +17,6 @@ from . import json_types
18
17
  from .catalog_cache import CatalogCache
19
18
  from .chains import (
20
19
  ChainDependency,
21
- ChainEnabledChange,
22
20
  ChainListResponse,
23
21
  ChainScope,
24
22
  ChainStatusView,
@@ -62,13 +60,6 @@ type JsonObject = json_types.JsonObject
62
60
  type JsonValue = json_types.JsonValue
63
61
 
64
62
 
65
- class ManagerApplyResponse(BaseModel):
66
- model_config = ConfigDict(extra="forbid", strict=True)
67
-
68
- status: StatusResponse
69
- chains: list[ChainStatusView]
70
-
71
-
72
63
  class ServerHandle:
73
64
  def __init__(
74
65
  self,
@@ -187,6 +178,7 @@ class SavedChainHandlers(NamedTuple):
187
178
  execute: Callable[[str, JsonObject], Awaitable[ExecutionResponse]]
188
179
  save: SaveChainHandler
189
180
  list: Callable[[], ChainListResponse]
181
+ set_enabled: Callable[[str, ChainScope, bool], Awaitable[ChainStatusView]]
190
182
  revalidate: Callable[[str, ChainScope], Awaitable[ChainStatusView]]
191
183
  delete: Callable[[str, ChainScope], Awaitable[ChainListResponse]]
192
184
 
@@ -220,6 +212,14 @@ class SavedChainRuntime:
220
212
  def list(self) -> ChainListResponse:
221
213
  return self.handlers.list()
222
214
 
215
+ async def set_enabled(
216
+ self,
217
+ name: str,
218
+ scope: ChainScope,
219
+ enabled: bool,
220
+ ) -> ChainStatusView:
221
+ return await self.handlers.set_enabled(name, scope, enabled)
222
+
223
223
  async def revalidate(self, name: str, scope: ChainScope) -> ChainStatusView:
224
224
  return await self.handlers.revalidate(name, scope)
225
225
 
@@ -256,6 +256,7 @@ class GatewayRuntime:
256
256
  execute=self._execute_chain,
257
257
  save=self._save_chain,
258
258
  list=self._list_chains,
259
+ set_enabled=self._set_chain_enabled,
259
260
  revalidate=self._revalidate_chain,
260
261
  delete=self._delete_chain,
261
262
  )
@@ -778,6 +779,33 @@ class GatewayRuntime:
778
779
  )
779
780
  return response
780
781
 
782
+ async def _set_chain_enabled(
783
+ self,
784
+ name: str,
785
+ scope: ChainScope,
786
+ enabled: bool,
787
+ ) -> ChainStatusView:
788
+ started = time.perf_counter()
789
+ try:
790
+ self.chain_store.set_enabled(scope, name, enabled)
791
+ await self._rebuild_catalog()
792
+ response = self._chain_view(name, scope)
793
+ except BaseException:
794
+ self.stats_store.record_operation(
795
+ "set_chain_enabled",
796
+ duration_ms=_elapsed_ms(started),
797
+ success=False,
798
+ failure_stage="error",
799
+ )
800
+ raise
801
+ self.stats_store.record_operation(
802
+ "set_chain_enabled",
803
+ duration_ms=_elapsed_ms(started),
804
+ success=True,
805
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
806
+ )
807
+ return response
808
+
781
809
  async def _revalidate_chain(self, name: str, scope: ChainScope) -> ChainStatusView:
782
810
  started = time.perf_counter()
783
811
  try:
@@ -924,27 +952,6 @@ class GatewayRuntime:
924
952
  await self._rebuild_catalog()
925
953
  return self.status()
926
954
 
927
- async def apply_manager_changes(
928
- self,
929
- changes: list[ChainEnabledChange],
930
- ) -> ManagerApplyResponse:
931
- previous_settings = self.settings
932
- try:
933
- with self.chain_store.enabled_transaction(changes):
934
- self._load_settings()
935
- await self._rebuild_catalog()
936
- except BaseException:
937
- self.settings = previous_settings
938
- for handle in self.handles.values():
939
- handle.cache.max_age_seconds = previous_settings.cache_ttl_seconds
940
- self.executor.settings = previous_settings.execution_settings()
941
- await self._rebuild_catalog()
942
- raise
943
- return ManagerApplyResponse(
944
- status=self.status(),
945
- chains=self._chain_views(),
946
- )
947
-
948
955
  def _load_settings(self) -> None:
949
956
  self.settings = load_settings(self.settings_path)
950
957
  for handle in self.handles.values():
@@ -1316,11 +1323,13 @@ async def reload_settings() -> StatusResponse:
1316
1323
 
1317
1324
 
1318
1325
  @mcp.tool
1319
- async def apply_manager_changes(
1320
- changes: list[ChainEnabledChange],
1321
- ) -> ManagerApplyResponse:
1322
- """Apply staged settings and saved-chain enable changes with one catalog rebuild."""
1323
- return await _require_runtime().apply_manager_changes(changes)
1326
+ async def set_chain_enabled(
1327
+ name: str,
1328
+ scope: ChainScope,
1329
+ enabled: bool,
1330
+ ) -> ChainStatusView:
1331
+ """Enable or disable one saved chain in its storage scope."""
1332
+ return await _require_runtime().chains.set_enabled(name, scope, enabled)
1324
1333
 
1325
1334
 
1326
1335
  @mcp.tool
@@ -4,7 +4,8 @@ import hashlib
4
4
  import json
5
5
  import os
6
6
  import re
7
- from typing import TYPE_CHECKING
7
+ from typing import TYPE_CHECKING, override
8
+ from urllib.parse import urlsplit
8
9
 
9
10
  from fastmcp.client.auth import OAuth
10
11
  from fastmcp.mcp_config import (
@@ -58,6 +59,30 @@ class NormalizedConfig(BaseModel):
58
59
  servers: list[NormalizedServerInfo]
59
60
 
60
61
 
62
+ class PersistentCallbackOAuth(OAuth):
63
+ """Reuse the callback registered with a persisted dynamic OAuth client."""
64
+
65
+ @override
66
+ async def _initialize(self) -> None:
67
+ await super()._initialize()
68
+ client_info = self.context.client_info
69
+ if client_info is None or not client_info.redirect_uris:
70
+ return
71
+ redirect_uri = client_info.redirect_uris[0]
72
+ parsed = urlsplit(str(redirect_uri))
73
+ if (
74
+ parsed.scheme != "http"
75
+ or parsed.hostname not in {"localhost", "127.0.0.1", "::1"}
76
+ or parsed.port is None
77
+ ):
78
+ return
79
+ if parsed.path != "/callback" or parsed.query or parsed.fragment:
80
+ return
81
+ self.redirect_port = parsed.port
82
+ self._callback_host = parsed.hostname
83
+ self.context.client_metadata.redirect_uris = [redirect_uri]
84
+
85
+
61
86
  def load_mcp_json(path: Path) -> JsonObject:
62
87
  if not path.exists():
63
88
  raise FileNotFoundError(f"MCP config not found: {path}")
@@ -231,7 +256,7 @@ def normalize_mcp_config(
231
256
  auth: str | httpx.Auth | None
232
257
  auth_kind: ServerAuth | None = None
233
258
  if raw_auth == "oauth":
234
- auth = OAuth(
259
+ auth = PersistentCallbackOAuth(
235
260
  mcp_url=url,
236
261
  client_name=oauth_client_name,
237
262
  token_storage=oauth_storage,
package/src/chains.ts CHANGED
@@ -46,17 +46,6 @@ export interface SavedChainView {
46
46
  calledBy: string[];
47
47
  }
48
48
 
49
- export interface ChainEnabledChange {
50
- name: string;
51
- scope: ChainScope;
52
- enabled: boolean;
53
- }
54
-
55
- export interface ManagerApplyResult {
56
- chains: SavedChainView[];
57
- status: Record<string, unknown>;
58
- }
59
-
60
49
  export interface SaveChainInput {
61
50
  scope: ChainScope;
62
51
  name: string;
@@ -134,21 +123,21 @@ export class SavedChainManager {
134
123
  return views;
135
124
  }
136
125
 
137
- async applyEnabled(
138
- changes: readonly ChainEnabledChange[],
126
+ async setEnabled(
127
+ name: string,
128
+ scope: ChainScope,
129
+ enabled: boolean,
139
130
  signal?: AbortSignal,
140
- ): Promise<ManagerApplyResult> {
131
+ ): Promise<SavedChainView> {
141
132
  const result = await this.lifecycle.request(
142
- "apply_manager_changes",
143
- {
144
- changes: changes.map((change) => ({ ...change })),
145
- },
133
+ "set_chain_enabled",
134
+ { name, scope, enabled },
146
135
  signal,
147
136
  );
148
- const views = parseViewList(result.chains, "apply_manager_changes.chains");
149
- const status = requireRecord(result.status, "apply_manager_changes.status");
150
- this.synchronizeViews(views);
151
- return { chains: views, status };
137
+ const view = parseSavedChainView(result, "set_chain_enabled");
138
+ this.upsertView(view);
139
+ this.refreshNativeTools();
140
+ return view;
152
141
  }
153
142
 
154
143
  async revalidate(name: string, scope: ChainScope, signal?: AbortSignal): Promise<SavedChainView> {
package/src/config.ts CHANGED
@@ -5,54 +5,23 @@ import {
5
5
  writeJsonObjectAtomically,
6
6
  } from "./json-file.js";
7
7
 
8
- export interface McpServerEnabledChange {
9
- name: string;
10
- enabled: boolean;
11
- }
12
-
13
8
  export function setMcpServerEnabled(configPath: string, name: string, enabled: boolean): void {
14
- setMcpServersEnabled(configPath, [{ name, enabled }]);
15
- }
16
-
17
- export function setMcpServersEnabled(
18
- configPath: string,
19
- changes: readonly McpServerEnabledChange[],
20
- ): void {
21
- if (changes.length === 0) return;
22
- const duplicate = duplicateName(changes.map((change) => change.name));
23
- if (duplicate) throw new Error(`Duplicate MCP server change: ${JSON.stringify(duplicate)}`);
24
-
25
9
  const root = readJsonObject(configPath, "mcp.json root");
26
10
  const hasServerBlock = Object.hasOwn(root, "mcpServers");
27
11
  const servers = hasServerBlock ? requireJsonObject(root.mcpServers, "mcp.json mcpServers") : root;
28
- const updatedServers: JsonRecord = { ...servers };
12
+ const server = requireJsonObject(servers[name], `MCP server ${JSON.stringify(name)}`);
13
+ const updatedServer: JsonRecord = { ...server };
29
14
 
30
- for (const change of changes) {
31
- const server = requireJsonObject(
32
- servers[change.name],
33
- `MCP server ${JSON.stringify(change.name)}`,
34
- );
35
- const updatedServer: JsonRecord = { ...server };
36
- if (typeof server.enabled === "boolean") {
37
- updatedServer.enabled = change.enabled;
38
- delete updatedServer.disabled;
39
- } else {
40
- updatedServer.disabled = !change.enabled;
41
- }
42
- updatedServers[change.name] = updatedServer;
15
+ if (typeof server.enabled === "boolean") {
16
+ updatedServer.enabled = enabled;
17
+ delete updatedServer.disabled;
18
+ } else {
19
+ updatedServer.disabled = !enabled;
43
20
  }
44
21
 
22
+ const updatedServers: JsonRecord = { ...servers, [name]: updatedServer };
45
23
  const updatedRoot: JsonRecord = hasServerBlock
46
24
  ? { ...root, mcpServers: updatedServers }
47
25
  : updatedServers;
48
26
  writeJsonObjectAtomically(configPath, updatedRoot);
49
27
  }
50
-
51
- function duplicateName(names: readonly string[]): string | undefined {
52
- const seen = new Set<string>();
53
- for (const name of names) {
54
- if (seen.has(name)) return name;
55
- seen.add(name);
56
- }
57
- return undefined;
58
- }
package/src/mcp-client.ts CHANGED
@@ -22,11 +22,11 @@ export type SidecarToolName =
22
22
  | "inspect"
23
23
  | "discover"
24
24
  | "reload_settings"
25
- | "apply_manager_changes"
26
25
  | "execute"
27
26
  | "save_chain"
28
27
  | "list_chains"
29
28
  | "execute_chain"
29
+ | "set_chain_enabled"
30
30
  | "revalidate_chain"
31
31
  | "delete_chain"
32
32
  | "stats"
package/src/modal.ts CHANGED
@@ -13,13 +13,7 @@ import {
13
13
  } from "@earendil-works/pi-tui";
14
14
  import type { ChainScope, SavedChainView } from "./chains.js";
15
15
  import { summarizeError } from "./errors.js";
16
- import {
17
- type CodeMcpSettings,
18
- type EditableSettingKey,
19
- type EditableSettingValue,
20
- setEditableSetting,
21
- setToolEnabled,
22
- } from "./settings.js";
16
+ import type { CodeMcpSettings, EditableSettingKey, EditableSettingValue } from "./settings.js";
23
17
 
24
18
  export interface ToolModalState {
25
19
  name: string;
@@ -104,26 +98,6 @@ interface Keybindings {
104
98
  matches(data: string, id: "tui.select.up" | "tui.select.down" | "tui.select.cancel"): boolean;
105
99
  }
106
100
 
107
- export interface ServerEnabledChange {
108
- name: string;
109
- previousEnabled: boolean;
110
- enabled: boolean;
111
- }
112
-
113
- export interface ChainEnabledChange {
114
- name: string;
115
- scope: ChainScope;
116
- previousEnabled: boolean;
117
- enabled: boolean;
118
- }
119
-
120
- interface ManagerSaveResult {
121
- settings: CodeMcpSettings;
122
- servers: ServerModalState[];
123
- chains: ChainModalState[];
124
- }
125
-
126
- type UnsavedAction = "save" | "discard" | "cancel";
127
101
  export type ServerManagerResult = "report-problem" | undefined;
128
102
 
129
103
  interface ServerManagerOptions {
@@ -131,13 +105,15 @@ interface ServerManagerOptions {
131
105
  chains: ChainModalState[];
132
106
  settings: CodeMcpSettings;
133
107
  stats: StatsModalState;
108
+ onSetServerEnabled(server: ServerModalState, enabled: boolean): Promise<ServerModalState>;
134
109
  onDiscover(server: ServerModalState): Promise<ServerModalState>;
135
- onSaveChanges(
136
- settings: CodeMcpSettings,
137
- serverChanges: ServerEnabledChange[],
138
- chainChanges: ChainEnabledChange[],
139
- ): Promise<ManagerSaveResult>;
140
- onResolveUnsaved(): Promise<UnsavedAction>;
110
+ onSetToolEnabled(
111
+ server: ServerModalState,
112
+ tool: ToolModalState,
113
+ enabled: boolean,
114
+ ): Promise<ServerModalState>;
115
+ onSetSetting(key: EditableSettingKey, value: EditableSettingValue): Promise<CodeMcpSettings>;
116
+ onSetChainEnabled(chain: ChainModalState, enabled: boolean): Promise<ChainModalState[]>;
141
117
  onRevalidateChain(chain: ChainModalState): Promise<ChainModalState[]>;
142
118
  onDeleteChain(chain: ChainModalState): Promise<ChainModalState[]>;
143
119
  }
@@ -345,12 +321,6 @@ class ServerManagerModal implements Component, Focusable {
345
321
  private selectedToolIndex = 0;
346
322
  private selectedChainIndex = 0;
347
323
  private selectedSettingIndex = 0;
348
- private savedSettings: CodeMcpSettings;
349
- private draftSettings: CodeMcpSettings;
350
- private savedServerEnabled: Map<string, boolean>;
351
- private savedChainEnabled: Map<string, boolean>;
352
- private settingsBusy = false;
353
- private closePromptBusy = false;
354
324
  private settingsError: string | undefined;
355
325
  private _focused = false;
356
326
 
@@ -360,13 +330,7 @@ class ServerManagerModal implements Component, Focusable {
360
330
  private readonly keybindings: Keybindings,
361
331
  private readonly close: (result?: ServerManagerResult) => void,
362
332
  private readonly requestRender: () => void,
363
- ) {
364
- this.savedSettings = cloneSettings(options.settings);
365
- this.draftSettings = cloneSettings(options.settings);
366
- this.savedServerEnabled = serverEnabledMap(options.servers);
367
- this.savedChainEnabled = chainEnabledMap(options.chains);
368
- this.applyDraftToolPolicy();
369
- }
333
+ ) {}
370
334
 
371
335
  get focused(): boolean {
372
336
  return this._focused;
@@ -401,12 +365,7 @@ class ServerManagerModal implements Component, Focusable {
401
365
  }
402
366
 
403
367
  handleInput(data: string): void {
404
- if (matchesKey(data, Key.ctrl("s"))) {
405
- this.saveDraft();
406
- return;
407
- }
408
- if (this.settingsBusy || this.closePromptBusy) return;
409
- if (data === "R") {
368
+ if (data === "R" && this.activeTab !== "chains") {
410
369
  this.focusProblemReport();
411
370
  this.requestRender();
412
371
  return;
@@ -418,8 +377,7 @@ class ServerManagerModal implements Component, Focusable {
418
377
  this.requestRender();
419
378
  return;
420
379
  }
421
- if (this.hasUnsavedChanges()) this.resolveUnsavedClose();
422
- else this.close();
380
+ this.close();
423
381
  return;
424
382
  }
425
383
  if (matchesKey(data, Key.tab)) {
@@ -443,7 +401,7 @@ class ServerManagerModal implements Component, Focusable {
443
401
  }
444
402
 
445
403
  private handleServerInput(data: string): void {
446
- if (data === "d") {
404
+ if (data === "D") {
447
405
  this.discoverSelected();
448
406
  return;
449
407
  }
@@ -490,7 +448,7 @@ class ServerManagerModal implements Component, Focusable {
490
448
  );
491
449
  return;
492
450
  }
493
- if (data === "r") {
451
+ if (data === "R") {
494
452
  this.revalidateSelectedChain();
495
453
  return;
496
454
  }
@@ -510,7 +468,6 @@ class ServerManagerModal implements Component, Focusable {
510
468
  }
511
469
 
512
470
  private handleSettingsInput(data: string): void {
513
- if (this.settingsBusy) return;
514
471
  if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.up)) {
515
472
  this.selectedSettingIndex = cycleIndex(
516
473
  this.selectedSettingIndex,
@@ -613,7 +570,7 @@ class ServerManagerModal implements Component, Focusable {
613
570
  this.theme.fg("dim", `${server.transport}${server.auth ? ` · ${server.auth}` : ""}`),
614
571
  server.busy
615
572
  ? this.theme.fg("warning", "Working…")
616
- : `${this.theme.fg("accent", "[d]")} Discover tools ${this.theme.fg("accent", "[space]")} ${server.enabled ? "Disable server" : "Enable server"}`,
573
+ : `${this.theme.fg("accent", "[D]")} Discover tools ${this.theme.fg("accent", "[space]")} ${server.enabled ? "Disable server" : "Enable server"}`,
617
574
  ...(server.error ? [this.theme.fg("warning", `Error: ${server.error}`)] : []),
618
575
  this.theme.fg("dim", "─".repeat(Math.max(1, width))),
619
576
  this.theme.fg("dim", this.theme.bold("TOOLS")),
@@ -729,7 +686,7 @@ class ServerManagerModal implements Component, Focusable {
729
686
  this.theme.fg("muted", `${chain.scope} · ${chain.status} · ${chain.nativeTool}`),
730
687
  chain.busy
731
688
  ? this.theme.fg("warning", "Working…")
732
- : `${this.theme.fg("accent", "[space]")} ${chain.enabled ? "Disable" : "Enable"} ${this.theme.fg("accent", "[r]")} Revalidate ${this.theme.fg("accent", "[del]")} Delete`,
689
+ : `${this.theme.fg("accent", "[space]")} ${chain.enabled ? "Disable" : "Enable"} ${this.theme.fg("accent", "[R]")} Revalidate ${this.theme.fg("accent", "[del]")} Delete`,
733
690
  ...(chain.error ? [this.theme.fg("warning", `Error: ${chain.error}`)] : []),
734
691
  "",
735
692
  ...wrapPlainText(chain.description, width).map((line) => this.theme.fg("muted", line)),
@@ -824,7 +781,7 @@ class ServerManagerModal implements Component, Focusable {
824
781
  for (const [index, definition] of SETTING_DEFINITIONS.entries()) {
825
782
  const selected = index === this.selectedSettingIndex;
826
783
  const prefix = selected ? this.theme.fg("accent", "→") : " ";
827
- const value = settingLabel(definition, this.draftSettings[definition.key]);
784
+ const value = settingLabel(definition, this.options.settings[definition.key]);
828
785
  const reserved = visibleWidth(prefix) + visibleWidth(value) + 3;
829
786
  const label = truncateToWidth(definition.label, Math.max(4, leftWidth - reserved), "…");
830
787
  const gap = " ".repeat(Math.max(1, leftWidth - reserved - visibleWidth(label) + 1));
@@ -862,18 +819,13 @@ class ServerManagerModal implements Component, Focusable {
862
819
  : definition
863
820
  ? [
864
821
  this.theme.fg("accent", this.theme.bold(definition.label)),
865
- this.theme.fg("muted", settingLabel(definition, this.draftSettings[definition.key])),
822
+ this.theme.fg("muted", settingLabel(definition, this.options.settings[definition.key])),
866
823
  "",
867
824
  ...wrapPlainText(definition.description, rightWidth).map((line) =>
868
825
  this.theme.fg("muted", line),
869
826
  ),
870
827
  "",
871
- this.theme.fg("dim", "←/→ change · enter next · ctrl+s save"),
872
- ...(this.settingsBusy
873
- ? ["", this.theme.fg("warning", "Saving staged changes…")]
874
- : this.hasUnsavedChanges()
875
- ? ["", this.theme.fg("warning", "Unsaved changes")]
876
- : []),
828
+ this.theme.fg("dim", "←/→ change · enter next"),
877
829
  ...(this.settingsError
878
830
  ? ["", this.theme.fg("warning", `Error: ${this.settingsError}`)]
879
831
  : []),
@@ -889,18 +841,17 @@ class ServerManagerModal implements Component, Focusable {
889
841
  }
890
842
 
891
843
  private footer(): string {
892
- const pending = this.hasUnsavedChanges() ? " · * unsaved · ctrl+s save" : "";
893
844
  const report = ` · ${PROBLEM_REPORT_SHORTCUT}`;
894
845
  if (this.activeTab === "settings") {
895
- return `tab servers · ↑/↓ navigate · ←/→ change · enter select · ctrl+s save · esc close${pending}${report}`;
846
+ return `tab servers · ↑/↓ navigate · ←/→ change · enter select · esc close${report}`;
896
847
  }
897
848
  if (this.activeTab === "chains") {
898
- return `tab stats · ↑/↓ navigate · space toggle · r revalidate · del delete · esc close${pending}${report}`;
849
+ return "tab stats · ↑/↓ navigate · space toggle · R revalidate · del delete · esc close";
899
850
  }
900
851
  if (this.activeTab === "stats") {
901
- return `tab settings · bounded local rollups · esc close${pending}${report}`;
852
+ return `tab settings · bounded local rollups · esc close${report}`;
902
853
  }
903
- return `tab chains · ←/→ pane · ↑/↓ navigate · space toggle · d discover · esc close${pending}${report}`;
854
+ return `tab chains · ←/→ pane · ↑/↓ navigate · space toggle · D discover · esc close${report}`;
904
855
  }
905
856
 
906
857
  private moveSelection(direction: -1 | 1): void {
@@ -923,31 +874,31 @@ class ServerManagerModal implements Component, Focusable {
923
874
 
924
875
  private toggleSelectedServer(): void {
925
876
  const server = this.selectedServer();
926
- if (!server || server.busy || this.settingsBusy) return;
927
- server.enabled = !server.enabled;
877
+ if (!server || server.busy) return;
878
+ const enabled = !server.enabled;
879
+ server.busy = true;
928
880
  delete server.error;
881
+ void this.options
882
+ .onSetServerEnabled(server, enabled)
883
+ .then((updated) => applyServerUpdate(server, updated))
884
+ .catch((error: unknown) => {
885
+ server.error = summarizeError(error);
886
+ })
887
+ .finally(() => {
888
+ server.busy = false;
889
+ this.requestRender();
890
+ });
929
891
  }
930
892
 
931
893
  private discoverSelected(): void {
932
894
  const server = this.selectedServer();
933
- if (!server || server.busy || this.settingsBusy) return;
934
- if (server.enabled !== this.savedServerEnabled.get(server.name)) {
935
- server.error = "Save this server change before discovering tools";
936
- return;
937
- }
938
- if (!server.enabled) {
939
- server.error = "Enable and save this server before discovering tools";
940
- return;
941
- }
895
+ if (!server || server.busy) return;
942
896
  server.busy = true;
943
897
  delete server.error;
944
898
  this.requestRender();
945
899
  void this.options
946
900
  .onDiscover(server)
947
- .then((updated) => {
948
- applyServerUpdate(server, updated);
949
- this.applyDraftToolPolicy();
950
- })
901
+ .then((updated) => applyServerUpdate(server, updated))
951
902
  .catch((error: unknown) => {
952
903
  server.error = summarizeError(error);
953
904
  })
@@ -959,31 +910,43 @@ class ServerManagerModal implements Component, Focusable {
959
910
 
960
911
  private toggleSelectedTool(): void {
961
912
  const server = this.selectedServer();
962
- if (!server || server.busy || this.settingsBusy) return;
913
+ if (!server || server.busy) return;
963
914
  const tools = this.filteredTools(server);
964
915
  const tool = tools[this.selectedToolIndex];
965
916
  if (!tool || tool.busy) return;
966
- const enabled = !tool.enabled;
967
- this.draftSettings = setToolEnabled(this.draftSettings, server.name, tool.name, enabled);
968
- tool.enabled = enabled;
969
- server.toolCount = server.tools.filter((candidate) => candidate.enabled).length;
970
- this.settingsError = undefined;
917
+ tool.busy = true;
918
+ void this.options
919
+ .onSetToolEnabled(server, tool, !tool.enabled)
920
+ .then((updated) => applyServerUpdate(server, updated))
921
+ .catch((error: unknown) => {
922
+ server.error = summarizeError(error);
923
+ })
924
+ .finally(() => {
925
+ tool.busy = false;
926
+ this.requestRender();
927
+ });
971
928
  }
972
929
 
973
930
  private toggleSelectedChain(): void {
974
931
  const chain = this.selectedChain();
975
- if (!chain || chain.busy || this.settingsBusy) return;
976
- applyChainEnabled(chain, !chain.enabled);
932
+ if (!chain || chain.busy) return;
933
+ chain.busy = true;
977
934
  delete chain.error;
935
+ void this.options
936
+ .onSetChainEnabled(chain, !chain.enabled)
937
+ .then((updated) => this.replaceChains(updated, chain))
938
+ .catch((error: unknown) => {
939
+ chain.error = summarizeError(error);
940
+ })
941
+ .finally(() => {
942
+ chain.busy = false;
943
+ this.requestRender();
944
+ });
978
945
  }
979
946
 
980
947
  private revalidateSelectedChain(): void {
981
948
  const chain = this.selectedChain();
982
- if (!chain || chain.busy || this.settingsBusy) return;
983
- if (chain.enabled !== this.savedChainEnabled.get(chainKey(chain))) {
984
- chain.error = "Save this chain change before revalidation";
985
- return;
986
- }
949
+ if (!chain || chain.busy) return;
987
950
  chain.busy = true;
988
951
  delete chain.error;
989
952
  void this.options
@@ -998,20 +961,8 @@ class ServerManagerModal implements Component, Focusable {
998
961
  });
999
962
  }
1000
963
 
1001
- private replaceChains(
1002
- updated: ChainModalState[],
1003
- selected?: ChainModalState,
1004
- preserveDrafts = true,
1005
- ): void {
1006
- const drafts = preserveDrafts ? this.chainEnabledChanges() : [];
964
+ private replaceChains(updated: ChainModalState[], selected?: ChainModalState): void {
1007
965
  this.options.chains.splice(0, this.options.chains.length, ...updated);
1008
- this.savedChainEnabled = chainEnabledMap(updated);
1009
- for (const draft of drafts) {
1010
- const chain = this.options.chains.find(
1011
- (candidate) => candidate.name === draft.name && candidate.scope === draft.scope,
1012
- );
1013
- if (chain) applyChainEnabled(chain, draft.enabled);
1014
- }
1015
966
  if (selected) {
1016
967
  const index = this.filteredChains().findIndex(
1017
968
  (chain) => chain.name === selected.name && chain.scope === selected.scope,
@@ -1043,8 +994,8 @@ class ServerManagerModal implements Component, Focusable {
1043
994
 
1044
995
  private cycleSelectedSetting(direction: -1 | 1): void {
1045
996
  const definition = SETTING_DEFINITIONS[this.selectedSettingIndex];
1046
- if (!definition || this.settingsBusy) return;
1047
- const current = this.draftSettings[definition.key];
997
+ if (!definition) return;
998
+ const current = this.options.settings[definition.key];
1048
999
  const currentIndex = Math.max(
1049
1000
  0,
1050
1001
  definition.choices.findIndex((choice) => choice.value === current),
@@ -1053,43 +1004,15 @@ class ServerManagerModal implements Component, Focusable {
1053
1004
  definition.choices[cycleIndex(currentIndex, direction, definition.choices.length)];
1054
1005
  if (!choice) return;
1055
1006
  this.settingsError = undefined;
1056
- try {
1057
- this.draftSettings = setEditableSetting(this.draftSettings, definition.key, choice.value);
1058
- } catch (error) {
1059
- this.settingsError = summarizeError(error);
1060
- }
1061
- }
1062
-
1063
- private saveDraft(): void {
1064
- void this.persistDraft();
1065
- }
1066
-
1067
- private async persistDraft(): Promise<boolean> {
1068
- if (this.settingsBusy) return false;
1069
- if (!this.hasUnsavedChanges()) return true;
1070
- this.settingsBusy = true;
1071
- this.settingsError = undefined;
1072
- this.requestRender();
1073
- try {
1074
- const result = await this.options.onSaveChanges(
1075
- cloneSettings(this.draftSettings),
1076
- this.serverEnabledChanges(),
1077
- this.chainEnabledChanges(),
1078
- );
1079
- this.savedSettings = cloneSettings(result.settings);
1080
- this.draftSettings = cloneSettings(result.settings);
1081
- this.options.servers.splice(0, this.options.servers.length, ...result.servers);
1082
- this.savedServerEnabled = serverEnabledMap(result.servers);
1083
- this.replaceChains(result.chains, undefined, false);
1084
- this.applyDraftToolPolicy();
1085
- return true;
1086
- } catch (error) {
1087
- this.settingsError = summarizeError(error);
1088
- return false;
1089
- } finally {
1090
- this.settingsBusy = false;
1091
- this.requestRender();
1092
- }
1007
+ void this.options
1008
+ .onSetSetting(definition.key, choice.value)
1009
+ .then((settings) => {
1010
+ this.options.settings = settings;
1011
+ })
1012
+ .catch((error: unknown) => {
1013
+ this.settingsError = summarizeError(error);
1014
+ })
1015
+ .finally(() => this.requestRender());
1093
1016
  }
1094
1017
 
1095
1018
  private focusProblemReport(): void {
@@ -1100,70 +1023,7 @@ class ServerManagerModal implements Component, Focusable {
1100
1023
  }
1101
1024
 
1102
1025
  private openProblemReport(): void {
1103
- if (this.hasUnsavedChanges()) this.resolveUnsavedClose("report-problem");
1104
- else this.close("report-problem");
1105
- }
1106
-
1107
- private resolveUnsavedClose(result?: ServerManagerResult): void {
1108
- if (this.closePromptBusy || this.settingsBusy) return;
1109
- this.closePromptBusy = true;
1110
- void this.options
1111
- .onResolveUnsaved()
1112
- .then(async (action) => {
1113
- if (action === "discard") {
1114
- this.close(result);
1115
- return;
1116
- }
1117
- if (action === "save" && (await this.persistDraft())) this.close(result);
1118
- })
1119
- .catch((error: unknown) => {
1120
- this.settingsError = summarizeError(error);
1121
- })
1122
- .finally(() => {
1123
- this.closePromptBusy = false;
1124
- this.requestRender();
1125
- });
1126
- }
1127
-
1128
- private hasUnsavedChanges(): boolean {
1129
- return (
1130
- !settingsEqual(this.savedSettings, this.draftSettings) ||
1131
- this.serverEnabledChanges().length > 0 ||
1132
- this.chainEnabledChanges().length > 0
1133
- );
1134
- }
1135
-
1136
- private serverEnabledChanges(): ServerEnabledChange[] {
1137
- return this.options.servers.flatMap((server) => {
1138
- const previousEnabled = this.savedServerEnabled.get(server.name);
1139
- return previousEnabled === undefined || previousEnabled === server.enabled
1140
- ? []
1141
- : [{ name: server.name, previousEnabled, enabled: server.enabled }];
1142
- });
1143
- }
1144
-
1145
- private chainEnabledChanges(): ChainEnabledChange[] {
1146
- return this.options.chains.flatMap((chain) => {
1147
- const previousEnabled = this.savedChainEnabled.get(chainKey(chain));
1148
- return previousEnabled === undefined || previousEnabled === chain.enabled
1149
- ? []
1150
- : [
1151
- {
1152
- name: chain.name,
1153
- scope: chain.scope,
1154
- previousEnabled,
1155
- enabled: chain.enabled,
1156
- },
1157
- ];
1158
- });
1159
- }
1160
-
1161
- private applyDraftToolPolicy(): void {
1162
- for (const server of this.options.servers) {
1163
- const disabled = new Set(this.draftSettings.disabledTools[server.name] ?? []);
1164
- for (const tool of server.tools) tool.enabled = !disabled.has(tool.name);
1165
- server.toolCount = server.tools.filter((tool) => tool.enabled).length;
1166
- }
1026
+ this.close("report-problem");
1167
1027
  }
1168
1028
 
1169
1029
  private filteredServers(): ServerModalState[] {
@@ -1389,38 +1249,6 @@ function serverIcon(server: ServerModalState, theme: Theme): string {
1389
1249
  return theme.fg("warning", "◌");
1390
1250
  }
1391
1251
 
1392
- function serverEnabledMap(servers: readonly ServerModalState[]): Map<string, boolean> {
1393
- return new Map(servers.map((server) => [server.name, server.enabled]));
1394
- }
1395
-
1396
- function chainEnabledMap(chains: readonly ChainModalState[]): Map<string, boolean> {
1397
- return new Map(chains.map((chain) => [chainKey(chain), chain.enabled]));
1398
- }
1399
-
1400
- function chainKey(chain: Pick<ChainModalState, "name" | "scope">): string {
1401
- return `${chain.scope}:${chain.name}`;
1402
- }
1403
-
1404
- function applyChainEnabled(chain: ChainModalState, enabled: boolean): void {
1405
- const shadowed = chain.status === "shadowed";
1406
- chain.enabled = enabled;
1407
- if (shadowed) return;
1408
- chain.status = enabled ? (chain.staleDependencies.length > 0 ? "stale" : "ready") : "disabled";
1409
- }
1410
-
1411
- function cloneSettings(settings: CodeMcpSettings): CodeMcpSettings {
1412
- return {
1413
- ...settings,
1414
- disabledTools: Object.fromEntries(
1415
- Object.entries(settings.disabledTools).map(([server, tools]) => [server, [...tools]]),
1416
- ),
1417
- };
1418
- }
1419
-
1420
- function settingsEqual(left: CodeMcpSettings, right: CodeMcpSettings): boolean {
1421
- return JSON.stringify(left) === JSON.stringify(right);
1422
- }
1423
-
1424
1252
  function settingLabel(definition: SettingDefinition, value: EditableSettingValue): string {
1425
1253
  return definition.choices.find((choice) => choice.value === value)?.label ?? String(value);
1426
1254
  }
package/src/tools.ts CHANGED
@@ -476,11 +476,8 @@ export function registerCodeMcpTools(
476
476
  details: undefined,
477
477
  });
478
478
  if (action === "enable" || action === "disable") {
479
- const applied = await chains.applyEnabled(
480
- [{ name: params.name, scope, enabled: action === "enable" }],
481
- signal,
482
- );
483
- views = applied.chains;
479
+ await chains.setEnabled(params.name, scope, action === "enable", signal);
480
+ views = await chains.list(signal);
484
481
  } else if (action === "revalidate") {
485
482
  await chains.revalidate(params.name, scope, signal);
486
483
  views = await chains.list(signal);