@docyrus/docyrus 0.0.20 → 0.0.22
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/agent-loader.js +32 -1
- package/agent-loader.js.map +2 -2
- package/main.js +325 -71
- package/main.js.map +4 -4
- package/package.json +16 -3
- package/resources/chrome-tools/browser-content.js +103 -0
- package/resources/chrome-tools/browser-cookies.js +35 -0
- package/resources/chrome-tools/browser-eval.js +53 -0
- package/resources/chrome-tools/browser-hn-scraper.js +108 -0
- package/resources/chrome-tools/browser-nav.js +44 -0
- package/resources/chrome-tools/browser-pick.js +162 -0
- package/resources/chrome-tools/browser-screenshot.js +34 -0
- package/resources/chrome-tools/browser-start.js +86 -0
- package/resources/pi-agent/extensions/answer.ts +532 -0
- package/resources/pi-agent/extensions/context.ts +578 -0
- package/resources/pi-agent/extensions/control.ts +1779 -0
- package/resources/pi-agent/extensions/diff.ts +218 -0
- package/resources/pi-agent/extensions/files.ts +199 -0
- package/resources/pi-agent/extensions/loop.ts +446 -0
- package/resources/pi-agent/extensions/multi-edit.ts +835 -0
- package/resources/pi-agent/extensions/notify.ts +88 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/LICENSE +21 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/README.md +19 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/index.ts +52 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/package.json +61 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/pty-execute.ts +97 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/pty-kill.ts +25 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/pty-session.ts +143 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/spawn-helper.ts +31 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/terminal-emulator.ts +439 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/truncate.ts +68 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/widget.ts +114 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/CHANGELOG.md +192 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/LICENSE +21 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/README.md +296 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/app-bridge.bundle.js +67 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/cli.js +108 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/commands.ts +211 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/config.ts +227 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/consent-manager.ts +64 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/direct-tools.ts +301 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/errors.ts +219 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/glimpse-ui.ts +80 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/host-html-template.ts +427 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/index.ts +232 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/init.ts +319 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/lifecycle.ts +93 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/logger.ts +169 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/mcp-panel.ts +713 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/metadata-cache.ts +191 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/npx-resolver.ts +419 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/oauth-handler.ts +56 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/package.json +85 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/paths.ts +29 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/proxy-modes.ts +635 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/resource-tools.ts +17 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/server-manager.ts +330 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/state.ts +41 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/tool-metadata.ts +144 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/tool-registrar.ts +46 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/types.ts +367 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-resource-handler.ts +145 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-server.ts +623 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-session.ts +384 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-stream-types.ts +89 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/utils.ts +75 -0
- package/resources/pi-agent/extensions/prompt-editor.ts +1315 -0
- package/resources/pi-agent/extensions/prompt-url-widget.ts +158 -0
- package/resources/pi-agent/extensions/redraws.ts +24 -0
- package/resources/pi-agent/extensions/review.ts +2160 -0
- package/resources/pi-agent/extensions/todos.ts +2076 -0
- package/resources/pi-agent/extensions/tps.ts +47 -0
- package/resources/pi-agent/extensions/whimsical.ts +474 -0
- package/resources/pi-agent/skills/changelog-generator/SKILL.md +425 -0
- package/resources/pi-agent/skills/docyrus-chrome-devtools-cli/SKILL.md +80 -0
- package/resources/pi-agent/skills/docyrus-platform/references/docyrus-cli-usage.md +51 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { McpExtensionState } from "./state.js";
|
|
3
|
+
import type { ToolMetadata } from "./types.js";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { loadMcpConfig } from "./config.js";
|
|
6
|
+
import { ConsentManager } from "./consent-manager.js";
|
|
7
|
+
import { McpLifecycleManager } from "./lifecycle.js";
|
|
8
|
+
import {
|
|
9
|
+
computeServerHash,
|
|
10
|
+
getMetadataCachePath,
|
|
11
|
+
isServerCacheValid,
|
|
12
|
+
loadMetadataCache,
|
|
13
|
+
reconstructToolMetadata,
|
|
14
|
+
saveMetadataCache,
|
|
15
|
+
serializeResources,
|
|
16
|
+
serializeTools,
|
|
17
|
+
type ServerCacheEntry,
|
|
18
|
+
} from "./metadata-cache.js";
|
|
19
|
+
import { McpServerManager } from "./server-manager.js";
|
|
20
|
+
import { buildToolMetadata, totalToolCount } from "./tool-metadata.js";
|
|
21
|
+
import { UiResourceHandler } from "./ui-resource-handler.js";
|
|
22
|
+
import { openUrl, parallelLimit } from "./utils.js";
|
|
23
|
+
import { logger } from "./logger.js";
|
|
24
|
+
|
|
25
|
+
const FAILURE_BACKOFF_MS = 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export async function initializeMcp(
|
|
28
|
+
pi: ExtensionAPI,
|
|
29
|
+
ctx: ExtensionContext
|
|
30
|
+
): Promise<McpExtensionState> {
|
|
31
|
+
const configPath = pi.getFlag("mcp-config") as string | undefined;
|
|
32
|
+
const config = loadMcpConfig(configPath);
|
|
33
|
+
|
|
34
|
+
const manager = new McpServerManager();
|
|
35
|
+
const lifecycle = new McpLifecycleManager(manager);
|
|
36
|
+
const toolMetadata = new Map<string, ToolMetadata[]>();
|
|
37
|
+
const failureTracker = new Map<string, number>();
|
|
38
|
+
const uiResourceHandler = new UiResourceHandler(manager);
|
|
39
|
+
const consentManager = new ConsentManager("once-per-server");
|
|
40
|
+
const ui = ctx.hasUI ? ctx.ui : undefined;
|
|
41
|
+
const state: McpExtensionState = {
|
|
42
|
+
manager,
|
|
43
|
+
lifecycle,
|
|
44
|
+
toolMetadata,
|
|
45
|
+
config,
|
|
46
|
+
failureTracker,
|
|
47
|
+
uiResourceHandler,
|
|
48
|
+
consentManager,
|
|
49
|
+
uiServer: null,
|
|
50
|
+
completedUiSessions: [],
|
|
51
|
+
openBrowser: (url: string) => openUrl(pi, url, process.env.BROWSER),
|
|
52
|
+
ui,
|
|
53
|
+
sendMessage: (message, options) => pi.sendMessage(message, options),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const serverEntries = Object.entries(config.mcpServers);
|
|
57
|
+
if (serverEntries.length === 0) {
|
|
58
|
+
return state;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const idleSetting = typeof config.settings?.idleTimeout === "number" ? config.settings.idleTimeout : 10;
|
|
62
|
+
lifecycle.setGlobalIdleTimeout(idleSetting);
|
|
63
|
+
|
|
64
|
+
const cachePath = getMetadataCachePath();
|
|
65
|
+
const cacheFileExists = existsSync(cachePath);
|
|
66
|
+
let cache = loadMetadataCache();
|
|
67
|
+
let bootstrapAll = false;
|
|
68
|
+
|
|
69
|
+
if (!cacheFileExists) {
|
|
70
|
+
bootstrapAll = true;
|
|
71
|
+
saveMetadataCache({ version: 1, servers: {} });
|
|
72
|
+
} else if (!cache) {
|
|
73
|
+
cache = { version: 1, servers: {} };
|
|
74
|
+
saveMetadataCache(cache);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const prefix = config.settings?.toolPrefix ?? "server";
|
|
78
|
+
|
|
79
|
+
for (const [name, definition] of serverEntries) {
|
|
80
|
+
const lifecycleMode = definition.lifecycle ?? "lazy";
|
|
81
|
+
const idleOverride = definition.idleTimeout ?? (lifecycleMode === "eager" ? 0 : undefined);
|
|
82
|
+
lifecycle.registerServer(
|
|
83
|
+
name,
|
|
84
|
+
definition,
|
|
85
|
+
idleOverride !== undefined ? { idleTimeout: idleOverride } : undefined
|
|
86
|
+
);
|
|
87
|
+
if (lifecycleMode === "keep-alive") {
|
|
88
|
+
lifecycle.markKeepAlive(name, definition);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (cache?.servers?.[name] && isServerCacheValid(cache.servers[name], definition)) {
|
|
92
|
+
const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition.exposeResources);
|
|
93
|
+
toolMetadata.set(name, metadata);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const startupServers = bootstrapAll
|
|
98
|
+
? serverEntries
|
|
99
|
+
: serverEntries.filter(([, definition]) => {
|
|
100
|
+
const mode = definition.lifecycle ?? "lazy";
|
|
101
|
+
return mode === "keep-alive" || mode === "eager";
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (ctx.hasUI && startupServers.length > 0) {
|
|
105
|
+
ctx.ui.setStatus("mcp", `MCP: connecting to ${startupServers.length} servers...`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
|
|
109
|
+
try {
|
|
110
|
+
const connection = await manager.connect(name, definition);
|
|
111
|
+
return { name, definition, connection, error: null };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
return { name, definition, connection: null, error: message };
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
for (const { name, definition, connection, error } of results) {
|
|
119
|
+
if (error || !connection) {
|
|
120
|
+
if (ctx.hasUI) {
|
|
121
|
+
ctx.ui.notify(`MCP: Failed to connect to ${name}: ${error}`, "error");
|
|
122
|
+
}
|
|
123
|
+
console.error(`MCP: Failed to connect to ${name}: ${error}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
128
|
+
toolMetadata.set(name, metadata);
|
|
129
|
+
updateMetadataCache(state, name);
|
|
130
|
+
|
|
131
|
+
if (failedTools.length > 0 && ctx.hasUI) {
|
|
132
|
+
ctx.ui.notify(
|
|
133
|
+
`MCP: ${name} - ${failedTools.length} tools skipped`,
|
|
134
|
+
"warning"
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const connectedCount = results.filter(r => r.connection).length;
|
|
140
|
+
const failedCount = results.filter(r => r.error).length;
|
|
141
|
+
if (ctx.hasUI && connectedCount > 0) {
|
|
142
|
+
const totalTools = totalToolCount(state);
|
|
143
|
+
const msg = failedCount > 0
|
|
144
|
+
? `MCP: ${connectedCount}/${startupServers.length} servers connected (${totalTools} tools)`
|
|
145
|
+
: `MCP: ${connectedCount} servers connected (${totalTools} tools)`;
|
|
146
|
+
ctx.ui.notify(msg, "info");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const envDirect = process.env.MCP_DIRECT_TOOLS;
|
|
150
|
+
if (envDirect !== "__none__") {
|
|
151
|
+
const missingCacheServers: string[] = [];
|
|
152
|
+
const currentCache = loadMetadataCache();
|
|
153
|
+
for (const [name, definition] of serverEntries) {
|
|
154
|
+
const hasDirect = definition.directTools !== undefined
|
|
155
|
+
? !!definition.directTools
|
|
156
|
+
: !!config.settings?.directTools;
|
|
157
|
+
if (!hasDirect) continue;
|
|
158
|
+
const entry = currentCache?.servers?.[name];
|
|
159
|
+
if (!entry || !isServerCacheValid(entry, definition)) {
|
|
160
|
+
missingCacheServers.push(name);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (missingCacheServers.length > 0) {
|
|
165
|
+
const bootstrapResults = await parallelLimit(
|
|
166
|
+
missingCacheServers.filter(name => !results.some(r => r.name === name && r.connection)),
|
|
167
|
+
10,
|
|
168
|
+
async (name) => {
|
|
169
|
+
const definition = config.mcpServers[name];
|
|
170
|
+
try {
|
|
171
|
+
const connection = await manager.connect(name, definition);
|
|
172
|
+
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
173
|
+
toolMetadata.set(name, metadata);
|
|
174
|
+
updateMetadataCache(state, name);
|
|
175
|
+
return { name, ok: true };
|
|
176
|
+
} catch {
|
|
177
|
+
return { name, ok: false };
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
const bootstrapped = bootstrapResults.filter(r => r.ok).map(r => r.name);
|
|
182
|
+
if (bootstrapped.length > 0 && ctx.hasUI) {
|
|
183
|
+
ctx.ui.notify(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
lifecycle.setReconnectCallback((serverName) => {
|
|
189
|
+
updateServerMetadata(state, serverName);
|
|
190
|
+
updateMetadataCache(state, serverName);
|
|
191
|
+
state.failureTracker.delete(serverName);
|
|
192
|
+
updateStatusBar(state);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
lifecycle.setIdleShutdownCallback((serverName) => {
|
|
196
|
+
const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName);
|
|
197
|
+
logger.debug(`${serverName} shut down (idle ${idleMinutes}m)`);
|
|
198
|
+
updateStatusBar(state);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
lifecycle.startHealthChecks();
|
|
202
|
+
|
|
203
|
+
return state;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function updateServerMetadata(state: McpExtensionState, serverName: string): void {
|
|
207
|
+
const connection = state.manager.getConnection(serverName);
|
|
208
|
+
if (!connection || connection.status !== "connected") return;
|
|
209
|
+
|
|
210
|
+
const definition = state.config.mcpServers[serverName];
|
|
211
|
+
if (!definition) return;
|
|
212
|
+
|
|
213
|
+
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
214
|
+
|
|
215
|
+
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
|
|
216
|
+
state.toolMetadata.set(serverName, metadata);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function updateMetadataCache(state: McpExtensionState, serverName: string): void {
|
|
220
|
+
const connection = state.manager.getConnection(serverName);
|
|
221
|
+
if (!connection || connection.status !== "connected") return;
|
|
222
|
+
|
|
223
|
+
const definition = state.config.mcpServers[serverName];
|
|
224
|
+
if (!definition) return;
|
|
225
|
+
|
|
226
|
+
const configHash = computeServerHash(definition);
|
|
227
|
+
const existing = loadMetadataCache();
|
|
228
|
+
const existingEntry = existing?.servers?.[serverName];
|
|
229
|
+
|
|
230
|
+
const tools = serializeTools(connection.tools);
|
|
231
|
+
let resources = definition.exposeResources === false ? [] : serializeResources(connection.resources);
|
|
232
|
+
|
|
233
|
+
if (
|
|
234
|
+
definition.exposeResources !== false &&
|
|
235
|
+
resources.length === 0 &&
|
|
236
|
+
existingEntry?.resources?.length &&
|
|
237
|
+
existingEntry.configHash === configHash
|
|
238
|
+
) {
|
|
239
|
+
resources = existingEntry.resources;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const entry: ServerCacheEntry = {
|
|
243
|
+
configHash,
|
|
244
|
+
tools,
|
|
245
|
+
resources,
|
|
246
|
+
cachedAt: Date.now(),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function flushMetadataCache(state: McpExtensionState): void {
|
|
253
|
+
for (const [name, connection] of state.manager.getAllConnections()) {
|
|
254
|
+
if (connection.status === "connected") {
|
|
255
|
+
updateMetadataCache(state, name);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function updateStatusBar(state: McpExtensionState): void {
|
|
261
|
+
const ui = state.ui;
|
|
262
|
+
if (!ui) return;
|
|
263
|
+
const total = Object.keys(state.config.mcpServers).length;
|
|
264
|
+
if (total === 0) {
|
|
265
|
+
ui.setStatus("mcp", "");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const connectedCount = state.manager.getAllConnections().size;
|
|
269
|
+
ui.setStatus("mcp", ui.theme.fg("accent", `MCP: ${connectedCount}/${total} servers`));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function getFailureAgeSeconds(state: McpExtensionState, serverName: string): number | null {
|
|
273
|
+
const failedAt = state.failureTracker.get(serverName);
|
|
274
|
+
if (!failedAt) return null;
|
|
275
|
+
const ageMs = Date.now() - failedAt;
|
|
276
|
+
if (ageMs > FAILURE_BACKOFF_MS) return null;
|
|
277
|
+
return Math.round(ageMs / 1000);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function lazyConnect(state: McpExtensionState, serverName: string): Promise<boolean> {
|
|
281
|
+
const connection = state.manager.getConnection(serverName);
|
|
282
|
+
if (connection?.status === "connected") {
|
|
283
|
+
updateServerMetadata(state, serverName);
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const failedAgo = getFailureAgeSeconds(state, serverName);
|
|
288
|
+
if (failedAgo !== null) return false;
|
|
289
|
+
|
|
290
|
+
const definition = state.config.mcpServers[serverName];
|
|
291
|
+
if (!definition) return false;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
if (state.ui) {
|
|
295
|
+
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
|
296
|
+
}
|
|
297
|
+
await state.manager.connect(serverName, definition);
|
|
298
|
+
state.failureTracker.delete(serverName);
|
|
299
|
+
updateServerMetadata(state, serverName);
|
|
300
|
+
updateMetadataCache(state, serverName);
|
|
301
|
+
updateStatusBar(state);
|
|
302
|
+
return true;
|
|
303
|
+
} catch {
|
|
304
|
+
state.failureTracker.set(serverName, Date.now());
|
|
305
|
+
updateStatusBar(state);
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function getEffectiveIdleTimeoutMinutes(state: McpExtensionState, serverName: string): number {
|
|
311
|
+
const definition = state.config.mcpServers[serverName];
|
|
312
|
+
if (!definition) {
|
|
313
|
+
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
|
|
314
|
+
}
|
|
315
|
+
if (typeof definition.idleTimeout === "number") return definition.idleTimeout;
|
|
316
|
+
const mode = definition.lifecycle ?? "lazy";
|
|
317
|
+
if (mode === "eager") return 0;
|
|
318
|
+
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
|
|
319
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ServerDefinition } from "./types.js";
|
|
2
|
+
import type { McpServerManager } from "./server-manager.js";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
export type ReconnectCallback = (serverName: string) => void;
|
|
6
|
+
|
|
7
|
+
export class McpLifecycleManager {
|
|
8
|
+
private manager: McpServerManager;
|
|
9
|
+
private keepAliveServers = new Map<string, ServerDefinition>();
|
|
10
|
+
private allServers = new Map<string, ServerDefinition>();
|
|
11
|
+
private serverSettings = new Map<string, { idleTimeout?: number }>();
|
|
12
|
+
private globalIdleTimeout: number = 10 * 60 * 1000;
|
|
13
|
+
private healthCheckInterval?: NodeJS.Timeout;
|
|
14
|
+
private onReconnect?: ReconnectCallback;
|
|
15
|
+
private onIdleShutdown?: (serverName: string) => void;
|
|
16
|
+
|
|
17
|
+
constructor(manager: McpServerManager) {
|
|
18
|
+
this.manager = manager;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Set callback to be invoked after a successful auto-reconnect.
|
|
23
|
+
* Use this to update tool metadata when a server reconnects.
|
|
24
|
+
*/
|
|
25
|
+
setReconnectCallback(callback: ReconnectCallback): void {
|
|
26
|
+
this.onReconnect = callback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
markKeepAlive(name: string, definition: ServerDefinition): void {
|
|
30
|
+
this.keepAliveServers.set(name, definition);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
registerServer(name: string, definition: ServerDefinition, settings?: { idleTimeout?: number }): void {
|
|
34
|
+
this.allServers.set(name, definition);
|
|
35
|
+
if (settings?.idleTimeout !== undefined) {
|
|
36
|
+
this.serverSettings.set(name, settings);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setGlobalIdleTimeout(minutes: number): void {
|
|
41
|
+
this.globalIdleTimeout = minutes * 60 * 1000;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setIdleShutdownCallback(callback: (serverName: string) => void): void {
|
|
45
|
+
this.onIdleShutdown = callback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
startHealthChecks(intervalMs = 30000): void {
|
|
49
|
+
this.healthCheckInterval = setInterval(() => {
|
|
50
|
+
this.checkConnections();
|
|
51
|
+
}, intervalMs);
|
|
52
|
+
this.healthCheckInterval.unref();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async checkConnections(): Promise<void> {
|
|
56
|
+
for (const [name, definition] of this.keepAliveServers) {
|
|
57
|
+
const connection = this.manager.getConnection(name);
|
|
58
|
+
|
|
59
|
+
if (!connection || connection.status !== "connected") {
|
|
60
|
+
try {
|
|
61
|
+
await this.manager.connect(name, definition);
|
|
62
|
+
logger.debug(`Reconnected to ${name}`);
|
|
63
|
+
// Notify extension to update metadata
|
|
64
|
+
this.onReconnect?.(name);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error(`MCP: Failed to reconnect to ${name}:`, error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const [name] of this.allServers) {
|
|
72
|
+
if (this.keepAliveServers.has(name)) continue;
|
|
73
|
+
const timeout = this.getIdleTimeout(name);
|
|
74
|
+
if (timeout > 0 && this.manager.isIdle(name, timeout)) {
|
|
75
|
+
await this.manager.close(name);
|
|
76
|
+
this.onIdleShutdown?.(name);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private getIdleTimeout(name: string): number {
|
|
82
|
+
const perServer = this.serverSettings.get(name)?.idleTimeout;
|
|
83
|
+
if (perServer !== undefined) return perServer * 60 * 1000;
|
|
84
|
+
return this.globalIdleTimeout;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async gracefulShutdown(): Promise<void> {
|
|
88
|
+
if (this.healthCheckInterval) {
|
|
89
|
+
clearInterval(this.healthCheckInterval);
|
|
90
|
+
}
|
|
91
|
+
await this.manager.closeAll();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized logging for MCP UI operations.
|
|
3
|
+
* Provides structured, contextual logs with levels.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
7
|
+
|
|
8
|
+
export interface LogContext {
|
|
9
|
+
server?: string;
|
|
10
|
+
session?: string;
|
|
11
|
+
tool?: string;
|
|
12
|
+
uri?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LogEntry {
|
|
17
|
+
level: LogLevel;
|
|
18
|
+
message: string;
|
|
19
|
+
context?: LogContext;
|
|
20
|
+
error?: Error;
|
|
21
|
+
timestamp: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type LogHandler = (entry: LogEntry) => void;
|
|
25
|
+
|
|
26
|
+
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
27
|
+
debug: 0,
|
|
28
|
+
info: 1,
|
|
29
|
+
warn: 2,
|
|
30
|
+
error: 3,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const LEVEL_PREFIX: Record<LogLevel, string> = {
|
|
34
|
+
debug: "[MCP-UI:DEBUG]",
|
|
35
|
+
info: "[MCP-UI]",
|
|
36
|
+
warn: "[MCP-UI:WARN]",
|
|
37
|
+
error: "[MCP-UI:ERROR]",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
class Logger {
|
|
41
|
+
private minLevel: LogLevel = "info";
|
|
42
|
+
private handlers: LogHandler[] = [];
|
|
43
|
+
private defaultContext: LogContext = {};
|
|
44
|
+
|
|
45
|
+
setLevel(level: LogLevel): void {
|
|
46
|
+
this.minLevel = level;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setDefaultContext(context: LogContext): void {
|
|
50
|
+
this.defaultContext = context;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
addHandler(handler: LogHandler): void {
|
|
54
|
+
this.handlers.push(handler);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
clearHandlers(): void {
|
|
58
|
+
this.handlers = [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private shouldLog(level: LogLevel): boolean {
|
|
62
|
+
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[this.minLevel];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private emit(level: LogLevel, message: string, context?: LogContext, error?: Error): void {
|
|
66
|
+
if (!this.shouldLog(level)) return;
|
|
67
|
+
|
|
68
|
+
const entry: LogEntry = {
|
|
69
|
+
level,
|
|
70
|
+
message,
|
|
71
|
+
context: { ...this.defaultContext, ...context },
|
|
72
|
+
error,
|
|
73
|
+
timestamp: new Date(),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Default console output
|
|
77
|
+
const prefix = LEVEL_PREFIX[level];
|
|
78
|
+
const contextStr = formatContext(entry.context);
|
|
79
|
+
const fullMessage = contextStr ? `${prefix} ${message} ${contextStr}` : `${prefix} ${message}`;
|
|
80
|
+
|
|
81
|
+
if (level === "error") {
|
|
82
|
+
console.error(fullMessage, error ?? "");
|
|
83
|
+
} else if (level === "warn") {
|
|
84
|
+
console.warn(fullMessage);
|
|
85
|
+
} else if (level === "debug") {
|
|
86
|
+
console.debug(fullMessage);
|
|
87
|
+
} else {
|
|
88
|
+
console.log(fullMessage);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Custom handlers
|
|
92
|
+
for (const handler of this.handlers) {
|
|
93
|
+
try {
|
|
94
|
+
handler(entry);
|
|
95
|
+
} catch {
|
|
96
|
+
// Ignore handler errors
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
debug(message: string, context?: LogContext): void {
|
|
102
|
+
this.emit("debug", message, context);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
info(message: string, context?: LogContext): void {
|
|
106
|
+
this.emit("info", message, context);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
warn(message: string, context?: LogContext): void {
|
|
110
|
+
this.emit("warn", message, context);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
error(message: string, error?: Error, context?: LogContext): void {
|
|
114
|
+
this.emit("error", message, context, error);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create a child logger with additional default context.
|
|
119
|
+
*/
|
|
120
|
+
child(context: LogContext): ChildLogger {
|
|
121
|
+
return new ChildLogger(this, context);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class ChildLogger {
|
|
126
|
+
constructor(
|
|
127
|
+
private parent: Logger,
|
|
128
|
+
private context: LogContext
|
|
129
|
+
) {}
|
|
130
|
+
|
|
131
|
+
debug(message: string, context?: LogContext): void {
|
|
132
|
+
this.parent.debug(message, { ...this.context, ...context });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
info(message: string, context?: LogContext): void {
|
|
136
|
+
this.parent.info(message, { ...this.context, ...context });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
warn(message: string, context?: LogContext): void {
|
|
140
|
+
this.parent.warn(message, { ...this.context, ...context });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
error(message: string, error?: Error, context?: LogContext): void {
|
|
144
|
+
this.parent.error(message, error, { ...this.context, ...context });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
child(context: LogContext): ChildLogger {
|
|
148
|
+
return new ChildLogger(this.parent, { ...this.context, ...context });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatContext(context?: LogContext): string {
|
|
153
|
+
if (!context || Object.keys(context).length === 0) return "";
|
|
154
|
+
const parts: string[] = [];
|
|
155
|
+
for (const [key, value] of Object.entries(context)) {
|
|
156
|
+
if (value !== undefined && value !== null) {
|
|
157
|
+
parts.push(`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Singleton instance
|
|
164
|
+
export const logger = new Logger();
|
|
165
|
+
|
|
166
|
+
// Enable debug mode via environment variable
|
|
167
|
+
if (process.env.MCP_UI_DEBUG === "1" || process.env.MCP_UI_DEBUG === "true") {
|
|
168
|
+
logger.setLevel("debug");
|
|
169
|
+
}
|