surgent 0.7.0-alpha.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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -0
  3. package/bin/surgent.js +211 -0
  4. package/dist/optimizers/LICENSE +21 -0
  5. package/dist/optimizers/index.js +1984 -0
  6. package/dist/optimizers/index.js.map +7 -0
  7. package/dist/optimizers/package.json +31 -0
  8. package/package.json +45 -0
  9. package/src/agent/built-in/documenter.md +58 -0
  10. package/src/agent/built-in/general.md +107 -0
  11. package/src/agent/built-in/planner.md +73 -0
  12. package/src/agent/built-in/scout.md +97 -0
  13. package/src/agent/command.ts +140 -0
  14. package/src/agent/helpers.ts +95 -0
  15. package/src/agent/index.ts +9 -0
  16. package/src/agent/storage.ts +287 -0
  17. package/src/agent/types.ts +28 -0
  18. package/src/checkpoint/git.ts +173 -0
  19. package/src/checkpoint/index.ts +117 -0
  20. package/src/checkpoint/snapshot.ts +28 -0
  21. package/src/checkpoint/stage.ts +59 -0
  22. package/src/checkpoint/store.ts +108 -0
  23. package/src/cleanup/checkpoint.ts +31 -0
  24. package/src/cleanup/helpers.ts +24 -0
  25. package/src/cleanup/index.ts +21 -0
  26. package/src/cleanup/permission.ts +74 -0
  27. package/src/cleanup/subsession.ts +46 -0
  28. package/src/commands/helpers.ts +217 -0
  29. package/src/commands/index.ts +79 -0
  30. package/src/commands/render.ts +95 -0
  31. package/src/commands/types.ts +11 -0
  32. package/src/mcp-client/call-tool.ts +143 -0
  33. package/src/mcp-client/client.ts +90 -0
  34. package/src/mcp-client/command.ts +257 -0
  35. package/src/mcp-client/helpers.ts +153 -0
  36. package/src/mcp-client/index.ts +21 -0
  37. package/src/mcp-client/list-tools.ts +84 -0
  38. package/src/mcp-client/storage.ts +190 -0
  39. package/src/mcp-client/types.ts +34 -0
  40. package/src/mcp-client/validation.ts +115 -0
  41. package/src/optimizers/compactor/bash.ts +159 -0
  42. package/src/optimizers/compactor/grep.ts +141 -0
  43. package/src/optimizers/compactor/index.ts +132 -0
  44. package/src/optimizers/deduplicator/helpers.ts +75 -0
  45. package/src/optimizers/deduplicator/index.ts +23 -0
  46. package/src/optimizers/deduplicator/resources.ts +77 -0
  47. package/src/optimizers/deduplicator/state.ts +119 -0
  48. package/src/optimizers/deduplicator/types.ts +14 -0
  49. package/src/optimizers/entries.ts +104 -0
  50. package/src/optimizers/index.ts +17 -0
  51. package/src/optimizers/inspector/helpers.ts +60 -0
  52. package/src/optimizers/inspector/index.ts +89 -0
  53. package/src/optimizers/inspector/inspect.ts +88 -0
  54. package/src/optimizers/inspector/types.ts +7 -0
  55. package/src/optimizers/languages/go.ts +79 -0
  56. package/src/optimizers/languages/grammar.ts +200 -0
  57. package/src/optimizers/languages/index.ts +75 -0
  58. package/src/optimizers/languages/java.ts +64 -0
  59. package/src/optimizers/languages/python.ts +63 -0
  60. package/src/optimizers/languages/rust.ts +71 -0
  61. package/src/optimizers/languages/symbols.ts +95 -0
  62. package/src/optimizers/languages/tree-sitter-languages.d.ts +23 -0
  63. package/src/optimizers/languages/types.ts +134 -0
  64. package/src/optimizers/languages/typescript.ts +116 -0
  65. package/src/optimizers/mapper/files.ts +94 -0
  66. package/src/optimizers/mapper/index.ts +133 -0
  67. package/src/optimizers/mapper/types.ts +6 -0
  68. package/src/optimizers/pruner/cleanup.ts +121 -0
  69. package/src/optimizers/pruner/context.ts +46 -0
  70. package/src/optimizers/pruner/index.ts +45 -0
  71. package/src/optimizers/pruner/session.ts +34 -0
  72. package/src/optimizers/pruner/types.ts +18 -0
  73. package/src/permission/bash.ts +124 -0
  74. package/src/permission/command.ts +111 -0
  75. package/src/permission/components/prompt.ts +255 -0
  76. package/src/permission/components/rules-list.ts +342 -0
  77. package/src/permission/constants.ts +48 -0
  78. package/src/permission/helpers.ts +156 -0
  79. package/src/permission/index.ts +134 -0
  80. package/src/permission/pattern.ts +51 -0
  81. package/src/permission/piignore.ts +148 -0
  82. package/src/permission/precedence.ts +54 -0
  83. package/src/permission/resolution.ts +116 -0
  84. package/src/permission/storage.ts +142 -0
  85. package/src/permission/types.ts +57 -0
  86. package/src/questionnaire/component.ts +357 -0
  87. package/src/questionnaire/helpers.ts +220 -0
  88. package/src/questionnaire/index.ts +67 -0
  89. package/src/questionnaire/schemas.ts +50 -0
  90. package/src/questionnaire/types.ts +47 -0
  91. package/src/redactor/index.ts +34 -0
  92. package/src/redactor/patterns.ts +234 -0
  93. package/src/redactor/secrets.ts +113 -0
  94. package/src/subagent/helpers.ts +93 -0
  95. package/src/subagent/index.ts +81 -0
  96. package/src/subagent/storage.ts +100 -0
  97. package/src/subagent/subsession.ts +266 -0
  98. package/src/subagent/types.ts +83 -0
  99. package/src/subagent/validation.ts +100 -0
  100. package/src/ui/components/action-select-list.ts +165 -0
  101. package/src/ui/components/bash-mode.ts +281 -0
  102. package/src/ui/components/extended-select-list.ts +166 -0
  103. package/src/ui/components/form-field.ts +184 -0
  104. package/src/ui/components/form.ts +179 -0
  105. package/src/ui/components/frame.ts +60 -0
  106. package/src/ui/components/input-mode-indicator.ts +64 -0
  107. package/src/ui/components/keybound.ts +150 -0
  108. package/src/ui/components/lines.ts +27 -0
  109. package/src/ui/components/placeholder-input.ts +59 -0
  110. package/src/ui/components/scoped-input.ts +78 -0
  111. package/src/ui/components/scrollable-view.ts +155 -0
  112. package/src/ui/index.ts +40 -0
  113. package/src/utils.ts +206 -0
  114. package/src/web-tools/index.ts +15 -0
  115. package/src/web-tools/providers/brave.ts +55 -0
  116. package/src/web-tools/providers/firecrawl.ts +66 -0
  117. package/src/web-tools/providers/index.ts +50 -0
  118. package/src/web-tools/providers/jina.ts +48 -0
  119. package/src/web-tools/providers/native.ts +57 -0
  120. package/src/web-tools/providers/tavily.ts +56 -0
  121. package/src/web-tools/settings.ts +15 -0
  122. package/src/web-tools/web-fetch/helpers.ts +66 -0
  123. package/src/web-tools/web-fetch/index.ts +91 -0
  124. package/src/web-tools/web-fetch/parser.ts +51 -0
  125. package/src/web-tools/web-fetch/storage.ts +65 -0
  126. package/src/web-tools/web-fetch/types.ts +8 -0
  127. package/src/web-tools/web-login/helpers.ts +79 -0
  128. package/src/web-tools/web-login/index.ts +100 -0
  129. package/src/web-tools/web-login/types.ts +4 -0
  130. package/src/web-tools/web-search/helpers.ts +36 -0
  131. package/src/web-tools/web-search/index.ts +98 -0
  132. package/src/web-tools/web-search/types.ts +15 -0
@@ -0,0 +1,257 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key } from "@earendil-works/pi-tui";
3
+ import {
4
+ loadMcpConfigSet,
5
+ normalizeServerConfig,
6
+ readConfigFile,
7
+ updateServerConfig,
8
+ } from "./storage.js";
9
+ import type { McpServer, McpTransport, ResolvedMcpServer } from "./types.js";
10
+ import { ExtendedSelectList, type SelectEntry } from "../ui/components/extended-select-list.js";
11
+ import { ScopedInput } from "../ui/components/scoped-input.js";
12
+ import { Form } from "../ui/components/form.js";
13
+ import { McpClientManager } from "./client.js";
14
+ import { parseEditConfigValues } from "./validation.js";
15
+ import { getEditFields, saveEditedServer } from "./helpers.js";
16
+
17
+ export async function mcpCommandHandler(_args: string, ctx: ExtensionCommandContext) {
18
+ if (!ctx.hasUI) {
19
+ ctx.ui.notify("/mcp requires an interactive UI.", "error");
20
+ return;
21
+ }
22
+
23
+ while (true) {
24
+ const action = await showMcpOptions(ctx);
25
+ if (!action) return;
26
+
27
+ if (action.addMcp) {
28
+ await handleSaveFlow(ctx);
29
+ continue;
30
+ }
31
+
32
+ if (action.selectedServer) {
33
+ await handleEditFlow(ctx, action.selectedServer);
34
+ }
35
+ }
36
+ }
37
+
38
+ async function showMcpOptions(
39
+ ctx: ExtensionCommandContext,
40
+ ): Promise<{ addMcp: boolean; selectedServer?: ResolvedMcpServer } | null> {
41
+ const configuredServers = await loadMcpConfigSet(ctx.cwd);
42
+ const items = configuredServers.map((server) => ({
43
+ value: server.name,
44
+ label: `${server.name} [${server.scope}]`,
45
+ description: server.enabled ? "enabled" : "disabled",
46
+ data: server,
47
+ }));
48
+
49
+ return ctx.ui.custom<{ addMcp: boolean; selectedServer?: ResolvedMcpServer } | null>(
50
+ (tui, theme, _keybindings, done) => {
51
+ let isPending = false;
52
+ const selectList = new ExtendedSelectList<ResolvedMcpServer>(theme, {
53
+ title: "MCP servers",
54
+ addLabel: "Add MCP server",
55
+ items,
56
+ maxVisibleRows: 12,
57
+ });
58
+
59
+ const refresh = (pending = false) => {
60
+ isPending = pending;
61
+ selectList.invalidate();
62
+ tui.requestRender();
63
+ };
64
+
65
+ const handleServer = (item: SelectEntry<ResolvedMcpServer>, isDelete: boolean) => {
66
+ if (!item.data || isPending) return;
67
+ isPending = true;
68
+
69
+ if (isDelete) {
70
+ deleteMcpServer(ctx, item).finally(refresh);
71
+ } else {
72
+ toggleMcpServer(ctx, item, refresh).finally(refresh);
73
+ }
74
+ };
75
+
76
+ selectList.onAdd = () => done({ addMcp: true });
77
+ selectList.onCancel = () => done(null);
78
+ selectList.onSelect = (item) => done({ addMcp: false, selectedServer: item.data });
79
+ selectList.onDelete = (item) => handleServer(item, true);
80
+ selectList.extendKb(Key.tab, (item) => handleServer(item, false), "enable/disable");
81
+
82
+ return selectList;
83
+ },
84
+ );
85
+ }
86
+
87
+ async function toggleMcpServer(
88
+ ctx: ExtensionCommandContext,
89
+ item: SelectEntry<ResolvedMcpServer>,
90
+ refresh: (pending: boolean) => void,
91
+ ) {
92
+ const server = item.data;
93
+ if (!server) return;
94
+
95
+ const nextEnabledState = !server.enabled;
96
+ if (nextEnabledState) {
97
+ item.description = "checking";
98
+ const client = new McpClientManager();
99
+ refresh(true);
100
+
101
+ try {
102
+ await client.listTools({ ...server, enabled: true });
103
+ } catch (error) {
104
+ const errorMessage = error instanceof Error ? error.message : "Failed to check MCP server";
105
+ ctx.ui.notify(errorMessage, "error");
106
+ item.description = "disabled";
107
+ return;
108
+ } finally {
109
+ await client.disposeAll();
110
+ }
111
+ }
112
+
113
+ const { name, ...config } = server;
114
+ const updatedConfig: McpServer = { ...config, enabled: nextEnabledState };
115
+
116
+ try {
117
+ await updateServerConfig(server.scope, ctx.cwd, name, {
118
+ config: updatedConfig,
119
+ kind: "upsert",
120
+ });
121
+ const updated = { ...server, enabled: nextEnabledState };
122
+ item.data = updated;
123
+ item.description = updated.enabled ? "enabled" : "disabled";
124
+ } catch (error) {
125
+ item.description = server.enabled ? "enabled" : "disabled";
126
+ const errorMessage = error instanceof Error ? error.message : "Failed to update MCP server";
127
+ ctx.ui.notify(errorMessage, "error");
128
+ }
129
+ }
130
+
131
+ async function deleteMcpServer(ctx: ExtensionCommandContext, item: SelectEntry<ResolvedMcpServer>) {
132
+ const server = item.data;
133
+ if (!server) return;
134
+
135
+ try {
136
+ const result = await updateServerConfig(server.scope, ctx.cwd, server.name, {
137
+ kind: "delete",
138
+ });
139
+ if (!result.updated) {
140
+ ctx.ui.notify(`MCP server ${server.name} no longer exists in ${result.path}.`, "warning");
141
+ return;
142
+ }
143
+ } catch (error) {
144
+ const errorMessage = error instanceof Error ? error.message : "Failed to delete MCP server";
145
+ ctx.ui.notify(errorMessage, "error");
146
+ }
147
+ }
148
+
149
+ async function handleEditFlow(ctx: ExtensionCommandContext, server: ResolvedMcpServer) {
150
+ if (!ctx.hasUI) {
151
+ ctx.ui.notify("mcp edit requires an interactive UI.", "error");
152
+ return;
153
+ }
154
+
155
+ await ctx.ui.custom<void>((tui, theme, keybindings, done) => {
156
+ const editor = new Form<ResolvedMcpServer>(tui, keybindings, theme, {
157
+ title: `Edit MCP server: ${server.name}`,
158
+ fields: getEditFields(server),
159
+ emptyMessage: "No fields available for editing.",
160
+ parseOnSave: parseEditConfigValues,
161
+ });
162
+
163
+ editor.onCancel = () => done();
164
+ editor.onSave = async (updatedServer) => {
165
+ await saveEditedServer(ctx, server, updatedServer);
166
+ done();
167
+ };
168
+ editor.onSaveError = (error) => {
169
+ const message = error instanceof Error ? error.message : String(error);
170
+ ctx.ui.notify(`Failed to update MCP server: ${message}`, "error");
171
+ };
172
+
173
+ return editor;
174
+ });
175
+ }
176
+
177
+ async function handleSaveFlow(ctx: ExtensionCommandContext) {
178
+ if (!ctx.hasUI) {
179
+ ctx.ui.notify("mcp save requires an interactive UI.", "error");
180
+ return;
181
+ }
182
+
183
+ const result = await ctx.ui.custom<{ name: string; scope: string } | null>(
184
+ (_tui, theme, _kb, done) => {
185
+ const scopedInput = new ScopedInput(theme, "MCP server name");
186
+ scopedInput.onSubmit = ({ scope, value: name }) => done({ name, scope });
187
+ scopedInput.onCancel = () => done(null);
188
+ return scopedInput;
189
+ },
190
+ );
191
+
192
+ if (!result) return;
193
+ const { name, scope } = result;
194
+ if (scope !== "project" && scope !== "global") {
195
+ ctx.ui.notify(`Invalid scope: ${scope}`, "error");
196
+ return;
197
+ }
198
+
199
+ const configuredServers = await readConfigFile(scope, ctx.cwd);
200
+ const hasExisting = Object.hasOwn(configuredServers, name);
201
+
202
+ if (hasExisting) {
203
+ const confirmed = await ctx.ui.confirm(
204
+ `Replace ${name} MCP`,
205
+ `${name} MCP already exists ${scope}ly. Do you want to replace it?`,
206
+ );
207
+ if (!confirmed) return;
208
+ }
209
+
210
+ const transportType = await ctx.ui.select("Server type", ["Remote", "Local"]);
211
+ const transport = transportType === "Remote" ? "http" : "stdio";
212
+
213
+ const config = await promptServerConfig(ctx, transport, name, scope, configuredServers[name]);
214
+ if (!config) return;
215
+
216
+ const upsertResult = await updateServerConfig(scope, ctx.cwd, name, {
217
+ config,
218
+ kind: "upsert",
219
+ });
220
+ ctx.ui.notify(
221
+ `${upsertResult.updated ? "Updated" : "Saved"} MCP server ${name} ${scope}ly (${upsertResult.path}).`,
222
+ "info",
223
+ );
224
+ }
225
+
226
+ async function promptServerConfig(
227
+ ctx: ExtensionCommandContext,
228
+ transport: McpTransport,
229
+ name: string,
230
+ scope: "project" | "global",
231
+ existingConfig?: McpServer,
232
+ ): Promise<McpServer | undefined> {
233
+ const defaults =
234
+ transport === "http"
235
+ ? { name, scope, transport, url: "" }
236
+ : { name, scope, transport, command: "" };
237
+ const server: ResolvedMcpServer = existingConfig ? { name, scope, ...existingConfig } : defaults;
238
+
239
+ return ctx.ui.custom<McpServer | undefined>((tui, theme, keybindings, done) => {
240
+ const editor = new Form<McpServer>(tui, keybindings, theme, {
241
+ title: `Configure ${name} MCP server`,
242
+ fields: getEditFields(server).filter(
243
+ (field) => !["name", "scope", "transport"].includes(field.key),
244
+ ),
245
+ parseOnSave: (values) =>
246
+ normalizeServerConfig(name, parseEditConfigValues({ ...values, name, scope, transport })),
247
+ });
248
+
249
+ editor.onCancel = () => done(undefined);
250
+ editor.onSave = (serverConfig) => done(serverConfig);
251
+ editor.onSaveError = (error) => {
252
+ ctx.ui.notify(error instanceof Error ? error.message : "Invalid MCP server config", "error");
253
+ };
254
+
255
+ return editor;
256
+ });
257
+ }
@@ -0,0 +1,153 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { normalizeServerConfig, readConfigFile, updateServerConfig } from "./storage.js";
3
+ import type { ResolvedMcpServer } from "./types.js";
4
+ import type { Field } from "../ui/components/form-field.js";
5
+
6
+ export async function saveEditedServer(
7
+ ctx: ExtensionCommandContext,
8
+ previous: ResolvedMcpServer,
9
+ updated: ResolvedMcpServer,
10
+ ) {
11
+ if (
12
+ (updated.scope !== previous.scope || updated.name !== previous.name) &&
13
+ Object.hasOwn(await readConfigFile(updated.scope, ctx.cwd), updated.name)
14
+ ) {
15
+ throw new Error(`MCP server ${updated.name} already exists in ${updated.scope} scope.`);
16
+ }
17
+
18
+ const updatedConfig = normalizeServerConfig(updated.name, updated);
19
+
20
+ await updateServerConfig(updated.scope, ctx.cwd, updated.name, {
21
+ config: updatedConfig,
22
+ kind: "upsert",
23
+ });
24
+
25
+ if (updated.scope !== previous.scope || updated.name !== previous.name) {
26
+ await updateServerConfig(previous.scope, ctx.cwd, previous.name, { kind: "delete" });
27
+ }
28
+
29
+ ctx.ui.notify(`Updated MCP server ${previous.name}.`, "info");
30
+ }
31
+
32
+ export function getEditFields(server: ResolvedMcpServer): Field<string | number | boolean>[] {
33
+ const base: Field<string | number | boolean>[] = [
34
+ {
35
+ key: "name",
36
+ label: "name",
37
+ labelWidth: 18,
38
+ mode: {
39
+ type: "input",
40
+ placeholder: "MCP server name",
41
+ text: server.name,
42
+ },
43
+ },
44
+ {
45
+ key: "scope",
46
+ label: "scope",
47
+ labelWidth: 18,
48
+ mode: {
49
+ type: "toggle",
50
+ values: ["project", "global"],
51
+ initialIndex: server.scope === "project" ? 0 : 1,
52
+ },
53
+ },
54
+ {
55
+ key: "transport",
56
+ label: "transport",
57
+ labelWidth: 18,
58
+ mode: {
59
+ type: "toggle",
60
+ values: ["stdio", "http"],
61
+ initialIndex: server.transport === "stdio" ? 0 : 1,
62
+ },
63
+ },
64
+ {
65
+ key: "enabled",
66
+ label: "enabled",
67
+ labelWidth: 18,
68
+ mode: {
69
+ type: "toggle",
70
+ values: ["true", "false"],
71
+ initialIndex: server.enabled ? 0 : 1,
72
+ },
73
+ },
74
+ {
75
+ key: "description",
76
+ label: "description",
77
+ labelWidth: 18,
78
+ mode: {
79
+ type: "input",
80
+ placeholder: "Optional description",
81
+ text: server.description ?? "",
82
+ },
83
+ },
84
+ ];
85
+
86
+ if (server.transport === "stdio") {
87
+ return base.concat([
88
+ {
89
+ key: "command",
90
+ label: "command",
91
+ labelWidth: 18,
92
+ mode: {
93
+ type: "input",
94
+ placeholder: "Required when transport=stdio",
95
+ text: server.command,
96
+ },
97
+ },
98
+ {
99
+ key: "args",
100
+ label: "args",
101
+ labelWidth: 18,
102
+ mode: {
103
+ type: "input",
104
+ placeholder: "Optional JSON string array",
105
+ text: server.args ? JSON.stringify(server.args) : "",
106
+ },
107
+ },
108
+ {
109
+ key: "cwd",
110
+ label: "cwd",
111
+ labelWidth: 18,
112
+ mode: {
113
+ type: "input",
114
+ placeholder: "Optional working directory",
115
+ text: server.cwd ?? "",
116
+ },
117
+ },
118
+ {
119
+ key: "env",
120
+ label: "env",
121
+ labelWidth: 18,
122
+ mode: {
123
+ type: "input",
124
+ placeholder: "Optional JSON object of env vars",
125
+ text: server.env ? JSON.stringify(server.env) : "",
126
+ },
127
+ },
128
+ ]);
129
+ }
130
+
131
+ return base.concat([
132
+ {
133
+ key: "url",
134
+ label: "url",
135
+ labelWidth: 18,
136
+ mode: {
137
+ type: "input",
138
+ placeholder: "Required when transport=http",
139
+ text: server.url,
140
+ },
141
+ },
142
+ {
143
+ key: "headers",
144
+ label: "headers",
145
+ labelWidth: 18,
146
+ mode: {
147
+ type: "input",
148
+ placeholder: "Optional JSON object of headers",
149
+ text: server.headers ? JSON.stringify(server.headers) : "",
150
+ },
151
+ },
152
+ ]);
153
+ }
@@ -0,0 +1,21 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { mcpCommandHandler } from "./command.js";
3
+ import { McpClientManager } from "./client.js";
4
+ import { createMcpCallTool } from "./call-tool.js";
5
+ import { createMcpListToolsTool } from "./list-tools.js";
6
+
7
+ export default function mcpClientExtension(pi: ExtensionAPI) {
8
+ const clientManager = new McpClientManager();
9
+
10
+ pi.registerCommand("mcp", {
11
+ description: "Manage MCP servers: add new server and toggle enabled state",
12
+ handler: mcpCommandHandler,
13
+ });
14
+
15
+ pi.registerTool(createMcpCallTool(clientManager));
16
+ pi.registerTool(createMcpListToolsTool(clientManager));
17
+
18
+ pi.on("session_shutdown", async () => {
19
+ await clientManager.disposeAll();
20
+ });
21
+ }
@@ -0,0 +1,84 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { resolveServerConfig } from "./storage.js";
4
+ import { McpClientManager } from "./client.js";
5
+ import { renderCallText } from "../utils.js";
6
+
7
+ export function createMcpListToolsTool(clientManager: McpClientManager) {
8
+ return defineTool({
9
+ name: "list_mcp_tools",
10
+ label: "MCP List Tools",
11
+ description: "List available tools from one or more configured MCP servers.",
12
+ promptSnippet: "List available tools from one or more MCP servers with optional regex filter.",
13
+ promptGuidelines: [
14
+ "Use list_mcp_tools to discover available tool names before calling call_mcp_tool.",
15
+ "Provide searchRegex to filter tools relevant to the task and avoid bloating context, especially for large MCP servers.",
16
+ ],
17
+ parameters: Type.Object({
18
+ servers: Type.Array(Type.String({ description: "Configured server name" }), {
19
+ description: "Server names to query",
20
+ }),
21
+ searchRegex: Type.Optional(
22
+ Type.String({ description: "Regex pattern to filter tool names or descriptions" }),
23
+ ),
24
+ }),
25
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
26
+ if (signal?.aborted) {
27
+ throw new Error("list_mcp_tools was cancelled.");
28
+ }
29
+
30
+ const filter = params.searchRegex ? new RegExp(params.searchRegex, "i") : null;
31
+ const sections: string[] = [];
32
+
33
+ for (const serverName of params.servers) {
34
+ const serverConfig = await resolveServerConfig(ctx.cwd, serverName.trim());
35
+
36
+ if (!serverConfig) {
37
+ sections.push(`### ${serverName}\nError: Unknown MCP server. Configure it with /mcp.`);
38
+ continue;
39
+ }
40
+ if (serverConfig.enabled === false) {
41
+ sections.push(`### ${serverName}\nError: MCP server is disabled.`);
42
+ continue;
43
+ }
44
+ if (signal?.aborted) {
45
+ throw new Error("list_mcp_tools was cancelled.");
46
+ }
47
+
48
+ const toolsResult = await clientManager.listTools(serverConfig);
49
+ const matched = filter
50
+ ? toolsResult.tools.filter(
51
+ (tool) => filter.test(tool.name) || filter.test(tool.description ?? ""),
52
+ )
53
+ : toolsResult.tools;
54
+
55
+ if (matched.length === 0) {
56
+ sections.push(
57
+ `### ${serverName}\n${filter ? "No tools match the filter." : "No tools available."}`,
58
+ );
59
+ continue;
60
+ }
61
+
62
+ const lines = matched.map((tool) => {
63
+ const description = tool.description ? `: ${tool.description}` : "";
64
+ const inputSchema = JSON.stringify(tool.inputSchema, null, 2);
65
+ return `- **${tool.name}**${description}\nInput schema:\n\`\`\`json\n${inputSchema}\n\`\`\``;
66
+ });
67
+ sections.push(`### ${serverName}\n${lines.join("\n\n")}`);
68
+ }
69
+
70
+ return {
71
+ content: [{ type: "text", text: sections.join("\n\n") }],
72
+ details: { servers: params.servers, filter: params.searchRegex ?? null },
73
+ };
74
+ },
75
+ renderCall(args, theme, { isPartial }) {
76
+ const serverList = Array.isArray(args.servers) ? args.servers.join(", ") : "";
77
+ const filterPart = args.searchRegex ? theme.fg("dim", `/${args.searchRegex}/`) : "";
78
+ return renderCallText(
79
+ `${theme.fg("toolTitle", "list_mcp_tools")} ${theme.fg("accent", serverList)} ${filterPart}`,
80
+ isPartial,
81
+ );
82
+ },
83
+ });
84
+ }
@@ -0,0 +1,190 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { getPiPath, readJson } from "../utils.js";
3
+ import type { HttpMcpServer, McpServer, ResolvedMcpServer, StdioMcpServer } from "./types.js";
4
+
5
+ type Action = { kind: "delete" } | { kind: "upsert"; config: McpServer };
6
+
7
+ export async function updateServerConfig(
8
+ scope: "project" | "global",
9
+ cwd: string,
10
+ name: string,
11
+ action: Action,
12
+ ): Promise<{ path: string; updated: boolean }> {
13
+ const path = getPiPath("mcp", scope === "project" ? cwd : scope);
14
+ const mcpServers = await readConfigFile(scope, cwd);
15
+ const updated = Object.hasOwn(mcpServers, name);
16
+
17
+ if (action.kind === "upsert") {
18
+ mcpServers[name] = action.config;
19
+ } else {
20
+ delete mcpServers[name];
21
+ }
22
+ await writeFile(path, `${JSON.stringify(mcpServers, null, 2)}\n`, "utf8");
23
+
24
+ return { path, updated };
25
+ }
26
+
27
+ export async function loadMcpConfigSet(cwd: string): Promise<ResolvedMcpServer[]> {
28
+ const merged = new Map<string, ResolvedMcpServer>();
29
+ const [localServers, globalServers] = await Promise.all([
30
+ readConfigFile("project", cwd),
31
+ readConfigFile("global", cwd),
32
+ ]);
33
+
34
+ for (const [name, serverConfig] of Object.entries(localServers)) {
35
+ merged.set(name, { ...serverConfig, name, scope: "project" });
36
+ }
37
+ for (const [name, serverConfig] of Object.entries(globalServers)) {
38
+ merged.set(name, { ...serverConfig, name, scope: "global" });
39
+ }
40
+
41
+ return Array.from(merged.values());
42
+ }
43
+
44
+ export async function resolveServerConfig(
45
+ cwd: string,
46
+ serverName: string,
47
+ ): Promise<ResolvedMcpServer | undefined> {
48
+ const mcpServers = await loadMcpConfigSet(cwd);
49
+ return mcpServers.find((server) => server.name === serverName);
50
+ }
51
+
52
+ export async function readConfigFile(
53
+ scope: "project" | "global",
54
+ cwd: string,
55
+ ): Promise<Record<string, McpServer>> {
56
+ const path = getPiPath("mcp", scope === "project" ? cwd : scope);
57
+ return readJson<Record<string, McpServer>>(path, {});
58
+ }
59
+
60
+ export function normalizeServerConfig(name: string, raw: unknown): McpServer {
61
+ if (!isPlainObject(raw)) {
62
+ throw new Error(`Invalid MCP server config for ${name}: expected an object.`);
63
+ }
64
+
65
+ const transport = getTransport(raw, name);
66
+ const enabled = raw.enabled === undefined ? false : expectBoolean(raw.enabled, name, "enabled");
67
+ const description =
68
+ raw.description === undefined ? undefined : expectString(raw.description, name, "description");
69
+
70
+ if (transport === "stdio") {
71
+ const config: StdioMcpServer = {
72
+ transport: "stdio",
73
+ command: expectString(raw.command, name, "command"),
74
+ enabled,
75
+ };
76
+
77
+ if (description) {
78
+ config.description = description;
79
+ }
80
+ if (raw.args !== undefined) {
81
+ config.args = expectStringArray(raw.args, name, "args");
82
+ }
83
+ if (raw.cwd !== undefined) {
84
+ config.cwd = expectString(raw.cwd, name, "cwd");
85
+ }
86
+ if (raw.env !== undefined) {
87
+ config.env = expectStringRecord(raw.env, name, "env");
88
+ }
89
+
90
+ return config;
91
+ }
92
+
93
+ if (transport === "http") {
94
+ const config: HttpMcpServer = {
95
+ transport: "http",
96
+ url: expectString(raw.url, name, "url"),
97
+ enabled,
98
+ };
99
+
100
+ if (description) {
101
+ config.description = description;
102
+ }
103
+ if (raw.headers !== undefined) {
104
+ config.headers = expectStringRecord(raw.headers, name, "headers");
105
+ }
106
+
107
+ return config;
108
+ }
109
+
110
+ throw new Error(`Invalid MCP server config for ${name}: transport must be "stdio" or "http".`);
111
+ }
112
+
113
+ function getTransport(raw: Record<string, unknown>, serverName: string): "stdio" | "http" {
114
+ const declared = raw.transport ?? raw.type;
115
+
116
+ if (declared === "stdio") {
117
+ return "stdio";
118
+ }
119
+ if (declared === "http" || declared === "https") {
120
+ return "http";
121
+ }
122
+ if (declared !== undefined) {
123
+ throw new Error(
124
+ `Invalid MCP server config for ${serverName}: transport/type must be "stdio", "http", or "https".`,
125
+ );
126
+ }
127
+ if (typeof raw.command === "string") {
128
+ return "stdio";
129
+ }
130
+ if (typeof raw.url === "string") {
131
+ return "http";
132
+ }
133
+
134
+ throw new Error(
135
+ `Invalid MCP server config for ${serverName}: include either command for stdio or url for http, or set transport explicitly.`,
136
+ );
137
+ }
138
+
139
+ function expectBoolean(value: unknown, serverName: string, field: string): boolean {
140
+ if (typeof value !== "boolean") {
141
+ throw new Error(`Invalid MCP server config for ${serverName}: ${field} must be a boolean.`);
142
+ }
143
+ return value;
144
+ }
145
+
146
+ function expectString(value: unknown, serverName: string, field: string): string {
147
+ if (typeof value !== "string" || value.trim().length === 0) {
148
+ throw new Error(
149
+ `Invalid MCP server config for ${serverName}: ${field} must be a non-empty string.`,
150
+ );
151
+ }
152
+ return value.trim();
153
+ }
154
+
155
+ function expectStringArray(value: unknown, serverName: string, field: string): string[] {
156
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
157
+ throw new Error(
158
+ `Invalid MCP server config for ${serverName}: ${field} must be an array of strings.`,
159
+ );
160
+ }
161
+ return value;
162
+ }
163
+
164
+ function expectStringRecord(
165
+ value: unknown,
166
+ serverName: string,
167
+ field: string,
168
+ ): Record<string, string> {
169
+ if (!isPlainObject(value)) {
170
+ throw new Error(
171
+ `Invalid MCP server config for ${serverName}: ${field} must be an object of string values.`,
172
+ );
173
+ }
174
+
175
+ const record: Record<string, string> = {};
176
+ for (const [key, item] of Object.entries(value)) {
177
+ if (typeof item !== "string") {
178
+ throw new Error(
179
+ `Invalid MCP server config for ${serverName}: ${field}.${key} must be a string.`,
180
+ );
181
+ }
182
+ record[key] = item;
183
+ }
184
+
185
+ return record;
186
+ }
187
+
188
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
189
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
190
+ }