@pi-archimedes/mcp 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +39 -0
- package/src/auth-flow.test.ts +583 -0
- package/src/auth-flow.ts +310 -0
- package/src/auth-run.test.ts +309 -0
- package/src/auth-run.ts +146 -0
- package/src/auth-storage.test.ts +338 -0
- package/src/auth-storage.ts +330 -0
- package/src/auto-auth.test.ts +231 -0
- package/src/auto-auth.ts +135 -0
- package/src/callback-server.test.ts +446 -0
- package/src/callback-server.ts +538 -0
- package/src/commands-auth.test.ts +320 -0
- package/src/commands-auth.ts +128 -0
- package/src/commands.test.ts +834 -0
- package/src/commands.ts +424 -0
- package/src/config-write.test.ts +213 -0
- package/src/config-write.ts +207 -0
- package/src/config.test.ts +468 -0
- package/src/config.ts +278 -0
- package/src/direct-tools.test.ts +473 -0
- package/src/direct-tools.ts +250 -0
- package/src/host-configs.test.ts +231 -0
- package/src/host-configs.ts +106 -0
- package/src/index.test.ts +689 -0
- package/src/index.ts +146 -0
- package/src/lifecycle.test.ts +274 -0
- package/src/lifecycle.ts +77 -0
- package/src/metadata-cache.test.ts +383 -0
- package/src/metadata-cache.ts +231 -0
- package/src/npx-resolver.test.ts +142 -0
- package/src/npx-resolver.ts +126 -0
- package/src/oauth-provider.test.ts +404 -0
- package/src/oauth-provider.ts +197 -0
- package/src/oauth-types.ts +54 -0
- package/src/panel-rows.ts +210 -0
- package/src/panel.test.ts +298 -0
- package/src/panel.ts +742 -0
- package/src/proxy-tool.ts +524 -0
- package/src/renderer.test.ts +326 -0
- package/src/renderer.ts +239 -0
- package/src/schema-validator.test.ts +56 -0
- package/src/schema-validator.ts +42 -0
- package/src/server-client.test.ts +1001 -0
- package/src/server-client.ts +576 -0
- package/src/server-manager.ts +139 -0
- package/src/setup-panel.test.ts +162 -0
- package/src/setup-panel.ts +715 -0
- package/src/tool-naming.test.ts +168 -0
- package/src/tool-naming.ts +114 -0
- package/src/types.ts +162 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { loadMcpConfig, loadServerDefs, loadAllServerDefs } from "./config.js";
|
|
4
|
+
import { registerMcpCommand } from "./commands.js";
|
|
5
|
+
import { LifecycleManager } from "./lifecycle.js";
|
|
6
|
+
import { getCachedTools, getCachedPrompts } from "./metadata-cache.js";
|
|
7
|
+
import { ServerManager } from "./server-manager.js";
|
|
8
|
+
import type { McpConfig, ServerDef } from "./types.js";
|
|
9
|
+
import { renderProxyCall, renderProxyResult, type RenderContext } from "./renderer.js";
|
|
10
|
+
import { buildProxyToolExecute, buildSessionStartHandler } from "./proxy-tool.js";
|
|
11
|
+
|
|
12
|
+
// ── Module-level server manager (survives session restarts) ─────────────────
|
|
13
|
+
|
|
14
|
+
let manager = new ServerManager();
|
|
15
|
+
let _idleTimeoutMinutes = 10; // updated in session_start from config
|
|
16
|
+
|
|
17
|
+
// ── Test seams ──────────────────────────────────────────────────────────────
|
|
18
|
+
// Unit tests swap the module-level manager and the config loaders so the
|
|
19
|
+
// proxy's execute() can be exercised without touching real files or
|
|
20
|
+
// spawning real server processes.
|
|
21
|
+
let seamDefs: (() => Record<string, ServerDef>) | null = null;
|
|
22
|
+
let seamAllDefs: (() => Record<string, ServerDef>) | null = null;
|
|
23
|
+
let seamConfig: (() => McpConfig) | null = null;
|
|
24
|
+
|
|
25
|
+
/** Test-only: replace the module-level manager and/or config loaders. */
|
|
26
|
+
export function setIndexSeamsForTest(seams: {
|
|
27
|
+
manager?: ServerManager;
|
|
28
|
+
loadServerDefs?: () => Record<string, ServerDef>;
|
|
29
|
+
loadAllServerDefs?: () => Record<string, ServerDef>;
|
|
30
|
+
loadMcpConfig?: () => McpConfig;
|
|
31
|
+
} | null): void {
|
|
32
|
+
if (seams) {
|
|
33
|
+
if (seams.manager) manager = seams.manager;
|
|
34
|
+
seamDefs = seams.loadServerDefs ?? null;
|
|
35
|
+
seamAllDefs = seams.loadAllServerDefs ?? null;
|
|
36
|
+
seamConfig = seams.loadMcpConfig ?? null;
|
|
37
|
+
} else {
|
|
38
|
+
seamDefs = null;
|
|
39
|
+
seamAllDefs = null;
|
|
40
|
+
seamConfig = null;
|
|
41
|
+
// Fully restore initial state: discard any swapped-in manager (its fake
|
|
42
|
+
// clients/state must not leak into subsequent tests) and rebind the
|
|
43
|
+
// lifecycle to the fresh module-level manager so both agree again.
|
|
44
|
+
manager = new ServerManager();
|
|
45
|
+
lifecycle.setManager(manager);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Config-loader indirection: seam overrides win, real loaders otherwise */
|
|
50
|
+
const loadDefs = (): Record<string, ServerDef> => seamDefs?.() ?? loadServerDefs();
|
|
51
|
+
const loadAllDefs = (): Record<string, ServerDef> => seamAllDefs?.() ?? loadAllServerDefs();
|
|
52
|
+
const loadCfg = (): McpConfig => seamConfig?.() ?? loadMcpConfig();
|
|
53
|
+
|
|
54
|
+
// Health-check interval: reconnects downed keep-alive servers and closes
|
|
55
|
+
// idle non-keep-alive servers past their idleTimeout. unref'd, so it never
|
|
56
|
+
// keeps the process alive; stopped on session_shutdown.
|
|
57
|
+
const lifecycle = new LifecycleManager(manager, loadServerDefs, () => _idleTimeoutMinutes);
|
|
58
|
+
|
|
59
|
+
// ── Tool registration ───────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export function registerMcp(pi: ExtensionAPI): void {
|
|
62
|
+
// The /mcp command family (plan-027 Task 2): /mcp [status|tools|prompts|
|
|
63
|
+
// reconnect|enable|disable|logout|auth|panel|setup]. The command registry
|
|
64
|
+
// contains ONLY "mcp" — the standalone /mcp-auth + /mcp-logout commands
|
|
65
|
+
// are retired (their bodies live in commands-auth.ts, called via
|
|
66
|
+
// /mcp auth / /mcp logout).
|
|
67
|
+
registerMcpCommand(pi, {
|
|
68
|
+
getManager: () => manager,
|
|
69
|
+
// loadAllServerDefs includes disabled servers — they still have status.
|
|
70
|
+
// seamAllDefs indirection: real loader in production, stable fixture in
|
|
71
|
+
// tests (the same seam-discipline as loadDefs/loadCfg).
|
|
72
|
+
getServerDefs: () => loadAllDefs(),
|
|
73
|
+
getCachedTools,
|
|
74
|
+
getCachedPrompts,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// session_shutdown must be registered at the TOP LEVEL of registerMcp,
|
|
78
|
+
// NOT inside session_start — per AGENTS.md rule (prevents handler accumulation on /reload)
|
|
79
|
+
pi.on("session_shutdown", async () => {
|
|
80
|
+
lifecycle.stop();
|
|
81
|
+
await manager.closeAll();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
pi.on(
|
|
85
|
+
"session_start",
|
|
86
|
+
buildSessionStartHandler({
|
|
87
|
+
pi,
|
|
88
|
+
getManager: () => manager,
|
|
89
|
+
loadDefs,
|
|
90
|
+
loadCfg,
|
|
91
|
+
setIdleTimeout: (m) => { _idleTimeoutMinutes = m; },
|
|
92
|
+
startLifecycle: () => lifecycle.start(),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
pi.registerTool({
|
|
97
|
+
name: "mcp",
|
|
98
|
+
label: "MCP",
|
|
99
|
+
description: [
|
|
100
|
+
"Gateway to MCP (Model Context Protocol) servers. Use this tool to discover and call tools from connected MCP servers.",
|
|
101
|
+
"",
|
|
102
|
+
"Workflow:",
|
|
103
|
+
"1. Search: mcp({ search: 'keyword' }) — find available tools",
|
|
104
|
+
"2. Describe: mcp({ describe: 'tool_name' }) — see full parameters",
|
|
105
|
+
"3. Call: mcp({ tool: 'tool_name', args: { ... } }) — execute the tool",
|
|
106
|
+
"",
|
|
107
|
+
"Other actions:",
|
|
108
|
+
" Status: mcp({}) or mcp({ action: 'status' }) — list all servers and their connection status",
|
|
109
|
+
" List: mcp({ server: 'name' }) — list all tools on a specific server",
|
|
110
|
+
" Connect: mcp({ connect: 'name' }) — eagerly connect to a server",
|
|
111
|
+
"",
|
|
112
|
+
"Use 'server' to disambiguate when two servers export a tool with the same name.",
|
|
113
|
+
].join("\n"),
|
|
114
|
+
|
|
115
|
+
parameters: Type.Object({
|
|
116
|
+
tool: Type.Optional(Type.String({ description: "Tool name to call" })),
|
|
117
|
+
args: Type.Optional(
|
|
118
|
+
Type.Union([
|
|
119
|
+
Type.String({ description: "Tool arguments as a JSON string" }),
|
|
120
|
+
Type.Object({}, { additionalProperties: true, description: "Tool arguments as an object" }),
|
|
121
|
+
]),
|
|
122
|
+
),
|
|
123
|
+
search: Type.Optional(Type.String({ description: "Search tools by name/description keyword" })),
|
|
124
|
+
describe: Type.Optional(Type.String({ description: "Tool name to show full parameter schema for" })),
|
|
125
|
+
connect: Type.Optional(Type.String({ description: "Server name to eagerly connect" })),
|
|
126
|
+
server: Type.Optional(Type.String({ description: "Filter to a specific server (for list, search, or disambiguating calls)" })),
|
|
127
|
+
action: Type.Optional(Type.String({ description: "Action string (e.g. 'status')" })),
|
|
128
|
+
}),
|
|
129
|
+
|
|
130
|
+
renderCall(args: unknown, theme: Theme, context: unknown) {
|
|
131
|
+
return renderProxyCall(args as Record<string, unknown>, theme, context as RenderContext);
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
renderResult(result: unknown, options: unknown, theme: Theme, context: unknown) {
|
|
135
|
+
const typedResult = result as { content: Array<{ type: string; text?: string }>; details?: Record<string, unknown> };
|
|
136
|
+
const typedOptions = options as { expanded?: boolean; isPartial?: boolean };
|
|
137
|
+
return renderProxyResult(typedResult, typedOptions, theme, context as RenderContext);
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
execute: buildProxyToolExecute({
|
|
141
|
+
getManager: () => manager,
|
|
142
|
+
loadDefs,
|
|
143
|
+
loadCfg,
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ServerClient } from "./server-client.js";
|
|
3
|
+
import type { ServerManager } from "./server-manager.js";
|
|
4
|
+
import { LifecycleManager } from "./lifecycle.js";
|
|
5
|
+
import type { ServerDef } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/** Interval the lifecycle manager ticks at in production. */
|
|
8
|
+
const TICK_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
interface FakeClient {
|
|
11
|
+
name: string;
|
|
12
|
+
status: "disconnected" | "connecting" | "connected" | "error" | "needs-auth";
|
|
13
|
+
connect: ReturnType<typeof vi.fn>;
|
|
14
|
+
close: ReturnType<typeof vi.fn>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeClient(
|
|
18
|
+
name: string,
|
|
19
|
+
status: FakeClient["status"] = "disconnected",
|
|
20
|
+
): FakeClient {
|
|
21
|
+
return {
|
|
22
|
+
name,
|
|
23
|
+
status,
|
|
24
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stdioDef(extra?: Partial<ServerDef>): ServerDef {
|
|
30
|
+
return { type: "stdio", command: "true", ...extra } as ServerDef;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeLifecycle(
|
|
34
|
+
clients: FakeClient[],
|
|
35
|
+
defs: Record<string, ServerDef>,
|
|
36
|
+
globalIdleMin: number,
|
|
37
|
+
isIdleImpl: (name: string, timeoutMs: number) => boolean,
|
|
38
|
+
) {
|
|
39
|
+
const isIdle = vi.fn(isIdleImpl);
|
|
40
|
+
const manager = {
|
|
41
|
+
getClients: () => clients as unknown as ServerClient[],
|
|
42
|
+
isIdle,
|
|
43
|
+
} as unknown as ServerManager;
|
|
44
|
+
const lifecycle = new LifecycleManager(manager, () => defs, () => globalIdleMin);
|
|
45
|
+
return { lifecycle, isIdle };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.useFakeTimers();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.useRealTimers();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("LifecycleManager", () => {
|
|
57
|
+
it("reconnects a disconnected keep-alive server on tick", () => {
|
|
58
|
+
const client = makeClient("ka");
|
|
59
|
+
const { lifecycle } = makeLifecycle(
|
|
60
|
+
[client],
|
|
61
|
+
{ ka: stdioDef({ lifecycle: "keep-alive" }) },
|
|
62
|
+
10,
|
|
63
|
+
() => false,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
lifecycle.start();
|
|
67
|
+
vi.advanceTimersByTime(TICK_MS);
|
|
68
|
+
|
|
69
|
+
expect(client.connect).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(client.close).not.toHaveBeenCalled();
|
|
71
|
+
lifecycle.stop();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("reconnects a disconnected lazy-keep-alive server on tick", () => {
|
|
75
|
+
const client = makeClient("lka");
|
|
76
|
+
const { lifecycle } = makeLifecycle(
|
|
77
|
+
[client],
|
|
78
|
+
{ lka: stdioDef({ lifecycle: "lazy-keep-alive" }) },
|
|
79
|
+
10,
|
|
80
|
+
() => false,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
lifecycle.start();
|
|
84
|
+
vi.advanceTimersByTime(TICK_MS);
|
|
85
|
+
|
|
86
|
+
expect(client.connect).toHaveBeenCalledTimes(1);
|
|
87
|
+
lifecycle.stop();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does not reconnect or close a disconnected non-keep-alive server", () => {
|
|
91
|
+
const client = makeClient("lazy");
|
|
92
|
+
const { lifecycle } = makeLifecycle(
|
|
93
|
+
[client],
|
|
94
|
+
{ lazy: stdioDef({ lifecycle: "lazy" }) },
|
|
95
|
+
10,
|
|
96
|
+
() => false,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
lifecycle.start();
|
|
100
|
+
vi.advanceTimersByTime(TICK_MS * 2);
|
|
101
|
+
|
|
102
|
+
expect(client.connect).not.toHaveBeenCalled();
|
|
103
|
+
expect(client.close).not.toHaveBeenCalled();
|
|
104
|
+
lifecycle.stop();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reconnects a keep-alive server in error status on tick", () => {
|
|
108
|
+
const client = makeClient("err", "error");
|
|
109
|
+
const { lifecycle } = makeLifecycle(
|
|
110
|
+
[client],
|
|
111
|
+
{ err: stdioDef({ lifecycle: "keep-alive" }) },
|
|
112
|
+
10,
|
|
113
|
+
() => false,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
lifecycle.start();
|
|
117
|
+
vi.advanceTimersByTime(TICK_MS);
|
|
118
|
+
|
|
119
|
+
expect(client.connect).toHaveBeenCalledTimes(1);
|
|
120
|
+
expect(client.close).not.toHaveBeenCalled();
|
|
121
|
+
lifecycle.stop();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("does not touch a needs-auth keep-alive server", () => {
|
|
125
|
+
const client = makeClient("auth", "needs-auth");
|
|
126
|
+
const { lifecycle } = makeLifecycle(
|
|
127
|
+
[client],
|
|
128
|
+
{ auth: stdioDef({ lifecycle: "keep-alive" }) },
|
|
129
|
+
10,
|
|
130
|
+
() => false,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
lifecycle.start();
|
|
134
|
+
vi.advanceTimersByTime(TICK_MS);
|
|
135
|
+
|
|
136
|
+
expect(client.connect).not.toHaveBeenCalled();
|
|
137
|
+
expect(client.close).not.toHaveBeenCalled();
|
|
138
|
+
lifecycle.stop();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("closes a lazy server past its per-server idle timeout", () => {
|
|
142
|
+
const client = makeClient("lazy");
|
|
143
|
+
const { lifecycle, isIdle } = makeLifecycle(
|
|
144
|
+
[client],
|
|
145
|
+
{ lazy: stdioDef({ lifecycle: "lazy", idleTimeout: 5 }) },
|
|
146
|
+
10,
|
|
147
|
+
() => true,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
lifecycle.start();
|
|
151
|
+
vi.advanceTimersByTime(TICK_MS);
|
|
152
|
+
|
|
153
|
+
expect(isIdle).toHaveBeenCalledWith("lazy", 5 * 60_000);
|
|
154
|
+
expect(client.close).toHaveBeenCalledTimes(1);
|
|
155
|
+
expect(client.connect).not.toHaveBeenCalled();
|
|
156
|
+
lifecycle.stop();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("leaves a lazy server alone when it is not idle yet", () => {
|
|
160
|
+
const client = makeClient("lazy");
|
|
161
|
+
const { lifecycle } = makeLifecycle(
|
|
162
|
+
[client],
|
|
163
|
+
{ lazy: stdioDef({ lifecycle: "lazy", idleTimeout: 5 }) },
|
|
164
|
+
10,
|
|
165
|
+
() => false,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
lifecycle.start();
|
|
169
|
+
vi.advanceTimersByTime(TICK_MS * 2);
|
|
170
|
+
|
|
171
|
+
expect(client.close).not.toHaveBeenCalled();
|
|
172
|
+
expect(client.connect).not.toHaveBeenCalled();
|
|
173
|
+
lifecycle.stop();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("falls back to the global idle timeout when no per-server timeout is set", () => {
|
|
177
|
+
const client = makeClient("lazy");
|
|
178
|
+
const { lifecycle, isIdle } = makeLifecycle(
|
|
179
|
+
[client],
|
|
180
|
+
{ lazy: stdioDef({ lifecycle: "lazy" }) },
|
|
181
|
+
10,
|
|
182
|
+
() => true,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
lifecycle.start();
|
|
186
|
+
vi.advanceTimersByTime(TICK_MS);
|
|
187
|
+
|
|
188
|
+
expect(isIdle).toHaveBeenCalledWith("lazy", 10 * 60_000);
|
|
189
|
+
expect(client.close).toHaveBeenCalledTimes(1);
|
|
190
|
+
lifecycle.stop();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("idleTimeout 0 disables idle shutdown even when idle", () => {
|
|
194
|
+
const client = makeClient("lazy");
|
|
195
|
+
const { lifecycle, isIdle } = makeLifecycle(
|
|
196
|
+
[client],
|
|
197
|
+
{ lazy: stdioDef({ lifecycle: "lazy", idleTimeout: 0 }) },
|
|
198
|
+
10,
|
|
199
|
+
() => true,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
lifecycle.start();
|
|
203
|
+
vi.advanceTimersByTime(TICK_MS);
|
|
204
|
+
|
|
205
|
+
expect(isIdle).not.toHaveBeenCalled();
|
|
206
|
+
expect(client.close).not.toHaveBeenCalled();
|
|
207
|
+
lifecycle.stop();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("skips servers whose definition was removed", () => {
|
|
211
|
+
const client = makeClient("gone");
|
|
212
|
+
const { lifecycle } = makeLifecycle([client], {}, 10, () => true);
|
|
213
|
+
|
|
214
|
+
lifecycle.start();
|
|
215
|
+
vi.advanceTimersByTime(TICK_MS);
|
|
216
|
+
|
|
217
|
+
expect(client.connect).not.toHaveBeenCalled();
|
|
218
|
+
expect(client.close).not.toHaveBeenCalled();
|
|
219
|
+
lifecycle.stop();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("stop() clears the timer so no further ticks run", () => {
|
|
223
|
+
const client = makeClient("ka");
|
|
224
|
+
const { lifecycle } = makeLifecycle(
|
|
225
|
+
[client],
|
|
226
|
+
{ ka: stdioDef({ lifecycle: "keep-alive" }) },
|
|
227
|
+
10,
|
|
228
|
+
() => false,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
lifecycle.start();
|
|
232
|
+
vi.advanceTimersByTime(TICK_MS);
|
|
233
|
+
expect(client.connect).toHaveBeenCalledTimes(1);
|
|
234
|
+
|
|
235
|
+
lifecycle.stop();
|
|
236
|
+
vi.advanceTimersByTime(TICK_MS * 4);
|
|
237
|
+
expect(client.connect).toHaveBeenCalledTimes(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("start() is idempotent — calling it twice does not double the ticks", () => {
|
|
241
|
+
const client = makeClient("ka");
|
|
242
|
+
const { lifecycle } = makeLifecycle(
|
|
243
|
+
[client],
|
|
244
|
+
{ ka: stdioDef({ lifecycle: "keep-alive" }) },
|
|
245
|
+
10,
|
|
246
|
+
() => false,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
lifecycle.start();
|
|
250
|
+
lifecycle.start();
|
|
251
|
+
vi.advanceTimersByTime(TICK_MS);
|
|
252
|
+
|
|
253
|
+
expect(client.connect).toHaveBeenCalledTimes(1);
|
|
254
|
+
lifecycle.stop();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("unrefs the interval so it does not keep the process alive", () => {
|
|
258
|
+
vi.useRealTimers();
|
|
259
|
+
const unref = vi.fn();
|
|
260
|
+
const spy = vi
|
|
261
|
+
.spyOn(globalThis, "setInterval")
|
|
262
|
+
.mockImplementation(() => ({ unref }) as unknown as NodeJS.Timeout);
|
|
263
|
+
|
|
264
|
+
const lifecycle = new LifecycleManager(
|
|
265
|
+
{ getClients: () => [], isIdle: () => false } as unknown as ServerManager,
|
|
266
|
+
() => ({}),
|
|
267
|
+
() => 10,
|
|
268
|
+
);
|
|
269
|
+
lifecycle.start();
|
|
270
|
+
expect(unref).toHaveBeenCalledTimes(1);
|
|
271
|
+
lifecycle.stop();
|
|
272
|
+
spy.mockRestore();
|
|
273
|
+
});
|
|
274
|
+
});
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ServerManager } from "./server-manager.js";
|
|
2
|
+
import type { ServerDef } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** Health-check tick interval in production. */
|
|
5
|
+
const TICK_INTERVAL_MS = 30_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Periodic resource-lifecycle management for MCP server clients:
|
|
9
|
+
*
|
|
10
|
+
* - keep-alive / lazy-keep-alive servers that end up disconnected or in an
|
|
11
|
+
* error state are reconnected on the next tick.
|
|
12
|
+
* - Non-keep-alive servers are closed once they have been idle past their
|
|
13
|
+
* `idleTimeout` (per-server, falling back to the global default; 0 disables).
|
|
14
|
+
*
|
|
15
|
+
* The interval is `unref`'d so it never keeps the process alive, and `stop()`
|
|
16
|
+
* is idempotent so repeated session restarts cannot accumulate timers.
|
|
17
|
+
*/
|
|
18
|
+
export class LifecycleManager {
|
|
19
|
+
private timer: NodeJS.Timeout | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private manager: ServerManager,
|
|
23
|
+
private getDefs: () => Record<string, ServerDef>,
|
|
24
|
+
private getGlobalIdleMinutes: () => number,
|
|
25
|
+
private intervalMs: number = TICK_INTERVAL_MS,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Replace the managed ServerManager. Used by the index test-seam reset so
|
|
30
|
+
* the lifecycle stays bound to the module-level manager after a swap.
|
|
31
|
+
*/
|
|
32
|
+
setManager(manager: ServerManager): void {
|
|
33
|
+
this.manager = manager;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
start(): void {
|
|
37
|
+
if (this.timer) return;
|
|
38
|
+
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
|
39
|
+
this.timer.unref?.(); // don't keep the process alive
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
stop(): void {
|
|
43
|
+
if (this.timer) {
|
|
44
|
+
clearInterval(this.timer);
|
|
45
|
+
this.timer = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One pass over all clients. Public so tests can drive a single tick
|
|
51
|
+
* deterministically without waiting for (or faking) the interval.
|
|
52
|
+
*/
|
|
53
|
+
tick(): void {
|
|
54
|
+
const defs = this.getDefs();
|
|
55
|
+
for (const client of this.manager.getClients()) {
|
|
56
|
+
const def = defs[client.name];
|
|
57
|
+
if (!def) continue;
|
|
58
|
+
const lifecycle = def.lifecycle ?? "lazy";
|
|
59
|
+
if (lifecycle === "keep-alive" || lifecycle === "lazy-keep-alive") {
|
|
60
|
+
// Reconnect servers that fell out — including ones stuck in "error"
|
|
61
|
+
// after a failed connect (needs-auth/connecting/connected are left
|
|
62
|
+
// alone: 401 retries loop without OAuth, in-flight connects race).
|
|
63
|
+
if (client.status === "disconnected" || client.status === "error") {
|
|
64
|
+
// ADR 0004: deliberately NOT recorded — a 30s-tick reconnect is not
|
|
65
|
+
// a settle point; the live client wins within the session, and
|
|
66
|
+
// writing the ledger on every tick would only churn the cache file.
|
|
67
|
+
void client.connect().catch(() => {});
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
const idleMin = def.idleTimeout ?? this.getGlobalIdleMinutes();
|
|
71
|
+
if (idleMin > 0 && this.manager.isIdle(client.name, idleMin * 60_000)) {
|
|
72
|
+
void client.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|