pi-codemcp 0.1.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/src/output.ts ADDED
@@ -0,0 +1,52 @@
1
+ import {
2
+ DEFAULT_MAX_BYTES,
3
+ DEFAULT_MAX_LINES,
4
+ formatSize,
5
+ truncateHead,
6
+ } from "@earendil-works/pi-coding-agent";
7
+
8
+ export interface CodeMcpOutputDetails {
9
+ truncated: boolean;
10
+ outputBytes: number;
11
+ totalBytes: number;
12
+ outputLines: number;
13
+ totalLines: number;
14
+ outputTokens: number;
15
+ }
16
+
17
+ export interface CodeMcpOutputLimits {
18
+ maxBytes?: number;
19
+ maxLines?: number;
20
+ }
21
+
22
+ export function formatCodeMcpOutput(
23
+ value: unknown,
24
+ limits: CodeMcpOutputLimits = {},
25
+ ): {
26
+ text: string;
27
+ details: CodeMcpOutputDetails;
28
+ } {
29
+ const serialized = JSON.stringify(value, null, 2);
30
+ const truncation = truncateHead(serialized, {
31
+ maxBytes: limits.maxBytes ?? DEFAULT_MAX_BYTES,
32
+ maxLines: limits.maxLines ?? DEFAULT_MAX_LINES,
33
+ });
34
+ let text = truncation.content;
35
+ if (truncation.truncated) {
36
+ text +=
37
+ `\n\n[Output truncated: showing ${truncation.outputLines} of ` +
38
+ `${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ` +
39
+ `${formatSize(truncation.totalBytes)}). The full result was not persisted.]`;
40
+ }
41
+ return {
42
+ text,
43
+ details: {
44
+ truncated: truncation.truncated,
45
+ outputBytes: truncation.outputBytes,
46
+ totalBytes: truncation.totalBytes,
47
+ outputLines: truncation.outputLines,
48
+ totalLines: truncation.totalLines,
49
+ outputTokens: Math.ceil(text.length / 4),
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,144 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readJsonObject, requireJsonObject, writeJsonObjectAtomically } from "./json-file.js";
3
+
4
+ export interface CodeMcpSettings {
5
+ backgroundWarmup: boolean;
6
+ cacheTtlHours: number;
7
+ executionTimeoutSeconds: number;
8
+ toolTimeoutSeconds: number;
9
+ maxCalls: number;
10
+ resultLimitKiB: number;
11
+ outputLimitKiB: number;
12
+ outputLineLimit: number;
13
+ disabledTools: Record<string, string[]>;
14
+ }
15
+
16
+ export type EditableSettingKey = Exclude<keyof CodeMcpSettings, "disabledTools">;
17
+ export type EditableSettingValue = boolean | number;
18
+
19
+ export const DEFAULT_CODEMCP_SETTINGS: Readonly<CodeMcpSettings> = {
20
+ backgroundWarmup: true,
21
+ cacheTtlHours: 24,
22
+ executionTimeoutSeconds: 30,
23
+ toolTimeoutSeconds: 30,
24
+ maxCalls: 50,
25
+ resultLimitKiB: 16,
26
+ outputLimitKiB: 50,
27
+ outputLineLimit: 2_000,
28
+ disabledTools: {},
29
+ };
30
+
31
+ const ALLOWED_KEYS = new Set([
32
+ "version",
33
+ "backgroundWarmup",
34
+ "cacheTtlHours",
35
+ "executionTimeoutSeconds",
36
+ "toolTimeoutSeconds",
37
+ "maxCalls",
38
+ "resultLimitKiB",
39
+ "outputLimitKiB",
40
+ "outputLineLimit",
41
+ "disabledTools",
42
+ ]);
43
+
44
+ export function loadCodeMcpSettings(path: string): CodeMcpSettings {
45
+ if (!existsSync(path)) return cloneDefaults();
46
+ const root = readJsonObject(path, "CodeMCP settings");
47
+ const unknown = Object.keys(root).filter((key) => !ALLOWED_KEYS.has(key));
48
+ if (unknown.length > 0) {
49
+ throw new Error(`Unknown CodeMCP settings: ${unknown.join(", ")}`);
50
+ }
51
+ const version = root.version ?? 1;
52
+ if (version !== 1) throw new Error(`Unsupported CodeMCP settings version: ${String(version)}`);
53
+
54
+ return {
55
+ backgroundWarmup: booleanSetting(root, "backgroundWarmup"),
56
+ cacheTtlHours: integerSetting(root, "cacheTtlHours", 0, 720),
57
+ executionTimeoutSeconds: integerSetting(root, "executionTimeoutSeconds", 1, 300),
58
+ toolTimeoutSeconds: integerSetting(root, "toolTimeoutSeconds", 1, 300),
59
+ maxCalls: integerSetting(root, "maxCalls", 1, 200),
60
+ resultLimitKiB: integerSetting(root, "resultLimitKiB", 1, 1_024),
61
+ outputLimitKiB: integerSetting(root, "outputLimitKiB", 1, 1_024),
62
+ outputLineLimit: integerSetting(root, "outputLineLimit", 1, 10_000),
63
+ disabledTools: disabledToolSetting(root.disabledTools),
64
+ };
65
+ }
66
+
67
+ export function saveCodeMcpSettings(path: string, settings: CodeMcpSettings): void {
68
+ writeJsonObjectAtomically(path, {
69
+ version: 1,
70
+ backgroundWarmup: settings.backgroundWarmup,
71
+ cacheTtlHours: settings.cacheTtlHours,
72
+ executionTimeoutSeconds: settings.executionTimeoutSeconds,
73
+ toolTimeoutSeconds: settings.toolTimeoutSeconds,
74
+ maxCalls: settings.maxCalls,
75
+ resultLimitKiB: settings.resultLimitKiB,
76
+ outputLimitKiB: settings.outputLimitKiB,
77
+ outputLineLimit: settings.outputLineLimit,
78
+ disabledTools: settings.disabledTools,
79
+ });
80
+ }
81
+
82
+ export function setEditableSetting(
83
+ settings: CodeMcpSettings,
84
+ key: EditableSettingKey,
85
+ value: EditableSettingValue,
86
+ ): CodeMcpSettings {
87
+ if (key === "backgroundWarmup") {
88
+ if (typeof value !== "boolean") throw new TypeError(`${key} must be a boolean`);
89
+ return { ...settings, [key]: value };
90
+ }
91
+ if (typeof value !== "number") throw new TypeError(`${key} must be a number`);
92
+ return { ...settings, [key]: value };
93
+ }
94
+
95
+ export function setToolEnabled(
96
+ settings: CodeMcpSettings,
97
+ server: string,
98
+ tool: string,
99
+ enabled: boolean,
100
+ ): CodeMcpSettings {
101
+ const disabled = new Set(settings.disabledTools[server] ?? []);
102
+ if (enabled) disabled.delete(tool);
103
+ else disabled.add(tool);
104
+ const disabledTools = { ...settings.disabledTools };
105
+ if (disabled.size === 0) delete disabledTools[server];
106
+ else disabledTools[server] = [...disabled].sort();
107
+ return { ...settings, disabledTools };
108
+ }
109
+
110
+ function cloneDefaults(): CodeMcpSettings {
111
+ return { ...DEFAULT_CODEMCP_SETTINGS, disabledTools: {} };
112
+ }
113
+
114
+ function booleanSetting(root: Record<string, unknown>, key: "backgroundWarmup"): boolean {
115
+ const value = root[key] ?? DEFAULT_CODEMCP_SETTINGS[key];
116
+ if (typeof value !== "boolean") throw new TypeError(`${key} must be a boolean`);
117
+ return value;
118
+ }
119
+
120
+ function integerSetting(
121
+ root: Record<string, unknown>,
122
+ key: Exclude<EditableSettingKey, "backgroundWarmup">,
123
+ minimum: number,
124
+ maximum: number,
125
+ ): number {
126
+ const value = root[key] ?? DEFAULT_CODEMCP_SETTINGS[key];
127
+ if (typeof value !== "number" || !Number.isInteger(value) || value < minimum || value > maximum) {
128
+ throw new TypeError(`${key} must be an integer from ${minimum} to ${maximum}`);
129
+ }
130
+ return value;
131
+ }
132
+
133
+ function disabledToolSetting(value: unknown): Record<string, string[]> {
134
+ if (value === undefined) return {};
135
+ const root = requireJsonObject(value, "disabledTools");
136
+ return Object.fromEntries(
137
+ Object.entries(root).map(([server, tools]) => {
138
+ if (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string")) {
139
+ throw new TypeError(`disabledTools.${server} must be an array of tool names`);
140
+ }
141
+ return [server, [...new Set(tools)].sort()];
142
+ }),
143
+ );
144
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,332 @@
1
+ import { type ExtensionAPI, highlightCode, keyHint } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import { type ChainScope, nativeChainToolName, type SavedChainManager } from "./chains.js";
5
+ import {
6
+ getTextContent,
7
+ previewExecutionValue,
8
+ renderExecutionResult,
9
+ } from "./execution-rendering.js";
10
+ import type { CodeMcpLifecycle } from "./lifecycle.js";
11
+ import { type CodeMcpOutputDetails, formatCodeMcpOutput } from "./output.js";
12
+
13
+ interface SearchRenderDetails extends CodeMcpOutputDetails {
14
+ matchCount: number;
15
+ totalToolCount: number;
16
+ serverCount: number;
17
+ preview: string[];
18
+ }
19
+
20
+ const SearchParameters = Type.Object({
21
+ query: Type.String({
22
+ minLength: 1,
23
+ description: "Words describing an upstream capability, tool, or saved chain",
24
+ }),
25
+ limit: Type.Optional(
26
+ Type.Integer({
27
+ minimum: 1,
28
+ maximum: 20,
29
+ description: "Maximum compact matches to return (default 5)",
30
+ }),
31
+ ),
32
+ server: Type.Optional(
33
+ Type.String({
34
+ minLength: 1,
35
+ description: "Configured upstream server to search, or chains for saved chains",
36
+ }),
37
+ ),
38
+ });
39
+
40
+ const SaveChainParameters = Type.Object({
41
+ scope: Type.Optional(
42
+ Type.String({
43
+ enum: ["project", "global"],
44
+ description: "Storage scope for the chain (default project)",
45
+ }),
46
+ ),
47
+ name: Type.String({
48
+ minLength: 1,
49
+ maxLength: 64,
50
+ pattern: "^[a-z][a-z0-9_]{0,63}$",
51
+ description: "Stable lowercase SDK method name used as chains.<name>",
52
+ }),
53
+ description: Type.String({
54
+ minLength: 1,
55
+ maxLength: 1000,
56
+ description: "Purpose and appropriate use of the reusable native tool",
57
+ }),
58
+ code: Type.String({
59
+ minLength: 1,
60
+ description:
61
+ "Sandboxed Python body. Read typed arguments from input, call MCP or chains SDK methods, and return a value matching outputSchema.",
62
+ }),
63
+ inputSchema: Type.Record(Type.String(), Type.Unknown(), {
64
+ description: "JSON Schema for the native tool arguments; the root must be an object",
65
+ }),
66
+ outputSchema: Type.Record(Type.String(), Type.Unknown(), {
67
+ description: "Required JSON Schema for the chain return value",
68
+ }),
69
+ });
70
+
71
+ const ExecuteParameters = Type.Object({
72
+ code: Type.String({
73
+ minLength: 1,
74
+ description:
75
+ "Sandboxed Python body. Call typed SDK methods such as await linear.list_issues(arguments) and return a compact final value.",
76
+ }),
77
+ });
78
+
79
+ export function registerCodeMcpTools(
80
+ pi: ExtensionAPI,
81
+ lifecycle: CodeMcpLifecycle,
82
+ chains: SavedChainManager,
83
+ ): void {
84
+ pi.registerTool({
85
+ name: "codemcp_search",
86
+ label: "MCP Search",
87
+ description:
88
+ "Search configured upstream MCP tools and saved chains, returning their typed SDK stubs.",
89
+ promptSnippet: "Search MCP capabilities and reusable chains, then inspect typed SDK stubs",
90
+ promptGuidelines: [
91
+ "Use codemcp_search before codemcp_execute; every upstream or saved-chain match includes the complete typed SDK stub needed to write the execution.",
92
+ ],
93
+ parameters: SearchParameters,
94
+ async execute(_toolCallId, params, signal, onUpdate) {
95
+ onUpdate?.({
96
+ content: [{ type: "text", text: "Searching MCP tools..." }],
97
+ details: undefined,
98
+ });
99
+ const result = await lifecycle.request(
100
+ "search",
101
+ {
102
+ query: params.query,
103
+ limit: params.limit ?? 5,
104
+ ...(params.server === undefined ? {} : { server: params.server }),
105
+ },
106
+ signal,
107
+ );
108
+ const output = formatCodeMcpOutput(result, outputLimits(lifecycle));
109
+ const results = Array.isArray(result.results) ? result.results : [];
110
+ const preview = results
111
+ .slice(0, 3)
112
+ .map((item) => (isRecord(item) ? String(item.call ?? item.name ?? "unknown") : "unknown"));
113
+ const servers = Array.isArray(result.servers)
114
+ ? result.servers.flatMap((item) => {
115
+ if (!isRecord(item)) return [];
116
+ return [`${String(item.name)} ${String(item.tool_count ?? 0)}`];
117
+ })
118
+ : [];
119
+ return {
120
+ content: [{ type: "text", text: output.text }],
121
+ details: {
122
+ ...output.details,
123
+ matchCount: results.length,
124
+ totalToolCount: Number(result.total_tool_count ?? 0),
125
+ serverCount: servers.length,
126
+ preview,
127
+ },
128
+ };
129
+ },
130
+ renderCall(args, theme) {
131
+ return new Text(
132
+ `${theme.fg("toolTitle", theme.bold("MCP Search "))}${theme.fg("accent", `"${args.query}"`)}`,
133
+ 0,
134
+ 0,
135
+ );
136
+ },
137
+ renderResult(result, { expanded, isPartial }, theme) {
138
+ if (isPartial) return new Text(theme.fg("warning", "Searching catalog..."), 0, 0);
139
+ if (expanded) return renderExpandedJson(result.content);
140
+ const details = result.details as SearchRenderDetails | undefined;
141
+ let text = theme.fg(
142
+ "success",
143
+ `${details?.matchCount ?? 0} matches · ${details?.totalToolCount ?? 0} tools · ${details?.serverCount ?? 0} servers`,
144
+ );
145
+ for (const name of details?.preview ?? []) {
146
+ text += `\n${theme.fg("dim", ` ${name}`)}`;
147
+ }
148
+ text += `\n${theme.fg("muted", keyHint("app.tools.expand", "full results"))}`;
149
+ return new Text(text, 0, 0);
150
+ },
151
+ });
152
+
153
+ pi.registerTool({
154
+ name: "codemcp_execute",
155
+ label: "MCP Execute",
156
+ description:
157
+ "Type-check and execute one sandboxed Python MCP call graph. Supports sequential and dependent calls, loops, conditions, cross-server calls, enabled upstream tools, and reusable chains.* calls. The code has no host filesystem, environment, network, or subprocess access. Return a compact final value within the configured result limit; oversized values fail with a shape summary.",
158
+ promptSnippet: "Run a typed, sandboxed multi-call chain across configured MCP servers",
159
+ promptGuidelines: [
160
+ "Use codemcp_execute if you know tool schemas; call the returned server.method facade and use top-level return for the compact final value.",
161
+ "It is always better to execute multiple MCP calls in one codemcp_execute call rather than multiple single-call invocations.",
162
+ "You can compose upstream SDK calls and saved chains.* calls, running independent work with asyncio.gather or dependent work sequentially.",
163
+ ],
164
+ parameters: ExecuteParameters,
165
+ async execute(_toolCallId, params, signal, onUpdate) {
166
+ onUpdate?.({
167
+ content: [{ type: "text", text: "Type-checking MCP chain..." }],
168
+ details: undefined,
169
+ });
170
+ const result = await lifecycle.request("execute", { code: params.code }, signal);
171
+ const output = formatCodeMcpOutput(result, outputLimits(lifecycle));
172
+ const ok = result.ok === true;
173
+ return {
174
+ content: [{ type: "text", text: output.text }],
175
+ details: {
176
+ ...output.details,
177
+ ok,
178
+ failureStage: typeof result.failure_stage === "string" ? result.failure_stage : undefined,
179
+ callsMade: Number(result.calls_made ?? 0),
180
+ chainCalls: Number(result.chain_calls ?? 0),
181
+ preview: previewExecutionValue(ok ? result.result : result.error),
182
+ },
183
+ };
184
+ },
185
+ renderCall(args, theme, context) {
186
+ const code = args.code.trim();
187
+ const lineCount = code ? code.split("\n").length : 0;
188
+ const title = theme.fg("toolTitle", theme.bold("MCP Execute"));
189
+ const codeLabel = theme.fg(
190
+ "accent",
191
+ theme.bold(`Agent code · ${lineCount} ${lineCount === 1 ? "line" : "lines"}`),
192
+ );
193
+ if (context.expanded && code) {
194
+ return new Text(
195
+ `${title}\n${codeLabel}\n${highlightCode(code, "python").join("\n")}`,
196
+ 0,
197
+ 0,
198
+ );
199
+ }
200
+ const firstLine =
201
+ code
202
+ .split("\n")
203
+ .find((line) => line.trim())
204
+ ?.trim() ?? "";
205
+ return new Text(
206
+ `${title} ${theme.fg("muted", "·")} ${codeLabel}${firstLine ? `\n${theme.fg("dim", ` ${truncate(firstLine, 100)}`)}` : ""}`,
207
+ 0,
208
+ 0,
209
+ );
210
+ },
211
+ renderResult(result, state, theme) {
212
+ return renderExecutionResult(result, state, theme);
213
+ },
214
+ });
215
+
216
+ pi.registerTool({
217
+ name: "codemcp_save_chain",
218
+ label: "Save MCP Chain",
219
+ description:
220
+ "Validate and persist a reusable typed MCP chain in project scope by default or global scope when explicitly requested. Project chains override same-named global chains. The effective chain is immediately registered as a native mcp_chain_<name> tool and as chains.<name> inside CodeMCP. Requires explicit input and output JSON Schemas. Saving the same scoped name updates and re-enables it.",
221
+ promptSnippet: "Save a repeated MCP execution as a typed reusable native tool",
222
+ promptGuidelines: [
223
+ "If a user repeatedly performs the same MCP workflow, you may offer to save it with codemcp_save_chain, but do not persist it until the user explicitly asks or accepts.",
224
+ "Use codemcp_save_chain only after the user explicitly asks to save a chain or accepts your suggestion to do so.",
225
+ "When using codemcp_save_chain, parameterize repeated values through the typed input object and provide exact inputSchema and outputSchema contracts.",
226
+ "Save chains in project scope unless the user explicitly asks to make one available globally across projects.",
227
+ ],
228
+ parameters: SaveChainParameters,
229
+ async execute(_toolCallId, params, signal, onUpdate) {
230
+ onUpdate?.({
231
+ content: [{ type: "text", text: `Validating saved chain ${params.name}...` }],
232
+ details: undefined,
233
+ });
234
+ const scope = requireChainScope(params.scope ?? "project");
235
+ const view = await chains.save(
236
+ {
237
+ scope,
238
+ name: params.name,
239
+ description: params.description,
240
+ code: params.code,
241
+ inputSchema: params.inputSchema,
242
+ outputSchema: params.outputSchema,
243
+ },
244
+ signal,
245
+ );
246
+ const result = {
247
+ saved: true,
248
+ scope: view.scope,
249
+ name: view.chain.name,
250
+ native_tool: nativeChainToolName(view.chain.name),
251
+ call: `chains.${view.chain.name}`,
252
+ status: view.status,
253
+ dependencies: view.chain.dependencies.map((dependency) => dependency.call),
254
+ };
255
+ const output = formatCodeMcpOutput(result, outputLimits(lifecycle));
256
+ return {
257
+ content: [{ type: "text", text: output.text }],
258
+ details: {
259
+ ...output.details,
260
+ name: view.chain.name,
261
+ nativeTool: nativeChainToolName(view.chain.name),
262
+ dependencyCount: view.chain.dependencies.length,
263
+ },
264
+ };
265
+ },
266
+ renderCall(args, theme) {
267
+ return new Text(
268
+ `${theme.fg("toolTitle", theme.bold("Save MCP Chain "))}${theme.fg("accent", args.name)} ${theme.fg("muted", `· ${args.scope ?? "project"}`)}`,
269
+ 0,
270
+ 0,
271
+ );
272
+ },
273
+ renderResult(result, { expanded, isPartial }, theme, context) {
274
+ if (isPartial) return new Text(theme.fg("warning", "Validating chain contract..."), 0, 0);
275
+ const details = result.details as
276
+ | (CodeMcpOutputDetails & {
277
+ name: string;
278
+ nativeTool: string;
279
+ dependencyCount: number;
280
+ })
281
+ | undefined;
282
+ if (context.isError || !details) {
283
+ const message = getTextContent(result.content).trim();
284
+ if (expanded) {
285
+ return new Text(theme.fg("error", message || "Saved chain validation failed"), 0, 0);
286
+ }
287
+ const preview = message.split("\n", 1)[0];
288
+ return new Text(
289
+ theme.fg("error", `✗ Save failed${preview ? ` · ${truncate(preview, 120)}` : ""}`),
290
+ 0,
291
+ 0,
292
+ );
293
+ }
294
+ if (expanded) return renderExpandedJson(result.content);
295
+ return new Text(
296
+ theme.fg(
297
+ "success",
298
+ `✓ ${details.name} · ${details.nativeTool} · ${details.dependencyCount} dependencies`,
299
+ ),
300
+ 0,
301
+ 0,
302
+ );
303
+ },
304
+ });
305
+ }
306
+
307
+ function renderExpandedJson(content: readonly unknown[]): Text {
308
+ return new Text(highlightCode(getTextContent(content), "json").join("\n"), 0, 0);
309
+ }
310
+
311
+ function outputLimits(lifecycle: CodeMcpLifecycle): { maxBytes: number; maxLines: number } {
312
+ const settings = lifecycle.loadSettings();
313
+ return {
314
+ maxBytes: settings.outputLimitKiB * 1024,
315
+ maxLines: settings.outputLineLimit,
316
+ };
317
+ }
318
+
319
+ function truncate(value: string, maxLength: number): string {
320
+ return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`;
321
+ }
322
+
323
+ function requireChainScope(value: string): ChainScope {
324
+ if (value !== "project" && value !== "global") {
325
+ throw new TypeError("Saved chain scope must be project or global");
326
+ }
327
+ return value;
328
+ }
329
+
330
+ function isRecord(value: unknown): value is Record<string, unknown> {
331
+ return typeof value === "object" && value !== null && !Array.isArray(value);
332
+ }