@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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -1
  3. package/dist/modules/ai_assistant/ai-tools/search-pack.js +93 -3
  4. package/dist/modules/ai_assistant/ai-tools/search-pack.js.map +3 -3
  5. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +2 -0
  6. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +2 -2
  7. package/dist/modules/ai_assistant/lib/codemode-tools.js +14 -6
  8. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
  9. package/dist/modules/ai_assistant/lib/generated-registry-loader.js +10 -2
  10. package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
  11. package/dist/modules/ai_assistant/lib/http-server.js +3 -1
  12. package/dist/modules/ai_assistant/lib/http-server.js.map +2 -2
  13. package/dist/modules/ai_assistant/lib/in-process-client.js +3 -1
  14. package/dist/modules/ai_assistant/lib/in-process-client.js.map +2 -2
  15. package/dist/modules/ai_assistant/lib/mcp-client.js +2 -1
  16. package/dist/modules/ai_assistant/lib/mcp-client.js.map +2 -2
  17. package/dist/modules/ai_assistant/lib/mcp-dev-server.js +3 -1
  18. package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
  19. package/dist/modules/ai_assistant/lib/mcp-server.js +3 -1
  20. package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
  21. package/dist/modules/ai_assistant/lib/mcp-tool-annotations.js +18 -0
  22. package/dist/modules/ai_assistant/lib/mcp-tool-annotations.js.map +7 -0
  23. package/package.json +8 -7
  24. package/src/modules/ai_assistant/__tests__/integration/ws-c-tool-pack-coverage.test.ts +5 -0
  25. package/src/modules/ai_assistant/ai-tools/__tests__/search-pack.test.ts +211 -5
  26. package/src/modules/ai_assistant/ai-tools/search-pack.ts +110 -4
  27. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +3 -0
  28. package/src/modules/ai_assistant/lib/__tests__/codemode-tool-annotations.test.ts +58 -0
  29. package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +16 -0
  30. package/src/modules/ai_assistant/lib/__tests__/mcp-client.test.ts +30 -0
  31. package/src/modules/ai_assistant/lib/__tests__/mcp-server-tool-annotations.test.ts +120 -0
  32. package/src/modules/ai_assistant/lib/__tests__/mcp-tool-annotations.test.ts +57 -0
  33. package/src/modules/ai_assistant/lib/codemode-tools.ts +21 -7
  34. package/src/modules/ai_assistant/lib/generated-registry-loader.ts +10 -2
  35. package/src/modules/ai_assistant/lib/http-server.ts +2 -0
  36. package/src/modules/ai_assistant/lib/in-process-client.ts +2 -0
  37. package/src/modules/ai_assistant/lib/mcp-client.ts +1 -0
  38. package/src/modules/ai_assistant/lib/mcp-dev-server.ts +2 -0
  39. package/src/modules/ai_assistant/lib/mcp-server.ts +2 -0
  40. package/src/modules/ai_assistant/lib/mcp-tool-annotations.ts +35 -0
  41. 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
- ;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
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
- ;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
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
- ;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
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
- ;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
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 results = await service.search(input.q, {
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: input.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 results = await service.search(input.recordId, {
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
  })