@oh-my-pi/pi-coding-agent 13.13.2 → 13.14.2
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/CHANGELOG.md +26 -0
- package/package.json +7 -7
- package/src/config/model-registry.ts +10 -3
- package/src/config/settings-schema.ts +3 -0
- package/src/discovery/helpers.ts +9 -2
- package/src/exec/bash-executor.ts +7 -5
- package/src/mcp/client.ts +29 -4
- package/src/mcp/manager.ts +256 -57
- package/src/mcp/tool-bridge.ts +189 -106
- package/src/mcp/transports/http.ts +8 -3
- package/src/modes/components/bash-execution.ts +40 -11
- package/src/modes/components/python-execution.ts +2 -3
- package/src/modes/components/tool-execution.ts +4 -5
- package/src/modes/controllers/command-controller.ts +0 -2
- package/src/modes/controllers/mcp-command-controller.ts +45 -0
- package/src/modes/interactive-mode.ts +9 -5
- package/src/patch/index.ts +15 -7
- package/src/prompts/agents/explore.md +4 -67
- package/src/session/agent-session.ts +7 -16
- package/src/session/streaming-output.ts +87 -37
- package/src/slash-commands/builtin-registry.ts +1 -0
- package/src/tools/bash-interactive.ts +2 -6
- package/src/tools/python.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [13.14.0] - 2026-03-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Auto-reconnect MCP servers on connection loss with proactive SSE stream monitoring and retry backoff
|
|
10
|
+
- Tool-level reconnect: retriable connection errors (ECONNREFUSED, ECONNRESET, stale session 404/502/503) trigger automatic reconnection and single retry
|
|
11
|
+
- `/mcp reconnect <name>` command for manual server recovery after extended outages
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Extended transport reconnect handling to all transport types (not just HTTP/SSE), ensuring stdio and other transports trigger automatic reconnection on connection loss
|
|
16
|
+
- Improved reconnect robustness by aborting retry attempts when MCP server configuration changes during reconnection sequence
|
|
17
|
+
- Updated explore agent thinking level from off to med for improved reasoning
|
|
18
|
+
- Simplified explore agent output schema: consolidated file references into single `ref` field with optional line ranges instead of separate `path`, `line_start`, `line_end` fields
|
|
19
|
+
- Removed `code` section from explore agent output (critical code excerpts no longer extracted)
|
|
20
|
+
- Removed `dependencies` section from explore agent output
|
|
21
|
+
- Removed `risks` section from explore agent output
|
|
22
|
+
- Removed `start_here` section from explore agent output
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Fixed reconnect retry loop continuing after configuration changes by checking epoch before each reconnection attempt
|
|
27
|
+
- `roots/list` timeout on MCP server initialization: `connectToServer` now always installs a default handler for `ping` and `roots/list`
|
|
28
|
+
- Fixed resumed GitHub Copilot conversations that could fail with `401 input item does not belong to this connection` on the first follow-up after process restart ([#488](https://github.com/can1357/oh-my-pi/issues/488))
|
|
29
|
+
- Fixed STT Alt+H mic cursor rendering to measure the actual microphone glyph width, preventing one-column TUI overflow crashes when the active symbol preset uses a wide icon ([#484](https://github.com/can1357/oh-my-pi/issues/484))
|
|
30
|
+
|
|
5
31
|
## [13.13.2] - 2026-03-18
|
|
6
32
|
|
|
7
33
|
### Added
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "13.
|
|
4
|
+
"version": "13.14.2",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@mozilla/readability": "^0.6",
|
|
44
|
-
"@oh-my-pi/omp-stats": "13.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "13.
|
|
46
|
-
"@oh-my-pi/pi-ai": "13.
|
|
47
|
-
"@oh-my-pi/pi-natives": "13.
|
|
48
|
-
"@oh-my-pi/pi-tui": "13.
|
|
49
|
-
"@oh-my-pi/pi-utils": "13.
|
|
44
|
+
"@oh-my-pi/omp-stats": "13.14.2",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "13.14.2",
|
|
46
|
+
"@oh-my-pi/pi-ai": "13.14.2",
|
|
47
|
+
"@oh-my-pi/pi-natives": "13.14.2",
|
|
48
|
+
"@oh-my-pi/pi-tui": "13.14.2",
|
|
49
|
+
"@oh-my-pi/pi-utils": "13.14.2",
|
|
50
50
|
"@sinclair/typebox": "^0.34",
|
|
51
51
|
"@xterm/headless": "^6.0",
|
|
52
52
|
"ajv": "^8.18",
|
|
@@ -1440,10 +1440,17 @@ export class ModelRegistry {
|
|
|
1440
1440
|
}
|
|
1441
1441
|
#applyHardcodedModelPolicies(models: Model<Api>[]): Model<Api>[] {
|
|
1442
1442
|
return models.map(model => {
|
|
1443
|
-
if (model.id
|
|
1444
|
-
return
|
|
1443
|
+
if (model.id !== "gpt-5.4" || model.provider === "github-copilot") {
|
|
1444
|
+
return model;
|
|
1445
|
+
}
|
|
1446
|
+
const overrides = this.#modelOverrides.get(model.provider)?.get(model.id);
|
|
1447
|
+
if (!overrides) {
|
|
1448
|
+
return applyModelOverride(model, { contextWindow: 1_000_000 });
|
|
1445
1449
|
}
|
|
1446
|
-
return model
|
|
1450
|
+
return applyModelOverride(model, {
|
|
1451
|
+
contextWindow: overrides.contextWindow ?? 1_000_000,
|
|
1452
|
+
...overrides,
|
|
1453
|
+
});
|
|
1447
1454
|
});
|
|
1448
1455
|
}
|
|
1449
1456
|
|
|
@@ -1521,6 +1521,8 @@ export const SETTINGS_SCHEMA = {
|
|
|
1521
1521
|
"thinkingBudgets.medium": { type: "number", default: 8192 },
|
|
1522
1522
|
|
|
1523
1523
|
"thinkingBudgets.high": { type: "number", default: 16384 },
|
|
1524
|
+
|
|
1525
|
+
"thinkingBudgets.xhigh": { type: "number", default: 32768 },
|
|
1524
1526
|
} as const;
|
|
1525
1527
|
|
|
1526
1528
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1703,6 +1705,7 @@ export interface ThinkingBudgetsSettings {
|
|
|
1703
1705
|
low: number;
|
|
1704
1706
|
medium: number;
|
|
1705
1707
|
high: number;
|
|
1708
|
+
xhigh: number;
|
|
1706
1709
|
}
|
|
1707
1710
|
|
|
1708
1711
|
export interface SttSettings {
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
4
4
|
import { FileType, glob } from "@oh-my-pi/pi-natives";
|
|
5
5
|
import { CONFIG_DIR_NAME, getConfigDirName, tryParseJson } from "@oh-my-pi/pi-utils";
|
|
6
|
-
import { readFile } from "../capability/fs";
|
|
6
|
+
import { readDirEntries, readFile } from "../capability/fs";
|
|
7
7
|
import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
|
|
8
8
|
import type { Skill, SkillFrontmatter } from "../capability/skill";
|
|
9
9
|
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
|
|
@@ -529,7 +529,14 @@ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: strin
|
|
|
529
529
|
subdirsWithDeclaredExtensions.add(subdir);
|
|
530
530
|
const subdirPath = path.join(dir, subdir);
|
|
531
531
|
for (const extPath of declaredExtensions) {
|
|
532
|
-
|
|
532
|
+
let resolvedExtPath = path.resolve(subdirPath, extPath);
|
|
533
|
+
const entries = await readDirEntries(resolvedExtPath);
|
|
534
|
+
if (entries.length !== 0) {
|
|
535
|
+
const pluginFilePath = entries.find(
|
|
536
|
+
e => e.isFile() && (e.name === "index.ts" || e.name === "index.js"),
|
|
537
|
+
)?.name;
|
|
538
|
+
resolvedExtPath = pluginFilePath ? path.join(resolvedExtPath, pluginFilePath) : resolvedExtPath;
|
|
539
|
+
}
|
|
533
540
|
const content = await readFile(resolvedExtPath);
|
|
534
541
|
if (content !== null) {
|
|
535
542
|
discovered.add(resolvedExtPath);
|
|
@@ -69,11 +69,16 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
69
69
|
onChunk: options?.onChunk,
|
|
70
70
|
artifactPath: options?.artifactPath,
|
|
71
71
|
artifactId: options?.artifactId,
|
|
72
|
+
// Throttle the streaming preview callback to avoid saturating the
|
|
73
|
+
// event loop when commands produce massive output (e.g. seq 1 50M).
|
|
74
|
+
chunkThrottleMs: options?.onChunk ? 50 : 0,
|
|
72
75
|
});
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
// sink.push() is synchronous — buffer management, counters, and onChunk
|
|
78
|
+
// all run inline. File writes (artifact path) are handled asynchronously
|
|
79
|
+
// inside the sink. No promise chain needed.
|
|
75
80
|
const enqueueChunk = (chunk: string) => {
|
|
76
|
-
|
|
81
|
+
sink.push(chunk);
|
|
77
82
|
};
|
|
78
83
|
|
|
79
84
|
if (options?.signal?.aborted) {
|
|
@@ -160,8 +165,6 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
160
165
|
hardTimeoutDeferred.promise.then(() => ({ kind: "hard-timeout" as const })),
|
|
161
166
|
]);
|
|
162
167
|
|
|
163
|
-
await pendingChunks;
|
|
164
|
-
|
|
165
168
|
if (winner.kind === "hard-timeout") {
|
|
166
169
|
if (shellSession) {
|
|
167
170
|
resetSession = true;
|
|
@@ -215,7 +218,6 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
215
218
|
if (userSignal) {
|
|
216
219
|
userSignal.removeEventListener("abort", abortHandler);
|
|
217
220
|
}
|
|
218
|
-
await pendingChunks;
|
|
219
221
|
if (resetSession) {
|
|
220
222
|
shellSessions.delete(sessionKey);
|
|
221
223
|
}
|
package/src/mcp/client.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Handles connection initialization, tool listing, and tool calling.
|
|
5
5
|
*/
|
|
6
|
-
import
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as url from "node:url";
|
|
8
|
+
import { getProjectDir, logger, withTimeout } from "@oh-my-pi/pi-utils";
|
|
7
9
|
import { createHttpTransport } from "./transports/http";
|
|
8
10
|
import { createStdioTransport } from "./transports/stdio";
|
|
9
11
|
import type {
|
|
@@ -46,6 +48,27 @@ const CLIENT_INFO = {
|
|
|
46
48
|
version: "1.0.0",
|
|
47
49
|
};
|
|
48
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Default handler for standard MCP server-to-client requests.
|
|
53
|
+
* Handles `ping` and `roots/list`; rejects unknown methods with -32601.
|
|
54
|
+
* Reads getProjectDir() at call time so the root stays stable even if
|
|
55
|
+
* the process cwd changes during tool execution.
|
|
56
|
+
*/
|
|
57
|
+
async function defaultRequestHandler(method: string, _params: unknown): Promise<unknown> {
|
|
58
|
+
switch (method) {
|
|
59
|
+
case "ping":
|
|
60
|
+
return {};
|
|
61
|
+
case "roots/list": {
|
|
62
|
+
const cwd = getProjectDir();
|
|
63
|
+
return {
|
|
64
|
+
roots: [{ uri: url.pathToFileURL(cwd).href, name: path.basename(cwd) }],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
default:
|
|
68
|
+
throw Object.assign(new Error(`Unsupported server request: ${method}`), { code: -32601 });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
49
72
|
/**
|
|
50
73
|
* Create a transport for the given server config.
|
|
51
74
|
*/
|
|
@@ -124,9 +147,11 @@ export async function connectToServer(
|
|
|
124
147
|
if (options?.onNotification) {
|
|
125
148
|
transport.onNotification = options.onNotification;
|
|
126
149
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
150
|
+
|
|
151
|
+
// Always handle standard MCP server-to-client requests (ping, roots/list).
|
|
152
|
+
// The initialize request declares roots capability, so we must respond to
|
|
153
|
+
// roots/list — even for short-lived test connections.
|
|
154
|
+
transport.onRequest = options?.onRequest ?? defaultRequestHandler;
|
|
130
155
|
|
|
131
156
|
try {
|
|
132
157
|
const initResult = await initializeConnection(transport, {
|
package/src/mcp/manager.ts
CHANGED
|
@@ -131,6 +131,11 @@ export class MCPManager {
|
|
|
131
131
|
#notificationsEpoch = 0;
|
|
132
132
|
#subscribedResources = new Map<string, Set<string>>();
|
|
133
133
|
#pendingResourceRefresh = new Map<string, { connection: MCPServerConnection; promise: Promise<void> }>();
|
|
134
|
+
#pendingReconnections = new Map<string, Promise<MCPServerConnection | null>>();
|
|
135
|
+
/** Preserved configs for reconnection after connection loss. */
|
|
136
|
+
#serverConfigs = new Map<string, MCPServerConfig>();
|
|
137
|
+
/** Monotonic epoch incremented on disconnectAll to invalidate stale reconnections. */
|
|
138
|
+
#epoch = 0;
|
|
134
139
|
|
|
135
140
|
constructor(
|
|
136
141
|
private cwd: string,
|
|
@@ -287,7 +292,11 @@ export class MCPManager {
|
|
|
287
292
|
continue;
|
|
288
293
|
}
|
|
289
294
|
|
|
290
|
-
if (
|
|
295
|
+
if (
|
|
296
|
+
this.#pendingConnections.has(name) ||
|
|
297
|
+
this.#pendingToolLoads.has(name) ||
|
|
298
|
+
this.#pendingReconnections.has(name)
|
|
299
|
+
) {
|
|
291
300
|
continue;
|
|
292
301
|
}
|
|
293
302
|
|
|
@@ -299,6 +308,10 @@ export class MCPManager {
|
|
|
299
308
|
continue;
|
|
300
309
|
}
|
|
301
310
|
|
|
311
|
+
// Save config early so reconnection works even if the initial connect times out
|
|
312
|
+
// and falls back to cached/deferred tools.
|
|
313
|
+
this.#serverConfigs.set(name, config);
|
|
314
|
+
|
|
302
315
|
// Resolve auth config before connecting, but do so per-server in parallel.
|
|
303
316
|
const connectionPromise = (async () => {
|
|
304
317
|
const resolvedConfig = await this.#resolveAuthConfig(config);
|
|
@@ -315,6 +328,7 @@ export class MCPManager {
|
|
|
315
328
|
// Store original config (without resolved tokens) to keep
|
|
316
329
|
// cache keys stable and avoid leaking rotating credentials.
|
|
317
330
|
connection.config = config;
|
|
331
|
+
this.#serverConfigs.set(name, config);
|
|
318
332
|
if (sources[name]) {
|
|
319
333
|
connection._source = sources[name];
|
|
320
334
|
}
|
|
@@ -323,7 +337,7 @@ export class MCPManager {
|
|
|
323
337
|
this.#connections.set(name, connection);
|
|
324
338
|
}
|
|
325
339
|
|
|
326
|
-
// Wire auth refresh for HTTP transports so 401s trigger token refresh
|
|
340
|
+
// Wire auth refresh for HTTP transports so 401s trigger token refresh.
|
|
327
341
|
if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
|
|
328
342
|
connection.transport.onAuthError = async () => {
|
|
329
343
|
const refreshed = await this.#resolveAuthConfig(config, true);
|
|
@@ -334,6 +348,13 @@ export class MCPManager {
|
|
|
334
348
|
};
|
|
335
349
|
}
|
|
336
350
|
|
|
351
|
+
// Re-establish connection if the transport closes (server restart,
|
|
352
|
+
// network interruption).
|
|
353
|
+
connection.transport.onClose = () => {
|
|
354
|
+
logger.debug("MCP transport lost, triggering reconnect", { path: `mcp:${name}` });
|
|
355
|
+
void this.reconnectServer(name);
|
|
356
|
+
};
|
|
357
|
+
|
|
337
358
|
return connection;
|
|
338
359
|
},
|
|
339
360
|
error => {
|
|
@@ -358,61 +379,13 @@ export class MCPManager {
|
|
|
358
379
|
.then(async ({ connection, serverTools }) => {
|
|
359
380
|
if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
|
|
360
381
|
this.#pendingToolLoads.delete(name);
|
|
361
|
-
const
|
|
382
|
+
const reconnect = () => this.reconnectServer(name);
|
|
383
|
+
const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
|
|
362
384
|
this.#replaceServerTools(name, customTools);
|
|
363
385
|
this.#onToolsChanged?.(this.#tools);
|
|
364
386
|
void this.toolCache?.set(name, config, serverTools);
|
|
365
387
|
|
|
366
|
-
|
|
367
|
-
if (serverSupportsResources(connection.capabilities)) {
|
|
368
|
-
try {
|
|
369
|
-
const [resources] = await Promise.all([
|
|
370
|
-
listResources(connection),
|
|
371
|
-
listResourceTemplates(connection),
|
|
372
|
-
]);
|
|
373
|
-
|
|
374
|
-
if (this.#notificationsEnabled && connection.capabilities.resources?.subscribe) {
|
|
375
|
-
const uris = resources.map(r => r.uri);
|
|
376
|
-
const notificationEpoch = this.#notificationsEpoch;
|
|
377
|
-
void subscribeToResources(connection, uris)
|
|
378
|
-
.then(() => {
|
|
379
|
-
const action = resolveSubscriptionPostAction(
|
|
380
|
-
this.#notificationsEnabled,
|
|
381
|
-
this.#notificationsEpoch,
|
|
382
|
-
notificationEpoch,
|
|
383
|
-
);
|
|
384
|
-
if (action === "rollback") {
|
|
385
|
-
void unsubscribeFromResources(connection, uris).catch(error => {
|
|
386
|
-
logger.debug("Failed to rollback stale MCP resource subscription", {
|
|
387
|
-
path: `mcp:${name}`,
|
|
388
|
-
error,
|
|
389
|
-
});
|
|
390
|
-
});
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
if (action === "ignore") {
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
this.#subscribedResources.set(name, new Set(uris));
|
|
397
|
-
})
|
|
398
|
-
.catch(error => {
|
|
399
|
-
logger.debug("Failed to subscribe to MCP resources", { path: `mcp:${name}`, error });
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
} catch (error) {
|
|
403
|
-
logger.debug("Failed to load MCP resources", { path: `mcp:${name}`, error });
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Load prompts (best-effort)
|
|
408
|
-
if (serverSupportsPrompts(connection.capabilities)) {
|
|
409
|
-
try {
|
|
410
|
-
await listPrompts(connection);
|
|
411
|
-
this.#onPromptsChanged?.(name);
|
|
412
|
-
} catch (error) {
|
|
413
|
-
logger.debug("Failed to load MCP prompts", { path: `mcp:${name}`, error });
|
|
414
|
-
}
|
|
415
|
-
}
|
|
388
|
+
await this.#loadServerResourcesAndPrompts(name, connection);
|
|
416
389
|
})
|
|
417
390
|
.catch(error => {
|
|
418
391
|
if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
|
|
@@ -462,7 +435,8 @@ export class MCPManager {
|
|
|
462
435
|
if (!value) continue;
|
|
463
436
|
const { connection, serverTools } = value;
|
|
464
437
|
connectedServers.add(name);
|
|
465
|
-
|
|
438
|
+
const reconnect = () => this.reconnectServer(name);
|
|
439
|
+
allTools.push(...MCPTool.fromTools(connection, serverTools, reconnect));
|
|
466
440
|
} else if (task.tracked.status === "rejected") {
|
|
467
441
|
const message =
|
|
468
442
|
task.tracked.reason instanceof Error ? task.tracked.reason.message : String(task.tracked.reason);
|
|
@@ -472,7 +446,10 @@ export class MCPManager {
|
|
|
472
446
|
const cached = cachedTools.get(name);
|
|
473
447
|
if (cached) {
|
|
474
448
|
const source = this.#sources.get(name);
|
|
475
|
-
|
|
449
|
+
const reconnect = () => this.reconnectServer(name);
|
|
450
|
+
allTools.push(
|
|
451
|
+
...DeferredMCPTool.fromTools(name, cached, () => this.waitForConnection(name), source, reconnect),
|
|
452
|
+
);
|
|
476
453
|
}
|
|
477
454
|
}
|
|
478
455
|
}
|
|
@@ -580,7 +557,12 @@ export class MCPManager {
|
|
|
580
557
|
*/
|
|
581
558
|
getConnectionStatus(name: string): "connected" | "connecting" | "disconnected" {
|
|
582
559
|
if (this.#connections.has(name)) return "connected";
|
|
583
|
-
if (
|
|
560
|
+
if (
|
|
561
|
+
this.#pendingConnections.has(name) ||
|
|
562
|
+
this.#pendingToolLoads.has(name) ||
|
|
563
|
+
this.#pendingReconnections.has(name)
|
|
564
|
+
)
|
|
565
|
+
return "connecting";
|
|
584
566
|
return "disconnected";
|
|
585
567
|
}
|
|
586
568
|
|
|
@@ -599,6 +581,12 @@ export class MCPManager {
|
|
|
599
581
|
if (connection) return connection;
|
|
600
582
|
const pending = this.#pendingConnections.get(name);
|
|
601
583
|
if (pending) return pending;
|
|
584
|
+
// If a reconnection is in flight, wait for it to complete
|
|
585
|
+
const reconnecting = this.#pendingReconnections.get(name);
|
|
586
|
+
if (reconnecting) {
|
|
587
|
+
const result = await reconnecting;
|
|
588
|
+
if (result) return result;
|
|
589
|
+
}
|
|
602
590
|
throw new Error(`MCP server not connected: ${name}`);
|
|
603
591
|
}
|
|
604
592
|
|
|
@@ -631,7 +619,9 @@ export class MCPManager {
|
|
|
631
619
|
async disconnectServer(name: string): Promise<void> {
|
|
632
620
|
this.#pendingConnections.delete(name);
|
|
633
621
|
this.#pendingToolLoads.delete(name);
|
|
622
|
+
this.#pendingReconnections.delete(name);
|
|
634
623
|
this.#sources.delete(name);
|
|
624
|
+
this.#serverConfigs.delete(name);
|
|
635
625
|
this.#pendingResourceRefresh.delete(name);
|
|
636
626
|
|
|
637
627
|
const connection = this.#connections.get(name);
|
|
@@ -643,6 +633,8 @@ export class MCPManager {
|
|
|
643
633
|
this.#subscribedResources.delete(name);
|
|
644
634
|
|
|
645
635
|
if (connection) {
|
|
636
|
+
// Detach onClose to prevent spurious reconnect from close()
|
|
637
|
+
connection.transport.onClose = undefined;
|
|
646
638
|
await disconnectServer(connection);
|
|
647
639
|
this.#connections.delete(name);
|
|
648
640
|
}
|
|
@@ -660,18 +652,224 @@ export class MCPManager {
|
|
|
660
652
|
* Disconnect from all servers.
|
|
661
653
|
*/
|
|
662
654
|
async disconnectAll(): Promise<void> {
|
|
655
|
+
// Invalidate any in-flight reconnection attempts that outlive this call.
|
|
656
|
+
// They captured the old epoch; after increment they'll detect staleness.
|
|
657
|
+
this.#epoch++;
|
|
658
|
+
// Detach onClose before closing to prevent spurious reconnect attempts
|
|
659
|
+
for (const conn of this.#connections.values()) {
|
|
660
|
+
conn.transport.onClose = undefined;
|
|
661
|
+
}
|
|
663
662
|
const promises = Array.from(this.#connections.values()).map(conn => disconnectServer(conn));
|
|
664
663
|
await Promise.allSettled(promises);
|
|
665
664
|
|
|
666
665
|
this.#pendingConnections.clear();
|
|
667
666
|
this.#pendingToolLoads.clear();
|
|
667
|
+
this.#pendingReconnections.clear();
|
|
668
668
|
this.#pendingResourceRefresh.clear();
|
|
669
669
|
this.#sources.clear();
|
|
670
|
+
this.#serverConfigs.clear();
|
|
670
671
|
this.#connections.clear();
|
|
671
672
|
this.#tools = [];
|
|
672
673
|
this.#subscribedResources.clear();
|
|
673
674
|
}
|
|
674
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Reconnect to a server after a connection failure.
|
|
678
|
+
* Tears down the stale connection, re-resolves auth, establishes a new
|
|
679
|
+
* connection, reloads tools, and notifies consumers.
|
|
680
|
+
* Concurrent calls for the same server share one reconnection attempt.
|
|
681
|
+
* Returns the new connection, or null if reconnection failed.
|
|
682
|
+
*/
|
|
683
|
+
async reconnectServer(name: string): Promise<MCPServerConnection | null> {
|
|
684
|
+
const pending = this.#pendingReconnections.get(name);
|
|
685
|
+
if (pending) return pending;
|
|
686
|
+
|
|
687
|
+
const attempt = this.#doReconnect(name);
|
|
688
|
+
this.#pendingReconnections.set(name, attempt);
|
|
689
|
+
return attempt.finally(() => this.#pendingReconnections.delete(name));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async #doReconnect(name: string): Promise<MCPServerConnection | null> {
|
|
693
|
+
const oldConnection = this.#connections.get(name);
|
|
694
|
+
const config = oldConnection?.config ?? this.#serverConfigs.get(name);
|
|
695
|
+
const source = this.#sources.get(name) ?? oldConnection?._source;
|
|
696
|
+
if (!config) return null;
|
|
697
|
+
|
|
698
|
+
logger.debug("MCP reconnecting", { path: `mcp:${name}` });
|
|
699
|
+
|
|
700
|
+
// Close the old transport without removing tools or notifying consumers.
|
|
701
|
+
// Tools stay available (stale) while we establish the new connection.
|
|
702
|
+
// Fire-and-forget: don't await the close — HttpTransport.close() sends a
|
|
703
|
+
// DELETE with config.timeout (30s default), and blocking here delays the
|
|
704
|
+
// reconnect loop by that amount on every server restart.
|
|
705
|
+
const reconnectEpoch = this.#epoch;
|
|
706
|
+
if (oldConnection) {
|
|
707
|
+
// Detach onClose to prevent re-entrant reconnect from the close itself
|
|
708
|
+
oldConnection.transport.onClose = undefined;
|
|
709
|
+
void oldConnection.transport.close().catch(() => {});
|
|
710
|
+
this.#connections.delete(name);
|
|
711
|
+
}
|
|
712
|
+
this.#pendingConnections.delete(name);
|
|
713
|
+
this.#pendingToolLoads.delete(name);
|
|
714
|
+
|
|
715
|
+
// Retry with backoff — the server may still be starting up.
|
|
716
|
+
const delays = [500, 1000, 2000, 4000];
|
|
717
|
+
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
|
718
|
+
if (this.#epoch !== reconnectEpoch) {
|
|
719
|
+
logger.debug("MCP reconnect aborted before attempt after configuration changed", {
|
|
720
|
+
path: `mcp:${name}`,
|
|
721
|
+
storedEpoch: reconnectEpoch,
|
|
722
|
+
currentEpoch: this.#epoch,
|
|
723
|
+
});
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
const connection = await this.#connectAndWireServer(name, config, source, reconnectEpoch);
|
|
728
|
+
logger.debug("MCP reconnected", { path: `mcp:${name}`, tools: connection.tools?.length ?? 0 });
|
|
729
|
+
return connection;
|
|
730
|
+
} catch (error) {
|
|
731
|
+
if (this.#epoch !== reconnectEpoch) {
|
|
732
|
+
logger.debug("MCP reconnect aborted after configuration changed", {
|
|
733
|
+
path: `mcp:${name}`,
|
|
734
|
+
storedEpoch: reconnectEpoch,
|
|
735
|
+
currentEpoch: this.#epoch,
|
|
736
|
+
});
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
741
|
+
if (attempt < delays.length) {
|
|
742
|
+
logger.debug("MCP reconnect attempt failed, retrying", {
|
|
743
|
+
path: `mcp:${name}`,
|
|
744
|
+
attempt: attempt + 1,
|
|
745
|
+
error: msg,
|
|
746
|
+
});
|
|
747
|
+
await Bun.sleep(delays[attempt]);
|
|
748
|
+
} else {
|
|
749
|
+
logger.error("MCP reconnect failed after retries", { path: `mcp:${name}`, error: msg });
|
|
750
|
+
// Don't remove stale tools — keep them in the registry so they
|
|
751
|
+
// remain selected. Calls will fail with MCP errors, which
|
|
752
|
+
// triggers the tool-level reconnect, or the user can run
|
|
753
|
+
// /mcp reconnect <name> manually.
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Establish a new connection to a server, wire handlers, load tools. */
|
|
761
|
+
async #connectAndWireServer(
|
|
762
|
+
name: string,
|
|
763
|
+
config: MCPServerConfig,
|
|
764
|
+
source: SourceMeta | undefined,
|
|
765
|
+
reconnectEpoch: number,
|
|
766
|
+
): Promise<MCPServerConnection> {
|
|
767
|
+
const resolvedConfig = await this.#resolveAuthConfig(config);
|
|
768
|
+
const connection = await connectToServer(name, resolvedConfig, {
|
|
769
|
+
onNotification: (method, params) => {
|
|
770
|
+
this.#handleServerNotification(name, method, params);
|
|
771
|
+
},
|
|
772
|
+
onRequest: (method, params) => {
|
|
773
|
+
return this.#handleServerRequest(method, params);
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
connection.config = config;
|
|
778
|
+
if (source) connection._source = source;
|
|
779
|
+
|
|
780
|
+
// Bail out if the server was disconnected or the manager was reset
|
|
781
|
+
// while we were connecting (e.g. /mcp reload called disconnectAll).
|
|
782
|
+
if (!this.#serverConfigs.has(name) || this.#epoch !== reconnectEpoch) {
|
|
783
|
+
await connection.transport.close().catch(() => {});
|
|
784
|
+
throw new Error(`Server "${name}" was disconnected during reconnection`);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
this.#connections.set(name, connection);
|
|
788
|
+
|
|
789
|
+
// Wire auth refresh for HTTP transports, and reconnect for any transport.
|
|
790
|
+
if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
|
|
791
|
+
connection.transport.onAuthError = async () => {
|
|
792
|
+
const refreshed = await this.#resolveAuthConfig(config, true);
|
|
793
|
+
if (refreshed.type === "http" || refreshed.type === "sse") {
|
|
794
|
+
return refreshed.headers ?? null;
|
|
795
|
+
}
|
|
796
|
+
return null;
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
connection.transport.onClose = () => {
|
|
800
|
+
logger.debug("MCP transport lost, triggering reconnect", { path: `mcp:${name}` });
|
|
801
|
+
void this.reconnectServer(name);
|
|
802
|
+
};
|
|
803
|
+
try {
|
|
804
|
+
const serverTools = await listTools(connection);
|
|
805
|
+
const reconnect = () => this.reconnectServer(name);
|
|
806
|
+
const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
|
|
807
|
+
void this.toolCache?.set(name, config, serverTools);
|
|
808
|
+
this.#replaceServerTools(name, customTools);
|
|
809
|
+
this.#onToolsChanged?.(this.#tools);
|
|
810
|
+
void this.#loadServerResourcesAndPrompts(name, connection);
|
|
811
|
+
return connection;
|
|
812
|
+
} catch (error) {
|
|
813
|
+
// Clean up the connection to avoid zombie transports
|
|
814
|
+
connection.transport.onClose = undefined;
|
|
815
|
+
await connection.transport.close().catch(() => {});
|
|
816
|
+
this.#connections.delete(name);
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Best-effort loading of resources, resource subscriptions, and prompts.
|
|
823
|
+
* Shared between initial connection and reconnection.
|
|
824
|
+
*/
|
|
825
|
+
async #loadServerResourcesAndPrompts(name: string, connection: MCPServerConnection): Promise<void> {
|
|
826
|
+
if (serverSupportsResources(connection.capabilities)) {
|
|
827
|
+
try {
|
|
828
|
+
const [resources] = await Promise.all([listResources(connection), listResourceTemplates(connection)]);
|
|
829
|
+
|
|
830
|
+
if (this.#notificationsEnabled && connection.capabilities.resources?.subscribe) {
|
|
831
|
+
const uris = resources.map(r => r.uri);
|
|
832
|
+
const notificationEpoch = this.#notificationsEpoch;
|
|
833
|
+
void subscribeToResources(connection, uris)
|
|
834
|
+
.then(() => {
|
|
835
|
+
const action = resolveSubscriptionPostAction(
|
|
836
|
+
this.#notificationsEnabled,
|
|
837
|
+
this.#notificationsEpoch,
|
|
838
|
+
notificationEpoch,
|
|
839
|
+
);
|
|
840
|
+
if (action === "rollback") {
|
|
841
|
+
void unsubscribeFromResources(connection, uris).catch(error => {
|
|
842
|
+
logger.debug("Failed to rollback stale MCP resource subscription", {
|
|
843
|
+
path: `mcp:${name}`,
|
|
844
|
+
error,
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (action === "ignore") {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
this.#subscribedResources.set(name, new Set(uris));
|
|
853
|
+
})
|
|
854
|
+
.catch(error => {
|
|
855
|
+
logger.debug("Failed to subscribe to MCP resources", { path: `mcp:${name}`, error });
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
} catch (error) {
|
|
859
|
+
logger.debug("Failed to load MCP resources", { path: `mcp:${name}`, error });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (serverSupportsPrompts(connection.capabilities)) {
|
|
864
|
+
try {
|
|
865
|
+
await listPrompts(connection);
|
|
866
|
+
this.#onPromptsChanged?.(name);
|
|
867
|
+
} catch (error) {
|
|
868
|
+
logger.debug("Failed to load MCP prompts", { path: `mcp:${name}`, error });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
675
873
|
/**
|
|
676
874
|
* Refresh tools from a specific server.
|
|
677
875
|
*/
|
|
@@ -684,7 +882,8 @@ export class MCPManager {
|
|
|
684
882
|
|
|
685
883
|
// Reload tools
|
|
686
884
|
const serverTools = await listTools(connection);
|
|
687
|
-
const
|
|
885
|
+
const reconnect = () => this.reconnectServer(name);
|
|
886
|
+
const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
|
|
688
887
|
void this.toolCache?.set(name, connection.config, serverTools);
|
|
689
888
|
|
|
690
889
|
// Replace tools from this server
|