@open-mercato/ai-assistant 0.6.8-develop.6930.1.1e5976efc3 → 0.6.8-develop.6940.1.177ea30c6e

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 (27) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +9 -33
  3. package/dist/modules/ai_assistant/api/tools/route.js +2 -2
  4. package/dist/modules/ai_assistant/api/tools/route.js.map +2 -2
  5. package/dist/modules/ai_assistant/lib/generated-registry-loader.js +55 -10
  6. package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
  7. package/dist/modules/ai_assistant/lib/in-process-client.js +2 -2
  8. package/dist/modules/ai_assistant/lib/in-process-client.js.map +2 -2
  9. package/dist/modules/ai_assistant/lib/mcp-server.js +2 -3
  10. package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
  11. package/dist/modules/ai_assistant/lib/tool-input-schema.js +13 -0
  12. package/dist/modules/ai_assistant/lib/tool-input-schema.js.map +7 -0
  13. package/dist/modules/ai_assistant/lib/tool-loader.js +16 -2
  14. package/dist/modules/ai_assistant/lib/tool-loader.js.map +2 -2
  15. package/dist/modules/ai_assistant/lib/tool-registry.js +1 -1
  16. package/dist/modules/ai_assistant/lib/tool-registry.js.map +2 -2
  17. package/package.json +6 -6
  18. package/src/modules/ai_assistant/api/tools/route.ts +2 -2
  19. package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +128 -1
  20. package/src/modules/ai_assistant/lib/__tests__/tool-input-schema.test.ts +52 -0
  21. package/src/modules/ai_assistant/lib/__tests__/tool-loader.test.ts +65 -0
  22. package/src/modules/ai_assistant/lib/generated-registry-loader.ts +107 -12
  23. package/src/modules/ai_assistant/lib/in-process-client.ts +2 -2
  24. package/src/modules/ai_assistant/lib/mcp-server.ts +2 -3
  25. package/src/modules/ai_assistant/lib/tool-input-schema.ts +33 -0
  26. package/src/modules/ai_assistant/lib/tool-loader.ts +18 -1
  27. package/src/modules/ai_assistant/lib/tool-registry.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/tool-loader.ts"],
4
- "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { z } from 'zod'\nimport type { SearchService } from '@open-mercato/search/service'\nimport { registerMcpTool, getToolRegistry, toolRegistry, unregisterMcpTool } from './tool-registry'\nimport {\n applyToolOverrideMap,\n composeToolOverrideMap,\n type AiToolOverrideConfigEntry,\n} from './ai-overrides'\nimport type { McpToolDefinition, McpToolContext } from './types'\nimport { ToolSearchService } from './tool-search'\nimport {\n findGeneratedFile,\n compileAndImportGenerated,\n ensureApiRouteManifestsRegistered,\n} from './generated-registry-loader'\n\nconst logger = createLogger('ai_assistant').child({ component: 'tools' })\n\n/**\n * Module tool definition as exported from ai-tools.ts files.\n */\ntype ModuleAiTool = {\n name: string\n description: string\n inputSchema: any\n requiredFeatures?: string[]\n handler: (input: any, ctx: any) => Promise<unknown>\n}\n\n/**\n * Shape of a single entry inside `ai-tools.generated.ts`.\n * Matches the structural contract emitted by\n * `packages/cli/src/lib/generators/extensions/ai-tools.ts`.\n */\nexport type AiToolConfigEntry = {\n moduleId: string\n tools: unknown[]\n}\n\nfunction isModuleAiTool(value: unknown): value is ModuleAiTool {\n if (!value || typeof value !== 'object') return false\n const candidate = value as Record<string, unknown>\n return (\n typeof candidate.name === 'string' &&\n typeof candidate.description === 'string' &&\n candidate.inputSchema !== undefined &&\n typeof candidate.handler === 'function'\n )\n}\n\n/**\n * Built-in context.whoami tool that returns the current authentication context.\n * This is useful for AI to understand its current tenant/org scope.\n */\nconst contextWhoamiTool: McpToolDefinition = {\n name: 'context_whoami',\n description:\n 'Get the current authentication context including tenant ID, organization ID, user ID, and available features. Use this to understand your current scope before performing operations.',\n inputSchema: z.object({}),\n requiredFeatures: [], // No specific feature required - available to all authenticated users\n handler: async (_input: unknown, ctx: McpToolContext) => {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n isSuperAdmin: ctx.isSuperAdmin,\n features: ctx.userFeatures,\n featureCount: ctx.userFeatures.length,\n }\n },\n}\n\n/**\n * Load and register AI tools from a module's ai-tools.ts export.\n *\n * @param moduleId - The module identifier (e.g., 'search', 'customers')\n * @param tools - Array of tool definitions from the module\n *\n * IMPORTANT: We register the full typed tool (spread) \u2014 never reconstruct a\n * minimal `{ name, description, inputSchema, requiredFeatures, handler }`\n * payload. Stripping fields like `isMutation`, `displayName`, `isBulk`,\n * `loadBeforeRecord(s)` makes the agent settings UI report mutation tools\n * as read-only and prevents the chat dispatcher's mutation interceptor\n * from firing.\n */\nexport function loadModuleTools(moduleId: string, tools: ModuleAiTool[]): void {\n for (const tool of tools) {\n registerMcpTool(tool as McpToolDefinition, { moduleId })\n }\n}\n\n/**\n * Register the generated `aiToolConfigEntries` shape emitted by the\n * `ai-tools.generated.ts` generator extension. Entries with an empty\n * `tools` array stay silent (the generator already filters those out).\n * Invalid tool objects are skipped with a warning instead of throwing.\n *\n * Returns the number of tools actually registered so callers can log it.\n */\nexport function registerGeneratedAiToolEntries(entries: AiToolConfigEntry[]): number {\n let registered = 0\n for (const entry of entries) {\n if (!entry || typeof entry.moduleId !== 'string') continue\n if (!Array.isArray(entry.tools) || entry.tools.length === 0) continue\n for (const candidate of entry.tools) {\n if (!isModuleAiTool(candidate)) {\n logger.warn('Skipping malformed AI tool', { moduleId: entry.moduleId })\n continue\n }\n // Register the full typed tool \u2014 see comment on `loadModuleTools`.\n registerMcpTool(candidate as McpToolDefinition, { moduleId: entry.moduleId })\n registered += 1\n }\n }\n return registered\n}\n\n/**\n * Apply the list of `aiToolOverrides` exports collected by the generator\n * (one entry per module) to the live tool registry. Tools mapped to\n * `null` are unregistered; tools mapped to a full definition replace the\n * existing registration. Module load order controls precedence \u2014 last\n * entry wins. The `modules.ts`-tier and programmatic overrides (set via\n * {@link applyAiOverridesFromEnabledModules} and\n * {@link applyAiToolOverrides}) supersede file-based entries.\n *\n * Safe to call when no override file is present (the entries array is\n * empty); it is a no-op then.\n */\nexport function applyAiToolOverrideEntries(\n entries: readonly AiToolOverrideConfigEntry[],\n): void {\n const overrideMap = composeToolOverrideMap(entries)\n if (Object.keys(overrideMap).length === 0) return\n const baseTools = toolRegistry.getTools() as Map<string, McpToolDefinition>\n const overridden = applyToolOverrideMap<McpToolDefinition>(baseTools, overrideMap)\n for (const [name, value] of Object.entries(overrideMap)) {\n if (value === null) {\n unregisterMcpTool(name)\n logger.info('Tool disabled by override', { toolName: name })\n continue\n }\n const next = overridden.get(name)\n if (!next) continue\n // Re-register through the public path so moduleMap stays consistent.\n registerMcpTool(next as McpToolDefinition, { moduleId: 'ai_overrides' })\n logger.info('Tool replaced by override', { toolName: name })\n }\n}\n\n/**\n * Import the generated `ai-tools.generated.ts` registry.\n *\n * Dual-strategy so the same code works in every runtime without changing the\n * existing Next.js / agents-framework behavior:\n * 1. Prefer the `@/` path-alias import. Inside the Next.js bundler this\n * resolves at build time exactly as before \u2014 the in-app agents framework\n * path is untouched.\n * 2. Fall back to locating + compiling the file from disk when the alias\n * import throws. That only happens in a standalone Node process (the\n * `mcp:dev` / `mcp:serve` MCP servers), where `@/` is not a real package\n * specifier and Node throws `ERR_MODULE_NOT_FOUND`.\n *\n * Returns `null` when no generated file exists (pre-generate builds, tests).\n */\nasync function importGeneratedAiToolsModule(): Promise<Record<string, unknown> | null> {\n try {\n return (await import(\n '@/.mercato/generated/ai-tools.generated'\n )) as Record<string, unknown>\n } catch {\n const tsPath = findGeneratedFile('ai-tools.generated.ts')\n if (!tsPath) return null\n return compileAndImportGenerated(tsPath)\n }\n}\n\n/**\n * Read `aiToolOverrideEntries` from `ai-tools.generated.ts` and apply\n * them on top of the live tool registry. Safe to call when the file is\n * missing (pre-generate builds, tests) \u2014 applies only the\n * `modules.ts`-tier and programmatic overrides in that case.\n */\nexport async function loadGeneratedAiToolOverrides(): Promise<void> {\n let entries: AiToolOverrideConfigEntry[] = []\n try {\n const mod = (await importGeneratedAiToolsModule()) as {\n aiToolOverrideEntries?: unknown[]\n } | null\n entries =\n mod && Array.isArray(mod.aiToolOverrideEntries)\n ? (mod.aiToolOverrideEntries as AiToolOverrideConfigEntry[])\n : []\n } catch {\n // No override file generated.\n }\n applyAiToolOverrideEntries(entries)\n}\n\n/**\n * Load the generated `ai-tools.generated.ts` file emitted by\n * `yarn generate` and register every declared module tool through the\n * existing `registerMcpTool` path. Safe to call when the generated file\n * is missing (e.g., tests or pre-generate builds) \u2014 returns 0.\n */\nexport async function loadGeneratedModuleAiTools(): Promise<number> {\n try {\n const mod = (await importGeneratedAiToolsModule()) as {\n aiToolConfigEntries?: AiToolConfigEntry[]\n } | null\n if (!mod) {\n // No generated file (pre-generate build or tests).\n return 0\n }\n const entries = Array.isArray(mod.aiToolConfigEntries)\n ? mod.aiToolConfigEntries\n : []\n const count = registerGeneratedAiToolEntries(entries)\n // Apply module-to-module + programmatic tool overrides AFTER the base\n // registrations so disable / replace semantics work end-to-end.\n try {\n await loadGeneratedAiToolOverrides()\n } catch (error) {\n logger.error('Failed to apply tool overrides', { err: error })\n }\n return count\n } catch (error) {\n logger.error('Could not load ai-tools.generated.ts (module tools unavailable)', { err: error })\n return 0\n }\n}\n\n/**\n * Dynamically load tools from known module paths.\n * This is called during MCP server startup.\n */\nexport async function loadAllModuleTools(): Promise<void> {\n // 1. Register built-in tools\n registerMcpTool(contextWhoamiTool, { moduleId: 'context' })\n logger.debug('Registered built-in context_whoami tool')\n\n // 2. Register Code Mode tools (search + execute)\n // These two tools replace the previous api_discover, call_api, discover_schema,\n // and all module-specific AI tools. The AI writes JavaScript that runs in a\n // node:vm sandbox with access to the OpenAPI spec and api.request().\n try {\n const { loadCodeModeTools } = await import('./codemode-tools')\n const toolCount = await loadCodeModeTools()\n logger.info('Registered Code Mode tools', { toolCount })\n } catch (error) {\n logger.error('Could not load Code Mode tools', { err: error })\n }\n\n // 3. Register module-contributed tools from ai-tools.generated.ts.\n // Code Mode stays untouched; module tools are additive. Missing\n // generated file is not fatal (pre-generate builds, tests).\n try {\n const moduleToolCount = await loadGeneratedModuleAiTools()\n logger.info('Registered module-contributed AI tools', { toolCount: moduleToolCount })\n } catch (error) {\n logger.error('Could not load module AI tools', { err: error })\n }\n\n // 4. Register the API route manifest so API-backed module tools can run.\n // Their handlers delegate to createAiApiOperationRunner, which fails closed\n // with \"No API route manifest registered\" outside the Next.js bootstrap.\n try {\n const routeCount = await ensureApiRouteManifestsRegistered()\n if (routeCount > 0) {\n logger.info('Registered API route manifests for API-backed tools', { routeCount })\n }\n } catch (error) {\n logger.error('Could not register API route manifests', { err: error })\n }\n}\n\n/**\n * Index all registered tools for hybrid search discovery.\n * This should be called after loadAllModuleTools() when the search service is available.\n *\n * @param searchService - The search service from DI container\n * @param force - Force re-indexing even if checksums match\n * @returns Indexing result with statistics\n */\nexport async function indexToolsForSearch(\n searchService: SearchService,\n force = false\n): Promise<{\n indexed: number\n skipped: number\n strategies: string[]\n checksum: string\n}> {\n const registry = getToolRegistry()\n const toolSearchService = new ToolSearchService(searchService, registry)\n\n try {\n const result = await toolSearchService.indexTools(force)\n\n logger.info('Indexed tools for search', { indexed: result.indexed })\n logger.debug('Search strategies available', { strategies: result.strategies.join(',') })\n\n if (result.skipped > 0) {\n logger.debug('Skipped unchanged tools', { skipped: result.skipped })\n }\n\n return result\n } catch (error) {\n logger.error('Failed to index tools for search', { err: error })\n throw error\n }\n}\n\n/**\n * Create a ToolSearchService instance for tool discovery.\n * Use this to get a configured service for discovering relevant tools.\n *\n * @param searchService - The search service from DI container\n * @returns Configured ToolSearchService\n */\nexport function createToolSearchService(searchService: SearchService): ToolSearchService {\n const registry = getToolRegistry()\n return new ToolSearchService(searchService, registry)\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { z } from 'zod'\nimport type { SearchService } from '@open-mercato/search/service'\nimport { registerMcpTool, getToolRegistry, toolRegistry, unregisterMcpTool } from './tool-registry'\nimport {\n applyToolOverrideMap,\n composeToolOverrideMap,\n type AiToolOverrideConfigEntry,\n} from './ai-overrides'\nimport type { McpToolDefinition, McpToolContext } from './types'\nimport { ToolSearchService } from './tool-search'\nimport {\n findGeneratedFile,\n compileAndImportGenerated,\n ensureApiRouteManifestsRegistered,\n} from './generated-registry-loader'\n\nconst logger = createLogger('ai_assistant').child({ component: 'tools' })\n\nlet allModuleToolsLoad: Promise<void> | null = null\n\n/**\n * Module tool definition as exported from ai-tools.ts files.\n */\ntype ModuleAiTool = {\n name: string\n description: string\n inputSchema: any\n requiredFeatures?: string[]\n handler: (input: any, ctx: any) => Promise<unknown>\n}\n\n/**\n * Shape of a single entry inside `ai-tools.generated.ts`.\n * Matches the structural contract emitted by\n * `packages/cli/src/lib/generators/extensions/ai-tools.ts`.\n */\nexport type AiToolConfigEntry = {\n moduleId: string\n tools: unknown[]\n}\n\nfunction isModuleAiTool(value: unknown): value is ModuleAiTool {\n if (!value || typeof value !== 'object') return false\n const candidate = value as Record<string, unknown>\n return (\n typeof candidate.name === 'string' &&\n typeof candidate.description === 'string' &&\n candidate.inputSchema !== undefined &&\n typeof candidate.handler === 'function'\n )\n}\n\n/**\n * Built-in context.whoami tool that returns the current authentication context.\n * This is useful for AI to understand its current tenant/org scope.\n */\nconst contextWhoamiTool: McpToolDefinition = {\n name: 'context_whoami',\n description:\n 'Get the current authentication context including tenant ID, organization ID, user ID, and available features. Use this to understand your current scope before performing operations.',\n inputSchema: z.object({}),\n requiredFeatures: [], // No specific feature required - available to all authenticated users\n handler: async (_input: unknown, ctx: McpToolContext) => {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n isSuperAdmin: ctx.isSuperAdmin,\n features: ctx.userFeatures,\n featureCount: ctx.userFeatures.length,\n }\n },\n}\n\n/**\n * Load and register AI tools from a module's ai-tools.ts export.\n *\n * @param moduleId - The module identifier (e.g., 'search', 'customers')\n * @param tools - Array of tool definitions from the module\n *\n * IMPORTANT: We register the full typed tool (spread) \u2014 never reconstruct a\n * minimal `{ name, description, inputSchema, requiredFeatures, handler }`\n * payload. Stripping fields like `isMutation`, `displayName`, `isBulk`,\n * `loadBeforeRecord(s)` makes the agent settings UI report mutation tools\n * as read-only and prevents the chat dispatcher's mutation interceptor\n * from firing.\n */\nexport function loadModuleTools(moduleId: string, tools: ModuleAiTool[]): void {\n for (const tool of tools) {\n registerMcpTool(tool as McpToolDefinition, { moduleId })\n }\n}\n\n/**\n * Register the generated `aiToolConfigEntries` shape emitted by the\n * `ai-tools.generated.ts` generator extension. Entries with an empty\n * `tools` array stay silent (the generator already filters those out).\n * Invalid tool objects are skipped with a warning instead of throwing.\n *\n * Returns the number of tools actually registered so callers can log it.\n */\nexport function registerGeneratedAiToolEntries(entries: AiToolConfigEntry[]): number {\n let registered = 0\n for (const entry of entries) {\n if (!entry || typeof entry.moduleId !== 'string') continue\n if (!Array.isArray(entry.tools) || entry.tools.length === 0) continue\n for (const candidate of entry.tools) {\n if (!isModuleAiTool(candidate)) {\n logger.warn('Skipping malformed AI tool', { moduleId: entry.moduleId })\n continue\n }\n // Register the full typed tool \u2014 see comment on `loadModuleTools`.\n registerMcpTool(candidate as McpToolDefinition, { moduleId: entry.moduleId })\n registered += 1\n }\n }\n return registered\n}\n\n/**\n * Apply the list of `aiToolOverrides` exports collected by the generator\n * (one entry per module) to the live tool registry. Tools mapped to\n * `null` are unregistered; tools mapped to a full definition replace the\n * existing registration. Module load order controls precedence \u2014 last\n * entry wins. The `modules.ts`-tier and programmatic overrides (set via\n * {@link applyAiOverridesFromEnabledModules} and\n * {@link applyAiToolOverrides}) supersede file-based entries.\n *\n * Safe to call when no override file is present (the entries array is\n * empty); it is a no-op then.\n */\nexport function applyAiToolOverrideEntries(\n entries: readonly AiToolOverrideConfigEntry[],\n): void {\n const overrideMap = composeToolOverrideMap(entries)\n if (Object.keys(overrideMap).length === 0) return\n const baseTools = toolRegistry.getTools() as Map<string, McpToolDefinition>\n const overridden = applyToolOverrideMap<McpToolDefinition>(baseTools, overrideMap)\n for (const [name, value] of Object.entries(overrideMap)) {\n if (value === null) {\n unregisterMcpTool(name)\n logger.info('Tool disabled by override', { toolName: name })\n continue\n }\n const next = overridden.get(name)\n if (!next) continue\n // Re-register through the public path so moduleMap stays consistent.\n registerMcpTool(next as McpToolDefinition, { moduleId: 'ai_overrides' })\n logger.info('Tool replaced by override', { toolName: name })\n }\n}\n\n/**\n * Import the generated `ai-tools.generated.ts` registry.\n *\n * Dual-strategy so the same code works in every runtime without changing the\n * existing Next.js / agents-framework behavior:\n * 1. Prefer the `@/` path-alias import. Inside the Next.js bundler this\n * resolves at build time exactly as before \u2014 the in-app agents framework\n * path is untouched.\n * 2. Fall back to locating + compiling the file from disk when the alias\n * import throws. That only happens in a standalone Node process (the\n * `mcp:dev` / `mcp:serve` MCP servers), where `@/` is not a real package\n * specifier and Node throws `ERR_MODULE_NOT_FOUND`.\n *\n * Returns `null` when no generated file exists (pre-generate builds, tests).\n */\nasync function importGeneratedAiToolsModule(): Promise<Record<string, unknown> | null> {\n try {\n return (await import(\n '@/.mercato/generated/ai-tools.generated'\n )) as Record<string, unknown>\n } catch {\n const tsPath = findGeneratedFile('ai-tools.generated.ts')\n if (!tsPath) return null\n return compileAndImportGenerated(tsPath)\n }\n}\n\n/**\n * Read `aiToolOverrideEntries` from `ai-tools.generated.ts` and apply\n * them on top of the live tool registry. Safe to call when the file is\n * missing (pre-generate builds, tests) \u2014 applies only the\n * `modules.ts`-tier and programmatic overrides in that case.\n */\nexport async function loadGeneratedAiToolOverrides(): Promise<void> {\n let entries: AiToolOverrideConfigEntry[] = []\n try {\n const mod = (await importGeneratedAiToolsModule()) as {\n aiToolOverrideEntries?: unknown[]\n } | null\n entries =\n mod && Array.isArray(mod.aiToolOverrideEntries)\n ? (mod.aiToolOverrideEntries as AiToolOverrideConfigEntry[])\n : []\n } catch {\n // No override file generated.\n }\n applyAiToolOverrideEntries(entries)\n}\n\n/**\n * Load the generated `ai-tools.generated.ts` file emitted by\n * `yarn generate` and register every declared module tool through the\n * existing `registerMcpTool` path. Safe to call when the generated file\n * is missing (e.g., tests or pre-generate builds) \u2014 returns 0.\n */\nexport async function loadGeneratedModuleAiTools(): Promise<number> {\n try {\n const mod = (await importGeneratedAiToolsModule()) as {\n aiToolConfigEntries?: AiToolConfigEntry[]\n } | null\n if (!mod) {\n // No generated file (pre-generate build or tests).\n return 0\n }\n const entries = Array.isArray(mod.aiToolConfigEntries)\n ? mod.aiToolConfigEntries\n : []\n const count = registerGeneratedAiToolEntries(entries)\n // Apply module-to-module + programmatic tool overrides AFTER the base\n // registrations so disable / replace semantics work end-to-end.\n try {\n await loadGeneratedAiToolOverrides()\n } catch (error) {\n logger.error('Failed to apply tool overrides', { err: error })\n }\n return count\n } catch (error) {\n logger.error('Could not load ai-tools.generated.ts (module tools unavailable)', { err: error })\n return 0\n }\n}\n\n/**\n * Dynamically load tools from known module paths.\n * This is called during MCP server startup.\n */\nasync function loadAllModuleToolsUncached(): Promise<void> {\n // 1. Register built-in tools\n registerMcpTool(contextWhoamiTool, { moduleId: 'context' })\n logger.debug('Registered built-in context_whoami tool')\n\n // 2. Register Code Mode tools (search + execute)\n // These two tools replace the previous api_discover, call_api, discover_schema,\n // and all module-specific AI tools. The AI writes JavaScript that runs in a\n // node:vm sandbox with access to the OpenAPI spec and api.request().\n try {\n const { loadCodeModeTools } = await import('./codemode-tools')\n const toolCount = await loadCodeModeTools()\n logger.info('Registered Code Mode tools', { toolCount })\n } catch (error) {\n logger.error('Could not load Code Mode tools', { err: error })\n }\n\n // 3. Register module-contributed tools from ai-tools.generated.ts.\n // Code Mode stays untouched; module tools are additive. Missing\n // generated file is not fatal (pre-generate builds, tests).\n try {\n const moduleToolCount = await loadGeneratedModuleAiTools()\n logger.info('Registered module-contributed AI tools', { toolCount: moduleToolCount })\n } catch (error) {\n logger.error('Could not load module AI tools', { err: error })\n }\n\n // 4. Register the API route manifest so API-backed module tools can run.\n // Their handlers delegate to createAiApiOperationRunner, which fails closed\n // with \"No API route manifest registered\" outside the Next.js bootstrap.\n try {\n const routeCount = await ensureApiRouteManifestsRegistered()\n if (routeCount > 0) {\n logger.info('Registered API route manifests for API-backed tools', { routeCount })\n }\n } catch (error) {\n logger.error('Could not register API route manifests', { err: error })\n }\n}\n\nexport function loadAllModuleTools(): Promise<void> {\n if (!allModuleToolsLoad) {\n allModuleToolsLoad = loadAllModuleToolsUncached().catch((error) => {\n allModuleToolsLoad = null\n throw error\n })\n }\n return allModuleToolsLoad\n}\n\n/** @__internal Test-only hook \u2014 reset the process-wide loader memo. */\nexport function resetAllModuleToolsLoadForTests(): void {\n allModuleToolsLoad = null\n}\n\n/**\n * Index all registered tools for hybrid search discovery.\n * This should be called after loadAllModuleTools() when the search service is available.\n *\n * @param searchService - The search service from DI container\n * @param force - Force re-indexing even if checksums match\n * @returns Indexing result with statistics\n */\nexport async function indexToolsForSearch(\n searchService: SearchService,\n force = false\n): Promise<{\n indexed: number\n skipped: number\n strategies: string[]\n checksum: string\n}> {\n const registry = getToolRegistry()\n const toolSearchService = new ToolSearchService(searchService, registry)\n\n try {\n const result = await toolSearchService.indexTools(force)\n\n logger.info('Indexed tools for search', { indexed: result.indexed })\n logger.debug('Search strategies available', { strategies: result.strategies.join(',') })\n\n if (result.skipped > 0) {\n logger.debug('Skipped unchanged tools', { skipped: result.skipped })\n }\n\n return result\n } catch (error) {\n logger.error('Failed to index tools for search', { err: error })\n throw error\n }\n}\n\n/**\n * Create a ToolSearchService instance for tool discovery.\n * Use this to get a configured service for discovering relevant tools.\n *\n * @param searchService - The search service from DI container\n * @returns Configured ToolSearchService\n */\nexport function createToolSearchService(searchService: SearchService): ToolSearchService {\n const registry = getToolRegistry()\n return new ToolSearchService(searchService, registry)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -9,7 +9,7 @@ class ToolRegistryImpl {
9
9
  if (!tool?.name) {
10
10
  throw new Error("MCP tool must define a name");
11
11
  }
12
- if (this.tools.has(tool.name)) {
12
+ if (this.tools.has(tool.name) && options?.moduleId !== "ai_overrides") {
13
13
  logger.warn("Tool already registered, overwriting", { toolName: tool.name });
14
14
  }
15
15
  this.tools.set(tool.name, tool);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/tool-registry.ts"],
4
- "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { McpToolDefinition, McpToolRegistry, ToolRegistrationOptions } from './types'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Global tool registry singleton.\n * Modules call registerMcpTool() to add their tools.\n */\nclass ToolRegistryImpl implements McpToolRegistry {\n private tools = new Map<string, McpToolDefinition>()\n private moduleMap = new Map<string, string[]>()\n\n registerTool<TInput, TOutput>(\n tool: McpToolDefinition<TInput, TOutput>,\n options?: ToolRegistrationOptions\n ): void {\n if (!tool?.name) {\n throw new Error('MCP tool must define a name')\n }\n\n if (this.tools.has(tool.name)) {\n logger.warn('Tool already registered, overwriting', { toolName: tool.name })\n }\n\n this.tools.set(tool.name, tool as McpToolDefinition)\n\n if (options?.moduleId) {\n const existing = this.moduleMap.get(options.moduleId) ?? []\n if (!existing.includes(tool.name)) {\n existing.push(tool.name)\n }\n this.moduleMap.set(options.moduleId, existing)\n }\n }\n\n getTools(): Map<string, McpToolDefinition> {\n return new Map(this.tools)\n }\n\n getTool(name: string): McpToolDefinition | undefined {\n return this.tools.get(name)\n }\n\n listToolNames(): string[] {\n return Array.from(this.tools.keys())\n }\n\n listToolsByModule(moduleId: string): string[] {\n return this.moduleMap.get(moduleId) ?? []\n }\n\n unregisterTool(name: string): void {\n this.tools.delete(name)\n for (const [moduleId, tools] of this.moduleMap.entries()) {\n const index = tools.indexOf(name)\n if (index !== -1) {\n tools.splice(index, 1)\n this.moduleMap.set(moduleId, tools)\n }\n }\n }\n\n clear(): void {\n this.tools.clear()\n this.moduleMap.clear()\n }\n}\n\nexport const toolRegistry = new ToolRegistryImpl()\n\n/**\n * Register an MCP tool from any module.\n *\n * Note: Tool names must match the pattern ^[a-zA-Z0-9_-]{1,128}$\n * (no dots allowed - use underscores instead).\n *\n * @example\n * ```typescript\n * import { registerMcpTool } from '@open-mercato/ai-assistant/tools'\n * import { z } from 'zod'\n *\n * registerMcpTool({\n * name: 'customers_search',\n * description: 'Search for customers by name or email',\n * inputSchema: z.object({\n * query: z.string(),\n * limit: z.number().optional().default(10),\n * }),\n * requiredFeatures: ['customers.people.view'],\n * handler: async (input, ctx) => {\n * const queryEngine = ctx.container.resolve('queryEngine')\n * // ... implementation\n * }\n * }, { moduleId: 'customers' })\n * ```\n */\nexport function registerMcpTool<TInput, TOutput>(\n tool: McpToolDefinition<TInput, TOutput>,\n options?: ToolRegistrationOptions\n): void {\n toolRegistry.registerTool(tool, options)\n}\n\n/**\n * Get the global tool registry instance.\n */\nexport function getToolRegistry(): McpToolRegistry {\n return toolRegistry\n}\n\n/**\n * Unregister an MCP tool by name.\n */\nexport function unregisterMcpTool(name: string): void {\n toolRegistry.unregisterTool(name)\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAG7B,MAAM,SAAS,aAAa,cAAc;AAM1C,MAAM,iBAA4C;AAAA,EAAlD;AACE,SAAQ,QAAQ,oBAAI,IAA+B;AACnD,SAAQ,YAAY,oBAAI,IAAsB;AAAA;AAAA,EAE9C,aACE,MACA,SACM;AACN,QAAI,CAAC,MAAM,MAAM;AACf,YAAM,IAAI,MAAM,6BAA6B;AAAA,IAC/C;AAEA,QAAI,KAAK,MAAM,IAAI,KAAK,IAAI,GAAG;AAC7B,aAAO,KAAK,wCAAwC,EAAE,UAAU,KAAK,KAAK,CAAC;AAAA,IAC7E;AAEA,SAAK,MAAM,IAAI,KAAK,MAAM,IAAyB;AAEnD,QAAI,SAAS,UAAU;AACrB,YAAM,WAAW,KAAK,UAAU,IAAI,QAAQ,QAAQ,KAAK,CAAC;AAC1D,UAAI,CAAC,SAAS,SAAS,KAAK,IAAI,GAAG;AACjC,iBAAS,KAAK,KAAK,IAAI;AAAA,MACzB;AACA,WAAK,UAAU,IAAI,QAAQ,UAAU,QAAQ;AAAA,IAC/C;AAAA,EACF;AAAA,EAEA,WAA2C;AACzC,WAAO,IAAI,IAAI,KAAK,KAAK;AAAA,EAC3B;AAAA,EAEA,QAAQ,MAA6C;AACnD,WAAO,KAAK,MAAM,IAAI,IAAI;AAAA,EAC5B;AAAA,EAEA,gBAA0B;AACxB,WAAO,MAAM,KAAK,KAAK,MAAM,KAAK,CAAC;AAAA,EACrC;AAAA,EAEA,kBAAkB,UAA4B;AAC5C,WAAO,KAAK,UAAU,IAAI,QAAQ,KAAK,CAAC;AAAA,EAC1C;AAAA,EAEA,eAAe,MAAoB;AACjC,SAAK,MAAM,OAAO,IAAI;AACtB,eAAW,CAAC,UAAU,KAAK,KAAK,KAAK,UAAU,QAAQ,GAAG;AACxD,YAAM,QAAQ,MAAM,QAAQ,IAAI;AAChC,UAAI,UAAU,IAAI;AAChB,cAAM,OAAO,OAAO,CAAC;AACrB,aAAK,UAAU,IAAI,UAAU,KAAK;AAAA,MACpC;AAAA,IACF;AAAA,EACF;AAAA,EAEA,QAAc;AACZ,SAAK,MAAM,MAAM;AACjB,SAAK,UAAU,MAAM;AAAA,EACvB;AACF;AAEO,MAAM,eAAe,IAAI,iBAAiB;AA4B1C,SAAS,gBACd,MACA,SACM;AACN,eAAa,aAAa,MAAM,OAAO;AACzC;AAKO,SAAS,kBAAmC;AACjD,SAAO;AACT;AAKO,SAAS,kBAAkB,MAAoB;AACpD,eAAa,eAAe,IAAI;AAClC;",
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { McpToolDefinition, McpToolRegistry, ToolRegistrationOptions } from './types'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Global tool registry singleton.\n * Modules call registerMcpTool() to add their tools.\n */\nclass ToolRegistryImpl implements McpToolRegistry {\n private tools = new Map<string, McpToolDefinition>()\n private moduleMap = new Map<string, string[]>()\n\n registerTool<TInput, TOutput>(\n tool: McpToolDefinition<TInput, TOutput>,\n options?: ToolRegistrationOptions\n ): void {\n if (!tool?.name) {\n throw new Error('MCP tool must define a name')\n }\n\n if (this.tools.has(tool.name) && options?.moduleId !== 'ai_overrides') {\n logger.warn('Tool already registered, overwriting', { toolName: tool.name })\n }\n\n this.tools.set(tool.name, tool as McpToolDefinition)\n\n if (options?.moduleId) {\n const existing = this.moduleMap.get(options.moduleId) ?? []\n if (!existing.includes(tool.name)) {\n existing.push(tool.name)\n }\n this.moduleMap.set(options.moduleId, existing)\n }\n }\n\n getTools(): Map<string, McpToolDefinition> {\n return new Map(this.tools)\n }\n\n getTool(name: string): McpToolDefinition | undefined {\n return this.tools.get(name)\n }\n\n listToolNames(): string[] {\n return Array.from(this.tools.keys())\n }\n\n listToolsByModule(moduleId: string): string[] {\n return this.moduleMap.get(moduleId) ?? []\n }\n\n unregisterTool(name: string): void {\n this.tools.delete(name)\n for (const [moduleId, tools] of this.moduleMap.entries()) {\n const index = tools.indexOf(name)\n if (index !== -1) {\n tools.splice(index, 1)\n this.moduleMap.set(moduleId, tools)\n }\n }\n }\n\n clear(): void {\n this.tools.clear()\n this.moduleMap.clear()\n }\n}\n\nexport const toolRegistry = new ToolRegistryImpl()\n\n/**\n * Register an MCP tool from any module.\n *\n * Note: Tool names must match the pattern ^[a-zA-Z0-9_-]{1,128}$\n * (no dots allowed - use underscores instead).\n *\n * @example\n * ```typescript\n * import { registerMcpTool } from '@open-mercato/ai-assistant/tools'\n * import { z } from 'zod'\n *\n * registerMcpTool({\n * name: 'customers_search',\n * description: 'Search for customers by name or email',\n * inputSchema: z.object({\n * query: z.string(),\n * limit: z.number().optional().default(10),\n * }),\n * requiredFeatures: ['customers.people.view'],\n * handler: async (input, ctx) => {\n * const queryEngine = ctx.container.resolve('queryEngine')\n * // ... implementation\n * }\n * }, { moduleId: 'customers' })\n * ```\n */\nexport function registerMcpTool<TInput, TOutput>(\n tool: McpToolDefinition<TInput, TOutput>,\n options?: ToolRegistrationOptions\n): void {\n toolRegistry.registerTool(tool, options)\n}\n\n/**\n * Get the global tool registry instance.\n */\nexport function getToolRegistry(): McpToolRegistry {\n return toolRegistry\n}\n\n/**\n * Unregister an MCP tool by name.\n */\nexport function unregisterMcpTool(name: string): void {\n toolRegistry.unregisterTool(name)\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAG7B,MAAM,SAAS,aAAa,cAAc;AAM1C,MAAM,iBAA4C;AAAA,EAAlD;AACE,SAAQ,QAAQ,oBAAI,IAA+B;AACnD,SAAQ,YAAY,oBAAI,IAAsB;AAAA;AAAA,EAE9C,aACE,MACA,SACM;AACN,QAAI,CAAC,MAAM,MAAM;AACf,YAAM,IAAI,MAAM,6BAA6B;AAAA,IAC/C;AAEA,QAAI,KAAK,MAAM,IAAI,KAAK,IAAI,KAAK,SAAS,aAAa,gBAAgB;AACrE,aAAO,KAAK,wCAAwC,EAAE,UAAU,KAAK,KAAK,CAAC;AAAA,IAC7E;AAEA,SAAK,MAAM,IAAI,KAAK,MAAM,IAAyB;AAEnD,QAAI,SAAS,UAAU;AACrB,YAAM,WAAW,KAAK,UAAU,IAAI,QAAQ,QAAQ,KAAK,CAAC;AAC1D,UAAI,CAAC,SAAS,SAAS,KAAK,IAAI,GAAG;AACjC,iBAAS,KAAK,KAAK,IAAI;AAAA,MACzB;AACA,WAAK,UAAU,IAAI,QAAQ,UAAU,QAAQ;AAAA,IAC/C;AAAA,EACF;AAAA,EAEA,WAA2C;AACzC,WAAO,IAAI,IAAI,KAAK,KAAK;AAAA,EAC3B;AAAA,EAEA,QAAQ,MAA6C;AACnD,WAAO,KAAK,MAAM,IAAI,IAAI;AAAA,EAC5B;AAAA,EAEA,gBAA0B;AACxB,WAAO,MAAM,KAAK,KAAK,MAAM,KAAK,CAAC;AAAA,EACrC;AAAA,EAEA,kBAAkB,UAA4B;AAC5C,WAAO,KAAK,UAAU,IAAI,QAAQ,KAAK,CAAC;AAAA,EAC1C;AAAA,EAEA,eAAe,MAAoB;AACjC,SAAK,MAAM,OAAO,IAAI;AACtB,eAAW,CAAC,UAAU,KAAK,KAAK,KAAK,UAAU,QAAQ,GAAG;AACxD,YAAM,QAAQ,MAAM,QAAQ,IAAI;AAChC,UAAI,UAAU,IAAI;AAChB,cAAM,OAAO,OAAO,CAAC;AACrB,aAAK,UAAU,IAAI,UAAU,KAAK;AAAA,MACpC;AAAA,IACF;AAAA,EACF;AAAA,EAEA,QAAc;AACZ,SAAK,MAAM,MAAM;AACjB,SAAK,UAAU,MAAM;AAAA,EACvB;AACF;AAEO,MAAM,eAAe,IAAI,iBAAiB;AA4B1C,SAAS,gBACd,MACA,SACM;AACN,eAAa,aAAa,MAAM,OAAO;AACzC;AAKO,SAAS,kBAAmC;AACjD,SAAO;AACT;AAKO,SAAS,kBAAkB,MAAoB;AACpD,eAAa,eAAe,IAAI;AAClC;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ai-assistant",
3
- "version": "0.6.8-develop.6930.1.1e5976efc3",
3
+ "version": "0.6.8-develop.6940.1.177ea30c6e",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -99,16 +99,16 @@
99
99
  "zod-to-json-schema": "^3.25.2"
100
100
  },
101
101
  "peerDependencies": {
102
- "@open-mercato/shared": "0.6.8-develop.6930.1.1e5976efc3",
103
- "@open-mercato/ui": "0.6.8-develop.6930.1.1e5976efc3",
102
+ "@open-mercato/shared": "0.6.8-develop.6940.1.177ea30c6e",
103
+ "@open-mercato/ui": "0.6.8-develop.6940.1.177ea30c6e",
104
104
  "react": "^19.0.0",
105
105
  "react-dom": "^19.0.0",
106
106
  "zod": ">=3.23.0"
107
107
  },
108
108
  "devDependencies": {
109
- "@open-mercato/cli": "0.6.8-develop.6930.1.1e5976efc3",
110
- "@open-mercato/shared": "0.6.8-develop.6930.1.1e5976efc3",
111
- "@open-mercato/ui": "0.6.8-develop.6930.1.1e5976efc3",
109
+ "@open-mercato/cli": "0.6.8-develop.6940.1.177ea30c6e",
110
+ "@open-mercato/shared": "0.6.8-develop.6940.1.177ea30c6e",
111
+ "@open-mercato/ui": "0.6.8-develop.6940.1.177ea30c6e",
112
112
  "@types/react": "^19.2.17",
113
113
  "@types/react-dom": "^19.2.3",
114
114
  "react": "19.2.8",
@@ -1,10 +1,10 @@
1
1
  import { createLogger } from '@open-mercato/shared/lib/logger'
2
2
  import { NextResponse, type NextRequest } from 'next/server'
3
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
- import { zodToJsonSchema } from 'zod-to-json-schema'
5
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
6
  import { getToolRegistry } from '../../lib/tool-registry'
7
+ import { toolInputJsonSchema } from '../../lib/tool-input-schema'
8
8
  import { loadAllModuleTools } from '../../lib/tool-loader'
9
9
  import { hasRequiredFeatures } from '../../lib/auth'
10
10
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
@@ -56,7 +56,7 @@ export async function GET(req: NextRequest) {
56
56
  return {
57
57
  name: tool.name,
58
58
  description: tool.description,
59
- inputSchema: zodToJsonSchema(tool.inputSchema as any) as Record<string, unknown>,
59
+ inputSchema: toolInputJsonSchema(tool.inputSchema),
60
60
  module: nameParts[0] || 'other',
61
61
  }
62
62
  })
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url'
5
5
  import * as registry from '@open-mercato/shared/modules/registry'
6
6
  import {
7
7
  rewriteGeneratedAliasImports,
8
+ collectAppLocalSpecifiers,
8
9
  escapeUnsafeJsStringChars,
9
10
  findGeneratedFile,
10
11
  compileAndImportGenerated,
@@ -15,6 +16,18 @@ function makeTempDir(): string {
15
16
  return fs.mkdtempSync(path.join(os.tmpdir(), 'om-gen-loader-'))
16
17
  }
17
18
 
19
+ // The compile path needs a real app tsconfig: it is what esbuild reads for the
20
+ // app's TypeScript settings, and its absence is the loader's "not an app root"
21
+ // signal.
22
+ function makeTempAppRoot(): string {
23
+ const appRoot = makeTempDir()
24
+ fs.writeFileSync(
25
+ path.join(appRoot, 'tsconfig.json'),
26
+ JSON.stringify({ compilerOptions: { target: 'esnext', module: 'esnext', moduleResolution: 'bundler' } }),
27
+ )
28
+ return appRoot
29
+ }
30
+
18
31
  // Mirror the production sanitizer so expectations match how the rewriter
19
32
  // embeds a resolved path into the generated source. For ordinary file URLs the
20
33
  // escape is a no-op, but routing the expected literal through the same helper
@@ -121,6 +134,45 @@ describe('rewriteGeneratedAliasImports', () => {
121
134
  const expectedUrl = pathToFileURL(path.join(moduleDir, 'ai-tools.ts')).href
122
135
  expect(out).toBe(`import * as AI_TOOLS from ${safeJsLiteral(expectedUrl)}`)
123
136
  })
137
+
138
+ it('prefers a compiled artifact over the raw `.ts` target when one was built', () => {
139
+ const appRoot = makeTempDir()
140
+ const moduleDir = path.join(appRoot, 'src', 'modules', 'media_campaigns')
141
+ fs.mkdirSync(moduleDir, { recursive: true })
142
+ fs.writeFileSync(path.join(moduleDir, 'ai-tools.ts'), 'export const aiTools = []\n')
143
+ const artifact = path.join(appRoot, '.mercato', 'generated', 'app-modules', 'ai-tools.mjs')
144
+
145
+ const out = rewriteGeneratedAliasImports(
146
+ `import * as AI_TOOLS from "../../src/modules/media_campaigns/ai-tools"`,
147
+ appRoot,
148
+ new Map([['../../src/modules/media_campaigns/ai-tools', artifact]]),
149
+ )
150
+
151
+ expect(out).toBe(`import * as AI_TOOLS from ${safeJsLiteral(pathToFileURL(artifact).href)}`)
152
+ expect(out).not.toContain('ai-tools.ts')
153
+ })
154
+ })
155
+
156
+ describe('collectAppLocalSpecifiers', () => {
157
+ it('collects static and dynamic @app specifiers once each, and nothing else', () => {
158
+ const source = [
159
+ `import * as A from "../../src/modules/example/ai-tools"`,
160
+ `const b = () => import("../../src/modules/example/ai-agents")`,
161
+ `import * as C from "../../src/modules/example/ai-tools"`,
162
+ `import { z } from "zod"`,
163
+ `import D from "@/.mercato/generated/entities"`,
164
+ `import E from "@open-mercato/core/modules/customers/ai-tools"`,
165
+ ].join('\n')
166
+
167
+ expect(collectAppLocalSpecifiers(source)).toEqual([
168
+ '../../src/modules/example/ai-agents',
169
+ '../../src/modules/example/ai-tools',
170
+ ])
171
+ })
172
+
173
+ it('returns nothing for a registry that only references package-backed modules', () => {
174
+ expect(collectAppLocalSpecifiers(`import * as A from "@open-mercato/core/x"`)).toEqual([])
175
+ })
124
176
  })
125
177
 
126
178
  describe('escapeUnsafeJsStringChars', () => {
@@ -192,7 +244,7 @@ describe('findGeneratedFile', () => {
192
244
 
193
245
  describe('compileAndImportGenerated', () => {
194
246
  it('uses a Jest-safe CJS artifact instead of an existing ESM .mjs artifact', async () => {
195
- const appRoot = makeTempDir()
247
+ const appRoot = makeTempAppRoot()
196
248
  const generatedDir = path.join(appRoot, '.mercato', 'generated')
197
249
  const moduleDir = path.join(appRoot, 'src', 'modules', 'example')
198
250
  fs.mkdirSync(generatedDir, { recursive: true })
@@ -217,6 +269,81 @@ describe('compileAndImportGenerated', () => {
217
269
  expect(mod.allAiAgents).toEqual([{ id: 'example.agent' }])
218
270
  expect(fs.existsSync(path.join(generatedDir, 'ai-agents.generated.jest.cjs'))).toBe(true)
219
271
  })
272
+
273
+ // Regression for the TC-INT-AI-TOOLS failure on the first @app-local
274
+ // `ai-tools.ts`: the tool-test CLI died with
275
+ // "Cannot find module '.../src/modules/example/di' imported from
276
+ // .../src/modules/example/ai-tools.ts"
277
+ // before producing a report, so the whole tool registry failed to load.
278
+ // Pointing Node at the raw `.ts` only works while the module's entire graph
279
+ // stays inside what type stripping accepts — an extensionless relative
280
+ // specifier already breaks it, and the enum below breaks it again even with
281
+ // the extension spelled out.
282
+ it('loads an @app module whose own graph Node cannot import as raw TypeScript', async () => {
283
+ const appRoot = makeTempAppRoot()
284
+ const generatedDir = path.join(appRoot, '.mercato', 'generated')
285
+ const moduleDir = path.join(appRoot, 'src', 'modules', 'example')
286
+ fs.mkdirSync(generatedDir, { recursive: true })
287
+ fs.mkdirSync(moduleDir, { recursive: true })
288
+
289
+ fs.writeFileSync(
290
+ path.join(moduleDir, 'di.ts'),
291
+ 'export const EXAMPLE_TODO_SUMMARY_SERVICE = "exampleTodoSummaryService"\n',
292
+ )
293
+ fs.writeFileSync(
294
+ path.join(moduleDir, 'entities.ts'),
295
+ 'export enum ExampleCustomerPriority { High = "high" }\n',
296
+ )
297
+ fs.writeFileSync(
298
+ path.join(moduleDir, 'ai-tools.ts'),
299
+ [
300
+ `import { EXAMPLE_TODO_SUMMARY_SERVICE } from './di'`,
301
+ `import { ExampleCustomerPriority } from './entities'`,
302
+ 'export const aiTools = [{ name: "example.get_todo_summary", service: EXAMPLE_TODO_SUMMARY_SERVICE, priority: ExampleCustomerPriority.High }]',
303
+ ].join('\n'),
304
+ )
305
+
306
+ const generatedPath = path.join(generatedDir, 'ai-tools.generated.ts')
307
+ fs.writeFileSync(
308
+ generatedPath,
309
+ [
310
+ 'import * as ExampleTools from "../../src/modules/example/ai-tools"',
311
+ 'export const aiToolConfigEntries = ExampleTools.aiTools',
312
+ ].join('\n'),
313
+ )
314
+
315
+ const mod = await compileAndImportGenerated(generatedPath)
316
+
317
+ expect(mod.aiToolConfigEntries).toEqual([
318
+ { name: 'example.get_todo_summary', service: 'exampleTodoSummaryService', priority: 'high' },
319
+ ])
320
+ expect(fs.existsSync(path.join(generatedDir, 'app-modules'))).toBe(true)
321
+ })
322
+
323
+ it('falls back to the raw source path when the app module cannot be compiled', async () => {
324
+ // No tsconfig.json, so the root is not a compilable app: the loader must
325
+ // degrade to the previous behavior rather than drop the module's entries.
326
+ const appRoot = makeTempDir()
327
+ const generatedDir = path.join(appRoot, '.mercato', 'generated')
328
+ const moduleDir = path.join(appRoot, 'src', 'modules', 'example')
329
+ fs.mkdirSync(generatedDir, { recursive: true })
330
+ fs.mkdirSync(moduleDir, { recursive: true })
331
+ fs.writeFileSync(path.join(moduleDir, 'ai-tools.ts'), 'export const aiTools = [{ name: "example.raw" }]\n')
332
+
333
+ const generatedPath = path.join(generatedDir, 'ai-tools.generated.ts')
334
+ fs.writeFileSync(
335
+ generatedPath,
336
+ [
337
+ 'import * as ExampleTools from "../../src/modules/example/ai-tools"',
338
+ 'export const aiToolConfigEntries = ExampleTools.aiTools',
339
+ ].join('\n'),
340
+ )
341
+
342
+ const mod = await compileAndImportGenerated(generatedPath)
343
+
344
+ expect(mod.aiToolConfigEntries).toEqual([{ name: 'example.raw' }])
345
+ expect(fs.existsSync(path.join(generatedDir, 'app-modules'))).toBe(false)
346
+ })
220
347
  })
221
348
 
222
349
  describe('ensureApiRouteManifestsRegistered', () => {
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod'
2
+ import { toolInputJsonSchema } from '../tool-input-schema'
3
+
4
+ /**
5
+ * The regression these cover is silent by construction: the Zod 3 converter this replaced did
6
+ * not throw on a Zod 4 schema, it returned `{ "$schema": … }` and nothing else. So every
7
+ * assertion below is about the schema having CONTENT — a converter that quietly degrades again
8
+ * has to fail here rather than in a model's confused tool call.
9
+ */
10
+ describe('toolInputJsonSchema', () => {
11
+ it('publishes properties and required fields for a real tool input', () => {
12
+ const schema = toolInputJsonSchema(z.object({
13
+ customerId: z.string().uuid(),
14
+ note: z.string().optional(),
15
+ }))
16
+
17
+ expect(schema.type).toBe('object')
18
+ expect(Object.keys(schema.properties as Record<string, unknown>).sort()).toEqual(['customerId', 'note'])
19
+ expect(schema.required).toEqual(['customerId'])
20
+ })
21
+
22
+ it('distinguishes an argument-free tool from one that takes arguments', () => {
23
+ const empty = toolInputJsonSchema(z.object({}))
24
+ const withArgs = toolInputJsonSchema(z.object({ customerId: z.string() }))
25
+
26
+ expect(Object.keys(empty.properties as Record<string, unknown>)).toEqual([])
27
+ expect(Object.keys(withArgs.properties as Record<string, unknown>)).toEqual(['customerId'])
28
+ // The bug being guarded: the two used to serialize identically.
29
+ expect(JSON.stringify(empty)).not.toBe(JSON.stringify(withArgs))
30
+ })
31
+
32
+ it('describes what the caller sends, so a defaulted field is not published as required', () => {
33
+ const schema = toolInputJsonSchema(z.object({
34
+ limit: z.number().default(10),
35
+ query: z.string(),
36
+ }))
37
+
38
+ expect(schema.required).toEqual(['query'])
39
+ expect(Object.keys(schema.properties as Record<string, unknown>).sort()).toEqual(['limit', 'query'])
40
+ })
41
+
42
+ it('publishes an unrepresentable field as unconstrained instead of failing the whole listing', () => {
43
+ const schema = toolInputJsonSchema(z.object({ when: z.date(), title: z.string() }))
44
+
45
+ expect(Object.keys(schema.properties as Record<string, unknown>).sort()).toEqual(['title', 'when'])
46
+ expect((schema.properties as Record<string, unknown>).when).toEqual({})
47
+ })
48
+
49
+ it('degrades to an empty object schema when there is no schema at all', () => {
50
+ expect(toolInputJsonSchema(undefined)).toEqual({ type: 'object', properties: {} })
51
+ })
52
+ })
@@ -1,11 +1,16 @@
1
1
  import { z } from 'zod'
2
2
  import {
3
+ applyAiToolOverrideEntries,
4
+ loadAllModuleTools,
3
5
  registerGeneratedAiToolEntries,
6
+ resetAllModuleToolsLoadForTests,
4
7
  type AiToolConfigEntry,
5
8
  } from '../tool-loader'
6
9
  import { toolRegistry } from '../tool-registry'
7
10
  import { convertMcpToolsToAiSdk } from '../mcp-tool-adapter'
8
11
  import type { InProcessMcpClient, ToolInfoWithSchema } from '../in-process-client'
12
+ import * as codeModeTools from '../codemode-tools'
13
+ import * as generatedRegistryLoader from '../generated-registry-loader'
9
14
 
10
15
  function makeTool(name: string) {
11
16
  return {
@@ -67,6 +72,30 @@ describe('registerGeneratedAiToolEntries', () => {
67
72
  warnSpy.mockRestore()
68
73
  })
69
74
 
75
+ it('applies intentional overrides without reporting a duplicate registration', () => {
76
+ const baseTool = makeTool('example.get_todo_summary')
77
+ registerGeneratedAiToolEntries([{ moduleId: 'example', tools: [baseTool] }])
78
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
79
+
80
+ applyAiToolOverrideEntries([
81
+ {
82
+ moduleId: 'example',
83
+ overrides: {
84
+ [baseTool.name]: {
85
+ ...baseTool,
86
+ description: 'Tool replacement applied through the override layer',
87
+ },
88
+ },
89
+ },
90
+ ])
91
+
92
+ expect(toolRegistry.getTool(baseTool.name)?.description).toBe(
93
+ 'Tool replacement applied through the override layer',
94
+ )
95
+ expect(warnSpy.mock.calls.flat().join(' ')).not.toContain('Tool already registered')
96
+ warnSpy.mockRestore()
97
+ })
98
+
70
99
  it('skips malformed tool objects instead of throwing', () => {
71
100
  const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
72
101
  const entries: AiToolConfigEntry[] = [
@@ -123,3 +152,39 @@ describe('registerGeneratedAiToolEntries', () => {
123
152
  expect(out).toContain('"ok": true')
124
153
  })
125
154
  })
155
+
156
+ describe('loadAllModuleTools', () => {
157
+ beforeEach(() => {
158
+ toolRegistry.clear()
159
+ resetAllModuleToolsLoadForTests()
160
+ jest.restoreAllMocks()
161
+ })
162
+
163
+ afterEach(() => {
164
+ toolRegistry.clear()
165
+ resetAllModuleToolsLoadForTests()
166
+ jest.restoreAllMocks()
167
+ })
168
+
169
+ it('shares one load across concurrent and sequential callers', async () => {
170
+ const codeModeSpy = jest.spyOn(codeModeTools, 'loadCodeModeTools').mockResolvedValue(2)
171
+ const routeManifestSpy = jest
172
+ .spyOn(generatedRegistryLoader, 'ensureApiRouteManifestsRegistered')
173
+ .mockResolvedValue(0)
174
+ jest.spyOn(generatedRegistryLoader, 'findGeneratedFile').mockReturnValue(null)
175
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
176
+
177
+ const firstLoad = loadAllModuleTools()
178
+ const concurrentLoad = loadAllModuleTools()
179
+
180
+ expect(concurrentLoad).toBe(firstLoad)
181
+ await Promise.all([firstLoad, concurrentLoad])
182
+ expect(loadAllModuleTools()).toBe(firstLoad)
183
+ await loadAllModuleTools()
184
+
185
+ expect(codeModeSpy).toHaveBeenCalledTimes(1)
186
+ expect(routeManifestSpy).toHaveBeenCalledTimes(1)
187
+ expect(toolRegistry.listToolNames()).toEqual(['context_whoami'])
188
+ expect(warnSpy.mock.calls.flat().join(' ')).not.toContain('Tool already registered')
189
+ })
190
+ })