@oh-my-pi/pi-coding-agent 6.2.0 → 6.7.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 (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/docs/sdk.md +1 -1
  3. package/package.json +5 -5
  4. package/scripts/generate-template.ts +6 -6
  5. package/src/cli/args.ts +3 -0
  6. package/src/core/agent-session.ts +39 -0
  7. package/src/core/bash-executor.ts +3 -3
  8. package/src/core/cursor/exec-bridge.ts +95 -88
  9. package/src/core/custom-commands/bundled/review/index.ts +142 -145
  10. package/src/core/custom-commands/bundled/wt/index.ts +68 -66
  11. package/src/core/custom-commands/loader.ts +4 -6
  12. package/src/core/custom-tools/index.ts +2 -2
  13. package/src/core/custom-tools/loader.ts +66 -61
  14. package/src/core/custom-tools/types.ts +4 -4
  15. package/src/core/custom-tools/wrapper.ts +61 -25
  16. package/src/core/event-bus.ts +19 -47
  17. package/src/core/extensions/index.ts +8 -4
  18. package/src/core/extensions/loader.ts +160 -120
  19. package/src/core/extensions/types.ts +4 -4
  20. package/src/core/extensions/wrapper.ts +149 -100
  21. package/src/core/hooks/index.ts +1 -1
  22. package/src/core/hooks/tool-wrapper.ts +96 -70
  23. package/src/core/hooks/types.ts +1 -2
  24. package/src/core/index.ts +1 -0
  25. package/src/core/mcp/index.ts +6 -2
  26. package/src/core/mcp/json-rpc.ts +88 -0
  27. package/src/core/mcp/loader.ts +22 -4
  28. package/src/core/mcp/manager.ts +202 -48
  29. package/src/core/mcp/tool-bridge.ts +143 -55
  30. package/src/core/mcp/tool-cache.ts +122 -0
  31. package/src/core/python-executor.ts +3 -9
  32. package/src/core/sdk.ts +33 -32
  33. package/src/core/session-manager.ts +30 -0
  34. package/src/core/settings-manager.ts +34 -1
  35. package/src/core/ssh/ssh-executor.ts +6 -84
  36. package/src/core/streaming-output.ts +107 -53
  37. package/src/core/tools/ask.ts +92 -93
  38. package/src/core/tools/bash.ts +103 -94
  39. package/src/core/tools/calculator.ts +41 -26
  40. package/src/core/tools/complete.ts +76 -66
  41. package/src/core/tools/context.ts +22 -24
  42. package/src/core/tools/exa/index.ts +1 -1
  43. package/src/core/tools/exa/mcp-client.ts +56 -101
  44. package/src/core/tools/find.ts +250 -253
  45. package/src/core/tools/git.ts +39 -33
  46. package/src/core/tools/grep.ts +440 -427
  47. package/src/core/tools/index.ts +62 -61
  48. package/src/core/tools/ls.ts +119 -114
  49. package/src/core/tools/lsp/clients/biome-client.ts +5 -7
  50. package/src/core/tools/lsp/clients/index.ts +4 -4
  51. package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
  52. package/src/core/tools/lsp/config.ts +2 -2
  53. package/src/core/tools/lsp/index.ts +604 -578
  54. package/src/core/tools/notebook.ts +121 -119
  55. package/src/core/tools/output.ts +163 -147
  56. package/src/core/tools/patch/applicator.ts +1100 -0
  57. package/src/core/tools/patch/diff.ts +362 -0
  58. package/src/core/tools/patch/fuzzy.ts +647 -0
  59. package/src/core/tools/patch/index.ts +430 -0
  60. package/src/core/tools/patch/normalize.ts +220 -0
  61. package/src/core/tools/patch/normative.ts +49 -0
  62. package/src/core/tools/patch/parser.ts +528 -0
  63. package/src/core/tools/patch/shared.ts +228 -0
  64. package/src/core/tools/patch/types.ts +244 -0
  65. package/src/core/tools/python.ts +139 -136
  66. package/src/core/tools/read.ts +237 -216
  67. package/src/core/tools/render-utils.ts +196 -77
  68. package/src/core/tools/renderers.ts +1 -1
  69. package/src/core/tools/ssh.ts +99 -80
  70. package/src/core/tools/task/executor.ts +11 -7
  71. package/src/core/tools/task/index.ts +352 -343
  72. package/src/core/tools/task/worker.ts +13 -23
  73. package/src/core/tools/todo-write.ts +74 -59
  74. package/src/core/tools/web-fetch.ts +54 -47
  75. package/src/core/tools/web-search/index.ts +27 -16
  76. package/src/core/tools/write.ts +73 -44
  77. package/src/core/ttsr.ts +106 -152
  78. package/src/core/voice.ts +49 -39
  79. package/src/index.ts +16 -12
  80. package/src/lib/worktree/index.ts +1 -9
  81. package/src/modes/interactive/components/diff.ts +15 -8
  82. package/src/modes/interactive/components/settings-defs.ts +24 -0
  83. package/src/modes/interactive/components/tool-execution.ts +34 -6
  84. package/src/modes/interactive/controllers/event-controller.ts +6 -19
  85. package/src/modes/interactive/controllers/input-controller.ts +1 -1
  86. package/src/modes/interactive/utils/ui-helpers.ts +5 -1
  87. package/src/modes/rpc/rpc-mode.ts +99 -81
  88. package/src/prompts/tools/patch.md +76 -0
  89. package/src/prompts/tools/read.md +1 -1
  90. package/src/prompts/tools/{edit.md → replace.md} +1 -0
  91. package/src/utils/shell.ts +0 -40
  92. package/src/core/tools/edit-diff.ts +0 -574
  93. package/src/core/tools/edit.ts +0 -345
@@ -7,11 +7,48 @@
7
7
 
8
8
  import type { TSchema } from "@sinclair/typebox";
9
9
  import type { CustomTool } from "../custom-tools/types";
10
+ import { logger } from "../logger";
10
11
  import { connectToServer, disconnectServer, listTools } from "./client";
11
12
  import { loadAllMCPConfigs, validateServerConfig } from "./config";
12
13
  import type { MCPToolDetails } from "./tool-bridge";
13
- import { createMCPTools } from "./tool-bridge";
14
- import type { MCPServerConfig, MCPServerConnection } from "./types";
14
+ import { DeferredMCPTool, MCPTool } from "./tool-bridge";
15
+ import type { MCPToolCache } from "./tool-cache";
16
+ import type { MCPServerConfig, MCPServerConnection, MCPToolDefinition } from "./types";
17
+
18
+ type SourceMeta = import("../../capability/types").SourceMeta;
19
+
20
+ type ToolLoadResult = {
21
+ connection: MCPServerConnection;
22
+ serverTools: MCPToolDefinition[];
23
+ };
24
+
25
+ type TrackedPromise<T> = {
26
+ promise: Promise<T>;
27
+ status: "pending" | "fulfilled" | "rejected";
28
+ value?: T;
29
+ reason?: unknown;
30
+ };
31
+
32
+ const STARTUP_TIMEOUT_MS = 250;
33
+
34
+ function trackPromise<T>(promise: Promise<T>): TrackedPromise<T> {
35
+ const tracked: TrackedPromise<T> = { promise, status: "pending" };
36
+ promise.then(
37
+ (value) => {
38
+ tracked.status = "fulfilled";
39
+ tracked.value = value;
40
+ },
41
+ (reason) => {
42
+ tracked.status = "rejected";
43
+ tracked.reason = reason;
44
+ },
45
+ );
46
+ return tracked;
47
+ }
48
+
49
+ function delay(ms: number): Promise<void> {
50
+ return new Promise((resolve) => setTimeout(resolve, ms));
51
+ }
15
52
 
16
53
  /** Result of loading MCP tools */
17
54
  export interface MCPLoadResult {
@@ -43,8 +80,14 @@ export interface MCPDiscoverOptions {
43
80
  export class MCPManager {
44
81
  private connections = new Map<string, MCPServerConnection>();
45
82
  private tools: CustomTool<TSchema, MCPToolDetails>[] = [];
83
+ private pendingConnections = new Map<string, Promise<MCPServerConnection>>();
84
+ private pendingToolLoads = new Map<string, Promise<ToolLoadResult>>();
85
+ private sources = new Map<string, SourceMeta>();
46
86
 
47
- constructor(private cwd: string) {}
87
+ constructor(
88
+ private cwd: string,
89
+ private toolCache: MCPToolCache | null = null,
90
+ ) {}
48
91
 
49
92
  /**
50
93
  * Discover and connect to all MCP servers from .mcp.json files.
@@ -66,24 +109,41 @@ export class MCPManager {
66
109
  */
67
110
  async connectServers(
68
111
  configs: Record<string, MCPServerConfig>,
69
- sources: Record<string, import("../../capability/types").SourceMeta>,
112
+ sources: Record<string, SourceMeta>,
70
113
  onConnecting?: (serverNames: string[]) => void,
71
114
  ): Promise<MCPLoadResult> {
115
+ type ConnectionTask = {
116
+ name: string;
117
+ config: MCPServerConfig;
118
+ tracked: TrackedPromise<ToolLoadResult>;
119
+ toolsPromise: Promise<ToolLoadResult>;
120
+ };
121
+
72
122
  const errors = new Map<string, string>();
73
- const connectedServers: string[] = [];
123
+ const connectedServers = new Set<string>();
74
124
  const allTools: CustomTool<TSchema, MCPToolDetails>[] = [];
125
+ const reportedErrors = new Set<string>();
126
+ let allowBackgroundLogging = false;
75
127
 
76
128
  // Prepare connection tasks
77
- const connectionTasks: Array<{
78
- name: string;
79
- config: MCPServerConfig;
80
- validationErrors: string[];
81
- }> = [];
129
+ const connectionTasks: ConnectionTask[] = [];
82
130
 
83
131
  for (const [name, config] of Object.entries(configs)) {
132
+ if (sources[name]) {
133
+ this.sources.set(name, sources[name]);
134
+ const existing = this.connections.get(name);
135
+ if (existing) {
136
+ existing._source = sources[name];
137
+ }
138
+ }
139
+
84
140
  // Skip if already connected
85
141
  if (this.connections.has(name)) {
86
- connectedServers.push(name);
142
+ connectedServers.add(name);
143
+ continue;
144
+ }
145
+
146
+ if (this.pendingConnections.has(name) || this.pendingToolLoads.has(name)) {
87
147
  continue;
88
148
  }
89
149
 
@@ -91,59 +151,128 @@ export class MCPManager {
91
151
  const validationErrors = validateServerConfig(name, config);
92
152
  if (validationErrors.length > 0) {
93
153
  errors.set(name, validationErrors.join("; "));
154
+ reportedErrors.add(name);
94
155
  continue;
95
156
  }
96
157
 
97
- connectionTasks.push({ name, config, validationErrors });
158
+ const connectionPromise = connectToServer(name, config).then(
159
+ (connection) => {
160
+ if (sources[name]) {
161
+ connection._source = sources[name];
162
+ }
163
+ if (this.pendingConnections.get(name) === connectionPromise) {
164
+ this.pendingConnections.delete(name);
165
+ this.connections.set(name, connection);
166
+ }
167
+ return connection;
168
+ },
169
+ (error) => {
170
+ if (this.pendingConnections.get(name) === connectionPromise) {
171
+ this.pendingConnections.delete(name);
172
+ }
173
+ throw error;
174
+ },
175
+ );
176
+ this.pendingConnections.set(name, connectionPromise);
177
+
178
+ const toolsPromise = connectionPromise.then(async (connection) => {
179
+ const serverTools = await listTools(connection);
180
+ return { connection, serverTools };
181
+ });
182
+ this.pendingToolLoads.set(name, toolsPromise);
183
+
184
+ const tracked = trackPromise(toolsPromise);
185
+ connectionTasks.push({ name, config, tracked, toolsPromise });
186
+
187
+ void toolsPromise
188
+ .then(({ connection, serverTools }) => {
189
+ if (this.pendingToolLoads.get(name) !== toolsPromise) return;
190
+ this.pendingToolLoads.delete(name);
191
+ const customTools = MCPTool.fromTools(connection, serverTools);
192
+ this.replaceServerTools(name, customTools);
193
+ void this.toolCache?.set(name, config, serverTools);
194
+ })
195
+ .catch((error) => {
196
+ if (this.pendingToolLoads.get(name) !== toolsPromise) return;
197
+ this.pendingToolLoads.delete(name);
198
+ if (!allowBackgroundLogging || reportedErrors.has(name)) return;
199
+ const message = error instanceof Error ? error.message : String(error);
200
+ logger.error("MCP tool load failed", { path: `mcp:${name}`, error: message });
201
+ });
98
202
  }
99
203
 
100
204
  // Notify about servers we're connecting to
101
205
  if (connectionTasks.length > 0 && onConnecting) {
102
- onConnecting(connectionTasks.map((t) => t.name));
206
+ onConnecting(connectionTasks.map((task) => task.name));
103
207
  }
104
208
 
105
- // Connect to all servers in parallel
106
- const results = await Promise.allSettled(
107
- connectionTasks.map(async ({ name, config }) => {
108
- const connection = await connectToServer(name, config);
109
- // Attach source metadata to connection
110
- if (sources[name]) {
111
- connection._source = sources[name];
209
+ if (connectionTasks.length > 0) {
210
+ await Promise.race([
211
+ Promise.allSettled(connectionTasks.map((task) => task.tracked.promise)),
212
+ delay(STARTUP_TIMEOUT_MS),
213
+ ]);
214
+
215
+ const cachedTools = new Map<string, MCPToolDefinition[]>();
216
+ const pendingTasks = connectionTasks.filter((task) => task.tracked.status === "pending");
217
+
218
+ if (pendingTasks.length > 0) {
219
+ if (this.toolCache) {
220
+ await Promise.all(
221
+ pendingTasks.map(async (task) => {
222
+ const cached = await this.toolCache?.get(task.name, task.config);
223
+ if (cached) {
224
+ cachedTools.set(task.name, cached);
225
+ }
226
+ }),
227
+ );
228
+ }
229
+
230
+ const pendingWithoutCache = pendingTasks.filter((task) => !cachedTools.has(task.name));
231
+ if (pendingWithoutCache.length > 0) {
232
+ await Promise.allSettled(pendingWithoutCache.map((task) => task.tracked.promise));
233
+ }
234
+ }
235
+
236
+ for (const task of connectionTasks) {
237
+ const { name } = task;
238
+ if (task.tracked.status === "fulfilled") {
239
+ const value = task.tracked.value;
240
+ if (!value) continue;
241
+ const { connection, serverTools } = value;
242
+ connectedServers.add(name);
243
+ allTools.push(...MCPTool.fromTools(connection, serverTools));
244
+ } else if (task.tracked.status === "rejected") {
245
+ const message =
246
+ task.tracked.reason instanceof Error ? task.tracked.reason.message : String(task.tracked.reason);
247
+ errors.set(name, message);
248
+ reportedErrors.add(name);
249
+ } else {
250
+ const cached = cachedTools.get(name);
251
+ if (cached) {
252
+ const source = this.sources.get(name);
253
+ allTools.push(...DeferredMCPTool.fromTools(name, cached, () => this.waitForConnection(name), source));
254
+ }
112
255
  }
113
- const serverTools = await listTools(connection);
114
- return { name, connection, serverTools };
115
- }),
116
- );
117
-
118
- // Process results
119
- for (let i = 0; i < results.length; i++) {
120
- const result = results[i];
121
- const { name } = connectionTasks[i];
122
-
123
- if (result.status === "fulfilled") {
124
- const { connection, serverTools } = result.value;
125
- this.connections.set(name, connection);
126
- connectedServers.push(name);
127
-
128
- const customTools = createMCPTools(connection, serverTools);
129
- allTools.push(...customTools);
130
- } else {
131
- const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
132
- errors.set(name, message);
133
256
  }
134
257
  }
135
258
 
136
259
  // Update cached tools
137
260
  this.tools = allTools;
261
+ allowBackgroundLogging = true;
138
262
 
139
263
  return {
140
264
  tools: allTools,
141
265
  errors,
142
- connectedServers,
266
+ connectedServers: Array.from(connectedServers),
143
267
  exaApiKeys: [], // Will be populated by discoverAndConnect
144
268
  };
145
269
  }
146
270
 
271
+ private replaceServerTools(name: string, tools: CustomTool<TSchema, MCPToolDetails>[]): void {
272
+ this.tools = this.tools.filter((t) => !t.name.startsWith(`mcp_${name}_`));
273
+ this.tools.push(...tools);
274
+ }
275
+
147
276
  /**
148
277
  * Get all loaded tools.
149
278
  */
@@ -158,6 +287,24 @@ export class MCPManager {
158
287
  return this.connections.get(name);
159
288
  }
160
289
 
290
+ /**
291
+ * Get the source metadata for a server.
292
+ */
293
+ getSource(name: string): SourceMeta | undefined {
294
+ return this.sources.get(name) ?? this.connections.get(name)?._source;
295
+ }
296
+
297
+ /**
298
+ * Wait for a connection to complete (or fail).
299
+ */
300
+ async waitForConnection(name: string): Promise<MCPServerConnection> {
301
+ const connection = this.connections.get(name);
302
+ if (connection) return connection;
303
+ const pending = this.pendingConnections.get(name);
304
+ if (pending) return pending;
305
+ throw new Error(`MCP server not connected: ${name}`);
306
+ }
307
+
161
308
  /**
162
309
  * Get all connected server names.
163
310
  */
@@ -169,11 +316,15 @@ export class MCPManager {
169
316
  * Disconnect from a specific server.
170
317
  */
171
318
  async disconnectServer(name: string): Promise<void> {
172
- const connection = this.connections.get(name);
173
- if (!connection) return;
319
+ this.pendingConnections.delete(name);
320
+ this.pendingToolLoads.delete(name);
321
+ this.sources.delete(name);
174
322
 
175
- await disconnectServer(connection);
176
- this.connections.delete(name);
323
+ const connection = this.connections.get(name);
324
+ if (connection) {
325
+ await disconnectServer(connection);
326
+ this.connections.delete(name);
327
+ }
177
328
 
178
329
  // Remove tools from this server
179
330
  this.tools = this.tools.filter((t) => !t.name.startsWith(`mcp_${name}_`));
@@ -186,6 +337,9 @@ export class MCPManager {
186
337
  const promises = Array.from(this.connections.values()).map((conn) => disconnectServer(conn));
187
338
  await Promise.allSettled(promises);
188
339
 
340
+ this.pendingConnections.clear();
341
+ this.pendingToolLoads.clear();
342
+ this.sources.clear();
189
343
  this.connections.clear();
190
344
  this.tools = [];
191
345
  }
@@ -202,11 +356,11 @@ export class MCPManager {
202
356
 
203
357
  // Reload tools
204
358
  const serverTools = await listTools(connection);
205
- const customTools = createMCPTools(connection, serverTools);
359
+ const customTools = MCPTool.fromTools(connection, serverTools);
360
+ void this.toolCache?.set(name, connection.config, serverTools);
206
361
 
207
362
  // Replace tools from this server
208
- this.tools = this.tools.filter((t) => !t.name.startsWith(`mcp_${name}_`));
209
- this.tools.push(...customTools);
363
+ this.replaceServerTools(name, customTools);
210
364
  }
211
365
 
212
366
  /**
@@ -4,8 +4,10 @@
4
4
  * Converts MCP tool definitions to CustomTool format for the agent.
5
5
  */
6
6
 
7
+ import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
8
  import type { TSchema } from "@sinclair/typebox";
8
- import type { CustomTool, CustomToolResult } from "../custom-tools/types";
9
+ import type { SourceMeta } from "../../capability/types";
10
+ import type { CustomTool, CustomToolContext, CustomToolResult } from "../custom-tools/types";
9
11
  import { callTool } from "./client";
10
12
  import type { MCPContent, MCPServerConnection, MCPToolDefinition } from "./types";
11
13
 
@@ -88,69 +90,155 @@ export function parseMCPToolName(name: string): { serverName: string; toolName:
88
90
  }
89
91
 
90
92
  /**
91
- * Convert an MCP tool definition to a CustomTool.
93
+ * CustomTool wrapping an MCP tool with an active connection.
92
94
  */
93
- export function createMCPTool(
94
- connection: MCPServerConnection,
95
- tool: MCPToolDefinition,
96
- ): CustomTool<TSchema, MCPToolDetails> {
97
- const name = createMCPToolName(connection.name, tool.name);
98
- const schema = convertSchema(tool.inputSchema);
99
-
100
- return {
101
- name,
102
- label: `${connection.name}/${tool.name}`,
103
- description: tool.description ?? `MCP tool from ${connection.name}`,
104
- parameters: schema,
105
-
106
- async execute(_toolCallId, params, _onUpdate, _ctx, _signal): Promise<CustomToolResult<MCPToolDetails>> {
107
- try {
108
- const result = await callTool(connection, tool.name, params as Record<string, unknown>);
109
-
110
- const text = formatMCPContent(result.content);
111
- const details: MCPToolDetails = {
112
- serverName: connection.name,
113
- mcpToolName: tool.name,
114
- isError: result.isError,
115
- rawContent: result.content,
116
- provider: connection._source?.provider,
117
- providerName: connection._source?.providerName,
118
- };
95
+ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
96
+ public readonly name: string;
97
+ public readonly label: string;
98
+ public readonly description: string;
99
+ public readonly parameters: TSchema;
100
+
101
+ /** Create MCPTool instances for all tools from an MCP server connection */
102
+ static fromTools(connection: MCPServerConnection, tools: MCPToolDefinition[]): MCPTool[] {
103
+ return tools.map((tool) => new MCPTool(connection, tool));
104
+ }
119
105
 
120
- if (result.isError) {
121
- return {
122
- content: [{ type: "text", text: `Error: ${text}` }],
123
- details,
124
- };
125
- }
106
+ constructor(
107
+ private readonly connection: MCPServerConnection,
108
+ private readonly tool: MCPToolDefinition,
109
+ ) {
110
+ this.name = createMCPToolName(connection.name, tool.name);
111
+ this.label = `${connection.name}/${tool.name}`;
112
+ this.description = tool.description ?? `MCP tool from ${connection.name}`;
113
+ this.parameters = convertSchema(tool.inputSchema);
114
+ }
126
115
 
116
+ async execute(
117
+ _toolCallId: string,
118
+ params: unknown,
119
+ _onUpdate: AgentToolUpdateCallback<MCPToolDetails> | undefined,
120
+ _ctx: CustomToolContext,
121
+ _signal?: AbortSignal,
122
+ ): Promise<CustomToolResult<MCPToolDetails>> {
123
+ try {
124
+ const result = await callTool(this.connection, this.tool.name, params as Record<string, unknown>);
125
+
126
+ const text = formatMCPContent(result.content);
127
+ const details: MCPToolDetails = {
128
+ serverName: this.connection.name,
129
+ mcpToolName: this.tool.name,
130
+ isError: result.isError,
131
+ rawContent: result.content,
132
+ provider: this.connection._source?.provider,
133
+ providerName: this.connection._source?.providerName,
134
+ };
135
+
136
+ if (result.isError) {
127
137
  return {
128
- content: [{ type: "text", text }],
138
+ content: [{ type: "text", text: `Error: ${text}` }],
129
139
  details,
130
140
  };
131
- } catch (error) {
132
- const message = error instanceof Error ? error.message : String(error);
133
- return {
134
- content: [{ type: "text", text: `MCP error: ${message}` }],
135
- details: {
136
- serverName: connection.name,
137
- mcpToolName: tool.name,
138
- isError: true,
139
- provider: connection._source?.provider,
140
- providerName: connection._source?.providerName,
141
- },
142
- };
143
141
  }
144
- },
145
- };
142
+
143
+ return {
144
+ content: [{ type: "text", text }],
145
+ details,
146
+ };
147
+ } catch (error) {
148
+ const message = error instanceof Error ? error.message : String(error);
149
+ return {
150
+ content: [{ type: "text", text: `MCP error: ${message}` }],
151
+ details: {
152
+ serverName: this.connection.name,
153
+ mcpToolName: this.tool.name,
154
+ isError: true,
155
+ provider: this.connection._source?.provider,
156
+ providerName: this.connection._source?.providerName,
157
+ },
158
+ };
159
+ }
160
+ }
146
161
  }
147
162
 
148
163
  /**
149
- * Convert all tools from an MCP server to CustomTools.
164
+ * CustomTool wrapping an MCP tool with deferred connection resolution.
150
165
  */
151
- export function createMCPTools(
152
- connection: MCPServerConnection,
153
- tools: MCPToolDefinition[],
154
- ): CustomTool<TSchema, MCPToolDetails>[] {
155
- return tools.map((tool) => createMCPTool(connection, tool));
166
+ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
167
+ public readonly name: string;
168
+ public readonly label: string;
169
+ public readonly description: string;
170
+ public readonly parameters: TSchema;
171
+ private readonly fallbackProvider: string | undefined;
172
+ private readonly fallbackProviderName: string | undefined;
173
+
174
+ /** Create DeferredMCPTool instances for all tools from an MCP server */
175
+ static fromTools(
176
+ serverName: string,
177
+ tools: MCPToolDefinition[],
178
+ getConnection: () => Promise<MCPServerConnection>,
179
+ source?: SourceMeta,
180
+ ): DeferredMCPTool[] {
181
+ return tools.map((tool) => new DeferredMCPTool(serverName, tool, getConnection, source));
182
+ }
183
+
184
+ constructor(
185
+ private readonly serverName: string,
186
+ private readonly tool: MCPToolDefinition,
187
+ private readonly getConnection: () => Promise<MCPServerConnection>,
188
+ source?: SourceMeta,
189
+ ) {
190
+ this.name = createMCPToolName(serverName, tool.name);
191
+ this.label = `${serverName}/${tool.name}`;
192
+ this.description = tool.description ?? `MCP tool from ${serverName}`;
193
+ this.parameters = convertSchema(tool.inputSchema);
194
+ this.fallbackProvider = source?.provider;
195
+ this.fallbackProviderName = source?.providerName;
196
+ }
197
+
198
+ async execute(
199
+ _toolCallId: string,
200
+ params: unknown,
201
+ _onUpdate: AgentToolUpdateCallback<MCPToolDetails> | undefined,
202
+ _ctx: CustomToolContext,
203
+ _signal?: AbortSignal,
204
+ ): Promise<CustomToolResult<MCPToolDetails>> {
205
+ try {
206
+ const connection = await this.getConnection();
207
+ const result = await callTool(connection, this.tool.name, params as Record<string, unknown>);
208
+
209
+ const text = formatMCPContent(result.content);
210
+ const details: MCPToolDetails = {
211
+ serverName: this.serverName,
212
+ mcpToolName: this.tool.name,
213
+ isError: result.isError,
214
+ rawContent: result.content,
215
+ provider: connection._source?.provider ?? this.fallbackProvider,
216
+ providerName: connection._source?.providerName ?? this.fallbackProviderName,
217
+ };
218
+
219
+ if (result.isError) {
220
+ return {
221
+ content: [{ type: "text", text: `Error: ${text}` }],
222
+ details,
223
+ };
224
+ }
225
+
226
+ return {
227
+ content: [{ type: "text", text }],
228
+ details,
229
+ };
230
+ } catch (error) {
231
+ const message = error instanceof Error ? error.message : String(error);
232
+ return {
233
+ content: [{ type: "text", text: `MCP error: ${message}` }],
234
+ details: {
235
+ serverName: this.serverName,
236
+ mcpToolName: this.tool.name,
237
+ isError: true,
238
+ provider: this.fallbackProvider,
239
+ providerName: this.fallbackProviderName,
240
+ },
241
+ };
242
+ }
243
+ }
156
244
  }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * MCP tool cache.
3
+ *
4
+ * Stores tool definitions per server in agent.db for fast startup.
5
+ */
6
+
7
+ import type { AgentStorage } from "../agent-storage";
8
+ import { logger } from "../logger";
9
+ import type { MCPServerConfig, MCPToolDefinition } from "./types";
10
+
11
+ const CACHE_VERSION = 1;
12
+ const CACHE_PREFIX = "mcp_tools:";
13
+ const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
14
+
15
+ type MCPToolCachePayload = {
16
+ version: number;
17
+ configHash: string;
18
+ tools: MCPToolDefinition[];
19
+ };
20
+
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return !!value && typeof value === "object" && !Array.isArray(value);
23
+ }
24
+
25
+ function stableClone(value: unknown): unknown {
26
+ if (Array.isArray(value)) {
27
+ return value.map((item) => stableClone(item));
28
+ }
29
+ if (isRecord(value)) {
30
+ const sorted: Record<string, unknown> = {};
31
+ for (const key of Object.keys(value).sort()) {
32
+ sorted[key] = stableClone(value[key]);
33
+ }
34
+ return sorted;
35
+ }
36
+ return value;
37
+ }
38
+
39
+ function stableStringify(value: unknown): string {
40
+ return JSON.stringify(stableClone(value));
41
+ }
42
+
43
+ function toHex(buffer: ArrayBuffer): string {
44
+ const bytes = new Uint8Array(buffer);
45
+ let output = "";
46
+ for (const byte of bytes) {
47
+ output += byte.toString(16).padStart(2, "0");
48
+ }
49
+ return output;
50
+ }
51
+
52
+ async function hashConfig(config: MCPServerConfig): Promise<string> {
53
+ const stable = stableStringify(config);
54
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(stable));
55
+ return toHex(digest);
56
+ }
57
+
58
+ function cacheKey(serverName: string): string {
59
+ return `${CACHE_PREFIX}${serverName}`;
60
+ }
61
+
62
+ export class MCPToolCache {
63
+ constructor(private storage: AgentStorage) {}
64
+
65
+ async get(serverName: string, config: MCPServerConfig): Promise<MCPToolDefinition[] | null> {
66
+ const key = cacheKey(serverName);
67
+ const raw = this.storage.getCache(key);
68
+ if (!raw) return null;
69
+
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ } catch (error) {
74
+ logger.warn("MCP tool cache parse failed", { serverName, error: String(error) });
75
+ return null;
76
+ }
77
+
78
+ if (!isRecord(parsed)) return null;
79
+ if (parsed.version !== CACHE_VERSION) return null;
80
+ if (typeof parsed.configHash !== "string") return null;
81
+ if (!Array.isArray(parsed.tools)) return null;
82
+
83
+ let currentHash: string;
84
+ try {
85
+ currentHash = await hashConfig(config);
86
+ } catch (error) {
87
+ logger.warn("MCP tool cache hash failed", { serverName, error: String(error) });
88
+ return null;
89
+ }
90
+
91
+ if (parsed.configHash !== currentHash) return null;
92
+
93
+ return parsed.tools as MCPToolDefinition[];
94
+ }
95
+
96
+ async set(serverName: string, config: MCPServerConfig, tools: MCPToolDefinition[]): Promise<void> {
97
+ let configHash: string;
98
+ try {
99
+ configHash = await hashConfig(config);
100
+ } catch (error) {
101
+ logger.warn("MCP tool cache hash failed", { serverName, error: String(error) });
102
+ return;
103
+ }
104
+
105
+ const payload: MCPToolCachePayload = {
106
+ version: CACHE_VERSION,
107
+ configHash,
108
+ tools,
109
+ };
110
+
111
+ let serialized: string;
112
+ try {
113
+ serialized = JSON.stringify(payload);
114
+ } catch (error) {
115
+ logger.warn("MCP tool cache serialize failed", { serverName, error: String(error) });
116
+ return;
117
+ }
118
+
119
+ const expiresAtSec = Math.floor((Date.now() + CACHE_TTL_MS) / 1000);
120
+ this.storage.setCache(cacheKey(serverName), serialized, expiresAtSec);
121
+ }
122
+ }