pi-codemcp 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/extensions/index.ts +67 -77
- package/package.json +1 -1
- package/sidecar/chains.py +17 -44
- package/sidecar/cli.py +27 -7
- package/sidecar/executor.py +207 -35
- package/sidecar/gateway.py +505 -141
- package/sidecar/mcp_config.py +27 -2
- package/sidecar/refinement_cache.py +154 -0
- package/sidecar/sandbox_api.py +44 -0
- package/sidecar/stats.py +801 -194
- package/sidecar/tool_catalog.py +1 -9
- package/src/chains.ts +63 -34
- package/src/config.ts +8 -39
- package/src/mcp-client.ts +2 -1
- package/src/modal.ts +87 -248
- package/src/prompts.ts +3 -3
- package/src/tools.ts +37 -17
package/README.md
CHANGED
|
@@ -36,7 +36,8 @@ I built this because I care a lot about software that is genuinely fast, efficie
|
|
|
36
36
|
pi-codemcp is deliberately opinionated about operational quality:
|
|
37
37
|
|
|
38
38
|
- Pi startup does not wait for Python or MCP servers.
|
|
39
|
-
- Each upstream connection is lazy and independent.
|
|
39
|
+
- Each upstream connection is lazy and independent. A dead connection is evicted after the original call fails; that call is never replayed, and the next explicit call reconnects.
|
|
40
|
+
- Upstream failures include stable `kind`, `server`, `tool`, `retryable`, `status`, and `message` fields.
|
|
40
41
|
- Tool catalogs are cached per server and invalidated independently.
|
|
41
42
|
- Agent-written code is type-checked before execution.
|
|
42
43
|
- Time, memory, call count, and output size are bounded.
|
|
@@ -172,7 +173,9 @@ return {"number": number["value"], "identifier": saved["identifier"]}
|
|
|
172
173
|
'
|
|
173
174
|
```
|
|
174
175
|
|
|
175
|
-
Incomplete upstream schemas become recursive `JsonValue`, not `Any`;
|
|
176
|
+
Incomplete upstream schemas become recursive `JsonValue`, not `Any`; use the prebound `expect_object`, `expect_list`, `expect_string`, and `expect_integer` helpers to narrow unknown values explicitly. For unfamiliar outputs, `inspect_json(value, samples=2, max_depth=3)` returns a byte-bounded structural summary, cardinality, field sizes, and samples; `samples` is limited to 1–3 and `max_depth` to 1–6 during preflight. The generated prelude documents the sandbox surface: use `import asyncio` with `asyncio.gather`; unavailable host or stdlib APIs are rejected. Preflight type errors happen before any upstream call is made, and oversized final results fail explicitly with the same actionable inspection data.
|
|
177
|
+
|
|
178
|
+
When an oversized value fits the bounded in-memory refinement cache, the failure also returns an opaque `result_ref` and expiry. Pass that reference back as `inputRef` on one follow-up `codemcp_execute`; the retained JSON is exposed as `input`, so code can filter or aggregate it without repeating upstream calls. References expire after five minutes, are valid only in the originating sidecar, and are never persisted.
|
|
176
179
|
|
|
177
180
|
## Saved-chain CLI flow
|
|
178
181
|
|
|
@@ -211,7 +214,7 @@ Rendered Pi output is separately truncated by `outputLimitKiB`; the full oversiz
|
|
|
211
214
|
|
|
212
215
|
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
216
|
|
|
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
|
|
217
|
+
`/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
218
|
|
|
216
219
|
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
220
|
|
package/extensions/index.ts
CHANGED
|
@@ -4,22 +4,24 @@ import {
|
|
|
4
4
|
type ExtensionAPI,
|
|
5
5
|
type ExtensionCommandContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { SavedChainManager } from "../src/chains.js";
|
|
8
|
-
import {
|
|
7
|
+
import { newCodeMcpTraceId, SavedChainManager } from "../src/chains.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 {
|
|
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 = {}) {
|
|
@@ -35,7 +37,7 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
|
|
|
35
37
|
bindProjectChainScope(ctx, lifecycle, chains);
|
|
36
38
|
const [status, savedChains, settings, stats] = await Promise.all([
|
|
37
39
|
lifecycle.request("status", {}),
|
|
38
|
-
chains.list(),
|
|
40
|
+
chains.list(newCodeMcpTraceId("manager")),
|
|
39
41
|
Promise.resolve(lifecycle.loadSettings()),
|
|
40
42
|
lifecycle.request("stats", {}),
|
|
41
43
|
]);
|
|
@@ -50,29 +52,42 @@ 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
|
|
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
|
+
const traceId = newCodeMcpTraceId("manager");
|
|
79
|
+
await chains.setEnabled(chain.name, chain.scope, enabled, traceId);
|
|
80
|
+
return chainStatesFromViews(await chains.list(traceId));
|
|
69
81
|
},
|
|
70
82
|
onRevalidateChain: async (chain) => {
|
|
71
|
-
|
|
72
|
-
|
|
83
|
+
const traceId = newCodeMcpTraceId("manager");
|
|
84
|
+
await chains.revalidate(chain.name, chain.scope, traceId);
|
|
85
|
+
return chainStatesFromViews(await chains.list(traceId));
|
|
86
|
+
},
|
|
87
|
+
onDeleteChain: async (chain) => {
|
|
88
|
+
const traceId = newCodeMcpTraceId("manager");
|
|
89
|
+
return chainStatesFromViews(await chains.delete(chain.name, chain.scope, traceId));
|
|
73
90
|
},
|
|
74
|
-
onDeleteChain: async (chain) =>
|
|
75
|
-
chainStatesFromViews(await chains.delete(chain.name, chain.scope)),
|
|
76
91
|
});
|
|
77
92
|
if (managerResult === "report-problem") await promptForProblemReport(pi, ctx);
|
|
78
93
|
} catch (error) {
|
|
@@ -104,61 +119,36 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
|
|
|
104
119
|
};
|
|
105
120
|
}
|
|
106
121
|
|
|
107
|
-
export async function
|
|
108
|
-
lifecycle: Pick<CodeMcpLifecycle, "configPath" | "
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
export async function discoverServerFromManager(
|
|
123
|
+
lifecycle: Pick<CodeMcpLifecycle, "configPath" | "reload" | "request">,
|
|
124
|
+
server: ServerModalState,
|
|
125
|
+
): Promise<ServerModalState> {
|
|
126
|
+
if (!server.enabled) return setServerEnabledFromManager(lifecycle, server, true);
|
|
127
|
+
return requireServerStatus(
|
|
128
|
+
await lifecycle.request("discover", { server: server.name }),
|
|
129
|
+
server.name,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function setServerEnabledFromManager(
|
|
134
|
+
lifecycle: Pick<CodeMcpLifecycle, "configPath" | "reload" | "request">,
|
|
135
|
+
previous: ServerModalState,
|
|
136
|
+
enabled: boolean,
|
|
137
|
+
): Promise<ServerModalState> {
|
|
138
|
+
setMcpServerEnabled(lifecycle.configPath, previous.name, enabled);
|
|
139
|
+
await lifecycle.reload();
|
|
120
140
|
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
|
-
};
|
|
141
|
+
const status = enabled
|
|
142
|
+
? await lifecycle.request("discover", { server: previous.name })
|
|
143
|
+
: await lifecycle.request("status", {});
|
|
144
|
+
return requireServerStatus(status, previous.name);
|
|
142
145
|
} 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
146
|
try {
|
|
154
|
-
await lifecycle.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
"CodeMCP save failed and runtime rollback also failed",
|
|
159
|
-
);
|
|
147
|
+
const current = requireServerStatus(await lifecycle.request("status", {}), previous.name);
|
|
148
|
+
return { ...current, error: summarizeError(error) };
|
|
149
|
+
} catch {
|
|
150
|
+
throw error;
|
|
160
151
|
}
|
|
161
|
-
throw error;
|
|
162
152
|
}
|
|
163
153
|
}
|
|
164
154
|
|
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/cli.py
CHANGED
|
@@ -66,6 +66,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
66
66
|
help="Type-check and execute one sandboxed Python MCP call graph.",
|
|
67
67
|
)
|
|
68
68
|
_add_runtime_path_args(execute)
|
|
69
|
+
execute.add_argument(
|
|
70
|
+
"--input-ref",
|
|
71
|
+
help="Opaque retained-result reference to expose as input.",
|
|
72
|
+
)
|
|
69
73
|
_add_text_input_args(execute, "code", "Sandboxed Python body to execute.")
|
|
70
74
|
|
|
71
75
|
chain = subcommands.add_parser("chain", help="Manage and run saved MCP chains.")
|
|
@@ -152,27 +156,42 @@ async def _dispatch_runtime_command(
|
|
|
152
156
|
args: argparse.Namespace,
|
|
153
157
|
runtime: gateway.GatewayRuntime,
|
|
154
158
|
) -> BaseModel:
|
|
159
|
+
trace_id = gateway.new_trace_id("cli")
|
|
155
160
|
if args.command == "status":
|
|
156
161
|
return runtime.status()
|
|
157
162
|
if args.command == "discover":
|
|
158
163
|
return await runtime.discover(args.server)
|
|
159
164
|
if args.command == "search":
|
|
160
|
-
return await runtime.search(
|
|
165
|
+
return await runtime.search(
|
|
166
|
+
args.query,
|
|
167
|
+
args.limit,
|
|
168
|
+
args.server,
|
|
169
|
+
trace_id=trace_id,
|
|
170
|
+
)
|
|
161
171
|
if args.command == "execute":
|
|
162
|
-
return await runtime.execute(
|
|
172
|
+
return await runtime.execute(
|
|
173
|
+
_read_text_input(args, "code"),
|
|
174
|
+
trace_id,
|
|
175
|
+
args.input_ref,
|
|
176
|
+
)
|
|
163
177
|
if args.command == "chain":
|
|
164
|
-
return await _dispatch_chain_command(args, runtime)
|
|
178
|
+
return await _dispatch_chain_command(args, runtime, trace_id)
|
|
165
179
|
raise ValueError(f"unknown command: {args.command}")
|
|
166
180
|
|
|
167
181
|
|
|
168
182
|
async def _dispatch_chain_command(
|
|
169
183
|
args: argparse.Namespace,
|
|
170
184
|
runtime: gateway.GatewayRuntime,
|
|
185
|
+
trace_id: str,
|
|
171
186
|
) -> BaseModel:
|
|
172
187
|
if args.chain_command == "list":
|
|
173
|
-
return runtime.chains.list()
|
|
188
|
+
return runtime.chains.list(trace_id)
|
|
174
189
|
if args.chain_command == "run":
|
|
175
|
-
return await runtime.chains.execute(
|
|
190
|
+
return await runtime.chains.execute(
|
|
191
|
+
args.name,
|
|
192
|
+
_read_json_object_input(args, "input"),
|
|
193
|
+
trace_id,
|
|
194
|
+
)
|
|
176
195
|
if args.chain_command == "save":
|
|
177
196
|
return await runtime.chains.save(
|
|
178
197
|
scope=args.scope,
|
|
@@ -181,11 +200,12 @@ async def _dispatch_chain_command(
|
|
|
181
200
|
code=_read_text_input(args, "code"),
|
|
182
201
|
input_schema=_read_json_object_input(args, "input_schema"),
|
|
183
202
|
output_schema=_read_json_object_input(args, "output_schema"),
|
|
203
|
+
trace_id=trace_id,
|
|
184
204
|
)
|
|
185
205
|
if args.chain_command == "revalidate":
|
|
186
|
-
return await runtime.chains.revalidate(args.name, args.scope)
|
|
206
|
+
return await runtime.chains.revalidate(args.name, args.scope, trace_id)
|
|
187
207
|
if args.chain_command == "delete":
|
|
188
|
-
return await runtime.chains.delete(args.name, args.scope)
|
|
208
|
+
return await runtime.chains.delete(args.name, args.scope, trace_id)
|
|
189
209
|
raise ValueError(f"unknown chain command: {args.chain_command}")
|
|
190
210
|
|
|
191
211
|
|