@open-mercato/ai-assistant 0.6.8-develop.7100.1.fbf66fca35 → 0.7.0
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 +1 -1
- package/dist/modules/ai_assistant/ai-tools/search-pack.js +3 -93
- package/dist/modules/ai_assistant/ai-tools/search-pack.js.map +3 -3
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +0 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/lib/codemode-tools.js +6 -14
- package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js +2 -10
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
- package/dist/modules/ai_assistant/lib/http-server.js +1 -3
- package/dist/modules/ai_assistant/lib/http-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/in-process-client.js +1 -3
- package/dist/modules/ai_assistant/lib/in-process-client.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-client.js +1 -2
- package/dist/modules/ai_assistant/lib/mcp-client.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js +1 -3
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-server.js +1 -3
- package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
- package/package.json +7 -8
- package/src/modules/ai_assistant/__tests__/integration/ws-c-tool-pack-coverage.test.ts +0 -5
- package/src/modules/ai_assistant/ai-tools/__tests__/search-pack.test.ts +5 -211
- package/src/modules/ai_assistant/ai-tools/search-pack.ts +4 -110
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +0 -3
- package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +0 -16
- package/src/modules/ai_assistant/lib/__tests__/mcp-client.test.ts +0 -30
- package/src/modules/ai_assistant/lib/codemode-tools.ts +7 -21
- package/src/modules/ai_assistant/lib/generated-registry-loader.ts +2 -10
- package/src/modules/ai_assistant/lib/http-server.ts +0 -2
- package/src/modules/ai_assistant/lib/in-process-client.ts +0 -2
- package/src/modules/ai_assistant/lib/mcp-client.ts +0 -1
- package/src/modules/ai_assistant/lib/mcp-dev-server.ts +0 -2
- package/src/modules/ai_assistant/lib/mcp-server.ts +0 -2
- package/src/modules/ai_assistant/lib/types.ts +0 -11
- package/dist/modules/ai_assistant/lib/mcp-tool-annotations.js +0 -18
- package/dist/modules/ai_assistant/lib/mcp-tool-annotations.js.map +0 -7
- package/src/modules/ai_assistant/lib/__tests__/codemode-tool-annotations.test.ts +0 -58
- package/src/modules/ai_assistant/lib/__tests__/mcp-server-tool-annotations.test.ts +0 -120
- package/src/modules/ai_assistant/lib/__tests__/mcp-tool-annotations.test.ts +0 -57
- package/src/modules/ai_assistant/lib/mcp-tool-annotations.ts +0 -35
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Step 3.8 — `search.*` tool pack unit tests.
|
|
3
3
|
*
|
|
4
4
|
* Covers `search.hybrid_search` happy path and `search.get_record_context`
|
|
5
|
-
* happy / miss / tenant isolation
|
|
6
|
-
* issue #5211 (legacy pack must not leak records the caller cannot view).
|
|
5
|
+
* happy / miss / tenant isolation.
|
|
7
6
|
*/
|
|
8
7
|
import searchAiTools from '../search-pack'
|
|
9
8
|
|
|
@@ -58,19 +57,6 @@ function makeSearchService(results: unknown[]): {
|
|
|
58
57
|
}
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
function makeLookup(configs: Array<{ entityId: string; aclFeatures?: string[]; enabled?: boolean }>) {
|
|
62
|
-
const map = new Map(configs.map((c) => [c.entityId, c]))
|
|
63
|
-
return {
|
|
64
|
-
getEntityConfig: (entityId: string) => map.get(entityId) as any,
|
|
65
|
-
getAllEntityConfigs: () => configs as any,
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function permissiveLookup(entityIds: string[] = ['catalog:product']) {
|
|
70
|
-
const configs = entityIds.map((id) => ({ entityId: id, aclFeatures: ['search.view'], enabled: true }))
|
|
71
|
-
return makeLookup(configs)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
60
|
describe('search.hybrid_search', () => {
|
|
75
61
|
const tool = findTool('search.hybrid_search')
|
|
76
62
|
|
|
@@ -85,10 +71,8 @@ describe('search.hybrid_search', () => {
|
|
|
85
71
|
},
|
|
86
72
|
])
|
|
87
73
|
const ctx = makeCtx()
|
|
88
|
-
const lookup = permissiveLookup(['catalog:product'])
|
|
89
74
|
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
90
75
|
if (name === 'searchService') return service
|
|
91
|
-
if (name === 'searchIndexer') return lookup
|
|
92
76
|
throw new Error(`unexpected resolve ${name}`)
|
|
93
77
|
})
|
|
94
78
|
const result = (await tool.handler(
|
|
@@ -111,12 +95,7 @@ describe('search.hybrid_search', () => {
|
|
|
111
95
|
it('defaults limit to 20 when omitted', async () => {
|
|
112
96
|
const { service, calls } = makeSearchService([])
|
|
113
97
|
const ctx = makeCtx()
|
|
114
|
-
|
|
115
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
116
|
-
if (name === 'searchService') return service
|
|
117
|
-
if (name === 'searchIndexer') return lookup
|
|
118
|
-
return undefined
|
|
119
|
-
})
|
|
98
|
+
;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
|
|
120
99
|
await tool.handler({ q: 'hello' }, ctx as any)
|
|
121
100
|
expect(calls[0].options.limit).toBe(20)
|
|
122
101
|
})
|
|
@@ -126,105 +105,6 @@ describe('search.hybrid_search', () => {
|
|
|
126
105
|
;(ctx.container.resolve as jest.Mock).mockReturnValue({ search: jest.fn() })
|
|
127
106
|
await expect(tool.handler({ q: 'x' }, ctx as any)).rejects.toThrow(/Tenant context/)
|
|
128
107
|
})
|
|
129
|
-
|
|
130
|
-
it('withholds results for entity types the caller cannot view', async () => {
|
|
131
|
-
const lookup = makeLookup([
|
|
132
|
-
{ entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
|
|
133
|
-
{ entityId: 'catalog:catalog_product', aclFeatures: ['catalog.products.view'], enabled: true },
|
|
134
|
-
])
|
|
135
|
-
const { service, calls } = makeSearchService([
|
|
136
|
-
{ entityId: 'customers:customer_person_profile', recordId: 'p1', score: 0.9, source: 'fulltext', presenter: { title: 'Person' } },
|
|
137
|
-
{ entityId: 'catalog:catalog_product', recordId: 'c1', score: 0.8, source: 'fulltext', presenter: { title: 'Product' } },
|
|
138
|
-
])
|
|
139
|
-
const ctx = makeCtx({ userFeatures: ['search.view', 'customers.people.view'] })
|
|
140
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
141
|
-
if (name === 'searchService') return service
|
|
142
|
-
if (name === 'searchIndexer') return lookup
|
|
143
|
-
throw new Error(`unexpected resolve ${name}`)
|
|
144
|
-
})
|
|
145
|
-
const result = (await tool.handler({ q: 'test' }, ctx as any)) as Record<string, unknown>
|
|
146
|
-
// Should have narrowed entityTypes to only readable type
|
|
147
|
-
expect((calls[0].options as any).entityTypes).toEqual(['customers:customer_person_profile'])
|
|
148
|
-
// Defense in depth: results filtered to only readable
|
|
149
|
-
expect(result.totalResults).toBe(1)
|
|
150
|
-
expect((result.results as any[])[0].entityId).toBe('customers:customer_person_profile')
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
it('short-circuits without calling search when no entity type is readable for an explicit request', async () => {
|
|
154
|
-
const lookup = makeLookup([
|
|
155
|
-
{ entityId: 'catalog:catalog_product', aclFeatures: ['catalog.products.view'], enabled: true },
|
|
156
|
-
])
|
|
157
|
-
const { service, calls } = makeSearchService([
|
|
158
|
-
{ entityId: 'catalog:catalog_product', recordId: 'c1', score: 0.8, source: 'fulltext' },
|
|
159
|
-
])
|
|
160
|
-
const ctx = makeCtx({ userFeatures: ['search.view'] })
|
|
161
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
162
|
-
if (name === 'searchService') return service
|
|
163
|
-
if (name === 'searchIndexer') return lookup
|
|
164
|
-
throw new Error(`unexpected ${name}`)
|
|
165
|
-
})
|
|
166
|
-
const result = (await tool.handler({ q: 'test', entityTypes: ['catalog:catalog_product'] }, ctx as any)) as Record<string, unknown>
|
|
167
|
-
expect(calls).toHaveLength(0)
|
|
168
|
-
expect(result.totalResults).toBe(0)
|
|
169
|
-
expect(result.results).toEqual([])
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
it('intersects an explicitly requested mixed set with the readable set', async () => {
|
|
173
|
-
const lookup = makeLookup([
|
|
174
|
-
{ entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
|
|
175
|
-
{ entityId: 'catalog:catalog_product', aclFeatures: ['catalog.products.view'], enabled: true },
|
|
176
|
-
{ entityId: 'secret:thing', aclFeatures: ['secret.view'], enabled: true },
|
|
177
|
-
])
|
|
178
|
-
const { service, calls } = makeSearchService([
|
|
179
|
-
{ entityId: 'customers:customer_person_profile', recordId: 'p1', score: 0.9, source: 'fulltext' },
|
|
180
|
-
{ entityId: 'catalog:catalog_product', recordId: 'c1', score: 0.8, source: 'fulltext' },
|
|
181
|
-
])
|
|
182
|
-
const ctx = makeCtx({ userFeatures: ['search.view', 'customers.people.view'] })
|
|
183
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
184
|
-
if (name === 'searchService') return service
|
|
185
|
-
if (name === 'searchIndexer') return lookup
|
|
186
|
-
throw new Error(`unexpected ${name}`)
|
|
187
|
-
})
|
|
188
|
-
await tool.handler({ q: 'test', entityTypes: ['customers:customer_person_profile', 'catalog:catalog_product', 'secret:thing'] }, ctx as any)
|
|
189
|
-
expect((calls[0].options as any).entityTypes).toEqual(['customers:customer_person_profile'])
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
it('drops results a strategy returned for an unreadable entity type (defense in depth)', async () => {
|
|
193
|
-
const lookup = makeLookup([
|
|
194
|
-
{ entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
|
|
195
|
-
{ entityId: 'secret:thing', aclFeatures: ['secret.view'], enabled: true },
|
|
196
|
-
])
|
|
197
|
-
const { service } = makeSearchService([
|
|
198
|
-
{ entityId: 'customers:customer_person_profile', recordId: 'p1', score: 0.9, source: 'fulltext' },
|
|
199
|
-
{ entityId: 'secret:thing', recordId: 's1', score: 0.8, source: 'fulltext' },
|
|
200
|
-
])
|
|
201
|
-
const ctx = makeCtx({ userFeatures: ['search.view', 'customers.people.view'] })
|
|
202
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
203
|
-
if (name === 'searchService') return service
|
|
204
|
-
if (name === 'searchIndexer') return lookup
|
|
205
|
-
throw new Error(`unexpected ${name}`)
|
|
206
|
-
})
|
|
207
|
-
const result = (await tool.handler({ q: 'test' }, ctx as any)) as Record<string, unknown>
|
|
208
|
-
expect((result.results as any[]).every((r: any) => r.entityId === 'customers:customer_person_profile')).toBe(true)
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
it('allows a superadmin to see all requested entity types', async () => {
|
|
212
|
-
const lookup = makeLookup([
|
|
213
|
-
{ entityId: 'secret:thing', aclFeatures: ['secret.view'], enabled: true },
|
|
214
|
-
])
|
|
215
|
-
const { service, calls } = makeSearchService([
|
|
216
|
-
{ entityId: 'secret:thing', recordId: 's1', score: 0.8, source: 'fulltext' },
|
|
217
|
-
])
|
|
218
|
-
const ctx = makeCtx({ userFeatures: [], isSuperAdmin: true })
|
|
219
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
220
|
-
if (name === 'searchService') return service
|
|
221
|
-
if (name === 'searchIndexer') return lookup
|
|
222
|
-
throw new Error(`unexpected ${name}`)
|
|
223
|
-
})
|
|
224
|
-
const result = (await tool.handler({ q: 'test', entityTypes: ['secret:thing'] }, ctx as any)) as Record<string, unknown>
|
|
225
|
-
expect(calls[0].options.entityTypes).toEqual(['secret:thing'])
|
|
226
|
-
expect(result.totalResults).toBe(1)
|
|
227
|
-
})
|
|
228
108
|
})
|
|
229
109
|
|
|
230
110
|
describe('search.get_record_context', () => {
|
|
@@ -245,12 +125,7 @@ describe('search.get_record_context', () => {
|
|
|
245
125
|
match,
|
|
246
126
|
])
|
|
247
127
|
const ctx = makeCtx()
|
|
248
|
-
|
|
249
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
250
|
-
if (name === 'searchService') return service
|
|
251
|
-
if (name === 'searchIndexer') return lookup
|
|
252
|
-
throw new Error(`unexpected ${name}`)
|
|
253
|
-
})
|
|
128
|
+
;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
|
|
254
129
|
const result = (await tool.handler(
|
|
255
130
|
{ entityId: 'catalog:product', recordId: 'rec-42' },
|
|
256
131
|
ctx as any,
|
|
@@ -274,12 +149,7 @@ describe('search.get_record_context', () => {
|
|
|
274
149
|
{ entityId: 'catalog:product', recordId: 'other', score: 0.2, source: 'fulltext' },
|
|
275
150
|
])
|
|
276
151
|
const ctx = makeCtx()
|
|
277
|
-
|
|
278
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
279
|
-
if (name === 'searchService') return service
|
|
280
|
-
if (name === 'searchIndexer') return lookup
|
|
281
|
-
throw new Error(`unexpected ${name}`)
|
|
282
|
-
})
|
|
152
|
+
;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
|
|
283
153
|
const result = (await tool.handler(
|
|
284
154
|
{ entityId: 'catalog:product', recordId: 'missing' },
|
|
285
155
|
ctx as any,
|
|
@@ -291,12 +161,7 @@ describe('search.get_record_context', () => {
|
|
|
291
161
|
it('passes the caller tenant/org and never leaks another tenant', async () => {
|
|
292
162
|
const { service, calls } = makeSearchService([])
|
|
293
163
|
const ctx = makeCtx({ tenantId: 'tenant-A', organizationId: 'org-A' })
|
|
294
|
-
|
|
295
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
296
|
-
if (name === 'searchService') return service
|
|
297
|
-
if (name === 'searchIndexer') return lookup
|
|
298
|
-
throw new Error(`unexpected ${name}`)
|
|
299
|
-
})
|
|
164
|
+
;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
|
|
300
165
|
await tool.handler({ entityId: 'x:y', recordId: 'z' }, ctx as any)
|
|
301
166
|
expect(calls[0].options).toMatchObject({
|
|
302
167
|
tenantId: 'tenant-A',
|
|
@@ -312,77 +177,6 @@ describe('search.get_record_context', () => {
|
|
|
312
177
|
tool.handler({ entityId: 'x:y', recordId: 'z' }, ctx as any),
|
|
313
178
|
).rejects.toThrow(/Tenant context/)
|
|
314
179
|
})
|
|
315
|
-
|
|
316
|
-
it('rejects an explicitly requested unauthorized entity type', async () => {
|
|
317
|
-
const lookup = makeLookup([
|
|
318
|
-
{ entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
|
|
319
|
-
])
|
|
320
|
-
const { service } = makeSearchService([])
|
|
321
|
-
const ctx = makeCtx({ userFeatures: ['search.view'] })
|
|
322
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
323
|
-
if (name === 'searchService') return service
|
|
324
|
-
if (name === 'searchIndexer') return lookup
|
|
325
|
-
throw new Error(`unexpected ${name}`)
|
|
326
|
-
})
|
|
327
|
-
await expect(
|
|
328
|
-
tool.handler({ entityId: 'customers:customer_person_profile', recordId: 'rec-1' }, ctx as any),
|
|
329
|
-
).rejects.toThrow(/Insufficient permissions/)
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
it('allows an authorized caller to retrieve record context', async () => {
|
|
333
|
-
const lookup = makeLookup([
|
|
334
|
-
{ entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
|
|
335
|
-
])
|
|
336
|
-
const { service } = makeSearchService([
|
|
337
|
-
{ entityId: 'customers:customer_person_profile', recordId: 'rec-1', score: 1, source: 'fulltext', presenter: { title: 'Person' }, url: '/backend/customers/people/rec-1', links: [] },
|
|
338
|
-
])
|
|
339
|
-
const ctx = makeCtx({ userFeatures: ['search.view', 'customers.people.view'] })
|
|
340
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
341
|
-
if (name === 'searchService') return service
|
|
342
|
-
if (name === 'searchIndexer') return lookup
|
|
343
|
-
throw new Error(`unexpected ${name}`)
|
|
344
|
-
})
|
|
345
|
-
const result = (await tool.handler({ entityId: 'customers:customer_person_profile', recordId: 'rec-1' }, ctx as any)) as Record<string, unknown>
|
|
346
|
-
expect(result.found).toBe(true)
|
|
347
|
-
expect((result as any).presenter.title).toBe('Person')
|
|
348
|
-
})
|
|
349
|
-
|
|
350
|
-
it('filters stray results a strategy returned for an unreadable entity (defense in depth)', async () => {
|
|
351
|
-
const lookup = makeLookup([
|
|
352
|
-
{ entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
|
|
353
|
-
{ entityId: 'secret:thing', aclFeatures: ['secret.view'], enabled: true },
|
|
354
|
-
])
|
|
355
|
-
// User can read person, but strategy returns secret thing for same recordId
|
|
356
|
-
const { service } = makeSearchService([
|
|
357
|
-
{ entityId: 'secret:thing', recordId: 'rec-1', score: 1, source: 'fulltext', presenter: { title: 'Secret' } },
|
|
358
|
-
])
|
|
359
|
-
const ctx = makeCtx({ userFeatures: ['search.view', 'customers.people.view'] })
|
|
360
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
361
|
-
if (name === 'searchService') return service
|
|
362
|
-
if (name === 'searchIndexer') return lookup
|
|
363
|
-
throw new Error(`unexpected ${name}`)
|
|
364
|
-
})
|
|
365
|
-
// Requesting person, but backend returned secret – should be filtered to not found
|
|
366
|
-
const result = (await tool.handler({ entityId: 'customers:customer_person_profile', recordId: 'rec-1' }, ctx as any)) as Record<string, unknown>
|
|
367
|
-
expect(result.found).toBe(false)
|
|
368
|
-
})
|
|
369
|
-
|
|
370
|
-
it('allows a superadmin to retrieve any record context', async () => {
|
|
371
|
-
const lookup = makeLookup([
|
|
372
|
-
{ entityId: 'secret:thing', aclFeatures: ['secret.view'], enabled: true },
|
|
373
|
-
])
|
|
374
|
-
const { service } = makeSearchService([
|
|
375
|
-
{ entityId: 'secret:thing', recordId: 'rec-1', score: 1, source: 'fulltext', presenter: { title: 'Secret' }, url: '/x', links: [] },
|
|
376
|
-
])
|
|
377
|
-
const ctx = makeCtx({ userFeatures: [], isSuperAdmin: true })
|
|
378
|
-
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
379
|
-
if (name === 'searchService') return service
|
|
380
|
-
if (name === 'searchIndexer') return lookup
|
|
381
|
-
throw new Error(`unexpected ${name}`)
|
|
382
|
-
})
|
|
383
|
-
const result = (await tool.handler({ entityId: 'secret:thing', recordId: 'rec-1' }, ctx as any)) as Record<string, unknown>
|
|
384
|
-
expect(result.found).toBe(true)
|
|
385
|
-
})
|
|
386
180
|
})
|
|
387
181
|
|
|
388
182
|
describe('search-pack tool surface', () => {
|
|
@@ -7,68 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { z } from 'zod'
|
|
9
9
|
import type { SearchOptions, SearchResult, SearchStrategyId } from '@open-mercato/shared/modules/search'
|
|
10
|
-
import {
|
|
11
|
-
canReadSearchEntity,
|
|
12
|
-
filterSearchResultsByEntityAccess,
|
|
13
|
-
resolveReadableEntityTypes,
|
|
14
|
-
type SearchEntityAccessSubject,
|
|
15
|
-
type SearchEntityConfigLookup,
|
|
16
|
-
type SearchEntityDenyReason,
|
|
17
|
-
} from '@open-mercato/shared/lib/search/entityAccess'
|
|
18
10
|
import { defineAiTool } from '../lib/ai-tool-definition'
|
|
19
|
-
import type { AiToolDefinition
|
|
11
|
+
import type { AiToolDefinition } from '../lib/types'
|
|
20
12
|
|
|
21
13
|
type SearchServiceLike = {
|
|
22
14
|
search: (query: string, options: SearchOptions) => Promise<SearchResult[]>
|
|
23
15
|
}
|
|
24
16
|
|
|
25
|
-
class SearchToolAuthorizationError extends Error {
|
|
26
|
-
constructor(message: string) {
|
|
27
|
-
super(message)
|
|
28
|
-
this.name = 'SearchToolAuthorizationError'
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function resolveSearchIndexer(ctx: McpToolContext): SearchEntityConfigLookup {
|
|
33
|
-
try {
|
|
34
|
-
const indexer = ctx.container.resolve('searchIndexer') as SearchEntityConfigLookup | undefined
|
|
35
|
-
if (indexer && typeof indexer.getEntityConfig === 'function' && typeof indexer.getAllEntityConfigs === 'function') {
|
|
36
|
-
return indexer
|
|
37
|
-
}
|
|
38
|
-
} catch {
|
|
39
|
-
// fall through to throw
|
|
40
|
-
}
|
|
41
|
-
throw new SearchToolAuthorizationError('[internal] Search entity registry unavailable')
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function authorizeEntityAccess(
|
|
45
|
-
entityType: string,
|
|
46
|
-
lookup: SearchEntityConfigLookup,
|
|
47
|
-
subject: SearchEntityAccessSubject,
|
|
48
|
-
): void {
|
|
49
|
-
if (subject.isSuperAdmin) return
|
|
50
|
-
let denial: SearchEntityDenyReason | undefined
|
|
51
|
-
const allowed = canReadSearchEntity(entityType, lookup, subject, {
|
|
52
|
-
onDeny: (_, reason) => {
|
|
53
|
-
denial = reason
|
|
54
|
-
},
|
|
55
|
-
})
|
|
56
|
-
if (allowed) return
|
|
57
|
-
if (denial === 'unconfigured') {
|
|
58
|
-
throw new SearchToolAuthorizationError(`[internal] Entity type "${entityType}" is not configured for search`)
|
|
59
|
-
}
|
|
60
|
-
if (denial === 'no-acl-features') {
|
|
61
|
-
throw new SearchToolAuthorizationError(
|
|
62
|
-
`[internal] Entity type "${entityType}" does not declare aclFeatures; access denied`,
|
|
63
|
-
)
|
|
64
|
-
}
|
|
65
|
-
const config = lookup.getEntityConfig(entityType)
|
|
66
|
-
const required = config?.aclFeatures ?? []
|
|
67
|
-
throw new SearchToolAuthorizationError(
|
|
68
|
-
`[internal] Insufficient permissions for entity "${entityType}". Required: ${required.join(', ')}`,
|
|
69
|
-
)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
17
|
const hybridSearchInput = z.object({
|
|
73
18
|
q: z.string().min(1).describe('Search query text.'),
|
|
74
19
|
limit: z
|
|
@@ -104,52 +49,13 @@ const hybridSearchTool = defineAiTool({
|
|
|
104
49
|
const service = ctx.container.resolve<SearchServiceLike>('searchService')
|
|
105
50
|
const limit = input.limit ?? 20
|
|
106
51
|
const started = Date.now()
|
|
107
|
-
const
|
|
108
|
-
grantedFeatures: ctx.userFeatures,
|
|
109
|
-
isSuperAdmin: ctx.isSuperAdmin,
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (ctx.isSuperAdmin) {
|
|
113
|
-
const results = await service.search(input.q, {
|
|
114
|
-
tenantId: ctx.tenantId,
|
|
115
|
-
organizationId: ctx.organizationId,
|
|
116
|
-
limit,
|
|
117
|
-
strategies: input.strategies as SearchStrategyId[] | undefined,
|
|
118
|
-
entityTypes: input.entityTypes,
|
|
119
|
-
})
|
|
120
|
-
const timingMs = Date.now() - started
|
|
121
|
-
const strategiesUsed = Array.from(
|
|
122
|
-
new Set(results.map((result) => result.source).filter((id): id is SearchStrategyId => typeof id === 'string')),
|
|
123
|
-
)
|
|
124
|
-
return {
|
|
125
|
-
query: input.q,
|
|
126
|
-
totalResults: results.length,
|
|
127
|
-
results,
|
|
128
|
-
strategiesUsed,
|
|
129
|
-
timing: { ms: timingMs },
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const lookup = resolveSearchIndexer(ctx)
|
|
134
|
-
const readableEntityTypes = resolveReadableEntityTypes(lookup, subject, input.entityTypes)
|
|
135
|
-
if (readableEntityTypes && readableEntityTypes.length === 0) {
|
|
136
|
-
return {
|
|
137
|
-
query: input.q,
|
|
138
|
-
totalResults: 0,
|
|
139
|
-
results: [],
|
|
140
|
-
strategiesUsed: [],
|
|
141
|
-
timing: { ms: Date.now() - started },
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const rawResults = await service.search(input.q, {
|
|
52
|
+
const results = await service.search(input.q, {
|
|
146
53
|
tenantId: ctx.tenantId,
|
|
147
54
|
organizationId: ctx.organizationId,
|
|
148
55
|
limit,
|
|
149
56
|
strategies: input.strategies as SearchStrategyId[] | undefined,
|
|
150
|
-
entityTypes:
|
|
57
|
+
entityTypes: input.entityTypes,
|
|
151
58
|
})
|
|
152
|
-
const results = filterSearchResultsByEntityAccess(rawResults, lookup, subject)
|
|
153
59
|
const timingMs = Date.now() - started
|
|
154
60
|
const strategiesUsed = Array.from(
|
|
155
61
|
new Set(results.map((result) => result.source).filter((id): id is SearchStrategyId => typeof id === 'string')),
|
|
@@ -182,25 +88,13 @@ const getRecordContextTool = defineAiTool({
|
|
|
182
88
|
throw new Error('Tenant context is required for search.get_record_context')
|
|
183
89
|
}
|
|
184
90
|
const input = getRecordContextInput.parse(rawInput)
|
|
185
|
-
const subject: SearchEntityAccessSubject = {
|
|
186
|
-
grantedFeatures: ctx.userFeatures,
|
|
187
|
-
isSuperAdmin: ctx.isSuperAdmin,
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
let lookup: SearchEntityConfigLookup | undefined
|
|
191
|
-
if (!ctx.isSuperAdmin) {
|
|
192
|
-
lookup = resolveSearchIndexer(ctx)
|
|
193
|
-
authorizeEntityAccess(input.entityId, lookup, subject)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
91
|
const service = ctx.container.resolve<SearchServiceLike>('searchService')
|
|
197
|
-
const
|
|
92
|
+
const results = await service.search(input.recordId, {
|
|
198
93
|
tenantId: ctx.tenantId,
|
|
199
94
|
organizationId: ctx.organizationId,
|
|
200
95
|
limit: 5,
|
|
201
96
|
entityTypes: [input.entityId],
|
|
202
97
|
})
|
|
203
|
-
const results = lookup ? filterSearchResultsByEntityAccess(rawResults, lookup, subject) : rawResults
|
|
204
98
|
const match = results.find((result) => result.recordId === input.recordId)
|
|
205
99
|
if (!match) {
|
|
206
100
|
return {
|
|
@@ -32,7 +32,6 @@ type ModerationFlagsResponse = {
|
|
|
32
32
|
total: number
|
|
33
33
|
page: number
|
|
34
34
|
pageSize: number
|
|
35
|
-
totalIsCapped?: boolean
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
async function fetchModerationFlags(params: {
|
|
@@ -124,7 +123,6 @@ export function AiModerationFlagsPageClient() {
|
|
|
124
123
|
)
|
|
125
124
|
|
|
126
125
|
const total = query.data?.total ?? 0
|
|
127
|
-
const totalIsCapped = query.data?.totalIsCapped === true
|
|
128
126
|
|
|
129
127
|
return (
|
|
130
128
|
<div className="flex flex-col gap-4">
|
|
@@ -178,7 +176,6 @@ export function AiModerationFlagsPageClient() {
|
|
|
178
176
|
pageSize: PAGE_SIZE,
|
|
179
177
|
total,
|
|
180
178
|
totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)),
|
|
181
|
-
totalIsCapped,
|
|
182
179
|
onPageChange: setPage,
|
|
183
180
|
}}
|
|
184
181
|
/>
|
|
@@ -36,22 +36,6 @@ function safeJsLiteral(value: string): string {
|
|
|
36
36
|
return escapeUnsafeJsStringChars(JSON.stringify(value))
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
describe('Next.js bundle boundary', () => {
|
|
40
|
-
it('keeps runtime-only imports out of the Next.js bundle', () => {
|
|
41
|
-
const loaderSource = fs.readFileSync(
|
|
42
|
-
path.join(__dirname, '..', 'generated-registry-loader.ts'),
|
|
43
|
-
'utf8',
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
expect(loaderSource).toMatch(
|
|
47
|
-
/await import\(\s*\/\* webpackIgnore: true \*\/\s*\/\* turbopackIgnore: true \*\/\s*pathToFileURL\(jsPath\)\.href\s*\)/,
|
|
48
|
-
)
|
|
49
|
-
expect(loaderSource).toMatch(
|
|
50
|
-
/await import\(\s*\/\* webpackIgnore: true \*\/\s*\/\* turbopackIgnore: true \*\/\s*'@open-mercato\/shared\/lib\/bootstrap\/dynamicLoader'\s*\)/,
|
|
51
|
-
)
|
|
52
|
-
})
|
|
53
|
-
})
|
|
54
|
-
|
|
55
39
|
describe('rewriteGeneratedAliasImports', () => {
|
|
56
40
|
// Regression for the MCP dev-server crash:
|
|
57
41
|
// "Cannot find package '@/.mercato' imported from .../tool-loader.js"
|
|
@@ -90,34 +90,4 @@ describe('McpClient stdio transport', () => {
|
|
|
90
90
|
expect(transportArgs.args).not.toContain('omk_secret.value')
|
|
91
91
|
expect(JSON.stringify(transportArgs.args)).not.toContain('omk_secret.value')
|
|
92
92
|
})
|
|
93
|
-
|
|
94
|
-
it('preserves all MCP tool annotations returned by a remote server', async () => {
|
|
95
|
-
mockClientListTools.mockResolvedValueOnce({
|
|
96
|
-
tools: [{
|
|
97
|
-
name: 'customers.update_company',
|
|
98
|
-
description: 'Update a company.',
|
|
99
|
-
inputSchema: {},
|
|
100
|
-
annotations: {
|
|
101
|
-
title: 'Update company',
|
|
102
|
-
readOnlyHint: false,
|
|
103
|
-
destructiveHint: false,
|
|
104
|
-
idempotentHint: true,
|
|
105
|
-
openWorldHint: false,
|
|
106
|
-
},
|
|
107
|
-
}],
|
|
108
|
-
})
|
|
109
|
-
const client = await McpClient.connect({ transport: 'stdio', apiKeySecret: 'test-secret' })
|
|
110
|
-
|
|
111
|
-
await expect(client.listTools()).resolves.toEqual([
|
|
112
|
-
expect.objectContaining({
|
|
113
|
-
annotations: {
|
|
114
|
-
title: 'Update company',
|
|
115
|
-
readOnlyHint: false,
|
|
116
|
-
destructiveHint: false,
|
|
117
|
-
idempotentHint: true,
|
|
118
|
-
openWorldHint: false,
|
|
119
|
-
},
|
|
120
|
-
}),
|
|
121
|
-
])
|
|
122
|
-
})
|
|
123
93
|
})
|
|
@@ -13,7 +13,7 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
|
13
13
|
import { z } from 'zod'
|
|
14
14
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
15
15
|
import { registerMcpTool } from './tool-registry'
|
|
16
|
-
import type {
|
|
16
|
+
import type { McpToolContext } from './types'
|
|
17
17
|
import { createSandbox } from './sandbox'
|
|
18
18
|
import { truncateResult } from './truncate'
|
|
19
19
|
import { applyContextScopeToQuery, applyContextScopeToBody } from './scope-injection'
|
|
@@ -560,15 +560,6 @@ export const CODE_MODE_MAX_API_CALLS = 50
|
|
|
560
560
|
/** Maximum mutation (non-GET/HEAD/OPTIONS) api.request() calls allowed per execute() run. */
|
|
561
561
|
export const CODE_MODE_MAX_MUTATION_CALLS = 20
|
|
562
562
|
|
|
563
|
-
/**
|
|
564
|
-
* Register a Code Mode tool through the typed definition so the optional
|
|
565
|
-
* metadata (`isMutation`, `isDestructive`) survives registration — the MCP
|
|
566
|
-
* `tools/list` annotations are derived from those flags.
|
|
567
|
-
*/
|
|
568
|
-
function registerCodeModeTool(tool: AiToolDefinition<{ code: string }>): void {
|
|
569
|
-
registerMcpTool(tool, { moduleId: 'codemode' })
|
|
570
|
-
}
|
|
571
|
-
|
|
572
563
|
/**
|
|
573
564
|
* Load and register the two Code Mode tools.
|
|
574
565
|
* Generates TypeScript type stubs for common endpoints at startup.
|
|
@@ -585,10 +576,9 @@ export async function loadCodeModeTools(): Promise<number> {
|
|
|
585
576
|
* search — Query the OpenAPI spec and entity graph programmatically.
|
|
586
577
|
*/
|
|
587
578
|
function registerSearchTool(): void {
|
|
588
|
-
|
|
579
|
+
registerMcpTool(
|
|
589
580
|
{
|
|
590
581
|
name: 'search',
|
|
591
|
-
isMutation: false,
|
|
592
582
|
description: `Query the OpenAPI spec and entity schemas. READ-ONLY, no side effects.
|
|
593
583
|
Globals: spec.findEndpoints(keyword), spec.describeEndpoint(path, method), spec.describeEntity(keyword), spec.paths, spec.entitySchemas.
|
|
594
584
|
Use BEFORE execute to learn endpoint schemas for CREATE/UPDATE. Skip for common paths (companies, people, orders, quotes, products).`,
|
|
@@ -660,7 +650,8 @@ Use BEFORE execute to learn endpoint schemas for CREATE/UPDATE. Skip for common
|
|
|
660
650
|
_memoryContext: memoryContext,
|
|
661
651
|
}
|
|
662
652
|
},
|
|
663
|
-
}
|
|
653
|
+
},
|
|
654
|
+
{ moduleId: 'codemode' }
|
|
664
655
|
)
|
|
665
656
|
}
|
|
666
657
|
|
|
@@ -672,15 +663,9 @@ function registerExecuteTool(commonTypes: string): void {
|
|
|
672
663
|
? `\n\n${commonTypes}`
|
|
673
664
|
: ''
|
|
674
665
|
|
|
675
|
-
|
|
666
|
+
registerMcpTool(
|
|
676
667
|
{
|
|
677
668
|
name: 'execute',
|
|
678
|
-
// api.request() reaches every documented endpoint, including POST/PUT/DELETE,
|
|
679
|
-
// so the tool is neither read-only nor guaranteed non-destructive. It is
|
|
680
|
-
// intentionally exempt from prepareMutation: arbitrary sandbox code cannot
|
|
681
|
-
// provide the structured before/after preview that approval flow requires.
|
|
682
|
-
isMutation: true,
|
|
683
|
-
isDestructive: true,
|
|
684
669
|
description: `Make API calls. Returns JSON.
|
|
685
670
|
Globals: api.request({ method, path, query?, body? }) → { success, statusCode, data }, context { tenantId, organizationId, userId }.
|
|
686
671
|
RULES: For FIND/LIST → GET only (1 call). For UPDATE → PUT to collection path with id in BODY. NEVER PUT/POST/DELETE unless user explicitly asked to change data. Before ANY write operation (POST/PUT/DELETE), you MUST use the AskUserQuestion tool to get explicit user confirmation. Do NOT just ask in text — use the tool so execution pauses until the user responds.${typesBlock}`,
|
|
@@ -764,7 +749,8 @@ RULES: For FIND/LIST → GET only (1 call). For UPDATE → PUT to collection pat
|
|
|
764
749
|
_memoryContext: memoryContext,
|
|
765
750
|
}
|
|
766
751
|
},
|
|
767
|
-
}
|
|
752
|
+
},
|
|
753
|
+
{ moduleId: 'codemode' }
|
|
768
754
|
)
|
|
769
755
|
}
|
|
770
756
|
|
|
@@ -121,11 +121,7 @@ export async function compileAndImportGenerated(tsPath: string): Promise<Record<
|
|
|
121
121
|
if (useJestCjsArtifact) {
|
|
122
122
|
return requireFromHere(jsPath) as Record<string, unknown>
|
|
123
123
|
}
|
|
124
|
-
return (await import(
|
|
125
|
-
/* webpackIgnore: true */
|
|
126
|
-
/* turbopackIgnore: true */
|
|
127
|
-
pathToFileURL(jsPath).href
|
|
128
|
-
)) as Record<string, unknown>
|
|
124
|
+
return (await import(pathToFileURL(jsPath).href)) as Record<string, unknown>
|
|
129
125
|
}
|
|
130
126
|
|
|
131
127
|
function isJestRuntime(): boolean {
|
|
@@ -173,11 +169,7 @@ async function compileAppLocalModuleEntries(
|
|
|
173
169
|
if (specifiers.length === 0) return artifacts
|
|
174
170
|
|
|
175
171
|
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
176
|
-
const { compileAppSourceFile } = await import(
|
|
177
|
-
/* webpackIgnore: true */
|
|
178
|
-
/* turbopackIgnore: true */
|
|
179
|
-
'@open-mercato/shared/lib/bootstrap/dynamicLoader'
|
|
180
|
-
)
|
|
172
|
+
const { compileAppSourceFile } = await import('@open-mercato/shared/lib/bootstrap/dynamicLoader')
|
|
181
173
|
|
|
182
174
|
for (const specifier of specifiers) {
|
|
183
175
|
const target = path.resolve(generatedDir, specifier)
|
|
@@ -10,7 +10,6 @@ import { executeTool } from './tool-executor'
|
|
|
10
10
|
import { loadAllModuleTools, indexToolsForSearch } from './tool-loader'
|
|
11
11
|
import { authenticateMcpRequest, extractApiKeyFromHeaders, hasRequiredFeatures } from './auth'
|
|
12
12
|
import { jsonSchemaToZod, toSafeZodSchema } from './schema-utils'
|
|
13
|
-
import { buildMcpToolAnnotations } from './mcp-tool-annotations'
|
|
14
13
|
import { redactSecretForLog, deriveApiKeySessionId } from './log-redaction'
|
|
15
14
|
import type { McpServerConfig, McpToolContext } from './types'
|
|
16
15
|
import type { SearchService } from '@open-mercato/search/service'
|
|
@@ -220,7 +219,6 @@ function createMcpServerForRequest(
|
|
|
220
219
|
{
|
|
221
220
|
description: tool.description,
|
|
222
221
|
inputSchema: safeSchema,
|
|
223
|
-
annotations: buildMcpToolAnnotations(tool),
|
|
224
222
|
},
|
|
225
223
|
async (args: unknown) => {
|
|
226
224
|
const toolArgs = (args ?? {}) as Record<string, unknown>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
2
|
import type { z } from 'zod'
|
|
3
3
|
import { toolInputJsonSchema } from './tool-input-schema'
|
|
4
|
-
import { buildMcpToolAnnotations } from './mcp-tool-annotations'
|
|
5
4
|
import { getToolRegistry } from './tool-registry'
|
|
6
5
|
import { executeTool } from './tool-executor'
|
|
7
6
|
import { loadAllModuleTools } from './tool-loader'
|
|
@@ -139,7 +138,6 @@ export class InProcessMcpClient implements McpClientInterface {
|
|
|
139
138
|
name: tool.name,
|
|
140
139
|
description: tool.description,
|
|
141
140
|
inputSchema: toolInputJsonSchema(tool.inputSchema),
|
|
142
|
-
annotations: buildMcpToolAnnotations(tool),
|
|
143
141
|
}))
|
|
144
142
|
}
|
|
145
143
|
|
|
@@ -8,7 +8,6 @@ import { executeTool } from './tool-executor'
|
|
|
8
8
|
import { loadAllModuleTools, indexToolsForSearch } from './tool-loader'
|
|
9
9
|
import { authenticateMcpRequest, extractApiKeyFromHeaders, hasRequiredFeatures } from './auth'
|
|
10
10
|
import { jsonSchemaToZod } from './schema-utils'
|
|
11
|
-
import { buildMcpToolAnnotations } from './mcp-tool-annotations'
|
|
12
11
|
import { getApiKeyFromMcpJson } from './mcp-dev-key-resolution'
|
|
13
12
|
import type { McpToolContext } from './types'
|
|
14
13
|
import type { SearchService } from '@open-mercato/search/service'
|
|
@@ -112,7 +111,6 @@ function createDevMcpServer(
|
|
|
112
111
|
{
|
|
113
112
|
description: tool.description,
|
|
114
113
|
inputSchema: safeSchema,
|
|
115
|
-
annotations: buildMcpToolAnnotations(tool),
|
|
116
114
|
},
|
|
117
115
|
async (args: unknown) => {
|
|
118
116
|
const toolArgs = (args ?? {}) as Record<string, unknown>
|