@dxheroes/local-mcp-backend 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +27 -0
  3. package/dist/__tests__/integration/mcp-proxy-auth-http.test.js +246 -0
  4. package/dist/__tests__/integration/mcp-proxy-auth-http.test.js.map +1 -0
  5. package/dist/__tests__/integration/oauth-authorize-callback.test.js +122 -0
  6. package/dist/__tests__/integration/oauth-authorize-callback.test.js.map +1 -0
  7. package/dist/__tests__/integration/proxy-auth.test.js +121 -111
  8. package/dist/__tests__/integration/proxy-auth.test.js.map +1 -1
  9. package/dist/__tests__/unit/auth.guard.test.js +23 -2
  10. package/dist/__tests__/unit/auth.guard.test.js.map +1 -1
  11. package/dist/common/filters/all-exceptions.filter.js +6 -0
  12. package/dist/common/filters/all-exceptions.filter.js.map +1 -1
  13. package/dist/main.js +37 -0
  14. package/dist/main.js.map +1 -1
  15. package/dist/modules/auth/auth.config.js +5 -2
  16. package/dist/modules/auth/auth.config.js.map +1 -1
  17. package/dist/modules/auth/auth.module.js +5 -2
  18. package/dist/modules/auth/auth.module.js.map +1 -1
  19. package/dist/modules/auth/auth.service.js +2 -2
  20. package/dist/modules/auth/auth.service.js.map +1 -1
  21. package/dist/modules/auth/mcp-oauth.guard.js +70 -0
  22. package/dist/modules/auth/mcp-oauth.guard.js.map +1 -0
  23. package/dist/modules/auth/mcp-oauth.utils.js +75 -0
  24. package/dist/modules/auth/mcp-oauth.utils.js.map +1 -0
  25. package/dist/modules/mcp/mcp.service.js +48 -8
  26. package/dist/modules/mcp/mcp.service.js.map +1 -1
  27. package/dist/modules/oauth/oauth.controller.js +78 -1
  28. package/dist/modules/oauth/oauth.controller.js.map +1 -1
  29. package/dist/modules/oauth/oauth.service.js +197 -1
  30. package/dist/modules/oauth/oauth.service.js.map +1 -1
  31. package/dist/modules/proxy/proxy.controller.js +152 -27
  32. package/dist/modules/proxy/proxy.controller.js.map +1 -1
  33. package/dist/modules/proxy/proxy.service.js +28 -4
  34. package/dist/modules/proxy/proxy.service.js.map +1 -1
  35. package/docker-entrypoint.sh +15 -2
  36. package/package.json +7 -7
  37. package/src/__tests__/integration/mcp-proxy-auth-http.test.ts +281 -0
  38. package/src/__tests__/integration/oauth-authorize-callback.test.ts +155 -0
  39. package/src/__tests__/integration/proxy-auth.test.ts +119 -168
  40. package/src/__tests__/unit/auth.guard.test.ts +12 -2
  41. package/src/common/filters/all-exceptions.filter.ts +11 -0
  42. package/src/main.ts +32 -1
  43. package/src/modules/auth/auth.config.ts +4 -1
  44. package/src/modules/auth/auth.module.ts +3 -2
  45. package/src/modules/auth/auth.service.ts +2 -2
  46. package/src/modules/auth/mcp-oauth.guard.ts +75 -0
  47. package/src/modules/auth/mcp-oauth.utils.ts +80 -0
  48. package/src/modules/mcp/mcp.service.ts +54 -12
  49. package/src/modules/oauth/oauth.controller.ts +84 -1
  50. package/src/modules/oauth/oauth.service.ts +218 -1
  51. package/src/modules/proxy/proxy.controller.ts +120 -25
  52. package/src/modules/proxy/proxy.service.ts +26 -4
  53. package/vitest.config.ts +2 -1
@@ -19,46 +19,33 @@ import {
19
19
  Post,
20
20
  Req,
21
21
  Res,
22
- UnauthorizedException,
22
+ UseGuards,
23
23
  } from '@nestjs/common';
24
24
  import { EventEmitter2 } from '@nestjs/event-emitter';
25
25
  import type { Request, Response } from 'express';
26
26
  import { fromEvent, map } from 'rxjs';
27
- import { AuthService } from '../auth/auth.service.js';
28
27
  import { Public } from '../auth/decorators/public.decorator.js';
28
+ import { McpOAuthGuard } from '../auth/mcp-oauth.guard.js';
29
29
  import { GATEWAY_PROFILE_CHANGED, SettingsService } from '../settings/settings.service.js';
30
30
  import type { McpRequest, McpResponse } from './proxy.service.js';
31
31
  import { ProxyService } from './proxy.service.js';
32
32
 
33
33
  @Public()
34
+ @UseGuards(McpOAuthGuard)
34
35
  @Controller('mcp')
35
36
  export class ProxyController {
36
37
  constructor(
37
38
  private readonly proxyService: ProxyService,
38
39
  private readonly settingsService: SettingsService,
39
- private readonly eventEmitter: EventEmitter2,
40
- private readonly authService: AuthService
40
+ private readonly eventEmitter: EventEmitter2
41
41
  ) {}
42
42
 
43
43
  /**
44
- * Resolve user from Bearer token (MCP OAuth).
45
- * When no token is provided, returns unauthenticated sentinel so
46
- * system profiles (organizationId=null) still work for unauthenticated MCP clients.
44
+ * Resolve user from request.
45
+ * McpOAuthGuard always validates and attaches user before this is called.
47
46
  */
48
- private async resolveUser(req: Request): Promise<{ id: string }> {
49
- const authHeader = req.headers.authorization;
50
- if (!authHeader?.startsWith('Bearer ')) {
51
- // No token — unauthenticated MCP client, can only access system profiles
52
- return { id: '__unauthenticated__' };
53
- }
54
-
55
- const token = authHeader.slice(7);
56
- const user = await this.authService.validateMcpToken(token);
57
- if (!user) {
58
- throw new UnauthorizedException('Invalid or expired MCP OAuth token');
59
- }
60
-
61
- return user;
47
+ private resolveUser(req: Request): { id: string } {
48
+ return req.user!;
62
49
  }
63
50
 
64
51
  // =========================================
@@ -90,7 +77,7 @@ export class ProxyController {
90
77
  */
91
78
  @Get('gateway/info')
92
79
  async getGatewayInfo(@Req() req: Request) {
93
- const user = await this.resolveUser(req);
80
+ const user = this.resolveUser(req);
94
81
  const profileName = await this.settingsService.getDefaultGatewayProfile();
95
82
 
96
83
  try {
@@ -121,7 +108,7 @@ export class ProxyController {
121
108
  return this.streamGatewayEvents(req, res);
122
109
  }
123
110
 
124
- const user = await this.resolveUser(req);
111
+ const user = this.resolveUser(req);
125
112
  const profileName = await this.settingsService.getDefaultGatewayProfile();
126
113
 
127
114
  try {
@@ -195,7 +182,7 @@ export class ProxyController {
195
182
  @Req() req: Request,
196
183
  @Body() request: McpRequest
197
184
  ): Promise<McpResponse> {
198
- const user = await this.resolveUser(req);
185
+ const user = this.resolveUser(req);
199
186
  const profileName = await this.settingsService.getDefaultGatewayProfile();
200
187
 
201
188
  try {
@@ -210,6 +197,114 @@ export class ProxyController {
210
197
  }
211
198
  }
212
199
 
200
+ // =========================================
201
+ // Org-scoped Gateway Endpoints: /api/mcp/:orgSlug/gateway
202
+ // (must come BEFORE :orgSlug/:profileName to avoid "gateway" matching :profileName)
203
+ // =========================================
204
+
205
+ /**
206
+ * SSE endpoint for org-scoped gateway
207
+ */
208
+ @Get(':orgSlug/gateway/sse')
209
+ async streamOrgGatewaySse(
210
+ @Param('orgSlug') orgSlug: string,
211
+ @Req() req: Request,
212
+ @Res() res: Response
213
+ ) {
214
+ const profileName = await this.settingsService.getDefaultGatewayProfile();
215
+ await this.proxyService.getProfileInfoByOrgSlug(profileName, orgSlug);
216
+ return this.streamGatewayEvents(req, res);
217
+ }
218
+
219
+ /**
220
+ * POST handler for org-scoped gateway SSE
221
+ */
222
+ @Post(':orgSlug/gateway/sse')
223
+ @HttpCode(HttpStatus.OK)
224
+ async handleOrgGatewaySseRequest(
225
+ @Req() req: Request,
226
+ @Param('orgSlug') orgSlug: string,
227
+ @Body() request: McpRequest
228
+ ): Promise<McpResponse> {
229
+ const user = this.resolveUser(req);
230
+ const profileName = await this.settingsService.getDefaultGatewayProfile();
231
+ return this.proxyService.handleRequestByOrgSlug(profileName, orgSlug, request, user.id);
232
+ }
233
+
234
+ /**
235
+ * Get org-scoped gateway info
236
+ */
237
+ @Get(':orgSlug/gateway/info')
238
+ async getOrgGatewayInfo(@Param('orgSlug') orgSlug: string) {
239
+ const profileName = await this.settingsService.getDefaultGatewayProfile();
240
+ const info = await this.proxyService.getProfileInfoByOrgSlug(profileName, orgSlug);
241
+ return {
242
+ ...info,
243
+ gateway: {
244
+ activeProfile: profileName,
245
+ },
246
+ };
247
+ }
248
+
249
+ /**
250
+ * GET handler for org-scoped gateway endpoint
251
+ */
252
+ @Get(':orgSlug/gateway')
253
+ async getOrgGatewayEndpoint(
254
+ @Param('orgSlug') orgSlug: string,
255
+ @Req() req: Request,
256
+ @Res() res: Response
257
+ ) {
258
+ const acceptHeader = req.headers.accept || '';
259
+ if (acceptHeader.includes('text/event-stream')) {
260
+ const profileName = await this.settingsService.getDefaultGatewayProfile();
261
+ await this.proxyService.getProfileInfoByOrgSlug(profileName, orgSlug);
262
+ return this.streamGatewayEvents(req, res);
263
+ }
264
+
265
+ const profileName = await this.settingsService.getDefaultGatewayProfile();
266
+ const info = await this.proxyService.getProfileInfoByOrgSlug(profileName, orgSlug);
267
+
268
+ return res.json({
269
+ message: 'This is the MCP Gateway endpoint. Use POST for JSON-RPC requests.',
270
+ usage: {
271
+ method: 'POST',
272
+ contentType: 'application/json',
273
+ body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
274
+ },
275
+ endpoints: {
276
+ sse: `/api/mcp/${orgSlug}/gateway/sse`,
277
+ http: `/api/mcp/${orgSlug}/gateway`,
278
+ },
279
+ gateway: {
280
+ activeProfile: profileName,
281
+ settingsEndpoint: '/api/settings/default-gateway-profile',
282
+ },
283
+ profile: {
284
+ name: profileName,
285
+ toolCount: info.tools.length,
286
+ serverCount: info.serverStatus.total,
287
+ connectedServers: info.serverStatus.connected,
288
+ },
289
+ infoEndpoint: `/api/mcp/${orgSlug}/gateway/info`,
290
+ });
291
+ }
292
+
293
+ /**
294
+ * MCP JSON-RPC endpoint for org-scoped gateway
295
+ */
296
+ @Post(':orgSlug/gateway')
297
+ @HttpCode(HttpStatus.OK)
298
+ async handleOrgGatewayRequest(
299
+ @Req() req: Request,
300
+ @Param('orgSlug') orgSlug: string,
301
+ @Body() request: McpRequest
302
+ ): Promise<McpResponse> {
303
+ const user = this.resolveUser(req);
304
+ const profileName = await this.settingsService.getDefaultGatewayProfile();
305
+ return this.proxyService.handleRequestByOrgSlug(profileName, orgSlug, request, user.id);
306
+ }
307
+
213
308
  // =========================================
214
309
  // Org-scoped Profile Endpoints: /api/mcp/:orgSlug/:profileName
215
310
  // =========================================
@@ -302,7 +397,7 @@ export class ProxyController {
302
397
  @Param('profileName') profileName: string,
303
398
  @Body() request: McpRequest
304
399
  ): Promise<McpResponse> {
305
- const user = await this.resolveUser(req);
400
+ const user = this.resolveUser(req);
306
401
  return this.proxyService.handleRequestByOrgSlug(profileName, orgSlug, request, user.id);
307
402
  }
308
403
  }
@@ -41,6 +41,7 @@ export interface McpResponse {
41
41
  interface ServerConfig {
42
42
  builtinId?: string;
43
43
  url?: string;
44
+ headers?: Record<string, string>;
44
45
  // External server config
45
46
  command?: string;
46
47
  args?: string[];
@@ -488,11 +489,32 @@ export class ProxyService {
488
489
  }
489
490
  }
490
491
 
492
+ // For remote_http and remote_sse servers, look up OAuth token
493
+ let oauthToken = null;
494
+ if (server.type === 'remote_http' || server.type === 'remote_sse') {
495
+ const tokenRecord = await this.prisma.oAuthToken.findUnique({
496
+ where: { mcpServerId: server.id },
497
+ });
498
+ if (tokenRecord) {
499
+ oauthToken = {
500
+ id: tokenRecord.id,
501
+ mcpServerId: tokenRecord.mcpServerId,
502
+ accessToken: tokenRecord.accessToken,
503
+ tokenType: tokenRecord.tokenType,
504
+ refreshToken: tokenRecord.refreshToken ?? undefined,
505
+ scope: tokenRecord.scope ?? undefined,
506
+ expiresAt: tokenRecord.expiresAt?.getTime(),
507
+ createdAt: tokenRecord.createdAt.getTime(),
508
+ updatedAt: tokenRecord.updatedAt.getTime(),
509
+ };
510
+ }
511
+ }
512
+
491
513
  // For remote_http servers, create RemoteHttpMcpServer
492
514
  if (server.type === 'remote_http' && config?.url) {
493
515
  const remoteServer = new RemoteHttpMcpServer(
494
- { url: config.url, transport: 'http' },
495
- null,
516
+ { url: config.url, transport: 'http', headers: config.headers as Record<string, string> },
517
+ oauthToken,
496
518
  apiKeyConfig
497
519
  );
498
520
  await remoteServer.initialize();
@@ -503,8 +525,8 @@ export class ProxyService {
503
525
  // For remote_sse servers, create RemoteSseMcpServer
504
526
  if (server.type === 'remote_sse' && config?.url) {
505
527
  const remoteServer = new RemoteSseMcpServer(
506
- { url: config.url, transport: 'sse' },
507
- null,
528
+ { url: config.url, transport: 'sse', headers: config.headers as Record<string, string> },
529
+ oauthToken,
508
530
  apiKeyConfig
509
531
  );
510
532
  await remoteServer.initialize();
package/vitest.config.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { createVitestConfig } from '@dxheroes/local-mcp-config/vitest';
2
- import { defineConfig, mergeConfig } from 'vitest/config';
2
+ import { configDefaults, defineConfig, mergeConfig } from 'vitest/config';
3
3
 
4
4
  export default mergeConfig(
5
5
  createVitestConfig(),
6
6
  defineConfig({
7
7
  test: {
8
+ exclude: [...configDefaults.exclude, 'dist/**', '**/dist/**'],
8
9
  root: '.',
9
10
  coverage: {
10
11
  provider: 'v8',