@link-assistant/agent 0.26.15 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -7
- package/package.json +51 -42
- package/src/agent/agent.ts +12 -7
- package/src/auth/azure-login.ts +241 -0
- package/src/auth/plugins.ts +9 -0
- package/src/auth/provider-logins.ts +804 -0
- package/src/cli/argv.ts +1 -1
- package/src/cli/cmd/auth.ts +4 -0
- package/src/cli/cmd/models.ts +68 -13
- package/src/cli/cmd/run.ts +7 -1
- package/src/cli/continuous-mode.js +38 -12
- package/src/cli/event-handler.js +3 -1
- package/src/cli/model-config.js +27 -8
- package/src/cli/run-options.js +144 -78
- package/src/config/config.ts +306 -105
- package/src/config/defaults.ts +13 -5
- package/src/config/file-config.ts +16 -0
- package/src/file/ripgrep.ts +38 -5
- package/src/file/time.ts +86 -9
- package/src/index.js +42 -33
- package/src/mcp/index.ts +370 -106
- package/src/permission/index.ts +22 -0
- package/src/project/instance.ts +5 -1
- package/src/project/project.ts +35 -2
- package/src/provider/env-template.ts +115 -0
- package/src/provider/extra-providers.ts +347 -0
- package/src/provider/free-tier-error.ts +1 -1
- package/src/provider/kilo-deprecation.ts +30 -0
- package/src/provider/kilo.ts +44 -7
- package/src/provider/model-discovery.ts +690 -0
- package/src/provider/models.ts +11 -1
- package/src/provider/parity-loaders.ts +595 -0
- package/src/provider/provider.ts +309 -203
- package/src/provider/retry-fetch.ts +5 -0
- package/src/provider/transform.ts +168 -17
- package/src/session/compaction.ts +18 -2
- package/src/session/index.ts +30 -3
- package/src/session/message-v2.ts +318 -32
- package/src/session/processor.ts +274 -140
- package/src/session/prompt.ts +154 -128
- package/src/session/retry.ts +26 -13
- package/src/session/summary.ts +4 -1
- package/src/tool/bash.ts +232 -45
- package/src/tool/bash.txt +1 -1
- package/src/tool/codesearch.ts +15 -78
- package/src/tool/edit.ts +111 -30
- package/src/tool/glob.ts +2 -1
- package/src/tool/grep.ts +175 -57
- package/src/tool/ls.ts +2 -1
- package/src/tool/multiedit.ts +3 -1
- package/src/tool/read.ts +169 -41
- package/src/tool/task.ts +26 -18
- package/src/tool/tool.ts +4 -1
- package/src/tool/webfetch.ts +68 -56
- package/src/tool/webfetch.txt +4 -4
- package/src/tool/websearch.ts +20 -75
- package/src/tool/websearch.txt +2 -0
- package/src/tool/write.ts +23 -6
- package/src/util/bom.ts +32 -0
- package/src/util/exa-mcp.ts +82 -0
- package/src/util/legacy-error-names.ts +39 -0
- package/src/util/read-bounded.ts +32 -0
- package/src/util/sse-usage-extractor.ts +67 -13
- package/src/util/timeout.ts +3 -4
- package/src/util/verbose-fetch.ts +40 -1
package/src/mcp/index.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { NamedError } from '../util/error';
|
|
|
10
10
|
import z from 'zod/v4';
|
|
11
11
|
import { Instance } from '../project/instance';
|
|
12
12
|
import { withTimeout } from '../util/timeout';
|
|
13
|
+
import { sanitizeUrl } from '../util/verbose-fetch';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
13
15
|
|
|
14
16
|
export namespace MCP {
|
|
15
17
|
const log = Log.create({ service: 'mcp' });
|
|
@@ -35,6 +37,10 @@ export namespace MCP {
|
|
|
35
37
|
|
|
36
38
|
type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>;
|
|
37
39
|
|
|
40
|
+
/** Documented default of the per-server `timeout` for listing tools. */
|
|
41
|
+
const DEFAULT_LIST_TIMEOUT = 5000;
|
|
42
|
+
const MAX_LIST_PAGES = 1000;
|
|
43
|
+
|
|
38
44
|
/** Global timeout defaults from configuration */
|
|
39
45
|
export interface GlobalTimeoutDefaults {
|
|
40
46
|
/** Global default timeout for MCP tool calls */
|
|
@@ -85,10 +91,14 @@ export namespace MCP {
|
|
|
85
91
|
const state = Instance.state(
|
|
86
92
|
async () => {
|
|
87
93
|
const cfg = await Config.get();
|
|
88
|
-
|
|
94
|
+
// Named `servers`, not `config`: shadowing the imported AgentConfig made
|
|
95
|
+
// both global defaults `undefined`, so every tool timeout became NaN.
|
|
96
|
+
const servers = cfg.mcp ?? {};
|
|
89
97
|
const clients: Record<string, Client> = {};
|
|
90
98
|
const status: Record<string, Status> = {};
|
|
91
99
|
const timeoutConfigs: Record<string, TimeoutConfig> = {};
|
|
100
|
+
const listTimeouts: Record<string, number> = {};
|
|
101
|
+
const processes: Record<string, StdioClientTransport> = {};
|
|
92
102
|
|
|
93
103
|
// Determine global timeout defaults from config and environment variables.
|
|
94
104
|
// Uses config.mcp*() which reads from centralized AgentConfig (lino-arguments).
|
|
@@ -102,11 +112,22 @@ export namespace MCP {
|
|
|
102
112
|
};
|
|
103
113
|
|
|
104
114
|
await Promise.all(
|
|
105
|
-
Object.entries(
|
|
106
|
-
|
|
115
|
+
Object.entries(servers).map(async ([key, mcp]) => {
|
|
116
|
+
// A config that throws while connecting (e.g. a bad URL) must still
|
|
117
|
+
// show up as failed instead of silently disappearing.
|
|
118
|
+
// See: https://github.com/anomalyco/opencode/pull/25019
|
|
119
|
+
const result = await create(key, mcp).catch((error) => ({
|
|
120
|
+
mcpClient: undefined,
|
|
121
|
+
transport: undefined,
|
|
122
|
+
status: {
|
|
123
|
+
status: 'failed' as const,
|
|
124
|
+
error: error instanceof Error ? error.message : String(error),
|
|
125
|
+
},
|
|
126
|
+
}));
|
|
107
127
|
if (!result) return;
|
|
108
128
|
|
|
109
129
|
status[key] = result.status;
|
|
130
|
+
listTimeouts[key] = mcp.timeout ?? DEFAULT_LIST_TIMEOUT;
|
|
110
131
|
|
|
111
132
|
// Store timeout configuration for this MCP server
|
|
112
133
|
// Per-server timeout overrides global default, but is capped at global max
|
|
@@ -121,6 +142,7 @@ export namespace MCP {
|
|
|
121
142
|
|
|
122
143
|
if (result.mcpClient) {
|
|
123
144
|
clients[key] = result.mcpClient;
|
|
145
|
+
if (result.transport) processes[key] = result.transport;
|
|
124
146
|
}
|
|
125
147
|
})
|
|
126
148
|
);
|
|
@@ -128,26 +150,118 @@ export namespace MCP {
|
|
|
128
150
|
status,
|
|
129
151
|
clients,
|
|
130
152
|
timeoutConfigs,
|
|
153
|
+
listTimeouts,
|
|
154
|
+
processes,
|
|
131
155
|
globalDefaults,
|
|
132
156
|
};
|
|
133
157
|
},
|
|
134
158
|
async (state) => {
|
|
135
159
|
await Promise.all(
|
|
136
|
-
Object.
|
|
137
|
-
client.
|
|
138
|
-
log.error(() => ({
|
|
139
|
-
message: 'Failed to close MCP client',
|
|
140
|
-
error,
|
|
141
|
-
}));
|
|
142
|
-
})
|
|
160
|
+
Object.entries(state.clients).map(([key, client]) =>
|
|
161
|
+
closeClient(client, state.processes[key])
|
|
143
162
|
)
|
|
144
163
|
);
|
|
145
164
|
}
|
|
146
165
|
);
|
|
147
166
|
|
|
167
|
+
/** Every process below `pid`, collected before the tree is torn down. */
|
|
168
|
+
function descendants(pid: number | null | undefined): number[] {
|
|
169
|
+
if (!pid || process.platform === 'win32') return [];
|
|
170
|
+
const result: number[] = [];
|
|
171
|
+
const queue = [pid];
|
|
172
|
+
while (queue.length > 0) {
|
|
173
|
+
const parent = queue.shift()!;
|
|
174
|
+
const out = spawnSync('pgrep', ['-P', String(parent)], {
|
|
175
|
+
encoding: 'utf8',
|
|
176
|
+
});
|
|
177
|
+
for (const line of (out.stdout ?? '').split('\n')) {
|
|
178
|
+
const child = Number(line.trim());
|
|
179
|
+
if (!child || result.includes(child)) continue;
|
|
180
|
+
result.push(child);
|
|
181
|
+
queue.push(child);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Closing the client only stops the direct child; a server started through
|
|
189
|
+
* a wrapper (`sh -c`, `npx`, `uvx`) leaves its real process running.
|
|
190
|
+
* See: https://github.com/anomalyco/opencode/pull/15516
|
|
191
|
+
*/
|
|
192
|
+
async function closeClient(client: Client, transport?: StdioClientTransport) {
|
|
193
|
+
const pids = descendants(transport?.pid);
|
|
194
|
+
await client.close().catch((error) => {
|
|
195
|
+
log.error(() => ({
|
|
196
|
+
message: 'Failed to close MCP client',
|
|
197
|
+
error,
|
|
198
|
+
}));
|
|
199
|
+
});
|
|
200
|
+
for (const pid of pids) {
|
|
201
|
+
try {
|
|
202
|
+
process.kill(pid, 'SIGTERM');
|
|
203
|
+
} catch {
|
|
204
|
+
// already gone
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sanitize(name: string) {
|
|
210
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Lists every tool page with a per-request timeout, bounded against servers
|
|
215
|
+
* that repeat a cursor or never stop paginating. A server without the
|
|
216
|
+
* `tools` capability simply has no tools.
|
|
217
|
+
* See: https://github.com/anomalyco/opencode/pull/31442
|
|
218
|
+
* See: https://github.com/anomalyco/opencode/pull/31271
|
|
219
|
+
*/
|
|
220
|
+
async function listTools(
|
|
221
|
+
client: Client,
|
|
222
|
+
timeout: number
|
|
223
|
+
): Promise<Record<string, Tool>> {
|
|
224
|
+
const definitions: Awaited<ReturnType<Client['listTools']>>['tools'] = [];
|
|
225
|
+
const seen = new Set<string>();
|
|
226
|
+
let cursor: string | undefined;
|
|
227
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
228
|
+
const result = await client
|
|
229
|
+
.listTools({
|
|
230
|
+
params: cursor ? { cursor } : undefined,
|
|
231
|
+
options: { timeout },
|
|
232
|
+
})
|
|
233
|
+
.catch((error) => {
|
|
234
|
+
if (
|
|
235
|
+
error instanceof Error &&
|
|
236
|
+
error.message.includes('does not support tools')
|
|
237
|
+
)
|
|
238
|
+
return undefined;
|
|
239
|
+
throw error;
|
|
240
|
+
});
|
|
241
|
+
if (!result) return {};
|
|
242
|
+
definitions.push(...result.tools);
|
|
243
|
+
if (result.nextCursor == null)
|
|
244
|
+
return client.toolsFromDefinitions({ tools: definitions });
|
|
245
|
+
if (seen.has(result.nextCursor))
|
|
246
|
+
throw new Error(
|
|
247
|
+
`MCP server repeated tools/list cursor "${result.nextCursor}"`
|
|
248
|
+
);
|
|
249
|
+
seen.add(result.nextCursor);
|
|
250
|
+
cursor = result.nextCursor;
|
|
251
|
+
}
|
|
252
|
+
throw new Error(`MCP tools/list exceeded ${MAX_LIST_PAGES} pages`);
|
|
253
|
+
}
|
|
254
|
+
|
|
148
255
|
export async function add(name: string, mcp: Config.Mcp) {
|
|
149
256
|
const s = await state();
|
|
150
|
-
const result = await create(name, mcp)
|
|
257
|
+
const result = await create(name, mcp).catch((error) => ({
|
|
258
|
+
mcpClient: undefined,
|
|
259
|
+
transport: undefined,
|
|
260
|
+
status: {
|
|
261
|
+
status: 'failed' as const,
|
|
262
|
+
error: error instanceof Error ? error.message : String(error),
|
|
263
|
+
},
|
|
264
|
+
}));
|
|
151
265
|
if (!result) {
|
|
152
266
|
const status = {
|
|
153
267
|
status: 'failed' as const,
|
|
@@ -158,6 +272,7 @@ export namespace MCP {
|
|
|
158
272
|
status,
|
|
159
273
|
};
|
|
160
274
|
}
|
|
275
|
+
s.listTimeouts[name] = mcp.timeout ?? DEFAULT_LIST_TIMEOUT;
|
|
161
276
|
if (!result.mcpClient) {
|
|
162
277
|
s.status[name] = result.status;
|
|
163
278
|
return {
|
|
@@ -165,6 +280,7 @@ export namespace MCP {
|
|
|
165
280
|
};
|
|
166
281
|
}
|
|
167
282
|
s.clients[name] = result.mcpClient;
|
|
283
|
+
if (result.transport) s.processes[name] = result.transport;
|
|
168
284
|
s.status[name] = result.status;
|
|
169
285
|
|
|
170
286
|
return {
|
|
@@ -172,6 +288,28 @@ export namespace MCP {
|
|
|
172
288
|
};
|
|
173
289
|
}
|
|
174
290
|
|
|
291
|
+
/**
|
|
292
|
+
* `initialize` gets a deadline and a transport that fails to come up is
|
|
293
|
+
* closed, so a hung server cannot stall startup or leak its process.
|
|
294
|
+
* See: https://github.com/anomalyco/opencode/pull/6760
|
|
295
|
+
*/
|
|
296
|
+
async function connect(
|
|
297
|
+
transport: Parameters<typeof experimental_createMCPClient>[0]['transport'],
|
|
298
|
+
timeout: number
|
|
299
|
+
) {
|
|
300
|
+
return experimental_createMCPClient({
|
|
301
|
+
name: 'opencode',
|
|
302
|
+
transport,
|
|
303
|
+
// 0 means "no bound" (the AI SDK would otherwise abort immediately).
|
|
304
|
+
initializationOptions: timeout > 0 ? { timeout } : undefined,
|
|
305
|
+
}).catch(async (error) => {
|
|
306
|
+
await (transport as { close?: () => Promise<void> })
|
|
307
|
+
.close?.()
|
|
308
|
+
.catch(() => {});
|
|
309
|
+
throw error;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
175
313
|
async function create(key: string, mcp: Config.Mcp) {
|
|
176
314
|
if (mcp.enabled === false) {
|
|
177
315
|
log.info(() => ({ message: 'mcp server disabled', key }));
|
|
@@ -179,33 +317,52 @@ export namespace MCP {
|
|
|
179
317
|
}
|
|
180
318
|
log.info(() => ({ message: 'found', key, type: mcp.type }));
|
|
181
319
|
let mcpClient: MCPClient | undefined;
|
|
320
|
+
let stdio: StdioClientTransport | undefined;
|
|
182
321
|
let status: Status | undefined = undefined;
|
|
322
|
+
// Bound on starting the server and `initialize` (main had none, upstream
|
|
323
|
+
// reuses `timeout`). `timeout` keeps its documented meaning (listing
|
|
324
|
+
// tools), so a short listing timeout does not start failing slow-starting
|
|
325
|
+
// servers, while a raised one also raises the default connect bound.
|
|
326
|
+
const connectTimeout =
|
|
327
|
+
mcp.connect_timeout ??
|
|
328
|
+
(config.mcpConnectTimeout > 0
|
|
329
|
+
? Math.max(config.mcpConnectTimeout, mcp.timeout ?? 0)
|
|
330
|
+
: 0);
|
|
183
331
|
|
|
184
332
|
if (mcp.type === 'remote') {
|
|
333
|
+
if (!URL.canParse(mcp.url)) {
|
|
334
|
+
return {
|
|
335
|
+
mcpClient: undefined,
|
|
336
|
+
transport: undefined,
|
|
337
|
+
status: {
|
|
338
|
+
status: 'failed' as const,
|
|
339
|
+
error: `Invalid MCP URL: ${mcp.url}`,
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
185
343
|
const transports = [
|
|
186
344
|
{
|
|
187
345
|
name: 'StreamableHTTP',
|
|
188
|
-
transport:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
346
|
+
transport: () =>
|
|
347
|
+
new StreamableHTTPClientTransport(new URL(mcp.url), {
|
|
348
|
+
requestInit: {
|
|
349
|
+
headers: mcp.headers,
|
|
350
|
+
},
|
|
351
|
+
}),
|
|
193
352
|
},
|
|
194
353
|
{
|
|
195
354
|
name: 'SSE',
|
|
196
|
-
transport:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
355
|
+
transport: () =>
|
|
356
|
+
new SSEClientTransport(new URL(mcp.url), {
|
|
357
|
+
requestInit: {
|
|
358
|
+
headers: mcp.headers,
|
|
359
|
+
},
|
|
360
|
+
}),
|
|
201
361
|
},
|
|
202
362
|
];
|
|
203
363
|
let lastError: Error | undefined;
|
|
204
364
|
for (const { name, transport } of transports) {
|
|
205
|
-
const result = await
|
|
206
|
-
name: 'opencode',
|
|
207
|
-
transport,
|
|
208
|
-
})
|
|
365
|
+
const result = await connect(transport(), connectTimeout)
|
|
209
366
|
.then((client) => {
|
|
210
367
|
log.info(() => ({
|
|
211
368
|
message: 'connected',
|
|
@@ -223,7 +380,7 @@ export namespace MCP {
|
|
|
223
380
|
message: 'transport connection failed',
|
|
224
381
|
key,
|
|
225
382
|
transport: name,
|
|
226
|
-
url: mcp.url,
|
|
383
|
+
url: sanitizeUrl(mcp.url),
|
|
227
384
|
error: lastError.message,
|
|
228
385
|
}));
|
|
229
386
|
status = {
|
|
@@ -238,21 +395,20 @@ export namespace MCP {
|
|
|
238
395
|
|
|
239
396
|
if (mcp.type === 'local') {
|
|
240
397
|
const [cmd, ...args] = mcp.command;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}),
|
|
253
|
-
})
|
|
398
|
+
const transport = new StdioClientTransport({
|
|
399
|
+
stderr: 'ignore',
|
|
400
|
+
command: cmd,
|
|
401
|
+
args,
|
|
402
|
+
env: {
|
|
403
|
+
...process.env,
|
|
404
|
+
...(cmd === 'opencode' ? { BUN_BE_BUN: '1' } : {}),
|
|
405
|
+
...mcp.environment,
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
await connect(transport, connectTimeout)
|
|
254
409
|
.then((client) => {
|
|
255
410
|
mcpClient = client;
|
|
411
|
+
stdio = transport;
|
|
256
412
|
status = {
|
|
257
413
|
status: 'connected',
|
|
258
414
|
};
|
|
@@ -281,37 +437,31 @@ export namespace MCP {
|
|
|
281
437
|
if (!mcpClient) {
|
|
282
438
|
return {
|
|
283
439
|
mcpClient: undefined,
|
|
440
|
+
transport: undefined,
|
|
284
441
|
status,
|
|
285
442
|
};
|
|
286
443
|
}
|
|
287
444
|
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
445
|
+
const client: MCPClient = mcpClient;
|
|
446
|
+
const result = await listTools(
|
|
447
|
+
client,
|
|
448
|
+
mcp.timeout ?? DEFAULT_LIST_TIMEOUT
|
|
291
449
|
).catch((err) => {
|
|
292
450
|
log.error(() => ({
|
|
293
451
|
message: 'failed to get tools from client',
|
|
294
452
|
key,
|
|
295
|
-
error: err,
|
|
453
|
+
error: err instanceof Error ? err.message : String(err),
|
|
296
454
|
}));
|
|
297
|
-
return
|
|
455
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
298
456
|
});
|
|
299
|
-
if (
|
|
300
|
-
await
|
|
301
|
-
log.error(() => ({
|
|
302
|
-
message: 'Failed to close MCP client',
|
|
303
|
-
error,
|
|
304
|
-
}));
|
|
305
|
-
});
|
|
306
|
-
status = {
|
|
307
|
-
status: 'failed',
|
|
308
|
-
error: 'Failed to get tools',
|
|
309
|
-
};
|
|
457
|
+
if (result instanceof Error) {
|
|
458
|
+
await closeClient(client, stdio);
|
|
310
459
|
return {
|
|
311
460
|
mcpClient: undefined,
|
|
461
|
+
transport: undefined,
|
|
312
462
|
status: {
|
|
313
463
|
status: 'failed' as const,
|
|
314
|
-
error:
|
|
464
|
+
error: `Failed to get tools: ${result.message}`,
|
|
315
465
|
},
|
|
316
466
|
};
|
|
317
467
|
}
|
|
@@ -322,7 +472,8 @@ export namespace MCP {
|
|
|
322
472
|
toolCount: Object.keys(result).length,
|
|
323
473
|
}));
|
|
324
474
|
return {
|
|
325
|
-
mcpClient,
|
|
475
|
+
mcpClient: client,
|
|
476
|
+
transport: stdio,
|
|
326
477
|
status,
|
|
327
478
|
};
|
|
328
479
|
}
|
|
@@ -338,33 +489,158 @@ export namespace MCP {
|
|
|
338
489
|
export async function tools() {
|
|
339
490
|
const result: Record<string, Tool> = {};
|
|
340
491
|
const s = await state();
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
error
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
492
|
+
// Servers are listed in parallel, each under its own timeout, so one
|
|
493
|
+
// stalled server cannot hold up the others. A server that fails is closed
|
|
494
|
+
// (not just forgotten) and reported as failed.
|
|
495
|
+
// See: https://github.com/anomalyco/opencode/pull/13229
|
|
496
|
+
const listed = await Promise.all(
|
|
497
|
+
Object.entries(s.clients).map(async ([clientName, client]) => {
|
|
498
|
+
const tools = await listTools(
|
|
499
|
+
client,
|
|
500
|
+
s.listTimeouts[clientName] ?? DEFAULT_LIST_TIMEOUT
|
|
501
|
+
).catch(async (e) => {
|
|
502
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
503
|
+
log.error(() => ({
|
|
504
|
+
message: 'failed to get tools',
|
|
505
|
+
clientName,
|
|
506
|
+
error,
|
|
507
|
+
}));
|
|
508
|
+
s.status[clientName] = { status: 'failed' as const, error };
|
|
509
|
+
if (s.clients[clientName] === client) {
|
|
510
|
+
delete s.clients[clientName];
|
|
511
|
+
const transport = s.processes[clientName];
|
|
512
|
+
delete s.processes[clientName];
|
|
513
|
+
await closeClient(client, transport);
|
|
514
|
+
}
|
|
515
|
+
return undefined;
|
|
516
|
+
});
|
|
517
|
+
return [clientName, tools] as const;
|
|
518
|
+
})
|
|
519
|
+
);
|
|
520
|
+
for (const [clientName, tools] of listed) {
|
|
521
|
+
if (!tools) continue;
|
|
359
522
|
for (const [toolName, tool] of Object.entries(tools)) {
|
|
360
|
-
|
|
361
|
-
const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
362
|
-
result[sanitizedClientName + '_' + sanitizedToolName] = tool;
|
|
523
|
+
result[sanitize(clientName) + '_' + sanitize(toolName)] = tool;
|
|
363
524
|
}
|
|
364
525
|
}
|
|
365
526
|
return result;
|
|
366
527
|
}
|
|
367
528
|
|
|
529
|
+
export interface NormalizedResult {
|
|
530
|
+
output: string;
|
|
531
|
+
attachments: { mime: string; url: string; filename?: string }[];
|
|
532
|
+
content: unknown[];
|
|
533
|
+
isError: boolean;
|
|
534
|
+
metadata: Record<string, any>;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const ATTACHMENT_MIMES = new Set([
|
|
538
|
+
'application/pdf',
|
|
539
|
+
'image/gif',
|
|
540
|
+
'image/jpeg',
|
|
541
|
+
'image/png',
|
|
542
|
+
'image/webp',
|
|
543
|
+
]);
|
|
544
|
+
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Turns an MCP `tools/call` result into text and attachments. Results with
|
|
548
|
+
* no `content` (structured or legacy `toolResult`) and embedded resources
|
|
549
|
+
* used to reach the model as an empty string or crash the loop.
|
|
550
|
+
* See: https://github.com/anomalyco/opencode/pull/7879
|
|
551
|
+
* See: https://github.com/anomalyco/opencode/pull/32244
|
|
552
|
+
*/
|
|
553
|
+
export function normalizeResult(result: any): NormalizedResult {
|
|
554
|
+
const content: any[] = Array.isArray(result?.content) ? result.content : [];
|
|
555
|
+
const texts: string[] = [];
|
|
556
|
+
const attachments: NormalizedResult['attachments'] = [];
|
|
557
|
+
if (content.length === 0) {
|
|
558
|
+
if (result?.structuredContent !== undefined)
|
|
559
|
+
texts.push(JSON.stringify(result.structuredContent));
|
|
560
|
+
else if (result && 'toolResult' in result)
|
|
561
|
+
texts.push(JSON.stringify(result.toolResult));
|
|
562
|
+
}
|
|
563
|
+
for (const item of content) {
|
|
564
|
+
if (item?.type === 'text') {
|
|
565
|
+
texts.push(item.text ?? '');
|
|
566
|
+
} else if (item?.type === 'image') {
|
|
567
|
+
attachments.push({
|
|
568
|
+
mime: item.mimeType,
|
|
569
|
+
url: `data:${item.mimeType};base64,${item.data}`,
|
|
570
|
+
});
|
|
571
|
+
} else if (item?.type === 'resource' && item.resource) {
|
|
572
|
+
const resource = item.resource;
|
|
573
|
+
if (typeof resource.text === 'string') {
|
|
574
|
+
texts.push(resource.text);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (typeof resource.blob !== 'string') continue;
|
|
578
|
+
const mime = resource.mimeType ?? 'application/octet-stream';
|
|
579
|
+
const bytes = Math.floor((resource.blob.length * 3) / 4);
|
|
580
|
+
if (!ATTACHMENT_MIMES.has(mime)) {
|
|
581
|
+
texts.push(
|
|
582
|
+
`[Binary MCP resource omitted: ${resource.uri} (${mime}) is not a supported attachment type]`
|
|
583
|
+
);
|
|
584
|
+
} else if (bytes > MAX_ATTACHMENT_BYTES) {
|
|
585
|
+
texts.push(
|
|
586
|
+
`[Binary MCP resource omitted: ${resource.uri} (${mime}) is larger than 10 MB]`
|
|
587
|
+
);
|
|
588
|
+
} else {
|
|
589
|
+
attachments.push({
|
|
590
|
+
mime,
|
|
591
|
+
url: `data:${mime};base64,${resource.blob}`,
|
|
592
|
+
filename: resource.uri,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
} else if (item?.type === 'resource_link') {
|
|
596
|
+
texts.push(
|
|
597
|
+
`Resource: ${[item.name, item.uri].filter(Boolean).join(' ')}`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const isError = result?.isError === true;
|
|
602
|
+
const output = texts.join('\n\n');
|
|
603
|
+
return {
|
|
604
|
+
output:
|
|
605
|
+
isError && output.trim() === '' ? 'MCP tool returned an error' : output,
|
|
606
|
+
attachments,
|
|
607
|
+
content,
|
|
608
|
+
isError,
|
|
609
|
+
metadata: result?.metadata ?? {},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Runs an MCP tool call under `timeout`. The request is given its own abort
|
|
615
|
+
* signal (linked to the caller's), which is aborted when the timeout fires,
|
|
616
|
+
* so a timed-out call no longer keeps running in the background.
|
|
617
|
+
*/
|
|
618
|
+
export async function callWithTimeout<T>(
|
|
619
|
+
key: string,
|
|
620
|
+
run: (abortSignal: AbortSignal) => PromiseLike<T>,
|
|
621
|
+
timeout: number,
|
|
622
|
+
parent?: AbortSignal
|
|
623
|
+
): Promise<T> {
|
|
624
|
+
const controller = new AbortController();
|
|
625
|
+
const signal = parent
|
|
626
|
+
? AbortSignal.any([parent, controller.signal])
|
|
627
|
+
: controller.signal;
|
|
628
|
+
try {
|
|
629
|
+
return await withTimeout(Promise.resolve(run(signal)), timeout);
|
|
630
|
+
} catch (error) {
|
|
631
|
+
controller.abort();
|
|
632
|
+
if (error instanceof Error && error.message.includes('timed out after')) {
|
|
633
|
+
const timeoutSec = Math.round(timeout / 1000);
|
|
634
|
+
throw new Error(
|
|
635
|
+
`MCP tool "${key}" timed out after ${timeoutSec} seconds. ` +
|
|
636
|
+
`The operation did not complete within the configured timeout. ` +
|
|
637
|
+
`You can increase the timeout in the MCP server configuration using tool_call_timeout or tool_timeouts.`
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
throw error;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
368
644
|
/**
|
|
369
645
|
* Get the timeout configuration for all MCP servers
|
|
370
646
|
*/
|
|
@@ -392,40 +668,28 @@ export namespace MCP {
|
|
|
392
668
|
const s = await state();
|
|
393
669
|
const maxTimeout = s.globalDefaults.maxTimeout;
|
|
394
670
|
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const toolName = fullToolName.substring(underscoreIndex + 1);
|
|
404
|
-
|
|
405
|
-
// Find the server config (need to handle sanitization)
|
|
406
|
-
const config = s.timeoutConfigs[serverName];
|
|
407
|
-
if (!config) {
|
|
408
|
-
// Try to find by iterating (in case of sanitization differences)
|
|
409
|
-
for (const [key, cfg] of Object.entries(s.timeoutConfigs)) {
|
|
410
|
-
const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
411
|
-
if (sanitizedKey === serverName) {
|
|
412
|
-
// Check for per-tool timeout override
|
|
413
|
-
const perToolTimeout = cfg.toolTimeouts[toolName];
|
|
414
|
-
if (perToolTimeout !== undefined) {
|
|
415
|
-
return Math.min(perToolTimeout, maxTimeout);
|
|
416
|
-
}
|
|
417
|
-
return cfg.defaultTimeout;
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
return s.globalDefaults.defaultTimeout;
|
|
671
|
+
// Format: serverName_toolName (both sanitized). Server names may contain
|
|
672
|
+
// `_` themselves, so the longest matching server prefix wins.
|
|
673
|
+
let match: { prefix: string; config: TimeoutConfig } | undefined;
|
|
674
|
+
for (const [key, cfg] of Object.entries(s.timeoutConfigs)) {
|
|
675
|
+
const prefix = sanitize(key) + '_';
|
|
676
|
+
if (!fullToolName.startsWith(prefix)) continue;
|
|
677
|
+
if (match && match.prefix.length >= prefix.length) continue;
|
|
678
|
+
match = { prefix, config: cfg };
|
|
421
679
|
}
|
|
680
|
+
if (!match) return s.globalDefaults.defaultTimeout;
|
|
422
681
|
|
|
423
682
|
// Check for per-tool timeout override
|
|
424
|
-
const
|
|
683
|
+
const toolName = fullToolName.slice(match.prefix.length);
|
|
684
|
+
const perToolTimeout =
|
|
685
|
+
match.config.toolTimeouts[toolName] ??
|
|
686
|
+
Object.entries(match.config.toolTimeouts).find(
|
|
687
|
+
([name]) => sanitize(name) === toolName
|
|
688
|
+
)?.[1];
|
|
425
689
|
if (perToolTimeout !== undefined) {
|
|
426
690
|
return Math.min(perToolTimeout, maxTimeout);
|
|
427
691
|
}
|
|
428
692
|
|
|
429
|
-
return config.defaultTimeout;
|
|
693
|
+
return match.config.defaultTimeout;
|
|
430
694
|
}
|
|
431
695
|
}
|
package/src/permission/index.ts
CHANGED
|
@@ -391,6 +391,28 @@ export namespace Permission {
|
|
|
391
391
|
});
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Rejects and drops the pending asks of a session. Called when its turn is
|
|
396
|
+
* cancelled, so an aborted turn does not leave a pending promise (and its
|
|
397
|
+
* tool call) behind until the instance is disposed (issue #335).
|
|
398
|
+
*/
|
|
399
|
+
export function clearSession(sessionID: Info['sessionID']) {
|
|
400
|
+
const { pending } = state();
|
|
401
|
+
const items = pending[sessionID];
|
|
402
|
+
if (!items) return;
|
|
403
|
+
delete pending[sessionID];
|
|
404
|
+
for (const item of Object.values(items)) {
|
|
405
|
+
item.reject(
|
|
406
|
+
new RejectedError(
|
|
407
|
+
item.info.sessionID,
|
|
408
|
+
item.info.id,
|
|
409
|
+
item.info.callID,
|
|
410
|
+
item.info.metadata
|
|
411
|
+
)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
394
416
|
export function respond(input: {
|
|
395
417
|
sessionID: Info['sessionID'];
|
|
396
418
|
permissionID: Info['id'];
|
package/src/project/instance.ts
CHANGED
|
@@ -57,7 +57,11 @@ export const Instance = {
|
|
|
57
57
|
},
|
|
58
58
|
async dispose() {
|
|
59
59
|
Log.Default.info('disposing instance', { directory: Instance.directory });
|
|
60
|
-
|
|
60
|
+
const directory = Instance.directory;
|
|
61
|
+
await State.dispose(directory);
|
|
62
|
+
// Drop the resolved context too; it used to stay cached for every
|
|
63
|
+
// directory ever served (issue #335).
|
|
64
|
+
cache.delete(directory);
|
|
61
65
|
await Project.prune();
|
|
62
66
|
},
|
|
63
67
|
async disposeAll() {
|