ft-scout 9.0.7 → 9.0.9

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 (82) hide show
  1. package/.firebase/hosting.d2Vi.cache +8 -8
  2. package/AUTONOMOUS_FEATURES.md +1 -1
  3. package/README.md +113 -16
  4. package/bin/src/commands/agent.d.ts.map +1 -1
  5. package/bin/src/commands/agent.js +28 -15
  6. package/bin/src/commands/agent.js.map +1 -1
  7. package/bin/src/commands/audit.js +1 -1
  8. package/bin/src/commands/audit.js.map +1 -1
  9. package/bin/src/commands/dashboard.js +1 -1
  10. package/bin/src/commands/dashboard.js.map +1 -1
  11. package/bin/src/commands/mcp.d.ts.map +1 -0
  12. package/bin/src/commands/mcp.js +393 -0
  13. package/bin/src/commands/mcp.js.map +1 -0
  14. package/bin/src/commands/owners.js +1 -1
  15. package/bin/src/commands/owners.js.map +1 -1
  16. package/bin/src/discovery/messages.js +2 -2
  17. package/bin/src/discovery/messages.js.map +1 -1
  18. package/bin/src/discovery/signals.js +3 -3
  19. package/bin/src/discovery/signals.js.map +1 -1
  20. package/bin/src/engine/agentEngine.d.ts.map +1 -1
  21. package/bin/src/engine/agentEngine.js +111 -32
  22. package/bin/src/engine/agentEngine.js.map +1 -1
  23. package/bin/src/engine/runtime/agentRuntime.d.ts.map +1 -1
  24. package/bin/src/engine/runtime/agentRuntime.js +40 -3
  25. package/bin/src/engine/runtime/agentRuntime.js.map +1 -1
  26. package/bin/src/engine/toolRegistry.d.ts.map +1 -1
  27. package/bin/src/engine/toolRegistry.js +13 -0
  28. package/bin/src/engine/toolRegistry.js.map +1 -1
  29. package/bin/src/engine/verifier.js +2 -2
  30. package/bin/src/engine/verifier.js.map +1 -1
  31. package/bin/src/index.js +85 -7
  32. package/bin/src/index.js.map +1 -1
  33. package/bin/src/mcp/client.d.ts.map +1 -0
  34. package/bin/src/mcp/client.js +127 -0
  35. package/bin/src/mcp/client.js.map +1 -0
  36. package/bin/src/mcp/discovery.d.ts.map +1 -0
  37. package/bin/src/mcp/discovery.js +91 -0
  38. package/bin/src/mcp/discovery.js.map +1 -0
  39. package/bin/src/mcp/health.d.ts.map +1 -0
  40. package/bin/src/mcp/health.js +44 -0
  41. package/bin/src/mcp/health.js.map +1 -0
  42. package/bin/src/mcp/index.d.ts.map +1 -0
  43. package/bin/src/mcp/index.js +15 -0
  44. package/bin/src/mcp/index.js.map +1 -0
  45. package/bin/src/mcp/manager.d.ts.map +1 -0
  46. package/bin/src/mcp/manager.js +648 -0
  47. package/bin/src/mcp/manager.js.map +1 -0
  48. package/bin/src/mcp/permissions.d.ts.map +1 -0
  49. package/bin/src/mcp/permissions.js +50 -0
  50. package/bin/src/mcp/permissions.js.map +1 -0
  51. package/bin/src/mcp/persistence.d.ts.map +1 -0
  52. package/bin/src/mcp/persistence.js +171 -0
  53. package/bin/src/mcp/persistence.js.map +1 -0
  54. package/bin/src/mcp/recovery.d.ts.map +1 -0
  55. package/bin/src/mcp/recovery.js +75 -0
  56. package/bin/src/mcp/recovery.js.map +1 -0
  57. package/bin/src/mcp/telemetry.d.ts.map +1 -0
  58. package/bin/src/mcp/telemetry.js +82 -0
  59. package/bin/src/mcp/telemetry.js.map +1 -0
  60. package/bin/src/mcp/toolRouter.d.ts.map +1 -0
  61. package/bin/src/mcp/toolRouter.js +127 -0
  62. package/bin/src/mcp/toolRouter.js.map +1 -0
  63. package/bin/src/mcp/transport.d.ts.map +1 -0
  64. package/bin/src/mcp/transport.js +328 -0
  65. package/bin/src/mcp/transport.js.map +1 -0
  66. package/bin/src/mcp/types.d.ts.map +1 -0
  67. package/bin/src/mcp/types.js +6 -0
  68. package/bin/src/mcp/types.js.map +1 -0
  69. package/bin/src/server/server.d.ts.map +1 -1
  70. package/bin/src/server/server.js +223 -10
  71. package/bin/src/server/server.js.map +1 -1
  72. package/bin/src/utils/admin.d.ts.map +1 -1
  73. package/bin/src/utils/admin.js +5 -11
  74. package/bin/src/utils/admin.js.map +1 -1
  75. package/bin/src/utils/branding.js +1 -1
  76. package/bin/src/utils/email.js +4 -4
  77. package/bin/src/utils/session.d.ts.map +1 -1
  78. package/bin/src/utils/session.js +8 -7
  79. package/bin/src/utils/session.js.map +1 -1
  80. package/package.json +1 -1
  81. package/web/app.js +1493 -75
  82. package/web/styles.css +1474 -15
@@ -0,0 +1,648 @@
1
+ /**
2
+ * Scout MCP Ecosystem - Central Connection Manager
3
+ * Coordinates lifecycle, connection pools, tool execution, permissions, health, and persistence.
4
+ */
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import crypto from 'crypto';
9
+ import { loadMCPServers, saveMCPServers, saveServerSecret, getServerSecret, deleteServerSecret, sanitizeServerConfig, } from './persistence.js';
10
+ import { HttpMcpTransport, StdioMcpTransport } from './transport.js';
11
+ import { MCPClient } from './client.js';
12
+ import { normalizeMCPTool } from './discovery.js';
13
+ import { MCPPermissionManager } from './permissions.js';
14
+ import { MCPToolRouter } from './toolRouter.js';
15
+ import { MCPTelemetry } from './telemetry.js';
16
+ import { MCPHealthMonitor } from './health.js';
17
+ import { MCPRecovery } from './recovery.js';
18
+ import { loadAuthConfig } from '../utils/auth.js';
19
+ export class MCPManager {
20
+ static instance = null;
21
+ servers = new Map();
22
+ clients = new Map();
23
+ initialized = false;
24
+ watcherDebounceTimer = null;
25
+ constructor() {
26
+ this.loadState();
27
+ this.setupFileWatcher();
28
+ }
29
+ static getInstance() {
30
+ if (!MCPManager.instance) {
31
+ MCPManager.instance = new MCPManager();
32
+ }
33
+ return MCPManager.instance;
34
+ }
35
+ setupFileWatcher() {
36
+ try {
37
+ const homeFtDir = path.join(os.homedir(), '.ft');
38
+ if (!fs.existsSync(homeFtDir)) {
39
+ fs.mkdirSync(homeFtDir, { recursive: true });
40
+ }
41
+ fs.watch(homeFtDir, (eventType, filename) => {
42
+ if (filename === 'mcp_servers.json' || !filename) {
43
+ if (this.watcherDebounceTimer)
44
+ clearTimeout(this.watcherDebounceTimer);
45
+ this.watcherDebounceTimer = setTimeout(() => {
46
+ try {
47
+ this.syncWithDisk();
48
+ }
49
+ catch { }
50
+ }, 100);
51
+ }
52
+ });
53
+ }
54
+ catch {
55
+ // Non-fatal if filesystem watcher cannot be established
56
+ }
57
+ }
58
+ /**
59
+ * Synchronize in-memory server configurations with disk storage (~/.ft/mcp_servers.json).
60
+ * Automatically picks up servers added, modified, or removed via CLI or other processes.
61
+ */
62
+ syncWithDisk() {
63
+ const diskServers = loadMCPServers();
64
+ const diskServerMap = new Map();
65
+ for (const s of diskServers) {
66
+ diskServerMap.set(s.id, s);
67
+ }
68
+ // Add or update servers from disk
69
+ for (const [id, diskServer] of diskServerMap.entries()) {
70
+ const existing = this.servers.get(id);
71
+ if (!existing) {
72
+ // New server added from CLI or external process
73
+ if (diskServer.status === 'connecting' || diskServer.status === 'reconnecting') {
74
+ diskServer.status = 'disconnected';
75
+ }
76
+ this.servers.set(id, diskServer);
77
+ }
78
+ else {
79
+ // Server already in memory; update its config & permissions from disk
80
+ // Preserve in-memory client connection if currently alive in this process
81
+ const isConnectedLocally = this.clients.has(id);
82
+ existing.name = diskServer.name;
83
+ existing.description = diskServer.description;
84
+ existing.transport = diskServer.transport;
85
+ existing.endpoint = diskServer.endpoint;
86
+ existing.command = diskServer.command;
87
+ existing.args = diskServer.args;
88
+ existing.env = diskServer.env;
89
+ existing.authType = diskServer.authType;
90
+ existing.authHeader = diskServer.authHeader;
91
+ existing.permissions = diskServer.permissions;
92
+ existing.hasSecret = diskServer.hasSecret;
93
+ existing.userId = diskServer.userId || existing.userId;
94
+ existing.userEmail = diskServer.userEmail || existing.userEmail;
95
+ existing.updatedAt = diskServer.updatedAt;
96
+ if (!isConnectedLocally) {
97
+ existing.status = diskServer.status;
98
+ existing.health = diskServer.health;
99
+ existing.tools = diskServer.tools;
100
+ existing.capabilities = diskServer.capabilities;
101
+ existing.lastConnectedAt = diskServer.lastConnectedAt;
102
+ existing.lastActivityAt = diskServer.lastActivityAt;
103
+ }
104
+ }
105
+ }
106
+ // Remove any servers from memory that were deleted from disk
107
+ for (const id of Array.from(this.servers.keys())) {
108
+ if (!diskServerMap.has(id)) {
109
+ this.clients.get(id)?.disconnect().catch(() => { });
110
+ this.clients.delete(id);
111
+ this.servers.delete(id);
112
+ }
113
+ }
114
+ }
115
+ loadState() {
116
+ const saved = loadMCPServers();
117
+ this.servers.clear();
118
+ for (const s of saved) {
119
+ // Reset in-memory volatile status on startup if was previously connecting
120
+ if (s.status === 'connecting' || s.status === 'reconnecting') {
121
+ s.status = 'disconnected';
122
+ }
123
+ this.servers.set(s.id, s);
124
+ }
125
+ this.initialized = true;
126
+ }
127
+ persistState() {
128
+ const list = Array.from(this.servers.values());
129
+ saveMCPServers(list);
130
+ }
131
+ /**
132
+ * Find server by ID or case-insensitive Name
133
+ */
134
+ getServer(idOrName) {
135
+ if (!idOrName)
136
+ return undefined;
137
+ // Direct ID lookup
138
+ let byId = this.servers.get(idOrName);
139
+ if (byId)
140
+ return byId;
141
+ // Sync with disk in case it was just added in CLI
142
+ this.syncWithDisk();
143
+ byId = this.servers.get(idOrName);
144
+ if (byId)
145
+ return byId;
146
+ // Name lookup
147
+ const lower = idOrName.toLowerCase().trim();
148
+ for (const s of this.servers.values()) {
149
+ if (s.name.toLowerCase() === lower || s.id.toLowerCase() === lower) {
150
+ return s;
151
+ }
152
+ }
153
+ return undefined;
154
+ }
155
+ /**
156
+ * List all registered MCP servers (with secrets sanitized), optionally filtered by user account
157
+ */
158
+ listServers(filterUserEmail, filterUserId) {
159
+ this.syncWithDisk();
160
+ let list = Array.from(this.servers.values());
161
+ // Account-wise filtering if requested
162
+ if (filterUserEmail || filterUserId) {
163
+ const normEmail = filterUserEmail ? filterUserEmail.toLowerCase().trim() : '';
164
+ list = list.filter((s) => {
165
+ if (filterUserId && s.userId === filterUserId)
166
+ return true;
167
+ if (normEmail && s.userEmail && s.userEmail.toLowerCase().trim() === normEmail)
168
+ return true;
169
+ // Allow untagged (legacy/global) servers to be visible to avoid losing access
170
+ if (!s.userId && !s.userEmail)
171
+ return true;
172
+ return false;
173
+ });
174
+ }
175
+ return list.map((s) => sanitizeServerConfig(s));
176
+ }
177
+ /**
178
+ * Register a new MCP server configuration and optionally store credentials
179
+ */
180
+ async addServer(config, secret) {
181
+ const name = config.name.trim();
182
+ const existing = this.getServer(name);
183
+ if (existing) {
184
+ throw new Error(`An MCP server with name "${name}" already exists.`);
185
+ }
186
+ const id = config.id || name.toLowerCase().replace(/[^a-z0-9]/g, '-') || `mcp-${Date.now()}`;
187
+ const now = new Date().toISOString();
188
+ const currentAuth = loadAuthConfig();
189
+ const defaultPermissions = {
190
+ enabled: true,
191
+ allowedTools: [],
192
+ disabledTools: [],
193
+ requireConfirmationForDestructive: true,
194
+ autoApproveSafe: true,
195
+ ...config.permissions,
196
+ };
197
+ const server = {
198
+ id,
199
+ name,
200
+ description: config.description || '',
201
+ transport: config.transport,
202
+ endpoint: config.endpoint,
203
+ command: config.command,
204
+ args: config.args || [],
205
+ env: config.env || {},
206
+ authType: config.authType || (secret ? 'bearer' : 'none'),
207
+ authHeader: config.authHeader,
208
+ userId: config.userId || currentAuth?.user?.id || undefined,
209
+ userEmail: config.userEmail || currentAuth?.user?.email || undefined,
210
+ status: 'disconnected',
211
+ health: {
212
+ status: 'disconnected',
213
+ },
214
+ tools: [],
215
+ permissions: defaultPermissions,
216
+ createdAt: now,
217
+ updatedAt: now,
218
+ };
219
+ this.servers.set(id, server);
220
+ if (secret) {
221
+ saveServerSecret(id, secret);
222
+ server.hasSecret = true;
223
+ }
224
+ this.persistState();
225
+ return sanitizeServerConfig(server);
226
+ }
227
+ /**
228
+ * Remove an MCP server and its stored secrets
229
+ */
230
+ async removeServer(idOrName) {
231
+ const server = this.getServer(idOrName);
232
+ if (!server)
233
+ return false;
234
+ await this.disconnectServer(server.id).catch(() => { });
235
+ deleteServerSecret(server.id);
236
+ this.servers.delete(server.id);
237
+ this.clients.delete(server.id);
238
+ this.persistState();
239
+ return true;
240
+ }
241
+ /**
242
+ * Create an IMCPTransport instance for the given server config
243
+ */
244
+ createTransport(server) {
245
+ if (server.transport === 'http') {
246
+ if (!server.endpoint) {
247
+ throw new Error(`HTTP MCP server "${server.name}" requires an endpoint URL.`);
248
+ }
249
+ const headers = {};
250
+ const secret = getServerSecret(server.id);
251
+ if (secret) {
252
+ const headerName = server.authHeader || 'Authorization';
253
+ if (server.authType === 'bearer' || server.authType === 'oauth') {
254
+ headers[headerName] = `Bearer ${secret}`;
255
+ }
256
+ else if (server.authType === 'api-key') {
257
+ headers[headerName] = secret;
258
+ }
259
+ else {
260
+ headers[headerName] = secret;
261
+ }
262
+ }
263
+ return new HttpMcpTransport(server.endpoint, headers);
264
+ }
265
+ else {
266
+ if (!server.command) {
267
+ throw new Error(`stdio MCP server "${server.name}" requires a command.`);
268
+ }
269
+ return new StdioMcpTransport(server.command, server.args || [], server.env || {});
270
+ }
271
+ }
272
+ /**
273
+ * Connect to an MCP server, perform initialize handshake, and discover tools
274
+ */
275
+ async connectServer(idOrName) {
276
+ const server = this.getServer(idOrName);
277
+ if (!server) {
278
+ throw new Error(`MCP server "${idOrName}" not found.`);
279
+ }
280
+ server.status = 'connecting';
281
+ this.persistState();
282
+ try {
283
+ // Disconnect old client if present
284
+ if (this.clients.has(server.id)) {
285
+ await this.clients.get(server.id)?.disconnect().catch(() => { });
286
+ this.clients.delete(server.id);
287
+ }
288
+ const transport = this.createTransport(server);
289
+ const client = new MCPClient(transport);
290
+ const initResult = await client.connectAndInitialize();
291
+ server.capabilities = initResult.capabilities;
292
+ // Discover and normalize tools
293
+ const rawTools = await client.listTools();
294
+ const normalizedTools = rawTools.map((rt) => normalizeMCPTool(rt, server.id, server.name));
295
+ // Restore disabled state if previously set
296
+ const disabledSet = new Set(server.permissions.disabledTools || []);
297
+ for (const t of normalizedTools) {
298
+ if (disabledSet.has(t.name)) {
299
+ t.enabled = false;
300
+ }
301
+ }
302
+ server.tools = normalizedTools;
303
+ server.status = 'connected';
304
+ server.lastConnectedAt = new Date().toISOString();
305
+ server.updatedAt = new Date().toISOString();
306
+ // Ping for initial latency check
307
+ try {
308
+ const latency = await client.ping();
309
+ server.health = {
310
+ status: 'connected',
311
+ latencyMs: latency,
312
+ lastCheckedAt: new Date().toISOString(),
313
+ };
314
+ }
315
+ catch {
316
+ server.health = {
317
+ status: 'connected',
318
+ lastCheckedAt: new Date().toISOString(),
319
+ };
320
+ }
321
+ this.clients.set(server.id, client);
322
+ this.persistState();
323
+ return sanitizeServerConfig(server);
324
+ }
325
+ catch (err) {
326
+ server.status = 'error';
327
+ server.health = {
328
+ status: 'error',
329
+ error: err?.message || String(err),
330
+ lastCheckedAt: new Date().toISOString(),
331
+ };
332
+ this.persistState();
333
+ throw err;
334
+ }
335
+ }
336
+ /**
337
+ * Disconnect an active MCP server
338
+ */
339
+ async disconnectServer(idOrName) {
340
+ const server = this.getServer(idOrName);
341
+ if (!server) {
342
+ throw new Error(`MCP server "${idOrName}" not found.`);
343
+ }
344
+ const client = this.clients.get(server.id);
345
+ if (client) {
346
+ await client.disconnect().catch(() => { });
347
+ this.clients.delete(server.id);
348
+ }
349
+ server.status = 'disconnected';
350
+ server.health = {
351
+ status: 'disconnected',
352
+ lastCheckedAt: new Date().toISOString(),
353
+ };
354
+ server.updatedAt = new Date().toISOString();
355
+ this.persistState();
356
+ return sanitizeServerConfig(server);
357
+ }
358
+ /**
359
+ * Reconnect to an MCP server
360
+ */
361
+ async reconnectServer(idOrName) {
362
+ const server = this.getServer(idOrName);
363
+ if (!server) {
364
+ throw new Error(`MCP server "${idOrName}" not found.`);
365
+ }
366
+ await this.disconnectServer(server.id).catch(() => { });
367
+ return await this.connectServer(server.id);
368
+ }
369
+ /**
370
+ * Check health and latency for a server, or all connected servers
371
+ */
372
+ async checkHealth(idOrName) {
373
+ this.syncWithDisk();
374
+ const results = {};
375
+ const targets = idOrName
376
+ ? [this.getServer(idOrName)].filter(Boolean)
377
+ : Array.from(this.servers.values());
378
+ for (const server of targets) {
379
+ const client = this.clients.get(server.id);
380
+ const health = await MCPHealthMonitor.checkHealth(server, client);
381
+ server.health = health;
382
+ server.status = health.status;
383
+ results[server.id] = health;
384
+ }
385
+ this.persistState();
386
+ return results;
387
+ }
388
+ /**
389
+ * Retrieve all discovered tools for a server
390
+ */
391
+ getToolsForServer(idOrName) {
392
+ const server = this.getServer(idOrName);
393
+ return server ? server.tools : [];
394
+ }
395
+ /**
396
+ * Retrieve all discovered tools across all servers
397
+ */
398
+ getAllTools(options = {}) {
399
+ const list = [];
400
+ for (const server of this.servers.values()) {
401
+ if (options.onlyEnabled && (server.status !== 'connected' || !server.permissions.enabled)) {
402
+ continue;
403
+ }
404
+ for (const tool of server.tools) {
405
+ if (options.onlyEnabled && !tool.enabled)
406
+ continue;
407
+ list.push(tool);
408
+ }
409
+ }
410
+ return list;
411
+ }
412
+ /**
413
+ * Enable or disable an individual tool on a server
414
+ */
415
+ setToolEnabled(serverId, toolName, enabled) {
416
+ const server = this.getServer(serverId);
417
+ if (!server)
418
+ return false;
419
+ const tool = server.tools.find((t) => t.name === toolName || t.qualifiedName === toolName);
420
+ if (!tool)
421
+ return false;
422
+ tool.enabled = enabled;
423
+ const disabledSet = new Set(server.permissions.disabledTools || []);
424
+ if (enabled) {
425
+ disabledSet.delete(tool.name);
426
+ }
427
+ else {
428
+ disabledSet.add(tool.name);
429
+ }
430
+ server.permissions.disabledTools = Array.from(disabledSet);
431
+ server.updatedAt = new Date().toISOString();
432
+ this.persistState();
433
+ return true;
434
+ }
435
+ /**
436
+ * Update permissions for an MCP server
437
+ */
438
+ setServerPermissions(serverId, permissions) {
439
+ const server = this.getServer(serverId);
440
+ if (!server)
441
+ return false;
442
+ server.permissions = {
443
+ ...server.permissions,
444
+ ...permissions,
445
+ };
446
+ server.updatedAt = new Date().toISOString();
447
+ this.persistState();
448
+ return true;
449
+ }
450
+ /**
451
+ * Toggle a tool enabled/disabled state for a given server
452
+ */
453
+ toggleTool(serverId, toolName, enabled) {
454
+ const server = this.getServer(serverId);
455
+ if (!server)
456
+ return undefined;
457
+ const targetTool = server.tools.find((t) => t.originalName === toolName || t.name === toolName);
458
+ if (!targetTool)
459
+ return undefined;
460
+ targetTool.enabled = enabled !== undefined ? enabled : !targetTool.enabled;
461
+ const disabledList = new Set(server.permissions.disabledTools || []);
462
+ if (targetTool.enabled) {
463
+ disabledList.delete(targetTool.originalName);
464
+ disabledList.delete(targetTool.name);
465
+ }
466
+ else {
467
+ disabledList.add(targetTool.originalName);
468
+ }
469
+ server.permissions.disabledTools = Array.from(disabledList);
470
+ server.updatedAt = new Date().toISOString();
471
+ this.persistState();
472
+ return sanitizeServerConfig(server);
473
+ }
474
+ /**
475
+ * Route user request to relevant MCP tools
476
+ */
477
+ routeTools(userGoal) {
478
+ this.syncWithDisk();
479
+ return MCPToolRouter.routeTools(userGoal, Array.from(this.servers.values()));
480
+ }
481
+ /**
482
+ * Find tool by either its qualified name (mcp__<serverId>__<toolName>) or its original name
483
+ */
484
+ findTool(nameOrQualified) {
485
+ this.syncWithDisk();
486
+ for (const server of this.servers.values()) {
487
+ for (const tool of server.tools) {
488
+ if (tool.qualifiedName === nameOrQualified || tool.name === nameOrQualified) {
489
+ return { tool, server };
490
+ }
491
+ }
492
+ }
493
+ return undefined;
494
+ }
495
+ /**
496
+ * Execute an MCP tool by name, handling permissions, connection, retry, and telemetry
497
+ */
498
+ async executeTool(nameOrQualified, args = {}) {
499
+ const startedAt = new Date().toISOString();
500
+ const startTime = Date.now();
501
+ const invocationId = `mcp_inv_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
502
+ const target = this.findTool(nameOrQualified);
503
+ if (!target) {
504
+ const err = `Tool "${nameOrQualified}" is not registered on any connected MCP server.`;
505
+ const result = {
506
+ serverId: 'unknown',
507
+ serverName: 'Unknown',
508
+ toolName: nameOrQualified,
509
+ invocationId,
510
+ startedAt,
511
+ completedAt: new Date().toISOString(),
512
+ status: 'error',
513
+ inputSummary: JSON.stringify(args),
514
+ outputSummary: err,
515
+ result: null,
516
+ error: err,
517
+ executionDurationMs: Date.now() - startTime,
518
+ };
519
+ MCPTelemetry.recordResult(result);
520
+ return result;
521
+ }
522
+ const { tool, server } = target;
523
+ // 1. Enforce Permissions
524
+ const perm = MCPPermissionManager.checkToolAccess(server, tool);
525
+ if (!perm.allowed) {
526
+ const blockedReason = perm.reason || `Tool "${tool.name}" is restricted by Scout permissions.`;
527
+ const result = {
528
+ serverId: server.id,
529
+ serverName: server.name,
530
+ toolName: tool.name,
531
+ invocationId,
532
+ startedAt,
533
+ completedAt: new Date().toISOString(),
534
+ status: 'blocked',
535
+ inputSummary: JSON.stringify(args),
536
+ outputSummary: `[MCP PERMISSION BLOCKED]: ${blockedReason}`,
537
+ result: { success: false, error: 'PermissionBlocked', message: blockedReason },
538
+ error: blockedReason,
539
+ executionDurationMs: Date.now() - startTime,
540
+ };
541
+ MCPTelemetry.recordResult(result);
542
+ return result;
543
+ }
544
+ // 2. Ensure Client is connected
545
+ let client = this.clients.get(server.id);
546
+ if (!client || !client.isConnected()) {
547
+ try {
548
+ await this.connectServer(server.id);
549
+ client = this.clients.get(server.id);
550
+ }
551
+ catch (connErr) {
552
+ const classified = MCPRecovery.classifyError(connErr, server.name, tool.permissionLevel === 'destructive');
553
+ const result = {
554
+ serverId: server.id,
555
+ serverName: server.name,
556
+ toolName: tool.name,
557
+ invocationId,
558
+ startedAt,
559
+ completedAt: new Date().toISOString(),
560
+ status: 'error',
561
+ inputSummary: JSON.stringify(args),
562
+ outputSummary: classified.userFriendlyMessage,
563
+ result: null,
564
+ error: classified.originalError,
565
+ executionDurationMs: Date.now() - startTime,
566
+ };
567
+ MCPTelemetry.recordResult(result);
568
+ return result;
569
+ }
570
+ }
571
+ if (!client) {
572
+ throw new Error(`Could not obtain active client for MCP server "${server.name}".`);
573
+ }
574
+ // 3. Execute with bounded recovery
575
+ let execResult = null;
576
+ let execError = null;
577
+ const maxRetries = tool.permissionLevel === 'read' ? 1 : 0;
578
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
579
+ try {
580
+ execResult = await client.callTool(tool.originalName, args);
581
+ execError = null;
582
+ break;
583
+ }
584
+ catch (callErr) {
585
+ execError = callErr;
586
+ const classified = MCPRecovery.classifyError(callErr, server.name, tool.permissionLevel === 'destructive');
587
+ if (classified.isRecoverable && classified.canRetry && attempt < maxRetries) {
588
+ // Try reconnecting once before final retry
589
+ await this.reconnectServer(server.id).catch(() => { });
590
+ client = this.clients.get(server.id) || client;
591
+ continue;
592
+ }
593
+ break;
594
+ }
595
+ }
596
+ server.lastActivityAt = new Date().toISOString();
597
+ this.persistState();
598
+ const completedAt = new Date().toISOString();
599
+ const duration = Date.now() - startTime;
600
+ if (execError) {
601
+ const classified = MCPRecovery.classifyError(execError, server.name, tool.permissionLevel === 'destructive');
602
+ const result = {
603
+ serverId: server.id,
604
+ serverName: server.name,
605
+ toolName: tool.name,
606
+ invocationId,
607
+ startedAt,
608
+ completedAt,
609
+ status: classified.category === 'TIMEOUT' ? 'timeout' : 'error',
610
+ inputSummary: JSON.stringify(args),
611
+ outputSummary: classified.userFriendlyMessage,
612
+ result: null,
613
+ error: classified.originalError,
614
+ executionDurationMs: duration,
615
+ };
616
+ MCPTelemetry.recordResult(result);
617
+ return result;
618
+ }
619
+ const outSummary = typeof execResult === 'string'
620
+ ? execResult.slice(0, 300)
621
+ : (execResult?.text ? String(execResult.text).slice(0, 300) : JSON.stringify(execResult).slice(0, 300));
622
+ const successRecord = {
623
+ serverId: server.id,
624
+ serverName: server.name,
625
+ toolName: tool.name,
626
+ invocationId,
627
+ startedAt,
628
+ completedAt,
629
+ status: 'success',
630
+ inputSummary: JSON.stringify(args),
631
+ outputSummary: outSummary,
632
+ result: execResult,
633
+ executionDurationMs: duration,
634
+ };
635
+ MCPTelemetry.recordResult(successRecord);
636
+ return successRecord;
637
+ }
638
+ /**
639
+ * Get recent observable activity
640
+ */
641
+ getActivityLog(limit = 50) {
642
+ return MCPTelemetry.loadActivity(limit);
643
+ }
644
+ }
645
+ export function getMCPManager() {
646
+ return MCPManager.getInstance();
647
+ }
648
+ //# sourceMappingURL=manager.js.map