@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +9 -33
- package/dist/modules/ai_assistant/api/tools/route.js +2 -2
- package/dist/modules/ai_assistant/api/tools/route.js.map +2 -2
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js +55 -10
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
- package/dist/modules/ai_assistant/lib/in-process-client.js +2 -2
- package/dist/modules/ai_assistant/lib/in-process-client.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-server.js +2 -3
- package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/tool-input-schema.js +13 -0
- package/dist/modules/ai_assistant/lib/tool-input-schema.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-loader.js +16 -2
- package/dist/modules/ai_assistant/lib/tool-loader.js.map +2 -2
- package/dist/modules/ai_assistant/lib/tool-registry.js +1 -1
- package/dist/modules/ai_assistant/lib/tool-registry.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/ai_assistant/api/tools/route.ts +2 -2
- package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +128 -1
- package/src/modules/ai_assistant/lib/__tests__/tool-input-schema.test.ts +52 -0
- package/src/modules/ai_assistant/lib/__tests__/tool-loader.test.ts +65 -0
- package/src/modules/ai_assistant/lib/generated-registry-loader.ts +107 -12
- package/src/modules/ai_assistant/lib/in-process-client.ts +2 -2
- package/src/modules/ai_assistant/lib/mcp-server.ts +2 -3
- package/src/modules/ai_assistant/lib/tool-input-schema.ts +33 -0
- package/src/modules/ai_assistant/lib/tool-loader.ts +18 -1
- package/src/modules/ai_assistant/lib/tool-registry.ts +1 -1
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
18
18
|
import path from 'node:path'
|
|
19
19
|
import fs from 'node:fs'
|
|
20
|
+
import crypto from 'node:crypto'
|
|
20
21
|
import { createRequire } from 'node:module'
|
|
21
22
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
22
23
|
|
|
@@ -72,6 +73,10 @@ export function findGeneratedFile(fileName: string): string | null {
|
|
|
72
73
|
* lazily through the workspace's normal module resolution. Eagerly bundling
|
|
73
74
|
* pulls Next.js / route-handler internals into the `.mjs` and breaks at runtime
|
|
74
75
|
* (e.g. `next/server` package-exports map).
|
|
76
|
+
*
|
|
77
|
+
* The `@app` local module entries are the one exception: those targets are raw
|
|
78
|
+
* app TypeScript with no compiled sibling, so they are compiled separately
|
|
79
|
+
* (see `compileAppLocalModuleEntries`) and the registry points at the artifact.
|
|
75
80
|
*/
|
|
76
81
|
export async function compileAndImportGenerated(tsPath: string): Promise<Record<string, unknown>> {
|
|
77
82
|
const useJestCjsArtifact = isJestRuntime()
|
|
@@ -83,17 +88,25 @@ export async function compileAndImportGenerated(tsPath: string): Promise<Record<
|
|
|
83
88
|
throw new Error(`Generated file not found: ${tsPath}`)
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
const runtime = useJestCjsArtifact ? 'cjs' : 'esm'
|
|
92
|
+
const tsSource = fs.readFileSync(tsPath, 'utf-8')
|
|
93
|
+
// Runs on every call, not only when the registry itself is stale: the
|
|
94
|
+
// artifact path is stable, so a registry cache hit would otherwise pin an
|
|
95
|
+
// app module's compiled output to whatever it was when the registry was
|
|
96
|
+
// last regenerated.
|
|
97
|
+
const appLocalArtifacts = await compileAppLocalModuleEntries(tsSource, appRoot, runtime)
|
|
98
|
+
|
|
86
99
|
const jsExists = fs.existsSync(jsPath)
|
|
87
100
|
const needsCompile =
|
|
88
101
|
!jsExists || fs.statSync(tsPath).mtimeMs > fs.statSync(jsPath).mtimeMs
|
|
89
102
|
|
|
90
103
|
if (needsCompile) {
|
|
91
104
|
const esbuild = await import('esbuild')
|
|
92
|
-
const tsSource = fs.readFileSync(tsPath, 'utf-8')
|
|
93
105
|
const aliasRewritten = rewriteGeneratedAliasImportsForRuntime(
|
|
94
106
|
tsSource,
|
|
95
107
|
appRoot,
|
|
96
|
-
|
|
108
|
+
runtime,
|
|
109
|
+
appLocalArtifacts,
|
|
97
110
|
)
|
|
98
111
|
const result = await esbuild.transform(aliasRewritten, {
|
|
99
112
|
loader: 'ts',
|
|
@@ -115,6 +128,73 @@ function isJestRuntime(): boolean {
|
|
|
115
128
|
return typeof process.env.JEST_WORKER_ID === 'string'
|
|
116
129
|
}
|
|
117
130
|
|
|
131
|
+
/** Every `../../src/...` specifier the generator emitted for `@app` local modules. */
|
|
132
|
+
export function collectAppLocalSpecifiers(source: string): string[] {
|
|
133
|
+
const specifiers = new Set<string>()
|
|
134
|
+
for (const [, specifier] of source.matchAll(APP_LOCAL_STATIC_IMPORT)) specifiers.add(specifier)
|
|
135
|
+
for (const [, specifier] of source.matchAll(APP_LOCAL_DYNAMIC_IMPORT)) specifiers.add(specifier)
|
|
136
|
+
return [...specifiers].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Stable, collision-free artifact name for one app-local specifier. */
|
|
140
|
+
function appLocalArtifactName(specifier: string, runtime: 'esm' | 'cjs'): string {
|
|
141
|
+
const slug = specifier.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
142
|
+
const digest = crypto.createHash('sha256').update(specifier).digest('hex').slice(0, 8)
|
|
143
|
+
return `${slug}-${digest}.${runtime === 'cjs' ? 'cjs' : 'mjs'}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Compile every `@app` local module entry the generated registry references,
|
|
148
|
+
* and map each specifier to its artifact.
|
|
149
|
+
*
|
|
150
|
+
* Package-backed modules (`@open-mercato/*`) never reach here — their bare
|
|
151
|
+
* specifiers resolve through `node_modules` to compiled `.js`. App-local
|
|
152
|
+
* modules have no compiled sibling, and Node cannot load their `.ts` source
|
|
153
|
+
* directly: relative specifiers need explicit extensions under type stripping,
|
|
154
|
+
* and the module's own graph (`./di`, `./data/entities`) carries decorator and
|
|
155
|
+
* enum syntax that type stripping rejects outright. Bundling the entry with
|
|
156
|
+
* every package import left external is what makes the source loadable.
|
|
157
|
+
*
|
|
158
|
+
* A module that fails to compile is logged and left pointing at its raw source,
|
|
159
|
+
* which reproduces the pre-existing resolution error rather than silently
|
|
160
|
+
* dropping the module's tools from the registry.
|
|
161
|
+
*/
|
|
162
|
+
async function compileAppLocalModuleEntries(
|
|
163
|
+
source: string,
|
|
164
|
+
appRoot: string,
|
|
165
|
+
runtime: 'esm' | 'cjs',
|
|
166
|
+
): Promise<Map<string, string>> {
|
|
167
|
+
const specifiers = collectAppLocalSpecifiers(source)
|
|
168
|
+
const artifacts = new Map<string, string>()
|
|
169
|
+
if (specifiers.length === 0) return artifacts
|
|
170
|
+
|
|
171
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
172
|
+
const { compileAppSourceFile } = await import('@open-mercato/shared/lib/bootstrap/dynamicLoader')
|
|
173
|
+
|
|
174
|
+
for (const specifier of specifiers) {
|
|
175
|
+
const target = path.resolve(generatedDir, specifier)
|
|
176
|
+
const tsPath = fs.existsSync(`${target}.ts`)
|
|
177
|
+
? `${target}.ts`
|
|
178
|
+
: fs.existsSync(target) && target.endsWith('.ts')
|
|
179
|
+
? target
|
|
180
|
+
: null
|
|
181
|
+
if (tsPath === null) continue
|
|
182
|
+
|
|
183
|
+
const outFile = path.join(generatedDir, 'app-modules', appLocalArtifactName(specifier, runtime))
|
|
184
|
+
try {
|
|
185
|
+
await compileAppSourceFile(tsPath, { appRoot, outFile, format: runtime })
|
|
186
|
+
artifacts.set(specifier, outFile)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
logger.warn('Could not compile an app-local module entry for the generated registry', {
|
|
189
|
+
specifier,
|
|
190
|
+
err: error,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return artifacts
|
|
196
|
+
}
|
|
197
|
+
|
|
118
198
|
const UNSAFE_JS_STRING_CHAR_ESCAPES: Record<number, string> = {
|
|
119
199
|
0x3c: '\\u003C', // < — HTML/script-tag breakout
|
|
120
200
|
0x3e: '\\u003E', // > — HTML/script-tag breakout
|
|
@@ -166,33 +246,48 @@ function toSafeJsStringLiteral(value: string): string {
|
|
|
166
246
|
* relative to. Package-backed modules (`@open-mercato/*`) are unaffected —
|
|
167
247
|
* their bare specifiers resolve through `node_modules` to compiled `.js`.
|
|
168
248
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
249
|
+
* When `appLocalArtifacts` maps a shape-2 specifier to a compiled artifact, that
|
|
250
|
+
* artifact wins: pointing Node at raw app TypeScript only works while the file
|
|
251
|
+
* and its whole graph stay within what type stripping accepts, which app
|
|
252
|
+
* modules do not (see `compileAppLocalModuleEntries`). Without a mapping both
|
|
253
|
+
* shapes fall back to the same `.ts`-suffix probe. Other specifiers (bare
|
|
171
254
|
* packages, sibling `./` imports) are left untouched. Exported for unit testing.
|
|
172
255
|
*/
|
|
173
|
-
export function rewriteGeneratedAliasImports(
|
|
174
|
-
|
|
256
|
+
export function rewriteGeneratedAliasImports(
|
|
257
|
+
source: string,
|
|
258
|
+
appRoot: string,
|
|
259
|
+
appLocalArtifacts?: Map<string, string>,
|
|
260
|
+
): string {
|
|
261
|
+
return rewriteGeneratedAliasImportsForRuntime(source, appRoot, 'esm', appLocalArtifacts)
|
|
175
262
|
}
|
|
176
263
|
|
|
264
|
+
const APP_LOCAL_STATIC_IMPORT = /from\s+["']((?:\.\.\/)+src\/[^"']+)["']/g
|
|
265
|
+
const APP_LOCAL_DYNAMIC_IMPORT = /import\s*\(\s*["']((?:\.\.\/)+src\/[^"']+)["']\s*\)/g
|
|
266
|
+
|
|
177
267
|
function rewriteGeneratedAliasImportsForRuntime(
|
|
178
268
|
source: string,
|
|
179
269
|
appRoot: string,
|
|
180
270
|
runtime: 'esm' | 'cjs',
|
|
271
|
+
appLocalArtifacts: Map<string, string> = new Map(),
|
|
181
272
|
): string {
|
|
182
273
|
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
274
|
+
const toRuntimeLiteral = (target: string): string =>
|
|
275
|
+
toSafeJsStringLiteral(runtime === 'esm' ? pathToFileURL(target).href : target)
|
|
183
276
|
const toResolvedLiteral = (target: string): string => {
|
|
184
277
|
const candidate = fs.existsSync(target)
|
|
185
278
|
? target
|
|
186
279
|
: fs.existsSync(target + '.ts')
|
|
187
280
|
? target + '.ts'
|
|
188
281
|
: target
|
|
189
|
-
|
|
190
|
-
return toSafeJsStringLiteral(specifier)
|
|
282
|
+
return toRuntimeLiteral(candidate)
|
|
191
283
|
}
|
|
192
284
|
const resolveAlias = (relativePath: string): string =>
|
|
193
285
|
toResolvedLiteral(path.join(appRoot, relativePath))
|
|
194
|
-
const resolveRelative = (specifier: string): string =>
|
|
195
|
-
|
|
286
|
+
const resolveRelative = (specifier: string): string => {
|
|
287
|
+
const artifact = appLocalArtifacts.get(specifier)
|
|
288
|
+
if (artifact !== undefined) return toRuntimeLiteral(artifact)
|
|
289
|
+
return toResolvedLiteral(path.resolve(generatedDir, specifier))
|
|
290
|
+
}
|
|
196
291
|
return source
|
|
197
292
|
.replace(/from\s+["']@\/([^"']+)["']/g, (_match, relativePath: string) => {
|
|
198
293
|
return `from ${resolveAlias(relativePath)}`
|
|
@@ -200,10 +295,10 @@ function rewriteGeneratedAliasImportsForRuntime(
|
|
|
200
295
|
.replace(/import\s*\(\s*["']@\/([^"']+)["']\s*\)/g, (_match, relativePath: string) => {
|
|
201
296
|
return `import(${resolveAlias(relativePath)})`
|
|
202
297
|
})
|
|
203
|
-
.replace(
|
|
298
|
+
.replace(APP_LOCAL_STATIC_IMPORT, (_match, specifier: string) => {
|
|
204
299
|
return `from ${resolveRelative(specifier)}`
|
|
205
300
|
})
|
|
206
|
-
.replace(
|
|
301
|
+
.replace(APP_LOCAL_DYNAMIC_IMPORT, (_match, specifier: string) => {
|
|
207
302
|
return `import(${resolveRelative(specifier)})`
|
|
208
303
|
})
|
|
209
304
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
2
|
import type { z } from 'zod'
|
|
3
|
-
import {
|
|
3
|
+
import { toolInputJsonSchema } from './tool-input-schema'
|
|
4
4
|
import { getToolRegistry } from './tool-registry'
|
|
5
5
|
import { executeTool } from './tool-executor'
|
|
6
6
|
import { loadAllModuleTools } from './tool-loader'
|
|
@@ -137,7 +137,7 @@ export class InProcessMcpClient implements McpClientInterface {
|
|
|
137
137
|
return accessibleTools.map((tool) => ({
|
|
138
138
|
name: tool.name,
|
|
139
139
|
description: tool.description,
|
|
140
|
-
inputSchema:
|
|
140
|
+
inputSchema: toolInputJsonSchema(tool.inputSchema),
|
|
141
141
|
}))
|
|
142
142
|
}
|
|
143
143
|
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
ListToolsRequestSchema,
|
|
5
5
|
CallToolRequestSchema,
|
|
6
6
|
} from '@modelcontextprotocol/sdk/types.js'
|
|
7
|
-
import {
|
|
7
|
+
import { toolInputJsonSchema } from './tool-input-schema'
|
|
8
8
|
import { getToolRegistry } from './tool-registry'
|
|
9
9
|
import { executeTool } from './tool-executor'
|
|
10
10
|
import { loadAllModuleTools, indexToolsForSearch } from './tool-loader'
|
|
@@ -132,8 +132,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
132
132
|
tools: accessibleTools.map((tool) => ({
|
|
133
133
|
name: tool.name,
|
|
134
134
|
description: tool.description,
|
|
135
|
-
|
|
136
|
-
inputSchema: zodToJsonSchema(tool.inputSchema as any) as Record<string, unknown>,
|
|
135
|
+
inputSchema: toolInputJsonSchema(tool.inputSchema),
|
|
137
136
|
})),
|
|
138
137
|
}
|
|
139
138
|
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z, type ZodTypeAny } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Publish a tool's declared input as JSON Schema.
|
|
5
|
+
*
|
|
6
|
+
* Every place a tool is advertised — the MCP `tools/list` handler, the in-process client and
|
|
7
|
+
* `GET /api/ai_assistant/tools` — used to call `zod-to-json-schema`, a Zod **3** converter. This
|
|
8
|
+
* repository is on Zod 4, whose internals that package cannot read, so it silently returned
|
|
9
|
+
* `{ "$schema": "…" }` for every tool: no `properties`, no `required`, nothing. It did not throw
|
|
10
|
+
* and nothing failed a type check, so the tools kept listing and a model was simply never told
|
|
11
|
+
* that `example.get_customer_priority` takes a `customerId`. An argument-free tool looked
|
|
12
|
+
* identical to one with three required fields.
|
|
13
|
+
*
|
|
14
|
+
* Zod 4 ships its own converter, so the fix is to use it. Two deliberate choices:
|
|
15
|
+
*
|
|
16
|
+
* - `io: 'input'` — a tool schema describes what the CALLER sends. With the default (`'output'`)
|
|
17
|
+
* a field carrying a `.default()` is published as required-on-output, which is the opposite of
|
|
18
|
+
* what a caller needs to know.
|
|
19
|
+
* - `unrepresentable: 'any'` — the default throws on a type with no JSON Schema form (`z.date()`,
|
|
20
|
+
* `z.bigint()`). Throwing here would take down the whole tool listing because one tool declared
|
|
21
|
+
* a date, so such a field is published as an unconstrained value instead.
|
|
22
|
+
*
|
|
23
|
+
* A schema that still cannot be converted degrades to the empty object schema rather than
|
|
24
|
+
* propagating: listing the remaining tools is worth more than failing the request.
|
|
25
|
+
*/
|
|
26
|
+
export function toolInputJsonSchema(schema: ZodTypeAny | undefined): Record<string, unknown> {
|
|
27
|
+
if (!schema) return { type: 'object', properties: {} }
|
|
28
|
+
try {
|
|
29
|
+
return z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as Record<string, unknown>
|
|
30
|
+
} catch {
|
|
31
|
+
return { type: 'object', properties: {} }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
|
|
18
18
|
const logger = createLogger('ai_assistant').child({ component: 'tools' })
|
|
19
19
|
|
|
20
|
+
let allModuleToolsLoad: Promise<void> | null = null
|
|
21
|
+
|
|
20
22
|
/**
|
|
21
23
|
* Module tool definition as exported from ai-tools.ts files.
|
|
22
24
|
*/
|
|
@@ -235,7 +237,7 @@ export async function loadGeneratedModuleAiTools(): Promise<number> {
|
|
|
235
237
|
* Dynamically load tools from known module paths.
|
|
236
238
|
* This is called during MCP server startup.
|
|
237
239
|
*/
|
|
238
|
-
|
|
240
|
+
async function loadAllModuleToolsUncached(): Promise<void> {
|
|
239
241
|
// 1. Register built-in tools
|
|
240
242
|
registerMcpTool(contextWhoamiTool, { moduleId: 'context' })
|
|
241
243
|
logger.debug('Registered built-in context_whoami tool')
|
|
@@ -275,6 +277,21 @@ export async function loadAllModuleTools(): Promise<void> {
|
|
|
275
277
|
}
|
|
276
278
|
}
|
|
277
279
|
|
|
280
|
+
export function loadAllModuleTools(): Promise<void> {
|
|
281
|
+
if (!allModuleToolsLoad) {
|
|
282
|
+
allModuleToolsLoad = loadAllModuleToolsUncached().catch((error) => {
|
|
283
|
+
allModuleToolsLoad = null
|
|
284
|
+
throw error
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
return allModuleToolsLoad
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** @__internal Test-only hook — reset the process-wide loader memo. */
|
|
291
|
+
export function resetAllModuleToolsLoadForTests(): void {
|
|
292
|
+
allModuleToolsLoad = null
|
|
293
|
+
}
|
|
294
|
+
|
|
278
295
|
/**
|
|
279
296
|
* Index all registered tools for hybrid search discovery.
|
|
280
297
|
* This should be called after loadAllModuleTools() when the search service is available.
|
|
@@ -19,7 +19,7 @@ class ToolRegistryImpl implements McpToolRegistry {
|
|
|
19
19
|
throw new Error('MCP tool must define a name')
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
if (this.tools.has(tool.name)) {
|
|
22
|
+
if (this.tools.has(tool.name) && options?.moduleId !== 'ai_overrides') {
|
|
23
23
|
logger.warn('Tool already registered, overwriting', { toolName: tool.name })
|
|
24
24
|
}
|
|
25
25
|
|