pi-codemcp 0.1.0 → 1.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/tools.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { type ExtensionAPI, highlightCode, keyHint } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
- import { type ChainScope, nativeChainToolName, type SavedChainManager } from "./chains.js";
4
+ import {
5
+ type ChainScope,
6
+ nativeChainToolName,
7
+ type SavedChainManager,
8
+ type SavedChainView,
9
+ } from "./chains.js";
5
10
  import {
6
11
  getTextContent,
7
12
  previewExecutionValue,
@@ -9,34 +14,72 @@ import {
9
14
  } from "./execution-rendering.js";
10
15
  import type { CodeMcpLifecycle } from "./lifecycle.js";
11
16
  import { type CodeMcpOutputDetails, formatCodeMcpOutput } from "./output.js";
17
+ import {
18
+ EXECUTE_PROMPT_GUIDELINES,
19
+ INSPECT_PROMPT_GUIDELINES,
20
+ MANAGE_CHAIN_PROMPT_GUIDELINES,
21
+ SAVE_CHAIN_PROMPT_GUIDELINES,
22
+ SEARCH_PROMPT_GUIDELINES,
23
+ } from "./prompts.js";
12
24
 
13
25
  interface SearchRenderDetails extends CodeMcpOutputDetails {
14
26
  matchCount: number;
15
27
  totalToolCount: number;
16
28
  serverCount: number;
29
+ hasMore: boolean;
30
+ nextCursor?: number;
31
+ detail: string;
17
32
  preview: string[];
18
33
  }
19
34
 
20
35
  const SearchParameters = Type.Object({
21
- query: Type.String({
22
- minLength: 1,
23
- description: "Words describing an upstream capability, tool, or saved chain",
24
- }),
36
+ query: Type.Optional(
37
+ Type.String({
38
+ minLength: 1,
39
+ description: "Capability words for search mode; omit for inventory mode",
40
+ }),
41
+ ),
42
+ mode: Type.Optional(
43
+ Type.String({
44
+ enum: ["search", "inventory"],
45
+ description: "Rank by capability (default) or page through compact inventory",
46
+ }),
47
+ ),
48
+ detail: Type.Optional(
49
+ Type.String({
50
+ enum: ["names", "signatures", "full"],
51
+ description: "Disclosure level (default signatures); use inspect for selected full stubs",
52
+ }),
53
+ ),
25
54
  limit: Type.Optional(
26
55
  Type.Integer({
27
56
  minimum: 1,
28
57
  maximum: 20,
29
- description: "Maximum compact matches to return (default 5)",
58
+ description: "Maximum matches per page (default 5)",
59
+ }),
60
+ ),
61
+ cursor: Type.Optional(
62
+ Type.Integer({
63
+ minimum: 0,
64
+ description: "Pagination cursor returned by a previous search",
30
65
  }),
31
66
  ),
32
67
  server: Type.Optional(
33
68
  Type.String({
34
69
  minLength: 1,
35
- description: "Configured upstream server to search, or chains for saved chains",
70
+ description: "Exact configured server name, or chains for saved chains",
36
71
  }),
37
72
  ),
38
73
  });
39
74
 
75
+ const InspectParameters = Type.Object({
76
+ calls: Type.Array(Type.String({ minLength: 1 }), {
77
+ minItems: 1,
78
+ maxItems: 20,
79
+ description: "Exact call identifiers returned by search, such as grafana.query_prometheus",
80
+ }),
81
+ });
82
+
40
83
  const SaveChainParameters = Type.Object({
41
84
  scope: Type.Optional(
42
85
  Type.String({
@@ -68,6 +111,32 @@ const SaveChainParameters = Type.Object({
68
111
  }),
69
112
  });
70
113
 
114
+ const ManageChainsParameters = Type.Object({
115
+ action: Type.String({
116
+ enum: ["list", "enable", "disable", "revalidate", "delete"],
117
+ description: "Chain management action",
118
+ }),
119
+ name: Type.Optional(
120
+ Type.String({
121
+ minLength: 1,
122
+ maxLength: 64,
123
+ pattern: "^[a-z][a-z0-9_]{0,63}$",
124
+ description: "Saved-chain name; required for mutations",
125
+ }),
126
+ ),
127
+ scope: Type.Optional(
128
+ Type.String({
129
+ enum: ["project", "global"],
130
+ description: "Saved-chain scope; required for mutations",
131
+ }),
132
+ ),
133
+ confirmedByUser: Type.Optional(
134
+ Type.Boolean({
135
+ description: "Must be true for enable, disable, revalidate, or delete",
136
+ }),
137
+ ),
138
+ });
139
+
71
140
  const ExecuteParameters = Type.Object({
72
141
  code: Type.String({
73
142
  minLength: 1,
@@ -85,11 +154,9 @@ export function registerCodeMcpTools(
85
154
  name: "codemcp_search",
86
155
  label: "MCP Search",
87
156
  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
- ],
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
+ promptSnippet: "Discover compact MCP capabilities or inventory",
159
+ promptGuidelines: [...SEARCH_PROMPT_GUIDELINES],
93
160
  parameters: SearchParameters,
94
161
  async execute(_toolCallId, params, signal, onUpdate) {
95
162
  onUpdate?.({
@@ -99,8 +166,11 @@ export function registerCodeMcpTools(
99
166
  const result = await lifecycle.request(
100
167
  "search",
101
168
  {
102
- query: params.query,
169
+ ...(params.query === undefined ? {} : { query: params.query }),
170
+ mode: params.mode ?? "search",
171
+ detail: params.detail ?? "signatures",
103
172
  limit: params.limit ?? 5,
173
+ cursor: params.cursor ?? 0,
104
174
  ...(params.server === undefined ? {} : { server: params.server }),
105
175
  },
106
176
  signal,
@@ -123,13 +193,17 @@ export function registerCodeMcpTools(
123
193
  matchCount: results.length,
124
194
  totalToolCount: Number(result.total_tool_count ?? 0),
125
195
  serverCount: servers.length,
196
+ hasMore: result.has_more === true,
197
+ nextCursor: typeof result.next_cursor === "number" ? result.next_cursor : undefined,
198
+ detail: typeof result.detail === "string" ? result.detail : "signatures",
126
199
  preview,
127
200
  },
128
201
  };
129
202
  },
130
203
  renderCall(args, theme) {
204
+ const subject = args.mode === "inventory" ? "inventory" : `"${args.query ?? ""}"`;
131
205
  return new Text(
132
- `${theme.fg("toolTitle", theme.bold("MCP Search "))}${theme.fg("accent", `"${args.query}"`)}`,
206
+ `${theme.fg("toolTitle", theme.bold("MCP Search "))}${theme.fg("accent", subject)}`,
133
207
  0,
134
208
  0,
135
209
  );
@@ -140,27 +214,75 @@ export function registerCodeMcpTools(
140
214
  const details = result.details as SearchRenderDetails | undefined;
141
215
  let text = theme.fg(
142
216
  "success",
143
- `${details?.matchCount ?? 0} matches · ${details?.totalToolCount ?? 0} tools · ${details?.serverCount ?? 0} servers`,
217
+ `${details?.matchCount ?? 0} matches · ${details?.detail ?? "signatures"} · ${details?.totalToolCount ?? 0} tools · ${details?.serverCount ?? 0} servers`,
144
218
  );
145
219
  for (const name of details?.preview ?? []) {
146
220
  text += `\n${theme.fg("dim", ` ${name}`)}`;
147
221
  }
222
+ if (details?.hasMore) {
223
+ text += `\n${theme.fg("muted", ` more at cursor ${details.nextCursor ?? "?"}`)}`;
224
+ }
148
225
  text += `\n${theme.fg("muted", keyHint("app.tools.expand", "full results"))}`;
149
226
  return new Text(text, 0, 0);
150
227
  },
151
228
  });
152
229
 
230
+ pi.registerTool({
231
+ name: "codemcp_inspect",
232
+ label: "MCP Inspect",
233
+ description:
234
+ "Return the exact typed SDK stubs for selected call identifiers from codemcp_search. The response deduplicates the shared JsonValue/type prelude.",
235
+ promptSnippet: "Load exact typed contracts for selected MCP calls",
236
+ promptGuidelines: [...INSPECT_PROMPT_GUIDELINES],
237
+ parameters: InspectParameters,
238
+ async execute(_toolCallId, params, signal, onUpdate) {
239
+ onUpdate?.({
240
+ content: [{ type: "text", text: "Inspecting MCP tool contracts..." }],
241
+ details: undefined,
242
+ });
243
+ const result = await lifecycle.request("inspect", { calls: params.calls }, signal);
244
+ const output = formatCodeMcpOutput(result, outputLimits(lifecycle));
245
+ const results = Array.isArray(result.results) ? result.results : [];
246
+ return {
247
+ content: [{ type: "text", text: output.text }],
248
+ details: {
249
+ ...output.details,
250
+ matchCount: results.length,
251
+ preview: results
252
+ .slice(0, 3)
253
+ .flatMap((item) =>
254
+ isRecord(item) && typeof item.call === "string" ? [item.call] : [],
255
+ ),
256
+ },
257
+ };
258
+ },
259
+ renderCall(args, theme) {
260
+ return new Text(
261
+ `${theme.fg("toolTitle", theme.bold("MCP Inspect "))}${theme.fg("accent", `${args.calls.length} calls`)}`,
262
+ 0,
263
+ 0,
264
+ );
265
+ },
266
+ renderResult(result, { expanded, isPartial }, theme) {
267
+ if (isPartial) return new Text(theme.fg("warning", "Loading exact contracts..."), 0, 0);
268
+ if (expanded) return renderExpandedJson(result.content);
269
+ const details = result.details as
270
+ | (CodeMcpOutputDetails & { matchCount: number; preview: string[] })
271
+ | undefined;
272
+ let text = theme.fg("success", `${details?.matchCount ?? 0} exact contracts`);
273
+ for (const call of details?.preview ?? []) text += `\n${theme.fg("dim", ` ${call}`)}`;
274
+ text += `\n${theme.fg("muted", keyHint("app.tools.expand", "full stubs"))}`;
275
+ return new Text(text, 0, 0);
276
+ },
277
+ });
278
+
153
279
  pi.registerTool({
154
280
  name: "codemcp_execute",
155
281
  label: "MCP Execute",
156
282
  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
- ],
283
+ "Type-check and execute one bounded sandboxed Python MCP call graph. Use it when code can deterministically process intermediate results; preserve a model turn for semantic decisions or approvals. The sandbox has no host filesystem, environment, network, or subprocess access. Oversized results fail with bounded shape, size, and sample diagnostics.",
284
+ promptSnippet: "Run a bounded typed MCP workflow and return a compact result",
285
+ promptGuidelines: [...EXECUTE_PROMPT_GUIDELINES],
164
286
  parameters: ExecuteParameters,
165
287
  async execute(_toolCallId, params, signal, onUpdate) {
166
288
  onUpdate?.({
@@ -168,8 +290,17 @@ export function registerCodeMcpTools(
168
290
  details: undefined,
169
291
  });
170
292
  const result = await lifecycle.request("execute", { code: params.code }, signal);
171
- const output = formatCodeMcpOutput(result, outputLimits(lifecycle));
172
293
  const ok = result.ok === true;
294
+ const modelValue = ok
295
+ ? result.result
296
+ : {
297
+ failure_stage: result.failure_stage,
298
+ error: result.error,
299
+ shape: result.shape,
300
+ calls_made: result.calls_made,
301
+ chain_calls: result.chain_calls,
302
+ };
303
+ const output = formatCodeMcpOutput(modelValue, outputLimits(lifecycle));
173
304
  return {
174
305
  content: [{ type: "text", text: output.text }],
175
306
  details: {
@@ -178,6 +309,7 @@ export function registerCodeMcpTools(
178
309
  failureStage: typeof result.failure_stage === "string" ? result.failure_stage : undefined,
179
310
  callsMade: Number(result.calls_made ?? 0),
180
311
  chainCalls: Number(result.chain_calls ?? 0),
312
+ ...(isRecord(result.timings) ? { timings: result.timings } : {}),
181
313
  preview: previewExecutionValue(ok ? result.result : result.error),
182
314
  },
183
315
  };
@@ -219,19 +351,19 @@ export function registerCodeMcpTools(
219
351
  description:
220
352
  "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
353
  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
- ],
354
+ promptGuidelines: [...SAVE_CHAIN_PROMPT_GUIDELINES],
228
355
  parameters: SaveChainParameters,
229
356
  async execute(_toolCallId, params, signal, onUpdate) {
357
+ const scope = requireChainScope(params.scope ?? "project");
358
+ if (scope === "project" && lifecycle.projectChainsPath === undefined) {
359
+ throw new Error(
360
+ "Project saved-chain scope is unavailable in this session; ask before using global scope",
361
+ );
362
+ }
230
363
  onUpdate?.({
231
364
  content: [{ type: "text", text: `Validating saved chain ${params.name}...` }],
232
365
  details: undefined,
233
366
  });
234
- const scope = requireChainScope(params.scope ?? "project");
235
367
  const view = await chains.save(
236
368
  {
237
369
  scope,
@@ -302,18 +434,120 @@ export function registerCodeMcpTools(
302
434
  );
303
435
  },
304
436
  });
437
+
438
+ pi.registerTool({
439
+ name: "codemcp_manage_chains",
440
+ label: "Manage MCP Chains",
441
+ description:
442
+ "List saved chains, or explicitly enable, disable, revalidate, or delete one scoped chain. Mutations require confirmedByUser=true and never bypass dependency checks.",
443
+ promptSnippet: "List or explicitly manage saved MCP chains",
444
+ promptGuidelines: [...MANAGE_CHAIN_PROMPT_GUIDELINES],
445
+ parameters: ManageChainsParameters,
446
+ async execute(_toolCallId, params, signal, onUpdate) {
447
+ const action = params.action;
448
+ let views: SavedChainView[];
449
+ if (action === "list") {
450
+ views = await chains.list(signal);
451
+ } else {
452
+ if (params.confirmedByUser !== true) {
453
+ throw new Error(`${action} requires confirmedByUser=true after explicit user approval`);
454
+ }
455
+ if (params.name === undefined || params.scope === undefined) {
456
+ throw new Error(`${action} requires name and scope`);
457
+ }
458
+ const scope = requireChainScope(params.scope);
459
+ if (scope === "project" && lifecycle.projectChainsPath === undefined) {
460
+ throw new Error("Project saved-chain scope is unavailable in this session");
461
+ }
462
+ onUpdate?.({
463
+ content: [{ type: "text", text: `${action} saved chain ${params.name}...` }],
464
+ details: undefined,
465
+ });
466
+ if (action === "enable" || action === "disable") {
467
+ const applied = await chains.applyEnabled(
468
+ [{ name: params.name, scope, enabled: action === "enable" }],
469
+ signal,
470
+ );
471
+ views = applied.chains;
472
+ } else if (action === "revalidate") {
473
+ await chains.revalidate(params.name, scope, signal);
474
+ views = await chains.list(signal);
475
+ } else {
476
+ views = await chains.delete(params.name, scope, signal);
477
+ }
478
+ }
479
+ const result = {
480
+ action,
481
+ project_scope_available: lifecycle.projectChainsPath !== undefined,
482
+ chains: views.map(compactChainView),
483
+ };
484
+ const output = formatCodeMcpOutput(result, outputLimits(lifecycle));
485
+ return {
486
+ content: [{ type: "text", text: output.text }],
487
+ details: {
488
+ ...output.details,
489
+ action,
490
+ chainCount: views.length,
491
+ },
492
+ };
493
+ },
494
+ renderCall(args, theme) {
495
+ return new Text(
496
+ `${theme.fg("toolTitle", theme.bold("Manage MCP Chains "))}${theme.fg("accent", args.action)}${args.name ? ` ${theme.fg("muted", args.name)}` : ""}`,
497
+ 0,
498
+ 0,
499
+ );
500
+ },
501
+ renderResult(result, { expanded, isPartial }, theme, context) {
502
+ if (isPartial) return new Text(theme.fg("warning", "Managing saved chains..."), 0, 0);
503
+ const details = result.details as
504
+ | (CodeMcpOutputDetails & { action: string; chainCount: number })
505
+ | undefined;
506
+ if (context.isError || !details) {
507
+ return new Text(
508
+ theme.fg("error", getTextContent(result.content).trim() || "Chain management failed"),
509
+ 0,
510
+ 0,
511
+ );
512
+ }
513
+ if (expanded) return renderExpandedJson(result.content);
514
+ return new Text(
515
+ theme.fg("success", `${details.action} · ${details.chainCount} chains`),
516
+ 0,
517
+ 0,
518
+ );
519
+ },
520
+ });
521
+ }
522
+
523
+ function compactChainView(view: SavedChainView) {
524
+ return {
525
+ name: view.chain.name,
526
+ scope: view.scope,
527
+ status: view.status,
528
+ enabled: view.chain.enabled,
529
+ description: view.chain.description,
530
+ native_tool: nativeChainToolName(view.chain.name),
531
+ call: `chains.${view.chain.name}`,
532
+ stale_dependencies: view.staleDependencies,
533
+ called_by: view.calledBy,
534
+ };
305
535
  }
306
536
 
307
537
  function renderExpandedJson(content: readonly unknown[]): Text {
308
- return new Text(highlightCode(getTextContent(content), "json").join("\n"), 0, 0);
538
+ const raw = getTextContent(content);
539
+ let formatted = raw;
540
+ try {
541
+ formatted = JSON.stringify(JSON.parse(raw), null, 2);
542
+ } catch {
543
+ // Keep explicit non-JSON errors readable.
544
+ }
545
+ return new Text(highlightCode(formatted, "json").join("\n"), 0, 0);
309
546
  }
310
547
 
311
- function outputLimits(lifecycle: CodeMcpLifecycle): { maxBytes: number; maxLines: number } {
548
+ function outputLimits(lifecycle: CodeMcpLifecycle): { maxBytes: number } {
312
549
  const settings = lifecycle.loadSettings();
313
- return {
314
- maxBytes: settings.outputLimitKiB * 1024,
315
- maxLines: settings.outputLineLimit,
316
- };
550
+ return { maxBytes: settings.outputLimitKiB * 1024 };
317
551
  }
318
552
 
319
553
  function truncate(value: string, maxLength: number): string {