@open-mercato/ai-assistant 0.7.0 → 0.7.1-develop.7103.1.41ff100d93
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 +93 -3
- 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 +2 -0
- 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 +14 -6
- package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js +10 -2
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
- package/dist/modules/ai_assistant/lib/http-server.js +3 -1
- package/dist/modules/ai_assistant/lib/http-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/in-process-client.js +3 -1
- package/dist/modules/ai_assistant/lib/in-process-client.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-client.js +2 -1
- package/dist/modules/ai_assistant/lib/mcp-client.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js +3 -1
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-server.js +3 -1
- package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-tool-annotations.js +18 -0
- package/dist/modules/ai_assistant/lib/mcp-tool-annotations.js.map +7 -0
- package/package.json +8 -7
- package/src/modules/ai_assistant/__tests__/integration/ws-c-tool-pack-coverage.test.ts +5 -0
- package/src/modules/ai_assistant/ai-tools/__tests__/search-pack.test.ts +211 -5
- package/src/modules/ai_assistant/ai-tools/search-pack.ts +110 -4
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +3 -0
- package/src/modules/ai_assistant/lib/__tests__/codemode-tool-annotations.test.ts +58 -0
- package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +16 -0
- package/src/modules/ai_assistant/lib/__tests__/mcp-client.test.ts +30 -0
- package/src/modules/ai_assistant/lib/__tests__/mcp-server-tool-annotations.test.ts +120 -0
- package/src/modules/ai_assistant/lib/__tests__/mcp-tool-annotations.test.ts +57 -0
- package/src/modules/ai_assistant/lib/codemode-tools.ts +21 -7
- package/src/modules/ai_assistant/lib/generated-registry-loader.ts +10 -2
- package/src/modules/ai_assistant/lib/http-server.ts +2 -0
- package/src/modules/ai_assistant/lib/in-process-client.ts +2 -0
- package/src/modules/ai_assistant/lib/mcp-client.ts +1 -0
- package/src/modules/ai_assistant/lib/mcp-dev-server.ts +2 -0
- package/src/modules/ai_assistant/lib/mcp-server.ts +2 -0
- package/src/modules/ai_assistant/lib/mcp-tool-annotations.ts +35 -0
- package/src/modules/ai_assistant/lib/types.ts +11 -0
|
@@ -156,10 +156,15 @@ describe('WS-C integration — tool-pack coverage', () => {
|
|
|
156
156
|
|
|
157
157
|
it('propagates tenantId + organizationId to the search service call', async () => {
|
|
158
158
|
const searchMock = jest.fn().mockResolvedValue([])
|
|
159
|
+
const searchIndexerMock = {
|
|
160
|
+
getEntityConfig: (entityId: string) => ({ entityId, aclFeatures: ['ai_assistant.view'], enabled: true } as any),
|
|
161
|
+
getAllEntityConfigs: () => [{ entityId: 'test:entity', aclFeatures: ['ai_assistant.view'], enabled: true } as any],
|
|
162
|
+
}
|
|
159
163
|
const ctx = makeCtx({
|
|
160
164
|
container: {
|
|
161
165
|
resolve: (name: string) => {
|
|
162
166
|
if (name === 'searchService') return { search: searchMock }
|
|
167
|
+
if (name === 'searchIndexer') return searchIndexerMock
|
|
163
168
|
throw new Error(`Unknown registration: ${name}`)
|
|
164
169
|
},
|
|
165
170
|
},
|
|
@@ -2,7 +2,8 @@
|
|
|
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
|
|
5
|
+
* happy / miss / tenant isolation, plus per-entity ACL enforcement for
|
|
6
|
+
* issue #5211 (legacy pack must not leak records the caller cannot view).
|
|
6
7
|
*/
|
|
7
8
|
import searchAiTools from '../search-pack'
|
|
8
9
|
|
|
@@ -57,6 +58,19 @@ function makeSearchService(results: unknown[]): {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
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
|
+
|
|
60
74
|
describe('search.hybrid_search', () => {
|
|
61
75
|
const tool = findTool('search.hybrid_search')
|
|
62
76
|
|
|
@@ -71,8 +85,10 @@ describe('search.hybrid_search', () => {
|
|
|
71
85
|
},
|
|
72
86
|
])
|
|
73
87
|
const ctx = makeCtx()
|
|
88
|
+
const lookup = permissiveLookup(['catalog:product'])
|
|
74
89
|
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
75
90
|
if (name === 'searchService') return service
|
|
91
|
+
if (name === 'searchIndexer') return lookup
|
|
76
92
|
throw new Error(`unexpected resolve ${name}`)
|
|
77
93
|
})
|
|
78
94
|
const result = (await tool.handler(
|
|
@@ -95,7 +111,12 @@ describe('search.hybrid_search', () => {
|
|
|
95
111
|
it('defaults limit to 20 when omitted', async () => {
|
|
96
112
|
const { service, calls } = makeSearchService([])
|
|
97
113
|
const ctx = makeCtx()
|
|
98
|
-
|
|
114
|
+
const lookup = permissiveLookup(['catalog:product'])
|
|
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
|
+
})
|
|
99
120
|
await tool.handler({ q: 'hello' }, ctx as any)
|
|
100
121
|
expect(calls[0].options.limit).toBe(20)
|
|
101
122
|
})
|
|
@@ -105,6 +126,105 @@ describe('search.hybrid_search', () => {
|
|
|
105
126
|
;(ctx.container.resolve as jest.Mock).mockReturnValue({ search: jest.fn() })
|
|
106
127
|
await expect(tool.handler({ q: 'x' }, ctx as any)).rejects.toThrow(/Tenant context/)
|
|
107
128
|
})
|
|
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
|
+
})
|
|
108
228
|
})
|
|
109
229
|
|
|
110
230
|
describe('search.get_record_context', () => {
|
|
@@ -125,7 +245,12 @@ describe('search.get_record_context', () => {
|
|
|
125
245
|
match,
|
|
126
246
|
])
|
|
127
247
|
const ctx = makeCtx()
|
|
128
|
-
|
|
248
|
+
const lookup = permissiveLookup(['catalog:product'])
|
|
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
|
+
})
|
|
129
254
|
const result = (await tool.handler(
|
|
130
255
|
{ entityId: 'catalog:product', recordId: 'rec-42' },
|
|
131
256
|
ctx as any,
|
|
@@ -149,7 +274,12 @@ describe('search.get_record_context', () => {
|
|
|
149
274
|
{ entityId: 'catalog:product', recordId: 'other', score: 0.2, source: 'fulltext' },
|
|
150
275
|
])
|
|
151
276
|
const ctx = makeCtx()
|
|
152
|
-
|
|
277
|
+
const lookup = permissiveLookup(['catalog:product'])
|
|
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
|
+
})
|
|
153
283
|
const result = (await tool.handler(
|
|
154
284
|
{ entityId: 'catalog:product', recordId: 'missing' },
|
|
155
285
|
ctx as any,
|
|
@@ -161,7 +291,12 @@ describe('search.get_record_context', () => {
|
|
|
161
291
|
it('passes the caller tenant/org and never leaks another tenant', async () => {
|
|
162
292
|
const { service, calls } = makeSearchService([])
|
|
163
293
|
const ctx = makeCtx({ tenantId: 'tenant-A', organizationId: 'org-A' })
|
|
164
|
-
|
|
294
|
+
const lookup = permissiveLookup(['x:y'])
|
|
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
|
+
})
|
|
165
300
|
await tool.handler({ entityId: 'x:y', recordId: 'z' }, ctx as any)
|
|
166
301
|
expect(calls[0].options).toMatchObject({
|
|
167
302
|
tenantId: 'tenant-A',
|
|
@@ -177,6 +312,77 @@ describe('search.get_record_context', () => {
|
|
|
177
312
|
tool.handler({ entityId: 'x:y', recordId: 'z' }, ctx as any),
|
|
178
313
|
).rejects.toThrow(/Tenant context/)
|
|
179
314
|
})
|
|
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
|
+
})
|
|
180
386
|
})
|
|
181
387
|
|
|
182
388
|
describe('search-pack tool surface', () => {
|
|
@@ -7,13 +7,68 @@
|
|
|
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'
|
|
10
18
|
import { defineAiTool } from '../lib/ai-tool-definition'
|
|
11
|
-
import type { AiToolDefinition } from '../lib/types'
|
|
19
|
+
import type { AiToolDefinition, McpToolContext } from '../lib/types'
|
|
12
20
|
|
|
13
21
|
type SearchServiceLike = {
|
|
14
22
|
search: (query: string, options: SearchOptions) => Promise<SearchResult[]>
|
|
15
23
|
}
|
|
16
24
|
|
|
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
|
+
|
|
17
72
|
const hybridSearchInput = z.object({
|
|
18
73
|
q: z.string().min(1).describe('Search query text.'),
|
|
19
74
|
limit: z
|
|
@@ -49,13 +104,52 @@ const hybridSearchTool = defineAiTool({
|
|
|
49
104
|
const service = ctx.container.resolve<SearchServiceLike>('searchService')
|
|
50
105
|
const limit = input.limit ?? 20
|
|
51
106
|
const started = Date.now()
|
|
52
|
-
const
|
|
107
|
+
const subject: SearchEntityAccessSubject = {
|
|
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, {
|
|
53
146
|
tenantId: ctx.tenantId,
|
|
54
147
|
organizationId: ctx.organizationId,
|
|
55
148
|
limit,
|
|
56
149
|
strategies: input.strategies as SearchStrategyId[] | undefined,
|
|
57
|
-
entityTypes:
|
|
150
|
+
entityTypes: readableEntityTypes,
|
|
58
151
|
})
|
|
152
|
+
const results = filterSearchResultsByEntityAccess(rawResults, lookup, subject)
|
|
59
153
|
const timingMs = Date.now() - started
|
|
60
154
|
const strategiesUsed = Array.from(
|
|
61
155
|
new Set(results.map((result) => result.source).filter((id): id is SearchStrategyId => typeof id === 'string')),
|
|
@@ -88,13 +182,25 @@ const getRecordContextTool = defineAiTool({
|
|
|
88
182
|
throw new Error('Tenant context is required for search.get_record_context')
|
|
89
183
|
}
|
|
90
184
|
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
|
+
|
|
91
196
|
const service = ctx.container.resolve<SearchServiceLike>('searchService')
|
|
92
|
-
const
|
|
197
|
+
const rawResults = await service.search(input.recordId, {
|
|
93
198
|
tenantId: ctx.tenantId,
|
|
94
199
|
organizationId: ctx.organizationId,
|
|
95
200
|
limit: 5,
|
|
96
201
|
entityTypes: [input.entityId],
|
|
97
202
|
})
|
|
203
|
+
const results = lookup ? filterSearchResultsByEntityAccess(rawResults, lookup, subject) : rawResults
|
|
98
204
|
const match = results.find((result) => result.recordId === input.recordId)
|
|
99
205
|
if (!match) {
|
|
100
206
|
return {
|
|
@@ -32,6 +32,7 @@ type ModerationFlagsResponse = {
|
|
|
32
32
|
total: number
|
|
33
33
|
page: number
|
|
34
34
|
pageSize: number
|
|
35
|
+
totalIsCapped?: boolean
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
async function fetchModerationFlags(params: {
|
|
@@ -123,6 +124,7 @@ export function AiModerationFlagsPageClient() {
|
|
|
123
124
|
)
|
|
124
125
|
|
|
125
126
|
const total = query.data?.total ?? 0
|
|
127
|
+
const totalIsCapped = query.data?.totalIsCapped === true
|
|
126
128
|
|
|
127
129
|
return (
|
|
128
130
|
<div className="flex flex-col gap-4">
|
|
@@ -176,6 +178,7 @@ export function AiModerationFlagsPageClient() {
|
|
|
176
178
|
pageSize: PAGE_SIZE,
|
|
177
179
|
total,
|
|
178
180
|
totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)),
|
|
181
|
+
totalIsCapped,
|
|
179
182
|
onPageChange: setPage,
|
|
180
183
|
}}
|
|
181
184
|
/>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { loadCodeModeTools } from '../codemode-tools'
|
|
2
|
+
import { buildMcpToolAnnotations } from '../mcp-tool-annotations'
|
|
3
|
+
import { registerMcpTool } from '../tool-registry'
|
|
4
|
+
import type { McpToolDefinition } from '../types'
|
|
5
|
+
|
|
6
|
+
jest.mock('../api-endpoint-index', () => ({
|
|
7
|
+
getApiEndpoints: jest.fn(async () => []),
|
|
8
|
+
getRawOpenApiSpec: jest.fn(async () => null),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
jest.mock('../tool-registry', () => ({
|
|
12
|
+
registerMcpTool: jest.fn(),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const mockedRegisterMcpTool = jest.mocked(registerMcpTool)
|
|
16
|
+
|
|
17
|
+
async function loadRegisteredCodeModeTools(): Promise<Map<string, McpToolDefinition>> {
|
|
18
|
+
await loadCodeModeTools()
|
|
19
|
+
const tools = new Map<string, McpToolDefinition>()
|
|
20
|
+
for (const call of mockedRegisterMcpTool.mock.calls) {
|
|
21
|
+
const tool = call[0] as McpToolDefinition
|
|
22
|
+
tools.set(tool.name, tool)
|
|
23
|
+
}
|
|
24
|
+
return tools
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('issue #5283 — Code Mode tools declare their mutation surface', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
mockedRegisterMcpTool.mockClear()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('advertises the spec-querying search tool as read-only', async () => {
|
|
33
|
+
const tools = await loadRegisteredCodeModeTools()
|
|
34
|
+
const search = tools.get('search')
|
|
35
|
+
|
|
36
|
+
expect(search).toBeDefined()
|
|
37
|
+
expect(buildMcpToolAnnotations(search!)).toEqual({ readOnlyHint: true })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('never advertises the api.request() execute tool as read-only', async () => {
|
|
41
|
+
const tools = await loadRegisteredCodeModeTools()
|
|
42
|
+
const execute = tools.get('execute')
|
|
43
|
+
|
|
44
|
+
expect(execute).toBeDefined()
|
|
45
|
+
expect(buildMcpToolAnnotations(execute!)).toEqual({
|
|
46
|
+
readOnlyHint: false,
|
|
47
|
+
destructiveHint: true,
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('registers both Code Mode tools under the codemode module id', async () => {
|
|
52
|
+
await loadRegisteredCodeModeTools()
|
|
53
|
+
|
|
54
|
+
for (const call of mockedRegisterMcpTool.mock.calls) {
|
|
55
|
+
expect(call[1]).toEqual({ moduleId: 'codemode' })
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -36,6 +36,22 @@ 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
|
+
|
|
39
55
|
describe('rewriteGeneratedAliasImports', () => {
|
|
40
56
|
// Regression for the MCP dev-server crash:
|
|
41
57
|
// "Cannot find package '@/.mercato' imported from .../tool-loader.js"
|
|
@@ -90,4 +90,34 @@ 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
|
+
})
|
|
93
123
|
})
|