@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
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod'
2
+ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
3
+ import { createMcpServer } from '../mcp-server'
4
+ import type { AiToolDefinition, McpServerOptions, McpToolDefinition } from '../types'
5
+
6
+ const mockCapturedHandlers = new Map<unknown, (request: unknown) => Promise<unknown>>()
7
+ const mockRegisteredTools = new Map<string, McpToolDefinition>()
8
+
9
+ jest.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
10
+ Server: class {
11
+ setRequestHandler(schema: unknown, handler: (request: unknown) => Promise<unknown>) {
12
+ mockCapturedHandlers.set(schema, handler)
13
+ }
14
+ },
15
+ }))
16
+ jest.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
17
+ StdioServerTransport: class {},
18
+ }))
19
+ jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
20
+ ListToolsRequestSchema: { id: 'tools/list' },
21
+ CallToolRequestSchema: { id: 'tools/call' },
22
+ }))
23
+ jest.mock('../tool-registry', () => ({
24
+ getToolRegistry: () => ({
25
+ getTools: () => mockRegisteredTools,
26
+ listToolNames: () => Array.from(mockRegisteredTools.keys()),
27
+ }),
28
+ }))
29
+ jest.mock('../tool-executor', () => ({ executeTool: jest.fn() }))
30
+ jest.mock('../tool-loader', () => ({
31
+ loadAllModuleTools: jest.fn(),
32
+ indexToolsForSearch: jest.fn(),
33
+ }))
34
+ jest.mock('../auth', () => ({
35
+ authenticateMcpRequest: jest.fn(),
36
+ hasRequiredFeatures: jest.fn(() => true),
37
+ }))
38
+
39
+ function registerTool(overrides: Partial<AiToolDefinition> & { name: string }): void {
40
+ mockRegisteredTools.set(overrides.name, {
41
+ description: `${overrides.name} description`,
42
+ inputSchema: z.object({ id: z.string() }),
43
+ handler: async () => ({}),
44
+ ...overrides,
45
+ } as McpToolDefinition)
46
+ }
47
+
48
+ function makeContainer(): McpServerOptions['container'] {
49
+ return {
50
+ resolve: () => ({ loadAcl: jest.fn() }),
51
+ } as unknown as McpServerOptions['container']
52
+ }
53
+
54
+ type ListedTool = {
55
+ name: string
56
+ description: string
57
+ inputSchema: unknown
58
+ annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }
59
+ }
60
+
61
+ async function listTools(): Promise<ListedTool[]> {
62
+ await createMcpServer({
63
+ config: { name: 'test-mcp', version: '0.0.0' },
64
+ container: makeContainer(),
65
+ allowUnauthenticatedSuperadmin: true,
66
+ })
67
+ const handler = mockCapturedHandlers.get(ListToolsRequestSchema)
68
+ if (!handler) throw new Error('[internal] tools/list handler was not registered')
69
+ const response = (await handler({})) as { tools: ListedTool[] }
70
+ return response.tools
71
+ }
72
+
73
+ describe('issue #5283 — MCP tools/list publishes readOnlyHint annotations', () => {
74
+ beforeEach(() => {
75
+ mockRegisteredTools.clear()
76
+ mockCapturedHandlers.clear()
77
+ jest.spyOn(process.stderr, 'write').mockImplementation(() => true)
78
+ })
79
+
80
+ afterEach(() => {
81
+ jest.restoreAllMocks()
82
+ })
83
+
84
+ it('marks read operations as read-only so clients can skip approval', async () => {
85
+ registerTool({ name: 'customers.get_company' })
86
+ registerTool({ name: 'customers.list_people' })
87
+ registerTool({ name: 'search_status', isMutation: false })
88
+
89
+ const tools = await listTools()
90
+
91
+ expect(tools).toHaveLength(3)
92
+ for (const tool of tools) {
93
+ expect(tool.annotations?.readOnlyHint).toBe(true)
94
+ }
95
+ })
96
+
97
+ it('never marks mutating operations as read-only', async () => {
98
+ registerTool({ name: 'catalog.update_product', isMutation: true })
99
+ registerTool({ name: 'customers.manage_deal_comment', isMutation: true, isDestructive: true })
100
+
101
+ const tools = await listTools()
102
+
103
+ const byName = new Map(tools.map((tool) => [tool.name, tool]))
104
+ expect(byName.get('catalog.update_product')?.annotations?.readOnlyHint).toBe(false)
105
+ expect(byName.get('customers.manage_deal_comment')?.annotations).toEqual({
106
+ readOnlyHint: false,
107
+ destructiveHint: true,
108
+ })
109
+ })
110
+
111
+ it('keeps the existing name / description / inputSchema fields intact', async () => {
112
+ registerTool({ name: 'customers.get_company' })
113
+
114
+ const [tool] = await listTools()
115
+
116
+ expect(tool.name).toBe('customers.get_company')
117
+ expect(tool.description).toBe('customers.get_company description')
118
+ expect(tool.inputSchema).toBeDefined()
119
+ })
120
+ })
@@ -0,0 +1,57 @@
1
+ import { z } from 'zod'
2
+ import { buildMcpToolAnnotations } from '../mcp-tool-annotations'
3
+ import type { AiToolDefinition } from '../types'
4
+
5
+ function makeTool(overrides: Partial<AiToolDefinition> = {}): AiToolDefinition {
6
+ return {
7
+ name: 'customers.get_company',
8
+ description: 'Fetch a single company record.',
9
+ inputSchema: z.object({ id: z.string() }),
10
+ handler: async () => ({}),
11
+ ...overrides,
12
+ } as AiToolDefinition
13
+ }
14
+
15
+ describe('buildMcpToolAnnotations', () => {
16
+ it('advertises readOnlyHint for tools that do not declare a mutation', () => {
17
+ expect(buildMcpToolAnnotations(makeTool())).toEqual({ readOnlyHint: true })
18
+ })
19
+
20
+ it('advertises readOnlyHint for tools that explicitly declare isMutation: false', () => {
21
+ expect(buildMcpToolAnnotations(makeTool({ isMutation: false }))).toEqual({ readOnlyHint: true })
22
+ })
23
+
24
+ it('never advertises readOnlyHint for mutating tools', () => {
25
+ expect(buildMcpToolAnnotations(makeTool({ isMutation: true }))).toEqual({ readOnlyHint: false })
26
+ })
27
+
28
+ it('advertises destructiveHint when the mutation declares isDestructive', () => {
29
+ expect(buildMcpToolAnnotations(makeTool({ isMutation: true, isDestructive: true }))).toEqual({
30
+ readOnlyHint: false,
31
+ destructiveHint: true,
32
+ })
33
+ })
34
+
35
+ it('carries an explicit non-destructive declaration through', () => {
36
+ expect(buildMcpToolAnnotations(makeTool({ isMutation: true, isDestructive: false }))).toEqual({
37
+ readOnlyHint: false,
38
+ destructiveHint: false,
39
+ })
40
+ })
41
+
42
+ it('resolves a per-input isDestructive predicate to true rather than under-warning', () => {
43
+ const annotations = buildMcpToolAnnotations(
44
+ makeTool({ isMutation: true, isDestructive: (input: unknown) => (input as { op: string }).op === 'delete' }),
45
+ )
46
+ expect(annotations).toEqual({ readOnlyHint: false, destructiveHint: true })
47
+ })
48
+
49
+ it('omits destructiveHint when a mutation does not declare isDestructive', () => {
50
+ expect(buildMcpToolAnnotations(makeTool({ isMutation: true }))).not.toHaveProperty('destructiveHint')
51
+ })
52
+
53
+ it('never advertises idempotentHint, which no tool definition carries', () => {
54
+ expect(buildMcpToolAnnotations(makeTool())).not.toHaveProperty('idempotentHint')
55
+ expect(buildMcpToolAnnotations(makeTool({ isMutation: true }))).not.toHaveProperty('idempotentHint')
56
+ })
57
+ })
@@ -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 { McpToolContext } from './types'
16
+ import type { AiToolDefinition, McpToolContext } from './types'
17
17
  import { createSandbox } from './sandbox'
18
18
  import { truncateResult } from './truncate'
19
19
  import { applyContextScopeToQuery, applyContextScopeToBody } from './scope-injection'
@@ -560,6 +560,15 @@ 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
+
563
572
  /**
564
573
  * Load and register the two Code Mode tools.
565
574
  * Generates TypeScript type stubs for common endpoints at startup.
@@ -576,9 +585,10 @@ export async function loadCodeModeTools(): Promise<number> {
576
585
  * search — Query the OpenAPI spec and entity graph programmatically.
577
586
  */
578
587
  function registerSearchTool(): void {
579
- registerMcpTool(
588
+ registerCodeModeTool(
580
589
  {
581
590
  name: 'search',
591
+ isMutation: false,
582
592
  description: `Query the OpenAPI spec and entity schemas. READ-ONLY, no side effects.
583
593
  Globals: spec.findEndpoints(keyword), spec.describeEndpoint(path, method), spec.describeEntity(keyword), spec.paths, spec.entitySchemas.
584
594
  Use BEFORE execute to learn endpoint schemas for CREATE/UPDATE. Skip for common paths (companies, people, orders, quotes, products).`,
@@ -650,8 +660,7 @@ Use BEFORE execute to learn endpoint schemas for CREATE/UPDATE. Skip for common
650
660
  _memoryContext: memoryContext,
651
661
  }
652
662
  },
653
- },
654
- { moduleId: 'codemode' }
663
+ }
655
664
  )
656
665
  }
657
666
 
@@ -663,9 +672,15 @@ function registerExecuteTool(commonTypes: string): void {
663
672
  ? `\n\n${commonTypes}`
664
673
  : ''
665
674
 
666
- registerMcpTool(
675
+ registerCodeModeTool(
667
676
  {
668
677
  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,
669
684
  description: `Make API calls. Returns JSON.
670
685
  Globals: api.request({ method, path, query?, body? }) → { success, statusCode, data }, context { tenantId, organizationId, userId }.
671
686
  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}`,
@@ -749,8 +764,7 @@ RULES: For FIND/LIST → GET only (1 call). For UPDATE → PUT to collection pat
749
764
  _memoryContext: memoryContext,
750
765
  }
751
766
  },
752
- },
753
- { moduleId: 'codemode' }
767
+ }
754
768
  )
755
769
  }
756
770
 
@@ -121,7 +121,11 @@ 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(pathToFileURL(jsPath).href)) as Record<string, unknown>
124
+ return (await import(
125
+ /* webpackIgnore: true */
126
+ /* turbopackIgnore: true */
127
+ pathToFileURL(jsPath).href
128
+ )) as Record<string, unknown>
125
129
  }
126
130
 
127
131
  function isJestRuntime(): boolean {
@@ -169,7 +173,11 @@ async function compileAppLocalModuleEntries(
169
173
  if (specifiers.length === 0) return artifacts
170
174
 
171
175
  const generatedDir = path.join(appRoot, '.mercato', 'generated')
172
- const { compileAppSourceFile } = await import('@open-mercato/shared/lib/bootstrap/dynamicLoader')
176
+ const { compileAppSourceFile } = await import(
177
+ /* webpackIgnore: true */
178
+ /* turbopackIgnore: true */
179
+ '@open-mercato/shared/lib/bootstrap/dynamicLoader'
180
+ )
173
181
 
174
182
  for (const specifier of specifiers) {
175
183
  const target = path.resolve(generatedDir, specifier)
@@ -10,6 +10,7 @@ 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'
13
14
  import { redactSecretForLog, deriveApiKeySessionId } from './log-redaction'
14
15
  import type { McpServerConfig, McpToolContext } from './types'
15
16
  import type { SearchService } from '@open-mercato/search/service'
@@ -219,6 +220,7 @@ function createMcpServerForRequest(
219
220
  {
220
221
  description: tool.description,
221
222
  inputSchema: safeSchema,
223
+ annotations: buildMcpToolAnnotations(tool),
222
224
  },
223
225
  async (args: unknown) => {
224
226
  const toolArgs = (args ?? {}) as Record<string, unknown>
@@ -1,6 +1,7 @@
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'
4
5
  import { getToolRegistry } from './tool-registry'
5
6
  import { executeTool } from './tool-executor'
6
7
  import { loadAllModuleTools } from './tool-loader'
@@ -138,6 +139,7 @@ export class InProcessMcpClient implements McpClientInterface {
138
139
  name: tool.name,
139
140
  description: tool.description,
140
141
  inputSchema: toolInputJsonSchema(tool.inputSchema),
142
+ annotations: buildMcpToolAnnotations(tool),
141
143
  }))
142
144
  }
143
145
 
@@ -147,6 +147,7 @@ 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,
150
151
  }))
151
152
  }
152
153
 
@@ -8,6 +8,7 @@ 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'
11
12
  import { getApiKeyFromMcpJson } from './mcp-dev-key-resolution'
12
13
  import type { McpToolContext } from './types'
13
14
  import type { SearchService } from '@open-mercato/search/service'
@@ -111,6 +112,7 @@ function createDevMcpServer(
111
112
  {
112
113
  description: tool.description,
113
114
  inputSchema: safeSchema,
115
+ annotations: buildMcpToolAnnotations(tool),
114
116
  },
115
117
  async (args: unknown) => {
116
118
  const toolArgs = (args ?? {}) as Record<string, unknown>
@@ -5,6 +5,7 @@ import {
5
5
  CallToolRequestSchema,
6
6
  } from '@modelcontextprotocol/sdk/types.js'
7
7
  import { toolInputJsonSchema } from './tool-input-schema'
8
+ import { buildMcpToolAnnotations } from './mcp-tool-annotations'
8
9
  import { getToolRegistry } from './tool-registry'
9
10
  import { executeTool } from './tool-executor'
10
11
  import { loadAllModuleTools, indexToolsForSearch } from './tool-loader'
@@ -133,6 +134,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
133
134
  name: tool.name,
134
135
  description: tool.description,
135
136
  inputSchema: toolInputJsonSchema(tool.inputSchema),
137
+ annotations: buildMcpToolAnnotations(tool),
136
138
  })),
137
139
  }
138
140
  })
@@ -0,0 +1,35 @@
1
+ import type { AiToolDefinition, McpToolAnnotations, McpToolDefinition } from './types'
2
+
3
+ /**
4
+ * Build the MCP annotations advertised for a registered tool.
5
+ *
6
+ * `readOnlyHint` follows the registry's existing mutation contract: a tool is
7
+ * read-only unless it declares `isMutation: true`. Every write tool is required
8
+ * to set that flag (see the module AGENTS.md) and the agent mutation-policy gate
9
+ * already reads it the same way.
10
+ *
11
+ * `destructiveHint` only carries meaning for mutating tools. A predicate
12
+ * `isDestructive` cannot be evaluated without call input, so it resolves to
13
+ * `true` rather than under-warning the client. When the flag is absent the hint
14
+ * is omitted so clients fall back to the conservative MCP default (`true`).
15
+ *
16
+ * `idempotentHint` is deliberately not emitted: no tool-definition field
17
+ * carries that information, so clients keep the MCP default (`false`).
18
+ */
19
+ export function buildMcpToolAnnotations(tool: McpToolDefinition): McpToolAnnotations {
20
+ const definition = tool as AiToolDefinition
21
+
22
+ if (definition.isMutation !== true) {
23
+ return { readOnlyHint: true }
24
+ }
25
+
26
+ const { isDestructive } = definition
27
+ if (isDestructive === undefined) {
28
+ return { readOnlyHint: false }
29
+ }
30
+
31
+ return {
32
+ readOnlyHint: false,
33
+ destructiveHint: typeof isDestructive === 'function' ? true : isDestructive,
34
+ }
35
+ }
@@ -249,6 +249,17 @@ export type ToolInfo = {
249
249
  name: string
250
250
  description: string
251
251
  inputSchema: Record<string, unknown>
252
+ /** MCP capability hints advertised for the tool (see `buildMcpToolAnnotations`). */
253
+ annotations?: McpToolAnnotations
254
+ }
255
+
256
+ /** MCP `ToolAnnotations` values carried through remote and in-process clients. */
257
+ export type McpToolAnnotations = {
258
+ title?: string
259
+ readOnlyHint?: boolean
260
+ destructiveHint?: boolean
261
+ idempotentHint?: boolean
262
+ openWorldHint?: boolean
252
263
  }
253
264
 
254
265
  /**