@dxheroes/local-mcp-backend 0.4.0 → 0.5.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.
- package/.turbo/turbo-build.log +2 -2
- package/AGENTS.md +4 -0
- package/CHANGELOG.md +32 -0
- package/dist/app.module.js +5 -0
- package/dist/app.module.js.map +1 -1
- package/dist/modules/profiles/profiles.service.js +15 -0
- package/dist/modules/profiles/profiles.service.js.map +1 -1
- package/dist/modules/proxy/proxy.controller.js +258 -9
- package/dist/modules/proxy/proxy.controller.js.map +1 -1
- package/dist/modules/proxy/proxy.module.js +4 -1
- package/dist/modules/proxy/proxy.module.js.map +1 -1
- package/dist/modules/settings/settings.constants.js +18 -0
- package/dist/modules/settings/settings.constants.js.map +1 -0
- package/dist/modules/settings/settings.controller.js +89 -0
- package/dist/modules/settings/settings.controller.js.map +1 -0
- package/dist/modules/settings/settings.module.js +30 -0
- package/dist/modules/settings/settings.module.js.map +1 -0
- package/dist/modules/settings/settings.service.js +110 -0
- package/dist/modules/settings/settings.service.js.map +1 -0
- package/package.json +6 -5
- package/src/AGENTS.md +23 -0
- package/src/app.module.ts +6 -0
- package/src/common/AGENTS.md +19 -0
- package/src/config/AGENTS.md +17 -0
- package/src/modules/AGENTS.md +31 -0
- package/src/modules/database/AGENTS.md +30 -0
- package/src/modules/debug/AGENTS.md +30 -0
- package/src/modules/health/AGENTS.md +22 -0
- package/src/modules/mcp/AGENTS.md +38 -0
- package/src/modules/oauth/AGENTS.md +32 -0
- package/src/modules/profiles/AGENTS.md +33 -0
- package/src/modules/profiles/profiles.service.ts +19 -0
- package/src/modules/proxy/AGENTS.md +34 -0
- package/src/modules/proxy/proxy.controller.ts +249 -7
- package/src/modules/proxy/proxy.module.ts +3 -1
- package/src/modules/settings/AGENTS.md +31 -0
- package/src/modules/settings/settings.constants.ts +20 -0
- package/src/modules/settings/settings.controller.ts +47 -0
- package/src/modules/settings/settings.module.ts +16 -0
- package/src/modules/settings/settings.service.ts +99 -0
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from '@nestjs/common';
|
|
14
14
|
import { PrismaService } from '../database/prisma.service.js';
|
|
15
15
|
import { ProxyService } from '../proxy/proxy.service.js';
|
|
16
|
+
import { RESERVED_PROFILE_NAMES } from '../settings/settings.constants.js';
|
|
16
17
|
|
|
17
18
|
interface CreateProfileDto {
|
|
18
19
|
name: string;
|
|
@@ -51,6 +52,16 @@ export class ProfilesService {
|
|
|
51
52
|
private readonly proxyService: ProxyService
|
|
52
53
|
) {}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Validate profile name against reserved names
|
|
57
|
+
*/
|
|
58
|
+
private validateProfileName(name: string): void {
|
|
59
|
+
const lowerName = name.toLowerCase();
|
|
60
|
+
if (RESERVED_PROFILE_NAMES.some((reserved) => reserved.toLowerCase() === lowerName)) {
|
|
61
|
+
throw new ConflictException(`Profile name "${name}" is reserved for system use`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
/**
|
|
55
66
|
* Get all profiles
|
|
56
67
|
*/
|
|
@@ -128,6 +139,9 @@ export class ProfilesService {
|
|
|
128
139
|
* Create a new profile
|
|
129
140
|
*/
|
|
130
141
|
async create(dto: CreateProfileDto) {
|
|
142
|
+
// Validate against reserved names
|
|
143
|
+
this.validateProfileName(dto.name);
|
|
144
|
+
|
|
131
145
|
// Check for unique name
|
|
132
146
|
const existing = await this.prisma.profile.findUnique({
|
|
133
147
|
where: { name: dto.name },
|
|
@@ -155,6 +169,11 @@ export class ProfilesService {
|
|
|
155
169
|
throw new NotFoundException(`Profile ${id} not found`);
|
|
156
170
|
}
|
|
157
171
|
|
|
172
|
+
// Validate new name against reserved names
|
|
173
|
+
if (dto.name) {
|
|
174
|
+
this.validateProfileName(dto.name);
|
|
175
|
+
}
|
|
176
|
+
|
|
158
177
|
// Check for unique name if changing
|
|
159
178
|
if (dto.name && dto.name !== profile.name) {
|
|
160
179
|
const existing = await this.prisma.profile.findUnique({
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Proxy Module
|
|
2
|
+
|
|
3
|
+
## Description
|
|
4
|
+
|
|
5
|
+
MCP proxy module that exposes profile endpoints for AI clients. Handles MCP protocol communication (HTTP/SSE) and routes requests to underlying servers.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- `proxy.module.ts` - Module definition
|
|
10
|
+
- `proxy.controller.ts` - MCP protocol endpoints (HTTP, SSE)
|
|
11
|
+
- `proxy.service.ts` - Request routing and tool aggregation
|
|
12
|
+
|
|
13
|
+
## Key Endpoints
|
|
14
|
+
|
|
15
|
+
- `POST /api/mcp/:profileSlug` - HTTP MCP endpoint (Streamable HTTP)
|
|
16
|
+
- `GET /api/mcp/:profileSlug/sse` - SSE MCP endpoint (legacy)
|
|
17
|
+
- `POST /api/mcp/:profileSlug/message` - SSE message endpoint (legacy)
|
|
18
|
+
|
|
19
|
+
## Key Concepts
|
|
20
|
+
|
|
21
|
+
- **Streamable HTTP**: Modern MCP transport (single POST endpoint)
|
|
22
|
+
- **SSE Transport**: Legacy Server-Sent Events transport
|
|
23
|
+
- **Tool Aggregation**: Combines tools from all profile MCP servers
|
|
24
|
+
- **Request Routing**: Routes tool calls to correct MCP server
|
|
25
|
+
- **Debug Logging**: All traffic is logged for debugging
|
|
26
|
+
|
|
27
|
+
## Proxy Flow
|
|
28
|
+
|
|
29
|
+
1. AI client connects to profile endpoint
|
|
30
|
+
2. Proxy aggregates tool lists from all servers
|
|
31
|
+
3. Client calls a tool
|
|
32
|
+
4. Proxy routes to correct MCP server
|
|
33
|
+
5. Response returned to client
|
|
34
|
+
6. All traffic logged to debug logs
|
|
@@ -1,16 +1,235 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Proxy Controller
|
|
3
3
|
*
|
|
4
|
-
* MCP proxy endpoints for profiles.
|
|
4
|
+
* MCP proxy endpoints for profiles and gateway.
|
|
5
|
+
* Supports MCP Streamable HTTP transport (2025-11-25 spec) with SSE notifications.
|
|
6
|
+
*
|
|
7
|
+
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
Body,
|
|
12
|
+
Controller,
|
|
13
|
+
Get,
|
|
14
|
+
HttpCode,
|
|
15
|
+
HttpStatus,
|
|
16
|
+
NotFoundException,
|
|
17
|
+
Param,
|
|
18
|
+
Post,
|
|
19
|
+
Req,
|
|
20
|
+
Res,
|
|
21
|
+
} from '@nestjs/common';
|
|
22
|
+
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
23
|
+
import type { Request, Response } from 'express';
|
|
24
|
+
import { fromEvent, map } from 'rxjs';
|
|
25
|
+
import { GATEWAY_PROFILE_CHANGED, SettingsService } from '../settings/settings.service.js';
|
|
8
26
|
import type { McpRequest, McpResponse } from './proxy.service.js';
|
|
9
27
|
import { ProxyService } from './proxy.service.js';
|
|
10
28
|
|
|
11
29
|
@Controller('mcp')
|
|
12
30
|
export class ProxyController {
|
|
13
|
-
constructor(
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly proxyService: ProxyService,
|
|
33
|
+
private readonly settingsService: SettingsService,
|
|
34
|
+
private readonly eventEmitter: EventEmitter2
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
// =========================================
|
|
38
|
+
// Gateway Endpoints (must come BEFORE :profileName routes)
|
|
39
|
+
// =========================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* SSE endpoint for gateway notifications (dedicated URL)
|
|
43
|
+
* Sends notifications/tools/list_changed when the active profile changes.
|
|
44
|
+
*/
|
|
45
|
+
@Get('gateway/sse')
|
|
46
|
+
streamGatewaySse(@Req() req: Request, @Res() res: Response) {
|
|
47
|
+
return this.streamGatewayEvents(req, res);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* POST handler for gateway SSE - handles JSON-RPC requests via SSE transport
|
|
52
|
+
*/
|
|
53
|
+
@Post('gateway/sse')
|
|
54
|
+
@HttpCode(HttpStatus.OK)
|
|
55
|
+
async handleGatewaySseRequest(@Body() request: McpRequest): Promise<McpResponse> {
|
|
56
|
+
return this.handleGatewayRequest(request);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get gateway info - proxies to default profile info
|
|
61
|
+
*/
|
|
62
|
+
@Get('gateway/info')
|
|
63
|
+
async getGatewayInfo() {
|
|
64
|
+
const profileName = await this.settingsService.getDefaultGatewayProfile();
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const info = await this.proxyService.getProfileInfo(profileName);
|
|
68
|
+
return {
|
|
69
|
+
...info,
|
|
70
|
+
gateway: {
|
|
71
|
+
activeProfile: profileName,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof NotFoundException) {
|
|
76
|
+
throw new NotFoundException(
|
|
77
|
+
`Default gateway profile "${profileName}" not found. Please select a valid profile in settings.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* GET handler for gateway endpoint
|
|
86
|
+
*
|
|
87
|
+
* Streamable HTTP content negotiation:
|
|
88
|
+
* - Accept: text/event-stream -> Returns SSE stream for notifications
|
|
89
|
+
* - Otherwise -> Returns JSON usage instructions
|
|
90
|
+
*
|
|
91
|
+
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
|
|
92
|
+
*/
|
|
93
|
+
@Get('gateway')
|
|
94
|
+
async getGatewayEndpoint(@Req() req: Request, @Res() res: Response) {
|
|
95
|
+
// Streamable HTTP: check Accept header for content negotiation
|
|
96
|
+
const acceptHeader = req.headers.accept || '';
|
|
97
|
+
if (acceptHeader.includes('text/event-stream')) {
|
|
98
|
+
return this.streamGatewayEvents(req, res);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Return JSON usage info
|
|
102
|
+
const profileName = await this.settingsService.getDefaultGatewayProfile();
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const info = await this.proxyService.getProfileInfo(profileName);
|
|
106
|
+
return res.json({
|
|
107
|
+
message: 'This is the MCP Gateway endpoint. Use POST for JSON-RPC requests.',
|
|
108
|
+
usage: {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
contentType: 'application/json',
|
|
111
|
+
body: {
|
|
112
|
+
jsonrpc: '2.0',
|
|
113
|
+
method: 'tools/list',
|
|
114
|
+
id: 1,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
endpoints: {
|
|
118
|
+
sse: '/api/mcp/gateway/sse',
|
|
119
|
+
http: '/api/mcp/gateway',
|
|
120
|
+
},
|
|
121
|
+
gateway: {
|
|
122
|
+
activeProfile: profileName,
|
|
123
|
+
settingsEndpoint: '/api/settings/default-gateway-profile',
|
|
124
|
+
},
|
|
125
|
+
profile: {
|
|
126
|
+
name: profileName,
|
|
127
|
+
toolCount: info.tools.length,
|
|
128
|
+
serverCount: info.serverStatus.total,
|
|
129
|
+
connectedServers: info.serverStatus.connected,
|
|
130
|
+
},
|
|
131
|
+
infoEndpoint: '/api/mcp/gateway/info',
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof NotFoundException) {
|
|
135
|
+
throw new NotFoundException(
|
|
136
|
+
`Default gateway profile "${profileName}" not found. Please select a valid profile in settings.`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Stream SSE events for gateway notifications.
|
|
145
|
+
* Sends notifications/tools/list_changed when the active profile changes.
|
|
146
|
+
*
|
|
147
|
+
* Follows MCP Streamable HTTP transport (2025-11-25):
|
|
148
|
+
* - No endpoint event needed (unified endpoint)
|
|
149
|
+
* - Simple data: format without event: prefix
|
|
150
|
+
*
|
|
151
|
+
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
|
|
152
|
+
*/
|
|
153
|
+
private streamGatewayEvents(req: Request, res: Response) {
|
|
154
|
+
// Set SSE headers
|
|
155
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
156
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
157
|
+
res.setHeader('Connection', 'keep-alive');
|
|
158
|
+
res.setHeader('X-Accel-Buffering', 'no'); // For nginx
|
|
159
|
+
|
|
160
|
+
// Streamable HTTP - no endpoint event needed
|
|
161
|
+
// Just send a comment to establish connection
|
|
162
|
+
res.write(': connected\n\n');
|
|
163
|
+
|
|
164
|
+
// Subscribe to profile change events
|
|
165
|
+
const subscription = fromEvent(this.eventEmitter, GATEWAY_PROFILE_CHANGED)
|
|
166
|
+
.pipe(
|
|
167
|
+
map(() => ({
|
|
168
|
+
jsonrpc: '2.0',
|
|
169
|
+
method: 'notifications/tools/list_changed',
|
|
170
|
+
}))
|
|
171
|
+
)
|
|
172
|
+
.subscribe((notification) => {
|
|
173
|
+
// Streamable HTTP - simple data format, no event prefix
|
|
174
|
+
res.write(`data: ${JSON.stringify(notification)}\n\n`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Cleanup on client disconnect
|
|
178
|
+
req.on('close', () => {
|
|
179
|
+
subscription.unsubscribe();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* MCP JSON-RPC endpoint for the gateway - proxies to default profile
|
|
185
|
+
*/
|
|
186
|
+
@Post('gateway')
|
|
187
|
+
@HttpCode(HttpStatus.OK)
|
|
188
|
+
async handleGatewayRequest(@Body() request: McpRequest): Promise<McpResponse> {
|
|
189
|
+
const profileName = await this.settingsService.getDefaultGatewayProfile();
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
return await this.proxyService.handleRequest(profileName, request);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error instanceof NotFoundException) {
|
|
195
|
+
throw new NotFoundException(
|
|
196
|
+
`Default gateway profile "${profileName}" not found. Please select a valid profile in settings.`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// =========================================
|
|
204
|
+
// Profile-specific Endpoints (existing)
|
|
205
|
+
// =========================================
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* SSE endpoint for profile notifications (dedicated URL)
|
|
209
|
+
* Sends notifications/tools/list_changed when the active gateway profile changes.
|
|
210
|
+
*/
|
|
211
|
+
@Get(':profileName/sse')
|
|
212
|
+
async streamProfileSse(
|
|
213
|
+
@Param('profileName') profileName: string,
|
|
214
|
+
@Req() req: Request,
|
|
215
|
+
@Res() res: Response
|
|
216
|
+
) {
|
|
217
|
+
// Validate profile exists
|
|
218
|
+
await this.proxyService.getProfileInfo(profileName);
|
|
219
|
+
return this.streamGatewayEvents(req, res);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* POST handler for profile SSE - handles JSON-RPC requests via SSE transport
|
|
224
|
+
*/
|
|
225
|
+
@Post(':profileName/sse')
|
|
226
|
+
@HttpCode(HttpStatus.OK)
|
|
227
|
+
async handleProfileSseRequest(
|
|
228
|
+
@Param('profileName') profileName: string,
|
|
229
|
+
@Body() request: McpRequest
|
|
230
|
+
): Promise<McpResponse> {
|
|
231
|
+
return this.handleMcpRequest(profileName, request);
|
|
232
|
+
}
|
|
14
233
|
|
|
15
234
|
/**
|
|
16
235
|
* Get profile info with aggregated tools and server status
|
|
@@ -21,12 +240,31 @@ export class ProxyController {
|
|
|
21
240
|
}
|
|
22
241
|
|
|
23
242
|
/**
|
|
24
|
-
* GET handler for MCP endpoint
|
|
243
|
+
* GET handler for MCP endpoint
|
|
244
|
+
*
|
|
245
|
+
* Streamable HTTP content negotiation:
|
|
246
|
+
* - Accept: text/event-stream -> Returns SSE stream for notifications
|
|
247
|
+
* - Otherwise -> Returns JSON usage instructions
|
|
248
|
+
*
|
|
249
|
+
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
|
|
25
250
|
*/
|
|
26
251
|
@Get(':profileName')
|
|
27
|
-
async getMcpEndpoint(
|
|
252
|
+
async getMcpEndpoint(
|
|
253
|
+
@Param('profileName') profileName: string,
|
|
254
|
+
@Req() req: Request,
|
|
255
|
+
@Res() res: Response
|
|
256
|
+
) {
|
|
257
|
+
// Validate profile exists first
|
|
28
258
|
const info = await this.proxyService.getProfileInfo(profileName);
|
|
29
|
-
|
|
259
|
+
|
|
260
|
+
// Streamable HTTP: check Accept header for content negotiation
|
|
261
|
+
const acceptHeader = req.headers.accept || '';
|
|
262
|
+
if (acceptHeader.includes('text/event-stream')) {
|
|
263
|
+
return this.streamGatewayEvents(req, res);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Return JSON usage info
|
|
267
|
+
return res.json({
|
|
30
268
|
message: 'This is an MCP (Model Context Protocol) endpoint. Use POST for JSON-RPC requests.',
|
|
31
269
|
usage: {
|
|
32
270
|
method: 'POST',
|
|
@@ -37,6 +275,10 @@ export class ProxyController {
|
|
|
37
275
|
id: 1,
|
|
38
276
|
},
|
|
39
277
|
},
|
|
278
|
+
endpoints: {
|
|
279
|
+
sse: `/api/mcp/${profileName}/sse`,
|
|
280
|
+
http: `/api/mcp/${profileName}`,
|
|
281
|
+
},
|
|
40
282
|
profile: {
|
|
41
283
|
name: profileName,
|
|
42
284
|
toolCount: info.tools.length,
|
|
@@ -44,7 +286,7 @@ export class ProxyController {
|
|
|
44
286
|
connectedServers: info.serverStatus.connected,
|
|
45
287
|
},
|
|
46
288
|
infoEndpoint: `/api/mcp/${profileName}/info`,
|
|
47
|
-
};
|
|
289
|
+
});
|
|
48
290
|
}
|
|
49
291
|
|
|
50
292
|
/**
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
* Proxy Module
|
|
3
3
|
*
|
|
4
4
|
* Handles MCP proxy endpoints for profiles.
|
|
5
|
+
* Supports SSE notifications for MCP Streamable HTTP transport.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { Module } from '@nestjs/common';
|
|
8
9
|
import { DebugModule } from '../debug/debug.module.js';
|
|
9
10
|
import { McpModule } from '../mcp/mcp.module.js';
|
|
11
|
+
import { SettingsModule } from '../settings/settings.module.js';
|
|
10
12
|
import { ProxyController } from './proxy.controller.js';
|
|
11
13
|
import { ProxyService } from './proxy.service.js';
|
|
12
14
|
|
|
13
15
|
@Module({
|
|
14
|
-
imports: [McpModule, DebugModule],
|
|
16
|
+
imports: [McpModule, DebugModule, SettingsModule],
|
|
15
17
|
controllers: [ProxyController],
|
|
16
18
|
providers: [ProxyService],
|
|
17
19
|
exports: [ProxyService],
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Settings Module
|
|
2
|
+
|
|
3
|
+
## Description
|
|
4
|
+
|
|
5
|
+
Application settings module. Manages gateway-wide configuration stored in the database.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- `settings.module.ts` - Module definition
|
|
10
|
+
- `settings.controller.ts` - REST API for settings
|
|
11
|
+
- `settings.service.ts` - Settings storage and retrieval
|
|
12
|
+
- `settings.constants.ts` - Default settings values
|
|
13
|
+
|
|
14
|
+
## Key Endpoints
|
|
15
|
+
|
|
16
|
+
- `GET /api/settings` - Get all settings
|
|
17
|
+
- `PUT /api/settings` - Update settings
|
|
18
|
+
- `GET /api/settings/:key` - Get single setting
|
|
19
|
+
|
|
20
|
+
## Key Settings
|
|
21
|
+
|
|
22
|
+
- `mcpTransport` - Default MCP transport type (http/sse)
|
|
23
|
+
- `debugLogRetention` - Debug log retention period
|
|
24
|
+
- Other gateway-wide configuration
|
|
25
|
+
|
|
26
|
+
## Key Concepts
|
|
27
|
+
|
|
28
|
+
- **Key-Value Storage**: Settings stored as key-value pairs
|
|
29
|
+
- **Defaults**: Default values defined in constants
|
|
30
|
+
- **Persistence**: Settings persist across restarts
|
|
31
|
+
- **Type Safety**: Settings validated against expected types
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings Constants
|
|
3
|
+
*
|
|
4
|
+
* Defines setting keys, defaults, and reserved profile names.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Setting keys
|
|
8
|
+
export const SETTING_KEYS = {
|
|
9
|
+
DEFAULT_GATEWAY_PROFILE: 'default_gateway_profile',
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
// Default values for settings
|
|
13
|
+
export const SETTING_DEFAULTS: Record<string, string> = {
|
|
14
|
+
[SETTING_KEYS.DEFAULT_GATEWAY_PROFILE]: 'default',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Reserved profile names that cannot be created by users
|
|
18
|
+
export const RESERVED_PROFILE_NAMES = ['gateway'] as const;
|
|
19
|
+
|
|
20
|
+
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings Controller
|
|
3
|
+
*
|
|
4
|
+
* REST API endpoints for gateway settings management.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Body, Controller, Get, Put } from '@nestjs/common';
|
|
8
|
+
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
|
9
|
+
import { SettingsService } from './settings.service.js';
|
|
10
|
+
|
|
11
|
+
export class UpdateDefaultGatewayProfileDto {
|
|
12
|
+
@IsString()
|
|
13
|
+
@IsNotEmpty({ message: 'Profile name cannot be empty' })
|
|
14
|
+
@MaxLength(100, { message: 'Profile name must be at most 100 characters' })
|
|
15
|
+
profileName: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@Controller('settings')
|
|
19
|
+
export class SettingsController {
|
|
20
|
+
constructor(private readonly settingsService: SettingsService) {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get all gateway settings
|
|
24
|
+
*/
|
|
25
|
+
@Get()
|
|
26
|
+
async getAllSettings() {
|
|
27
|
+
return this.settingsService.getAllSettings();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get the default gateway profile setting
|
|
32
|
+
*/
|
|
33
|
+
@Get('default-gateway-profile')
|
|
34
|
+
async getDefaultGatewayProfile() {
|
|
35
|
+
const profileName = await this.settingsService.getDefaultGatewayProfile();
|
|
36
|
+
return { profileName };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Set the default gateway profile
|
|
41
|
+
*/
|
|
42
|
+
@Put('default-gateway-profile')
|
|
43
|
+
async setDefaultGatewayProfile(@Body() dto: UpdateDefaultGatewayProfileDto) {
|
|
44
|
+
await this.settingsService.setDefaultGatewayProfile(dto.profileName);
|
|
45
|
+
return { profileName: dto.profileName };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings Module
|
|
3
|
+
*
|
|
4
|
+
* Handles gateway configuration settings.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Module } from '@nestjs/common';
|
|
8
|
+
import { SettingsController } from './settings.controller.js';
|
|
9
|
+
import { SettingsService } from './settings.service.js';
|
|
10
|
+
|
|
11
|
+
@Module({
|
|
12
|
+
controllers: [SettingsController],
|
|
13
|
+
providers: [SettingsService],
|
|
14
|
+
exports: [SettingsService],
|
|
15
|
+
})
|
|
16
|
+
export class SettingsModule {}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings Service
|
|
3
|
+
*
|
|
4
|
+
* Business logic for gateway settings management.
|
|
5
|
+
* Emits events when gateway settings change for SSE notifications.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
9
|
+
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
10
|
+
import { PrismaService } from '../database/prisma.service.js';
|
|
11
|
+
import { SETTING_DEFAULTS, SETTING_KEYS, type SettingKey } from './settings.constants.js';
|
|
12
|
+
|
|
13
|
+
/** Event name for gateway profile changes */
|
|
14
|
+
export const GATEWAY_PROFILE_CHANGED = 'gateway.profile.changed';
|
|
15
|
+
|
|
16
|
+
@Injectable()
|
|
17
|
+
export class SettingsService {
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly prisma: PrismaService,
|
|
20
|
+
private readonly eventEmitter: EventEmitter2
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get a setting value by key, returns default if not set
|
|
25
|
+
*/
|
|
26
|
+
async getSetting(key: SettingKey): Promise<string> {
|
|
27
|
+
const setting = await this.prisma.gatewaySetting.findUnique({
|
|
28
|
+
where: { key },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return setting?.value ?? SETTING_DEFAULTS[key];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Set a setting value
|
|
36
|
+
*/
|
|
37
|
+
async setSetting(key: SettingKey, value: string): Promise<{ key: string; value: string }> {
|
|
38
|
+
const setting = await this.prisma.gatewaySetting.upsert({
|
|
39
|
+
where: { key },
|
|
40
|
+
update: { value },
|
|
41
|
+
create: { key, value },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return { key: setting.key, value: setting.value };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the default gateway profile name
|
|
49
|
+
*/
|
|
50
|
+
async getDefaultGatewayProfile(): Promise<string> {
|
|
51
|
+
return this.getSetting(SETTING_KEYS.DEFAULT_GATEWAY_PROFILE);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Set the default gateway profile
|
|
56
|
+
* Validates that the profile exists before setting.
|
|
57
|
+
* Emits GATEWAY_PROFILE_CHANGED event for SSE subscribers.
|
|
58
|
+
*/
|
|
59
|
+
async setDefaultGatewayProfile(profileName: string): Promise<{ key: string; value: string }> {
|
|
60
|
+
// Input sanitization
|
|
61
|
+
const trimmedName = profileName?.trim();
|
|
62
|
+
if (!trimmedName) {
|
|
63
|
+
throw new BadRequestException('Profile name cannot be empty');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Validate that profile exists
|
|
67
|
+
const profile = await this.prisma.profile.findUnique({
|
|
68
|
+
where: { name: trimmedName },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!profile) {
|
|
72
|
+
throw new NotFoundException(`Profile "${trimmedName}" not found`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = await this.setSetting(SETTING_KEYS.DEFAULT_GATEWAY_PROFILE, trimmedName);
|
|
76
|
+
|
|
77
|
+
// Emit event for SSE subscribers (MCP Streamable HTTP notifications)
|
|
78
|
+
this.eventEmitter.emit(GATEWAY_PROFILE_CHANGED, { profileName: trimmedName });
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get all settings with their current values
|
|
85
|
+
*/
|
|
86
|
+
async getAllSettings(): Promise<Record<string, string>> {
|
|
87
|
+
const settings = await this.prisma.gatewaySetting.findMany();
|
|
88
|
+
|
|
89
|
+
// Start with defaults
|
|
90
|
+
const result: Record<string, string> = { ...SETTING_DEFAULTS };
|
|
91
|
+
|
|
92
|
+
// Override with stored values
|
|
93
|
+
for (const setting of settings) {
|
|
94
|
+
result[setting.key] = setting.value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
}
|