@aexol/spectral 0.2.5 → 0.2.7

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 (40) hide show
  1. package/dist/cli.js +10 -47
  2. package/dist/mcp/agent-dir.js +18 -0
  3. package/dist/mcp/app-bridge.bundle.js +67 -0
  4. package/dist/mcp/commands.js +263 -0
  5. package/dist/mcp/config.js +532 -0
  6. package/dist/mcp/consent-manager.js +59 -0
  7. package/dist/mcp/direct-tools.js +354 -0
  8. package/dist/mcp/errors.js +165 -0
  9. package/dist/mcp/glimpse-ui.js +67 -0
  10. package/dist/mcp/host-html-template.js +412 -0
  11. package/dist/mcp/index.js +291 -0
  12. package/dist/mcp/init.js +280 -0
  13. package/dist/mcp/lifecycle.js +79 -0
  14. package/dist/mcp/logger.js +130 -0
  15. package/dist/mcp/mcp-auth-flow.js +283 -0
  16. package/dist/mcp/mcp-auth.js +226 -0
  17. package/dist/mcp/mcp-callback-server.js +225 -0
  18. package/dist/mcp/mcp-oauth-provider.js +243 -0
  19. package/dist/mcp/mcp-panel.js +646 -0
  20. package/dist/mcp/mcp-setup-panel.js +485 -0
  21. package/dist/mcp/metadata-cache.js +158 -0
  22. package/dist/mcp/npx-resolver.js +385 -0
  23. package/dist/mcp/oauth-handler.js +54 -0
  24. package/dist/mcp/onboarding-state.js +56 -0
  25. package/dist/mcp/proxy-modes.js +714 -0
  26. package/dist/mcp/resource-tools.js +14 -0
  27. package/dist/mcp/sampling-handler.js +206 -0
  28. package/dist/mcp/server-manager.js +301 -0
  29. package/dist/mcp/state.js +1 -0
  30. package/dist/mcp/tool-metadata.js +128 -0
  31. package/dist/mcp/tool-registrar.js +43 -0
  32. package/dist/mcp/types.js +93 -0
  33. package/dist/mcp/ui-resource-handler.js +113 -0
  34. package/dist/mcp/ui-server.js +522 -0
  35. package/dist/mcp/ui-session.js +306 -0
  36. package/dist/mcp/ui-stream-types.js +58 -0
  37. package/dist/mcp/utils.js +104 -0
  38. package/dist/mcp/vitest.config.js +13 -0
  39. package/dist/server/pi-bridge.js +9 -30
  40. package/package.json +6 -3
@@ -0,0 +1,291 @@
1
+ import { Type } from "typebox";
2
+ import { showStatus, showTools, reconnectServers, authenticateServer, openMcpPanel, openMcpSetup } from "./commands.js";
3
+ import { loadMcpConfig } from "./config.js";
4
+ import { buildProxyDescription, createDirectToolExecutor, getMissingConfiguredDirectToolServers, resolveDirectTools } from "./direct-tools.js";
5
+ import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.js";
6
+ import { loadMetadataCache } from "./metadata-cache.js";
7
+ import { executeCall, executeConnect, executeDescribe, executeList, executeSearch, executeStatus, executeUiMessages } from "./proxy-modes.js";
8
+ import { getConfigPathFromArgv, truncateAtWord } from "./utils.js";
9
+ import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.js";
10
+ export default function mcpAdapter(pi) {
11
+ let state = null;
12
+ let initPromise = null;
13
+ let lifecycleGeneration = 0;
14
+ async function shutdownState(currentState, reason) {
15
+ if (!currentState)
16
+ return;
17
+ if (currentState.uiServer) {
18
+ currentState.uiServer.close(reason);
19
+ currentState.uiServer = null;
20
+ }
21
+ let flushError;
22
+ try {
23
+ flushMetadataCache(currentState);
24
+ }
25
+ catch (error) {
26
+ flushError = error;
27
+ }
28
+ try {
29
+ await currentState.lifecycle.gracefulShutdown();
30
+ }
31
+ catch (error) {
32
+ if (flushError) {
33
+ console.error("MCP: graceful shutdown failed after metadata flush error", error);
34
+ }
35
+ else {
36
+ throw error;
37
+ }
38
+ }
39
+ if (flushError) {
40
+ throw flushError;
41
+ }
42
+ }
43
+ const earlyConfigPath = getConfigPathFromArgv();
44
+ const earlyConfig = loadMcpConfig(earlyConfigPath);
45
+ const earlyCache = loadMetadataCache();
46
+ const prefix = earlyConfig.settings?.toolPrefix ?? "server";
47
+ const envRaw = process.env.MCP_DIRECT_TOOLS;
48
+ const directSpecs = envRaw === "__none__"
49
+ ? []
50
+ : resolveDirectTools(earlyConfig, earlyCache, prefix, envRaw?.split(",").map(s => s.trim()).filter(Boolean));
51
+ const missingConfiguredDirectToolServers = getMissingConfiguredDirectToolServers(earlyConfig, earlyCache);
52
+ const shouldRegisterProxyTool = earlyConfig.settings?.disableProxyTool !== true
53
+ || directSpecs.length === 0
54
+ || missingConfiguredDirectToolServers.length > 0;
55
+ for (const spec of directSpecs) {
56
+ pi.registerTool({
57
+ name: spec.prefixedName,
58
+ label: `MCP: ${spec.originalName}`,
59
+ description: spec.description || "(no description)",
60
+ promptSnippet: truncateAtWord(spec.description, 100) || `MCP tool from ${spec.serverName}`,
61
+ parameters: Type.Unsafe(spec.inputSchema || { type: "object", properties: {} }),
62
+ execute: createDirectToolExecutor(() => state, () => initPromise, spec),
63
+ });
64
+ }
65
+ const getPiTools = () => pi.getAllTools();
66
+ pi.registerFlag("mcp-config", {
67
+ description: "Path to MCP config file",
68
+ type: "string",
69
+ });
70
+ pi.on("session_start", async (_event, ctx) => {
71
+ const generation = ++lifecycleGeneration;
72
+ const previousState = state;
73
+ state = null;
74
+ initPromise = null;
75
+ try {
76
+ await Promise.all([
77
+ shutdownState(previousState, "session_restart"),
78
+ shutdownOAuth(),
79
+ ]);
80
+ }
81
+ catch (error) {
82
+ console.error("MCP: failed to shut down previous session state", error);
83
+ }
84
+ if (generation !== lifecycleGeneration) {
85
+ return;
86
+ }
87
+ await initializeOAuth().catch(err => {
88
+ console.error("MCP OAuth initialization failed:", err);
89
+ });
90
+ const promise = initializeMcp(pi, ctx);
91
+ initPromise = promise;
92
+ promise.then(async (nextState) => {
93
+ if (generation !== lifecycleGeneration || initPromise !== promise) {
94
+ try {
95
+ await shutdownState(nextState, "stale_session_start");
96
+ }
97
+ catch (error) {
98
+ console.error("MCP: failed to clean stale session state", error);
99
+ }
100
+ return;
101
+ }
102
+ state = nextState;
103
+ updateStatusBar(nextState);
104
+ initPromise = null;
105
+ }).catch(err => {
106
+ if (generation !== lifecycleGeneration) {
107
+ return;
108
+ }
109
+ if (initPromise !== promise && initPromise !== null) {
110
+ return;
111
+ }
112
+ console.error("MCP initialization failed:", err);
113
+ initPromise = null;
114
+ });
115
+ });
116
+ pi.on("session_shutdown", async () => {
117
+ ++lifecycleGeneration;
118
+ const currentState = state;
119
+ state = null;
120
+ initPromise = null;
121
+ try {
122
+ await Promise.all([
123
+ shutdownState(currentState, "session_shutdown"),
124
+ shutdownOAuth(),
125
+ ]);
126
+ }
127
+ catch (error) {
128
+ console.error("MCP: session shutdown cleanup failed", error);
129
+ }
130
+ });
131
+ pi.registerCommand("mcp", {
132
+ description: "Show MCP server status",
133
+ handler: async (args, ctx) => {
134
+ if (!state && initPromise) {
135
+ try {
136
+ state = await initPromise;
137
+ }
138
+ catch (error) {
139
+ const message = error instanceof Error ? error.message : String(error);
140
+ if (ctx.hasUI)
141
+ ctx.ui.notify(`MCP initialization failed: ${message}`, "error");
142
+ return;
143
+ }
144
+ }
145
+ if (!state) {
146
+ if (ctx.hasUI)
147
+ ctx.ui.notify("MCP not initialized", "error");
148
+ return;
149
+ }
150
+ const parts = args?.trim()?.split(/\s+/) ?? [];
151
+ const subcommand = parts[0] ?? "";
152
+ const targetServer = parts[1];
153
+ switch (subcommand) {
154
+ case "reconnect":
155
+ await reconnectServers(state, ctx, targetServer);
156
+ break;
157
+ case "tools":
158
+ await showTools(state, ctx);
159
+ break;
160
+ case "setup": {
161
+ const result = await openMcpSetup(state, pi, ctx, earlyConfigPath, "setup");
162
+ if (result?.configChanged) {
163
+ await ctx.reload();
164
+ return;
165
+ }
166
+ break;
167
+ }
168
+ case "status":
169
+ case "":
170
+ default:
171
+ if (ctx.hasUI) {
172
+ const result = await openMcpPanel(state, pi, ctx, earlyConfigPath);
173
+ if (result?.configChanged) {
174
+ await ctx.reload();
175
+ return;
176
+ }
177
+ }
178
+ else {
179
+ await showStatus(state, ctx);
180
+ }
181
+ break;
182
+ }
183
+ },
184
+ });
185
+ pi.registerCommand("mcp-auth", {
186
+ description: "Authenticate with an MCP server (OAuth)",
187
+ handler: async (args, ctx) => {
188
+ const serverName = args?.trim();
189
+ if (!serverName) {
190
+ if (ctx.hasUI)
191
+ ctx.ui.notify("Usage: /mcp-auth <server-name>", "error");
192
+ return;
193
+ }
194
+ if (!state && initPromise) {
195
+ try {
196
+ state = await initPromise;
197
+ }
198
+ catch (error) {
199
+ const message = error instanceof Error ? error.message : String(error);
200
+ if (ctx.hasUI)
201
+ ctx.ui.notify(`MCP initialization failed: ${message}`, "error");
202
+ return;
203
+ }
204
+ }
205
+ if (!state) {
206
+ if (ctx.hasUI)
207
+ ctx.ui.notify("MCP not initialized", "error");
208
+ return;
209
+ }
210
+ await authenticateServer(serverName, state.config, ctx);
211
+ },
212
+ });
213
+ if (shouldRegisterProxyTool) {
214
+ pi.registerTool({
215
+ name: "mcp",
216
+ label: "MCP",
217
+ description: buildProxyDescription(earlyConfig, earlyCache, directSpecs),
218
+ promptSnippet: "MCP gateway - connect to MCP servers and call their tools",
219
+ parameters: Type.Object({
220
+ tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
221
+ args: Type.Optional(Type.String({ description: "Arguments as JSON string (e.g., '{\"key\": \"value\"}')" })),
222
+ connect: Type.Optional(Type.String({ description: "Server name to connect (closes any stale connection first, forces fresh reconnect)" })),
223
+ describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })),
224
+ search: Type.Optional(Type.String({ description: "Search tools by name/description" })),
225
+ regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
226
+ includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
227
+ server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
228
+ action: Type.Optional(Type.String({ description: "Action: 'ui-messages' to retrieve prompts/intents from UI sessions" })),
229
+ restart: Type.Optional(Type.String({ description: "Force restart a dead server process. Use when server returns 'Not connected' or calls fail." })),
230
+ }),
231
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
232
+ let parsedArgs;
233
+ if (params.args) {
234
+ try {
235
+ parsedArgs = JSON.parse(params.args);
236
+ if (typeof parsedArgs !== "object" || parsedArgs === null || Array.isArray(parsedArgs)) {
237
+ const gotType = Array.isArray(parsedArgs) ? "array" : parsedArgs === null ? "null" : typeof parsedArgs;
238
+ throw new Error(`Invalid args: expected a JSON object, got ${gotType}`);
239
+ }
240
+ }
241
+ catch (error) {
242
+ if (error instanceof SyntaxError) {
243
+ throw new Error(`Invalid args JSON: ${error.message}`, { cause: error });
244
+ }
245
+ throw error;
246
+ }
247
+ }
248
+ if (!state && initPromise) {
249
+ try {
250
+ state = await initPromise;
251
+ }
252
+ catch (error) {
253
+ const message = error instanceof Error ? error.message : String(error);
254
+ return {
255
+ content: [{ type: "text", text: `MCP initialization failed: ${message}` }],
256
+ details: { error: "init_failed", message },
257
+ };
258
+ }
259
+ }
260
+ if (!state) {
261
+ return {
262
+ content: [{ type: "text", text: "MCP not initialized" }],
263
+ details: { error: "not_initialized" },
264
+ };
265
+ }
266
+ if (params.action === "ui-messages") {
267
+ return executeUiMessages(state);
268
+ }
269
+ if (params.tool) {
270
+ return executeCall(state, params.tool, parsedArgs, params.server, getPiTools);
271
+ }
272
+ if (params.restart) {
273
+ return executeConnect(state, params.restart);
274
+ }
275
+ if (params.connect) {
276
+ return executeConnect(state, params.connect);
277
+ }
278
+ if (params.describe) {
279
+ return executeDescribe(state, params.describe);
280
+ }
281
+ if (params.search) {
282
+ return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas);
283
+ }
284
+ if (params.server) {
285
+ return executeList(state, params.server);
286
+ }
287
+ return executeStatus(state);
288
+ },
289
+ });
290
+ }
291
+ }
@@ -0,0 +1,280 @@
1
+ import { existsSync } from "node:fs";
2
+ import { loadMcpConfig } from "./config.js";
3
+ import { ConsentManager } from "./consent-manager.js";
4
+ import { McpLifecycleManager } from "./lifecycle.js";
5
+ import { computeServerHash, getMetadataCachePath, isServerCacheValid, loadMetadataCache, reconstructToolMetadata, saveMetadataCache, serializeResources, serializeTools, } from "./metadata-cache.js";
6
+ import { McpServerManager } from "./server-manager.js";
7
+ import { buildToolMetadata, totalToolCount } from "./tool-metadata.js";
8
+ import { UiResourceHandler } from "./ui-resource-handler.js";
9
+ import { openUrl, parallelLimit } from "./utils.js";
10
+ import { logger } from "./logger.js";
11
+ import { getMissingConfiguredDirectToolServers } from "./direct-tools.js";
12
+ const FAILURE_BACKOFF_MS = 60 * 1000;
13
+ export async function initializeMcp(pi, ctx) {
14
+ const configPath = pi.getFlag("mcp-config");
15
+ const config = loadMcpConfig(configPath);
16
+ const manager = new McpServerManager();
17
+ const samplingAutoApprove = config.settings?.samplingAutoApprove === true;
18
+ if (config.settings?.sampling !== false && (ctx.hasUI || samplingAutoApprove)) {
19
+ manager.setSamplingConfig({
20
+ autoApprove: samplingAutoApprove,
21
+ ui: ctx.hasUI ? ctx.ui : undefined,
22
+ modelRegistry: ctx.modelRegistry,
23
+ getCurrentModel: () => ctx.model,
24
+ getSignal: () => ctx.signal,
25
+ });
26
+ }
27
+ const lifecycle = new McpLifecycleManager(manager);
28
+ const toolMetadata = new Map();
29
+ const failureTracker = new Map();
30
+ const uiResourceHandler = new UiResourceHandler(manager);
31
+ const consentManager = new ConsentManager("once-per-server");
32
+ const ui = ctx.hasUI ? ctx.ui : undefined;
33
+ const state = {
34
+ manager,
35
+ lifecycle,
36
+ toolMetadata,
37
+ config,
38
+ failureTracker,
39
+ uiResourceHandler,
40
+ consentManager,
41
+ uiServer: null,
42
+ completedUiSessions: [],
43
+ openBrowser: (url) => openUrl(pi, url, process.env.BROWSER),
44
+ ui,
45
+ sendMessage: (message, options) => pi.sendMessage(message, options),
46
+ };
47
+ const serverEntries = Object.entries(config.mcpServers);
48
+ if (serverEntries.length === 0) {
49
+ return state;
50
+ }
51
+ const idleSetting = typeof config.settings?.idleTimeout === "number" ? config.settings.idleTimeout : 10;
52
+ lifecycle.setGlobalIdleTimeout(idleSetting);
53
+ const cachePath = getMetadataCachePath();
54
+ const cacheFileExists = existsSync(cachePath);
55
+ let cache = loadMetadataCache();
56
+ let bootstrapAll = false;
57
+ if (!cacheFileExists) {
58
+ bootstrapAll = true;
59
+ saveMetadataCache({ version: 1, servers: {} });
60
+ }
61
+ else if (!cache) {
62
+ cache = { version: 1, servers: {} };
63
+ saveMetadataCache(cache);
64
+ }
65
+ const prefix = config.settings?.toolPrefix ?? "server";
66
+ for (const [name, definition] of serverEntries) {
67
+ const lifecycleMode = definition.lifecycle ?? "lazy";
68
+ const idleOverride = definition.idleTimeout ?? (lifecycleMode === "eager" ? 0 : undefined);
69
+ lifecycle.registerServer(name, definition, idleOverride !== undefined ? { idleTimeout: idleOverride } : undefined);
70
+ if (lifecycleMode === "keep-alive") {
71
+ lifecycle.markKeepAlive(name, definition);
72
+ }
73
+ if (cache?.servers?.[name] && isServerCacheValid(cache.servers[name], definition)) {
74
+ const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition);
75
+ toolMetadata.set(name, metadata);
76
+ }
77
+ }
78
+ const startupServers = bootstrapAll
79
+ ? serverEntries
80
+ : serverEntries.filter(([, definition]) => {
81
+ const mode = definition.lifecycle ?? "lazy";
82
+ return mode === "keep-alive" || mode === "eager";
83
+ });
84
+ if (ctx.hasUI && startupServers.length > 0) {
85
+ ctx.ui.setStatus("mcp", `MCP: connecting to ${startupServers.length} servers...`);
86
+ }
87
+ const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
88
+ try {
89
+ const connection = await manager.connect(name, definition);
90
+ if (connection.status === "needs-auth") {
91
+ return { name, definition, connection: null, error: `OAuth authentication required. Run /mcp-auth ${name}.` };
92
+ }
93
+ return { name, definition, connection, error: null };
94
+ }
95
+ catch (error) {
96
+ const message = error instanceof Error ? error.message : String(error);
97
+ return { name, definition, connection: null, error: message };
98
+ }
99
+ });
100
+ for (const { name, definition, connection, error } of results) {
101
+ if (error || !connection) {
102
+ if (ctx.hasUI) {
103
+ ctx.ui.notify(`MCP: Failed to connect to ${name}: ${error}`, "error");
104
+ }
105
+ console.error(`MCP: Failed to connect to ${name}: ${error}`);
106
+ continue;
107
+ }
108
+ const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
109
+ toolMetadata.set(name, metadata);
110
+ updateMetadataCache(state, name);
111
+ if (failedTools.length > 0 && ctx.hasUI) {
112
+ ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning");
113
+ }
114
+ }
115
+ const connectedCount = results.filter(r => r.connection).length;
116
+ const failedCount = results.filter(r => r.error).length;
117
+ if (ctx.hasUI && connectedCount > 0) {
118
+ const totalTools = totalToolCount(state);
119
+ const msg = failedCount > 0
120
+ ? `MCP: ${connectedCount}/${startupServers.length} servers connected (${totalTools} tools)`
121
+ : `MCP: ${connectedCount} servers connected (${totalTools} tools)`;
122
+ ctx.ui.notify(msg, "info");
123
+ }
124
+ const envDirect = process.env.MCP_DIRECT_TOOLS;
125
+ if (envDirect !== "__none__") {
126
+ const currentCache = loadMetadataCache();
127
+ const missingCacheServers = getMissingConfiguredDirectToolServers(config, currentCache);
128
+ if (missingCacheServers.length > 0) {
129
+ const bootstrapResults = await parallelLimit(missingCacheServers.filter(name => !results.some(r => r.name === name && r.connection)), 10, async (name) => {
130
+ const definition = config.mcpServers[name];
131
+ try {
132
+ const connection = await manager.connect(name, definition);
133
+ if (connection.status === "needs-auth") {
134
+ return { name, ok: false };
135
+ }
136
+ const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
137
+ toolMetadata.set(name, metadata);
138
+ updateMetadataCache(state, name);
139
+ return { name, ok: true };
140
+ }
141
+ catch (error) {
142
+ const message = error instanceof Error ? error.message : String(error);
143
+ logger.debug(`MCP: direct-tools bootstrap failed for ${name}: ${message}`);
144
+ return { name, ok: false };
145
+ }
146
+ });
147
+ const bootstrapped = bootstrapResults.filter(r => r.ok).map(r => r.name);
148
+ if (bootstrapped.length > 0 && ctx.hasUI) {
149
+ ctx.ui.notify(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info");
150
+ }
151
+ }
152
+ }
153
+ lifecycle.setReconnectCallback((serverName) => {
154
+ updateServerMetadata(state, serverName);
155
+ updateMetadataCache(state, serverName);
156
+ state.failureTracker.delete(serverName);
157
+ updateStatusBar(state);
158
+ });
159
+ lifecycle.setIdleShutdownCallback((serverName) => {
160
+ const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName);
161
+ logger.debug(`${serverName} shut down (idle ${idleMinutes}m)`);
162
+ updateStatusBar(state);
163
+ });
164
+ lifecycle.startHealthChecks();
165
+ return state;
166
+ }
167
+ export function updateServerMetadata(state, serverName) {
168
+ const connection = state.manager.getConnection(serverName);
169
+ if (!connection || connection.status !== "connected")
170
+ return;
171
+ const definition = state.config.mcpServers[serverName];
172
+ if (!definition)
173
+ return;
174
+ const prefix = state.config.settings?.toolPrefix ?? "server";
175
+ const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
176
+ state.toolMetadata.set(serverName, metadata);
177
+ }
178
+ export function updateMetadataCache(state, serverName) {
179
+ const connection = state.manager.getConnection(serverName);
180
+ if (!connection || connection.status !== "connected")
181
+ return;
182
+ const definition = state.config.mcpServers[serverName];
183
+ if (!definition)
184
+ return;
185
+ const configHash = computeServerHash(definition);
186
+ const existing = loadMetadataCache();
187
+ const existingEntry = existing?.servers?.[serverName];
188
+ const tools = serializeTools(connection.tools);
189
+ let resources = definition.exposeResources === false ? [] : serializeResources(connection.resources);
190
+ if (definition.exposeResources !== false &&
191
+ resources.length === 0 &&
192
+ existingEntry?.resources?.length &&
193
+ existingEntry.configHash === configHash) {
194
+ resources = existingEntry.resources;
195
+ }
196
+ const entry = {
197
+ configHash,
198
+ tools,
199
+ resources,
200
+ cachedAt: Date.now(),
201
+ };
202
+ saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
203
+ }
204
+ export function flushMetadataCache(state) {
205
+ for (const [name, connection] of state.manager.getAllConnections()) {
206
+ if (connection.status === "connected") {
207
+ updateMetadataCache(state, name);
208
+ }
209
+ }
210
+ }
211
+ export function updateStatusBar(state) {
212
+ const ui = state.ui;
213
+ if (!ui)
214
+ return;
215
+ const total = Object.keys(state.config.mcpServers).length;
216
+ if (total === 0) {
217
+ ui.setStatus("mcp", undefined);
218
+ return;
219
+ }
220
+ const connectedCount = state.manager.getAllConnections().size;
221
+ ui.setStatus("mcp", ui.theme.fg("accent", `MCP: ${connectedCount}/${total} servers`));
222
+ }
223
+ export function getFailureAgeSeconds(state, serverName) {
224
+ const failedAt = state.failureTracker.get(serverName);
225
+ if (!failedAt)
226
+ return null;
227
+ const ageMs = Date.now() - failedAt;
228
+ if (ageMs > FAILURE_BACKOFF_MS)
229
+ return null;
230
+ return Math.round(ageMs / 1000);
231
+ }
232
+ export async function lazyConnect(state, serverName) {
233
+ const connection = state.manager.getConnection(serverName);
234
+ if (connection?.status === "needs-auth") {
235
+ return false;
236
+ }
237
+ if (connection?.status === "connected") {
238
+ updateServerMetadata(state, serverName);
239
+ return true;
240
+ }
241
+ const failedAgo = getFailureAgeSeconds(state, serverName);
242
+ if (failedAgo !== null)
243
+ return false;
244
+ const definition = state.config.mcpServers[serverName];
245
+ if (!definition)
246
+ return false;
247
+ try {
248
+ if (state.ui) {
249
+ state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
250
+ }
251
+ const newConnection = await state.manager.connect(serverName, definition);
252
+ if (newConnection.status === "needs-auth") {
253
+ return false;
254
+ }
255
+ state.failureTracker.delete(serverName);
256
+ updateServerMetadata(state, serverName);
257
+ updateMetadataCache(state, serverName);
258
+ updateStatusBar(state);
259
+ return true;
260
+ }
261
+ catch (error) {
262
+ state.failureTracker.set(serverName, Date.now());
263
+ const message = error instanceof Error ? error.message : String(error);
264
+ logger.debug(`MCP: lazy connect failed for ${serverName}: ${message}`);
265
+ updateStatusBar(state);
266
+ return false;
267
+ }
268
+ }
269
+ function getEffectiveIdleTimeoutMinutes(state, serverName) {
270
+ const definition = state.config.mcpServers[serverName];
271
+ if (!definition) {
272
+ return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
273
+ }
274
+ if (typeof definition.idleTimeout === "number")
275
+ return definition.idleTimeout;
276
+ const mode = definition.lifecycle ?? "lazy";
277
+ if (mode === "eager")
278
+ return 0;
279
+ return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
280
+ }
@@ -0,0 +1,79 @@
1
+ import { logger } from "./logger.js";
2
+ export class McpLifecycleManager {
3
+ manager;
4
+ keepAliveServers = new Map();
5
+ allServers = new Map();
6
+ serverSettings = new Map();
7
+ globalIdleTimeout = 10 * 60 * 1000;
8
+ healthCheckInterval;
9
+ onReconnect;
10
+ onIdleShutdown;
11
+ constructor(manager) {
12
+ this.manager = manager;
13
+ }
14
+ /**
15
+ * Set callback to be invoked after a successful auto-reconnect.
16
+ * Use this to update tool metadata when a server reconnects.
17
+ */
18
+ setReconnectCallback(callback) {
19
+ this.onReconnect = callback;
20
+ }
21
+ markKeepAlive(name, definition) {
22
+ this.keepAliveServers.set(name, definition);
23
+ }
24
+ registerServer(name, definition, settings) {
25
+ this.allServers.set(name, definition);
26
+ if (settings?.idleTimeout !== undefined) {
27
+ this.serverSettings.set(name, settings);
28
+ }
29
+ }
30
+ setGlobalIdleTimeout(minutes) {
31
+ this.globalIdleTimeout = minutes * 60 * 1000;
32
+ }
33
+ setIdleShutdownCallback(callback) {
34
+ this.onIdleShutdown = callback;
35
+ }
36
+ startHealthChecks(intervalMs = 30000) {
37
+ this.healthCheckInterval = setInterval(() => {
38
+ this.checkConnections();
39
+ }, intervalMs);
40
+ this.healthCheckInterval.unref();
41
+ }
42
+ async checkConnections() {
43
+ for (const [name, definition] of this.keepAliveServers) {
44
+ const connection = this.manager.getConnection(name);
45
+ if (!connection || connection.status !== "connected") {
46
+ try {
47
+ await this.manager.connect(name, definition);
48
+ logger.debug(`Reconnected to ${name}`);
49
+ // Notify extension to update metadata
50
+ this.onReconnect?.(name);
51
+ }
52
+ catch (error) {
53
+ console.error(`MCP: Failed to reconnect to ${name}:`, error);
54
+ }
55
+ }
56
+ }
57
+ for (const [name] of this.allServers) {
58
+ if (this.keepAliveServers.has(name))
59
+ continue;
60
+ const timeout = this.getIdleTimeout(name);
61
+ if (timeout > 0 && this.manager.isIdle(name, timeout)) {
62
+ await this.manager.close(name);
63
+ this.onIdleShutdown?.(name);
64
+ }
65
+ }
66
+ }
67
+ getIdleTimeout(name) {
68
+ const perServer = this.serverSettings.get(name)?.idleTimeout;
69
+ if (perServer !== undefined)
70
+ return perServer * 60 * 1000;
71
+ return this.globalIdleTimeout;
72
+ }
73
+ async gracefulShutdown() {
74
+ if (this.healthCheckInterval) {
75
+ clearInterval(this.healthCheckInterval);
76
+ }
77
+ await this.manager.closeAll();
78
+ }
79
+ }