@dxheroes/local-mcp-backend 0.10.0 → 0.12.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 (110) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +60 -0
  3. package/dist/__tests__/integration/mcp-proxy-auth-http.test.js +38 -1
  4. package/dist/__tests__/integration/mcp-proxy-auth-http.test.js.map +1 -1
  5. package/dist/__tests__/integration/multi-tenant.test.js +75 -111
  6. package/dist/__tests__/integration/multi-tenant.test.js.map +1 -1
  7. package/dist/__tests__/integration/oauth-authorize-callback.test.js.map +1 -1
  8. package/dist/__tests__/integration/proxy-auth.test.js +51 -0
  9. package/dist/__tests__/integration/proxy-auth.test.js.map +1 -1
  10. package/dist/__tests__/integration/sharing-credentials.test.js +79 -56
  11. package/dist/__tests__/integration/sharing-credentials.test.js.map +1 -1
  12. package/dist/__tests__/unit/mcp-registry.test.js +187 -0
  13. package/dist/__tests__/unit/mcp-registry.test.js.map +1 -0
  14. package/dist/__tests__/unit/mcp.service.test.js +1448 -82
  15. package/dist/__tests__/unit/mcp.service.test.js.map +1 -1
  16. package/dist/__tests__/unit/oauth.service.test.js +282 -0
  17. package/dist/__tests__/unit/oauth.service.test.js.map +1 -0
  18. package/dist/__tests__/unit/profiles.service.test.js +1175 -0
  19. package/dist/__tests__/unit/profiles.service.test.js.map +1 -0
  20. package/dist/__tests__/unit/proxy.service.test.js +2049 -0
  21. package/dist/__tests__/unit/proxy.service.test.js.map +1 -0
  22. package/dist/__tests__/unit/settings.service.test.js +214 -0
  23. package/dist/__tests__/unit/settings.service.test.js.map +1 -0
  24. package/dist/__tests__/unit/sharing.service.test.js +878 -0
  25. package/dist/__tests__/unit/sharing.service.test.js.map +1 -1
  26. package/dist/app.module.js +2 -0
  27. package/dist/app.module.js.map +1 -1
  28. package/dist/main.js +29 -2
  29. package/dist/main.js.map +1 -1
  30. package/dist/modules/auth/auth.config.js +114 -31
  31. package/dist/modules/auth/auth.config.js.map +1 -1
  32. package/dist/modules/auth/mcp-oauth.guard.js +32 -7
  33. package/dist/modules/auth/mcp-oauth.guard.js.map +1 -1
  34. package/dist/modules/database/prisma.service.js +0 -43
  35. package/dist/modules/database/prisma.service.js.map +1 -1
  36. package/dist/modules/health/health.controller.js +1 -1
  37. package/dist/modules/health/health.controller.js.map +1 -1
  38. package/dist/modules/mcp/mcp-presets.js +33 -40
  39. package/dist/modules/mcp/mcp-presets.js.map +1 -1
  40. package/dist/modules/mcp/mcp.controller.js +39 -28
  41. package/dist/modules/mcp/mcp.controller.js.map +1 -1
  42. package/dist/modules/mcp/mcp.module.js +8 -10
  43. package/dist/modules/mcp/mcp.module.js.map +1 -1
  44. package/dist/modules/mcp/mcp.service.js +217 -59
  45. package/dist/modules/mcp/mcp.service.js.map +1 -1
  46. package/dist/modules/oauth/oauth.service.js +19 -9
  47. package/dist/modules/oauth/oauth.service.js.map +1 -1
  48. package/dist/modules/organization-domains/blacklisted-domains.js +83 -0
  49. package/dist/modules/organization-domains/blacklisted-domains.js.map +1 -0
  50. package/dist/modules/organization-domains/organization-domains.controller.js +82 -0
  51. package/dist/modules/organization-domains/organization-domains.controller.js.map +1 -0
  52. package/dist/modules/organization-domains/organization-domains.module.js +27 -0
  53. package/dist/modules/organization-domains/organization-domains.module.js.map +1 -0
  54. package/dist/modules/organization-domains/organization-domains.service.js +83 -0
  55. package/dist/modules/organization-domains/organization-domains.service.js.map +1 -0
  56. package/dist/modules/profiles/profiles.module.js +3 -1
  57. package/dist/modules/profiles/profiles.module.js.map +1 -1
  58. package/dist/modules/profiles/profiles.service.js +86 -107
  59. package/dist/modules/profiles/profiles.service.js.map +1 -1
  60. package/dist/modules/proxy/proxy.service.js +51 -48
  61. package/dist/modules/proxy/proxy.service.js.map +1 -1
  62. package/dist/modules/settings/settings.constants.js +1 -2
  63. package/dist/modules/settings/settings.constants.js.map +1 -1
  64. package/dist/modules/settings/settings.controller.js +11 -4
  65. package/dist/modules/settings/settings.controller.js.map +1 -1
  66. package/dist/modules/settings/settings.service.js +9 -6
  67. package/dist/modules/settings/settings.service.js.map +1 -1
  68. package/dist/modules/sharing/sharing.controller.js +20 -0
  69. package/dist/modules/sharing/sharing.controller.js.map +1 -1
  70. package/dist/modules/sharing/sharing.service.js +166 -4
  71. package/dist/modules/sharing/sharing.service.js.map +1 -1
  72. package/package.json +10 -7
  73. package/src/__tests__/integration/mcp-proxy-auth-http.test.ts +36 -4
  74. package/src/__tests__/integration/multi-tenant.test.ts +74 -137
  75. package/src/__tests__/integration/oauth-authorize-callback.test.ts +6 -2
  76. package/src/__tests__/integration/proxy-auth.test.ts +37 -5
  77. package/src/__tests__/integration/sharing-credentials.test.ts +68 -55
  78. package/src/__tests__/unit/mcp-registry.test.ts +231 -0
  79. package/src/__tests__/unit/mcp.service.test.ts +1514 -86
  80. package/src/__tests__/unit/oauth.service.test.ts +320 -0
  81. package/src/__tests__/unit/profiles.service.test.ts +1168 -0
  82. package/src/__tests__/unit/proxy.service.test.ts +1932 -0
  83. package/src/__tests__/unit/settings.service.test.ts +234 -0
  84. package/src/__tests__/unit/sharing.service.test.ts +749 -0
  85. package/src/app.module.ts +2 -0
  86. package/src/main.ts +29 -2
  87. package/src/modules/auth/auth.config.ts +110 -32
  88. package/src/modules/auth/mcp-oauth.guard.ts +35 -8
  89. package/src/modules/database/prisma.service.ts +0 -49
  90. package/src/modules/health/health.controller.ts +1 -1
  91. package/src/modules/mcp/mcp-presets.ts +40 -28
  92. package/src/modules/mcp/mcp.controller.ts +33 -24
  93. package/src/modules/mcp/mcp.module.ts +7 -9
  94. package/src/modules/mcp/mcp.service.ts +276 -71
  95. package/src/modules/oauth/oauth.service.ts +27 -9
  96. package/src/modules/organization-domains/blacklisted-domains.ts +90 -0
  97. package/src/modules/organization-domains/organization-domains.controller.ts +41 -0
  98. package/src/modules/organization-domains/organization-domains.module.ts +15 -0
  99. package/src/modules/organization-domains/organization-domains.service.ts +60 -0
  100. package/src/modules/profiles/profiles.module.ts +2 -1
  101. package/src/modules/profiles/profiles.service.ts +82 -96
  102. package/src/modules/proxy/proxy.service.ts +46 -52
  103. package/src/modules/settings/settings.constants.ts +0 -1
  104. package/src/modules/settings/settings.controller.ts +11 -3
  105. package/src/modules/settings/settings.service.ts +12 -4
  106. package/src/modules/sharing/sharing.controller.ts +11 -0
  107. package/src/modules/sharing/sharing.service.ts +169 -5
  108. package/dist/modules/mcp/mcp-seed.service.js +0 -84
  109. package/dist/modules/mcp/mcp-seed.service.js.map +0 -1
  110. package/src/modules/mcp/mcp-seed.service.ts +0 -84
@@ -1,9 +1,9 @@
1
1
 
2
- > @dxheroes/local-mcp-backend@0.10.0 build /home/runner/work/local-mcp-gateway/local-mcp-gateway/apps/backend
2
+ > @dxheroes/local-mcp-backend@0.12.0 build /home/runner/work/local-mcp-gateway/local-mcp-gateway/apps/backend
3
3
  > nest build
4
4
 
5
5
  -  TSC  Initializing type checker...
6
6
  ✔  TSC  Initializing type checker...
7
7
  >  TSC  Found 0 issues.
8
8
  >  SWC  Running...
9
- Successfully compiled: 60 files with swc (100.08ms)
9
+ Successfully compiled: 68 files with swc (106.21ms)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0](https://github.com/DXHeroes/local-mcp-gateway/compare/backend-v0.11.0...backend-v0.12.0) (2026-03-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * **auth:** improve user organization handling during signup ([8447d08](https://github.com/DXHeroes/local-mcp-gateway/commit/8447d08e55274973e33d97fcc3b66a269b96be51))
9
+ * **docs:** enhance documentation and user onboarding experience ([27adcf5](https://github.com/DXHeroes/local-mcp-gateway/commit/27adcf58b55926406fdc2a926a61ccfb92fad798))
10
+ * **invitation:** implement organization invitation acceptance flow ([c3731c2](https://github.com/DXHeroes/local-mcp-gateway/commit/c3731c2e0ced853fa37868b9f560edb4bf0cff2f))
11
+ * **mcp:** enhance MCP presets and server configuration ([d5ce8f3](https://github.com/DXHeroes/local-mcp-gateway/commit/d5ce8f3adb9513c03df2e97c2ba1236926350387))
12
+ * **mcp:** enrich server creation with metadata and enhance documentation ([fb3d901](https://github.com/DXHeroes/local-mcp-gateway/commit/fb3d901d4ab97308732dbdb15218ec9968cf56c9))
13
+ * **mcp:** streamline MCP presets and enhance server configuration ([9eb1f2c](https://github.com/DXHeroes/local-mcp-gateway/commit/9eb1f2c17ae8dea5d7d8035474adcfde9da56474))
14
+ * **organization-domains:** add auto-join domain management for organizations ([2e5071d](https://github.com/DXHeroes/local-mcp-gateway/commit/2e5071d82811725df5ef1729cc2f657d11fade14))
15
+ * **organization-domains:** implement blacklisted domains for auto-join prevention ([5226d48](https://github.com/DXHeroes/local-mcp-gateway/commit/5226d481b94b6fd192cfe46e385143b0f642a4d5))
16
+ * **tests:** enhance integration tests for MCP services and sharing functionality ([d4550ba](https://github.com/DXHeroes/local-mcp-gateway/commit/d4550baefb3e623500e444cb6cbcfd911b88fefb))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **oauth:** standardize token type casing and enhance error logging ([47ef48b](https://github.com/DXHeroes/local-mcp-gateway/commit/47ef48ba45be779b059bf32faa062af2cea3eee4))
22
+
23
+
24
+ ### Dependencies
25
+
26
+ * The following workspace dependencies were updated
27
+ * dependencies
28
+ * @dxheroes/local-mcp-core bumped to 0.9.0
29
+ * @dxheroes/local-mcp-database bumped to 0.6.0
30
+ * @dxheroes/mcp-abra-flexi bumped to 0.4.0
31
+ * @dxheroes/mcp-fakturoid bumped to 0.4.0
32
+ * @dxheroes/mcp-gemini-deep-research bumped to 0.5.8
33
+ * @dxheroes/mcp-merk bumped to 0.3.8
34
+ * @dxheroes/mcp-toggl bumped to 0.3.8
35
+ * devDependencies
36
+ * @dxheroes/local-mcp-config bumped to 0.4.13
37
+
38
+ ## [0.11.0](https://github.com/DXHeroes/local-mcp-gateway/compare/backend-v0.10.0...backend-v0.11.0) (2026-03-16)
39
+
40
+
41
+ ### Features
42
+
43
+ * **auth:** enable toggle for email and password authentication ([a0c1dd0](https://github.com/DXHeroes/local-mcp-gateway/commit/a0c1dd0a192ffd4199af2bb00d9e83f1c526be5c))
44
+ * **auth:** enhance MCP OAuth guard to support session cookies ([cc2f0d0](https://github.com/DXHeroes/local-mcp-gateway/commit/cc2f0d092150710691e8258c2fa3d891575e5a76))
45
+ * **dependencies:** add new MCP packages to backend dependencies ([a29fdf4](https://github.com/DXHeroes/local-mcp-gateway/commit/a29fdf4c4af4db0b4df31a9b8a7d7f0fec5a5755))
46
+ * **seo:** enhance SEO and session handling across frontend and backend ([2178043](https://github.com/DXHeroes/local-mcp-gateway/commit/21780436a9e93da63e3c6cec8318b4f8d1a6dbf6))
47
+
48
+
49
+ ### Dependencies
50
+
51
+ * The following workspace dependencies were updated
52
+ * dependencies
53
+ * @dxheroes/local-mcp-core bumped to 0.8.1
54
+ * @dxheroes/local-mcp-database bumped to 0.5.4
55
+ * @dxheroes/mcp-abra-flexi bumped to 0.3.3
56
+ * @dxheroes/mcp-fakturoid bumped to 0.3.3
57
+ * @dxheroes/mcp-gemini-deep-research bumped to 0.5.7
58
+ * @dxheroes/mcp-merk bumped to 0.3.7
59
+ * @dxheroes/mcp-toggl bumped to 0.3.7
60
+ * devDependencies
61
+ * @dxheroes/local-mcp-config bumped to 0.4.12
62
+
3
63
  ## [0.10.0](https://github.com/DXHeroes/local-mcp-gateway/compare/backend-v0.9.2...backend-v0.10.0) (2026-03-12)
4
64
 
5
65
 
@@ -10,7 +10,8 @@ function createConfigService(values) {
10
10
  }
11
11
  function createAuthServiceMock() {
12
12
  return {
13
- validateMcpToken: vi.fn().mockResolvedValue(null)
13
+ validateMcpToken: vi.fn().mockResolvedValue(null),
14
+ getSession: vi.fn().mockResolvedValue(null)
14
15
  };
15
16
  }
16
17
  async function startTestApp(options) {
@@ -241,6 +242,42 @@ describe('MCP proxy auth HTTP contract', ()=>{
241
242
  });
242
243
  expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');
243
244
  });
245
+ it('accepts session cookie when no Bearer token is present', async ()=>{
246
+ authService.getSession.mockResolvedValue({
247
+ user: {
248
+ id: 'cookie-user',
249
+ name: 'Cookie User',
250
+ email: 'cookie@example.com'
251
+ },
252
+ session: {
253
+ id: 'sess-1',
254
+ userId: 'cookie-user'
255
+ }
256
+ });
257
+ const response = await fetch(`${baseUrl}/api/mcp/test`, {
258
+ headers: {
259
+ cookie: 'better-auth.session_token=valid-session'
260
+ }
261
+ });
262
+ const body = await response.json();
263
+ expect(response.status).toBe(200);
264
+ expect(body).toEqual({
265
+ ok: true,
266
+ userId: 'cookie-user'
267
+ });
268
+ expect(authService.getSession).toHaveBeenCalled();
269
+ });
270
+ it('returns 401 when session cookie is invalid and no Bearer token', async ()=>{
271
+ authService.getSession.mockResolvedValue(null);
272
+ const response = await fetch(`${baseUrl}/api/mcp/test`, {
273
+ headers: {
274
+ cookie: 'better-auth.session_token=expired'
275
+ }
276
+ });
277
+ const body = await response.json();
278
+ expect(response.status).toBe(401);
279
+ expect(body.message).toBe('Bearer token required');
280
+ });
244
281
  });
245
282
 
246
283
  //# sourceMappingURL=mcp-proxy-auth-http.test.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/__tests__/integration/mcp-proxy-auth-http.test.ts"],"sourcesContent":["import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';\nimport type { ConfigService } from '@nestjs/config';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { AllExceptionsFilter } from '../../common/filters/all-exceptions.filter.js';\nimport { AuthService, type AuthUser } from '../../modules/auth/auth.service.js';\nimport { McpOAuthGuard } from '../../modules/auth/mcp-oauth.guard.js';\nimport {\n createMcpProtectedResourceMetadata,\n resolvePublicAuthBaseUrl,\n resolvePublicBackendOrigin,\n} from '../../modules/auth/mcp-oauth.utils.js';\n\ntype MockAuthService = {\n validateMcpToken: ReturnType<typeof vi.fn>;\n};\n\ntype MockConfigService = {\n get: ReturnType<typeof vi.fn>;\n};\n\nfunction createConfigService(values: Record<string, unknown>): MockConfigService {\n return {\n get: vi.fn((key: string) => values[key]),\n };\n}\n\nfunction createAuthServiceMock(): MockAuthService {\n return {\n validateMcpToken: vi.fn().mockResolvedValue(null),\n };\n}\n\nasync function startTestApp(options?: {\n backendUrl?: string;\n}): Promise<{\n close: () => Promise<void>;\n authService: MockAuthService;\n baseUrl: string;\n}> {\n const authService = createAuthServiceMock();\n const configService = createConfigService({\n 'app.port': 3001,\n BETTER_AUTH_URL: options?.backendUrl ?? 'http://localhost:3001',\n });\n const guard = new McpOAuthGuard(authService as unknown as AuthService, configService as never);\n const filter = new AllExceptionsFilter();\n const backendOrigin = resolvePublicBackendOrigin(configService as unknown as ConfigService);\n const authBaseUrl = resolvePublicAuthBaseUrl(configService as unknown as ConfigService);\n\n const createRequest = (req: IncomingMessage) => {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);\n return {\n method: req.method ?? 'GET',\n url: `${url.pathname}${url.search}`,\n headers: req.headers,\n query: Object.fromEntries(url.searchParams.entries()),\n user: undefined,\n };\n };\n\n const createResponse = (res: ServerResponse) => {\n const response = {\n setHeader(name: string, value: string) {\n res.setHeader(name, value);\n return response;\n },\n status(statusCode: number) {\n res.statusCode = statusCode;\n return response;\n },\n json(body: unknown) {\n res.setHeader('content-type', 'application/json');\n res.end(JSON.stringify(body));\n },\n };\n\n return response;\n };\n\n const handleGuardedRoute = async (\n nodeRequest: IncomingMessage,\n nodeResponse: ServerResponse,\n responseBody: (request: ReturnType<typeof createRequest>) => Record<string, unknown>\n ) => {\n const request = createRequest(nodeRequest);\n const response = createResponse(nodeResponse);\n const context = {\n switchToHttp: () => ({\n getRequest: () => request,\n getResponse: () => response,\n }),\n } as never;\n\n const host = {\n switchToHttp: () => ({\n getRequest: () => request,\n getResponse: () => response,\n }),\n } as never;\n\n try {\n await guard.canActivate(context);\n response.json(responseBody(request));\n } catch (error) {\n filter.catch(error, host);\n }\n };\n\n const server = await new Promise<import('node:http').Server>((resolve) => {\n const listeningServer = createServer(async (req, res) => {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);\n\n if (req.method === 'GET' && url.pathname === '/api/mcp/test') {\n await handleGuardedRoute(req, res, (request) => ({\n ok: true,\n userId: (request as { user?: AuthUser }).user?.id ?? null,\n }));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/api/mcp/sse') {\n await handleGuardedRoute(req, res, (request) => ({\n ok: true,\n token: request.query.access_token ?? null,\n userId: (request as { user?: AuthUser }).user?.id ?? null,\n }));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') {\n res.setHeader('content-type', 'application/json');\n res.end(JSON.stringify(createMcpProtectedResourceMetadata(configService as unknown as ConfigService)));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify({\n issuer: backendOrigin,\n authorization_endpoint: `${authBaseUrl}/mcp/authorize`,\n token_endpoint: `${authBaseUrl}/mcp/token`,\n registration_endpoint: `${authBaseUrl}/mcp/register`,\n jwks_uri: `${authBaseUrl}/mcp/jwks`,\n response_types_supported: ['code'],\n grant_types_supported: ['authorization_code', 'refresh_token'],\n code_challenge_methods_supported: ['S256'],\n })\n );\n return;\n }\n\n if (req.method === 'POST' && url.pathname === '/api/auth/mcp/register') {\n res.statusCode = 201;\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify({\n client_id: 'cursor-client',\n client_secret: 'cursor-secret',\n redirect_uris: ['https://cursor.sh/callback'],\n })\n );\n return;\n }\n\n res.statusCode = 404;\n res.end();\n }).listen(0, () => resolve(listeningServer));\n });\n const address = server.address();\n if (!address || typeof address === 'string') {\n throw new Error('Failed to resolve test server address');\n }\n\n return {\n close: () =>\n new Promise<void>((resolve, reject) => {\n server.close((error) => {\n if (error) {\n reject(error);\n return;\n }\n resolve();\n });\n }),\n authService,\n baseUrl: `http://127.0.0.1:${address.port}`,\n };\n}\n\ndescribe('MCP proxy auth HTTP contract', () => {\n let close: (() => Promise<void>) | undefined;\n let authService: MockAuthService;\n let baseUrl: string;\n\n beforeEach(async () => {\n const result = await startTestApp({\n backendUrl: 'http://localhost:9631',\n });\n close = result.close;\n authService = result.authService;\n baseUrl = result.baseUrl;\n });\n\n afterEach(async () => {\n if (close) {\n await close();\n }\n });\n\n it('returns 401 with MCP discovery headers when token is missing', async () => {\n const response = await fetch(`${baseUrl}/api/mcp/test`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(401);\n expect(response.headers.get('access-control-expose-headers')).toBe('WWW-Authenticate');\n expect(response.headers.get('www-authenticate')).toContain('resource_metadata=');\n expect(response.headers.get('www-authenticate')).toContain('resource_metadata_uri=');\n expect(body.message).toBe('Bearer token required');\n });\n\n it('serves protected resource metadata with Better Auth-backed URLs', async () => {\n const response = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({\n resource: 'http://localhost:9631/api/mcp',\n authorization_servers: ['http://localhost:9631'],\n bearer_methods_supported: ['header'],\n scopes_supported: ['openid', 'profile', 'email', 'offline_access'],\n jwks_uri: 'http://localhost:9631/api/auth/mcp/jwks',\n resource_signing_alg_values_supported: ['RS256', 'none'],\n });\n });\n\n it('serves authorization server metadata with MCP registration endpoint', async () => {\n const response = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body.registration_endpoint).toBe('http://localhost:9631/api/auth/mcp/register');\n expect(body.authorization_endpoint).toBe('http://localhost:9631/api/auth/mcp/authorize');\n expect(body.token_endpoint).toBe('http://localhost:9631/api/auth/mcp/token');\n });\n\n it('exposes the advertised registration endpoint', async () => {\n const response = await fetch(`${baseUrl}/api/auth/mcp/register`, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n redirect_uris: ['https://cursor.sh/callback'],\n }),\n });\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(201);\n expect(body.client_id).toBe('cursor-client');\n });\n\n it('accepts access_token query params for SSE fallback', async () => {\n authService.validateMcpToken.mockResolvedValue({\n id: 'user-sse',\n name: 'SSE User',\n email: 'sse@example.com',\n });\n\n const response = await fetch(`${baseUrl}/api/mcp/sse?access_token=sse-token`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({\n ok: true,\n token: 'sse-token',\n userId: 'user-sse',\n });\n expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');\n });\n});\n"],"names":["createServer","afterEach","beforeEach","describe","expect","it","vi","AllExceptionsFilter","McpOAuthGuard","createMcpProtectedResourceMetadata","resolvePublicAuthBaseUrl","resolvePublicBackendOrigin","createConfigService","values","get","fn","key","createAuthServiceMock","validateMcpToken","mockResolvedValue","startTestApp","options","authService","configService","BETTER_AUTH_URL","backendUrl","guard","filter","backendOrigin","authBaseUrl","createRequest","req","url","URL","headers","host","method","pathname","search","query","Object","fromEntries","searchParams","entries","user","undefined","createResponse","res","response","setHeader","name","value","status","statusCode","json","body","end","JSON","stringify","handleGuardedRoute","nodeRequest","nodeResponse","responseBody","request","context","switchToHttp","getRequest","getResponse","canActivate","error","catch","server","Promise","resolve","listeningServer","ok","userId","id","token","access_token","issuer","authorization_endpoint","token_endpoint","registration_endpoint","jwks_uri","response_types_supported","grant_types_supported","code_challenge_methods_supported","client_id","client_secret","redirect_uris","listen","address","Error","close","reject","baseUrl","port","result","fetch","toBe","toContain","message","toEqual","resource","authorization_servers","bearer_methods_supported","scopes_supported","resource_signing_alg_values_supported","email","toHaveBeenCalledWith"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/__tests__/integration/mcp-proxy-auth-http.test.ts"],"sourcesContent":["import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';\nimport type { ConfigService } from '@nestjs/config';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { AllExceptionsFilter } from '../../common/filters/all-exceptions.filter.js';\nimport { AuthService, type AuthUser } from '../../modules/auth/auth.service.js';\nimport { McpOAuthGuard } from '../../modules/auth/mcp-oauth.guard.js';\nimport {\n createMcpProtectedResourceMetadata,\n resolvePublicAuthBaseUrl,\n resolvePublicBackendOrigin,\n} from '../../modules/auth/mcp-oauth.utils.js';\n\ntype MockAuthService = {\n validateMcpToken: ReturnType<typeof vi.fn>;\n getSession: ReturnType<typeof vi.fn>;\n};\n\ntype MockConfigService = {\n get: ReturnType<typeof vi.fn>;\n};\n\nfunction createConfigService(values: Record<string, unknown>): MockConfigService {\n return {\n get: vi.fn((key: string) => values[key]),\n };\n}\n\nfunction createAuthServiceMock(): MockAuthService {\n return {\n validateMcpToken: vi.fn().mockResolvedValue(null),\n getSession: vi.fn().mockResolvedValue(null),\n };\n}\n\nasync function startTestApp(options?: { backendUrl?: string }): Promise<{\n close: () => Promise<void>;\n authService: MockAuthService;\n baseUrl: string;\n}> {\n const authService = createAuthServiceMock();\n const configService = createConfigService({\n 'app.port': 3001,\n BETTER_AUTH_URL: options?.backendUrl ?? 'http://localhost:3001',\n });\n const guard = new McpOAuthGuard(authService as unknown as AuthService, configService as never);\n const filter = new AllExceptionsFilter();\n const backendOrigin = resolvePublicBackendOrigin(configService as unknown as ConfigService);\n const authBaseUrl = resolvePublicAuthBaseUrl(configService as unknown as ConfigService);\n\n const createRequest = (req: IncomingMessage) => {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);\n return {\n method: req.method ?? 'GET',\n url: `${url.pathname}${url.search}`,\n headers: req.headers,\n query: Object.fromEntries(url.searchParams.entries()),\n user: undefined,\n };\n };\n\n const createResponse = (res: ServerResponse) => {\n const response = {\n setHeader(name: string, value: string) {\n res.setHeader(name, value);\n return response;\n },\n status(statusCode: number) {\n res.statusCode = statusCode;\n return response;\n },\n json(body: unknown) {\n res.setHeader('content-type', 'application/json');\n res.end(JSON.stringify(body));\n },\n };\n\n return response;\n };\n\n const handleGuardedRoute = async (\n nodeRequest: IncomingMessage,\n nodeResponse: ServerResponse,\n responseBody: (request: ReturnType<typeof createRequest>) => Record<string, unknown>\n ) => {\n const request = createRequest(nodeRequest);\n const response = createResponse(nodeResponse);\n const context = {\n switchToHttp: () => ({\n getRequest: () => request,\n getResponse: () => response,\n }),\n } as never;\n\n const host = {\n switchToHttp: () => ({\n getRequest: () => request,\n getResponse: () => response,\n }),\n } as never;\n\n try {\n await guard.canActivate(context);\n response.json(responseBody(request));\n } catch (error) {\n filter.catch(error, host);\n }\n };\n\n const server = await new Promise<import('node:http').Server>((resolve) => {\n const listeningServer = createServer(async (req, res) => {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);\n\n if (req.method === 'GET' && url.pathname === '/api/mcp/test') {\n await handleGuardedRoute(req, res, (request) => ({\n ok: true,\n userId: (request as { user?: AuthUser }).user?.id ?? null,\n }));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/api/mcp/sse') {\n await handleGuardedRoute(req, res, (request) => ({\n ok: true,\n token: request.query.access_token ?? null,\n userId: (request as { user?: AuthUser }).user?.id ?? null,\n }));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') {\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify(\n createMcpProtectedResourceMetadata(configService as unknown as ConfigService)\n )\n );\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify({\n issuer: backendOrigin,\n authorization_endpoint: `${authBaseUrl}/mcp/authorize`,\n token_endpoint: `${authBaseUrl}/mcp/token`,\n registration_endpoint: `${authBaseUrl}/mcp/register`,\n jwks_uri: `${authBaseUrl}/mcp/jwks`,\n response_types_supported: ['code'],\n grant_types_supported: ['authorization_code', 'refresh_token'],\n code_challenge_methods_supported: ['S256'],\n })\n );\n return;\n }\n\n if (req.method === 'POST' && url.pathname === '/api/auth/mcp/register') {\n res.statusCode = 201;\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify({\n client_id: 'cursor-client',\n client_secret: 'cursor-secret',\n redirect_uris: ['https://cursor.sh/callback'],\n })\n );\n return;\n }\n\n res.statusCode = 404;\n res.end();\n }).listen(0, () => resolve(listeningServer));\n });\n const address = server.address();\n if (!address || typeof address === 'string') {\n throw new Error('Failed to resolve test server address');\n }\n\n return {\n close: () =>\n new Promise<void>((resolve, reject) => {\n server.close((error) => {\n if (error) {\n reject(error);\n return;\n }\n resolve();\n });\n }),\n authService,\n baseUrl: `http://127.0.0.1:${address.port}`,\n };\n}\n\ndescribe('MCP proxy auth HTTP contract', () => {\n let close: (() => Promise<void>) | undefined;\n let authService: MockAuthService;\n let baseUrl: string;\n\n beforeEach(async () => {\n const result = await startTestApp({\n backendUrl: 'http://localhost:9631',\n });\n close = result.close;\n authService = result.authService;\n baseUrl = result.baseUrl;\n });\n\n afterEach(async () => {\n if (close) {\n await close();\n }\n });\n\n it('returns 401 with MCP discovery headers when token is missing', async () => {\n const response = await fetch(`${baseUrl}/api/mcp/test`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(401);\n expect(response.headers.get('access-control-expose-headers')).toBe('WWW-Authenticate');\n expect(response.headers.get('www-authenticate')).toContain('resource_metadata=');\n expect(response.headers.get('www-authenticate')).toContain('resource_metadata_uri=');\n expect(body.message).toBe('Bearer token required');\n });\n\n it('serves protected resource metadata with Better Auth-backed URLs', async () => {\n const response = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({\n resource: 'http://localhost:9631/api/mcp',\n authorization_servers: ['http://localhost:9631'],\n bearer_methods_supported: ['header'],\n scopes_supported: ['openid', 'profile', 'email', 'offline_access'],\n jwks_uri: 'http://localhost:9631/api/auth/mcp/jwks',\n resource_signing_alg_values_supported: ['RS256', 'none'],\n });\n });\n\n it('serves authorization server metadata with MCP registration endpoint', async () => {\n const response = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body.registration_endpoint).toBe('http://localhost:9631/api/auth/mcp/register');\n expect(body.authorization_endpoint).toBe('http://localhost:9631/api/auth/mcp/authorize');\n expect(body.token_endpoint).toBe('http://localhost:9631/api/auth/mcp/token');\n });\n\n it('exposes the advertised registration endpoint', async () => {\n const response = await fetch(`${baseUrl}/api/auth/mcp/register`, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n redirect_uris: ['https://cursor.sh/callback'],\n }),\n });\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(201);\n expect(body.client_id).toBe('cursor-client');\n });\n\n it('accepts access_token query params for SSE fallback', async () => {\n authService.validateMcpToken.mockResolvedValue({\n id: 'user-sse',\n name: 'SSE User',\n email: 'sse@example.com',\n });\n\n const response = await fetch(`${baseUrl}/api/mcp/sse?access_token=sse-token`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({\n ok: true,\n token: 'sse-token',\n userId: 'user-sse',\n });\n expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');\n });\n\n it('accepts session cookie when no Bearer token is present', async () => {\n authService.getSession.mockResolvedValue({\n user: { id: 'cookie-user', name: 'Cookie User', email: 'cookie@example.com' },\n session: { id: 'sess-1', userId: 'cookie-user' },\n });\n\n const response = await fetch(`${baseUrl}/api/mcp/test`, {\n headers: { cookie: 'better-auth.session_token=valid-session' },\n });\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({ ok: true, userId: 'cookie-user' });\n expect(authService.getSession).toHaveBeenCalled();\n });\n\n it('returns 401 when session cookie is invalid and no Bearer token', async () => {\n authService.getSession.mockResolvedValue(null);\n\n const response = await fetch(`${baseUrl}/api/mcp/test`, {\n headers: { cookie: 'better-auth.session_token=expired' },\n });\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(401);\n expect(body.message).toBe('Bearer token required');\n });\n});\n"],"names":["createServer","afterEach","beforeEach","describe","expect","it","vi","AllExceptionsFilter","McpOAuthGuard","createMcpProtectedResourceMetadata","resolvePublicAuthBaseUrl","resolvePublicBackendOrigin","createConfigService","values","get","fn","key","createAuthServiceMock","validateMcpToken","mockResolvedValue","getSession","startTestApp","options","authService","configService","BETTER_AUTH_URL","backendUrl","guard","filter","backendOrigin","authBaseUrl","createRequest","req","url","URL","headers","host","method","pathname","search","query","Object","fromEntries","searchParams","entries","user","undefined","createResponse","res","response","setHeader","name","value","status","statusCode","json","body","end","JSON","stringify","handleGuardedRoute","nodeRequest","nodeResponse","responseBody","request","context","switchToHttp","getRequest","getResponse","canActivate","error","catch","server","Promise","resolve","listeningServer","ok","userId","id","token","access_token","issuer","authorization_endpoint","token_endpoint","registration_endpoint","jwks_uri","response_types_supported","grant_types_supported","code_challenge_methods_supported","client_id","client_secret","redirect_uris","listen","address","Error","close","reject","baseUrl","port","result","fetch","toBe","toContain","message","toEqual","resource","authorization_servers","bearer_methods_supported","scopes_supported","resource_signing_alg_values_supported","email","toHaveBeenCalledWith","session","cookie","toHaveBeenCalled"],"mappings":"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"}
@@ -1,19 +1,17 @@
1
1
  /**
2
- * Integration Tests: Multi-tenant data isolation
2
+ * Integration Tests: Per-user MCP isolation with sharing
3
3
  *
4
- * Verifies that ProfilesService and McpService correctly isolate data
5
- * between organizations while allowing access to system records.
4
+ * Verifies that:
5
+ * - McpService returns only servers owned by the user + shared servers
6
+ * - ProfilesService org-scopes profiles (per-user, no system profiles)
7
+ * - Cross-user access is denied unless shared
6
8
  */ import { ForbiddenException } from "@nestjs/common";
7
9
  import { beforeEach, describe, expect, it, vi } from "vitest";
8
- /** Sentinel value for unauthenticated MCP access */ const UNAUTHENTICATED_ID = '__unauthenticated__';
9
10
  import { McpService } from "../../modules/mcp/mcp.service.js";
10
11
  import { McpRegistry } from "../../modules/mcp/mcp-registry.js";
11
12
  import { ProfilesService } from "../../modules/profiles/profiles.service.js";
12
13
  const PROFILES = [];
13
14
  const SERVERS = [];
14
- // ────────────────────────────────────────────────
15
- // Helper: build a mock PrismaService over in-memory data
16
- // ────────────────────────────────────────────────
17
15
  function buildMockPrisma() {
18
16
  return {
19
17
  profile: {
@@ -25,8 +23,15 @@ function buildMockPrisma() {
25
23
  if (whereObj?.OR) {
26
24
  const orConds = whereObj.OR;
27
25
  result = result.filter((p)=>orConds.some((cond)=>{
26
+ // Handle combined conditions like { userId, organizationId }
27
+ if ('userId' in cond && 'organizationId' in cond) {
28
+ return p.userId === cond.userId && p.organizationId === cond.organizationId;
29
+ }
28
30
  if ('organizationId' in cond) {
29
- return cond.organizationId === null ? p.organizationId === null : p.organizationId === cond.organizationId;
31
+ return p.organizationId === cond.organizationId;
32
+ }
33
+ if ('userId' in cond) {
34
+ return p.userId === cond.userId;
30
35
  }
31
36
  if ('id' in cond && cond.id?.in) {
32
37
  return cond.id.in.includes(p.id);
@@ -45,8 +50,8 @@ function buildMockPrisma() {
45
50
  id: `profile-${Date.now()}`,
46
51
  name: data.name,
47
52
  description: data.description ?? null,
48
- userId: data.userId ?? null,
49
- organizationId: data.organizationId ?? null,
53
+ userId: data.userId,
54
+ organizationId: data.organizationId,
50
55
  mcpServers: []
51
56
  };
52
57
  PROFILES.push(profile);
@@ -64,8 +69,8 @@ function buildMockPrisma() {
64
69
  if (whereObj?.OR) {
65
70
  const orConds = whereObj.OR;
66
71
  result = result.filter((s)=>orConds.some((cond)=>{
67
- if ('organizationId' in cond) {
68
- return cond.organizationId === null ? s.organizationId === null : s.organizationId === cond.organizationId;
72
+ if ('userId' in cond) {
73
+ return s.userId === cond.userId;
69
74
  }
70
75
  if ('id' in cond && cond.id?.in) {
71
76
  return cond.id.in.includes(s.id);
@@ -87,8 +92,7 @@ function buildMockPrisma() {
87
92
  config: data.config,
88
93
  apiKeyConfig: data.apiKeyConfig ?? null,
89
94
  oauthConfig: data.oauthConfig ?? null,
90
- userId: data.userId ?? null,
91
- organizationId: data.organizationId ?? null,
95
+ userId: data.userId,
92
96
  profiles: [],
93
97
  oauthToken: null,
94
98
  toolsCache: []
@@ -104,31 +108,21 @@ function buildMockPrisma() {
104
108
  },
105
109
  mcpServerToolsCache: {
106
110
  findMany: vi.fn().mockResolvedValue([])
107
- },
108
- gatewaySetting: {
109
- upsert: vi.fn().mockResolvedValue({})
110
111
  }
111
112
  };
112
113
  }
113
114
  // ────────────────────────────────────────────────
114
- // Tests: ProfilesService data isolation
115
+ // Tests: ProfilesService data isolation (org-scoped, per-user)
115
116
  // ────────────────────────────────────────────────
116
117
  describe('Multi-tenant data isolation', ()=>{
117
118
  describe('ProfilesService', ()=>{
118
119
  let profilesService;
119
120
  let prisma;
120
121
  let proxyService;
122
+ let sharingService;
121
123
  beforeEach(()=>{
122
- // Reset in-memory data
123
124
  PROFILES.length = 0;
124
125
  PROFILES.push({
125
- id: 'sys-profile',
126
- name: 'default',
127
- description: 'System default profile',
128
- userId: null,
129
- organizationId: null,
130
- mcpServers: []
131
- }, {
132
126
  id: 'org-a-profile',
133
127
  name: 'org-a-dev',
134
128
  description: 'Org A dev profile',
@@ -147,28 +141,25 @@ describe('Multi-tenant data isolation', ()=>{
147
141
  proxyService = {
148
142
  getToolsForServer: vi.fn().mockResolvedValue([])
149
143
  };
150
- profilesService = new ProfilesService(prisma, proxyService);
144
+ sharingService = {
145
+ getSharedResourceIds: vi.fn().mockResolvedValue([]),
146
+ isSharedWith: vi.fn().mockResolvedValue(false),
147
+ getPermission: vi.fn().mockResolvedValue(null)
148
+ };
149
+ profilesService = new ProfilesService(prisma, proxyService, sharingService);
151
150
  });
152
- it('Org A sees org A profiles + system profiles, not Org B profiles', async ()=>{
151
+ it('Org A sees org A profiles, not Org B profiles', async ()=>{
153
152
  const result = await profilesService.findAll('user-a', 'org-a');
154
153
  const ids = result.map((p)=>p.id);
155
- expect(ids).toContain('sys-profile');
156
154
  expect(ids).toContain('org-a-profile');
157
155
  expect(ids).not.toContain('org-b-profile');
158
156
  });
159
- it('Org B sees org B profiles + system profiles, not Org A profiles', async ()=>{
157
+ it('Org B sees org B profiles, not Org A profiles', async ()=>{
160
158
  const result = await profilesService.findAll('user-b', 'org-b');
161
159
  const ids = result.map((p)=>p.id);
162
- expect(ids).toContain('sys-profile');
163
160
  expect(ids).toContain('org-b-profile');
164
161
  expect(ids).not.toContain('org-a-profile');
165
162
  });
166
- it('system profiles (organizationId=null) are visible to all orgs', async ()=>{
167
- const resultA = await profilesService.findAll('user-a', 'org-a');
168
- const resultB = await profilesService.findAll('user-b', 'org-b');
169
- expect(resultA.some((p)=>p.id === 'sys-profile')).toBe(true);
170
- expect(resultB.some((p)=>p.id === 'sys-profile')).toBe(true);
171
- });
172
163
  it('creating a profile sets userId and organizationId', async ()=>{
173
164
  const created = await profilesService.create({
174
165
  name: 'a-new'
@@ -179,67 +170,37 @@ describe('Multi-tenant data isolation', ()=>{
179
170
  it('Org A cannot access Org B profile by ID', async ()=>{
180
171
  await expect(profilesService.findById('org-b-profile', 'user-a', 'org-a')).rejects.toThrow(ForbiddenException);
181
172
  });
182
- it('Org A can access system profile by ID', async ()=>{
183
- const profile = await profilesService.findById('sys-profile', 'user-a', 'org-a');
184
- expect(profile).toBeDefined();
185
- expect(profile.id).toBe('sys-profile');
186
- });
187
- it('unauthenticated user sees all profiles', async ()=>{
188
- const result = await profilesService.findAll(UNAUTHENTICATED_ID);
189
- // Unauthenticated findAll queries without WHERE filter
190
- expect(prisma.profile.findMany).toHaveBeenCalledWith(expect.objectContaining({
191
- orderBy: {
192
- name: 'asc'
193
- }
194
- }));
195
- // The mock returns all profiles for filterless queries
196
- expect(result.length).toBeGreaterThanOrEqual(1);
197
- });
198
173
  });
199
174
  // ────────────────────────────────────────────────
200
- // Tests: McpService data isolation
175
+ // Tests: McpService per-user isolation
201
176
  // ────────────────────────────────────────────────
202
- describe('McpService', ()=>{
177
+ describe('McpService — per-user isolation', ()=>{
203
178
  let mcpService;
204
179
  let prisma;
205
180
  let registry;
206
181
  let debugService;
182
+ let sharingService;
207
183
  beforeEach(()=>{
208
- // Reset in-memory data
209
184
  SERVERS.length = 0;
210
185
  SERVERS.push({
211
- id: 'sys-server',
212
- name: 'System Builtin',
213
- type: 'builtin',
214
- config: '{"builtinId":"fetch"}',
215
- apiKeyConfig: null,
216
- oauthConfig: null,
217
- userId: null,
218
- organizationId: null,
219
- profiles: [],
220
- oauthToken: null,
221
- toolsCache: []
222
- }, {
223
- id: 'org-a-server',
186
+ id: 'user-a-server',
224
187
  name: 'A Custom',
225
188
  type: 'external',
226
189
  config: '{"command":"node"}',
227
190
  apiKeyConfig: '{"apiKey":"a-key"}',
228
191
  oauthConfig: null,
229
192
  userId: 'user-a',
230
- organizationId: 'org-a',
231
193
  profiles: [],
232
194
  oauthToken: null,
233
195
  toolsCache: []
234
196
  }, {
235
- id: 'org-b-server',
197
+ id: 'user-b-server',
236
198
  name: 'B Custom',
237
199
  type: 'external',
238
200
  config: '{"command":"node"}',
239
201
  apiKeyConfig: '{"apiKey":"b-key"}',
240
202
  oauthConfig: null,
241
203
  userId: 'user-b',
242
- organizationId: 'org-b',
243
204
  profiles: [],
244
205
  oauthToken: null,
245
206
  toolsCache: []
@@ -252,37 +213,47 @@ describe('Multi-tenant data isolation', ()=>{
252
213
  }),
253
214
  updateLog: vi.fn().mockResolvedValue({})
254
215
  };
255
- mcpService = new McpService(prisma, registry, debugService);
216
+ sharingService = {
217
+ getSharedResourceIds: vi.fn().mockResolvedValue([]),
218
+ isSharedWith: vi.fn().mockResolvedValue(false),
219
+ getPermission: vi.fn().mockResolvedValue(null)
220
+ };
221
+ mcpService = new McpService(prisma, registry, debugService, sharingService);
256
222
  });
257
- it('Org A sees org A servers + system servers, not Org B servers', async ()=>{
223
+ it('User A sees only own servers (no shared)', async ()=>{
258
224
  const result = await mcpService.findAll('user-a', 'org-a');
259
225
  const ids = result.map((s)=>s.id);
260
- expect(ids).toContain('sys-server');
261
- expect(ids).toContain('org-a-server');
262
- expect(ids).not.toContain('org-b-server');
226
+ expect(ids).toContain('user-a-server');
227
+ expect(ids).not.toContain('user-b-server');
263
228
  });
264
- it('Org B sees org B servers + system servers, not Org A servers', async ()=>{
265
- const result = await mcpService.findAll('user-b', 'org-b');
229
+ it('User A sees own + shared servers', async ()=>{
230
+ sharingService.getSharedResourceIds.mockResolvedValue([
231
+ 'user-b-server'
232
+ ]);
233
+ const result = await mcpService.findAll('user-a', 'org-a');
266
234
  const ids = result.map((s)=>s.id);
267
- expect(ids).toContain('sys-server');
268
- expect(ids).toContain('org-b-server');
269
- expect(ids).not.toContain('org-a-server');
270
- });
271
- it('builtin/system servers (organizationId=null) are visible to all orgs', async ()=>{
272
- const resultA = await mcpService.findAll('user-a', 'org-a');
273
- const resultB = await mcpService.findAll('user-b', 'org-b');
274
- expect(resultA.some((s)=>s.id === 'sys-server')).toBe(true);
275
- expect(resultB.some((s)=>s.id === 'sys-server')).toBe(true);
235
+ expect(ids).toContain('user-a-server');
236
+ expect(ids).toContain('user-b-server');
276
237
  });
277
- it('Org A cannot access Org B server by ID', async ()=>{
278
- await expect(mcpService.findById('org-b-server', 'user-a', 'org-a')).rejects.toThrow(ForbiddenException);
238
+ it('User A cannot access User B server by ID without sharing', async ()=>{
239
+ sharingService.isSharedWith.mockResolvedValue(false);
240
+ await expect(mcpService.findById('user-b-server', 'user-a')).rejects.toThrow(ForbiddenException);
279
241
  });
280
- it('Org A can access system server by ID', async ()=>{
281
- const server = await mcpService.findById('sys-server', 'user-a', 'org-a');
282
- expect(server).toBeDefined();
283
- expect(server.id).toBe('sys-server');
242
+ it('User A can access User B server when shared', async ()=>{
243
+ prisma.mcpServer.findUnique.mockResolvedValueOnce({
244
+ id: 'user-b-server',
245
+ userId: 'user-b'
246
+ }) // assertAccess
247
+ .mockResolvedValueOnce({
248
+ ...SERVERS[1],
249
+ oauthToken: null,
250
+ toolsCache: []
251
+ }); // findById
252
+ sharingService.isSharedWith.mockResolvedValue(true);
253
+ const result = await mcpService.findById('user-b-server', 'user-a');
254
+ expect(result).toBeDefined();
284
255
  });
285
- it('Creating a server in Org A sets organizationId to org-a', async ()=>{
256
+ it('Creating a server sets userId only (no organizationId)', async ()=>{
286
257
  await mcpService.create({
287
258
  name: 'New',
288
259
  type: 'external',
@@ -292,28 +263,21 @@ describe('Multi-tenant data isolation', ()=>{
292
263
  }, 'user-a', 'org-a');
293
264
  expect(prisma.mcpServer.create).toHaveBeenCalledWith(expect.objectContaining({
294
265
  data: expect.objectContaining({
295
- userId: 'user-a',
296
- organizationId: 'org-a'
266
+ userId: 'user-a'
297
267
  })
298
268
  }));
269
+ const callData = prisma.mcpServer.create.mock.calls[0][0].data;
270
+ expect(callData.organizationId).toBeUndefined();
299
271
  });
300
- it('unauthenticated user sees all servers without filtering', async ()=>{
301
- await mcpService.findAll(UNAUTHENTICATED_ID);
302
- // Anonymous uses the unfiltered findMany path
303
- expect(prisma.mcpServer.findMany).toHaveBeenCalledWith(expect.objectContaining({
304
- orderBy: {
305
- name: 'asc'
306
- }
307
- }));
308
- });
309
- it('Org A cannot delete Org B server', async ()=>{
310
- await expect(mcpService.delete('org-b-server', 'user-a', 'org-a')).rejects.toThrow(ForbiddenException);
272
+ it('User A cannot delete User B server', async ()=>{
273
+ sharingService.getPermission.mockResolvedValue(null);
274
+ await expect(mcpService.delete('user-b-server', 'user-a')).rejects.toThrow(ForbiddenException);
311
275
  });
312
- it('Org A can delete own org server', async ()=>{
313
- await mcpService.delete('org-a-server', 'user-a', 'org-a');
276
+ it('User A can delete own server', async ()=>{
277
+ await mcpService.delete('user-a-server', 'user-a');
314
278
  expect(prisma.mcpServer.delete).toHaveBeenCalledWith({
315
279
  where: {
316
- id: 'org-a-server'
280
+ id: 'user-a-server'
317
281
  }
318
282
  });
319
283
  });