@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxheroes/local-mcp-backend",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "NestJS API server providing MCP proxy, server aggregation, OAuth 2.1, and profile management",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -23,11 +23,11 @@
23
23
  "reflect-metadata": "^0.2.2",
24
24
  "rxjs": "^7.8.2",
25
25
  "zod": "^4.3.5",
26
- "@dxheroes/local-mcp-core": "0.7.2",
27
- "@dxheroes/local-mcp-database": "0.5.2",
28
- "@dxheroes/mcp-gemini-deep-research": "0.5.5",
29
- "@dxheroes/mcp-toggl": "0.3.5",
30
- "@dxheroes/mcp-merk": "0.3.5"
26
+ "@dxheroes/local-mcp-core": "0.8.0",
27
+ "@dxheroes/local-mcp-database": "0.5.3",
28
+ "@dxheroes/mcp-gemini-deep-research": "0.5.6",
29
+ "@dxheroes/mcp-merk": "0.3.6",
30
+ "@dxheroes/mcp-toggl": "0.3.6"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@nestjs/cli": "^11.0.14",
@@ -41,7 +41,7 @@
41
41
  "@types/pg": "^8.18.0",
42
42
  "typescript": "^5.9.3",
43
43
  "vitest": "^4.0.17",
44
- "@dxheroes/local-mcp-config": "0.4.10"
44
+ "@dxheroes/local-mcp-config": "0.4.11"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "nest build",
@@ -0,0 +1,281 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import type { ConfigService } from '@nestjs/config';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { AllExceptionsFilter } from '../../common/filters/all-exceptions.filter.js';
5
+ import { AuthService, type AuthUser } from '../../modules/auth/auth.service.js';
6
+ import { McpOAuthGuard } from '../../modules/auth/mcp-oauth.guard.js';
7
+ import {
8
+ createMcpProtectedResourceMetadata,
9
+ resolvePublicAuthBaseUrl,
10
+ resolvePublicBackendOrigin,
11
+ } from '../../modules/auth/mcp-oauth.utils.js';
12
+
13
+ type MockAuthService = {
14
+ validateMcpToken: ReturnType<typeof vi.fn>;
15
+ };
16
+
17
+ type MockConfigService = {
18
+ get: ReturnType<typeof vi.fn>;
19
+ };
20
+
21
+ function createConfigService(values: Record<string, unknown>): MockConfigService {
22
+ return {
23
+ get: vi.fn((key: string) => values[key]),
24
+ };
25
+ }
26
+
27
+ function createAuthServiceMock(): MockAuthService {
28
+ return {
29
+ validateMcpToken: vi.fn().mockResolvedValue(null),
30
+ };
31
+ }
32
+
33
+ async function startTestApp(options?: {
34
+ backendUrl?: string;
35
+ }): Promise<{
36
+ close: () => Promise<void>;
37
+ authService: MockAuthService;
38
+ baseUrl: string;
39
+ }> {
40
+ const authService = createAuthServiceMock();
41
+ const configService = createConfigService({
42
+ 'app.port': 3001,
43
+ BETTER_AUTH_URL: options?.backendUrl ?? 'http://localhost:3001',
44
+ });
45
+ const guard = new McpOAuthGuard(authService as unknown as AuthService, configService as never);
46
+ const filter = new AllExceptionsFilter();
47
+ const backendOrigin = resolvePublicBackendOrigin(configService as unknown as ConfigService);
48
+ const authBaseUrl = resolvePublicAuthBaseUrl(configService as unknown as ConfigService);
49
+
50
+ const createRequest = (req: IncomingMessage) => {
51
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
52
+ return {
53
+ method: req.method ?? 'GET',
54
+ url: `${url.pathname}${url.search}`,
55
+ headers: req.headers,
56
+ query: Object.fromEntries(url.searchParams.entries()),
57
+ user: undefined,
58
+ };
59
+ };
60
+
61
+ const createResponse = (res: ServerResponse) => {
62
+ const response = {
63
+ setHeader(name: string, value: string) {
64
+ res.setHeader(name, value);
65
+ return response;
66
+ },
67
+ status(statusCode: number) {
68
+ res.statusCode = statusCode;
69
+ return response;
70
+ },
71
+ json(body: unknown) {
72
+ res.setHeader('content-type', 'application/json');
73
+ res.end(JSON.stringify(body));
74
+ },
75
+ };
76
+
77
+ return response;
78
+ };
79
+
80
+ const handleGuardedRoute = async (
81
+ nodeRequest: IncomingMessage,
82
+ nodeResponse: ServerResponse,
83
+ responseBody: (request: ReturnType<typeof createRequest>) => Record<string, unknown>
84
+ ) => {
85
+ const request = createRequest(nodeRequest);
86
+ const response = createResponse(nodeResponse);
87
+ const context = {
88
+ switchToHttp: () => ({
89
+ getRequest: () => request,
90
+ getResponse: () => response,
91
+ }),
92
+ } as never;
93
+
94
+ const host = {
95
+ switchToHttp: () => ({
96
+ getRequest: () => request,
97
+ getResponse: () => response,
98
+ }),
99
+ } as never;
100
+
101
+ try {
102
+ await guard.canActivate(context);
103
+ response.json(responseBody(request));
104
+ } catch (error) {
105
+ filter.catch(error, host);
106
+ }
107
+ };
108
+
109
+ const server = await new Promise<import('node:http').Server>((resolve) => {
110
+ const listeningServer = createServer(async (req, res) => {
111
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
112
+
113
+ if (req.method === 'GET' && url.pathname === '/api/mcp/test') {
114
+ await handleGuardedRoute(req, res, (request) => ({
115
+ ok: true,
116
+ userId: (request as { user?: AuthUser }).user?.id ?? null,
117
+ }));
118
+ return;
119
+ }
120
+
121
+ if (req.method === 'GET' && url.pathname === '/api/mcp/sse') {
122
+ await handleGuardedRoute(req, res, (request) => ({
123
+ ok: true,
124
+ token: request.query.access_token ?? null,
125
+ userId: (request as { user?: AuthUser }).user?.id ?? null,
126
+ }));
127
+ return;
128
+ }
129
+
130
+ if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') {
131
+ res.setHeader('content-type', 'application/json');
132
+ res.end(JSON.stringify(createMcpProtectedResourceMetadata(configService as unknown as ConfigService)));
133
+ return;
134
+ }
135
+
136
+ if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {
137
+ res.setHeader('content-type', 'application/json');
138
+ res.end(
139
+ JSON.stringify({
140
+ issuer: backendOrigin,
141
+ authorization_endpoint: `${authBaseUrl}/mcp/authorize`,
142
+ token_endpoint: `${authBaseUrl}/mcp/token`,
143
+ registration_endpoint: `${authBaseUrl}/mcp/register`,
144
+ jwks_uri: `${authBaseUrl}/mcp/jwks`,
145
+ response_types_supported: ['code'],
146
+ grant_types_supported: ['authorization_code', 'refresh_token'],
147
+ code_challenge_methods_supported: ['S256'],
148
+ })
149
+ );
150
+ return;
151
+ }
152
+
153
+ if (req.method === 'POST' && url.pathname === '/api/auth/mcp/register') {
154
+ res.statusCode = 201;
155
+ res.setHeader('content-type', 'application/json');
156
+ res.end(
157
+ JSON.stringify({
158
+ client_id: 'cursor-client',
159
+ client_secret: 'cursor-secret',
160
+ redirect_uris: ['https://cursor.sh/callback'],
161
+ })
162
+ );
163
+ return;
164
+ }
165
+
166
+ res.statusCode = 404;
167
+ res.end();
168
+ }).listen(0, () => resolve(listeningServer));
169
+ });
170
+ const address = server.address();
171
+ if (!address || typeof address === 'string') {
172
+ throw new Error('Failed to resolve test server address');
173
+ }
174
+
175
+ return {
176
+ close: () =>
177
+ new Promise<void>((resolve, reject) => {
178
+ server.close((error) => {
179
+ if (error) {
180
+ reject(error);
181
+ return;
182
+ }
183
+ resolve();
184
+ });
185
+ }),
186
+ authService,
187
+ baseUrl: `http://127.0.0.1:${address.port}`,
188
+ };
189
+ }
190
+
191
+ describe('MCP proxy auth HTTP contract', () => {
192
+ let close: (() => Promise<void>) | undefined;
193
+ let authService: MockAuthService;
194
+ let baseUrl: string;
195
+
196
+ beforeEach(async () => {
197
+ const result = await startTestApp({
198
+ backendUrl: 'http://localhost:9631',
199
+ });
200
+ close = result.close;
201
+ authService = result.authService;
202
+ baseUrl = result.baseUrl;
203
+ });
204
+
205
+ afterEach(async () => {
206
+ if (close) {
207
+ await close();
208
+ }
209
+ });
210
+
211
+ it('returns 401 with MCP discovery headers when token is missing', async () => {
212
+ const response = await fetch(`${baseUrl}/api/mcp/test`);
213
+ const body = (await response.json()) as Record<string, unknown>;
214
+
215
+ expect(response.status).toBe(401);
216
+ expect(response.headers.get('access-control-expose-headers')).toBe('WWW-Authenticate');
217
+ expect(response.headers.get('www-authenticate')).toContain('resource_metadata=');
218
+ expect(response.headers.get('www-authenticate')).toContain('resource_metadata_uri=');
219
+ expect(body.message).toBe('Bearer token required');
220
+ });
221
+
222
+ it('serves protected resource metadata with Better Auth-backed URLs', async () => {
223
+ const response = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`);
224
+ const body = (await response.json()) as Record<string, unknown>;
225
+
226
+ expect(response.status).toBe(200);
227
+ expect(body).toEqual({
228
+ resource: 'http://localhost:9631/api/mcp',
229
+ authorization_servers: ['http://localhost:9631'],
230
+ bearer_methods_supported: ['header'],
231
+ scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
232
+ jwks_uri: 'http://localhost:9631/api/auth/mcp/jwks',
233
+ resource_signing_alg_values_supported: ['RS256', 'none'],
234
+ });
235
+ });
236
+
237
+ it('serves authorization server metadata with MCP registration endpoint', async () => {
238
+ const response = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);
239
+ const body = (await response.json()) as Record<string, unknown>;
240
+
241
+ expect(response.status).toBe(200);
242
+ expect(body.registration_endpoint).toBe('http://localhost:9631/api/auth/mcp/register');
243
+ expect(body.authorization_endpoint).toBe('http://localhost:9631/api/auth/mcp/authorize');
244
+ expect(body.token_endpoint).toBe('http://localhost:9631/api/auth/mcp/token');
245
+ });
246
+
247
+ it('exposes the advertised registration endpoint', async () => {
248
+ const response = await fetch(`${baseUrl}/api/auth/mcp/register`, {
249
+ method: 'POST',
250
+ headers: {
251
+ 'content-type': 'application/json',
252
+ },
253
+ body: JSON.stringify({
254
+ redirect_uris: ['https://cursor.sh/callback'],
255
+ }),
256
+ });
257
+ const body = (await response.json()) as Record<string, unknown>;
258
+
259
+ expect(response.status).toBe(201);
260
+ expect(body.client_id).toBe('cursor-client');
261
+ });
262
+
263
+ it('accepts access_token query params for SSE fallback', async () => {
264
+ authService.validateMcpToken.mockResolvedValue({
265
+ id: 'user-sse',
266
+ name: 'SSE User',
267
+ email: 'sse@example.com',
268
+ });
269
+
270
+ const response = await fetch(`${baseUrl}/api/mcp/sse?access_token=sse-token`);
271
+ const body = (await response.json()) as Record<string, unknown>;
272
+
273
+ expect(response.status).toBe(200);
274
+ expect(body).toEqual({
275
+ ok: true,
276
+ token: 'sse-token',
277
+ userId: 'user-sse',
278
+ });
279
+ expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');
280
+ });
281
+ });
@@ -0,0 +1,155 @@
1
+ import { createServer } from 'node:http';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { OAuthController } from '../../modules/oauth/oauth.controller.js';
4
+ import { OAuthService } from '../../modules/oauth/oauth.service.js';
5
+
6
+ function createOAuthServiceMock() {
7
+ return {
8
+ discoverAndAuthorize: vi.fn(),
9
+ handleCallback: vi.fn(),
10
+ };
11
+ }
12
+
13
+ describe('OAuth authorize and callback HTTP contract', () => {
14
+ let close: (() => Promise<void>) | undefined;
15
+ let oauthService: ReturnType<typeof createOAuthServiceMock>;
16
+ let baseUrl: string;
17
+
18
+ beforeEach(async () => {
19
+ oauthService = createOAuthServiceMock();
20
+ const controller = new OAuthController(oauthService as unknown as OAuthService);
21
+
22
+ const server = await new Promise<import('node:http').Server>((resolve) => {
23
+ const listeningServer = createServer(async (req, res) => {
24
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
25
+ const request = {
26
+ protocol: 'http',
27
+ query: Object.fromEntries(url.searchParams.entries()),
28
+ get: (headerName: string) => {
29
+ if (headerName.toLowerCase() === 'host') {
30
+ return req.headers.host ?? '';
31
+ }
32
+ return undefined;
33
+ },
34
+ };
35
+ const response = {
36
+ redirect(location: string) {
37
+ res.statusCode = 302;
38
+ res.setHeader('location', location);
39
+ res.end();
40
+ },
41
+ status(statusCode: number) {
42
+ res.statusCode = statusCode;
43
+ return response;
44
+ },
45
+ send(body: string) {
46
+ res.setHeader('content-type', 'text/html; charset=utf-8');
47
+ res.end(body);
48
+ },
49
+ };
50
+
51
+ if (req.method === 'GET' && url.pathname.startsWith('/api/oauth/authorize/')) {
52
+ const serverId = url.pathname.split('/').pop() ?? '';
53
+ await controller.authorize(serverId, request as never, response as never);
54
+ return;
55
+ }
56
+
57
+ if (req.method === 'GET' && url.pathname === '/api/oauth/callback') {
58
+ await controller.callback(
59
+ typeof request.query.code === 'string' ? request.query.code : '',
60
+ typeof request.query.state === 'string' ? request.query.state : '',
61
+ request as never,
62
+ response as never
63
+ );
64
+ return;
65
+ }
66
+
67
+ res.statusCode = 404;
68
+ res.end();
69
+ }).listen(0, () => resolve(listeningServer));
70
+ });
71
+ const address = server.address();
72
+ if (!address || typeof address === 'string') {
73
+ throw new Error('Failed to resolve test server address');
74
+ }
75
+
76
+ close = () =>
77
+ new Promise<void>((resolve, reject) => {
78
+ server.close((error) => {
79
+ if (error) {
80
+ reject(error);
81
+ return;
82
+ }
83
+ resolve();
84
+ });
85
+ });
86
+ baseUrl = `http://127.0.0.1:${address.port}`;
87
+ });
88
+
89
+ afterEach(async () => {
90
+ if (close) {
91
+ await close();
92
+ }
93
+ });
94
+
95
+ it('redirects authorize requests to the discovered authorization URL', async () => {
96
+ oauthService.discoverAndAuthorize.mockResolvedValue('https://auth.example.com/authorize?client_id=abc');
97
+
98
+ const response = await fetch(`${baseUrl}/api/oauth/authorize/server-1`, {
99
+ redirect: 'manual',
100
+ });
101
+
102
+ expect(response.status).toBe(302);
103
+ expect(response.headers.get('location')).toBe('https://auth.example.com/authorize?client_id=abc');
104
+ expect(oauthService.discoverAndAuthorize).toHaveBeenCalledWith(
105
+ 'server-1',
106
+ `${baseUrl}/api/oauth/callback`
107
+ );
108
+ });
109
+
110
+ it('renders an error page when authorize discovery fails', async () => {
111
+ oauthService.discoverAndAuthorize.mockRejectedValue(new Error('Metadata discovery failed'));
112
+
113
+ const response = await fetch(`${baseUrl}/api/oauth/authorize/server-1`);
114
+ const html = await response.text();
115
+
116
+ expect(response.status).toBe(400);
117
+ expect(html).toContain('Error: Metadata discovery failed');
118
+ });
119
+
120
+ it('rejects callbacks without code or state', async () => {
121
+ const response = await fetch(`${baseUrl}/api/oauth/callback`);
122
+ const html = await response.text();
123
+
124
+ expect(response.status).toBe(400);
125
+ expect(html).toContain('Missing code or state parameter');
126
+ });
127
+
128
+ it('renders a success page for valid callbacks', async () => {
129
+ oauthService.handleCallback.mockResolvedValue({ success: true });
130
+
131
+ const response = await fetch(`${baseUrl}/api/oauth/callback?code=auth-code&state=server-1`);
132
+ const html = await response.text();
133
+
134
+ expect(response.status).toBe(200);
135
+ expect(html).toContain('Authorization successful! This window will close.');
136
+ expect(oauthService.handleCallback).toHaveBeenCalledWith(
137
+ 'server-1',
138
+ 'auth-code',
139
+ `${baseUrl}/api/oauth/callback`
140
+ );
141
+ });
142
+
143
+ it('renders a failure page when callback token exchange fails', async () => {
144
+ oauthService.handleCallback.mockResolvedValue({
145
+ success: false,
146
+ error: 'Token exchange failed',
147
+ });
148
+
149
+ const response = await fetch(`${baseUrl}/api/oauth/callback?code=auth-code&state=server-1`);
150
+ const html = await response.text();
151
+
152
+ expect(response.status).toBe(200);
153
+ expect(html).toContain('Error: Token exchange failed');
154
+ });
155
+ });