@open-mercato/ai-assistant 0.4.2-canary-c02407ff85

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 (193) hide show
  1. package/AGENTS.md +1090 -0
  2. package/README.md +607 -0
  3. package/build.mjs +92 -0
  4. package/dist/di.js +8 -0
  5. package/dist/di.js.map +7 -0
  6. package/dist/frontend/components/CommandPalette/CommandFooter.js +80 -0
  7. package/dist/frontend/components/CommandPalette/CommandFooter.js.map +7 -0
  8. package/dist/frontend/components/CommandPalette/CommandHeader.js +53 -0
  9. package/dist/frontend/components/CommandPalette/CommandHeader.js.map +7 -0
  10. package/dist/frontend/components/CommandPalette/CommandInput.js +29 -0
  11. package/dist/frontend/components/CommandPalette/CommandInput.js.map +7 -0
  12. package/dist/frontend/components/CommandPalette/CommandItem.js +92 -0
  13. package/dist/frontend/components/CommandPalette/CommandItem.js.map +7 -0
  14. package/dist/frontend/components/CommandPalette/CommandPalette.js +244 -0
  15. package/dist/frontend/components/CommandPalette/CommandPalette.js.map +7 -0
  16. package/dist/frontend/components/CommandPalette/CommandPaletteProvider.js +42 -0
  17. package/dist/frontend/components/CommandPalette/CommandPaletteProvider.js.map +7 -0
  18. package/dist/frontend/components/CommandPalette/CommandPaletteWrapper.js +18 -0
  19. package/dist/frontend/components/CommandPalette/CommandPaletteWrapper.js.map +7 -0
  20. package/dist/frontend/components/CommandPalette/DebugPanel.js +215 -0
  21. package/dist/frontend/components/CommandPalette/DebugPanel.js.map +7 -0
  22. package/dist/frontend/components/CommandPalette/MessageBubble.js +64 -0
  23. package/dist/frontend/components/CommandPalette/MessageBubble.js.map +7 -0
  24. package/dist/frontend/components/CommandPalette/ToolCallConfirmation.js +91 -0
  25. package/dist/frontend/components/CommandPalette/ToolCallConfirmation.js.map +7 -0
  26. package/dist/frontend/components/CommandPalette/ToolCallDisplay.js +47 -0
  27. package/dist/frontend/components/CommandPalette/ToolCallDisplay.js.map +7 -0
  28. package/dist/frontend/components/CommandPalette/ToolChatPage.js +74 -0
  29. package/dist/frontend/components/CommandPalette/ToolChatPage.js.map +7 -0
  30. package/dist/frontend/components/CommandPalette/index.js +28 -0
  31. package/dist/frontend/components/CommandPalette/index.js.map +7 -0
  32. package/dist/frontend/constants.js +41 -0
  33. package/dist/frontend/constants.js.map +7 -0
  34. package/dist/frontend/hooks/index.js +13 -0
  35. package/dist/frontend/hooks/index.js.map +7 -0
  36. package/dist/frontend/hooks/useCommandPalette.js +1094 -0
  37. package/dist/frontend/hooks/useCommandPalette.js.map +7 -0
  38. package/dist/frontend/hooks/useMcpTools.js +66 -0
  39. package/dist/frontend/hooks/useMcpTools.js.map +7 -0
  40. package/dist/frontend/hooks/usePageContext.js +48 -0
  41. package/dist/frontend/hooks/usePageContext.js.map +7 -0
  42. package/dist/frontend/hooks/useRecentActions.js +56 -0
  43. package/dist/frontend/hooks/useRecentActions.js.map +7 -0
  44. package/dist/frontend/hooks/useRecentTools.js +55 -0
  45. package/dist/frontend/hooks/useRecentTools.js.map +7 -0
  46. package/dist/frontend/index.js +35 -0
  47. package/dist/frontend/index.js.map +7 -0
  48. package/dist/frontend/types.js +1 -0
  49. package/dist/frontend/types.js.map +7 -0
  50. package/dist/frontend/utils/index.js +7 -0
  51. package/dist/frontend/utils/index.js.map +7 -0
  52. package/dist/frontend/utils/toolMatcher.js +95 -0
  53. package/dist/frontend/utils/toolMatcher.js.map +7 -0
  54. package/dist/index.js +57 -0
  55. package/dist/index.js.map +7 -0
  56. package/dist/modules/ai_assistant/acl.js +14 -0
  57. package/dist/modules/ai_assistant/acl.js.map +7 -0
  58. package/dist/modules/ai_assistant/api/chat/route.js +152 -0
  59. package/dist/modules/ai_assistant/api/chat/route.js.map +7 -0
  60. package/dist/modules/ai_assistant/api/health/route.js +27 -0
  61. package/dist/modules/ai_assistant/api/health/route.js.map +7 -0
  62. package/dist/modules/ai_assistant/api/route/route.js +123 -0
  63. package/dist/modules/ai_assistant/api/route/route.js.map +7 -0
  64. package/dist/modules/ai_assistant/api/settings/route.js +60 -0
  65. package/dist/modules/ai_assistant/api/settings/route.js.map +7 -0
  66. package/dist/modules/ai_assistant/api/tools/execute/route.js +58 -0
  67. package/dist/modules/ai_assistant/api/tools/execute/route.js.map +7 -0
  68. package/dist/modules/ai_assistant/api/tools/route.js +48 -0
  69. package/dist/modules/ai_assistant/api/tools/route.js.map +7 -0
  70. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js +10 -0
  71. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js.map +7 -0
  72. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js +28 -0
  73. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js.map +7 -0
  74. package/dist/modules/ai_assistant/cli.js +192 -0
  75. package/dist/modules/ai_assistant/cli.js.map +7 -0
  76. package/dist/modules/ai_assistant/di.js +11 -0
  77. package/dist/modules/ai_assistant/di.js.map +7 -0
  78. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +257 -0
  79. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +7 -0
  80. package/dist/modules/ai_assistant/index.js +13 -0
  81. package/dist/modules/ai_assistant/index.js.map +7 -0
  82. package/dist/modules/ai_assistant/lib/ai-sdk.js +13 -0
  83. package/dist/modules/ai_assistant/lib/ai-sdk.js.map +7 -0
  84. package/dist/modules/ai_assistant/lib/api-discovery-tools.js +249 -0
  85. package/dist/modules/ai_assistant/lib/api-discovery-tools.js.map +7 -0
  86. package/dist/modules/ai_assistant/lib/api-endpoint-index-config.js +177 -0
  87. package/dist/modules/ai_assistant/lib/api-endpoint-index-config.js.map +7 -0
  88. package/dist/modules/ai_assistant/lib/api-endpoint-index.js +210 -0
  89. package/dist/modules/ai_assistant/lib/api-endpoint-index.js.map +7 -0
  90. package/dist/modules/ai_assistant/lib/auth.js +87 -0
  91. package/dist/modules/ai_assistant/lib/auth.js.map +7 -0
  92. package/dist/modules/ai_assistant/lib/chat-config.js +117 -0
  93. package/dist/modules/ai_assistant/lib/chat-config.js.map +7 -0
  94. package/dist/modules/ai_assistant/lib/client-factory.js +60 -0
  95. package/dist/modules/ai_assistant/lib/client-factory.js.map +7 -0
  96. package/dist/modules/ai_assistant/lib/http-server.js +367 -0
  97. package/dist/modules/ai_assistant/lib/http-server.js.map +7 -0
  98. package/dist/modules/ai_assistant/lib/in-process-client.js +126 -0
  99. package/dist/modules/ai_assistant/lib/in-process-client.js.map +7 -0
  100. package/dist/modules/ai_assistant/lib/mcp-client.js +146 -0
  101. package/dist/modules/ai_assistant/lib/mcp-client.js.map +7 -0
  102. package/dist/modules/ai_assistant/lib/mcp-dev-server.js +283 -0
  103. package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +7 -0
  104. package/dist/modules/ai_assistant/lib/mcp-server-config.js +160 -0
  105. package/dist/modules/ai_assistant/lib/mcp-server-config.js.map +7 -0
  106. package/dist/modules/ai_assistant/lib/mcp-server.js +156 -0
  107. package/dist/modules/ai_assistant/lib/mcp-server.js.map +7 -0
  108. package/dist/modules/ai_assistant/lib/mcp-tool-adapter.js +44 -0
  109. package/dist/modules/ai_assistant/lib/mcp-tool-adapter.js.map +7 -0
  110. package/dist/modules/ai_assistant/lib/opencode-client.js +247 -0
  111. package/dist/modules/ai_assistant/lib/opencode-client.js.map +7 -0
  112. package/dist/modules/ai_assistant/lib/opencode-handlers.js +398 -0
  113. package/dist/modules/ai_assistant/lib/opencode-handlers.js.map +7 -0
  114. package/dist/modules/ai_assistant/lib/schema-utils.js +94 -0
  115. package/dist/modules/ai_assistant/lib/schema-utils.js.map +7 -0
  116. package/dist/modules/ai_assistant/lib/tool-executor.js +55 -0
  117. package/dist/modules/ai_assistant/lib/tool-executor.js.map +7 -0
  118. package/dist/modules/ai_assistant/lib/tool-index-config.js +125 -0
  119. package/dist/modules/ai_assistant/lib/tool-index-config.js.map +7 -0
  120. package/dist/modules/ai_assistant/lib/tool-loader.js +88 -0
  121. package/dist/modules/ai_assistant/lib/tool-loader.js.map +7 -0
  122. package/dist/modules/ai_assistant/lib/tool-registry.js +65 -0
  123. package/dist/modules/ai_assistant/lib/tool-registry.js.map +7 -0
  124. package/dist/modules/ai_assistant/lib/tool-search.js +192 -0
  125. package/dist/modules/ai_assistant/lib/tool-search.js.map +7 -0
  126. package/dist/modules/ai_assistant/lib/types.js +1 -0
  127. package/dist/modules/ai_assistant/lib/types.js.map +7 -0
  128. package/package.json +108 -0
  129. package/src/di.ts +11 -0
  130. package/src/frontend/components/CommandPalette/CommandFooter.tsx +113 -0
  131. package/src/frontend/components/CommandPalette/CommandHeader.tsx +76 -0
  132. package/src/frontend/components/CommandPalette/CommandInput.tsx +50 -0
  133. package/src/frontend/components/CommandPalette/CommandItem.tsx +111 -0
  134. package/src/frontend/components/CommandPalette/CommandPalette.tsx +276 -0
  135. package/src/frontend/components/CommandPalette/CommandPaletteProvider.tsx +60 -0
  136. package/src/frontend/components/CommandPalette/CommandPaletteWrapper.tsx +21 -0
  137. package/src/frontend/components/CommandPalette/DebugPanel.tsx +257 -0
  138. package/src/frontend/components/CommandPalette/MessageBubble.tsx +73 -0
  139. package/src/frontend/components/CommandPalette/ToolCallConfirmation.tsx +130 -0
  140. package/src/frontend/components/CommandPalette/ToolCallDisplay.tsx +57 -0
  141. package/src/frontend/components/CommandPalette/ToolChatPage.tsx +125 -0
  142. package/src/frontend/components/CommandPalette/index.ts +14 -0
  143. package/src/frontend/constants.ts +35 -0
  144. package/src/frontend/hooks/index.ts +5 -0
  145. package/src/frontend/hooks/useCommandPalette.ts +1389 -0
  146. package/src/frontend/hooks/useMcpTools.ts +73 -0
  147. package/src/frontend/hooks/usePageContext.ts +61 -0
  148. package/src/frontend/hooks/useRecentActions.ts +64 -0
  149. package/src/frontend/hooks/useRecentTools.ts +69 -0
  150. package/src/frontend/index.ts +39 -0
  151. package/src/frontend/types.ts +260 -0
  152. package/src/frontend/utils/index.ts +1 -0
  153. package/src/frontend/utils/toolMatcher.ts +127 -0
  154. package/src/index.ts +92 -0
  155. package/src/modules/ai_assistant/acl.ts +10 -0
  156. package/src/modules/ai_assistant/api/chat/route.ts +213 -0
  157. package/src/modules/ai_assistant/api/health/route.ts +30 -0
  158. package/src/modules/ai_assistant/api/route/route.ts +149 -0
  159. package/src/modules/ai_assistant/api/settings/route.ts +73 -0
  160. package/src/modules/ai_assistant/api/tools/execute/route.ts +71 -0
  161. package/src/modules/ai_assistant/api/tools/route.ts +57 -0
  162. package/src/modules/ai_assistant/backend/config/ai-assistant/page.meta.ts +26 -0
  163. package/src/modules/ai_assistant/backend/config/ai-assistant/page.tsx +12 -0
  164. package/src/modules/ai_assistant/cli.ts +233 -0
  165. package/src/modules/ai_assistant/di.ts +9 -0
  166. package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +418 -0
  167. package/src/modules/ai_assistant/index.ts +11 -0
  168. package/src/modules/ai_assistant/lib/ai-sdk.ts +5 -0
  169. package/src/modules/ai_assistant/lib/api-discovery-tools.ts +334 -0
  170. package/src/modules/ai_assistant/lib/api-endpoint-index-config.ts +243 -0
  171. package/src/modules/ai_assistant/lib/api-endpoint-index.ts +381 -0
  172. package/src/modules/ai_assistant/lib/auth.ts +185 -0
  173. package/src/modules/ai_assistant/lib/chat-config.ts +152 -0
  174. package/src/modules/ai_assistant/lib/client-factory.ts +130 -0
  175. package/src/modules/ai_assistant/lib/http-server.ts +498 -0
  176. package/src/modules/ai_assistant/lib/in-process-client.ts +205 -0
  177. package/src/modules/ai_assistant/lib/mcp-client.ts +221 -0
  178. package/src/modules/ai_assistant/lib/mcp-dev-server.ts +373 -0
  179. package/src/modules/ai_assistant/lib/mcp-server-config.ts +287 -0
  180. package/src/modules/ai_assistant/lib/mcp-server.ts +214 -0
  181. package/src/modules/ai_assistant/lib/mcp-tool-adapter.ts +76 -0
  182. package/src/modules/ai_assistant/lib/opencode-client.ts +426 -0
  183. package/src/modules/ai_assistant/lib/opencode-handlers.ts +676 -0
  184. package/src/modules/ai_assistant/lib/schema-utils.ts +142 -0
  185. package/src/modules/ai_assistant/lib/tool-executor.ts +71 -0
  186. package/src/modules/ai_assistant/lib/tool-index-config.ts +178 -0
  187. package/src/modules/ai_assistant/lib/tool-loader.ts +149 -0
  188. package/src/modules/ai_assistant/lib/tool-registry.ts +114 -0
  189. package/src/modules/ai_assistant/lib/tool-search.ts +308 -0
  190. package/src/modules/ai_assistant/lib/types.ts +147 -0
  191. package/test-schema.ts +37 -0
  192. package/tsconfig.json +10 -0
  193. package/watch.mjs +6 -0
@@ -0,0 +1,130 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { McpClientInterface } from './types'
3
+
4
+ /**
5
+ * Client connection mode.
6
+ */
7
+ export type ClientMode = 'in-process' | 'stdio' | 'http'
8
+
9
+ /**
10
+ * Options for creating an MCP client.
11
+ */
12
+ export type CreateClientOptions = {
13
+ /** Connection mode */
14
+ mode: ClientMode
15
+ /** API key secret for authentication */
16
+ apiKeySecret: string
17
+ /** DI container (required for in-process mode) */
18
+ container?: AwilixContainer
19
+ /** HTTP server URL (required for http mode) */
20
+ httpUrl?: string
21
+ /** Custom command for stdio mode (default: 'yarn') */
22
+ stdioCommand?: string
23
+ /** Custom args for stdio mode (default: mercato mcp:serve) */
24
+ stdioArgs?: string[]
25
+ /** Working directory for stdio mode */
26
+ cwd?: string
27
+ }
28
+
29
+ /**
30
+ * Create an MCP client with the specified connection mode.
31
+ *
32
+ * All modes authenticate via API key, ensuring consistent ACL enforcement.
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * // In-process mode (fastest, same process)
37
+ * const client = await createMcpClient({
38
+ * mode: 'in-process',
39
+ * apiKeySecret: 'omk_xxx.yyy',
40
+ * container: diContainer,
41
+ * })
42
+ *
43
+ * // Stdio mode (subprocess)
44
+ * const client = await createMcpClient({
45
+ * mode: 'stdio',
46
+ * apiKeySecret: 'omk_xxx.yyy',
47
+ * })
48
+ *
49
+ * // HTTP mode (network)
50
+ * const client = await createMcpClient({
51
+ * mode: 'http',
52
+ * apiKeySecret: 'omk_xxx.yyy',
53
+ * httpUrl: 'http://localhost:3001/mcp',
54
+ * })
55
+ *
56
+ * // Use client (same interface for all modes)
57
+ * const tools = await client.listTools()
58
+ * const result = await client.callTool('search.query', { query: 'test' })
59
+ * await client.close()
60
+ * ```
61
+ */
62
+ export async function createMcpClient(options: CreateClientOptions): Promise<McpClientInterface> {
63
+ const { mode, apiKeySecret } = options
64
+
65
+ if (!apiKeySecret) {
66
+ throw new Error('API key secret is required')
67
+ }
68
+
69
+ switch (mode) {
70
+ case 'in-process': {
71
+ if (!options.container) {
72
+ throw new Error('DI container is required for in-process mode')
73
+ }
74
+
75
+ const { InProcessMcpClient } = await import('./in-process-client')
76
+ return InProcessMcpClient.create({
77
+ apiKeySecret,
78
+ container: options.container,
79
+ })
80
+ }
81
+
82
+ case 'stdio': {
83
+ const { McpClient } = await import('./mcp-client')
84
+
85
+ const stdioOptions: any = {
86
+ transport: 'stdio' as const,
87
+ apiKeySecret,
88
+ }
89
+
90
+ if (options.stdioCommand) {
91
+ stdioOptions.command = options.stdioCommand
92
+ }
93
+
94
+ if (options.stdioArgs) {
95
+ stdioOptions.args = options.stdioArgs
96
+ } else {
97
+ // Default args include the API key
98
+ stdioOptions.args = [
99
+ 'mercato',
100
+ 'ai_assistant',
101
+ 'mcp:serve',
102
+ '--api-key',
103
+ apiKeySecret,
104
+ ]
105
+ }
106
+
107
+ if (options.cwd) {
108
+ stdioOptions.cwd = options.cwd
109
+ }
110
+
111
+ return McpClient.connect(stdioOptions)
112
+ }
113
+
114
+ case 'http': {
115
+ if (!options.httpUrl) {
116
+ throw new Error('HTTP URL is required for http mode')
117
+ }
118
+
119
+ const { McpClient } = await import('./mcp-client')
120
+ return McpClient.connect({
121
+ transport: 'http',
122
+ apiKeySecret,
123
+ url: options.httpUrl,
124
+ })
125
+ }
126
+
127
+ default:
128
+ throw new Error(`Unknown client mode: ${mode}`)
129
+ }
130
+ }
@@ -0,0 +1,498 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
4
+ import type { AwilixContainer } from 'awilix'
5
+ import type { EntityManager } from '@mikro-orm/postgresql'
6
+ import { z, type ZodType } from 'zod'
7
+ import { getToolRegistry } from './tool-registry'
8
+ import { executeTool } from './tool-executor'
9
+ import { loadAllModuleTools, indexToolsForSearch } from './tool-loader'
10
+ import { authenticateMcpRequest, extractApiKeyFromHeaders, hasRequiredFeatures } from './auth'
11
+ import { jsonSchemaToZod, toSafeZodSchema } from './schema-utils'
12
+ import type { McpServerConfig, McpToolContext } from './types'
13
+ import type { SearchService } from '@open-mercato/search/service'
14
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
15
+ import { findApiKeyBySessionToken } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
16
+
17
+ /**
18
+ * Options for the HTTP MCP server.
19
+ */
20
+ export type McpHttpServerOptions = {
21
+ config: McpServerConfig
22
+ container: AwilixContainer
23
+ port: number
24
+ /** Static API key for server-level authentication (from env MCP_SERVER_API_KEY) */
25
+ serverApiKey?: string
26
+ }
27
+
28
+ /**
29
+ * Resolve user context from session token.
30
+ * Returns null if session token is invalid or expired.
31
+ */
32
+ async function resolveSessionContext(
33
+ sessionToken: string,
34
+ baseContext: McpToolContext,
35
+ debug?: boolean
36
+ ): Promise<McpToolContext | null> {
37
+ try {
38
+ const em = baseContext.container.resolve<EntityManager>('em')
39
+ const rbacService = baseContext.container.resolve<RbacService>('rbacService')
40
+
41
+ // Look up ephemeral key by session token
42
+ const sessionKey = await findApiKeyBySessionToken(em, sessionToken)
43
+ if (!sessionKey) {
44
+ if (debug) {
45
+ console.error(`[MCP HTTP] Session token not found or expired: ${sessionToken}`)
46
+ }
47
+ return null
48
+ }
49
+
50
+ // Load ACL for the session user
51
+ const userId = sessionKey.sessionUserId || sessionKey.createdBy
52
+ if (!userId) {
53
+ if (debug) {
54
+ console.error(`[MCP HTTP] Session key has no associated user`)
55
+ }
56
+ return null
57
+ }
58
+
59
+ const acl = await rbacService.loadAcl(`api_key:${sessionKey.id}`, {
60
+ tenantId: sessionKey.tenantId ?? null,
61
+ organizationId: sessionKey.organizationId ?? null,
62
+ })
63
+
64
+ if (debug) {
65
+ console.error(`[MCP HTTP] Session context resolved for user ${userId}:`, {
66
+ tenantId: sessionKey.tenantId,
67
+ organizationId: sessionKey.organizationId,
68
+ features: acl.features.length,
69
+ isSuperAdmin: acl.isSuperAdmin,
70
+ })
71
+ }
72
+
73
+ return {
74
+ tenantId: sessionKey.tenantId ?? null,
75
+ organizationId: sessionKey.organizationId ?? null,
76
+ userId,
77
+ container: baseContext.container,
78
+ userFeatures: acl.features,
79
+ isSuperAdmin: acl.isSuperAdmin,
80
+ apiKeySecret: baseContext.apiKeySecret,
81
+ }
82
+ } catch (error) {
83
+ if (debug) {
84
+ console.error(`[MCP HTTP] Error resolving session context:`, error)
85
+ }
86
+ return null
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Create a stateless MCP server instance for a single request.
92
+ * Tools are registered without pre-filtering - permission checks happen at execution time
93
+ * based on the session token provided in each tool call.
94
+ */
95
+ function createMcpServerForRequest(
96
+ config: McpServerConfig,
97
+ toolContext: McpToolContext
98
+ ): McpServer {
99
+ const server = new McpServer(
100
+ { name: config.name, version: config.version },
101
+ { capabilities: { tools: {} } }
102
+ )
103
+
104
+ const registry = getToolRegistry()
105
+ const tools = Array.from(registry.getTools().values())
106
+
107
+ if (config.debug) {
108
+ console.error(`[MCP HTTP] Registering ${tools.length} tools (ACL checked per-call via session token)`)
109
+ }
110
+
111
+ // Register ALL tools - permission checks happen at execution time via session token
112
+ for (const tool of tools) {
113
+ if (config.debug) {
114
+ console.error(`[MCP HTTP] Registering tool: ${tool.name}`)
115
+ }
116
+
117
+ // Convert Zod schema to a "safe" schema without Date types
118
+ // This uses JSON Schema round-trip to avoid issues with MCP SDK's internal conversion
119
+ // Also inject _sessionToken as an optional parameter so the AI knows to pass it
120
+ let safeSchema: ZodType | undefined
121
+ if (tool.inputSchema) {
122
+ try {
123
+ // Convert to JSON Schema first
124
+ const jsonSchema = z.toJSONSchema(tool.inputSchema, { unrepresentable: 'any' }) as Record<string, unknown>
125
+
126
+ // Inject _sessionToken into the JSON schema properties
127
+ const properties = (jsonSchema.properties ?? {}) as Record<string, unknown>
128
+ properties._sessionToken = {
129
+ type: 'string',
130
+ description: 'Session authorization token (REQUIRED for all tool calls)',
131
+ }
132
+ jsonSchema.properties = properties
133
+
134
+ // Convert back to Zod with passthrough to allow extra properties
135
+ const converted = jsonSchemaToZod(jsonSchema)
136
+ // Use type assertion since we know it's an object schema (we added properties above)
137
+ safeSchema = (converted as z.ZodObject<any>).passthrough()
138
+ } catch (error) {
139
+ if (config.debug) {
140
+ console.error(
141
+ `[MCP HTTP] Skipping tool ${tool.name} - schema conversion failed:`,
142
+ error instanceof Error ? error.message : error
143
+ )
144
+ }
145
+ continue
146
+ }
147
+ } else {
148
+ // If no schema, create one with just _sessionToken
149
+ safeSchema = z.object({
150
+ _sessionToken: z
151
+ .string()
152
+ .optional()
153
+ .describe('Session authorization token (REQUIRED for all tool calls)'),
154
+ })
155
+ }
156
+
157
+ // Wrap in try/catch to handle any remaining edge cases
158
+ try {
159
+ server.registerTool(
160
+ tool.name,
161
+ {
162
+ description: tool.description,
163
+ inputSchema: safeSchema,
164
+ },
165
+ async (args: unknown) => {
166
+ const toolArgs = (args ?? {}) as Record<string, unknown>
167
+
168
+ // Extract session token from args
169
+ const sessionToken = toolArgs._sessionToken as string | undefined
170
+ delete toolArgs._sessionToken // Remove before passing to tool handler
171
+
172
+ if (config.debug) {
173
+ console.error(`[MCP HTTP] Calling tool: ${tool.name}`, {
174
+ hasSessionToken: !!sessionToken,
175
+ args: JSON.stringify(toolArgs),
176
+ })
177
+ }
178
+
179
+ // Resolve user context from session token
180
+ let effectiveContext = toolContext
181
+ if (sessionToken) {
182
+ const sessionContext = await resolveSessionContext(sessionToken, toolContext, config.debug)
183
+ if (sessionContext) {
184
+ effectiveContext = sessionContext
185
+ } else {
186
+ // Session token expired - return user-friendly error for AI to relay
187
+ return {
188
+ content: [
189
+ {
190
+ type: 'text' as const,
191
+ text: JSON.stringify({
192
+ error: 'Your chat session has expired. Please close and reopen the chat window to continue.',
193
+ code: 'SESSION_EXPIRED',
194
+ }),
195
+ },
196
+ ],
197
+ isError: true,
198
+ }
199
+ }
200
+ } else {
201
+ // No session token provided - reject if base context has no permissions
202
+ if (!effectiveContext.userId && effectiveContext.userFeatures.length === 0) {
203
+ return {
204
+ content: [
205
+ {
206
+ type: 'text' as const,
207
+ text: JSON.stringify({
208
+ error: 'Session token required (_sessionToken parameter)',
209
+ code: 'UNAUTHORIZED',
210
+ }),
211
+ },
212
+ ],
213
+ isError: true,
214
+ }
215
+ }
216
+ }
217
+
218
+ // Check if user has required permissions for this tool
219
+ if (tool.requiredFeatures?.length) {
220
+ const hasAccess = hasRequiredFeatures(
221
+ tool.requiredFeatures,
222
+ effectiveContext.userFeatures,
223
+ effectiveContext.isSuperAdmin
224
+ )
225
+ if (!hasAccess) {
226
+ return {
227
+ content: [
228
+ {
229
+ type: 'text' as const,
230
+ text: JSON.stringify({
231
+ error: `Insufficient permissions for tool "${tool.name}". Required: ${tool.requiredFeatures.join(', ')}`,
232
+ code: 'UNAUTHORIZED',
233
+ }),
234
+ },
235
+ ],
236
+ isError: true,
237
+ }
238
+ }
239
+ }
240
+
241
+ const result = await executeTool(tool.name, toolArgs, effectiveContext)
242
+
243
+ if (!result.success) {
244
+ return {
245
+ content: [
246
+ {
247
+ type: 'text' as const,
248
+ text: JSON.stringify({ error: result.error, code: result.errorCode }),
249
+ },
250
+ ],
251
+ isError: true,
252
+ }
253
+ }
254
+
255
+ return {
256
+ content: [
257
+ {
258
+ type: 'text' as const,
259
+ text: JSON.stringify(result.result, null, 2),
260
+ },
261
+ ],
262
+ }
263
+ }
264
+ )
265
+ } catch (error) {
266
+ // Skip tools with schemas that can't be registered
267
+ if (config.debug) {
268
+ console.error(
269
+ `[MCP HTTP] Skipping tool ${tool.name} - registration failed:`,
270
+ error instanceof Error ? error.message : error
271
+ )
272
+ }
273
+ continue
274
+ }
275
+ }
276
+
277
+ return server
278
+ }
279
+
280
+ /**
281
+ * Maximum request body size (1MB).
282
+ * Prevents memory exhaustion from oversized payloads.
283
+ */
284
+ const MAX_BODY_SIZE = 1 * 1024 * 1024
285
+
286
+ /**
287
+ * Parse JSON body from request with size limit.
288
+ */
289
+ async function parseJsonBody(req: IncomingMessage): Promise<unknown> {
290
+ return new Promise((resolve, reject) => {
291
+ const chunks: Buffer[] = []
292
+ let totalSize = 0
293
+
294
+ req.on('data', (chunk: Buffer) => {
295
+ totalSize += chunk.length
296
+ if (totalSize > MAX_BODY_SIZE) {
297
+ req.destroy()
298
+ reject(new Error('Request payload too large'))
299
+ return
300
+ }
301
+ chunks.push(chunk)
302
+ })
303
+ req.on('end', () => {
304
+ try {
305
+ const body = Buffer.concat(chunks).toString('utf-8')
306
+ resolve(body ? JSON.parse(body) : undefined)
307
+ } catch (error) {
308
+ reject(error)
309
+ }
310
+ })
311
+ req.on('error', reject)
312
+ })
313
+ }
314
+
315
+ /**
316
+ * Run MCP server with HTTP transport (stateless mode).
317
+ *
318
+ * Each request creates a new MCP server instance and transport.
319
+ * The server authenticates requests using API keys from the x-api-key header.
320
+ */
321
+ export async function runMcpHttpServer(options: McpHttpServerOptions): Promise<void> {
322
+ const { config, container, port } = options
323
+
324
+ await loadAllModuleTools()
325
+
326
+ // Index tools and API endpoints for hybrid search discovery (if search service available)
327
+ try {
328
+ const searchService = container.resolve('searchService') as SearchService
329
+
330
+ // Index MCP tools
331
+ await indexToolsForSearch(searchService)
332
+
333
+ // Index API endpoints for api_discover
334
+ const { indexApiEndpoints } = await import('./api-endpoint-index')
335
+ const endpointCount = await indexApiEndpoints(searchService)
336
+ if (endpointCount > 0) {
337
+ console.error(`[MCP HTTP] Indexed ${endpointCount} API endpoints for hybrid search`)
338
+ }
339
+ } catch (error) {
340
+ // Search service might not be configured - discovery will use fallback
341
+ console.error('[MCP HTTP] Search indexing skipped (search service not available):', error)
342
+ }
343
+
344
+ const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
345
+ const url = new URL(req.url || '/', `http://localhost:${port}`)
346
+
347
+ // Health check endpoint
348
+ if (url.pathname === '/health') {
349
+ res.writeHead(200, { 'Content-Type': 'application/json' })
350
+ res.end(JSON.stringify({
351
+ status: 'ok',
352
+ tools: getToolRegistry().listToolNames().length,
353
+ timestamp: new Date().toISOString(),
354
+ }))
355
+ return
356
+ }
357
+
358
+ if (url.pathname !== '/mcp') {
359
+ res.writeHead(404, { 'Content-Type': 'application/json' })
360
+ res.end(JSON.stringify({ error: 'Not found' }))
361
+ return
362
+ }
363
+
364
+ // Extract headers
365
+ const headers: Record<string, string | undefined> = {}
366
+ for (const [key, value] of Object.entries(req.headers)) {
367
+ headers[key] = Array.isArray(value) ? value[0] : value
368
+ }
369
+
370
+ // Server-level authentication with static API key
371
+ const providedApiKey = extractApiKeyFromHeaders(headers)
372
+ if (!providedApiKey) {
373
+ res.writeHead(401, { 'Content-Type': 'application/json' })
374
+ res.end(JSON.stringify({ error: 'API key required (x-api-key header)' }))
375
+ return
376
+ }
377
+
378
+ // Check against static server API key (from env MCP_SERVER_API_KEY)
379
+ const serverApiKey = options.serverApiKey || process.env.MCP_SERVER_API_KEY
380
+ if (!serverApiKey) {
381
+ console.error('[MCP HTTP] Warning: MCP_SERVER_API_KEY not configured, rejecting all requests')
382
+ res.writeHead(500, { 'Content-Type': 'application/json' })
383
+ res.end(JSON.stringify({ error: 'MCP server not properly configured' }))
384
+ return
385
+ }
386
+
387
+ if (providedApiKey !== serverApiKey) {
388
+ res.writeHead(401, { 'Content-Type': 'application/json' })
389
+ res.end(JSON.stringify({ error: 'Invalid API key' }))
390
+ return
391
+ }
392
+
393
+ if (config.debug) {
394
+ console.error(`[MCP HTTP] Server-level auth passed (${req.method})`)
395
+ }
396
+
397
+ // Create base tool context (will be overridden by session token per-tool)
398
+ // Start with minimal permissions - session tokens provide user-level auth
399
+ const toolContext: McpToolContext = {
400
+ tenantId: null,
401
+ organizationId: null,
402
+ userId: null,
403
+ container,
404
+ userFeatures: [],
405
+ isSuperAdmin: false,
406
+ apiKeySecret: providedApiKey,
407
+ }
408
+
409
+ try {
410
+ // Create stateless transport (no session ID generator = stateless)
411
+ const transport = new StreamableHTTPServerTransport({
412
+ sessionIdGenerator: undefined,
413
+ enableJsonResponse: req.method === 'POST',
414
+ })
415
+
416
+ // Create new server for this request
417
+ const mcpServer = createMcpServerForRequest(config, toolContext)
418
+
419
+ if (config.debug) {
420
+ // Check registered tools on the server
421
+ const registeredTools = (mcpServer as any)._registeredTools || {}
422
+ console.error(`[MCP HTTP] Registered tools in McpServer:`, Object.keys(registeredTools))
423
+ console.error(`[MCP HTTP] Tool handlers initialized:`, (mcpServer as any)._toolHandlersInitialized)
424
+ }
425
+
426
+ // Connect server to transport
427
+ await mcpServer.connect(transport)
428
+
429
+ // Handle the request
430
+ if (req.method === 'POST') {
431
+ const body = await parseJsonBody(req)
432
+ await transport.handleRequest(req, res, body)
433
+ } else {
434
+ await transport.handleRequest(req, res)
435
+ }
436
+
437
+ // Cleanup after response finishes
438
+ res.on('finish', () => {
439
+ transport.close()
440
+ mcpServer.close()
441
+ if (config.debug) {
442
+ console.error(`[MCP HTTP] Request completed, cleaned up`)
443
+ }
444
+ })
445
+ } catch (error) {
446
+ console.error('[MCP HTTP] Error handling request:', error)
447
+ if (!res.headersSent) {
448
+ // Handle payload too large error
449
+ if (error instanceof Error && error.message === 'Request payload too large') {
450
+ res.writeHead(413, { 'Content-Type': 'application/json' })
451
+ res.end(JSON.stringify({ error: 'Request payload too large (max 1MB)' }))
452
+ return
453
+ }
454
+
455
+ res.writeHead(500, { 'Content-Type': 'application/json' })
456
+ res.end(
457
+ JSON.stringify({
458
+ jsonrpc: '2.0',
459
+ error: {
460
+ code: -32603,
461
+ message: `Internal server error: ${error instanceof Error ? error.message : String(error)}`,
462
+ },
463
+ id: null,
464
+ })
465
+ )
466
+ }
467
+ }
468
+ })
469
+
470
+ const toolCount = getToolRegistry().listToolNames().length
471
+ const serverKeyConfigured = !!(options.serverApiKey || process.env.MCP_SERVER_API_KEY)
472
+
473
+ console.error(`[MCP HTTP] Starting ${config.name} v${config.version}`)
474
+ console.error(`[MCP HTTP] Endpoint: http://localhost:${port}/mcp`)
475
+ console.error(`[MCP HTTP] Health: http://localhost:${port}/health`)
476
+ console.error(`[MCP HTTP] Tools registered: ${toolCount}`)
477
+ console.error(`[MCP HTTP] Mode: Stateless (new server per request)`)
478
+ console.error(`[MCP HTTP] Server Auth: ${serverKeyConfigured ? 'MCP_SERVER_API_KEY configured' : 'WARNING: MCP_SERVER_API_KEY not set!'}`)
479
+ console.error(`[MCP HTTP] User Auth: Session token in _sessionToken parameter`)
480
+
481
+ // Return a Promise that keeps the process alive until shutdown
482
+ return new Promise<void>((resolve) => {
483
+ httpServer.listen(port, () => {
484
+ console.error(`[MCP HTTP] Server listening on port ${port}`)
485
+ })
486
+
487
+ const shutdown = async () => {
488
+ console.error('[MCP HTTP] Shutting down...')
489
+ httpServer.close(() => {
490
+ console.error('[MCP HTTP] Server closed')
491
+ resolve()
492
+ })
493
+ }
494
+
495
+ process.on('SIGINT', shutdown)
496
+ process.on('SIGTERM', shutdown)
497
+ })
498
+ }