@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
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factories for the `mcp` proxy tool's execute handler and session_start
|
|
3
|
+
* direct-tool registration logic.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from index.ts (plan-028, Task 6) so the inline action-dispatch
|
|
6
|
+
* block no longer bloats the registration module. Each factory receives the
|
|
7
|
+
* runtime dependencies as closures and returns a function that the
|
|
8
|
+
* registration module can consume directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { autoAuthenticate, needsAuthToolResult } from "./auto-auth.js";
|
|
13
|
+
import { resolveServerSettings } from "./config.js";
|
|
14
|
+
import {
|
|
15
|
+
filterDirectTools,
|
|
16
|
+
pruneRegisteredNames,
|
|
17
|
+
registerDirectTools,
|
|
18
|
+
} from "./direct-tools.js";
|
|
19
|
+
import { getCachedTools, recordClientOutcome } from "./metadata-cache.js";
|
|
20
|
+
import type { ServerClient } from "./server-client.js";
|
|
21
|
+
import type { ServerManager } from "./server-manager.js";
|
|
22
|
+
import {
|
|
23
|
+
getServerPrefix,
|
|
24
|
+
matchRawToolName,
|
|
25
|
+
resolveServerFromToolName,
|
|
26
|
+
resolveServerRef,
|
|
27
|
+
} from "./tool-naming.js";
|
|
28
|
+
import type { CachedTool, McpConfig, ServerDef } from "./types.js";
|
|
29
|
+
|
|
30
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
type ExecuteParams = {
|
|
33
|
+
tool?: string;
|
|
34
|
+
args?: string | Record<string, unknown>;
|
|
35
|
+
search?: string;
|
|
36
|
+
describe?: string;
|
|
37
|
+
connect?: string;
|
|
38
|
+
server?: string;
|
|
39
|
+
action?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Shape of what pi.registerTool receives for the execute function. */
|
|
43
|
+
type ExecuteFn = Parameters<ExtensionAPI["registerTool"]>[0]["execute"];
|
|
44
|
+
|
|
45
|
+
// ── Helper ─────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a tool reference (raw name OR final prefixed name) to the owning
|
|
49
|
+
* server and the raw server tool name. Prefixed lookups use each server's
|
|
50
|
+
* OWN prefix mode, exactly as the call path does.
|
|
51
|
+
*/
|
|
52
|
+
function resolveToolRef(
|
|
53
|
+
ref: string,
|
|
54
|
+
defs: Record<string, ServerDef>,
|
|
55
|
+
config: McpConfig,
|
|
56
|
+
manager: ServerManager,
|
|
57
|
+
allTools: Array<CachedTool & { serverName: string }>,
|
|
58
|
+
): { serverName: string; rawName: string } | undefined {
|
|
59
|
+
// 1. Exact raw name
|
|
60
|
+
const rawHit = allTools.find((t) => t.name === ref);
|
|
61
|
+
if (rawHit) return { serverName: rawHit.serverName, rawName: rawHit.name };
|
|
62
|
+
// 2. Final prefixed name → owning server → format-match the raw tool name
|
|
63
|
+
const servers = Object.entries(defs).map(([name, def]) => ({
|
|
64
|
+
name,
|
|
65
|
+
prefix: resolveServerSettings(def, config).toolPrefix,
|
|
66
|
+
}));
|
|
67
|
+
const serverName = resolveServerFromToolName(ref, servers);
|
|
68
|
+
const def = serverName ? defs[serverName] : undefined;
|
|
69
|
+
if (!serverName || !def) return undefined;
|
|
70
|
+
const prefixMode = resolveServerSettings(def, config).toolPrefix;
|
|
71
|
+
const rawName = matchRawToolName(
|
|
72
|
+
ref,
|
|
73
|
+
serverName,
|
|
74
|
+
prefixMode,
|
|
75
|
+
manager.getToolsForServer(serverName, def),
|
|
76
|
+
);
|
|
77
|
+
return rawName !== undefined ? { serverName, rawName } : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Factory ────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the execute function for the `mcp` proxy tool.
|
|
84
|
+
*
|
|
85
|
+
* @param deps.getManager - returns the current module-level ServerManager
|
|
86
|
+
* @param deps.loadDefs - load enabled server definitions (seam-aware)
|
|
87
|
+
* @param deps.loadCfg - load the MCP config (seam-aware)
|
|
88
|
+
*/
|
|
89
|
+
export function buildProxyToolExecute(deps: {
|
|
90
|
+
getManager: () => ServerManager;
|
|
91
|
+
loadDefs: () => Record<string, ServerDef>;
|
|
92
|
+
loadCfg: () => McpConfig;
|
|
93
|
+
}): ExecuteFn {
|
|
94
|
+
const { getManager, loadDefs, loadCfg } = deps;
|
|
95
|
+
|
|
96
|
+
return async function execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
97
|
+
const manager = getManager();
|
|
98
|
+
const p = params as ExecuteParams;
|
|
99
|
+
|
|
100
|
+
// ── status (no meaningful params; action:'status' is an alias) ──────
|
|
101
|
+
if (
|
|
102
|
+
!p.tool &&
|
|
103
|
+
!p.search &&
|
|
104
|
+
!p.describe &&
|
|
105
|
+
!p.connect &&
|
|
106
|
+
!p.server &&
|
|
107
|
+
(p.action === undefined || p.action === "status")
|
|
108
|
+
) {
|
|
109
|
+
const defs = loadDefs();
|
|
110
|
+
manager.sync(defs);
|
|
111
|
+
const clients = manager.getClients();
|
|
112
|
+
const lines =
|
|
113
|
+
clients.length === 0
|
|
114
|
+
? ["No MCP servers configured."]
|
|
115
|
+
: clients.map(
|
|
116
|
+
(c) =>
|
|
117
|
+
`${c.name}: ${c.status}${c.error ? ` (${c.error})` : ""}`,
|
|
118
|
+
);
|
|
119
|
+
return {
|
|
120
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
121
|
+
details: {},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── connect ──────────────────────────────────────────────────────────
|
|
126
|
+
if (p.connect) {
|
|
127
|
+
const defs = loadDefs();
|
|
128
|
+
manager.sync(defs);
|
|
129
|
+
const client = manager.getClient(p.connect);
|
|
130
|
+
if (!client) {
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: "text" as const, text: `Unknown server: ${p.connect}` }],
|
|
133
|
+
details: {},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// ADR 0004 settle point: proxy p.connect action — recorded in
|
|
137
|
+
// `finally` so a failed connect ("error") persists too.
|
|
138
|
+
try {
|
|
139
|
+
await client.connect();
|
|
140
|
+
} finally {
|
|
141
|
+
recordClientOutcome(client);
|
|
142
|
+
}
|
|
143
|
+
// A 401 surfaces as status 'needs-auth' (connect() does not throw) —
|
|
144
|
+
// reporting that as "Connected with 0 tools" would be a lie.
|
|
145
|
+
if (client.status === "needs-auth") {
|
|
146
|
+
return {
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: "text" as const,
|
|
150
|
+
text: `Server ${p.connect} requires authentication: ${client.error ?? "token missing or rejected"}`,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
details: { server: p.connect, status: "needs-auth" },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
content: [
|
|
158
|
+
{
|
|
159
|
+
type: "text" as const,
|
|
160
|
+
text: `Connected to ${p.connect}. ${client.tools.length} tools available.`,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
details: { server: p.connect, toolCount: client.tools.length },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── search ───────────────────────────────────────────────────────────
|
|
168
|
+
if (p.search) {
|
|
169
|
+
// Served from live + metadata cache — no server connections
|
|
170
|
+
const defs = loadDefs();
|
|
171
|
+
manager.sync(defs);
|
|
172
|
+
const config = loadCfg();
|
|
173
|
+
const allTools = manager.getAllToolsWithCache(defs);
|
|
174
|
+
const find = (query: string, serverFilter: string | undefined) =>
|
|
175
|
+
allTools.filter(
|
|
176
|
+
(t) =>
|
|
177
|
+
(!serverFilter || t.serverName === serverFilter) &&
|
|
178
|
+
(t.name.toLowerCase().includes(query) ||
|
|
179
|
+
(t.description ?? "").toLowerCase().includes(query)),
|
|
180
|
+
);
|
|
181
|
+
let results = find(p.search.toLowerCase(), p.server);
|
|
182
|
+
if (results.length === 0) {
|
|
183
|
+
// The query may be a final prefixed name (e.g. "srv_a_b" for
|
|
184
|
+
// tool "a.b"): resolve it and retry with the raw tool name.
|
|
185
|
+
const ref = resolveToolRef(p.search, defs, config, manager, allTools);
|
|
186
|
+
if (ref) results = find(ref.rawName.toLowerCase(), ref.serverName);
|
|
187
|
+
}
|
|
188
|
+
if (results.length === 0) {
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: "text" as const, text: `No tools matching "${p.search}"` }],
|
|
191
|
+
details: {},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const text = results
|
|
195
|
+
.map((t) => `${t.name} (${t.serverName})\n ${t.description ?? "(no description)"}`)
|
|
196
|
+
.join("\n\n");
|
|
197
|
+
return { content: [{ type: "text" as const, text }], details: {} };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── describe ─────────────────────────────────────────────────────────
|
|
201
|
+
if (p.describe) {
|
|
202
|
+
// Served from live + metadata cache — no server connections
|
|
203
|
+
const defs = loadDefs();
|
|
204
|
+
manager.sync(defs);
|
|
205
|
+
const config = loadCfg();
|
|
206
|
+
const allTools = manager.getAllToolsWithCache(defs);
|
|
207
|
+
let tool = allTools.find((t) => t.name === p.describe);
|
|
208
|
+
// Also accept a final prefixed name (e.g. "srv_a_b" for tool "a.b")
|
|
209
|
+
if (!tool) {
|
|
210
|
+
const ref = resolveToolRef(p.describe, defs, config, manager, allTools);
|
|
211
|
+
if (ref) {
|
|
212
|
+
tool = allTools.find(
|
|
213
|
+
(t) => t.serverName === ref.serverName && t.name === ref.rawName,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!tool) {
|
|
218
|
+
return {
|
|
219
|
+
content: [{ type: "text" as const, text: `Tool not found: ${p.describe}` }],
|
|
220
|
+
details: {},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const schema = JSON.stringify(tool.inputSchema, null, 2);
|
|
224
|
+
return {
|
|
225
|
+
content: [
|
|
226
|
+
{
|
|
227
|
+
type: "text" as const,
|
|
228
|
+
text: `${tool.name} (${tool.serverName})\n${tool.description ?? ""}\n\nSchema:\n${schema}`,
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
details: {},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── list server (server param only, no tool) ─────────────────────────
|
|
236
|
+
if (p.server && !p.tool) {
|
|
237
|
+
const defs = loadDefs();
|
|
238
|
+
manager.sync(defs);
|
|
239
|
+
const config = loadCfg();
|
|
240
|
+
let serverRef = p.server;
|
|
241
|
+
let client = manager.getClient(serverRef);
|
|
242
|
+
if (!client) {
|
|
243
|
+
// Accept a prefixed tool-name reference instead of a bare server
|
|
244
|
+
// name (e.g. "github" for server "github-mcp" under "short" mode)
|
|
245
|
+
const servers = Object.entries(defs).map(([name, def]) => ({
|
|
246
|
+
name,
|
|
247
|
+
prefix: resolveServerSettings(def, config).toolPrefix,
|
|
248
|
+
}));
|
|
249
|
+
const resolved = resolveServerRef(serverRef, servers);
|
|
250
|
+
if (resolved) {
|
|
251
|
+
serverRef = resolved;
|
|
252
|
+
client = manager.getClient(serverRef);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!client) {
|
|
256
|
+
return {
|
|
257
|
+
content: [{ type: "text" as const, text: `Unknown server: ${p.server}` }],
|
|
258
|
+
details: {},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const def = defs[serverRef];
|
|
262
|
+
// Live tools if connected, else valid cache
|
|
263
|
+
const tools = def ? manager.getToolsForServer(serverRef, def) : client.tools;
|
|
264
|
+
const lines = tools.map(
|
|
265
|
+
(t) => `${t.name}: ${t.description ?? "(no description)"}`,
|
|
266
|
+
);
|
|
267
|
+
return {
|
|
268
|
+
content: [{ type: "text" as const, text: lines.join("\n") || "(no tools)" }],
|
|
269
|
+
details: { server: serverRef, toolCount: tools.length },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── call tool ────────────────────────────────────────────────────────
|
|
274
|
+
if (p.tool) {
|
|
275
|
+
const defs = loadDefs();
|
|
276
|
+
manager.sync(defs);
|
|
277
|
+
const config = loadCfg();
|
|
278
|
+
|
|
279
|
+
let serverName: string | undefined = p.server;
|
|
280
|
+
let rawToolName: string = p.tool;
|
|
281
|
+
|
|
282
|
+
if (!serverName) {
|
|
283
|
+
// First: resolve the final prefixed name against each server's ACTUAL
|
|
284
|
+
// prefix mode (per-server def, falling back to the global config).
|
|
285
|
+
const servers = Object.entries(defs).map(([name, def]) => ({
|
|
286
|
+
name,
|
|
287
|
+
prefix: resolveServerSettings(def, config).toolPrefix,
|
|
288
|
+
}));
|
|
289
|
+
const resolved = resolveServerFromToolName(p.tool, servers);
|
|
290
|
+
const resolvedDef = resolved ? defs[resolved] : undefined;
|
|
291
|
+
if (resolved && resolvedDef) {
|
|
292
|
+
const prefixMode = resolveServerSettings(resolvedDef, config).toolPrefix;
|
|
293
|
+
serverName = resolved;
|
|
294
|
+
// Resolve the RAW tool name by format-matching against the
|
|
295
|
+
// owning server's tool list — never by slicing the prefixed
|
|
296
|
+
// name. Slicing cannot reverse the `.`→`_` sanitization, so
|
|
297
|
+
// tool "a.b" would be called as "a_b" and miss on the server.
|
|
298
|
+
const tools = manager.getToolsForServer(resolved, resolvedDef);
|
|
299
|
+
const raw = matchRawToolName(p.tool, resolved, prefixMode, tools);
|
|
300
|
+
if (raw !== undefined) {
|
|
301
|
+
rawToolName = raw;
|
|
302
|
+
} else {
|
|
303
|
+
// Last resort (tool missing from the live list/cache, e.g.
|
|
304
|
+
// the server added it since the cache was written): strip
|
|
305
|
+
// the prefix. Lossless only for dot-free tool names.
|
|
306
|
+
const prefixStr = getServerPrefix(resolved, prefixMode);
|
|
307
|
+
rawToolName = p.tool.slice(prefixStr.length + 1);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Fallback: raw (unprefixed) tool-name lookup across live + cached
|
|
311
|
+
// metadata, so existing raw-name calls keep working.
|
|
312
|
+
if (!serverName) {
|
|
313
|
+
const toolDef = manager.getAllToolsWithCache(defs).find((t) => t.name === p.tool);
|
|
314
|
+
serverName = toolDef?.serverName;
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
// An explicit server was given — but the tool may STILL be a final
|
|
318
|
+
// prefixed name (e.g. mcp({ tool: "srv_a_b", server: "srv" })). Try
|
|
319
|
+
// format-matching against that server's own tool list first; if no
|
|
320
|
+
// tool formats to it, use p.tool as the raw name (existing behavior).
|
|
321
|
+
const def = defs[serverName];
|
|
322
|
+
if (def) {
|
|
323
|
+
const prefixMode = resolveServerSettings(def, config).toolPrefix;
|
|
324
|
+
const tools = manager.getToolsForServer(serverName, def);
|
|
325
|
+
const raw = matchRawToolName(p.tool, serverName, prefixMode, tools);
|
|
326
|
+
if (raw !== undefined) rawToolName = raw;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!serverName) {
|
|
331
|
+
return {
|
|
332
|
+
content: [{ type: "text" as const, text: `Tool not found: ${p.tool}` }],
|
|
333
|
+
details: {},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const client = manager.getClient(serverName);
|
|
338
|
+
if (!client) {
|
|
339
|
+
return {
|
|
340
|
+
content: [{ type: "text" as const, text: `Server not found: ${serverName}` }],
|
|
341
|
+
details: {},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Parse args: string → JSON (with error handling), object → use as-is, undefined → {}
|
|
346
|
+
let toolArgs: Record<string, unknown>;
|
|
347
|
+
if (typeof p.args === "string") {
|
|
348
|
+
try {
|
|
349
|
+
toolArgs = JSON.parse(p.args) as Record<string, unknown>;
|
|
350
|
+
} catch (e) {
|
|
351
|
+
return {
|
|
352
|
+
content: [{ type: "text" as const, text: `Invalid JSON in args: ${e instanceof Error ? e.message : String(e)}` }],
|
|
353
|
+
isError: true,
|
|
354
|
+
details: {},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
toolArgs = (p.args ?? {}) as Record<string, unknown>;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Connect the owning server (callTool would connect lazily anyway)
|
|
362
|
+
// so a 401 surfaces here as `needs-auth`: guidance by default,
|
|
363
|
+
// inline auto-auth + one retry when autoAuth is on.
|
|
364
|
+
// ADR 0004 settle point: proxy p.tool lazy connect — recorded in
|
|
365
|
+
// `finally` so a failed connect ("error") persists too.
|
|
366
|
+
try {
|
|
367
|
+
await client.connect();
|
|
368
|
+
} finally {
|
|
369
|
+
recordClientOutcome(client);
|
|
370
|
+
}
|
|
371
|
+
if (client.status === "needs-auth") {
|
|
372
|
+
if (!config.autoAuth) {
|
|
373
|
+
return needsAuthToolResult(serverName);
|
|
374
|
+
}
|
|
375
|
+
const outcome = await autoAuthenticate(ctx, client);
|
|
376
|
+
if (!outcome.proceed) {
|
|
377
|
+
return needsAuthToolResult(serverName, outcome.error);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The call below is the (single) retry after a successful auto-auth
|
|
382
|
+
const result = await client.callTool(rawToolName, toolArgs, signal);
|
|
383
|
+
// Cast MCP ContentBlock[] to pi's (TextContent | ImageContent)[]
|
|
384
|
+
// Both are discriminated unions on `type`; we only surface text + image blocks
|
|
385
|
+
const content = result.content as Array<
|
|
386
|
+
{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
|
|
387
|
+
>;
|
|
388
|
+
return {
|
|
389
|
+
content,
|
|
390
|
+
details: { server: serverName, tool: rawToolName },
|
|
391
|
+
isError: result.isError,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── fallback ─────────────────────────────────────────────────────────
|
|
396
|
+
return {
|
|
397
|
+
content: [{ type: "text" as const, text: "Unknown action" }],
|
|
398
|
+
details: {},
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── Session-start handler factory ─────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Build the session_start handler for direct-tool registration.
|
|
407
|
+
*
|
|
408
|
+
* @param deps.pi - ExtensionAPI (for registerDirectTools)
|
|
409
|
+
* @param deps.getManager - returns the current module-level ServerManager
|
|
410
|
+
* @param deps.loadDefs - load enabled server definitions (seam-aware)
|
|
411
|
+
* @param deps.loadCfg - load the MCP config (seam-aware)
|
|
412
|
+
* @param deps.setIdleTimeout - callback to update the module-level idle timeout
|
|
413
|
+
*/
|
|
414
|
+
export function buildSessionStartHandler(deps: {
|
|
415
|
+
pi: ExtensionAPI;
|
|
416
|
+
getManager: () => ServerManager;
|
|
417
|
+
loadDefs: () => Record<string, ServerDef>;
|
|
418
|
+
loadCfg: () => McpConfig;
|
|
419
|
+
setIdleTimeout: (minutes: number) => void;
|
|
420
|
+
startLifecycle: () => void;
|
|
421
|
+
}): () => Promise<void> {
|
|
422
|
+
const { pi, getManager, loadDefs, loadCfg, setIdleTimeout, startLifecycle } = deps;
|
|
423
|
+
|
|
424
|
+
return async function sessionStart() {
|
|
425
|
+
const manager = getManager();
|
|
426
|
+
// Re-sync server definitions on every session start (picks up config changes)
|
|
427
|
+
const defs = loadDefs();
|
|
428
|
+
manager.sync(defs);
|
|
429
|
+
|
|
430
|
+
const config = loadCfg();
|
|
431
|
+
setIdleTimeout(config.idleTimeout);
|
|
432
|
+
startLifecycle(); // idempotent — safe across /reload
|
|
433
|
+
|
|
434
|
+
// ── Cache-first direct tool registration ─────────────────────────────
|
|
435
|
+
// Register direct tools from the metadata cache: NO server is connected
|
|
436
|
+
// at startup (no connect storm). Servers without a valid cache (first run)
|
|
437
|
+
// are probed in the background, fire-and-forget — session_start does not
|
|
438
|
+
// wait for them, and their tools register once each probe settles.
|
|
439
|
+
const resolveClient = async (name: string): Promise<ServerClient> => {
|
|
440
|
+
const client = manager.getClient(name);
|
|
441
|
+
if (!client) throw new Error(`Server "${name}" is no longer configured`);
|
|
442
|
+
try {
|
|
443
|
+
await client.connect();
|
|
444
|
+
} finally {
|
|
445
|
+
// ADR 0004 settle point: direct-tool lazy connect.
|
|
446
|
+
recordClientOutcome(client);
|
|
447
|
+
}
|
|
448
|
+
return client;
|
|
449
|
+
};
|
|
450
|
+
const registerFromTools = (serverName: string, def: ServerDef, tools: CachedTool[]): void => {
|
|
451
|
+
const settings = resolveServerSettings(def, config);
|
|
452
|
+
if (settings.directTools === false) return;
|
|
453
|
+
registerDirectTools(pi, {
|
|
454
|
+
serverName,
|
|
455
|
+
prefix: settings.toolPrefix,
|
|
456
|
+
tools: filterDirectTools(tools, settings),
|
|
457
|
+
autoAuth: () => loadCfg().autoAuth,
|
|
458
|
+
resolveClient,
|
|
459
|
+
});
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// Drop tracked names from servers that are no longer configured, so a
|
|
463
|
+
// removed server cannot keep blocking a surviving server's identical
|
|
464
|
+
// final name (e.g. under toolPrefix "none").
|
|
465
|
+
pruneRegisteredNames(new Set(Object.keys(defs)));
|
|
466
|
+
|
|
467
|
+
const probeTargets: Array<{ name: string; def: ServerDef }> = [];
|
|
468
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
469
|
+
const cached = getCachedTools(name, def);
|
|
470
|
+
if (cached) {
|
|
471
|
+
registerFromTools(name, def, cached);
|
|
472
|
+
} else {
|
|
473
|
+
probeTargets.push({ name, def });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (probeTargets.length > 0) {
|
|
478
|
+
void Promise.allSettled(
|
|
479
|
+
probeTargets.map(async ({ name, def }) => {
|
|
480
|
+
const client = manager.getClient(name);
|
|
481
|
+
if (!client) return;
|
|
482
|
+
try {
|
|
483
|
+
await client.connect();
|
|
484
|
+
// A concurrent session_start (e.g. /reload) may have replaced this
|
|
485
|
+
// client while we were connecting: sync() closed it, so the
|
|
486
|
+
// generation fence made connect() resolve normally with EMPTY
|
|
487
|
+
// tools. Registering from that stale client would run a zero-tool
|
|
488
|
+
// pass, evicting the names the current session's probe just
|
|
489
|
+
// registered (and re-registering duplicates on the next start).
|
|
490
|
+
if (manager.getClient(name) !== client) return; // superseded
|
|
491
|
+
// A 401 surfaces as status 'needs-auth' (connect() does not
|
|
492
|
+
// throw) — record it (ADR 0004) and warn clearly instead of
|
|
493
|
+
// silently registering 0 tools.
|
|
494
|
+
if (client.status === "needs-auth") {
|
|
495
|
+
recordClientOutcome(client);
|
|
496
|
+
console.warn(
|
|
497
|
+
`[mcp] server "${name}" requires authentication (${client.error ?? "token missing or rejected"}) — no tools registered`,
|
|
498
|
+
);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
// ADR 0004 settle point: probe success.
|
|
502
|
+
recordClientOutcome(client);
|
|
503
|
+
const tools: CachedTool[] = client.tools.map((t) => {
|
|
504
|
+
const cached: CachedTool = { name: t.name, inputSchema: t.inputSchema };
|
|
505
|
+
if (t.description !== undefined) cached.description = t.description;
|
|
506
|
+
return cached;
|
|
507
|
+
});
|
|
508
|
+
registerFromTools(name, def, tools);
|
|
509
|
+
} catch (e) {
|
|
510
|
+
// A connect failure settles the client into "error" — persist
|
|
511
|
+
// that outcome (ADR 0004 settle point: probe error) so the
|
|
512
|
+
// failure is visible in /mcp status across sessions.
|
|
513
|
+
recordClientOutcome(client);
|
|
514
|
+
// Probe failure is logged, not thrown — the proxy tool will still
|
|
515
|
+
// surface the error on first use of a tool from this server.
|
|
516
|
+
console.warn(
|
|
517
|
+
`[mcp] background probe for server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
}
|