@enactprotocol/shared 2.1.23 → 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.
@@ -83,68 +83,85 @@ const SEMVER_REGEX =
83
83
  /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
84
84
 
85
85
  /**
86
- * Tool name regex - hierarchical path format
86
+ * Tool name regex - hierarchical path format (required for publishing)
87
87
  */
88
88
  const TOOL_NAME_REGEX = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)+$/;
89
89
 
90
+ /**
91
+ * Tool name regex - simple format (allowed for local tools)
92
+ * Allows both hierarchical (org/tool) and simple (my-tool) names
93
+ */
94
+ const TOOL_NAME_REGEX_LOCAL = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/;
95
+
90
96
  /**
91
97
  * Go duration regex (used for timeout)
92
98
  */
93
99
  const GO_DURATION_REGEX = /^(\d+)(ns|us|µs|ms|s|m|h)$/;
94
100
 
95
101
  /**
96
- * Complete tool manifest schema
102
+ * Create a tool manifest schema with configurable name validation
97
103
  */
98
- const ToolManifestSchema = z
99
- .object({
100
- // Required fields
101
- name: z
102
- .string()
103
- .min(1, "Tool name is required")
104
- .regex(
105
- TOOL_NAME_REGEX,
106
- "Tool name must be hierarchical path format (e.g., 'org/tool' or 'org/category/tool')"
107
- ),
108
-
109
- description: z
110
- .string()
111
- .min(1, "Description is required")
112
- .max(500, "Description should be 500 characters or less"),
113
-
114
- // Recommended fields
115
- enact: z.string().optional(),
116
- version: z
117
- .string()
118
- .regex(SEMVER_REGEX, "Version must be valid semver (e.g., '1.0.0')")
119
- .optional(),
120
- from: z.string().optional(),
121
- command: z.string().optional(),
122
- timeout: z
123
- .string()
124
- .regex(GO_DURATION_REGEX, "Timeout must be Go duration format (e.g., '30s', '5m', '1h')")
125
- .optional(),
126
- license: z.string().optional(),
127
- tags: z.array(z.string()).optional(),
128
-
129
- // Schema fields
130
- inputSchema: JsonSchemaSchema.optional(),
131
- outputSchema: JsonSchemaSchema.optional(),
132
-
133
- // Environment variables
134
- env: z.record(z.string(), EnvVariableSchema).optional(),
135
-
136
- // Behavior & Resources
137
- annotations: ToolAnnotationsSchema.optional(),
138
- resources: ResourceRequirementsSchema.optional(),
139
-
140
- // Documentation
141
- doc: z.string().optional(),
142
- authors: z.array(AuthorSchema).optional(),
143
-
144
- // Testing
145
- examples: z.array(ToolExampleSchema).optional(),
146
- })
147
- .passthrough(); // Allow x-* custom fields
104
+ function createToolManifestSchema(allowSimpleNames: boolean) {
105
+ const nameRegex = allowSimpleNames ? TOOL_NAME_REGEX_LOCAL : TOOL_NAME_REGEX;
106
+ const nameMessage = allowSimpleNames
107
+ ? "Tool name must contain only lowercase letters, numbers, hyphens, and underscores"
108
+ : "Tool name must be hierarchical path format (e.g., 'org/tool' or 'org/category/tool')";
109
+
110
+ return z
111
+ .object({
112
+ // Required fields
113
+ name: z.string().min(1, "Tool name is required").regex(nameRegex, nameMessage),
114
+
115
+ description: z
116
+ .string()
117
+ .min(1, "Description is required")
118
+ .max(500, "Description should be 500 characters or less"),
119
+
120
+ // Recommended fields
121
+ enact: z.string().optional(),
122
+ version: z
123
+ .string()
124
+ .regex(SEMVER_REGEX, "Version must be valid semver (e.g., '1.0.0')")
125
+ .optional(),
126
+ from: z.string().optional(),
127
+ command: z.string().optional(),
128
+ timeout: z
129
+ .string()
130
+ .regex(GO_DURATION_REGEX, "Timeout must be Go duration format (e.g., '30s', '5m', '1h')")
131
+ .optional(),
132
+ license: z.string().optional(),
133
+ tags: z.array(z.string()).optional(),
134
+
135
+ // Schema fields
136
+ inputSchema: JsonSchemaSchema.optional(),
137
+ outputSchema: JsonSchemaSchema.optional(),
138
+
139
+ // Environment variables
140
+ env: z.record(z.string(), EnvVariableSchema).optional(),
141
+
142
+ // Behavior & Resources
143
+ annotations: ToolAnnotationsSchema.optional(),
144
+ resources: ResourceRequirementsSchema.optional(),
145
+
146
+ // Documentation
147
+ doc: z.string().optional(),
148
+ authors: z.array(AuthorSchema).optional(),
149
+
150
+ // Testing
151
+ examples: z.array(ToolExampleSchema).optional(),
152
+ })
153
+ .passthrough(); // Allow x-* custom fields
154
+ }
155
+
156
+ /**
157
+ * Complete tool manifest schema (strict - requires hierarchical names)
158
+ */
159
+ const ToolManifestSchema = createToolManifestSchema(false);
160
+
161
+ /**
162
+ * Local tool manifest schema (relaxed - allows simple names)
163
+ */
164
+ const ToolManifestSchemaLocal = createToolManifestSchema(true);
148
165
 
149
166
  // ==================== Validation Functions ====================
150
167
 
@@ -239,14 +256,31 @@ function generateWarnings(manifest: ToolManifest): ValidationWarning[] {
239
256
  return warnings;
240
257
  }
241
258
 
259
+ /**
260
+ * Options for manifest validation
261
+ */
262
+ export interface ValidateManifestOptions {
263
+ /**
264
+ * Allow simple tool names without hierarchy (e.g., "my-tool" instead of "org/my-tool").
265
+ * Use this for local tools that won't be published.
266
+ * @default false
267
+ */
268
+ allowSimpleNames?: boolean;
269
+ }
270
+
242
271
  /**
243
272
  * Validate a tool manifest
244
273
  *
245
274
  * @param manifest - The manifest to validate (parsed but unvalidated)
275
+ * @param options - Validation options
246
276
  * @returns ValidationResult with valid flag, errors, and warnings
247
277
  */
248
- export function validateManifest(manifest: unknown): ValidationResult {
249
- const result = ToolManifestSchema.safeParse(manifest);
278
+ export function validateManifest(
279
+ manifest: unknown,
280
+ options: ValidateManifestOptions = {}
281
+ ): ValidationResult {
282
+ const schema = options.allowSimpleNames ? ToolManifestSchemaLocal : ToolManifestSchema;
283
+ const result = schema.safeParse(manifest);
250
284
 
251
285
  if (!result.success) {
252
286
  return {
@@ -270,11 +304,15 @@ export function validateManifest(manifest: unknown): ValidationResult {
270
304
  * Throws if validation fails
271
305
  *
272
306
  * @param manifest - The manifest to validate
307
+ * @param options - Validation options
273
308
  * @returns The validated ToolManifest
274
309
  * @throws Error if validation fails
275
310
  */
276
- export function validateManifestStrict(manifest: unknown): ToolManifest {
277
- const result = validateManifest(manifest);
311
+ export function validateManifestStrict(
312
+ manifest: unknown,
313
+ options: ValidateManifestOptions = {}
314
+ ): ToolManifest {
315
+ const result = validateManifest(manifest, options);
278
316
 
279
317
  if (!result.valid) {
280
318
  const errorMessages = result.errors?.map((e) => `${e.path}: ${e.message}`).join(", ");
@@ -285,12 +323,19 @@ export function validateManifestStrict(manifest: unknown): ToolManifest {
285
323
  }
286
324
 
287
325
  /**
288
- * Check if a string is a valid tool name
326
+ * Check if a string is a valid tool name (hierarchical format for publishing)
289
327
  */
290
328
  export function isValidToolName(name: string): boolean {
291
329
  return TOOL_NAME_REGEX.test(name);
292
330
  }
293
331
 
332
+ /**
333
+ * Check if a string is a valid local tool name (allows simple names)
334
+ */
335
+ export function isValidLocalToolName(name: string): boolean {
336
+ return TOOL_NAME_REGEX_LOCAL.test(name);
337
+ }
338
+
294
339
  /**
295
340
  * Check if a string is a valid semver version
296
341
  */
@@ -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
+ }