@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.
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 +3 -93
  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 +0 -2
  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 +6 -14
  8. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
  9. package/dist/modules/ai_assistant/lib/generated-registry-loader.js +2 -10
  10. package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
  11. package/dist/modules/ai_assistant/lib/http-server.js +1 -3
  12. package/dist/modules/ai_assistant/lib/http-server.js.map +2 -2
  13. package/dist/modules/ai_assistant/lib/in-process-client.js +1 -3
  14. package/dist/modules/ai_assistant/lib/in-process-client.js.map +2 -2
  15. package/dist/modules/ai_assistant/lib/mcp-client.js +1 -2
  16. package/dist/modules/ai_assistant/lib/mcp-client.js.map +2 -2
  17. package/dist/modules/ai_assistant/lib/mcp-dev-server.js +1 -3
  18. package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
  19. package/dist/modules/ai_assistant/lib/mcp-server.js +1 -3
  20. package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
  21. package/package.json +7 -8
  22. package/src/modules/ai_assistant/__tests__/integration/ws-c-tool-pack-coverage.test.ts +0 -5
  23. package/src/modules/ai_assistant/ai-tools/__tests__/search-pack.test.ts +5 -211
  24. package/src/modules/ai_assistant/ai-tools/search-pack.ts +4 -110
  25. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +0 -3
  26. package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +0 -16
  27. package/src/modules/ai_assistant/lib/__tests__/mcp-client.test.ts +0 -30
  28. package/src/modules/ai_assistant/lib/codemode-tools.ts +7 -21
  29. package/src/modules/ai_assistant/lib/generated-registry-loader.ts +2 -10
  30. package/src/modules/ai_assistant/lib/http-server.ts +0 -2
  31. package/src/modules/ai_assistant/lib/in-process-client.ts +0 -2
  32. package/src/modules/ai_assistant/lib/mcp-client.ts +0 -1
  33. package/src/modules/ai_assistant/lib/mcp-dev-server.ts +0 -2
  34. package/src/modules/ai_assistant/lib/mcp-server.ts +0 -2
  35. package/src/modules/ai_assistant/lib/types.ts +0 -11
  36. package/dist/modules/ai_assistant/lib/mcp-tool-annotations.js +0 -18
  37. package/dist/modules/ai_assistant/lib/mcp-tool-annotations.js.map +0 -7
  38. package/src/modules/ai_assistant/lib/__tests__/codemode-tool-annotations.test.ts +0 -58
  39. package/src/modules/ai_assistant/lib/__tests__/mcp-server-tool-annotations.test.ts +0 -120
  40. package/src/modules/ai_assistant/lib/__tests__/mcp-tool-annotations.test.ts +0 -57
  41. 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, plus per-entity ACL enforcement for
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
- 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
- })
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
- 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
- })
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
- 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
- })
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
- 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
- })
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, McpToolContext } from '../lib/types'
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 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, {
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: readableEntityTypes,
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 rawResults = await service.search(input.recordId, {
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 { AiToolDefinition, McpToolContext } from './types'
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
- registerCodeModeTool(
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
- registerCodeModeTool(
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
 
@@ -147,7 +147,6 @@ export class McpClient implements McpClientInterface {
147
147
  name: tool.name,
148
148
  description: tool.description ?? '',
149
149
  inputSchema: (tool.inputSchema ?? {}) as Record<string, unknown>,
150
- annotations: tool.annotations,
151
150
  }))
152
151
  }
153
152
 
@@ -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>