pi-codemcp 1.2.0 → 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
@@ -148,7 +148,7 @@ The Python sidecar enforces catalog cache TTL, execution timeout, per-tool timeo
148
148
 
149
149
  ## Search and execute flow
150
150
 
151
- The agent searches for a capability, inspects the selected exact stub when needed, and executes a compact plan:
151
+ The agent searches for a capability, inspects the selected exact stub when needed, and executes a compact plan. Unscoped searches discover stale or missing server catalogs independently: available servers still return results, while `discovery_failures` explicitly reports unavailable servers. Server-scoped searches remain fail-fast.
152
152
 
153
153
  ```python
154
154
  issues = await linear.list_issues({"assignee": "me", "limit": 50})
@@ -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.0",
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,
@@ -36,6 +34,7 @@ from .models import (
36
34
  SearchDetail,
37
35
  SearchMode,
38
36
  SearchResponse,
37
+ ServerDiscoveryFailure,
39
38
  ServerToolSummary,
40
39
  StatusResponse,
41
40
  UpstreamStatus,
@@ -61,13 +60,6 @@ type JsonObject = json_types.JsonObject
61
60
  type JsonValue = json_types.JsonValue
62
61
 
63
62
 
64
- class ManagerApplyResponse(BaseModel):
65
- model_config = ConfigDict(extra="forbid", strict=True)
66
-
67
- status: StatusResponse
68
- chains: list[ChainStatusView]
69
-
70
-
71
63
  class ServerHandle:
72
64
  def __init__(
73
65
  self,
@@ -186,6 +178,7 @@ class SavedChainHandlers(NamedTuple):
186
178
  execute: Callable[[str, JsonObject], Awaitable[ExecutionResponse]]
187
179
  save: SaveChainHandler
188
180
  list: Callable[[], ChainListResponse]
181
+ set_enabled: Callable[[str, ChainScope, bool], Awaitable[ChainStatusView]]
189
182
  revalidate: Callable[[str, ChainScope], Awaitable[ChainStatusView]]
190
183
  delete: Callable[[str, ChainScope], Awaitable[ChainListResponse]]
191
184
 
@@ -219,6 +212,14 @@ class SavedChainRuntime:
219
212
  def list(self) -> ChainListResponse:
220
213
  return self.handlers.list()
221
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
+
222
223
  async def revalidate(self, name: str, scope: ChainScope) -> ChainStatusView:
223
224
  return await self.handlers.revalidate(name, scope)
224
225
 
@@ -255,6 +256,7 @@ class GatewayRuntime:
255
256
  execute=self._execute_chain,
256
257
  save=self._save_chain,
257
258
  list=self._list_chains,
259
+ set_enabled=self._set_chain_enabled,
258
260
  revalidate=self._revalidate_chain,
259
261
  delete=self._delete_chain,
260
262
  )
@@ -377,7 +379,11 @@ class GatewayRuntime:
377
379
  discovery_servers = ()
378
380
  else:
379
381
  discovery_servers = (server,)
380
- await self._ensure_servers_discovered(discovery_servers)
382
+ if server is None:
383
+ discovery_failures = await self._discover_servers_for_unscoped_search(discovery_servers)
384
+ else:
385
+ await self._ensure_servers_discovered(discovery_servers)
386
+ discovery_failures = []
381
387
  self.stats_store.record_phase("discovery", _elapsed_ms(discovery_started))
382
388
  bounded_limit = min(max(limit, 1), 20)
383
389
  bounded_cursor = max(cursor, 0)
@@ -439,6 +445,7 @@ class GatewayRuntime:
439
445
  has_more=next_cursor is not None,
440
446
  project_scope_available=self.chain_store.project_store is not None,
441
447
  execution_limits=self._execution_limits_view(),
448
+ discovery_failures=discovery_failures,
442
449
  prelude=self.catalog.stub_prelude if include_prelude else None,
443
450
  results=results,
444
451
  )
@@ -772,6 +779,33 @@ class GatewayRuntime:
772
779
  )
773
780
  return response
774
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
+
775
809
  async def _revalidate_chain(self, name: str, scope: ChainScope) -> ChainStatusView:
776
810
  started = time.perf_counter()
777
811
  try:
@@ -918,27 +952,6 @@ class GatewayRuntime:
918
952
  await self._rebuild_catalog()
919
953
  return self.status()
920
954
 
921
- async def apply_manager_changes(
922
- self,
923
- changes: list[ChainEnabledChange],
924
- ) -> ManagerApplyResponse:
925
- previous_settings = self.settings
926
- try:
927
- with self.chain_store.enabled_transaction(changes):
928
- self._load_settings()
929
- await self._rebuild_catalog()
930
- except BaseException:
931
- self.settings = previous_settings
932
- for handle in self.handles.values():
933
- handle.cache.max_age_seconds = previous_settings.cache_ttl_seconds
934
- self.executor.settings = previous_settings.execution_settings()
935
- await self._rebuild_catalog()
936
- raise
937
- return ManagerApplyResponse(
938
- status=self.status(),
939
- chains=self._chain_views(),
940
- )
941
-
942
955
  def _load_settings(self) -> None:
943
956
  self.settings = load_settings(self.settings_path)
944
957
  for handle in self.handles.values():
@@ -981,6 +994,36 @@ class GatewayRuntime:
981
994
  async def _ensure_catalog_complete(self) -> None:
982
995
  await self._ensure_servers_discovered(self.handles.keys())
983
996
 
997
+ async def _discover_servers_for_unscoped_search(
998
+ self,
999
+ server_names: Iterable[str],
1000
+ ) -> list[ServerDiscoveryFailure]:
1001
+ requested = set(server_names)
1002
+ missing = [
1003
+ (name, handle)
1004
+ for name, handle in self.handles.items()
1005
+ if name in requested and handle.tools is None
1006
+ ]
1007
+ if not missing:
1008
+ return []
1009
+ results = await asyncio.gather(
1010
+ *(handle.discover() for _, handle in missing),
1011
+ return_exceptions=True,
1012
+ )
1013
+ failures: list[ServerDiscoveryFailure] = []
1014
+ for (name, _handle), result in zip(missing, results, strict=True):
1015
+ if isinstance(result, Exception):
1016
+ failures.append(
1017
+ ServerDiscoveryFailure(
1018
+ server=name,
1019
+ error=_discovery_error_message(result),
1020
+ )
1021
+ )
1022
+ elif isinstance(result, BaseException):
1023
+ raise result
1024
+ await self._rebuild_catalog()
1025
+ return failures
1026
+
984
1027
  async def _ensure_servers_discovered(self, server_names: Iterable[str]) -> None:
985
1028
  requested = set(server_names)
986
1029
  missing = [
@@ -1170,6 +1213,10 @@ def _elapsed_ms(started: float) -> float:
1170
1213
  return (time.perf_counter() - started) * 1_000
1171
1214
 
1172
1215
 
1216
+ def _discovery_error_message(error: Exception) -> str:
1217
+ return str(error).strip() or type(error).__name__
1218
+
1219
+
1173
1220
  def _parse_code(code: str) -> ast.AST | None:
1174
1221
  normalized = textwrap.dedent(code).strip("\n")
1175
1222
  wrapped = f"async def __codemcp_main():\n{textwrap.indent(normalized, ' ')}\n"
@@ -1276,11 +1323,13 @@ async def reload_settings() -> StatusResponse:
1276
1323
 
1277
1324
 
1278
1325
  @mcp.tool
1279
- async def apply_manager_changes(
1280
- changes: list[ChainEnabledChange],
1281
- ) -> ManagerApplyResponse:
1282
- """Apply staged settings and saved-chain enable changes with one catalog rebuild."""
1283
- 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)
1284
1333
 
1285
1334
 
1286
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/sidecar/models.py CHANGED
@@ -63,6 +63,13 @@ class ServerToolSummary(BaseModel):
63
63
  tool_count: int
64
64
 
65
65
 
66
+ class ServerDiscoveryFailure(BaseModel):
67
+ model_config = ConfigDict(extra="forbid", strict=True)
68
+
69
+ server: str
70
+ error: str
71
+
72
+
66
73
  class ExecutionLimitsView(BaseModel):
67
74
  model_config = ConfigDict(extra="forbid", strict=True)
68
75
 
@@ -85,6 +92,7 @@ class SearchResponse(BaseModel):
85
92
  has_more: bool = False
86
93
  project_scope_available: bool
87
94
  execution_limits: ExecutionLimitsView
95
+ discovery_failures: list[ServerDiscoveryFailure] = Field(default_factory=list)
88
96
  prelude: str | None = None
89
97
  results: list[ToolSchemaView]
90
98
 
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
@@ -30,6 +30,7 @@ interface SearchRenderDetails extends CodeMcpOutputDetails {
30
30
  nextCursor?: number;
31
31
  detail: string;
32
32
  preview: string[];
33
+ discoveryFailures: string[];
33
34
  }
34
35
 
35
36
  const SearchParameters = Type.Object({
@@ -154,7 +155,7 @@ export function registerCodeMcpTools(
154
155
  name: "codemcp_search",
155
156
  label: "MCP Search",
156
157
  description:
157
- "Search configured MCP capabilities or page through compact inventory. Default signature search includes exact stubs for up to three top matches plus compact alternatives; codemcp_inspect loads other selected stubs. Returns ranking evidence, pagination, scope, and execution limits; invalid server names fail with suggestions.",
158
+ "Search configured MCP capabilities or page through compact inventory. Default signature search includes exact stubs for up to three top matches plus compact alternatives; codemcp_inspect loads other selected stubs. Unscoped searches return available results plus explicit discovery failures; scoped searches remain fail-fast. Returns ranking evidence, pagination, scope, and execution limits; invalid server names fail with suggestions.",
158
159
  promptSnippet: "Discover compact MCP capabilities or inventory",
159
160
  promptGuidelines: [...SEARCH_PROMPT_GUIDELINES],
160
161
  parameters: SearchParameters,
@@ -186,6 +187,11 @@ export function registerCodeMcpTools(
186
187
  return [`${String(item.name)} ${String(item.tool_count ?? 0)}`];
187
188
  })
188
189
  : [];
190
+ const discoveryFailures = Array.isArray(result.discovery_failures)
191
+ ? result.discovery_failures.flatMap((item) =>
192
+ isRecord(item) && typeof item.server === "string" ? [item.server] : [],
193
+ )
194
+ : [];
189
195
  return {
190
196
  content: [{ type: "text", text: output.text }],
191
197
  details: {
@@ -197,6 +203,7 @@ export function registerCodeMcpTools(
197
203
  nextCursor: typeof result.next_cursor === "number" ? result.next_cursor : undefined,
198
204
  detail: typeof result.detail === "string" ? result.detail : "signatures",
199
205
  preview,
206
+ discoveryFailures,
200
207
  },
201
208
  };
202
209
  },
@@ -212,13 +219,18 @@ export function registerCodeMcpTools(
212
219
  if (isPartial) return new Text(theme.fg("warning", "Searching catalog..."), 0, 0);
213
220
  if (expanded) return renderExpandedJson(result.content);
214
221
  const details = result.details as SearchRenderDetails | undefined;
222
+ const discoveryFailures = details?.discoveryFailures ?? [];
223
+ const unavailable = discoveryFailures.length;
215
224
  let text = theme.fg(
216
- "success",
217
- `${details?.matchCount ?? 0} matches · ${details?.detail ?? "signatures"} · ${details?.totalToolCount ?? 0} tools · ${details?.serverCount ?? 0} servers`,
225
+ unavailable > 0 ? "warning" : "success",
226
+ `${details?.matchCount ?? 0} matches · ${details?.detail ?? "signatures"} · ${details?.totalToolCount ?? 0} tools · ${details?.serverCount ?? 0} servers${unavailable > 0 ? ` · ${unavailable} unavailable` : ""}`,
218
227
  );
219
228
  for (const name of details?.preview ?? []) {
220
229
  text += `\n${theme.fg("dim", ` ${name}`)}`;
221
230
  }
231
+ for (const server of discoveryFailures) {
232
+ text += `\n${theme.fg("warning", ` unavailable: ${server}`)}`;
233
+ }
222
234
  if (details?.hasMore) {
223
235
  text += `\n${theme.fg("muted", ` more at cursor ${details.nextCursor ?? "?"}`)}`;
224
236
  }
@@ -464,11 +476,8 @@ export function registerCodeMcpTools(
464
476
  details: undefined,
465
477
  });
466
478
  if (action === "enable" || action === "disable") {
467
- const applied = await chains.applyEnabled(
468
- [{ name: params.name, scope, enabled: action === "enable" }],
469
- signal,
470
- );
471
- views = applied.chains;
479
+ await chains.setEnabled(params.name, scope, action === "enable", signal);
480
+ views = await chains.list(signal);
472
481
  } else if (action === "revalidate") {
473
482
  await chains.revalidate(params.name, scope, signal);
474
483
  views = await chains.list(signal);