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.
- package/.firebase/hosting.d2Vi.cache +8 -8
- package/AUTONOMOUS_FEATURES.md +1 -1
- package/README.md +113 -16
- package/bin/src/commands/agent.d.ts.map +1 -1
- package/bin/src/commands/agent.js +28 -15
- package/bin/src/commands/agent.js.map +1 -1
- package/bin/src/commands/audit.js +1 -1
- package/bin/src/commands/audit.js.map +1 -1
- package/bin/src/commands/dashboard.js +1 -1
- package/bin/src/commands/dashboard.js.map +1 -1
- package/bin/src/commands/mcp.d.ts.map +1 -0
- package/bin/src/commands/mcp.js +393 -0
- package/bin/src/commands/mcp.js.map +1 -0
- package/bin/src/commands/owners.js +1 -1
- package/bin/src/commands/owners.js.map +1 -1
- package/bin/src/discovery/messages.js +2 -2
- package/bin/src/discovery/messages.js.map +1 -1
- package/bin/src/discovery/signals.js +3 -3
- package/bin/src/discovery/signals.js.map +1 -1
- package/bin/src/engine/agentEngine.d.ts.map +1 -1
- package/bin/src/engine/agentEngine.js +111 -32
- package/bin/src/engine/agentEngine.js.map +1 -1
- package/bin/src/engine/runtime/agentRuntime.d.ts.map +1 -1
- package/bin/src/engine/runtime/agentRuntime.js +40 -3
- package/bin/src/engine/runtime/agentRuntime.js.map +1 -1
- package/bin/src/engine/toolRegistry.d.ts.map +1 -1
- package/bin/src/engine/toolRegistry.js +13 -0
- package/bin/src/engine/toolRegistry.js.map +1 -1
- package/bin/src/engine/verifier.js +2 -2
- package/bin/src/engine/verifier.js.map +1 -1
- package/bin/src/index.js +85 -7
- package/bin/src/index.js.map +1 -1
- package/bin/src/mcp/client.d.ts.map +1 -0
- package/bin/src/mcp/client.js +127 -0
- package/bin/src/mcp/client.js.map +1 -0
- package/bin/src/mcp/discovery.d.ts.map +1 -0
- package/bin/src/mcp/discovery.js +91 -0
- package/bin/src/mcp/discovery.js.map +1 -0
- package/bin/src/mcp/health.d.ts.map +1 -0
- package/bin/src/mcp/health.js +44 -0
- package/bin/src/mcp/health.js.map +1 -0
- package/bin/src/mcp/index.d.ts.map +1 -0
- package/bin/src/mcp/index.js +15 -0
- package/bin/src/mcp/index.js.map +1 -0
- package/bin/src/mcp/manager.d.ts.map +1 -0
- package/bin/src/mcp/manager.js +648 -0
- package/bin/src/mcp/manager.js.map +1 -0
- package/bin/src/mcp/permissions.d.ts.map +1 -0
- package/bin/src/mcp/permissions.js +50 -0
- package/bin/src/mcp/permissions.js.map +1 -0
- package/bin/src/mcp/persistence.d.ts.map +1 -0
- package/bin/src/mcp/persistence.js +171 -0
- package/bin/src/mcp/persistence.js.map +1 -0
- package/bin/src/mcp/recovery.d.ts.map +1 -0
- package/bin/src/mcp/recovery.js +75 -0
- package/bin/src/mcp/recovery.js.map +1 -0
- package/bin/src/mcp/telemetry.d.ts.map +1 -0
- package/bin/src/mcp/telemetry.js +82 -0
- package/bin/src/mcp/telemetry.js.map +1 -0
- package/bin/src/mcp/toolRouter.d.ts.map +1 -0
- package/bin/src/mcp/toolRouter.js +127 -0
- package/bin/src/mcp/toolRouter.js.map +1 -0
- package/bin/src/mcp/transport.d.ts.map +1 -0
- package/bin/src/mcp/transport.js +328 -0
- package/bin/src/mcp/transport.js.map +1 -0
- package/bin/src/mcp/types.d.ts.map +1 -0
- package/bin/src/mcp/types.js +6 -0
- package/bin/src/mcp/types.js.map +1 -0
- package/bin/src/server/server.d.ts.map +1 -1
- package/bin/src/server/server.js +223 -10
- package/bin/src/server/server.js.map +1 -1
- package/bin/src/utils/admin.d.ts.map +1 -1
- package/bin/src/utils/admin.js +5 -11
- package/bin/src/utils/admin.js.map +1 -1
- package/bin/src/utils/branding.js +1 -1
- package/bin/src/utils/email.js +4 -4
- package/bin/src/utils/session.d.ts.map +1 -1
- package/bin/src/utils/session.js +8 -7
- package/bin/src/utils/session.js.map +1 -1
- package/package.json +1 -1
- package/web/app.js +1493 -75
- 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
|