diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..4d27658 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,50 @@ +# MCPHub Coding Instructions + +## Project Overview + +MCPHub is a TypeScript/Node.js MCP server management hub that provides unified access through HTTP endpoints. + +**Core Components:** + +- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`) +- **Frontend**: React/Vite + Tailwind CSS (`frontend/`) +- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`) + +## Development Environment + +```bash +pnpm install +pnpm dev # Start both backend and frontend +pnpm backend:dev # Backend only +pnpm frontend:dev # Frontend only +``` + +## Project Conventions + +### File Structure + +- `src/services/` - Core business logic +- `src/controllers/` - HTTP request handlers +- `src/types/index.ts` - TypeScript type definitions +- `src/config/index.ts` - Configuration management + +### Key Notes + +- Use ESM modules: Import with `.js` extensions, not `.ts` +- Configuration file: `mcp_settings.json` +- Endpoint formats: `/mcp/{group|server}` and `/mcp/$smart` +- All code comments must be written in English +- Frontend uses i18n with resource files in `locales/` folder +- Server-side code should use appropriate abstraction layers for extensibility and replaceability + +## Development Process + +- For complex features, implement step by step and wait for confirmation before proceeding to the next step +- After implementing features, no separate summary documentation is needed - update README.md and README.zh.md as appropriate + +### Development Entry Points + +- **MCP Servers**: Modify `src/services/mcpService.ts` +- **API Endpoints**: Add routes in `src/routes/`, controllers in `src/controllers/` +- **Frontend Features**: Start from `frontend/src/pages/` +- **Testing**: Follow existing patterns in `tests/` diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 829b70a..b3506c1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: samanhappy/mcphub + images: ${{ github.repository }} tags: | type=raw,value=edge${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} type=semver,pattern={{version}}${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 07cc2b7..85caa60 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import LoginPage from './pages/LoginPage'; import DashboardPage from './pages/Dashboard'; import ServersPage from './pages/ServersPage'; import GroupsPage from './pages/GroupsPage'; +import UsersPage from './pages/UsersPage'; import SettingsPage from './pages/SettingsPage'; import MarketPage from './pages/MarketPage'; import LogsPage from './pages/LogsPage'; @@ -31,6 +32,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/PermissionChecker.tsx b/frontend/src/components/PermissionChecker.tsx new file mode 100644 index 0000000..82e5489 --- /dev/null +++ b/frontend/src/components/PermissionChecker.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { useAuth } from '../contexts/AuthContext'; + +interface PermissionCheckerProps { + permissions: string | string[]; + fallback?: React.ReactNode; + children: React.ReactNode; +} + +/** + * Permission checker component for conditional rendering + * @param permissions Required permissions, supports single permission string or permission array + * @param fallback Content to show when permission is denied, defaults to null + * @param children Content to show when permission is granted + */ +export const PermissionChecker: React.FC = ({ + permissions, + fallback = null, + children, +}) => { + const hasPermission = usePermissionCheck(permissions); + + return hasPermission ? <>{children} : <>{fallback}; +}; + +/** + * Permission check hook + * @param requiredPermissions Permissions to check + * @returns Whether user has permission + */ +export const usePermissionCheck = (requiredPermissions: string | string[]): boolean => { + const { auth } = useAuth(); + + if (!auth.isAuthenticated || !auth.user) { + return false; + } + + const userPermissions = auth.user.permissions || []; + + if (requiredPermissions === 'x' && !userPermissions.includes('x')) { + return false; + } + + // If user has '*' permission, they have all permissions + if (userPermissions.includes('*')) { + return true; + } + + // If user is admin, they have all permissions by default + if (auth.user.isAdmin) { + return true; + } + + // Normalize required permissions to array + const permissionsToCheck = Array.isArray(requiredPermissions) + ? requiredPermissions + : [requiredPermissions]; + + // Check if user has any of the required permissions + return permissionsToCheck.some(permission => + userPermissions.includes(permission) + ); +}; + +/** + * Permission check hook - requires all permissions + * @param requiredPermissions Array of permissions to check + * @returns Whether user has all permissions + */ +export const usePermissionCheckAll = (requiredPermissions: string[]): boolean => { + const { auth } = useAuth(); + + if (!auth.isAuthenticated || !auth.user) { + return false; + } + + const userPermissions = auth.user.permissions || []; + + // If user has '*' permission, they have all permissions + if (userPermissions.includes('*')) { + return true; + } + + // If user is admin, they have all permissions by default + if (auth.user.isAdmin) { + return true; + } + + // Check if user has all required permissions + return requiredPermissions.every(permission => + userPermissions.includes(permission) + ); +}; + +export default PermissionChecker; diff --git a/frontend/src/components/ServerForm.tsx b/frontend/src/components/ServerForm.tsx index 2cc5f2b..e46014d 100644 --- a/frontend/src/components/ServerForm.tsx +++ b/frontend/src/components/ServerForm.tsx @@ -624,9 +624,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr {headerVars.map((headerVar, index) => ( @@ -651,9 +651,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr ))} @@ -685,9 +685,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr {headerVars.map((headerVar, index) => ( @@ -712,9 +712,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr ))} @@ -761,9 +761,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr {envVars.map((envVar, index) => ( @@ -788,9 +788,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr ))} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts new file mode 100644 index 0000000..8435ca6 --- /dev/null +++ b/frontend/src/components/index.ts @@ -0,0 +1,42 @@ +// Permission components unified export +export { PermissionChecker, usePermissionCheck, usePermissionCheckAll } from './PermissionChecker'; +export { PERMISSIONS } from '../constants/permissions'; + +// Convenient permission check Hook +export { useAuth } from '../contexts/AuthContext'; + +// Permission utility functions +export const hasPermission = ( + userPermissions: string[] = [], + requiredPermissions: string | string[], +): boolean => { + if (requiredPermissions === 'x' && !userPermissions.includes('x')) { + return false; + } + + // If user has '*' permission, it means they have all permissions + if (userPermissions.includes('*')) { + return true; + } + + // Normalize required permissions to array + const permissionsToCheck = Array.isArray(requiredPermissions) + ? requiredPermissions + : [requiredPermissions]; + + // Check if user has any of the required permissions + return permissionsToCheck.some((permission) => userPermissions.includes(permission)); +}; + +export const hasAllPermissions = ( + userPermissions: string[] = [], + requiredPermissions: string[], +): boolean => { + // If user has '*' permission, it means they have all permissions + if (userPermissions.includes('*')) { + return true; + } + + // Check if user has all required permissions + return requiredPermissions.every((permission) => userPermissions.includes(permission)); +}; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 55e5bd0..9eeab97 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { NavLink } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; +import { usePermissionCheck } from '../PermissionChecker'; import UserProfileMenu from '@/components/ui/UserProfileMenu'; interface SidebarProps { @@ -15,6 +17,7 @@ interface MenuItem { const Sidebar: React.FC = ({ collapsed }) => { const { t } = useTranslation(); + const { auth } = useAuth(); // Application version from package.json (accessed via Vite environment variables) const appVersion = import.meta.env.PACKAGE_VERSION as string; @@ -49,6 +52,15 @@ const Sidebar: React.FC = ({ collapsed }) => { ), }, + ...(auth.user?.isAdmin && usePermissionCheck('x') ? [{ + path: '/users', + label: t('nav.users'), + icon: ( + + + + ), + }] : []), { path: '/market', label: t('nav.market'), diff --git a/frontend/src/components/ui/DeleteDialog.tsx b/frontend/src/components/ui/DeleteDialog.tsx index c4c02ee..9bc2ee3 100644 --- a/frontend/src/components/ui/DeleteDialog.tsx +++ b/frontend/src/components/ui/DeleteDialog.tsx @@ -6,9 +6,10 @@ interface DeleteDialogProps { onConfirm: () => void serverName: string isGroup?: boolean + isUser?: boolean } -const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false }: DeleteDialogProps) => { +const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false, isUser = false }: DeleteDialogProps) => { const { t } = useTranslation() if (!isOpen) return null @@ -18,12 +19,18 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false

- {isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')} + {isUser + ? t('users.confirmDelete') + : isGroup + ? t('groups.confirmDelete') + : t('server.confirmDelete')}

- {isGroup - ? t('groups.deleteWarning', { name: serverName }) - : t('server.deleteWarning', { name: serverName })} + {isUser + ? t('users.deleteWarning', { username: serverName }) + : isGroup + ? t('groups.deleteWarning', { name: serverName }) + : t('server.deleteWarning', { name: serverName })}

-
+
{/* Smart Routing Configuration Settings */} -
-
toggleSection('smartRoutingConfig')} - > -

{t('pages.settings.smartRouting')}

- - {sectionsVisible.smartRoutingConfig ? '▼' : '►'} - -
- - {sectionsVisible.smartRoutingConfig && ( -
-
-
-

{t('settings.enableSmartRouting')}

-

{t('settings.enableSmartRoutingDescription')}

-
- handleSmartRoutingEnabledChange(checked)} - /> -
- -
-
-

- *{t('settings.dbUrl')} -

-
-
- handleSmartRoutingConfigChange('dbUrl', e.target.value)} - placeholder={t('settings.dbUrlPlaceholder')} - className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input" - disabled={loading} - /> - -
-
- -
-
-

- *{t('settings.openaiApiKey')} -

-
-
- handleSmartRoutingConfigChange('openaiApiKey', e.target.value)} - placeholder={t('settings.openaiApiKeyPlaceholder')} - className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300" - disabled={loading} - /> - -
-
- -
-
-

{t('settings.openaiApiBaseUrl')}

-
-
- handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)} - placeholder={t('settings.openaiApiBaseUrlPlaceholder')} - className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" - disabled={loading} - /> - -
-
- -
-
-

{t('settings.openaiApiEmbeddingModel')}

-
-
- handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)} - placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')} - className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" - disabled={loading} - /> - -
-
+ +
+
toggleSection('smartRoutingConfig')} + > +

{t('pages.settings.smartRouting')}

+ + {sectionsVisible.smartRoutingConfig ? '▼' : '►'} +
- )} -
+ + {sectionsVisible.smartRoutingConfig && ( +
+
+
+

{t('settings.enableSmartRouting')}

+

{t('settings.enableSmartRoutingDescription')}

+
+ handleSmartRoutingEnabledChange(checked)} + /> +
+ +
+
+

+ *{t('settings.dbUrl')} +

+
+
+ handleSmartRoutingConfigChange('dbUrl', e.target.value)} + placeholder={t('settings.dbUrlPlaceholder')} + className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input" + disabled={loading} + /> + +
+
+ +
+
+

+ *{t('settings.openaiApiKey')} +

+
+
+ handleSmartRoutingConfigChange('openaiApiKey', e.target.value)} + placeholder={t('settings.openaiApiKeyPlaceholder')} + className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300" + disabled={loading} + /> + +
+
+ +
+
+

{t('settings.openaiApiBaseUrl')}

+
+
+ handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)} + placeholder={t('settings.openaiApiBaseUrlPlaceholder')} + className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" + disabled={loading} + /> + +
+
+ +
+
+

{t('settings.openaiApiEmbeddingModel')}

+
+
+ handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)} + placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')} + className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" + disabled={loading} + /> + +
+
+
+ )} +
+ {/* Route Configuration Settings */}
@@ -430,86 +434,90 @@ const SettingsPage: React.FC = () => { />
-
-
-

{t('settings.skipAuth')}

-

{t('settings.skipAuthDescription')}

+ +
+
+

{t('settings.skipAuth')}

+

{t('settings.skipAuthDescription')}

+
+ handleRoutingConfigChange('skipAuth', checked)} + />
- handleRoutingConfigChange('skipAuth', checked)} - /> -
+
)}
{/* Installation Configuration Settings */} -
-
toggleSection('installConfig')} - > -

{t('settings.installConfig')}

- - {sectionsVisible.installConfig ? '▼' : '►'} - -
- - {sectionsVisible.installConfig && ( -
-
-
-

{t('settings.pythonIndexUrl')}

-

{t('settings.pythonIndexUrlDescription')}

-
-
- handleInstallConfigChange('pythonIndexUrl', e.target.value)} - placeholder={t('settings.pythonIndexUrlPlaceholder')} - className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" - disabled={loading} - /> - -
-
- -
-
-

{t('settings.npmRegistry')}

-

{t('settings.npmRegistryDescription')}

-
-
- handleInstallConfigChange('npmRegistry', e.target.value)} - placeholder={t('settings.npmRegistryPlaceholder')} - className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" - disabled={loading} - /> - -
-
+ +
+
toggleSection('installConfig')} + > +

{t('settings.installConfig')}

+ + {sectionsVisible.installConfig ? '▼' : '►'} +
- )} -
+ + {sectionsVisible.installConfig && ( +
+
+
+

{t('settings.pythonIndexUrl')}

+

{t('settings.pythonIndexUrlDescription')}

+
+
+ handleInstallConfigChange('pythonIndexUrl', e.target.value)} + placeholder={t('settings.pythonIndexUrlPlaceholder')} + className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" + disabled={loading} + /> + +
+
+ +
+
+

{t('settings.npmRegistry')}

+

{t('settings.npmRegistryDescription')}

+
+
+ handleInstallConfigChange('npmRegistry', e.target.value)} + placeholder={t('settings.npmRegistryPlaceholder')} + className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" + disabled={loading} + /> + +
+
+
+ )} +
+ {/* Change Password */}
diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx new file mode 100644 index 0000000..4d8ee0c --- /dev/null +++ b/frontend/src/pages/UsersPage.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const UsersPage: React.FC = () => { + return ( +
+ ); +}; + +export default UsersPage; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5d4c2d9..6b33f06 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -210,6 +210,30 @@ export interface ApiResponse { export interface IUser { username: string; isAdmin?: boolean; + permissions?: string[]; +} + +// User management types +export interface User { + username: string; + isAdmin: boolean; +} + +export interface UserFormData { + username: string; + password: string; + isAdmin: boolean; +} + +export interface UserUpdateData { + isAdmin?: boolean; + newPassword?: string; +} + +export interface UserStats { + totalUsers: number; + adminUsers: number; + regularUsers: number; } export interface AuthState { diff --git a/jest.config.cjs b/jest.config.cjs index edd51f8..301c51b 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -12,6 +12,7 @@ module.exports = { 'ts-jest', { useESM: true, + tsconfig: './tsconfig.test.json', }, ], }, @@ -37,8 +38,10 @@ module.exports = { }, moduleNameMapper: { '^@/(.*)$': '/src/$1', + '^(\\.{1,2}/.*)\\.js$': '$1', }, + transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'], extensionsToTreatAsEsm: ['.ts'], - testTimeout: 10000, + testTimeout: 30000, verbose: true, }; diff --git a/package.json b/package.json index 338cb09..437a245 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,8 @@ "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", "@shadcn/ui": "^0.0.4", + "@swc/core": "^1.13.0", + "@swc/jest": "^0.2.39", "@tailwindcss/postcss": "^4.1.3", "@tailwindcss/vite": "^4.1.7", "@types/bcryptjs": "^3.0.0", @@ -118,5 +120,5 @@ "engines": { "node": "^18.0.0 || >=20.0.0" }, - "packageManager": "pnpm@10.11.0+sha256.a69e9cb077da419d47d18f1dd52e207245b29cac6e076acedbeb8be3b1a67bd7" -} \ No newline at end of file + "packageManager": "pnpm@10.12.4+sha256.cadfd9e6c9fcc2cb76fe7c0779a5250b632898aea5f53d833a73690c77a778d9" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b3d1cb..b36ceb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,7 +70,7 @@ importers: version: 0.2.2 typeorm: specifier: ^0.3.24 - version: 0.3.25(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)) + version: 0.3.25(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)) uuid: specifier: ^11.1.0 version: 11.1.0 @@ -84,6 +84,12 @@ importers: '@shadcn/ui': specifier: ^0.0.4 version: 0.0.4 + '@swc/core': + specifier: ^1.13.0 + version: 1.13.0 + '@swc/jest': + specifier: ^0.2.39 + version: 0.2.39(@swc/core@1.13.0) '@tailwindcss/postcss': specifier: ^4.1.3 version: 4.1.11 @@ -149,13 +155,13 @@ importers: version: 8.2.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)) + version: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)) jest-environment-node: specifier: ^30.0.0 version: 30.0.2 jest-mock-extended: specifier: 4.0.0-beta1 - version: 4.0.0-beta1(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3) + version: 4.0.0-beta1(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3) lucide-react: specifier: ^0.486.0 version: 0.486.0(react@19.1.0) @@ -194,10 +200,10 @@ importers: version: 4.1.11 ts-jest: specifier: ^29.1.1 - version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@30.0.1)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@30.0.2)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@30.0.1)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@30.0.2)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3) ts-node-dev: specifier: ^2.0.0 - version: 2.0.0(@types/node@22.15.34)(typescript@5.8.3) + version: 2.0.0(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3) tsx: specifier: ^4.7.0 version: 4.20.3 @@ -901,6 +907,10 @@ packages: node-notifier: optional: true + '@jest/create-cache-key-function@30.0.2': + resolution: {integrity: sha512-AwlDAHwEHDi+etw9vKWx9HeIApVos8GD/sSTpHtDkqhm9OWuEUPKKPP6EaS17yv0GSzBB3TeeJFLyJ5LPjRqWg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/environment@29.7.0': resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1348,12 +1358,90 @@ packages: '@sqltools/formatter@1.2.5': resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} + '@swc/core-darwin-arm64@1.13.0': + resolution: {integrity: sha512-SkmR9u7MHDu2X8hf7SjZTmsAfQTmel0mi+TJ7AGtufLwGySv6pwQfJ/CIJpcPxYENVqDJAFnDrHaKV8mgA6kxQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.13.0': + resolution: {integrity: sha512-15/SyDjXRtFJ09fYHBXUXrj4tpiSpCkjgsF1z3/sSpHH1POWpQUQzxmFyomPQVZ/SsDqP18WGH09Vph4Qriuiw==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.13.0': + resolution: {integrity: sha512-AHauVHZQEJI/dCZQg6VYNNQ6HROz8dSOnCSheXzzBw1DGWo77BlcxRP0fF0jaAXM9WNqtCUOY1HiJ9ohkAE61Q==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.13.0': + resolution: {integrity: sha512-qyZmBZF7asF6954/x7yn6R7Bzd45KRG05rK2atIF9J3MTa8az7vubP1Q3BWmmss1j8699DELpbuoJucGuhsNXw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.13.0': + resolution: {integrity: sha512-whskQCOUlLQT7MjnronpHmyHegBka5ig9JkQvecbqhWzRfdwN+c2xTJs3kQsWy2Vc2f1hcL3D8hGIwY5TwPxMQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.13.0': + resolution: {integrity: sha512-51n4P4nv6rblXyH3zCEktvmR9uSAZ7+zbfeby0sxbj8LS/IKuVd7iCwD5dwMj4CxG9Fs+HgjN73dLQF/OerHhg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.13.0': + resolution: {integrity: sha512-VMqelgvnXs27eQyhDf1S2O2MxSdchIH7c1tkxODRtu9eotcAeniNNgqqLjZ5ML0MGeRk/WpbsAY/GWi7eSpiHw==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.13.0': + resolution: {integrity: sha512-NLJmseWJngWeENgat+O/WB4ptNxtx2X4OfPnSG5a/A4sxcn2E4jq91OPvbeUQwDkH+ZQWKXmbXFzt7Nn661QYA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.13.0': + resolution: {integrity: sha512-UBfwrp0xW37KQGTA08mwrCLIm1ZKy6pXK8IVwou7BvhMgrItRNweTGyUrCnvDLUfyYFuJCmzcEaJ3NudtctD6g==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.13.0': + resolution: {integrity: sha512-BAB1P7Z/y2EENsfsPytPnjIyBVRZN2WULY+s3ozW4QkGmYHde6XXG28n0ABTHhcIOmmR2VzM+uaW1x48laSimw==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.13.0': + resolution: {integrity: sha512-7Fh16ZH/Rj3Di720if+sw9BictD4N5kbTpsyDC+URXhvsZ7qRt1lH7PaeIQYyJJQHwFhoKpwwGxfGU9SHgPLdw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/jest@0.2.39': + resolution: {integrity: sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA==} + engines: {npm: '>= 7.0.0'} + peerDependencies: + '@swc/core': '*' + + '@swc/types@0.1.23': + resolution: {integrity: sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==} + '@tailwindcss/node@4.1.11': resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==} @@ -2920,6 +3008,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} @@ -4825,7 +4916,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3))': + '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -4839,7 +4930,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)) + jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -4860,6 +4951,10 @@ snapshots: - supports-color - ts-node + '@jest/create-cache-key-function@30.0.2': + dependencies: + '@jest/types': 30.0.1 + '@jest/environment@29.7.0': dependencies: '@jest/fake-timers': 29.7.0 @@ -5320,12 +5415,69 @@ snapshots: '@sqltools/formatter@1.2.5': {} + '@swc/core-darwin-arm64@1.13.0': + optional: true + + '@swc/core-darwin-x64@1.13.0': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.13.0': + optional: true + + '@swc/core-linux-arm64-gnu@1.13.0': + optional: true + + '@swc/core-linux-arm64-musl@1.13.0': + optional: true + + '@swc/core-linux-x64-gnu@1.13.0': + optional: true + + '@swc/core-linux-x64-musl@1.13.0': + optional: true + + '@swc/core-win32-arm64-msvc@1.13.0': + optional: true + + '@swc/core-win32-ia32-msvc@1.13.0': + optional: true + + '@swc/core-win32-x64-msvc@1.13.0': + optional: true + + '@swc/core@1.13.0': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.23 + optionalDependencies: + '@swc/core-darwin-arm64': 1.13.0 + '@swc/core-darwin-x64': 1.13.0 + '@swc/core-linux-arm-gnueabihf': 1.13.0 + '@swc/core-linux-arm64-gnu': 1.13.0 + '@swc/core-linux-arm64-musl': 1.13.0 + '@swc/core-linux-x64-gnu': 1.13.0 + '@swc/core-linux-x64-musl': 1.13.0 + '@swc/core-win32-arm64-msvc': 1.13.0 + '@swc/core-win32-ia32-msvc': 1.13.0 + '@swc/core-win32-x64-msvc': 1.13.0 + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@swc/jest@0.2.39(@swc/core@1.13.0)': + dependencies: + '@jest/create-cache-key-function': 30.0.2 + '@swc/core': 1.13.0 + '@swc/counter': 0.1.3 + jsonc-parser: 3.3.1 + + '@swc/types@0.1.23': + dependencies: + '@swc/counter': 0.1.3 + '@tailwindcss/node@4.1.11': dependencies: '@ampproject/remapping': 2.3.0 @@ -6093,13 +6245,13 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - create-jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)): + create-jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)) + jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -6911,16 +7063,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)): + jest-cli@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)) + create-jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)) + jest-config: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -6930,7 +7082,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)): + jest-config@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)): dependencies: '@babel/core': 7.27.4 '@jest/test-sequencer': 29.7.0 @@ -6956,7 +7108,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.15.34 - ts-node: 10.9.2(@types/node@22.15.34)(typescript@5.8.3) + ts-node: 10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -7053,10 +7205,10 @@ snapshots: slash: 3.0.0 stack-utils: 2.0.6 - jest-mock-extended@4.0.0-beta1(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3): + jest-mock-extended@4.0.0-beta1(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3): dependencies: '@jest/globals': 29.7.0 - jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)) + jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)) ts-essentials: 10.1.0(typescript@5.8.3) typescript: 5.8.3 @@ -7231,12 +7383,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)): + jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)) + jest-cli: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -7270,6 +7422,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@6.1.0: dependencies: universalify: 2.0.1 @@ -8257,12 +8411,12 @@ snapshots: optionalDependencies: typescript: 5.8.3 - ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@30.0.1)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@30.0.2)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3): + ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@30.0.1)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@30.0.2)(jest@29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)))(typescript@5.8.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)) + jest: 29.7.0(@types/node@22.15.34)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -8277,7 +8431,7 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.27.4) jest-util: 30.0.2 - ts-node-dev@2.0.0(@types/node@22.15.34)(typescript@5.8.3): + ts-node-dev@2.0.0(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3): dependencies: chokidar: 3.6.0 dynamic-dedupe: 0.3.0 @@ -8287,7 +8441,7 @@ snapshots: rimraf: 2.7.1 source-map-support: 0.5.21 tree-kill: 1.2.2 - ts-node: 10.9.2(@types/node@22.15.34)(typescript@5.8.3) + ts-node: 10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3) tsconfig: 7.0.0 typescript: 5.8.3 transitivePeerDependencies: @@ -8295,7 +8449,7 @@ snapshots: - '@swc/wasm' - '@types/node' - ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3): + ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -8312,6 +8466,8 @@ snapshots: typescript: 5.8.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.13.0 tsconfig@7.0.0: dependencies: @@ -8354,7 +8510,7 @@ snapshots: typedarray@0.0.6: {} - typeorm@0.3.25(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.15.34)(typescript@5.8.3)): + typeorm@0.3.25(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3)): dependencies: '@sqltools/formatter': 1.2.5 ansis: 3.17.0 @@ -8373,7 +8529,7 @@ snapshots: yargs: 17.7.2 optionalDependencies: pg: 8.16.3 - ts-node: 10.9.2(@types/node@22.15.34)(typescript@5.8.3) + ts-node: 10.9.2(@swc/core@1.13.0)(@types/node@22.15.34)(typescript@5.8.3) transitivePeerDependencies: - babel-plugin-macros - supports-color diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..7a75600 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +ignoredBuiltDependencies: + - '@swc/core' diff --git a/src/config/index.ts b/src/config/index.ts index 5b673b7..affa41f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -3,6 +3,8 @@ import fs from 'fs'; import { McpSettings } from '../types/index.js'; import { getConfigFilePath } from '../utils/path.js'; import { getPackageVersion } from '../utils/version.js'; +import { getDataService } from '../services/services.js'; +import { DataService } from '../services/dataService.js'; dotenv.config(); @@ -15,6 +17,8 @@ const defaultConfig = { mcpHubVersion: getPackageVersion(), }; +const dataService: DataService = getDataService(); + // Settings cache let settingsCache: McpSettings | null = null; @@ -22,7 +26,7 @@ export const getSettingsPath = (): string => { return getConfigFilePath('mcp_settings.json', 'Settings'); }; -export const loadSettings = (): McpSettings => { +export const loadOriginalSettings = (): McpSettings => { // If cache exists, return cached data directly if (settingsCache) { return settingsCache; @@ -49,13 +53,18 @@ export const loadSettings = (): McpSettings => { } }; +export const loadSettings = (): McpSettings => { + return dataService.filterSettings!(loadOriginalSettings()); +}; + export const saveSettings = (settings: McpSettings): boolean => { const settingsPath = getSettingsPath(); try { - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); + const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings); + fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8'); // Update cache after successful save - settingsCache = settings; + settingsCache = mergedSettings; return true; } catch (error) { diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index fc86bf4..9ae6a92 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -1,7 +1,16 @@ import { Request, Response } from 'express'; import jwt from 'jsonwebtoken'; import { validationResult } from 'express-validator'; -import { findUserByUsername, verifyPassword, createUser, updateUserPassword } from '../models/User.js'; +import { + findUserByUsername, + verifyPassword, + createUser, + updateUserPassword, +} from '../models/User.js'; +import { getDataService } from '../services/services.js'; +import { DataService } from '../services/dataService.js'; + +const dataService: DataService = getDataService(); // Default secret key - in production, use an environment variable const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this'; @@ -21,7 +30,7 @@ export const login = async (req: Request, res: Response): Promise => { try { // Find user by username const user = findUserByUsername(username); - + if (!user) { res.status(401).json({ success: false, message: 'Invalid credentials' }); return; @@ -29,7 +38,7 @@ export const login = async (req: Request, res: Response): Promise => { // Verify password const isPasswordValid = await verifyPassword(password, user.password); - + if (!isPasswordValid) { res.status(401).json({ success: false, message: 'Invalid credentials' }); return; @@ -39,26 +48,22 @@ export const login = async (req: Request, res: Response): Promise => { const payload = { user: { username: user.username, - isAdmin: user.isAdmin || false - } + isAdmin: user.isAdmin || false, + }, }; - jwt.sign( - payload, - JWT_SECRET, - { expiresIn: TOKEN_EXPIRY }, - (err, token) => { - if (err) throw err; - res.json({ - success: true, - token, - user: { - username: user.username, - isAdmin: user.isAdmin - } - }); - } - ); + jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => { + if (err) throw err; + res.json({ + success: true, + token, + user: { + username: user.username, + isAdmin: user.isAdmin, + permissions: dataService.getPermissions(user), + }, + }); + }); } catch (error) { console.error('Login error:', error); res.status(500).json({ success: false, message: 'Server error' }); @@ -79,7 +84,7 @@ export const register = async (req: Request, res: Response): Promise => { try { // Create new user const newUser = await createUser({ username, password, isAdmin }); - + if (!newUser) { res.status(400).json({ success: false, message: 'User already exists' }); return; @@ -89,26 +94,22 @@ export const register = async (req: Request, res: Response): Promise => { const payload = { user: { username: newUser.username, - isAdmin: newUser.isAdmin || false - } + isAdmin: newUser.isAdmin || false, + }, }; - jwt.sign( - payload, - JWT_SECRET, - { expiresIn: TOKEN_EXPIRY }, - (err, token) => { - if (err) throw err; - res.json({ - success: true, - token, - user: { - username: newUser.username, - isAdmin: newUser.isAdmin - } - }); - } - ); + jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => { + if (err) throw err; + res.json({ + success: true, + token, + user: { + username: newUser.username, + isAdmin: newUser.isAdmin, + permissions: dataService.getPermissions(newUser), + }, + }); + }); } catch (error) { console.error('Registration error:', error); res.status(500).json({ success: false, message: 'Server error' }); @@ -120,13 +121,14 @@ export const getCurrentUser = (req: Request, res: Response): void => { try { // User is already attached to request by auth middleware const user = (req as any).user; - - res.json({ - success: true, + + res.json({ + success: true, user: { username: user.username, - isAdmin: user.isAdmin - } + isAdmin: user.isAdmin, + permissions: dataService.getPermissions(user), + }, }); } catch (error) { console.error('Get current user error:', error); @@ -149,7 +151,7 @@ export const changePassword = async (req: Request, res: Response): Promise try { // Find user by username const user = findUserByUsername(username); - + if (!user) { res.status(404).json({ success: false, message: 'User not found' }); return; @@ -157,7 +159,7 @@ export const changePassword = async (req: Request, res: Response): Promise // Verify current password const isPasswordValid = await verifyPassword(currentPassword, user.password); - + if (!isPasswordValid) { res.status(401).json({ success: false, message: 'Current password is incorrect' }); return; @@ -165,7 +167,7 @@ export const changePassword = async (req: Request, res: Response): Promise // Update the password const updated = await updateUserPassword(username, newPassword); - + if (!updated) { res.status(500).json({ success: false, message: 'Failed to update password' }); return; @@ -176,4 +178,4 @@ export const changePassword = async (req: Request, res: Response): Promise console.error('Change password error:', error); res.status(500).json({ success: false, message: 'Server error' }); } -}; \ No newline at end of file +}; diff --git a/src/controllers/dxtController.ts b/src/controllers/dxtController.ts index 35c4c14..7261b54 100644 --- a/src/controllers/dxtController.ts +++ b/src/controllers/dxtController.ts @@ -2,18 +2,13 @@ import { Request, Response } from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; -import { fileURLToPath } from 'url'; import AdmZip from 'adm-zip'; import { ApiResponse } from '../types/index.js'; -// Get the directory name in ESM -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - // Configure multer for file uploads const storage = multer.diskStorage({ destination: (req, file, cb) => { - const uploadDir = path.join(__dirname, '../../data/uploads/dxt'); + const uploadDir = path.join(process.cwd(), 'data/uploads/dxt'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } @@ -36,7 +31,7 @@ const upload = multer({ } }, limits: { - fileSize: 100 * 1024 * 1024, // 100MB limit + fileSize: 500 * 1024 * 1024, // 500MB limit }, }); @@ -45,7 +40,7 @@ export const uploadMiddleware = upload.single('dxtFile'); // Clean up old DXT server files when installing a new version const cleanupOldDxtServer = (serverName: string): void => { try { - const uploadDir = path.join(__dirname, '../../data/uploads/dxt'); + const uploadDir = path.join(process.cwd(), 'data/uploads/dxt'); const serverPattern = `server-${serverName}`; if (fs.existsSync(uploadDir)) { diff --git a/src/controllers/groupController.ts b/src/controllers/groupController.ts index 65d68cd..2fe1b1c 100644 --- a/src/controllers/groupController.ts +++ b/src/controllers/groupController.ts @@ -75,7 +75,12 @@ export const createNewGroup = (req: Request, res: Response): void => { } const serverList = Array.isArray(servers) ? servers : []; - const newGroup = createGroup(name, description, serverList); + + // Set owner property - use current user's username, default to 'admin' + const currentUser = (req as any).user; + const owner = currentUser?.username || 'admin'; + + const newGroup = createGroup(name, description, serverList, owner); if (!newGroup) { res.status(400).json({ success: false, diff --git a/src/controllers/serverController.ts b/src/controllers/serverController.ts index d433605..a78cd62 100644 --- a/src/controllers/serverController.ts +++ b/src/controllers/serverController.ts @@ -11,16 +11,18 @@ import { } from '../services/mcpService.js'; import { loadSettings, saveSettings } from '../config/index.js'; import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js'; +import { createSafeJSON } from '../utils/serialization.js'; export const getAllServers = (_: Request, res: Response): void => { try { const serversInfo = getServersInfo(); const response: ApiResponse = { success: true, - data: serversInfo, + data: createSafeJSON(serversInfo), }; res.json(response); } catch (error) { + console.error('Failed to get servers information:', error); res.status(500).json({ success: false, message: 'Failed to get servers information', @@ -33,7 +35,7 @@ export const getAllSettings = (_: Request, res: Response): void => { const settings = loadSettings(); const response: ApiResponse = { success: true, - data: settings, + data: createSafeJSON(settings), }; res.json(response); } catch (error) { @@ -127,6 +129,12 @@ export const createServer = async (req: Request, res: Response): Promise = config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers } + // Set owner property - use current user's username, default to 'admin' + if (!config.owner) { + const currentUser = (req as any).user; + config.owner = currentUser?.username || 'admin'; + } + const result = await addServer(name, config); if (result.success) { notifyToolChanged(); @@ -264,6 +272,12 @@ export const updateServer = async (req: Request, res: Response): Promise = config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers } + // Set owner property if not provided - use current user's username, default to 'admin' + if (!config.owner) { + const currentUser = (req as any).user; + config.owner = currentUser?.username || 'admin'; + } + const result = await addOrUpdateServer(name, config, true); // Allow override for updates if (result.success) { notifyToolChanged(); diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts new file mode 100644 index 0000000..f2c3a68 --- /dev/null +++ b/src/controllers/userController.ts @@ -0,0 +1,263 @@ +import { Request, Response } from 'express'; +import { ApiResponse } from '../types/index.js'; +import { + getAllUsers, + getUserByUsername, + createNewUser, + updateUser, + deleteUser, + getUserCount, + getAdminCount, +} from '../services/userService.js'; + +// Admin permission check middleware function +const requireAdmin = (req: Request, res: Response): boolean => { + const user = (req as any).user; + if (!user || !user.isAdmin) { + res.status(403).json({ + success: false, + message: 'Admin privileges required', + }); + return false; + } + return true; +}; + +// Get all users (admin only) +export const getUsers = (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + + try { + const users = getAllUsers().map(({ password: _, ...user }) => user); // Remove password from response + const response: ApiResponse = { + success: true, + data: users, + }; + res.json(response); + } catch (error) { + res.status(500).json({ + success: false, + message: 'Failed to get users information', + }); + } +}; + +// Get a specific user by username (admin only) +export const getUser = (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + + try { + const { username } = req.params; + if (!username) { + res.status(400).json({ + success: false, + message: 'Username is required', + }); + return; + } + + const user = getUserByUsername(username); + if (!user) { + res.status(404).json({ + success: false, + message: 'User not found', + }); + return; + } + + const { password: _, ...userData } = user; // Remove password from response + const response: ApiResponse = { + success: true, + data: userData, + }; + res.json(response); + } catch (error) { + res.status(500).json({ + success: false, + message: 'Failed to get user information', + }); + } +}; + +// Create a new user (admin only) +export const createUser = async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + + try { + const { username, password, isAdmin } = req.body; + + if (!username || !password) { + res.status(400).json({ + success: false, + message: 'Username and password are required', + }); + return; + } + + const newUser = await createNewUser(username, password, isAdmin || false); + if (!newUser) { + res.status(400).json({ + success: false, + message: 'Failed to create user or username already exists', + }); + return; + } + + const { password: _, ...userData } = newUser; // Remove password from response + const response: ApiResponse = { + success: true, + data: userData, + message: 'User created successfully', + }; + res.status(201).json(response); + } catch (error) { + res.status(500).json({ + success: false, + message: 'Internal server error', + }); + } +}; + +// Update an existing user (admin only) +export const updateExistingUser = async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + + try { + const { username } = req.params; + const { isAdmin, newPassword } = req.body; + + if (!username) { + res.status(400).json({ + success: false, + message: 'Username is required', + }); + return; + } + + // Check if trying to change admin status + if (isAdmin !== undefined) { + const currentUser = getUserByUsername(username); + if (!currentUser) { + res.status(404).json({ + success: false, + message: 'User not found', + }); + return; + } + + // Prevent removing admin status from the last admin + if (currentUser.isAdmin && !isAdmin && getAdminCount() === 1) { + res.status(400).json({ + success: false, + message: 'Cannot remove admin status from the last admin user', + }); + return; + } + } + + const updateData: any = {}; + if (isAdmin !== undefined) updateData.isAdmin = isAdmin; + if (newPassword) updateData.newPassword = newPassword; + + if (Object.keys(updateData).length === 0) { + res.status(400).json({ + success: false, + message: 'At least one field (isAdmin or newPassword) is required to update', + }); + return; + } + + const updatedUser = await updateUser(username, updateData); + if (!updatedUser) { + res.status(404).json({ + success: false, + message: 'User not found or update failed', + }); + return; + } + + const { password: _, ...userData } = updatedUser; // Remove password from response + const response: ApiResponse = { + success: true, + data: userData, + message: 'User updated successfully', + }; + res.json(response); + } catch (error) { + res.status(500).json({ + success: false, + message: 'Internal server error', + }); + } +}; + +// Delete a user (admin only) +export const deleteExistingUser = (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + + try { + const { username } = req.params; + if (!username) { + res.status(400).json({ + success: false, + message: 'Username is required', + }); + return; + } + + // Check if trying to delete the current admin user + const currentUser = (req as any).user; + if (currentUser.username === username) { + res.status(400).json({ + success: false, + message: 'Cannot delete your own account', + }); + return; + } + + const success = deleteUser(username); + if (!success) { + res.status(400).json({ + success: false, + message: 'User not found, failed to delete, or cannot delete the last admin', + }); + return; + } + + res.json({ + success: true, + message: 'User deleted successfully', + }); + } catch (error) { + res.status(500).json({ + success: false, + message: 'Internal server error', + }); + } +}; + +// Get user statistics (admin only) +export const getUserStats = (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + + try { + const totalUsers = getUserCount(); + const adminUsers = getAdminCount(); + const regularUsers = totalUsers - adminUsers; + + const response: ApiResponse = { + success: true, + data: { + totalUsers, + adminUsers, + regularUsers, + }, + }; + res.json(response); + } catch (error) { + res.status(500).json({ + success: false, + message: 'Failed to get user statistics', + }); + } +}; diff --git a/src/middlewares/index.ts b/src/middlewares/index.ts index fac511a..30dc900 100644 --- a/src/middlewares/index.ts +++ b/src/middlewares/index.ts @@ -1,5 +1,6 @@ import express, { Request, Response, NextFunction } from 'express'; import { auth } from './auth.js'; +import { userContextMiddleware } from './userContext.js'; import { initializeDefaultUser } from '../models/User.js'; import config from '../config/index.js'; @@ -27,7 +28,13 @@ export const initMiddlewares = (app: express.Application): void => { if ( req.path !== `${basePath}/sse` && !req.path.startsWith(`${basePath}/sse/`) && - req.path !== `${basePath}/messages` + req.path !== `${basePath}/messages` && + !req.path.match( + new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+/messages$`), + ) && + !req.path.match( + new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+/sse(/.*)?$`), + ) ) { express.json()(req, res, next); } else { @@ -46,7 +53,15 @@ export const initMiddlewares = (app: express.Application): void => { if (req.path === '/auth/login' || req.path === '/auth/register') { next(); } else { - auth(req, res, next); + // Apply authentication middleware first + auth(req, res, (err) => { + if (err) { + next(err); + } else { + // Apply user context middleware after successful authentication + userContextMiddleware(req, res, next); + } + }); } }); diff --git a/src/middlewares/userContext.ts b/src/middlewares/userContext.ts new file mode 100644 index 0000000..8ccd459 --- /dev/null +++ b/src/middlewares/userContext.ts @@ -0,0 +1,136 @@ +import { Request, Response, NextFunction } from 'express'; +import { UserContextService } from '../services/userContextService.js'; +import { IUser } from '../types/index.js'; + +/** + * User context middleware + * Sets user context after authentication middleware, allowing service layer to access current user information + */ +export const userContextMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const currentUser = (req as any).user as IUser; + + if (currentUser) { + // Set user context + const userContextService = UserContextService.getInstance(); + userContextService.setCurrentUser(currentUser); + + // Clean up user context when response ends + res.on('finish', () => { + const userContextService = UserContextService.getInstance(); + userContextService.clearCurrentUser(); + }); + } + + next(); + } catch (error) { + console.error('Error in user context middleware:', error); + next(error); + } +}; + +/** + * User context middleware for SSE/MCP endpoints + * Extracts user from URL path parameter and sets user context + */ +export const sseUserContextMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const userContextService = UserContextService.getInstance(); + const username = req.params.user; + + if (username) { + // For user-scoped routes, set the user context + // Note: In a real implementation, you should validate the user exists + // and has proper permissions + const user: IUser = { + username, + password: '', + isAdmin: false, // TODO: Should be retrieved from user database + }; + + userContextService.setCurrentUser(user); + + // Clean up user context when response ends + res.on('finish', () => { + userContextService.clearCurrentUser(); + }); + + // Also clean up on connection close for SSE + res.on('close', () => { + userContextService.clearCurrentUser(); + }); + + console.log(`User context set for SSE/MCP endpoint: ${username}`); + } else { + // For global routes, clear user context (admin access) + userContextService.clearCurrentUser(); + console.log('Global SSE/MCP endpoint access - no user context'); + } + + next(); + } catch (error) { + console.error('Error in SSE user context middleware:', error); + next(error); + } +}; + +/** + * Extended data service that can directly access current user context + */ +export interface ContextAwareDataService { + getCurrentUserFromContext(): Promise; + getUserDataFromContext(dataType: string): Promise; + isCurrentUserAdmin(): Promise; +} + +export class ContextAwareDataServiceImpl implements ContextAwareDataService { + private getUserContextService() { + return UserContextService.getInstance(); + } + + async getCurrentUserFromContext(): Promise { + const userContextService = this.getUserContextService(); + return userContextService.getCurrentUser(); + } + + async getUserDataFromContext(dataType: string): Promise { + const userContextService = this.getUserContextService(); + const user = userContextService.getCurrentUser(); + + if (!user) { + throw new Error('No user in context'); + } + + console.log(`Getting ${dataType} data for user: ${user.username}`); + + // Return different data based on user permissions + if (user.isAdmin) { + return { + type: dataType, + data: 'Admin level data from context', + user: user.username, + access: 'full', + }; + } else { + return { + type: dataType, + data: 'User level data from context', + user: user.username, + access: 'limited', + }; + } + } + + async isCurrentUserAdmin(): Promise { + const userContextService = this.getUserContextService(); + return userContextService.isAdmin(); + } +} diff --git a/src/routes/index.ts b/src/routes/index.ts index ced312b..1ec0b55 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -23,6 +23,14 @@ import { getGroupServers, updateGroupServersBatch, } from '../controllers/groupController.js'; +import { + getUsers, + getUser, + createUser, + updateExistingUser, + deleteExistingUser, + getUserStats, +} from '../controllers/userController.js'; import { getAllMarketServers, getMarketServer, @@ -65,6 +73,14 @@ export const initRoutes = (app: express.Application): void => { // New route for batch updating servers in a group router.put('/groups/:id/servers/batch', updateGroupServersBatch); + // User management routes (admin only) + router.get('/users', getUsers); + router.get('/users/:username', getUser); + router.post('/users', createUser); + router.put('/users/:username', updateExistingUser); + router.delete('/users/:username', deleteExistingUser); + router.get('/users-stats', getUserStats); + // Tool management routes router.post('/tools/call/:server', callTool); diff --git a/src/server.ts b/src/server.ts index 315bdf6..38fa60d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,8 @@ import express from 'express'; import config from './config/index.js'; import path from 'path'; -import { fileURLToPath } from 'url'; import fs from 'fs'; -import { initUpstreamServers } from './services/mcpService.js'; +import { initUpstreamServers, connected } from './services/mcpService.js'; import { initMiddlewares } from './middlewares/index.js'; import { initRoutes } from './routes/index.js'; import { @@ -13,10 +12,10 @@ import { handleMcpOtherRequest, } from './services/sseService.js'; import { initializeDefaultUser } from './models/User.js'; +import { sseUserContextMiddleware } from './middlewares/userContext.js'; -// Get the directory name in ESM -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +// Get the current working directory (will be project root in most cases) +const currentFileDir = process.cwd() + '/src'; export class AppServer { private app: express.Application; @@ -42,11 +41,52 @@ export class AppServer { initUpstreamServers() .then(() => { console.log('MCP server initialized successfully'); - this.app.get(`${this.basePath}/sse/:group?`, (req, res) => handleSseConnection(req, res)); - this.app.post(`${this.basePath}/messages`, handleSseMessage); - this.app.post(`${this.basePath}/mcp/:group?`, handleMcpPostRequest); - this.app.get(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest); - this.app.delete(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest); + + // Original routes (global and group-based) + this.app.get(`${this.basePath}/sse/:group?`, sseUserContextMiddleware, (req, res) => + handleSseConnection(req, res), + ); + this.app.post(`${this.basePath}/messages`, sseUserContextMiddleware, handleSseMessage); + this.app.post( + `${this.basePath}/mcp/:group?`, + sseUserContextMiddleware, + handleMcpPostRequest, + ); + this.app.get( + `${this.basePath}/mcp/:group?`, + sseUserContextMiddleware, + handleMcpOtherRequest, + ); + this.app.delete( + `${this.basePath}/mcp/:group?`, + sseUserContextMiddleware, + handleMcpOtherRequest, + ); + + // User-scoped routes with user context middleware + this.app.get(`${this.basePath}/:user/sse/:group?`, sseUserContextMiddleware, (req, res) => + handleSseConnection(req, res), + ); + this.app.post( + `${this.basePath}/:user/messages`, + sseUserContextMiddleware, + handleSseMessage, + ); + this.app.post( + `${this.basePath}/:user/mcp/:group?`, + sseUserContextMiddleware, + handleMcpPostRequest, + ); + this.app.get( + `${this.basePath}/:user/mcp/:group?`, + sseUserContextMiddleware, + handleMcpOtherRequest, + ); + this.app.delete( + `${this.basePath}/:user/mcp/:group?`, + sseUserContextMiddleware, + handleMcpOtherRequest, + ); }) .catch((error) => { console.error('Error initializing MCP server:', error); @@ -108,6 +148,10 @@ export class AppServer { }); } + connected(): boolean { + return connected(); + } + getApp(): express.Application { return this.app; } @@ -119,7 +163,7 @@ export class AppServer { if (debug) { console.log('DEBUG: Current directory:', process.cwd()); - console.log('DEBUG: Script directory:', __dirname); + console.log('DEBUG: Script directory:', currentFileDir); } // First, find the package root directory @@ -159,13 +203,13 @@ export class AppServer { // Possible locations for package.json const possibleRoots = [ // Standard npm package location - path.resolve(__dirname, '..', '..'), + path.resolve(currentFileDir, '..', '..'), // Current working directory process.cwd(), // When running from dist directory - path.resolve(__dirname, '..'), + path.resolve(currentFileDir, '..'), // When installed via npx - path.resolve(__dirname, '..', '..', '..'), + path.resolve(currentFileDir, '..', '..', '..'), ]; // Special handling for npx diff --git a/src/services/dataService.test.ts b/src/services/dataService.test.ts new file mode 100644 index 0000000..984cfa8 --- /dev/null +++ b/src/services/dataService.test.ts @@ -0,0 +1,13 @@ +import { DataService } from './dataService.js'; +import { getDataService } from './services.js'; +import './services.js'; + +describe('DataService', () => { + test('should get default implementation and call foo method', async () => { + const dataService: DataService = await getDataService(); + const consoleSpy = jest.spyOn(console, 'log'); + dataService.foo(); + expect(consoleSpy).toHaveBeenCalledWith('default implementation'); + consoleSpy.mockRestore(); + }); +}); diff --git a/src/services/dataService.ts b/src/services/dataService.ts new file mode 100644 index 0000000..886b0eb --- /dev/null +++ b/src/services/dataService.ts @@ -0,0 +1,31 @@ +import { IUser, McpSettings } from '../types/index.js'; + +export interface DataService { + foo(): void; + filterData(data: any[]): any[]; + filterSettings(settings: McpSettings): McpSettings; + mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings; + getPermissions(user: IUser): string[]; +} + +export class DataServiceImpl implements DataService { + foo() { + console.log('default implementation'); + } + + filterData(data: any[]): any[] { + return data; + } + + filterSettings(settings: McpSettings): McpSettings { + return settings; + } + + mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings { + return newSettings; + } + + getPermissions(_user: IUser): string[] { + return ['*']; + } +} diff --git a/src/services/groupService.ts b/src/services/groupService.ts index 492fddc..a1e06e5 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -2,11 +2,15 @@ import { v4 as uuidv4 } from 'uuid'; import { IGroup } from '../types/index.js'; import { loadSettings, saveSettings } from '../config/index.js'; import { notifyToolChanged } from './mcpService.js'; +import { getDataService } from './services.js'; // Get all groups export const getAllGroups = (): IGroup[] => { const settings = loadSettings(); - return settings.groups || []; + const dataService = getDataService(); + return dataService.filterData + ? dataService.filterData(settings.groups || []) + : settings.groups || []; }; // Get group by ID or name @@ -29,6 +33,7 @@ export const createGroup = ( name: string, description?: string, servers: string[] = [], + owner?: string, ): IGroup | null => { try { const settings = loadSettings(); @@ -47,6 +52,7 @@ export const createGroup = ( name, description, servers: validServers, + owner: owner || 'admin', }; // Initialize groups array if it doesn't exist diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts index dcf143e..16cc49a 100644 --- a/src/services/mcpService.ts +++ b/src/services/mcpService.ts @@ -11,6 +11,7 @@ import { getGroup } from './sseService.js'; import { getServersInGroup } from './groupService.js'; import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js'; import { OpenAPIClient } from '../clients/openapi.js'; +import { getDataService } from './services.js'; const servers: { [sessionId: string]: Server } = {}; @@ -101,6 +102,33 @@ export const syncToolEmbedding = async (serverName: string, toolName: string) => // Store all server information let serverInfos: ServerInfo[] = []; +// Returns true if all servers are connected +export const connected = (): boolean => { + return serverInfos.every((serverInfo) => serverInfo.status === 'connected'); +}; + +// Global cleanup function to close all connections +export const cleanupAllServers = (): void => { + for (const serverInfo of serverInfos) { + try { + if (serverInfo.client) { + serverInfo.client.close(); + } + if (serverInfo.transport) { + serverInfo.transport.close(); + } + } catch (error) { + console.warn(`Error closing server ${serverInfo.name}:`, error); + } + } + serverInfos = []; + + // Clear session servers as well + Object.keys(servers).forEach((sessionId) => { + delete servers[sessionId]; + }); +}; + // Helper function to create transport based on server configuration const createTransportFromConfig = (name: string, conf: ServerConfig): any => { let transport; @@ -294,6 +322,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise => { // Get all server information export const getServersInfo = (): Omit[] => { const settings = loadSettings(); - const infos = serverInfos.map(({ name, status, tools, createTime, error }) => { + const dataService = getDataService(); + const filterServerInfos: ServerInfo[] = dataService.filterData + ? dataService.filterData(serverInfos) + : serverInfos; + const infos = filterServerInfos.map(({ name, status, tools, createTime, error }) => { const serverConfig = settings.mcpServers[name]; const enabled = serverConfig ? serverConfig.enabled !== false : true; @@ -774,13 +810,15 @@ Available servers: ${serversList}`; }; } - const allServerInfos = serverInfos.filter((serverInfo) => { - if (serverInfo.enabled === false) return false; - if (!group) return true; - const serversInGroup = getServersInGroup(group); - if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group; - return serversInGroup.includes(serverInfo.name); - }); + const allServerInfos = getDataService() + .filterData(serverInfos) + .filter((serverInfo) => { + if (serverInfo.enabled === false) return false; + if (!group) return true; + const serversInGroup = getServersInGroup(group); + if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group; + return serversInGroup.includes(serverInfo.name); + }); const allTools = []; for (const serverInfo of allServerInfos) { diff --git a/src/services/registry.ts b/src/services/registry.ts new file mode 100644 index 0000000..71099ec --- /dev/null +++ b/src/services/registry.ts @@ -0,0 +1,37 @@ +type Class = new (...args: any[]) => T; + +interface Service { + defaultImpl: Class; +} + +const registry = new Map>(); +const instances = new Map(); + +export function registerService(key: string, entry: Service) { + registry.set(key, entry); +} + +export function getService(key: string): T { + if (instances.has(key)) { + return instances.get(key) as T; + } + + const entry = registry.get(key); + if (!entry) throw new Error(`Service not registered for key: ${key.toString()}`); + + let Impl = entry.defaultImpl; + + const overridePath = './' + key + 'x.js'; + import(overridePath) + .then((mod) => { + const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'] ?? Impl.name; + if (typeof override === 'function') { + Impl = override; + } + }) + .catch(() => {}); + + const instance = new Impl(); + instances.set(key, instance); + return instance; +} diff --git a/src/services/services.ts b/src/services/services.ts new file mode 100644 index 0000000..4c1738f --- /dev/null +++ b/src/services/services.ts @@ -0,0 +1,10 @@ +import { registerService, getService } from './registry.js'; +import { DataService, DataServiceImpl } from './dataService.js'; + +registerService('dataService', { + defaultImpl: DataServiceImpl, +}); + +export function getDataService(): DataService { + return getService('dataService'); +} diff --git a/src/services/sseService.test.ts b/src/services/sseService.test.ts new file mode 100644 index 0000000..9a70793 --- /dev/null +++ b/src/services/sseService.test.ts @@ -0,0 +1,482 @@ +import { Request, Response } from 'express'; +import { jest } from '@jest/globals'; +import { + handleSseConnection, + handleSseMessage, + handleMcpPostRequest, + handleMcpOtherRequest, + getGroup, + getConnectionCount, +} from './sseService.js'; + +// Mock dependencies +jest.mock('./mcpService.js', () => ({ + deleteMcpServer: jest.fn(), + getMcpServer: jest.fn(() => ({ + connect: jest.fn(), + })), +})); + +jest.mock('../config/index.js', () => { + const config = { + basePath: '/test', + }; + return { + __esModule: true, + default: config, + loadSettings: jest.fn(() => ({ + mcpServers: {}, + systemConfig: { + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: false, + bearerAuthKey: 'test-key', + }, + }, + })), + }; +}); + +jest.mock('./userContextService.js', () => ({ + UserContextService: { + getInstance: jest.fn(() => ({ + getCurrentUser: jest.fn(() => ({ username: 'testuser' })), + })), + }, +})); + +jest.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({ + SSEServerTransport: jest.fn().mockImplementation((_path, _res) => ({ + sessionId: 'test-session-id', + connect: jest.fn(), + handlePostMessage: jest.fn(), + })), +})); + +jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({ + StreamableHTTPServerTransport: jest.fn().mockImplementation(() => ({ + sessionId: 'test-session-id', + connect: jest.fn(), + handleRequest: jest.fn(), + onclose: null, + })), +})); + +jest.mock('@modelcontextprotocol/sdk/types.js', () => ({ + isInitializeRequest: jest.fn(() => true), +})); + +// Import mocked modules +import { getMcpServer } from './mcpService.js'; +import { loadSettings } from '../config/index.js'; +import { UserContextService } from './userContextService.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + +// Mock Express Request and Response +const createMockRequest = (overrides: Partial = {}): Request => + ({ + headers: {}, + params: {}, + query: {}, + body: {}, + ...overrides, + }) as Request; + +const createMockResponse = (): Response => { + const res = { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + on: jest.fn(), + } as unknown as Response; + return res; +}; + +describe('sseService', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Reset settings cache + (loadSettings as jest.MockedFunction).mockReturnValue({ + mcpServers: {}, + systemConfig: { + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: false, + bearerAuthKey: 'test-key', + }, + }, + }); + }); + + describe('bearer authentication', () => { + it('should pass when bearer auth is disabled', async () => { + const req = createMockRequest({ + params: { group: 'test-group' }, + }); + const res = createMockResponse(); + + await handleSseConnection(req, res); + + expect(res.status).not.toHaveBeenCalledWith(401); + expect(SSEServerTransport).toHaveBeenCalled(); + }); + + it('should return 401 when bearer auth is enabled but no authorization header', async () => { + (loadSettings as jest.MockedFunction).mockReturnValue({ + mcpServers: {}, + systemConfig: { + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: true, + bearerAuthKey: 'test-key', + }, + }, + }); + + const req = createMockRequest(); + const res = createMockResponse(); + + await handleSseConnection(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token'); + }); + + it('should return 401 when bearer auth is enabled with invalid token', async () => { + (loadSettings as jest.MockedFunction).mockReturnValue({ + mcpServers: {}, + systemConfig: { + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: true, + bearerAuthKey: 'test-key', + }, + }, + }); + + const req = createMockRequest({ + headers: { authorization: 'Bearer invalid-token' }, + }); + const res = createMockResponse(); + + await handleSseConnection(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token'); + }); + + it('should pass when bearer auth is enabled with valid token', async () => { + (loadSettings as jest.MockedFunction).mockReturnValue({ + mcpServers: {}, + systemConfig: { + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: true, + bearerAuthKey: 'test-key', + }, + }, + }); + + const req = createMockRequest({ + headers: { authorization: 'Bearer test-key' }, + params: { group: 'test-group' }, + }); + const res = createMockResponse(); + + await handleSseConnection(req, res); + + expect(res.status).not.toHaveBeenCalledWith(401); + expect(SSEServerTransport).toHaveBeenCalled(); + }); + }); + + describe('getGroup', () => { + it('should return empty string for non-existent session', () => { + const result = getGroup('non-existent-session'); + expect(result).toBe(''); + }); + + it('should return group for existing session', () => { + // This would need to be tested after a connection is established + // For now, testing the default behavior + const result = getGroup('test-session'); + expect(result).toBe(''); + }); + }); + + describe('getConnectionCount', () => { + it('should return current number of connections', () => { + const count = getConnectionCount(); + // The count may be > 0 due to previous tests since transports is module-level + expect(typeof count).toBe('number'); + expect(count).toBeGreaterThanOrEqual(0); + }); + }); + + describe('handleSseConnection', () => { + it('should reject global routes when disabled', async () => { + (loadSettings as jest.MockedFunction).mockReturnValue({ + mcpServers: {}, + systemConfig: { + routing: { + enableGlobalRoute: false, + enableGroupNameRoute: true, + enableBearerAuth: false, + bearerAuthKey: '', + }, + }, + }); + + const req = createMockRequest(); // No group in params + const res = createMockResponse(); + + await handleSseConnection(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith( + 'Global routes are disabled. Please specify a group ID.', + ); + }); + + it('should create SSE transport for valid request', async () => { + const req = createMockRequest({ + params: { group: 'test-group' }, + }); + const res = createMockResponse(); + + await handleSseConnection(req, res); + + expect(SSEServerTransport).toHaveBeenCalledWith('/test/testuser/messages', res); + expect(getMcpServer).toHaveBeenCalledWith('test-session-id', 'test-group'); + }); + + it('should handle user context correctly', async () => { + const mockGetCurrentUser = jest.fn(() => ({ username: 'testuser2' })); + (UserContextService.getInstance as jest.MockedFunction).mockReturnValue({ + getCurrentUser: mockGetCurrentUser, + }); + + const req = createMockRequest({ + params: { group: 'test-group' }, + }); + const res = createMockResponse(); + + await handleSseConnection(req, res); + + expect(mockGetCurrentUser).toHaveBeenCalled(); + expect(SSEServerTransport).toHaveBeenCalledWith('/test/testuser2/messages', res); + }); + + it('should handle anonymous user correctly', async () => { + const mockGetCurrentUser = jest.fn(() => null); + (UserContextService.getInstance as jest.MockedFunction).mockReturnValue({ + getCurrentUser: mockGetCurrentUser, + }); + + const req = createMockRequest({ + params: { group: 'test-group' }, + }); + const res = createMockResponse(); + + await handleSseConnection(req, res); + + expect(mockGetCurrentUser).toHaveBeenCalled(); + expect(SSEServerTransport).toHaveBeenCalledWith('/test/messages', res); + }); + }); + + describe('handleSseMessage', () => { + it('should return 400 when sessionId is missing', async () => { + const req = createMockRequest({ + query: {}, // No sessionId + }); + const res = createMockResponse(); + + await handleSseMessage(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith('Missing sessionId parameter'); + }); + + it('should return 404 when transport not found', async () => { + const req = createMockRequest({ + query: { sessionId: 'non-existent-session' }, + }); + const res = createMockResponse(); + + await handleSseMessage(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.send).toHaveBeenCalledWith('No transport found for sessionId'); + }); + + it('should return 401 when bearer auth fails', async () => { + (loadSettings as jest.MockedFunction).mockReturnValue({ + mcpServers: {}, + systemConfig: { + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: true, + bearerAuthKey: 'test-key', + }, + }, + }); + + const req = createMockRequest({ + query: { sessionId: 'test-session' }, + }); + const res = createMockResponse(); + + await handleSseMessage(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token'); + }); + }); + + describe('handleMcpPostRequest', () => { + it('should reject global routes when disabled', async () => { + (loadSettings as jest.MockedFunction).mockReturnValue({ + mcpServers: {}, + systemConfig: { + routing: { + enableGlobalRoute: false, + enableGroupNameRoute: true, + enableBearerAuth: false, + bearerAuthKey: '', + }, + }, + }); + + const req = createMockRequest({ + params: {}, // No group + body: { method: 'initialize' }, + }); + const res = createMockResponse(); + + await handleMcpPostRequest(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith( + 'Global routes are disabled. Please specify a group ID.', + ); + }); + + it('should create new transport for initialize request without sessionId', async () => { + const req = createMockRequest({ + params: { group: 'test-group' }, + body: { method: 'initialize' }, + }); + const res = createMockResponse(); + + await handleMcpPostRequest(req, res); + + expect(StreamableHTTPServerTransport).toHaveBeenCalled(); + expect(getMcpServer).toHaveBeenCalled(); + }); + + it('should return error for invalid session', async () => { + const req = createMockRequest({ + params: { group: 'test-group' }, + headers: { 'mcp-session-id': 'invalid-session' }, + body: { method: 'someMethod' }, + }); + const res = createMockResponse(); + + await handleMcpPostRequest(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + }); + + it('should return 401 when bearer auth fails', async () => { + (loadSettings as jest.MockedFunction).mockReturnValue({ + mcpServers: {}, + systemConfig: { + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: true, + bearerAuthKey: 'test-key', + }, + }, + }); + + const req = createMockRequest({ + params: { group: 'test-group' }, + body: { method: 'initialize' }, + }); + const res = createMockResponse(); + + await handleMcpPostRequest(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token'); + }); + }); + + describe('handleMcpOtherRequest', () => { + it('should return 400 for missing session ID', async () => { + const req = createMockRequest({ + headers: {}, // No mcp-session-id + }); + const res = createMockResponse(); + + await handleMcpOtherRequest(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith('Invalid or missing session ID'); + }); + + it('should return 400 for invalid session ID', async () => { + const req = createMockRequest({ + headers: { 'mcp-session-id': 'invalid-session' }, + }); + const res = createMockResponse(); + + await handleMcpOtherRequest(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith('Invalid or missing session ID'); + }); + + it('should return 401 when bearer auth fails', async () => { + (loadSettings as jest.MockedFunction).mockReturnValue({ + mcpServers: {}, + systemConfig: { + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: true, + bearerAuthKey: 'test-key', + }, + }, + }); + + const req = createMockRequest({ + headers: { 'mcp-session-id': 'test-session' }, + }); + const res = createMockResponse(); + + await handleMcpOtherRequest(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token'); + }); + }); +}); diff --git a/src/services/sseService.ts b/src/services/sseService.ts index 2327820..027a433 100644 --- a/src/services/sseService.ts +++ b/src/services/sseService.ts @@ -7,6 +7,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { deleteMcpServer, getMcpServer } from './mcpService.js'; import { loadSettings } from '../config/index.js'; import config from '../config/index.js'; +import { UserContextService } from './userContextService.js'; const transports: { [sessionId: string]: { transport: Transport; group: string } } = {}; @@ -38,8 +39,14 @@ const validateBearerAuth = (req: Request): boolean => { }; export const handleSseConnection = async (req: Request, res: Response): Promise => { - // Check bearer auth + // User context is now set by sseUserContextMiddleware + const userContextService = UserContextService.getInstance(); + const currentUser = userContextService.getCurrentUser(); + const username = currentUser?.username; + + // Check bearer auth using filtered settings if (!validateBearerAuth(req)) { + console.warn('Bearer authentication failed or not provided'); res.status(401).send('Bearer authentication required or invalid token'); return; } @@ -55,11 +62,25 @@ export const handleSseConnection = async (req: Request, res: Response): Promise< // Check if this is a global route (no group) and if it's allowed if (!group && !routingConfig.enableGlobalRoute) { + console.warn('Global routes are disabled, group ID is required'); res.status(403).send('Global routes are disabled. Please specify a group ID.'); return; } - const transport = new SSEServerTransport(`${config.basePath}/messages`, res); + // For user-scoped routes, validate that the user has access to the requested group + if (username && group) { + // Additional validation can be added here to check if user has access to the group + console.log(`User ${username} accessing group: ${group}`); + } + + // Construct the appropriate messages path based on user context + const messagesPath = username + ? `${config.basePath}/${username}/messages` + : `${config.basePath}/messages`; + + console.log(`Creating SSE transport with messages path: ${messagesPath}`); + + const transport = new SSEServerTransport(messagesPath, res); transports[transport.sessionId] = { transport, group: group }; res.on('close', () => { @@ -69,13 +90,18 @@ export const handleSseConnection = async (req: Request, res: Response): Promise< }); console.log( - `New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}`, + `New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}${username ? ` for user: ${username}` : ''}`, ); await getMcpServer(transport.sessionId, group).connect(transport); }; export const handleSseMessage = async (req: Request, res: Response): Promise => { - // Check bearer auth + // User context is now set by sseUserContextMiddleware + const userContextService = UserContextService.getInstance(); + const currentUser = userContextService.getCurrentUser(); + const username = currentUser?.username; + + // Check bearer auth using filtered settings if (!validateBearerAuth(req)) { res.status(401).send('Bearer authentication required or invalid token'); return; @@ -101,24 +127,31 @@ export const handleSseMessage = async (req: Request, res: Response): Promise => { + // User context is now set by sseUserContextMiddleware + const userContextService = UserContextService.getInstance(); + const currentUser = userContextService.getCurrentUser(); + const username = currentUser?.username; + const sessionId = req.headers['mcp-session-id'] as string | undefined; const group = req.params.group; const body = req.body; console.log( - `Handling MCP post request for sessionId: ${sessionId} and group: ${group} with body: ${JSON.stringify(body)}`, + `Handling MCP post request for sessionId: ${sessionId} and group: ${group}${username ? ` for user: ${username}` : ''} with body: ${JSON.stringify(body)}`, ); - // Check bearer auth + + // Check bearer auth using filtered settings if (!validateBearerAuth(req)) { res.status(401).send('Bearer authentication required or invalid token'); return; } + // Get filtered settings based on user context (after setting user context) const settings = loadSettings(); const routingConfig = settings.systemConfig?.routing || { enableGlobalRoute: true, @@ -150,7 +183,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise } }; - console.log(`MCP connection established: ${transport.sessionId}`); + console.log(`MCP connection established: ${transport.sessionId}${username ? ` for user: ${username}` : ''}`); await getMcpServer(transport.sessionId, group).connect(transport); } else { res.status(400).json({ @@ -169,8 +202,14 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise }; export const handleMcpOtherRequest = async (req: Request, res: Response) => { - console.log('Handling MCP other request'); - // Check bearer auth + // User context is now set by sseUserContextMiddleware + const userContextService = UserContextService.getInstance(); + const currentUser = userContextService.getCurrentUser(); + const username = currentUser?.username; + + console.log(`Handling MCP other request${username ? ` for user: ${username}` : ''}`); + + // Check bearer auth using filtered settings if (!validateBearerAuth(req)) { res.status(401).send('Bearer authentication required or invalid token'); return; diff --git a/src/services/userContextService.ts b/src/services/userContextService.ts new file mode 100644 index 0000000..12cd8d5 --- /dev/null +++ b/src/services/userContextService.ts @@ -0,0 +1,59 @@ +import { IUser } from '../types/index.js'; + +// User context storage +class UserContext { + private static instance: UserContext; + private currentUser: IUser | null = null; + + static getInstance(): UserContext { + if (!UserContext.instance) { + UserContext.instance = new UserContext(); + } + return UserContext.instance; + } + + setUser(user: IUser): void { + this.currentUser = user; + } + + getUser(): IUser | null { + return this.currentUser; + } + + clearUser(): void { + this.currentUser = null; + } +} + +export class UserContextService { + private static instance: UserContextService; + private userContext = UserContext.getInstance(); + + static getInstance(): UserContextService { + if (!UserContextService.instance) { + UserContextService.instance = new UserContextService(); + } + return UserContextService.instance; + } + + getCurrentUser(): IUser | null { + return this.userContext.getUser(); + } + + setCurrentUser(user: IUser): void { + this.userContext.setUser(user); + } + + clearCurrentUser(): void { + this.userContext.clearUser(); + } + + isAdmin(): boolean { + const user = this.getCurrentUser(); + return user?.isAdmin || false; + } + + hasUser(): boolean { + return this.getCurrentUser() !== null; + } +} diff --git a/src/services/userService.ts b/src/services/userService.ts new file mode 100644 index 0000000..628ac90 --- /dev/null +++ b/src/services/userService.ts @@ -0,0 +1,126 @@ +import { IUser } from '../types/index.js'; +import { getUsers, createUser, findUserByUsername } from '../models/User.js'; +import { saveSettings, loadSettings } from '../config/index.js'; +import bcrypt from 'bcryptjs'; + +// Get all users +export const getAllUsers = (): IUser[] => { + return getUsers(); +}; + +// Get user by username +export const getUserByUsername = (username: string): IUser | undefined => { + return findUserByUsername(username); +}; + +// Create a new user +export const createNewUser = async ( + username: string, + password: string, + isAdmin: boolean = false, +): Promise => { + try { + const existingUser = findUserByUsername(username); + if (existingUser) { + return null; // User already exists + } + + const userData: IUser = { + username, + password, + isAdmin, + }; + + return await createUser(userData); + } catch (error) { + console.error('Failed to create user:', error); + return null; + } +}; + +// Update user information +export const updateUser = async ( + username: string, + data: { isAdmin?: boolean; newPassword?: string }, +): Promise => { + try { + const users = getUsers(); + const userIndex = users.findIndex((user) => user.username === username); + + if (userIndex === -1) { + return null; + } + + const user = users[userIndex]; + + // Update admin status if provided + if (data.isAdmin !== undefined) { + user.isAdmin = data.isAdmin; + } + + // Update password if provided + if (data.newPassword) { + const salt = await bcrypt.genSalt(10); + user.password = await bcrypt.hash(data.newPassword, salt); + } + + // Save users array back to settings + const { saveSettings, loadSettings } = await import('../config/index.js'); + const settings = loadSettings(); + settings.users = users; + + if (!saveSettings(settings)) { + return null; + } + + return user; + } catch (error) { + console.error('Failed to update user:', error); + return null; + } +}; + +// Delete a user +export const deleteUser = (username: string): boolean => { + try { + // Cannot delete the last admin user + const users = getUsers(); + const adminUsers = users.filter((user) => user.isAdmin); + const userToDelete = users.find((user) => user.username === username); + + if (userToDelete?.isAdmin && adminUsers.length === 1) { + return false; // Cannot delete the last admin + } + + const filteredUsers = users.filter((user) => user.username !== username); + + if (filteredUsers.length === users.length) { + return false; // User not found + } + + // Save filtered users back to settings + const settings = loadSettings(); + settings.users = filteredUsers; + + return saveSettings(settings); + } catch (error) { + console.error('Failed to delete user:', error); + return false; + } +}; + +// Check if user has admin permissions +export const isUserAdmin = (username: string): boolean => { + const user = findUserByUsername(username); + return user?.isAdmin || false; +}; + +// Get user count +export const getUserCount = (): number => { + return getUsers().length; +}; + +// Get admin count +export const getAdminCount = (): number => { + return getUsers().filter((user) => user.isAdmin).length; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 651476b..9552b8d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,6 +18,7 @@ export interface IGroup { name: string; // Display name of the group description?: string; // Optional description of the group servers: string[]; // Array of server names that belong to this group + owner?: string; // Owner of the group, defaults to 'admin' user } // Market server types @@ -74,6 +75,30 @@ export interface MarketServer { is_official?: boolean; } +export interface SystemConfig { + routing?: { + enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled + enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed + enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes + bearerAuthKey?: string; // The bearer auth key to validate against + skipAuth?: boolean; // Controls whether authentication is required for frontend and API access + }; + install?: { + pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX) + npmRegistry?: string; // NPM registry URL (npm_config_registry) + }; + smartRouting?: SmartRoutingConfig; +} + +export interface UserConfig { + routing?: { + enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled + enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed + enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes + bearerAuthKey?: string; // The bearer auth key to validate against + }; +} + // Represents the settings for MCP servers export interface McpSettings { users?: IUser[]; // Array of user credentials and permissions @@ -81,21 +106,8 @@ export interface McpSettings { [key: string]: ServerConfig; // Key-value pairs of server names and their configurations }; groups?: IGroup[]; // Array of server groups - systemConfig?: { - routing?: { - enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled - enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed - enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes - bearerAuthKey?: string; // The bearer auth key to validate against - skipAuth?: boolean; // Controls whether authentication is required for frontend and API access - }; - install?: { - pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX) - npmRegistry?: string; // NPM registry URL (npm_config_registry) - }; - smartRouting?: SmartRoutingConfig; - // Add other system configuration sections here in the future - }; + systemConfig?: SystemConfig; // System-wide configuration settings + userConfigs?: Record; // User-specific configurations } // Configuration details for an individual server @@ -107,6 +119,7 @@ export interface ServerConfig { env?: Record; // Environment variables headers?: Record; // HTTP headers for SSE/streamable-http/openapi servers enabled?: boolean; // Flag to enable/disable the server + owner?: string; // Owner of the server, defaults to 'admin' user keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers) tools?: Record; // Tool-specific configurations with enable/disable state and custom descriptions options?: Partial>; // MCP request options configuration @@ -154,6 +167,7 @@ export interface OpenAPISecurityConfig { // Information about a server's status and tools export interface ServerInfo { name: string; // Unique name of the server + owner?: string; // Owner of the server, defaults to 'admin' user status: 'connected' | 'connecting' | 'disconnected'; // Current connection status error: string | null; // Error message if any tools: ToolInfo[]; // List of tools available on the server diff --git a/src/utils/path.ts b/src/utils/path.ts index b8dbdde..8069414 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,13 +1,9 @@ import fs from 'fs'; import path from 'path'; -import { fileURLToPath } from 'url'; import { dirname } from 'path'; -// Get current file's directory -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -// Project root directory should be the parent directory of src -const rootDir = dirname(dirname(__dirname)); +// Project root directory - use process.cwd() as a simpler alternative +const rootDir = process.cwd(); /** * Find the path to a configuration file by checking multiple potential locations. @@ -24,7 +20,7 @@ export const getConfigFilePath = (filename: string, description = 'Configuration // Use path relative to the root directory path.join(rootDir, filename), // If installed with npx, may need to look one level up - path.join(dirname(rootDir), filename) + path.join(dirname(rootDir), filename), ]; for (const filePath of potentialPaths) { @@ -38,6 +34,8 @@ export const getConfigFilePath = (filename: string, description = 'Configuration // even if the configuration file is missing. This fallback is particularly useful in // development environments or when the file is optional. const defaultPath = path.resolve(process.cwd(), filename); - console.debug(`${description} file not found at any expected location, using default path: ${defaultPath}`); + console.debug( + `${description} file not found at any expected location, using default path: ${defaultPath}`, + ); return defaultPath; -}; \ No newline at end of file +}; diff --git a/src/utils/serialization.ts b/src/utils/serialization.ts new file mode 100644 index 0000000..9a64819 --- /dev/null +++ b/src/utils/serialization.ts @@ -0,0 +1,72 @@ +/** + * Utility functions for safe JSON serialization + * Handles circular references and provides type-safe serialization + */ + +/** + * Creates a JSON-safe copy of an object by removing circular references + * Uses a replacer function with WeakSet to efficiently track visited objects + * + * @param obj - The object to make JSON-safe + * @returns A new object that can be safely serialized to JSON + */ +export const createSafeJSON = (obj: T): T => { + const seen = new WeakSet(); + + return JSON.parse( + JSON.stringify(obj, (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular Reference]'; + } + seen.add(value); + } + return value; + }), + ); +}; + +/** + * Safe JSON stringifier that handles circular references + * Useful for logging or debugging purposes + * + * @param obj - The object to stringify + * @param space - Number of spaces to use for indentation (optional) + * @returns JSON string representation of the object + */ +export const safeStringify = (obj: any, space?: number): string => { + const seen = new WeakSet(); + + return JSON.stringify( + obj, + (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular Reference]'; + } + seen.add(value); + } + return value; + }, + space, + ); +}; + +/** + * Removes specific properties that might contain circular references + * More targeted approach for known problematic properties + * + * @param obj - The object to clean + * @param excludeProps - Array of property names to exclude + * @returns A new object without the specified properties + */ +export const excludeCircularProps = >( + obj: T, + excludeProps: string[], +): Omit => { + const result = { ...obj }; + excludeProps.forEach((prop) => { + delete result[prop]; + }); + return result; +}; diff --git a/src/utils/version.ts b/src/utils/version.ts index cd78e06..7289de1 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,10 +1,5 @@ import fs from 'fs'; import path from 'path'; -import { fileURLToPath } from 'url'; - -// Get the directory name in ESM -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); /** * Gets the package version from package.json @@ -12,7 +7,7 @@ const __dirname = path.dirname(__filename); */ export const getPackageVersion = (): string => { try { - const packageJsonPath = path.resolve(__dirname, '../../package.json'); + const packageJsonPath = path.resolve(process.cwd(), 'package.json'); const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8'); const packageJson = JSON.parse(packageJsonContent); return packageJson.version || 'dev'; diff --git a/tests/integration/sse-service-real-client.test.ts b/tests/integration/sse-service-real-client.test.ts new file mode 100644 index 0000000..03128f7 --- /dev/null +++ b/tests/integration/sse-service-real-client.test.ts @@ -0,0 +1,465 @@ +import { Server } from 'http'; +import { AppServer } from '../../src/server.js'; +import { TestServerHelper } from '../utils/testServerHelper.js'; +import * as mockSettings from '../utils/mockSettings.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { cleanupAllServers } from '../../src/services/mcpService.js'; + +describe('Real Client Transport Integration Tests', () => { + let _appServer: AppServer; + let httpServer: Server; + let baseURL: string; + let testServerHelper: TestServerHelper; + + beforeAll(async () => { + const settings = mockSettings.createMockSettings(); + testServerHelper = new TestServerHelper(); + const result = await testServerHelper.createTestServer(settings); + + _appServer = result.appServer; + httpServer = result.httpServer; + baseURL = result.baseURL; + }, 60000); + + afterAll(async () => { + // Clean up all MCP server connections first + cleanupAllServers(); + + // Close the test server properly using the helper + if (testServerHelper) { + await testServerHelper.closeTestServer(); + } else if (httpServer) { + // Fallback to direct close if helper is not available + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + } + + // Wait a bit to ensure all async operations complete + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + describe('SSE Client Transport Tests', () => { + it('should connect using real SSEClientTransport', async () => { + const sseUrl = new URL(`${baseURL}/sse`); + const options = { + requestInit: { + headers: { + Authorization: 'Bearer test-auth-token-123', + }, + }, + }; + + const transport = new SSEClientTransport(sseUrl, options); + + const client = new Client( + { + name: 'real-sse-test-client', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + prompts: {}, + }, + }, + ); + + let isConnected = false; + let error: any = null; + + try { + await client.connect(transport, {}); + isConnected = true; + console.log('SSE Client connected successfully'); + + // Test list tools + const tools = await client.listTools({}); + console.log('Available tools (SSE):', JSON.stringify(tools, null, 2)); + + await client.close(); + console.log('SSE Client closed successfully'); + } catch (err) { + error = err; + console.error('SSE Client test failed:', err); + + if (isConnected) { + try { + await client.close(); + } catch (closeErr) { + console.error('Error closing client:', closeErr); + } + } + } + + expect(error).toBeNull(); + expect(isConnected).toBe(true); + }, 30000); + + it('should connect using real SSEClientTransport with group', async () => { + const testGroup = 'integration-test-group'; + const options = { + requestInit: { + headers: { + Authorization: 'Bearer test-auth-token-123', + }, + }, + }; + const sseUrl = new URL(`${baseURL}/sse/${testGroup}`); + + const transport = new SSEClientTransport(sseUrl, options); + + const client = new Client( + { + name: 'real-sse-group-test-client', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + prompts: {}, + }, + }, + ); + + let isConnected = false; + let error: any = null; + + try { + await client.connect(transport, {}); + isConnected = true; + + console.log(`SSE Client with group ${testGroup} connected successfully`); + + // Test basic operations + const tools = await client.listTools({}); + console.log('Available tools (SSE with group):', JSON.stringify(tools, null, 2)); + + await client.close(); + } catch (err) { + error = err; + console.error('SSE Client with group test failed:', err); + + if (isConnected) { + try { + await client.close(); + } catch (closeErr) { + console.error('Error closing client:', closeErr); + } + } + } + + expect(error).toBeNull(); + expect(isConnected).toBe(true); + }, 30000); + }); + + describe('StreamableHTTP Client Transport Tests', () => { + it('should connect using real StreamableHTTPClientTransport', async () => { + const mcpUrl = new URL(`${baseURL}/mcp`); + const options: any = { + requestInit: { + headers: { + Authorization: `Bearer test-auth-token-123`, + }, + }, + }; + + const transport = new StreamableHTTPClientTransport(mcpUrl, options); + + const client = new Client( + { + name: 'real-http-test-client', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + prompts: {}, + }, + }, + ); + + let isConnected = false; + let error: any = null; + + try { + await client.connect(transport, {}); + isConnected = true; + console.log('HTTP Client connected successfully'); + + // Test list tools + const tools = await client.listTools({}); + console.log('Available tools (HTTP):', JSON.stringify(tools, null, 2)); + + await client.close(); + console.log('HTTP Client closed successfully'); + } catch (err) { + error = err; + console.error('HTTP Client test failed:', err); + + if (isConnected) { + try { + await client.close(); + } catch (closeErr) { + console.error('Error closing client:', closeErr); + } + } + } + + expect(error).toBeNull(); + expect(isConnected).toBe(true); + }, 30000); + + it('should connect using real StreamableHTTPClientTransport with group', async () => { + const testGroup = 'integration-test-group'; + const mcpUrl = new URL(`${baseURL}/mcp/${testGroup}`); + const options: any = { + requestInit: { + headers: { + Authorization: `Bearer test-auth-token-123`, + }, + }, + }; + + const transport = new StreamableHTTPClientTransport(mcpUrl, options); + + const client = new Client( + { + name: 'real-http-group-test-client', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + prompts: {}, + }, + }, + ); + + let isConnected = false; + let error: any = null; + + try { + await client.connect(transport, {}); + isConnected = true; + + console.log(`HTTP Client with group ${testGroup} connected successfully`); + + // Test basic operations + const tools = await client.listTools({}); + console.log('Available tools (HTTP with group):', JSON.stringify(tools, null, 2)); + + await client.close(); + } catch (err) { + error = err; + console.error('HTTP Client with group test failed:', err); + + if (isConnected) { + try { + await client.close(); + } catch (closeErr) { + console.error('Error closing client:', closeErr); + } + } + } + + expect(error).toBeNull(); + expect(isConnected).toBe(true); + }, 30000); + }); + + describe('Real Client Authentication Tests', () => { + let _authAppServer: AppServer; + let _authHttpServer: Server; + let authBaseURL: string; + + beforeAll(async () => { + const authSettings = mockSettings.createMockSettingsWithAuth(); + const authTestServerHelper = new TestServerHelper(); + const authResult = await authTestServerHelper.createTestServer(authSettings); + + _authAppServer = authResult.appServer; + _authHttpServer = authResult.httpServer; + authBaseURL = authResult.baseURL; + }, 30000); + + afterAll(async () => { + if (_authHttpServer) { + _authHttpServer.close(); + } + }); + + it('should fail to connect with SSEClientTransport without auth', async () => { + const sseUrl = new URL(`${authBaseURL}/sse`); + const options = { + requestInit: { + headers: { + Authorization: 'Bearer test-auth-token-123', + }, + }, + }; + const transport = new SSEClientTransport(sseUrl, options); + + const client = new Client( + { + name: 'real-sse-test-client-no-auth', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + prompts: {}, + }, + }, + ); + + let error: any = null; + + try { + await client.connect(transport, {}); + + // Should not reach here due to auth failure + await client.listTools({}); + + await client.close(); + } catch (err) { + error = err; + console.log('Expected auth error:', err); + + try { + await client.close(); + } catch (closeErr) { + // Ignore close errors after connection failure + } + } + + expect(error).toBeDefined(); + if (error) { + expect(error.message).toContain('401'); + } + }, 30000); + + it('should connect with SSEClientTransport with valid auth', async () => { + const sseUrl = new URL(`${authBaseURL}/sse`); + + const options = { + requestInit: { + headers: { + Authorization: 'Bearer test-auth-token-123', + }, + }, + }; + + const transport = new SSEClientTransport(sseUrl, options); + + const client = new Client( + { + name: 'real-sse-auth-test-client', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + prompts: {}, + }, + }, + ); + + let isConnected = false; + let error: any = null; + + try { + await client.connect(transport, {}); + isConnected = true; + console.log('SSE Client with auth connected successfully'); + + // Test basic operations + const tools = await client.listTools({}); + console.log('Available tools (SSE with auth):', JSON.stringify(tools, null, 2)); + + await client.close(); + } catch (err) { + error = err; + console.error('SSE Client with auth test failed:', err); + + if (isConnected) { + try { + await client.close(); + } catch (closeErr) { + console.error('Error closing client:', closeErr); + } + } + } + + expect(error).toBeNull(); + expect(isConnected).toBe(true); + }, 30000); + + it('should connect with StreamableHTTPClientTransport with auth', async () => { + const mcpUrl = new URL(`${authBaseURL}/mcp`); + + const options = { + requestInit: { + headers: { + Authorization: 'Bearer test-auth-token-123', + }, + }, + }; + + const transport = new StreamableHTTPClientTransport(mcpUrl, options); + + const client = new Client( + { + name: 'real-http-auth-test-client', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + prompts: {}, + }, + }, + ); + + let isConnected = false; + let error: any = null; + + try { + await client.connect(transport, {}); + isConnected = true; + + console.log('HTTP Client with auth connected successfully'); + + // Test basic operations + const tools = await client.listTools({}); + console.log('Available tools (HTTP with auth):', JSON.stringify(tools, null, 2)); + + await client.close(); + } catch (err) { + error = err; + console.error('HTTP Client with auth test failed:', err); + + if (isConnected) { + try { + await client.close(); + } catch (closeErr) { + console.error('Error closing client:', closeErr); + } + } + } + + expect(error).toBeNull(); + expect(isConnected).toBe(true); + }, 30000); + }); +}); diff --git a/tests/utils/mockSettings.ts b/tests/utils/mockSettings.ts new file mode 100644 index 0000000..87d09f1 --- /dev/null +++ b/tests/utils/mockSettings.ts @@ -0,0 +1,107 @@ +import { McpSettings, ServerConfig, SystemConfig, IGroup, IUser } from '../../src/types/index.js'; + +/** + * Creates mock MCP settings for testing + * @param overrides Optional configuration overrides + * @returns Mock McpSettings object + */ +export const createMockSettings = (overrides: Partial = {}): McpSettings => { + const defaultSettings: McpSettings = { + mcpServers: { + 'test-server-1': { + command: 'npx', + args: ['-y', 'time-mcp'], + env: {}, + enabled: true, + keepAliveInterval: 30000, + type: 'stdio', + } as ServerConfig, + }, + groups: [ + { + name: 'integration-test-group', + servers: ['test-server-1'], + description: 'Test group for integration tests', + owner: 'admin', + } as IGroup, + ], + systemConfig: { + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: true, + bearerAuthKey: 'test-auth-token-123', + }, + } as SystemConfig, + users: [ + { + username: 'testuser', + password: 'testpass', + isAdmin: false, + } as IUser, + ], + }; + + return { + ...defaultSettings, + ...overrides, + mcpServers: { + ...defaultSettings.mcpServers, + ...(overrides.mcpServers || {}), + }, + groups: [...(defaultSettings.groups || []), ...(overrides.groups || [])], + systemConfig: { + ...defaultSettings.systemConfig, + ...(overrides.systemConfig || {}), + }, + }; +}; + +/** + * Creates mock settings with bearer authentication enabled + */ +export const createMockSettingsWithAuth = (bearerKey = 'test-auth-token-123'): McpSettings => { + return createMockSettings({ + systemConfig: { + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: true, + bearerAuthKey: bearerKey, + }, + }, + }); +}; + +/** + * Creates mock settings with global routes disabled + */ +export const createMockSettingsNoGlobalRoutes = (): McpSettings => { + return createMockSettings({ + systemConfig: { + routing: { + enableGlobalRoute: false, + enableGroupNameRoute: true, + enableBearerAuth: false, + bearerAuthKey: '', + }, + }, + }); +}; + +/** + * Mock settings helper for specific test scenarios + */ +export const getMockSettingsForScenario = ( + scenario: 'auth' | 'no-global' | 'basic', +): McpSettings => { + switch (scenario) { + case 'auth': + return createMockSettingsWithAuth(); + case 'no-global': + return createMockSettingsNoGlobalRoutes(); + case 'basic': + default: + return createMockSettings(); + } +}; diff --git a/tests/utils/testServerHelper.ts b/tests/utils/testServerHelper.ts new file mode 100644 index 0000000..03b5bba --- /dev/null +++ b/tests/utils/testServerHelper.ts @@ -0,0 +1,176 @@ +import { Server } from 'http'; +import { AppServer } from '../../src/server.js'; +import { McpSettings } from '../../src/types/index.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createMockSettings } from './mockSettings.js'; +import { clearSettingsCache } from '../../src/config/index.js'; + +/** + * Test server helper class for managing AppServer instances during testing + */ +export class TestServerHelper { + private appServer: AppServer | null = null; + private httpServer: Server | null = null; + private originalConfigPath: string | null = null; + private testConfigPath: string | null = null; + + /** + * Creates and initializes a test server with mock settings + * @param mockSettings Optional mock settings to use + * @returns Object containing server instance and base URL + */ + async createTestServer(mockSettings?: McpSettings): Promise<{ + appServer: AppServer; + httpServer: Server; + baseURL: string; + port: number; + }> { + // Use provided mock settings or create default ones + const settings = mockSettings || createMockSettings(); + + // Create temporary config file for testing + await this.setupTemporaryConfig(settings); + + // Create and initialize AppServer + this.appServer = new AppServer(); + await this.appServer.initialize(); + + // Wait for server connection with timeout + const maxAttempts = 30; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (this.appServer.connected()) { + console.log('Test server is ready'); + break; + } else if (attempt === maxAttempts - 1) { + throw new Error('Test server did not become ready in time'); + } + console.log(`Waiting for test server to be ready... Attempt ${attempt + 1}/${maxAttempts}`); + await delay(3000); // Short delay between checks + } + + // Start server on random available port + const app = this.appServer.getApp(); + this.httpServer = app.listen(0); + + const address = this.httpServer.address(); + const port = typeof address === 'object' && address ? address.port : 3000; + const baseURL = `http://localhost:${port}`; + + return { + appServer: this.appServer, + httpServer: this.httpServer, + baseURL, + port, + }; + } + + /** + * Closes the test server and cleans up temporary files + */ + async closeTestServer(): Promise { + if (this.httpServer) { + await new Promise((resolve) => { + this.httpServer!.close(() => resolve()); + }); + this.httpServer = null; + } + + this.appServer = null; + + // Clean up temporary config file + await this.cleanupTemporaryConfig(); + } + + /** + * Sets up a temporary config file for testing + * @param settings Mock settings to write to the config file + */ + private async setupTemporaryConfig(settings: McpSettings): Promise { + // Store original path if it exists + this.originalConfigPath = process.env.MCPHUB_SETTING_PATH || null; + + const configDir = path.join(process.cwd(), 'temp-test-config'); + + // Create temp config directory if it doesn't exist + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + this.testConfigPath = path.join(configDir, 'mcp_settings.json'); + + // Write mock settings to temporary file + fs.writeFileSync(this.testConfigPath, JSON.stringify(settings, null, 2)); + + // Override the settings path for the test + process.env.MCPHUB_SETTING_PATH = this.testConfigPath; + + // Clear settings cache to force re-reading from the new config file + clearSettingsCache(); + + console.log(`Set test config path: ${this.testConfigPath}`); + } + + /** + * Cleans up the temporary config file + */ + private async cleanupTemporaryConfig(): Promise { + if (this.testConfigPath && fs.existsSync(this.testConfigPath)) { + fs.unlinkSync(this.testConfigPath); + + // Try to remove the temp directory if empty + const configDir = path.dirname(this.testConfigPath); + try { + fs.rmdirSync(configDir); + } catch (error) { + // Ignore error if directory is not empty + } + } + + // Reset environment variable + if (this.originalConfigPath !== null) { + process.env.MCPHUB_SETTING_PATH = this.originalConfigPath; + } else { + delete process.env.MCPHUB_SETTING_PATH; + } + + this.testConfigPath = null; + } +} + +/** + * Waits for a server to be ready by attempting to connect + * @param baseURL Base URL of the server + * @param maxAttempts Maximum number of connection attempts + * @param delay Delay between attempts in milliseconds + */ +export const waitForServerReady = async ( + baseURL: string, + maxAttempts = 10, + delay = 500, +): Promise => { + for (let i = 0; i < maxAttempts; i++) { + try { + const response = await fetch(`${baseURL}/health`); + if (response.ok || response.status === 404) { + return; // Server is responding + } + } catch (error) { + // Server not ready yet + } + + if (i < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw new Error(`Server at ${baseURL} not ready after ${maxAttempts} attempts`); +}; + +/** + * Creates a promise that resolves after the specified delay + * @param ms Delay in milliseconds + */ +export const delay = (ms: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; diff --git a/tsconfig.json b/tsconfig.json index 227cf73..4aacaa3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "sourceMap": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, - "strictPropertyInitialization": false + "strictPropertyInitialization": false, + "isolatedModules": true }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts", "dist"] diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..1559d80 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "NodeNext", + "isolatedModules": true, + "types": ["jest", "node"] + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +}