@dxheroes/local-mcp-backend 0.9.2 → 0.11.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/CHANGELOG.md +52 -0
- package/dist/__tests__/integration/mcp-proxy-auth-http.test.js +283 -0
- package/dist/__tests__/integration/mcp-proxy-auth-http.test.js.map +1 -0
- package/dist/__tests__/integration/oauth-authorize-callback.test.js +122 -0
- package/dist/__tests__/integration/oauth-authorize-callback.test.js.map +1 -0
- package/dist/__tests__/integration/proxy-auth.test.js +171 -110
- package/dist/__tests__/integration/proxy-auth.test.js.map +1 -1
- package/dist/__tests__/unit/auth.guard.test.js +23 -2
- package/dist/__tests__/unit/auth.guard.test.js.map +1 -1
- package/dist/common/filters/all-exceptions.filter.js +6 -0
- package/dist/common/filters/all-exceptions.filter.js.map +1 -1
- package/dist/main.js +63 -2
- package/dist/main.js.map +1 -1
- package/dist/modules/auth/auth.config.js +10 -5
- package/dist/modules/auth/auth.config.js.map +1 -1
- package/dist/modules/auth/auth.module.js +5 -2
- package/dist/modules/auth/auth.module.js.map +1 -1
- package/dist/modules/auth/auth.service.js +2 -2
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/mcp-oauth.guard.js +95 -0
- package/dist/modules/auth/mcp-oauth.guard.js.map +1 -0
- package/dist/modules/auth/mcp-oauth.utils.js +75 -0
- package/dist/modules/auth/mcp-oauth.utils.js.map +1 -0
- package/dist/modules/health/health.controller.js +1 -1
- package/dist/modules/health/health.controller.js.map +1 -1
- package/dist/modules/mcp/mcp.service.js +48 -8
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/oauth/oauth.controller.js +78 -1
- package/dist/modules/oauth/oauth.controller.js.map +1 -1
- package/dist/modules/oauth/oauth.service.js +197 -1
- package/dist/modules/oauth/oauth.service.js.map +1 -1
- package/dist/modules/proxy/proxy.controller.js +152 -27
- package/dist/modules/proxy/proxy.controller.js.map +1 -1
- package/dist/modules/proxy/proxy.service.js +28 -4
- package/dist/modules/proxy/proxy.service.js.map +1 -1
- package/docker-entrypoint.sh +15 -2
- package/package.json +9 -7
- package/src/__tests__/integration/mcp-proxy-auth-http.test.ts +311 -0
- package/src/__tests__/integration/oauth-authorize-callback.test.ts +155 -0
- package/src/__tests__/integration/proxy-auth.test.ts +151 -168
- package/src/__tests__/unit/auth.guard.test.ts +12 -2
- package/src/common/filters/all-exceptions.filter.ts +11 -0
- package/src/main.ts +56 -2
- package/src/modules/auth/auth.config.ts +9 -4
- package/src/modules/auth/auth.module.ts +3 -2
- package/src/modules/auth/auth.service.ts +2 -2
- package/src/modules/auth/mcp-oauth.guard.ts +102 -0
- package/src/modules/auth/mcp-oauth.utils.ts +80 -0
- package/src/modules/health/health.controller.ts +1 -1
- package/src/modules/mcp/mcp.service.ts +54 -12
- package/src/modules/oauth/oauth.controller.ts +84 -1
- package/src/modules/oauth/oauth.service.ts +218 -1
- package/src/modules/proxy/proxy.controller.ts +120 -25
- package/src/modules/proxy/proxy.service.ts +26 -4
- package/vitest.config.ts +2 -1
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Manages OAuth tokens for MCP servers.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { OAuthDiscoveryService } from '@dxheroes/local-mcp-core';
|
|
8
|
+
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
8
9
|
import { PrismaService } from '../database/prisma.service.js';
|
|
9
10
|
|
|
10
11
|
interface StartOAuthFlowDto {
|
|
@@ -35,8 +36,10 @@ interface StoreTokenDto {
|
|
|
35
36
|
|
|
36
37
|
@Injectable()
|
|
37
38
|
export class OAuthService {
|
|
39
|
+
private readonly logger = new Logger(OAuthService.name);
|
|
38
40
|
// In-memory storage for PKCE verifiers (in production, use Redis or similar)
|
|
39
41
|
private pkceVerifiers = new Map<string, { verifier: string; expiresAt: number }>();
|
|
42
|
+
private readonly discoveryService = new OAuthDiscoveryService();
|
|
40
43
|
|
|
41
44
|
constructor(private readonly prisma: PrismaService) {}
|
|
42
45
|
|
|
@@ -183,6 +186,220 @@ export class OAuthService {
|
|
|
183
186
|
});
|
|
184
187
|
}
|
|
185
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Discover OAuth configuration and build an authorization URL for a server.
|
|
191
|
+
* Uses RFC 9728 / 8414 / 7591 auto-discovery and optional DCR.
|
|
192
|
+
* Returns an authorization URL the browser should be redirected to.
|
|
193
|
+
*/
|
|
194
|
+
async discoverAndAuthorize(serverId: string, callbackUrl: string): Promise<string> {
|
|
195
|
+
const server = await this.prisma.mcpServer.findUnique({ where: { id: serverId } });
|
|
196
|
+
if (!server) {
|
|
197
|
+
throw new NotFoundException(`MCP server ${serverId} not found`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Parse server URL from config
|
|
201
|
+
const config = typeof server.config === 'string' ? JSON.parse(server.config) : server.config;
|
|
202
|
+
const serverUrl = config?.url as string | undefined;
|
|
203
|
+
if (!serverUrl) {
|
|
204
|
+
throw new BadRequestException('Server has no URL configured');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Parse existing oauthConfig if any (may have manual clientId)
|
|
208
|
+
const existingOAuthConfig = server.oauthConfig
|
|
209
|
+
? typeof server.oauthConfig === 'string'
|
|
210
|
+
? JSON.parse(server.oauthConfig)
|
|
211
|
+
: server.oauthConfig
|
|
212
|
+
: null;
|
|
213
|
+
|
|
214
|
+
// Step 1: Discover OAuth endpoints via RFC 9728 + 8414
|
|
215
|
+
this.logger.log(`Discovering OAuth for server ${serverId} at ${serverUrl}`);
|
|
216
|
+
const discovery = await this.discoveryService.discoverFromServerUrl(serverUrl);
|
|
217
|
+
|
|
218
|
+
// Step 2: Get or register client
|
|
219
|
+
let clientId: string;
|
|
220
|
+
|
|
221
|
+
if (existingOAuthConfig?.clientId) {
|
|
222
|
+
// Use manually configured clientId
|
|
223
|
+
clientId = existingOAuthConfig.clientId;
|
|
224
|
+
} else {
|
|
225
|
+
// Check for existing DCR registration
|
|
226
|
+
const existingReg = await this.prisma.oAuthClientRegistration.findUnique({
|
|
227
|
+
where: {
|
|
228
|
+
mcpServerId_authorizationServerUrl: {
|
|
229
|
+
mcpServerId: serverId,
|
|
230
|
+
authorizationServerUrl: discovery.authorizationServerUrl,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (existingReg) {
|
|
236
|
+
// Delete stale registration — redirect_uri may have changed
|
|
237
|
+
await this.prisma.oAuthClientRegistration.delete({
|
|
238
|
+
where: { id: existingReg.id },
|
|
239
|
+
});
|
|
240
|
+
this.logger.log(`Deleted stale DCR registration for ${serverId}, will re-register`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (discovery.registrationEndpoint) {
|
|
244
|
+
// Perform Dynamic Client Registration (RFC 7591)
|
|
245
|
+
this.logger.log(`Performing DCR at ${discovery.registrationEndpoint}`);
|
|
246
|
+
const registration = await this.discoveryService.registerClient(
|
|
247
|
+
discovery.registrationEndpoint,
|
|
248
|
+
callbackUrl,
|
|
249
|
+
discovery.scopes
|
|
250
|
+
);
|
|
251
|
+
clientId = registration.clientId;
|
|
252
|
+
|
|
253
|
+
// Store registration
|
|
254
|
+
await this.prisma.oAuthClientRegistration.create({
|
|
255
|
+
data: {
|
|
256
|
+
mcpServerId: serverId,
|
|
257
|
+
authorizationServerUrl: discovery.authorizationServerUrl,
|
|
258
|
+
clientId: registration.clientId,
|
|
259
|
+
clientSecret: registration.clientSecret,
|
|
260
|
+
registrationAccessToken: registration.registrationAccessToken,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
} else {
|
|
264
|
+
throw new BadRequestException(
|
|
265
|
+
'No clientId configured and server does not support Dynamic Client Registration. ' +
|
|
266
|
+
'Please configure a clientId manually in the OAuth settings.'
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Step 3: Store discovery result in oauthConfig on the server
|
|
272
|
+
await this.prisma.mcpServer.update({
|
|
273
|
+
where: { id: serverId },
|
|
274
|
+
data: {
|
|
275
|
+
oauthConfig: JSON.stringify({
|
|
276
|
+
authorizationServerUrl: discovery.authorizationServerUrl,
|
|
277
|
+
authorizationEndpoint: discovery.authorizationEndpoint,
|
|
278
|
+
tokenEndpoint: discovery.tokenEndpoint,
|
|
279
|
+
registrationEndpoint: discovery.registrationEndpoint,
|
|
280
|
+
resource: discovery.resource,
|
|
281
|
+
scopes: discovery.scopes,
|
|
282
|
+
clientId,
|
|
283
|
+
requiresOAuth: true,
|
|
284
|
+
}),
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Step 4: Generate PKCE
|
|
289
|
+
const codeVerifier = this.generateCodeVerifier();
|
|
290
|
+
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
|
|
291
|
+
|
|
292
|
+
// Store verifier (expires in 10 minutes)
|
|
293
|
+
this.pkceVerifiers.set(serverId, {
|
|
294
|
+
verifier: codeVerifier,
|
|
295
|
+
expiresAt: Date.now() + 10 * 60 * 1000,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Step 5: Build authorization URL
|
|
299
|
+
const params = new URLSearchParams({
|
|
300
|
+
response_type: 'code',
|
|
301
|
+
client_id: clientId,
|
|
302
|
+
redirect_uri: callbackUrl,
|
|
303
|
+
code_challenge: codeChallenge,
|
|
304
|
+
code_challenge_method: 'S256',
|
|
305
|
+
state: serverId, // Use serverId as state for callback routing
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (discovery.scopes.length > 0) {
|
|
309
|
+
params.set('scope', discovery.scopes.join(' '));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (discovery.resource) {
|
|
313
|
+
params.set('resource', discovery.resource);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return `${discovery.authorizationEndpoint}?${params.toString()}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Handle OAuth callback: exchange authorization code for tokens.
|
|
321
|
+
*/
|
|
322
|
+
async handleCallback(
|
|
323
|
+
serverId: string,
|
|
324
|
+
code: string,
|
|
325
|
+
callbackUrl: string
|
|
326
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
327
|
+
const server = await this.prisma.mcpServer.findUnique({ where: { id: serverId } });
|
|
328
|
+
if (!server) {
|
|
329
|
+
return { success: false, error: `MCP server ${serverId} not found` };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Retrieve PKCE verifier
|
|
333
|
+
const pkceEntry = this.pkceVerifiers.get(serverId);
|
|
334
|
+
if (!pkceEntry) {
|
|
335
|
+
return { success: false, error: 'PKCE verifier not found or expired' };
|
|
336
|
+
}
|
|
337
|
+
if (pkceEntry.expiresAt < Date.now()) {
|
|
338
|
+
this.pkceVerifiers.delete(serverId);
|
|
339
|
+
return { success: false, error: 'PKCE verifier expired' };
|
|
340
|
+
}
|
|
341
|
+
const codeVerifier = pkceEntry.verifier;
|
|
342
|
+
this.pkceVerifiers.delete(serverId);
|
|
343
|
+
|
|
344
|
+
// Get oauthConfig for token endpoint and clientId
|
|
345
|
+
const oauthConfig = server.oauthConfig
|
|
346
|
+
? typeof server.oauthConfig === 'string'
|
|
347
|
+
? JSON.parse(server.oauthConfig)
|
|
348
|
+
: server.oauthConfig
|
|
349
|
+
: null;
|
|
350
|
+
|
|
351
|
+
if (!oauthConfig?.tokenEndpoint || !oauthConfig?.clientId) {
|
|
352
|
+
return { success: false, error: 'OAuth configuration incomplete (missing tokenEndpoint or clientId)' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
// Exchange code for tokens
|
|
357
|
+
const response = await fetch(oauthConfig.tokenEndpoint, {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
360
|
+
body: new URLSearchParams({
|
|
361
|
+
grant_type: 'authorization_code',
|
|
362
|
+
code,
|
|
363
|
+
redirect_uri: callbackUrl,
|
|
364
|
+
client_id: oauthConfig.clientId,
|
|
365
|
+
code_verifier: codeVerifier,
|
|
366
|
+
}),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
if (!response.ok) {
|
|
370
|
+
const errorText = await response.text();
|
|
371
|
+
return { success: false, error: `Token exchange failed: ${errorText}` };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const tokenData = (await response.json()) as {
|
|
375
|
+
access_token: string;
|
|
376
|
+
refresh_token?: string;
|
|
377
|
+
token_type?: string;
|
|
378
|
+
scope?: string;
|
|
379
|
+
expires_in?: number;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const expiresAt = tokenData.expires_in
|
|
383
|
+
? new Date(Date.now() + tokenData.expires_in * 1000)
|
|
384
|
+
: null;
|
|
385
|
+
|
|
386
|
+
// Store token
|
|
387
|
+
await this.storeToken({
|
|
388
|
+
mcpServerId: serverId,
|
|
389
|
+
accessToken: tokenData.access_token,
|
|
390
|
+
refreshToken: tokenData.refresh_token,
|
|
391
|
+
tokenType: tokenData.token_type || 'Bearer',
|
|
392
|
+
scope: tokenData.scope,
|
|
393
|
+
expiresAt,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return { success: true };
|
|
397
|
+
} catch (error) {
|
|
398
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
399
|
+
return { success: false, error: message };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
186
403
|
/**
|
|
187
404
|
* Generate a random code verifier for PKCE
|
|
188
405
|
*/
|
|
@@ -19,46 +19,33 @@ import {
|
|
|
19
19
|
Post,
|
|
20
20
|
Req,
|
|
21
21
|
Res,
|
|
22
|
-
|
|
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
|
|
45
|
-
*
|
|
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
|
|
49
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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',
|