@causyne/agent 1.10.13 → 1.10.17

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 (59) hide show
  1. package/docs/ARCHITECTURE.md +101 -17
  2. package/docs/SECURITY_REVIEW.md +13 -1
  3. package/docs/THIRD_PARTY_NOTICES.md +43 -10
  4. package/package.json +10 -4
  5. package/src/agentLoop.js +25 -3
  6. package/src/agentProfiles.js +135 -0
  7. package/src/agentProfiles.ts +143 -0
  8. package/src/approvalGrants.js +17 -35
  9. package/src/approvalGrants.ts +54 -0
  10. package/src/auditLog.d.ts +3 -0
  11. package/src/browser/camofoxRuntime.js +93 -0
  12. package/src/browser/camofoxRuntime.ts +112 -0
  13. package/src/browser/causyneBrowser.js +495 -0
  14. package/src/browser/causyneBrowser.ts +594 -0
  15. package/src/browser/searchProviders.js +156 -0
  16. package/src/browser/searchProviders.ts +156 -0
  17. package/src/browser/snapshotWindow.js +28 -0
  18. package/src/browser/snapshotWindow.ts +40 -0
  19. package/src/browser/urlPolicy.js +89 -0
  20. package/src/browser/urlPolicy.ts +89 -0
  21. package/src/checkpoints.js +19 -6
  22. package/src/commands.js +37 -8
  23. package/src/config.d.ts +2 -0
  24. package/src/hooks.js +121 -107
  25. package/src/hooks.ts +193 -0
  26. package/src/mcp/mcpClient.js +520 -132
  27. package/src/mcp/mcpClient.ts +584 -0
  28. package/src/mcp/mcpRegistry.js +399 -185
  29. package/src/mcp/mcpRegistry.ts +451 -0
  30. package/src/planCache.js +473 -450
  31. package/src/planCache.ts +840 -0
  32. package/src/plugins/pluginManager.js +40 -5
  33. package/src/plugins/pluginSkills.js +84 -0
  34. package/src/plugins/pluginSkills.ts +106 -0
  35. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  36. package/src/provisioning/capabilityProvisioner.js +5 -0
  37. package/src/provisioning/capabilityTools.js +2 -1
  38. package/src/sandbox.js +32 -2
  39. package/src/sessionJournal.js +471 -0
  40. package/src/sessionJournal.ts +496 -0
  41. package/src/sessionStore.js +3 -279
  42. package/src/skillsManager.js +18 -0
  43. package/src/subagentJournal.js +215 -0
  44. package/src/subagentJournal.ts +254 -0
  45. package/src/taskShape.js +179 -198
  46. package/src/taskShape.ts +259 -0
  47. package/src/tools/browserAutomator.js +45 -241
  48. package/src/tools/browserAutomator.ts +57 -0
  49. package/src/tools/manageLocalAiDependencies.js +1 -1
  50. package/src/tools/subagentOrchestrator.js +73 -12
  51. package/src/tools/webFetch.js +183 -68
  52. package/src/tools/webFetch.ts +185 -0
  53. package/src/tools/webSearch.js +50 -75
  54. package/src/tools/webSearch.ts +56 -0
  55. package/src/types/contracts.js +8 -0
  56. package/src/types/contracts.ts +263 -0
  57. package/src/utils/paths.d.ts +3 -0
  58. package/src/version.d.ts +1 -0
  59. package/src/mcp/jsonRpc.js +0 -165
@@ -1,207 +1,421 @@
1
- // The bridge between user-configured MCP servers and Causyne's tool catalogue.
2
- //
3
- // Discovered tools are namespaced `mcp__<server>__<tool>`, which is what keeps
4
- // them from ever colliding with a native tool name — and makes it obvious in a
5
- // transcript, an audit log and an approval prompt that the call is leaving
6
- // Causyne's own code.
7
-
8
- import { McpClient } from './mcpClient.js';
1
+ import { createHash } from 'node:crypto';
2
+ import { McpClient, McpConnectionError } from './mcpClient.js';
9
3
  import { loadConfig, saveConfig } from '../config.js';
10
4
  import { auditLog } from '../auditLog.js';
11
-
12
5
  const NAMESPACE = 'mcp__';
13
- const connected = new Map(); // server name -> McpClient
14
-
15
- /** Server definitions the USER configured. Never model-supplied. */
6
+ const RESOURCE_LIST_TOOL = '_causyne_resources_list';
7
+ const RESOURCE_READ_TOOL = '_causyne_resource_read';
8
+ const PROMPT_LIST_TOOL = '_causyne_prompts_list';
9
+ const PROMPT_GET_TOOL = '_causyne_prompt_get';
10
+ const connected = new Map();
11
+ const connecting = new Map();
12
+ const statuses = new Map();
13
+ const connectionEpochs = new Map();
14
+ function isRecord(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+ function cloneStringRecord(value) {
18
+ if (!isRecord(value))
19
+ return undefined;
20
+ const result = {};
21
+ for (const [key, item] of Object.entries(value)) {
22
+ if (typeof item === 'string')
23
+ result[key] = item;
24
+ }
25
+ return Object.keys(result).length > 0 ? result : undefined;
26
+ }
27
+ function cloneStringArray(value) {
28
+ if (!Array.isArray(value))
29
+ return undefined;
30
+ return value.filter((item) => typeof item === 'string');
31
+ }
32
+ function normalizeDefinition(value) {
33
+ if (!isRecord(value) || typeof value.name !== 'string')
34
+ return null;
35
+ const definition = { name: value.name };
36
+ if (typeof value.command === 'string')
37
+ definition.command = value.command;
38
+ if (typeof value.url === 'string')
39
+ definition.url = value.url;
40
+ const args = cloneStringArray(value.args);
41
+ const env = cloneStringRecord(value.env);
42
+ const headers = cloneStringRecord(value.headers);
43
+ if (args)
44
+ definition.args = args;
45
+ if (env)
46
+ definition.env = env;
47
+ if (headers)
48
+ definition.headers = headers;
49
+ if (typeof value.cwd === 'string')
50
+ definition.cwd = value.cwd;
51
+ if (typeof value.enabled === 'boolean')
52
+ definition.enabled = value.enabled;
53
+ if (typeof value.timeoutMs === 'number')
54
+ definition.timeoutMs = value.timeoutMs;
55
+ if (value.protocol === 'legacy' || value.protocol === 'auto')
56
+ definition.protocol = value.protocol;
57
+ if (isRecord(value.tools)) {
58
+ const include = cloneStringArray(value.tools.include);
59
+ const exclude = cloneStringArray(value.tools.exclude);
60
+ if (include || exclude) {
61
+ definition.tools = {};
62
+ if (include)
63
+ definition.tools.include = include;
64
+ if (exclude)
65
+ definition.tools.exclude = exclude;
66
+ }
67
+ }
68
+ return McpClient.validateDefinition(definition) === null ? definition : null;
69
+ }
70
+ function canonicalize(value) {
71
+ if (Array.isArray(value))
72
+ return value.map(canonicalize);
73
+ if (!isRecord(value))
74
+ return value;
75
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalize(value[key])]));
76
+ }
77
+ export function definitionDigest(definition) {
78
+ return createHash('sha256').update(JSON.stringify(canonicalize(definition))).digest('hex');
79
+ }
80
+ function consentRecord(value) {
81
+ if (!isRecord(value) || typeof value.digest !== 'string' || typeof value.grantedAt !== 'number')
82
+ return null;
83
+ return { digest: value.digest, grantedAt: value.grantedAt };
84
+ }
85
+ function globMatches(name, pattern) {
86
+ const source = pattern
87
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
88
+ .replaceAll('*', '.*')
89
+ .replaceAll('?', '.');
90
+ return new RegExp(`^${source}$`, 'u').test(name);
91
+ }
92
+ function toolAllowed(server, toolName) {
93
+ const include = server.tools?.include;
94
+ const exclude = server.tools?.exclude;
95
+ if (include && !include.some(pattern => globMatches(toolName, pattern)))
96
+ return false;
97
+ if (exclude?.some(pattern => globMatches(toolName, pattern)))
98
+ return false;
99
+ return true;
100
+ }
101
+ function utilityDefinitions(server, client) {
102
+ const definitions = [];
103
+ if (client.supportsResources()) {
104
+ definitions.push({
105
+ name: toolName(server.name, RESOURCE_LIST_TOOL),
106
+ description: `[MCP: ${server.name}] List the resources and resource templates exposed by this server.`,
107
+ parameters: { type: 'object', properties: {}, additionalProperties: false }
108
+ }, {
109
+ name: toolName(server.name, RESOURCE_READ_TOOL),
110
+ description: `[MCP: ${server.name}] Read one resource by its exact URI.`,
111
+ parameters: {
112
+ type: 'object',
113
+ properties: { uri: { type: 'string', description: 'Exact MCP resource URI.' } },
114
+ required: ['uri'],
115
+ additionalProperties: false
116
+ }
117
+ });
118
+ }
119
+ if (client.supportsPrompts()) {
120
+ definitions.push({
121
+ name: toolName(server.name, PROMPT_LIST_TOOL),
122
+ description: `[MCP: ${server.name}] List the prompts exposed by this server.`,
123
+ parameters: { type: 'object', properties: {}, additionalProperties: false }
124
+ }, {
125
+ name: toolName(server.name, PROMPT_GET_TOOL),
126
+ description: `[MCP: ${server.name}] Get a prompt by name with optional string arguments.`,
127
+ parameters: {
128
+ type: 'object',
129
+ properties: {
130
+ name: { type: 'string', description: 'Exact MCP prompt name.' },
131
+ arguments: { type: 'object', additionalProperties: { type: 'string' } }
132
+ },
133
+ required: ['name'],
134
+ additionalProperties: false
135
+ }
136
+ });
137
+ }
138
+ return definitions.filter(definition => toolAllowed(server, parseToolName(definition.name)?.tool ?? ''));
139
+ }
140
+ function config() {
141
+ return loadConfig();
142
+ }
143
+ function connectionEpoch(name) {
144
+ return connectionEpochs.get(name) ?? 0;
145
+ }
146
+ function invalidateConnection(name) {
147
+ connectionEpochs.set(name, connectionEpoch(name) + 1);
148
+ }
149
+ /** Server definitions the user configured. Model output never reaches this source. */
16
150
  export function configuredServers() {
17
- const config = loadConfig();
18
- const servers = Array.isArray(config.mcpServers) ? config.mcpServers : [];
19
- return servers.filter(s => McpClient.validateDefinition(s) === null);
20
- }
21
-
22
-
23
- export function addServer(definition) {
24
- const problem = McpClient.validateDefinition(definition);
25
- if (problem) return { ok: false, error: problem };
26
-
27
- const config = loadConfig();
28
- const servers = Array.isArray(config.mcpServers) ? [...config.mcpServers] : [];
29
-
30
- if (servers.some(s => s && s.name === definition.name)) {
31
- return { ok: false, error: `a server named "${definition.name}" is already configured` };
32
- }
33
-
34
- // Store only the fields the client actually reads. Anything else a caller
35
- // sends is dropped rather than persisted into the user's config.
36
- const stored = { name: definition.name };
37
- if (definition.url) {
38
- stored.url = definition.url;
39
- if (definition.headers && typeof definition.headers === 'object') stored.headers = definition.headers;
40
- } else {
41
- stored.command = definition.command;
42
- if (Array.isArray(definition.args)) stored.args = definition.args;
43
- if (definition.env && typeof definition.env === 'object') stored.env = definition.env;
44
- if (typeof definition.cwd === 'string') stored.cwd = definition.cwd;
45
- }
46
-
47
- config.mcpServers = [...servers, stored];
48
- // Registering is not consenting: leave it ungranted.
49
- config.mcpConsent = { ...(config.mcpConsent || {}), [definition.name]: false };
50
- saveConfig(config);
51
-
52
- auditLog.log({ event: 'mcp_server_added', server: definition.name, transport: definition.url ? 'http' : 'stdio' });
53
- return { ok: true };
54
- }
55
-
56
- /** Remove a server, drop its consent, and close any live connection. */
151
+ const servers = config().mcpServers;
152
+ if (!Array.isArray(servers))
153
+ return [];
154
+ return servers.map(normalizeDefinition).filter((server) => server !== null);
155
+ }
156
+ export function addServer(value) {
157
+ const problem = McpClient.validateDefinition(value);
158
+ if (problem)
159
+ return { ok: false, error: problem };
160
+ const definition = normalizeDefinition(value);
161
+ if (!definition)
162
+ return { ok: false, error: 'invalid server definition' };
163
+ const current = config();
164
+ const servers = Array.isArray(current.mcpServers) ? [...current.mcpServers] : [];
165
+ if (servers.some(server => isRecord(server) && server.name === definition.name)) {
166
+ return { ok: false, error: `a server named "${definition.name}" is already configured` };
167
+ }
168
+ current.mcpServers = [...servers, definition];
169
+ current.mcpConsent = { ...(current.mcpConsent ?? {}), [definition.name]: false };
170
+ saveConfig(current);
171
+ statuses.set(definition.name, definition.enabled === false ? { state: 'disabled' } : { state: 'disconnected' });
172
+ auditLog.log({ event: 'mcp_server_added', server: definition.name, transport: definition.url ? 'http' : 'stdio' });
173
+ return { ok: true };
174
+ }
57
175
  export function removeServer(name) {
58
- const config = loadConfig();
59
- const servers = Array.isArray(config.mcpServers) ? config.mcpServers : [];
60
- if (!servers.some(s => s && s.name === name)) {
61
- return { ok: false, error: `no server named "${name}" is configured` };
62
- }
63
-
64
- config.mcpServers = servers.filter(s => !s || s.name !== name);
65
- const consent = { ...(config.mcpConsent || {}) };
66
- delete consent[name];
67
- config.mcpConsent = consent;
68
- saveConfig(config);
69
-
70
- const client = connected.get(name);
71
- if (client) { try { client.close(); } catch (e) {} connected.delete(name); }
72
-
73
- auditLog.log({ event: 'mcp_server_removed', server: name });
74
- return { ok: true };
75
- }
76
-
176
+ const current = config();
177
+ const servers = Array.isArray(current.mcpServers) ? current.mcpServers : [];
178
+ if (!servers.some(server => isRecord(server) && server.name === name)) {
179
+ return { ok: false, error: `no server named "${name}" is configured` };
180
+ }
181
+ current.mcpServers = servers.filter(server => !isRecord(server) || server.name !== name);
182
+ const consent = { ...(current.mcpConsent ?? {}) };
183
+ delete consent[name];
184
+ current.mcpConsent = consent;
185
+ saveConfig(current);
186
+ void disconnectServer(name);
187
+ statuses.delete(name);
188
+ auditLog.log({ event: 'mcp_server_removed', server: name });
189
+ return { ok: true };
190
+ }
77
191
  export function isServerGranted(name) {
78
- const config = loadConfig();
79
- return (config.mcpConsent || {})[name] === true;
192
+ const definition = configuredServers().find(server => server.name === name);
193
+ if (!definition || definition.enabled === false)
194
+ return false;
195
+ const consent = consentRecord(config().mcpConsent?.[name]);
196
+ return consent?.digest === definitionDigest(definition);
80
197
  }
81
-
82
198
  export function grantServer(name) {
83
- const config = loadConfig();
84
- config.mcpConsent = { ...(config.mcpConsent || {}), [name]: true };
85
- saveConfig(config);
86
- // The consent boundary is exactly what a post-hoc review needs to see.
87
- auditLog.log({ event: 'mcp_server_granted', server: name });
199
+ const definition = configuredServers().find(server => server.name === name);
200
+ if (!definition)
201
+ return { ok: false, error: `no server named "${name}" is configured` };
202
+ const current = config();
203
+ const digest = definitionDigest(definition);
204
+ current.mcpConsent = {
205
+ ...(current.mcpConsent ?? {}),
206
+ [name]: { digest, grantedAt: Date.now() }
207
+ };
208
+ saveConfig(current);
209
+ statuses.set(name, definition.enabled === false ? { state: 'disabled' } : { state: 'disconnected' });
210
+ auditLog.log({ event: 'mcp_server_granted', server: name, definitionDigest: digest.slice(0, 12) });
211
+ return { ok: true };
88
212
  }
89
-
90
213
  export function revokeServer(name) {
91
- const config = loadConfig();
92
- config.mcpConsent = { ...(config.mcpConsent || {}), [name]: false };
93
- saveConfig(config);
94
- const client = connected.get(name);
95
- if (client) { client.close(); connected.delete(name); }
96
- auditLog.log({ event: 'mcp_server_revoked', server: name });
97
- }
98
-
214
+ const current = config();
215
+ current.mcpConsent = { ...(current.mcpConsent ?? {}), [name]: false };
216
+ saveConfig(current);
217
+ void disconnectServer(name);
218
+ statuses.set(name, { state: 'disconnected' });
219
+ auditLog.log({ event: 'mcp_server_revoked', server: name });
220
+ }
99
221
  export function toolName(serverName, tool) {
100
- return `${NAMESPACE}${serverName}__${tool}`;
222
+ return `${NAMESPACE}${serverName}__${tool}`;
101
223
  }
102
-
103
224
  export function isMcpToolName(name) {
104
- return typeof name === 'string' && name.startsWith(NAMESPACE);
225
+ return typeof name === 'string' && name.startsWith(NAMESPACE);
105
226
  }
106
-
107
- /** Split `mcp__server__tool` back into its parts. Tool names may contain `__`. */
108
227
  export function parseToolName(name) {
109
- if (!isMcpToolName(name)) return null;
110
- const rest = name.slice(NAMESPACE.length);
111
- const separator = rest.indexOf('__');
112
- if (separator === -1) return null;
113
- return { server: rest.slice(0, separator), tool: rest.slice(separator + 2) };
114
- }
115
-
116
- /**
117
- * Connect to a granted server, or return the live client.
118
- * Connecting is never automatic: the caller has already checked consent.
119
- */
228
+ if (!isMcpToolName(name))
229
+ return null;
230
+ const rest = name.slice(NAMESPACE.length);
231
+ const separator = rest.indexOf('__');
232
+ if (separator <= 0 || separator === rest.length - 2)
233
+ return null;
234
+ return { server: rest.slice(0, separator), tool: rest.slice(separator + 2) };
235
+ }
120
236
  export async function connectServer(name) {
121
- if (connected.has(name)) return connected.get(name);
122
- const definition = configuredServers().find(s => s.name === name);
123
- if (!definition) throw new Error(`no MCP server named "${name}" is configured`);
124
- const client = await new McpClient(definition).connect();
125
- connected.set(name, client);
126
- return client;
127
- }
128
-
129
- /**
130
- * Tool schemas for every granted, reachable server.
131
- *
132
- * A server that fails to start is skipped silently rather than failing the
133
- * whole catalogue — one broken third-party server must not take the agent down.
134
- */
135
- export async function discoverTools() {
136
- const definitions = [];
137
- for (const server of configuredServers()) {
138
- if (!isServerGranted(server.name)) continue;
139
- let client;
140
- try {
141
- client = await connectServer(server.name);
142
- } catch (e) {
143
- console.warn(`[MCP] ${server.name} unavailable: ${e.message}`);
144
- continue;
237
+ const definition = configuredServers().find(server => server.name === name);
238
+ if (!definition)
239
+ throw new Error(`no MCP server named "${name}" is configured`);
240
+ if (definition.enabled === false) {
241
+ statuses.set(name, { state: 'disabled' });
242
+ throw new Error(`MCP server "${name}" is disabled`);
145
243
  }
146
- for (const tool of client.tools) {
147
- definitions.push({
148
- name: toolName(server.name, tool.name),
149
- description: `[MCP: ${server.name}] ${tool.description}`.trim(),
150
- parameters: tool.inputSchema
151
- });
244
+ if (!isServerGranted(name))
245
+ throw new Error(`MCP server "${name}" has not been granted for its current configuration`);
246
+ const live = connected.get(name);
247
+ if (live && live.status.state === 'connected' && definitionDigest(live.definition) === definitionDigest(definition)) {
248
+ return live;
152
249
  }
153
- }
154
- return definitions;
155
- }
156
-
157
- /**
158
- * A tool object the registry can execute, for any `mcp__…` name.
159
- * Returns null for a name that is not MCP, or whose server is not granted.
160
- */
250
+ if (live)
251
+ await disconnectServer(name);
252
+ const pending = connecting.get(name);
253
+ if (pending)
254
+ return pending;
255
+ const startedAtEpoch = connectionEpoch(name);
256
+ let promise;
257
+ promise = (async () => {
258
+ const client = new McpClient(definition, {
259
+ workspace: process.cwd(),
260
+ onStatus: status => {
261
+ if (connectionEpoch(name) !== startedAtEpoch)
262
+ return;
263
+ statuses.set(name, status);
264
+ if (status.state === 'failed' || status.state === 'needs_auth')
265
+ connected.delete(name);
266
+ }
267
+ });
268
+ statuses.set(name, { state: 'connecting' });
269
+ try {
270
+ await client.connect();
271
+ const current = configuredServers().find(server => server.name === name);
272
+ const connectionIsCurrent = connectionEpoch(name) === startedAtEpoch
273
+ && current !== undefined
274
+ && definitionDigest(current) === definitionDigest(definition)
275
+ && isServerGranted(name);
276
+ if (!connectionIsCurrent) {
277
+ await client.close();
278
+ throw new Error(`MCP server "${name}" connection was cancelled`);
279
+ }
280
+ connected.set(name, client);
281
+ statuses.set(name, client.status);
282
+ return client;
283
+ }
284
+ catch (error) {
285
+ const status = error instanceof McpConnectionError
286
+ ? { state: error.state, error: error.message }
287
+ : { state: 'failed', error: error instanceof Error ? error.message : String(error) };
288
+ if (connectionEpoch(name) === startedAtEpoch)
289
+ statuses.set(name, status);
290
+ throw error;
291
+ }
292
+ finally {
293
+ if (connecting.get(name) === promise)
294
+ connecting.delete(name);
295
+ }
296
+ })();
297
+ connecting.set(name, promise);
298
+ return promise;
299
+ }
300
+ export async function disconnectServer(name) {
301
+ invalidateConnection(name);
302
+ const client = connected.get(name);
303
+ connected.delete(name);
304
+ connecting.delete(name);
305
+ if (client)
306
+ await client.close();
307
+ if (configuredServers().some(server => server.name === name))
308
+ statuses.set(name, { state: 'disconnected' });
309
+ }
310
+ export async function refreshServer(name) {
311
+ const client = await connectServer(name);
312
+ return client.refreshTools();
313
+ }
314
+ export async function discoverTools() {
315
+ const granted = configuredServers().filter(server => isServerGranted(server.name));
316
+ const results = await Promise.all(granted.map(async (server) => {
317
+ try {
318
+ const client = await connectServer(server.name);
319
+ return client.tools
320
+ .filter(tool => toolAllowed(server, tool.name))
321
+ .map(tool => ({
322
+ name: toolName(server.name, tool.name),
323
+ description: `[MCP: ${server.name}] ${tool.description}`.trim(),
324
+ parameters: tool.inputSchema
325
+ }))
326
+ .concat(utilityDefinitions(server, client));
327
+ }
328
+ catch (error) {
329
+ const message = error instanceof Error ? error.message : String(error);
330
+ console.warn(`[MCP] ${server.name} unavailable: ${message}`);
331
+ return [];
332
+ }
333
+ }));
334
+ return results.flat();
335
+ }
161
336
  export function resolveMcpTool(name) {
162
- const parsed = parseToolName(name);
163
- if (!parsed) return null;
164
- if (!configuredServers().some(s => s.name === parsed.server)) return null;
165
-
166
- return {
167
- schema: { name, description: `MCP tool ${parsed.tool} on server ${parsed.server}`, parameters: { type: 'object', properties: {} } },
168
- async execute(args) {
169
- if (!isServerGranted(parsed.server)) {
170
- return {
171
- success: false,
172
- requiresConsent: true,
173
- error: `The MCP server "${parsed.server}" is configured but not enabled. ` +
174
- `Call manage_local_dependencies with action "grant_mcp_server" and group "${parsed.server}" ` +
175
- `to request the user's approval.`
176
- };
177
- }
178
- try {
179
- const client = await connectServer(parsed.server);
180
- return await client.callTool(parsed.tool, args);
181
- } catch (e) {
182
- return { success: false, error: `MCP server "${parsed.server}": ${e.message}` };
183
- }
184
- }
185
- };
337
+ const parsed = parseToolName(name);
338
+ if (!parsed)
339
+ return null;
340
+ const definition = configuredServers().find(server => server.name === parsed.server);
341
+ if (!definition)
342
+ return null;
343
+ return {
344
+ schema: {
345
+ name: name,
346
+ description: `MCP tool ${parsed.tool} on server ${parsed.server}`,
347
+ parameters: { type: 'object', properties: {} }
348
+ },
349
+ async execute(args, context = {}) {
350
+ if (!isServerGranted(parsed.server)) {
351
+ return {
352
+ success: false,
353
+ requiresConsent: true,
354
+ error: `The MCP server "${parsed.server}" is configured but not enabled for its current definition. ` +
355
+ `Request approval with manage_local_dependencies action "grant_mcp_server" and group "${parsed.server}".`
356
+ };
357
+ }
358
+ try {
359
+ const client = await connectServer(parsed.server);
360
+ if (parsed.tool === RESOURCE_LIST_TOOL)
361
+ return await client.listResources(context.signal);
362
+ if (parsed.tool === RESOURCE_READ_TOOL) {
363
+ return typeof args.uri === 'string'
364
+ ? await client.readResource(args.uri, context.signal)
365
+ : { success: false, error: '`uri` must be a string' };
366
+ }
367
+ if (parsed.tool === PROMPT_LIST_TOOL)
368
+ return await client.listPrompts(context.signal);
369
+ if (parsed.tool === PROMPT_GET_TOOL) {
370
+ if (typeof args.name !== 'string')
371
+ return { success: false, error: '`name` must be a string' };
372
+ const promptArgs = {};
373
+ if (isRecord(args.arguments)) {
374
+ for (const [key, value] of Object.entries(args.arguments)) {
375
+ if (typeof value !== 'string')
376
+ return { success: false, error: '`arguments` values must be strings' };
377
+ promptArgs[key] = value;
378
+ }
379
+ }
380
+ return await client.getPrompt(args.name, promptArgs, context.signal);
381
+ }
382
+ const tool = client.tools.find(item => item.name === parsed.tool);
383
+ if (!tool || !toolAllowed(definition, parsed.tool)) {
384
+ return { success: false, error: `MCP server "${parsed.server}" does not expose permitted tool "${parsed.tool}"` };
385
+ }
386
+ return await client.callTool(parsed.tool, args ?? {}, context.signal);
387
+ }
388
+ catch (error) {
389
+ return { success: false, error: `MCP server "${parsed.server}": ${error instanceof Error ? error.message : String(error)}` };
390
+ }
391
+ }
392
+ };
186
393
  }
187
-
188
- /** State for the user-facing listing. */
189
394
  export function serverStatus() {
190
- return configuredServers().map(server => ({
191
- name: server.name,
192
- transport: server.url ? 'http' : 'stdio',
193
- // The full invocation, not just the binary. "npx" alone tells a user nothing
194
- // about what they are consenting to run; "npx -y @scope/server-fs /tmp" does.
195
- target: server.url || [server.command, ...(server.args || [])].join(' '),
196
- granted: isServerGranted(server.name),
197
- connected: connected.has(server.name),
198
- tools: connected.has(server.name) ? connected.get(server.name).tools.map(t => t.name) : []
199
- }));
200
- }
201
-
202
- export function closeAll() {
203
- for (const [, client] of connected) {
204
- try { client.close(); } catch (e) {}
205
- }
206
- connected.clear();
395
+ return configuredServers().map(server => {
396
+ const live = connected.get(server.name);
397
+ const status = server.enabled === false
398
+ ? { state: 'disabled' }
399
+ : statuses.get(server.name) ?? { state: 'disconnected' };
400
+ const result = {
401
+ name: server.name,
402
+ transport: status.transport ?? (server.url ? 'http' : 'stdio'),
403
+ target: server.url ?? [server.command, ...(server.args ?? [])].filter(Boolean).join(' '),
404
+ granted: isServerGranted(server.name),
405
+ connected: status.state === 'connected',
406
+ status: status.state,
407
+ tools: live?.tools.filter(tool => toolAllowed(server, tool.name)).map(tool => tool.name) ?? []
408
+ };
409
+ return status.error ? { ...result, error: status.error } : result;
410
+ });
411
+ }
412
+ export async function closeAll() {
413
+ for (const name of new Set([...connected.keys(), ...connecting.keys()]))
414
+ invalidateConnection(name);
415
+ const clients = [...connected.values()];
416
+ connected.clear();
417
+ connecting.clear();
418
+ await Promise.allSettled(clients.map(client => client.close()));
419
+ statuses.clear();
420
+ connectionEpochs.clear();
207
421
  }