@hed-hog/core 0.0.327 → 0.0.329

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 (153) hide show
  1. package/dist/ai/ai.service.d.ts +1 -1
  2. package/dist/auth/auth.controller.d.ts +2 -2
  3. package/dist/auth/auth.service.d.ts +2 -2
  4. package/dist/core/system.controller.d.ts +1 -1
  5. package/dist/core/system.service.d.ts +1 -1
  6. package/dist/core.module.d.ts.map +1 -1
  7. package/dist/core.module.js +4 -0
  8. package/dist/core.module.js.map +1 -1
  9. package/dist/dashboard/dashboard/dashboard.controller.d.ts +21 -21
  10. package/dist/dashboard/dashboard/dashboard.service.d.ts +21 -21
  11. package/dist/dashboard/dashboard-component/dashboard-component.controller.d.ts +11 -11
  12. package/dist/dashboard/dashboard-component/dashboard-component.service.d.ts +11 -11
  13. package/dist/dashboard/dashboard-component-role/dashboard-component-role.controller.d.ts +5 -5
  14. package/dist/dashboard/dashboard-component-role/dashboard-component-role.service.d.ts +5 -5
  15. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +4 -4
  16. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +4 -4
  17. package/dist/dashboard/dashboard-item/dashboard-item.controller.d.ts +4 -4
  18. package/dist/dashboard/dashboard-item/dashboard-item.service.d.ts +4 -4
  19. package/dist/dashboard/dashboard-role/dashboard-role.controller.d.ts +3 -3
  20. package/dist/dashboard/dashboard-role/dashboard-role.service.d.ts +3 -3
  21. package/dist/file/file.controller.d.ts +6 -6
  22. package/dist/file/file.service.d.ts +12 -12
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +6 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/mail/mail.controller.d.ts +2 -2
  28. package/dist/mail/mail.service.d.ts +2 -2
  29. package/dist/mail-sent/mail-sent.controller.d.ts +3 -3
  30. package/dist/mail-sent/mail-sent.service.d.ts +3 -3
  31. package/dist/mcp/decorators/mcp-tool.decorator.d.ts +4 -0
  32. package/dist/mcp/decorators/mcp-tool.decorator.d.ts.map +1 -0
  33. package/dist/mcp/decorators/mcp-tool.decorator.js +8 -0
  34. package/dist/mcp/decorators/mcp-tool.decorator.js.map +1 -0
  35. package/dist/mcp/guards/mcp-auth.guard.d.ts +9 -0
  36. package/dist/mcp/guards/mcp-auth.guard.d.ts.map +1 -0
  37. package/dist/mcp/guards/mcp-auth.guard.js +41 -0
  38. package/dist/mcp/guards/mcp-auth.guard.js.map +1 -0
  39. package/dist/mcp/guards/mcp-permission.guard.d.ts +8 -0
  40. package/dist/mcp/guards/mcp-permission.guard.d.ts.map +1 -0
  41. package/dist/mcp/guards/mcp-permission.guard.js +57 -0
  42. package/dist/mcp/guards/mcp-permission.guard.js.map +1 -0
  43. package/dist/mcp/interfaces/mcp-tool-definition.interface.d.ts +7 -0
  44. package/dist/mcp/interfaces/mcp-tool-definition.interface.d.ts.map +1 -0
  45. package/dist/mcp/interfaces/mcp-tool-definition.interface.js +3 -0
  46. package/dist/mcp/interfaces/mcp-tool-definition.interface.js.map +1 -0
  47. package/dist/mcp/mcp-audit.service.d.ts +17 -0
  48. package/dist/mcp/mcp-audit.service.d.ts.map +1 -0
  49. package/dist/mcp/mcp-audit.service.js +46 -0
  50. package/dist/mcp/mcp-audit.service.js.map +1 -0
  51. package/dist/mcp/mcp-registry.service.d.ts +21 -0
  52. package/dist/mcp/mcp-registry.service.d.ts.map +1 -0
  53. package/dist/mcp/mcp-registry.service.js +69 -0
  54. package/dist/mcp/mcp-registry.service.js.map +1 -0
  55. package/dist/mcp/mcp-server.service.d.ts +12 -0
  56. package/dist/mcp/mcp-server.service.d.ts.map +1 -0
  57. package/dist/mcp/mcp-server.service.js +94 -0
  58. package/dist/mcp/mcp-server.service.js.map +1 -0
  59. package/dist/mcp/mcp.controller.d.ts +8 -0
  60. package/dist/mcp/mcp.controller.d.ts.map +1 -0
  61. package/dist/mcp/mcp.controller.js +54 -0
  62. package/dist/mcp/mcp.controller.js.map +1 -0
  63. package/dist/mcp/mcp.module.d.ts +3 -0
  64. package/dist/mcp/mcp.module.d.ts.map +1 -0
  65. package/dist/mcp/mcp.module.js +49 -0
  66. package/dist/mcp/mcp.module.js.map +1 -0
  67. package/dist/mcp/tools/core-settings.mcp-tools.d.ts +12 -0
  68. package/dist/mcp/tools/core-settings.mcp-tools.d.ts.map +1 -0
  69. package/dist/mcp/tools/core-settings.mcp-tools.js +66 -0
  70. package/dist/mcp/tools/core-settings.mcp-tools.js.map +1 -0
  71. package/dist/mcp/tools/core-users.mcp-tools.d.ts +55 -0
  72. package/dist/mcp/tools/core-users.mcp-tools.d.ts.map +1 -0
  73. package/dist/mcp/tools/core-users.mcp-tools.js +295 -0
  74. package/dist/mcp/tools/core-users.mcp-tools.js.map +1 -0
  75. package/dist/mcp/types/mcp-context.type.d.ts +7 -0
  76. package/dist/mcp/types/mcp-context.type.d.ts.map +1 -0
  77. package/dist/mcp/types/mcp-context.type.js +3 -0
  78. package/dist/mcp/types/mcp-context.type.js.map +1 -0
  79. package/dist/mcp-chat/dto/create-conversation.dto.d.ts +4 -0
  80. package/dist/mcp-chat/dto/create-conversation.dto.d.ts.map +1 -0
  81. package/dist/mcp-chat/dto/create-conversation.dto.js +22 -0
  82. package/dist/mcp-chat/dto/create-conversation.dto.js.map +1 -0
  83. package/dist/mcp-chat/dto/send-message.dto.d.ts +4 -0
  84. package/dist/mcp-chat/dto/send-message.dto.d.ts.map +1 -0
  85. package/dist/mcp-chat/dto/send-message.dto.js +22 -0
  86. package/dist/mcp-chat/dto/send-message.dto.js.map +1 -0
  87. package/dist/mcp-chat/mcp-chat.controller.d.ts +55 -0
  88. package/dist/mcp-chat/mcp-chat.controller.d.ts.map +1 -0
  89. package/dist/mcp-chat/mcp-chat.controller.js +90 -0
  90. package/dist/mcp-chat/mcp-chat.controller.js.map +1 -0
  91. package/dist/mcp-chat/mcp-chat.module.d.ts +3 -0
  92. package/dist/mcp-chat/mcp-chat.module.d.ts.map +1 -0
  93. package/dist/mcp-chat/mcp-chat.module.js +30 -0
  94. package/dist/mcp-chat/mcp-chat.module.js.map +1 -0
  95. package/dist/mcp-chat/mcp-chat.service.d.ts +50 -0
  96. package/dist/mcp-chat/mcp-chat.service.d.ts.map +1 -0
  97. package/dist/mcp-chat/mcp-chat.service.js +290 -0
  98. package/dist/mcp-chat/mcp-chat.service.js.map +1 -0
  99. package/dist/menu/menu.controller.d.ts +3 -3
  100. package/dist/menu/menu.service.d.ts +3 -3
  101. package/dist/profile/profile.controller.d.ts +2 -2
  102. package/dist/profile/profile.service.d.ts +2 -2
  103. package/dist/route/dto/create.dto.d.ts +6 -2
  104. package/dist/route/dto/create.dto.d.ts.map +1 -1
  105. package/dist/route/dto/create.dto.js +19 -1
  106. package/dist/route/dto/create.dto.js.map +1 -1
  107. package/dist/route/dto/update.dto.d.ts +4 -0
  108. package/dist/route/dto/update.dto.d.ts.map +1 -1
  109. package/dist/route/dto/update.dto.js +19 -3
  110. package/dist/route/dto/update.dto.js.map +1 -1
  111. package/dist/route/route.service.d.ts +1 -1
  112. package/dist/route/route.service.d.ts.map +1 -1
  113. package/dist/route/route.service.js +18 -11
  114. package/dist/route/route.service.js.map +1 -1
  115. package/dist/setting/setting.controller.d.ts +5 -5
  116. package/dist/setting/setting.service.d.ts +5 -5
  117. package/dist/user/user.controller.d.ts +3 -3
  118. package/dist/user/user.service.d.ts +6 -6
  119. package/hedhog/data/menu.yaml +40 -26
  120. package/hedhog/data/route.yaml +575 -152
  121. package/hedhog/data/setting_group.yaml +1 -1
  122. package/hedhog/frontend/app/configurations/[slug]/page.tsx.ejs +8 -1
  123. package/hedhog/frontend/app/configurations/layout.tsx.ejs +152 -34
  124. package/hedhog/frontend/app/mcp_chat/page.tsx.ejs +574 -0
  125. package/hedhog/frontend/messages/en.json +17 -0
  126. package/hedhog/frontend/messages/pt.json +17 -0
  127. package/hedhog/table/mcp_conversation.yaml +12 -0
  128. package/hedhog/table/mcp_conversation_message.yaml +23 -0
  129. package/hedhog/table/mcp_tool_call_log.yaml +22 -0
  130. package/hedhog/table/route.yaml +33 -22
  131. package/package.json +5 -4
  132. package/src/core.module.ts +14 -10
  133. package/src/index.ts +17 -10
  134. package/src/mcp/decorators/mcp-tool.decorator.ts +7 -0
  135. package/src/mcp/guards/mcp-auth.guard.ts +32 -0
  136. package/src/mcp/guards/mcp-permission.guard.ts +51 -0
  137. package/src/mcp/interfaces/mcp-tool-definition.interface.ts +6 -0
  138. package/src/mcp/mcp-audit.service.ts +36 -0
  139. package/src/mcp/mcp-registry.service.ts +69 -0
  140. package/src/mcp/mcp-server.service.ts +87 -0
  141. package/src/mcp/mcp.controller.ts +30 -0
  142. package/src/mcp/mcp.module.ts +36 -0
  143. package/src/mcp/tools/core-settings.mcp-tools.ts +53 -0
  144. package/src/mcp/tools/core-users.mcp-tools.ts +267 -0
  145. package/src/mcp/types/mcp-context.type.ts +6 -0
  146. package/src/mcp-chat/dto/create-conversation.dto.ts +7 -0
  147. package/src/mcp-chat/dto/send-message.dto.ts +7 -0
  148. package/src/mcp-chat/mcp-chat.controller.ts +60 -0
  149. package/src/mcp-chat/mcp-chat.module.ts +17 -0
  150. package/src/mcp-chat/mcp-chat.service.ts +332 -0
  151. package/src/route/dto/create.dto.ts +32 -14
  152. package/src/route/dto/update.dto.ts +31 -16
  153. package/src/route/route.service.ts +23 -12
@@ -0,0 +1,267 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { UserService } from '../../user/user.service';
3
+ import { McpTool } from '../decorators/mcp-tool.decorator';
4
+ import { McpContext } from '../types/mcp-context.type';
5
+
6
+ @Injectable()
7
+ export class CoreUsersMcpTools {
8
+ constructor(private readonly userService: UserService) {}
9
+
10
+ @McpTool({
11
+ name: 'core.users.me',
12
+ description: 'Returns the authenticated user profile',
13
+ inputSchema: { type: 'object', properties: {} },
14
+ readOnly: true,
15
+ })
16
+ async getMe(_args: Record<string, any>, context: McpContext): Promise<any> {
17
+ return this.userService.findUserById(context.locale, context.userId);
18
+ }
19
+
20
+ @McpTool({
21
+ name: 'core.users.list',
22
+ description: 'Lists all users with optional pagination and search. Admin only.',
23
+ inputSchema: {
24
+ type: 'object',
25
+ properties: {
26
+ page: { type: 'number', description: 'Page number (default: 1)' },
27
+ pageSize: { type: 'number', description: 'Items per page (default: 20)' },
28
+ search: { type: 'string', description: 'Search term to filter users' },
29
+ sortField: { type: 'string', description: 'Field to sort by' },
30
+ sortOrder: { type: 'string', enum: ['asc', 'desc'], description: 'Sort direction' },
31
+ },
32
+ },
33
+ readOnly: true,
34
+ })
35
+ async listUsers(
36
+ args: {
37
+ page?: number;
38
+ pageSize?: number;
39
+ search?: string;
40
+ sortField?: string;
41
+ sortOrder?: string;
42
+ },
43
+ _context: McpContext,
44
+ ): Promise<any> {
45
+ return this.userService.list({
46
+ page: args.page ?? 1,
47
+ pageSize: args.pageSize ?? 20,
48
+ search: args.search ?? '',
49
+ sortField: args.sortField ?? 'id',
50
+ sortOrder: (args.sortOrder as any) ?? 'asc',
51
+ fields: '',
52
+ });
53
+ }
54
+
55
+ @McpTool({
56
+ name: 'core.users.get',
57
+ description: 'Returns a single user by ID. Admin only.',
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {
61
+ userId: { type: 'number', description: 'User ID' },
62
+ },
63
+ required: ['userId'],
64
+ },
65
+ readOnly: true,
66
+ })
67
+ async getUser(
68
+ args: { userId: number },
69
+ context: McpContext,
70
+ ): Promise<any> {
71
+ return this.userService.get(context.locale, args.userId);
72
+ }
73
+
74
+ @McpTool({
75
+ name: 'core.users.create',
76
+ description: 'Creates a new user with email and password. Admin only.',
77
+ inputSchema: {
78
+ type: 'object',
79
+ properties: {
80
+ email: { type: 'string', description: 'User email address' },
81
+ password: { type: 'string', description: 'User password' },
82
+ name: { type: 'string', description: 'User full name' },
83
+ },
84
+ required: ['email', 'password', 'name'],
85
+ },
86
+ })
87
+ async createUser(
88
+ args: { email: string; password: string; name: string },
89
+ context: McpContext,
90
+ ): Promise<any> {
91
+ return this.userService.createWithEmailAndPassword(context.locale, args as any);
92
+ }
93
+
94
+ @McpTool({
95
+ name: 'core.users.update',
96
+ description: 'Updates a user name by ID. Admin only.',
97
+ inputSchema: {
98
+ type: 'object',
99
+ properties: {
100
+ userId: { type: 'number', description: 'User ID' },
101
+ name: { type: 'string', description: 'New user name' },
102
+ },
103
+ required: ['userId'],
104
+ },
105
+ })
106
+ async updateUser(
107
+ args: { userId: number; name?: string },
108
+ context: McpContext,
109
+ ): Promise<any> {
110
+ return this.userService.update(context.locale, args.userId, { name: args.name } as any);
111
+ }
112
+
113
+ @McpTool({
114
+ name: 'core.users.reset-password',
115
+ description: 'Resets the password of a user by ID. Admin only.',
116
+ inputSchema: {
117
+ type: 'object',
118
+ properties: {
119
+ userId: { type: 'number', description: 'User ID' },
120
+ password: { type: 'string', description: 'New password (must meet strength requirements)' },
121
+ },
122
+ required: ['userId'],
123
+ },
124
+ })
125
+ async resetPassword(
126
+ args: { userId: number; password?: string },
127
+ context: McpContext,
128
+ ): Promise<any> {
129
+ return this.userService.resetPassword(context.locale, args.userId, { password: args.password } as any);
130
+ }
131
+
132
+ @McpTool({
133
+ name: 'core.users.delete',
134
+ description: 'Deletes one or more users by their IDs. Admin only.',
135
+ inputSchema: {
136
+ type: 'object',
137
+ properties: {
138
+ ids: {
139
+ type: 'array',
140
+ items: { type: 'number' },
141
+ description: 'Array of user IDs to delete',
142
+ },
143
+ },
144
+ required: ['ids'],
145
+ },
146
+ })
147
+ async deleteUsers(
148
+ args: { ids: number[] },
149
+ context: McpContext,
150
+ ): Promise<any> {
151
+ return this.userService.delete(context.locale, { ids: args.ids });
152
+ }
153
+
154
+ @McpTool({
155
+ name: 'core.users.roles',
156
+ description: 'Returns the roles assigned to a user. Admin only.',
157
+ inputSchema: {
158
+ type: 'object',
159
+ properties: {
160
+ userId: { type: 'number', description: 'User ID' },
161
+ },
162
+ required: ['userId'],
163
+ },
164
+ readOnly: true,
165
+ })
166
+ async getUserRoles(
167
+ args: { userId: number },
168
+ _context: McpContext,
169
+ ): Promise<any> {
170
+ return this.userService.getUserRoles(args.userId);
171
+ }
172
+
173
+ @McpTool({
174
+ name: 'core.users.menus',
175
+ description: 'Returns the menu items accessible to a user. Admin only.',
176
+ inputSchema: {
177
+ type: 'object',
178
+ properties: {
179
+ userId: { type: 'number', description: 'User ID' },
180
+ },
181
+ required: ['userId'],
182
+ },
183
+ readOnly: true,
184
+ })
185
+ async getUserMenus(
186
+ args: { userId: number },
187
+ context: McpContext,
188
+ ): Promise<any> {
189
+ return this.userService.getUserMenus(context.locale, args.userId);
190
+ }
191
+
192
+ @McpTool({
193
+ name: 'core.users.routes',
194
+ description: 'Returns the routes accessible to a user. Admin only.',
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {
198
+ userId: { type: 'number', description: 'User ID' },
199
+ },
200
+ required: ['userId'],
201
+ },
202
+ readOnly: true,
203
+ })
204
+ async getUserRoutes(
205
+ args: { userId: number },
206
+ context: McpContext,
207
+ ): Promise<any> {
208
+ return this.userService.getUserRoutes(context.locale, args.userId);
209
+ }
210
+
211
+ @McpTool({
212
+ name: 'core.users.assign-role',
213
+ description: 'Assigns a role to a user. Admin only.',
214
+ inputSchema: {
215
+ type: 'object',
216
+ properties: {
217
+ userId: { type: 'number', description: 'User ID' },
218
+ roleId: { type: 'number', description: 'Role ID to assign' },
219
+ },
220
+ required: ['userId', 'roleId'],
221
+ },
222
+ })
223
+ async assignRole(
224
+ args: { userId: number; roleId: number },
225
+ context: McpContext,
226
+ ): Promise<any> {
227
+ return this.userService.assignRoleToUser(context.locale, args.userId, args.roleId);
228
+ }
229
+
230
+ @McpTool({
231
+ name: 'core.users.remove-role',
232
+ description: 'Removes a role from a user. Admin only.',
233
+ inputSchema: {
234
+ type: 'object',
235
+ properties: {
236
+ userId: { type: 'number', description: 'User ID' },
237
+ roleId: { type: 'number', description: 'Role ID to remove' },
238
+ },
239
+ required: ['userId', 'roleId'],
240
+ },
241
+ })
242
+ async removeRole(
243
+ args: { userId: number; roleId: number },
244
+ context: McpContext,
245
+ ): Promise<any> {
246
+ return this.userService.removeRoleFromUser(context.locale, args.userId, args.roleId);
247
+ }
248
+
249
+ @McpTool({
250
+ name: 'core.users.verify-identifier',
251
+ description: 'Verifies a user identifier (e.g. email confirmation). Admin only.',
252
+ inputSchema: {
253
+ type: 'object',
254
+ properties: {
255
+ userId: { type: 'number', description: 'User ID' },
256
+ identifierId: { type: 'number', description: 'Identifier ID to verify' },
257
+ },
258
+ required: ['userId', 'identifierId'],
259
+ },
260
+ })
261
+ async verifyIdentifier(
262
+ args: { userId: number; identifierId: number },
263
+ context: McpContext,
264
+ ): Promise<any> {
265
+ return this.userService.verifyIdentifier(context.locale, args.userId, args.identifierId);
266
+ }
267
+ }
@@ -0,0 +1,6 @@
1
+ export type McpContext = {
2
+ userId: number;
3
+ sessionId: string;
4
+ locale: string;
5
+ toolName: string;
6
+ };
@@ -0,0 +1,7 @@
1
+ import { IsOptional, IsString } from 'class-validator';
2
+
3
+ export class CreateConversationDTO {
4
+ @IsOptional()
5
+ @IsString()
6
+ title?: string;
7
+ }
@@ -0,0 +1,7 @@
1
+ import { IsNotEmpty, IsString } from 'class-validator';
2
+
3
+ export class SendMessageDTO {
4
+ @IsString()
5
+ @IsNotEmpty()
6
+ message: string;
7
+ }
@@ -0,0 +1,60 @@
1
+ import { Role, User } from '@hed-hog/api';
2
+ import { Locale } from '@hed-hog/api-locale';
3
+ import {
4
+ Body,
5
+ Controller,
6
+ Delete,
7
+ Get,
8
+ Param,
9
+ ParseIntPipe,
10
+ Post,
11
+ } from '@nestjs/common';
12
+ import { DeleteDTO } from '../dto/delete.dto';
13
+ import { CreateConversationDTO } from './dto/create-conversation.dto';
14
+ import { SendMessageDTO } from './dto/send-message.dto';
15
+ import { McpChatService } from './mcp-chat.service';
16
+
17
+ @Role()
18
+ @Controller('mcp-chat')
19
+ export class McpChatController {
20
+ constructor(private readonly service: McpChatService) {}
21
+
22
+ @Get()
23
+ listConversations(@User() { id }: { id: number }) {
24
+ return this.service.listConversations(id);
25
+ }
26
+
27
+ @Post()
28
+ createConversation(
29
+ @User() { id }: { id: number },
30
+ @Body() dto: CreateConversationDTO,
31
+ ) {
32
+ return this.service.createConversation(id, dto);
33
+ }
34
+
35
+ @Delete()
36
+ deleteConversations(
37
+ @User() { id }: { id: number },
38
+ @Body() dto: DeleteDTO,
39
+ ) {
40
+ return this.service.deleteConversations(id, dto.ids);
41
+ }
42
+
43
+ @Get(':id/messages')
44
+ getMessages(
45
+ @User() { id }: { id: number },
46
+ @Param('id', ParseIntPipe) conversationId: number,
47
+ ) {
48
+ return this.service.getMessages(conversationId, id);
49
+ }
50
+
51
+ @Post(':id/messages')
52
+ sendMessage(
53
+ @User() { id }: { id: number },
54
+ @Param('id', ParseIntPipe) conversationId: number,
55
+ @Body() dto: SendMessageDTO,
56
+ @Locale() locale: string,
57
+ ) {
58
+ return this.service.sendMessage(conversationId, id, locale, dto.message);
59
+ }
60
+ }
@@ -0,0 +1,17 @@
1
+ import { PrismaModule } from '@hed-hog/api-prisma';
2
+ import { Module, forwardRef } from '@nestjs/common';
3
+ import { McpModule } from '../mcp/mcp.module';
4
+ import { SettingModule } from '../setting/setting.module';
5
+ import { McpChatController } from './mcp-chat.controller';
6
+ import { McpChatService } from './mcp-chat.service';
7
+
8
+ @Module({
9
+ imports: [
10
+ forwardRef(() => PrismaModule),
11
+ forwardRef(() => SettingModule),
12
+ forwardRef(() => McpModule),
13
+ ],
14
+ controllers: [McpChatController],
15
+ providers: [McpChatService],
16
+ })
17
+ export class McpChatModule {}
@@ -0,0 +1,332 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import {
3
+ BadRequestException,
4
+ ForbiddenException,
5
+ forwardRef,
6
+ HttpException,
7
+ HttpStatus,
8
+ Inject,
9
+ Injectable,
10
+ NotFoundException,
11
+ } from '@nestjs/common';
12
+ import axios from 'axios';
13
+ import { SettingService } from '../setting/setting.service';
14
+ import { McpContext } from '../mcp/types/mcp-context.type';
15
+ import { McpRegistryService } from '../mcp/mcp-registry.service';
16
+ import { CreateConversationDTO } from './dto/create-conversation.dto';
17
+
18
+ type OpenAiMessage =
19
+ | { role: 'system'; content: string }
20
+ | { role: 'user'; content: string }
21
+ | { role: 'assistant'; content: string | null; tool_calls?: OpenAiToolCall[] }
22
+ | { role: 'tool'; tool_call_id: string; content: string };
23
+
24
+ interface OpenAiToolCall {
25
+ id: string;
26
+ type: 'function';
27
+ function: { name: string; arguments: string };
28
+ }
29
+
30
+ const SYSTEM_PROMPT =
31
+ 'You are a helpful assistant with access to tools from the HedHog system. ' +
32
+ 'Use the available tools to answer the user\'s questions accurately. ' +
33
+ 'Always respond in the same language the user writes in.';
34
+
35
+ const sanitize = (n: string) => n.replace(/\./g, '__');
36
+ const restore = (n: string) => n.replace(/__/g, '.');
37
+
38
+ @Injectable()
39
+ export class McpChatService {
40
+ constructor(
41
+ @Inject(forwardRef(() => PrismaService))
42
+ private readonly prisma: PrismaService,
43
+ @Inject(forwardRef(() => SettingService))
44
+ private readonly setting: SettingService,
45
+ @Inject(forwardRef(() => McpRegistryService))
46
+ private readonly registry: McpRegistryService,
47
+ ) {}
48
+
49
+ async listConversations(userId: number) {
50
+ return this.prisma.mcp_conversation.findMany({
51
+ where: { user_id: userId },
52
+ orderBy: { created_at: 'desc' },
53
+ select: { id: true, title: true, created_at: true, updated_at: true },
54
+ });
55
+ }
56
+
57
+ async createConversation(userId: number, dto: CreateConversationDTO) {
58
+ return this.prisma.mcp_conversation.create({
59
+ data: {
60
+ user_id: userId,
61
+ title: dto.title ?? null,
62
+ },
63
+ });
64
+ }
65
+
66
+ async deleteConversations(userId: number, ids: number[]) {
67
+ await this.prisma.mcp_conversation.deleteMany({
68
+ where: { id: { in: ids }, user_id: userId },
69
+ });
70
+ return { count: ids.length };
71
+ }
72
+
73
+ async getMessages(conversationId: number, userId: number) {
74
+ await this.assertOwnership(conversationId, userId);
75
+ return this.prisma.mcp_conversation_message.findMany({
76
+ where: { conversation_id: conversationId },
77
+ orderBy: { created_at: 'asc' },
78
+ });
79
+ }
80
+
81
+ async sendMessage(
82
+ conversationId: number,
83
+ userId: number,
84
+ locale: string,
85
+ message: string,
86
+ ) {
87
+ await this.assertOwnership(conversationId, userId);
88
+
89
+ const apiKey = await this.getOpenAiKey();
90
+
91
+ // Save user message
92
+ await this.prisma.mcp_conversation_message.create({
93
+ data: {
94
+ conversation_id: conversationId,
95
+ role: 'user',
96
+ content: message,
97
+ },
98
+ });
99
+
100
+ // Auto-set title on first message
101
+ const conv = await this.prisma.mcp_conversation.findUnique({
102
+ where: { id: conversationId },
103
+ });
104
+ if (!conv!.title) {
105
+ await this.prisma.mcp_conversation.update({
106
+ where: { id: conversationId },
107
+ data: { title: message.slice(0, 60) },
108
+ });
109
+ }
110
+
111
+ // Build OpenAI messages from history
112
+ const history = await this.prisma.mcp_conversation_message.findMany({
113
+ where: { conversation_id: conversationId },
114
+ orderBy: { created_at: 'asc' },
115
+ });
116
+
117
+ const openAiMessages: OpenAiMessage[] = [
118
+ { role: 'system', content: SYSTEM_PROMPT },
119
+ ...this.toOpenAiMessages(history),
120
+ ];
121
+
122
+ // Build tool definitions
123
+ const tools = this.registry.getAll().map((entry) => ({
124
+ type: 'function' as const,
125
+ function: {
126
+ name: sanitize(entry.definition.name),
127
+ description: entry.definition.description,
128
+ parameters: entry.definition.inputSchema ?? {
129
+ type: 'object',
130
+ properties: {},
131
+ },
132
+ },
133
+ }));
134
+
135
+ // Tool-calling loop
136
+ let maxIterations = 10;
137
+ while (maxIterations-- > 0) {
138
+ let response: any;
139
+ try {
140
+ response = await axios.post(
141
+ 'https://api.openai.com/v1/chat/completions',
142
+ {
143
+ model: 'gpt-4o-mini',
144
+ messages: openAiMessages,
145
+ tools: tools.length > 0 ? tools : undefined,
146
+ tool_choice: tools.length > 0 ? 'auto' : undefined,
147
+ },
148
+ {
149
+ headers: {
150
+ Authorization: `Bearer ${apiKey}`,
151
+ 'Content-Type': 'application/json',
152
+ },
153
+ },
154
+ );
155
+ } catch (err: any) {
156
+ const status = err?.response?.status;
157
+ const errBody = err?.response?.data?.error;
158
+ if (status === 429) {
159
+ throw new HttpException(
160
+ errBody?.message ?? 'OpenAI rate limit exceeded. Please try again later.',
161
+ HttpStatus.TOO_MANY_REQUESTS,
162
+ );
163
+ }
164
+ if (status === 401) {
165
+ throw new BadRequestException('Invalid OpenAI API key.');
166
+ }
167
+ throw new HttpException(
168
+ errBody?.message ?? 'OpenAI API error.',
169
+ status ?? HttpStatus.BAD_GATEWAY,
170
+ );
171
+ }
172
+
173
+ const choice = response.data?.choices?.[0];
174
+ const assistantMsg = choice?.message;
175
+
176
+ if (assistantMsg?.tool_calls?.length) {
177
+ // Add assistant tool-call message to context
178
+ openAiMessages.push({
179
+ role: 'assistant',
180
+ content: null,
181
+ tool_calls: assistantMsg.tool_calls,
182
+ });
183
+
184
+ // Execute each tool call
185
+ for (const toolCall of assistantMsg.tool_calls as OpenAiToolCall[]) {
186
+ const originalName = restore(toolCall.function.name);
187
+ const argsJson = toolCall.function.arguments;
188
+ let args: Record<string, any> = {};
189
+ try {
190
+ args = JSON.parse(argsJson);
191
+ } catch {
192
+ args = {};
193
+ }
194
+
195
+ // Persist tool_call message
196
+ await this.prisma.mcp_conversation_message.create({
197
+ data: {
198
+ conversation_id: conversationId,
199
+ role: 'tool_call',
200
+ content: argsJson,
201
+ tool_name: originalName,
202
+ tool_call_id: toolCall.id,
203
+ },
204
+ });
205
+
206
+ // Execute the tool
207
+ let resultContent: string;
208
+ const entry = this.registry.get(originalName);
209
+ if (entry) {
210
+ const context: McpContext = {
211
+ userId,
212
+ sessionId: '',
213
+ locale,
214
+ toolName: originalName,
215
+ };
216
+ try {
217
+ const result = await entry.handler(args, context);
218
+ resultContent = JSON.stringify(result);
219
+ } catch (e: any) {
220
+ resultContent = JSON.stringify({ error: e.message });
221
+ }
222
+ } else {
223
+ resultContent = JSON.stringify({ error: `Tool '${originalName}' not found` });
224
+ }
225
+
226
+ // Persist tool_result message
227
+ await this.prisma.mcp_conversation_message.create({
228
+ data: {
229
+ conversation_id: conversationId,
230
+ role: 'tool_result',
231
+ content: resultContent,
232
+ tool_name: originalName,
233
+ tool_call_id: toolCall.id,
234
+ },
235
+ });
236
+
237
+ // Add tool result to context for next iteration
238
+ openAiMessages.push({
239
+ role: 'tool',
240
+ tool_call_id: toolCall.id,
241
+ content: resultContent,
242
+ });
243
+ }
244
+
245
+ continue; // next iteration with tool results
246
+ }
247
+
248
+ // Final assistant response
249
+ const finalContent: string = assistantMsg?.content || '';
250
+ await this.prisma.mcp_conversation_message.create({
251
+ data: {
252
+ conversation_id: conversationId,
253
+ role: 'assistant',
254
+ content: finalContent,
255
+ },
256
+ });
257
+
258
+ return this.prisma.mcp_conversation_message.findMany({
259
+ where: { conversation_id: conversationId },
260
+ orderBy: { created_at: 'asc' },
261
+ });
262
+ }
263
+
264
+ throw new BadRequestException('Maximum tool call iterations exceeded.');
265
+ }
266
+
267
+ private toOpenAiMessages(
268
+ history: Array<{
269
+ role: string;
270
+ content: string;
271
+ tool_name: string | null;
272
+ tool_call_id: string | null;
273
+ }>,
274
+ ): OpenAiMessage[] {
275
+ const messages: OpenAiMessage[] = [];
276
+ const toolCallMap = new Map<string, string>(); // tool_call_id → sanitized name
277
+
278
+ for (const msg of history) {
279
+ if (msg.role === 'user') {
280
+ messages.push({ role: 'user', content: msg.content });
281
+ } else if (msg.role === 'assistant') {
282
+ messages.push({ role: 'assistant', content: msg.content });
283
+ } else if (msg.role === 'tool_call' && msg.tool_call_id && msg.tool_name) {
284
+ const sanitizedName = sanitize(msg.tool_name);
285
+ toolCallMap.set(msg.tool_call_id, sanitizedName);
286
+ messages.push({
287
+ role: 'assistant',
288
+ content: null,
289
+ tool_calls: [
290
+ {
291
+ id: msg.tool_call_id,
292
+ type: 'function',
293
+ function: { name: sanitizedName, arguments: msg.content },
294
+ },
295
+ ],
296
+ });
297
+ } else if (msg.role === 'tool_result' && msg.tool_call_id) {
298
+ messages.push({
299
+ role: 'tool',
300
+ tool_call_id: msg.tool_call_id,
301
+ content: msg.content,
302
+ });
303
+ }
304
+ }
305
+
306
+ return messages;
307
+ }
308
+
309
+ private async assertOwnership(conversationId: number, userId: number) {
310
+ const conv = await this.prisma.mcp_conversation.findUnique({
311
+ where: { id: conversationId },
312
+ select: { user_id: true },
313
+ });
314
+ if (!conv) {
315
+ throw new NotFoundException(`Conversation ${conversationId} not found.`);
316
+ }
317
+ if (conv.user_id !== userId) {
318
+ throw new ForbiddenException('Access denied to this conversation.');
319
+ }
320
+ }
321
+
322
+ private async getOpenAiKey(): Promise<string> {
323
+ const settings = await this.setting.getSettingValues(['ai-openai-api-key']);
324
+ const key = settings['ai-openai-api-key'];
325
+ if (!key) {
326
+ throw new BadRequestException(
327
+ 'OpenAI API key is not configured (setting "ai-openai-api-key").',
328
+ );
329
+ }
330
+ return key;
331
+ }
332
+ }