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 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`; unknown values must be narrowed explicitly before typed use. For unfamiliar outputs, `inspect_json(value, samples=2, max_depth=3)` returns a byte-bounded structural summary, cardinality, field sizes, and samples. Preflight type errors happen before any upstream call is made, and oversized final results fail explicitly with the same actionable inspection data.
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 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.
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
 
@@ -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 { setMcpServersEnabled } from "../src/config.js";
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 { 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 = {}) {
@@ -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
- 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
+ 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
- await chains.revalidate(chain.name, chain.scope);
72
- return chainStatesFromViews(await chains.list());
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 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;
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
- 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
- };
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.reload();
155
- } catch (rollbackError) {
156
- throw new AggregateError(
157
- [error, rollbackError],
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codemcp",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
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)
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(args.query, args.limit, args.server)
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(_read_text_input(args, "code"))
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(args.name, _read_json_object_input(args, "input"))
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