@enactprotocol/shared 2.1.24 → 2.1.28

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.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * MCP tools registry management
3
+ *
4
+ * Manages mcp.json for tracking tools exposed to MCP clients:
5
+ * - Global only: ~/.enact/mcp.json
6
+ *
7
+ * Similar to tools.json but adds toolset management for organizing
8
+ * which tools are exposed to MCP clients.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { getCacheDir, getEnactHome } from "./paths";
14
+
15
+ /**
16
+ * Structure of mcp.json file
17
+ */
18
+ export interface McpRegistry {
19
+ /** Map of tool name to installed version */
20
+ tools: Record<string, string>;
21
+ /** Named collections of tools */
22
+ toolsets: Record<string, string[]>;
23
+ /** Currently active toolset (null = expose all tools) */
24
+ activeToolset: string | null;
25
+ }
26
+
27
+ /**
28
+ * Information about an MCP-exposed tool
29
+ */
30
+ export interface McpToolInfo {
31
+ name: string;
32
+ version: string;
33
+ cachePath: string;
34
+ }
35
+
36
+ /**
37
+ * Get the path to mcp.json
38
+ */
39
+ export function getMcpJsonPath(): string {
40
+ return join(getEnactHome(), "mcp.json");
41
+ }
42
+
43
+ /**
44
+ * Load mcp.json
45
+ * Returns empty registry if file doesn't exist
46
+ */
47
+ export function loadMcpRegistry(): McpRegistry {
48
+ const registryPath = getMcpJsonPath();
49
+
50
+ if (!existsSync(registryPath)) {
51
+ return { tools: {}, toolsets: {}, activeToolset: null };
52
+ }
53
+
54
+ try {
55
+ const content = readFileSync(registryPath, "utf-8");
56
+ const parsed = JSON.parse(content);
57
+ return {
58
+ tools: parsed.tools ?? {},
59
+ toolsets: parsed.toolsets ?? {},
60
+ activeToolset: parsed.activeToolset ?? null,
61
+ };
62
+ } catch {
63
+ // Return empty registry on parse error
64
+ return { tools: {}, toolsets: {}, activeToolset: null };
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Save mcp.json
70
+ */
71
+ export function saveMcpRegistry(registry: McpRegistry): void {
72
+ const registryPath = getMcpJsonPath();
73
+
74
+ // Ensure directory exists
75
+ const dir = dirname(registryPath);
76
+ if (!existsSync(dir)) {
77
+ mkdirSync(dir, { recursive: true });
78
+ }
79
+
80
+ const content = JSON.stringify(registry, null, 2);
81
+ writeFileSync(registryPath, content, "utf-8");
82
+ }
83
+
84
+ /**
85
+ * Add a tool to the MCP registry
86
+ */
87
+ export function addMcpTool(toolName: string, version: string): void {
88
+ const registry = loadMcpRegistry();
89
+ registry.tools[toolName] = version;
90
+ saveMcpRegistry(registry);
91
+ }
92
+
93
+ /**
94
+ * Remove a tool from the MCP registry
95
+ */
96
+ export function removeMcpTool(toolName: string): boolean {
97
+ const registry = loadMcpRegistry();
98
+
99
+ if (!(toolName in registry.tools)) {
100
+ return false;
101
+ }
102
+
103
+ delete registry.tools[toolName];
104
+
105
+ // Also remove from all toolsets
106
+ for (const toolsetName of Object.keys(registry.toolsets)) {
107
+ const toolset = registry.toolsets[toolsetName];
108
+ if (toolset) {
109
+ registry.toolsets[toolsetName] = toolset.filter((t) => t !== toolName);
110
+ }
111
+ }
112
+
113
+ saveMcpRegistry(registry);
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * Check if a tool is in the MCP registry
119
+ */
120
+ export function isMcpToolInstalled(toolName: string): boolean {
121
+ const registry = loadMcpRegistry();
122
+ return toolName in registry.tools;
123
+ }
124
+
125
+ /**
126
+ * Get the installed version of an MCP tool
127
+ * Returns null if not installed
128
+ */
129
+ export function getMcpToolVersion(toolName: string): string | null {
130
+ const registry = loadMcpRegistry();
131
+ return registry.tools[toolName] ?? null;
132
+ }
133
+
134
+ /**
135
+ * Get the cache path for an MCP tool
136
+ */
137
+ function getMcpToolCachePath(toolName: string, version: string): string {
138
+ const cacheDir = getCacheDir();
139
+ const normalizedVersion = version.startsWith("v") ? version.slice(1) : version;
140
+ return join(cacheDir, toolName, `v${normalizedVersion}`);
141
+ }
142
+
143
+ /**
144
+ * List all tools that should be exposed to MCP clients
145
+ * If activeToolset is set, only returns tools in that toolset
146
+ * Otherwise returns all tools
147
+ */
148
+ export function listMcpTools(): McpToolInfo[] {
149
+ const registry = loadMcpRegistry();
150
+ const tools: McpToolInfo[] = [];
151
+
152
+ // Determine which tools to expose
153
+ let toolsToExpose: string[];
154
+
155
+ const activeToolsetTools = registry.activeToolset
156
+ ? registry.toolsets[registry.activeToolset]
157
+ : undefined;
158
+
159
+ if (activeToolsetTools) {
160
+ // Filter to only tools in the active toolset that are also installed
161
+ toolsToExpose = activeToolsetTools.filter((name) => name in registry.tools);
162
+ } else {
163
+ // Expose all installed tools
164
+ toolsToExpose = Object.keys(registry.tools);
165
+ }
166
+
167
+ for (const name of toolsToExpose) {
168
+ const version = registry.tools[name];
169
+ if (version) {
170
+ tools.push({
171
+ name,
172
+ version,
173
+ cachePath: getMcpToolCachePath(name, version),
174
+ });
175
+ }
176
+ }
177
+
178
+ return tools;
179
+ }
180
+
181
+ /**
182
+ * Get info for a specific MCP tool if it's exposed
183
+ */
184
+ export function getMcpToolInfo(toolName: string): McpToolInfo | null {
185
+ const registry = loadMcpRegistry();
186
+ const version = registry.tools[toolName];
187
+
188
+ if (!version) {
189
+ return null;
190
+ }
191
+
192
+ // Check if tool is in active toolset (if one is set)
193
+ if (registry.activeToolset) {
194
+ const activeToolsetTools = registry.toolsets[registry.activeToolset];
195
+ if (activeToolsetTools && !activeToolsetTools.includes(toolName)) {
196
+ return null; // Tool is installed but not in active toolset
197
+ }
198
+ }
199
+
200
+ const cachePath = getMcpToolCachePath(toolName, version);
201
+
202
+ // Verify cache exists
203
+ if (!existsSync(cachePath)) {
204
+ return null;
205
+ }
206
+
207
+ return { name: toolName, version, cachePath };
208
+ }
209
+
210
+ // Toolset management
211
+
212
+ /**
213
+ * Create a new toolset
214
+ */
215
+ export function createToolset(name: string, tools: string[] = []): void {
216
+ const registry = loadMcpRegistry();
217
+ registry.toolsets[name] = tools;
218
+ saveMcpRegistry(registry);
219
+ }
220
+
221
+ /**
222
+ * Delete a toolset
223
+ */
224
+ export function deleteToolset(name: string): boolean {
225
+ const registry = loadMcpRegistry();
226
+
227
+ if (!(name in registry.toolsets)) {
228
+ return false;
229
+ }
230
+
231
+ delete registry.toolsets[name];
232
+
233
+ // Clear active toolset if it was the deleted one
234
+ if (registry.activeToolset === name) {
235
+ registry.activeToolset = null;
236
+ }
237
+
238
+ saveMcpRegistry(registry);
239
+ return true;
240
+ }
241
+
242
+ /**
243
+ * Add a tool to a toolset
244
+ */
245
+ export function addToolToToolset(toolsetName: string, toolName: string): boolean {
246
+ const registry = loadMcpRegistry();
247
+
248
+ const toolset = registry.toolsets[toolsetName];
249
+ if (!toolset) {
250
+ return false;
251
+ }
252
+
253
+ if (!toolset.includes(toolName)) {
254
+ toolset.push(toolName);
255
+ saveMcpRegistry(registry);
256
+ }
257
+
258
+ return true;
259
+ }
260
+
261
+ /**
262
+ * Remove a tool from a toolset
263
+ */
264
+ export function removeToolFromToolset(toolsetName: string, toolName: string): boolean {
265
+ const registry = loadMcpRegistry();
266
+
267
+ const toolset = registry.toolsets[toolsetName];
268
+ if (!toolset) {
269
+ return false;
270
+ }
271
+
272
+ const index = toolset.indexOf(toolName);
273
+ if (index === -1) {
274
+ return false;
275
+ }
276
+
277
+ toolset.splice(index, 1);
278
+ saveMcpRegistry(registry);
279
+ return true;
280
+ }
281
+
282
+ /**
283
+ * Set the active toolset
284
+ */
285
+ export function setActiveToolset(name: string | null): boolean {
286
+ const registry = loadMcpRegistry();
287
+
288
+ if (name !== null && !(name in registry.toolsets)) {
289
+ return false;
290
+ }
291
+
292
+ registry.activeToolset = name;
293
+ saveMcpRegistry(registry);
294
+ return true;
295
+ }
296
+
297
+ /**
298
+ * Get the active toolset name
299
+ */
300
+ export function getActiveToolset(): string | null {
301
+ const registry = loadMcpRegistry();
302
+ return registry.activeToolset;
303
+ }
304
+
305
+ /**
306
+ * List all toolsets
307
+ */
308
+ export function listToolsets(): Array<{ name: string; tools: string[]; isActive: boolean }> {
309
+ const registry = loadMcpRegistry();
310
+
311
+ return Object.entries(registry.toolsets).map(([name, tools]) => ({
312
+ name,
313
+ tools,
314
+ isActive: registry.activeToolset === name,
315
+ }));
316
+ }
317
+
318
+ /**
319
+ * Sync MCP registry with global tools.json
320
+ * Adds any tools from tools.json that aren't in mcp.json
321
+ * Does NOT remove tools (allows mcp.json to have subset of tools.json)
322
+ */
323
+ export function syncMcpWithGlobalTools(globalTools: Record<string, string>): void {
324
+ const registry = loadMcpRegistry();
325
+ let changed = false;
326
+
327
+ for (const [name, version] of Object.entries(globalTools)) {
328
+ if (!(name in registry.tools)) {
329
+ registry.tools[name] = version;
330
+ changed = true;
331
+ }
332
+ }
333
+
334
+ if (changed) {
335
+ saveMcpRegistry(registry);
336
+ }
337
+ }
package/src/resolver.ts CHANGED
@@ -10,7 +10,12 @@
10
10
 
11
11
  import { existsSync, readdirSync } from "node:fs";
12
12
  import { dirname, isAbsolute, join, resolve } from "node:path";
13
- import { type LoadManifestOptions, findManifestFile, loadManifest } from "./manifest/loader";
13
+ import {
14
+ type LoadManifestOptions,
15
+ ManifestLoadError,
16
+ findManifestFile,
17
+ loadManifest,
18
+ } from "./manifest/loader";
14
19
  import { getCacheDir, getProjectEnactDir } from "./paths";
15
20
  import { getInstalledVersion, getToolCachePath } from "./registry";
16
21
  import type { ToolLocation, ToolResolution } from "./types/manifest";
@@ -29,6 +34,22 @@ export class ToolResolveError extends Error {
29
34
  }
30
35
  }
31
36
 
37
+ /**
38
+ * Result of trying to resolve a tool with error details
39
+ */
40
+ export interface TryResolveResult {
41
+ /** The resolved tool, or null if not found/invalid */
42
+ resolution: ToolResolution | null;
43
+ /** Error that occurred during resolution, if any */
44
+ error?: Error;
45
+ /** Locations that were searched */
46
+ searchedLocations: string[];
47
+ /** Whether a manifest was found but had errors */
48
+ manifestFound: boolean;
49
+ /** Path where manifest was found (if any) */
50
+ manifestPath?: string;
51
+ }
52
+
32
53
  /**
33
54
  * Options for tool resolution
34
55
  */
@@ -267,21 +288,123 @@ export function tryResolveTool(
267
288
  toolNameOrPath: string,
268
289
  options: ResolveOptions = {}
269
290
  ): ToolResolution | null {
291
+ const result = tryResolveToolDetailed(toolNameOrPath, options);
292
+ return result.resolution;
293
+ }
294
+
295
+ /**
296
+ * Try to resolve a tool with detailed error information
297
+ *
298
+ * Unlike tryResolveTool, this function returns information about why
299
+ * resolution failed, allowing callers to provide better error messages.
300
+ *
301
+ * @param toolNameOrPath - Tool name or path
302
+ * @param options - Resolution options
303
+ * @returns TryResolveResult with resolution or error details
304
+ */
305
+ export function tryResolveToolDetailed(
306
+ toolNameOrPath: string,
307
+ options: ResolveOptions = {}
308
+ ): TryResolveResult {
309
+ const searchedLocations: string[] = [];
310
+
311
+ // Check if it looks like a path
312
+ const isPath =
313
+ toolNameOrPath.startsWith("/") ||
314
+ toolNameOrPath.startsWith("./") ||
315
+ toolNameOrPath.startsWith("../") ||
316
+ toolNameOrPath.includes("\\") ||
317
+ existsSync(toolNameOrPath);
318
+
319
+ if (isPath) {
320
+ // Resolve from path
321
+ const absolutePath = isAbsolute(toolNameOrPath) ? toolNameOrPath : resolve(toolNameOrPath);
322
+ searchedLocations.push(absolutePath);
323
+
324
+ // Check if path exists
325
+ if (!existsSync(absolutePath)) {
326
+ return {
327
+ resolution: null,
328
+ searchedLocations,
329
+ manifestFound: false,
330
+ };
331
+ }
332
+
333
+ // Find manifest file
334
+ const manifestPath =
335
+ absolutePath.endsWith(".yaml") ||
336
+ absolutePath.endsWith(".yml") ||
337
+ absolutePath.endsWith(".md")
338
+ ? absolutePath
339
+ : findManifestFile(absolutePath);
340
+
341
+ if (!manifestPath) {
342
+ return {
343
+ resolution: null,
344
+ searchedLocations,
345
+ manifestFound: false,
346
+ };
347
+ }
348
+
349
+ // Try to load the manifest
350
+ try {
351
+ const resolution = resolveToolFromPath(toolNameOrPath);
352
+ return {
353
+ resolution,
354
+ searchedLocations,
355
+ manifestFound: true,
356
+ manifestPath,
357
+ };
358
+ } catch (error) {
359
+ // Manifest found but invalid
360
+ return {
361
+ resolution: null,
362
+ error: error instanceof Error ? error : new Error(String(error)),
363
+ searchedLocations,
364
+ manifestFound: true,
365
+ manifestPath,
366
+ };
367
+ }
368
+ }
369
+
370
+ // Resolve by name
270
371
  try {
271
- // Check if it looks like a path
272
- if (
273
- toolNameOrPath.startsWith("/") ||
274
- toolNameOrPath.startsWith("./") ||
275
- toolNameOrPath.startsWith("../") ||
276
- toolNameOrPath.includes("\\") ||
277
- existsSync(toolNameOrPath)
278
- ) {
279
- return resolveToolFromPath(toolNameOrPath);
372
+ const resolution = resolveTool(toolNameOrPath, options);
373
+ return {
374
+ resolution,
375
+ searchedLocations: getToolSearchPaths(toolNameOrPath, options),
376
+ manifestFound: true,
377
+ manifestPath: resolution.manifestPath,
378
+ };
379
+ } catch (error) {
380
+ // Check if error is due to manifest validation vs not found
381
+ if (error instanceof ToolResolveError) {
382
+ return {
383
+ resolution: null,
384
+ error,
385
+ searchedLocations: error.searchedLocations ?? [],
386
+ manifestFound: false,
387
+ };
280
388
  }
281
389
 
282
- return resolveTool(toolNameOrPath, options);
283
- } catch {
284
- return null;
390
+ // ManifestLoadError means manifest was found but invalid
391
+ if (error instanceof ManifestLoadError) {
392
+ return {
393
+ resolution: null,
394
+ error,
395
+ searchedLocations: getToolSearchPaths(toolNameOrPath, options),
396
+ manifestFound: true,
397
+ manifestPath: error.filePath,
398
+ };
399
+ }
400
+
401
+ // Other error
402
+ return {
403
+ resolution: null,
404
+ error: error instanceof Error ? error : new Error(String(error)),
405
+ searchedLocations: getToolSearchPaths(toolNameOrPath, options),
406
+ manifestFound: false,
407
+ };
285
408
  }
286
409
  }
287
410