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 +2 -2
- package/extensions/index.ts +57 -71
- package/package.json +1 -1
- package/sidecar/chains.py +17 -44
- package/sidecar/gateway.py +85 -36
- package/sidecar/mcp_config.py +27 -2
- package/sidecar/models.py +8 -0
- package/src/chains.ts +11 -22
- package/src/config.ts +8 -39
- package/src/mcp-client.ts +1 -1
- package/src/modal.ts +76 -248
- package/src/tools.ts +17 -8
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
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
108
|
-
lifecycle: Pick<CodeMcpLifecycle, "configPath" | "
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
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
|
|
8
|
+
from contextlib import suppress
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
) ->
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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)
|
package/sidecar/gateway.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
package/sidecar/mcp_config.py
CHANGED
|
@@ -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 =
|
|
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
|
|
138
|
-
|
|
126
|
+
async setEnabled(
|
|
127
|
+
name: string,
|
|
128
|
+
scope: ChainScope,
|
|
129
|
+
enabled: boolean,
|
|
139
130
|
signal?: AbortSignal,
|
|
140
|
-
): Promise<
|
|
131
|
+
): Promise<SavedChainView> {
|
|
141
132
|
const result = await this.lifecycle.request(
|
|
142
|
-
"
|
|
143
|
-
{
|
|
144
|
-
changes: changes.map((change) => ({ ...change })),
|
|
145
|
-
},
|
|
133
|
+
"set_chain_enabled",
|
|
134
|
+
{ name, scope, enabled },
|
|
146
135
|
signal,
|
|
147
136
|
);
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
this.
|
|
151
|
-
return
|
|
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
|
|
12
|
+
const server = requireJsonObject(servers[name], `MCP server ${JSON.stringify(name)}`);
|
|
13
|
+
const updatedServer: JsonRecord = { ...server };
|
|
29
14
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
): Promise<
|
|
140
|
-
|
|
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 (
|
|
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
|
-
|
|
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 === "
|
|
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 === "
|
|
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", "[
|
|
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", "[
|
|
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.
|
|
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.
|
|
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
|
|
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 ·
|
|
846
|
+
return `tab servers · ↑/↓ navigate · ←/→ change · enter select · esc close${report}`;
|
|
896
847
|
}
|
|
897
848
|
if (this.activeTab === "chains") {
|
|
898
|
-
return
|
|
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${
|
|
852
|
+
return `tab settings · bounded local rollups · esc close${report}`;
|
|
902
853
|
}
|
|
903
|
-
return `tab chains · ←/→ pane · ↑/↓ navigate · space toggle ·
|
|
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
|
|
927
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
|
976
|
-
|
|
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
|
|
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
|
|
1047
|
-
const current = this.
|
|
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
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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);
|