Compare commits

...

15 Commits

Author SHA1 Message Date
samanhappy
6d0d622bd8 feat: add permissions for contents and packages in build workflow (#238) 2025-07-22 10:05:16 +08:00
samanhappy
ab50c7e9eb feat: add conditional check for repository in build and npm publish workflows (#236)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-07-22 08:56:50 +08:00
samanhappy
e507bea2e3 Refactor service registration and revert lazy loading implementation (#234) 2025-07-20 22:30:09 +08:00
samanhappy
0f00ad7200 feat: implement lazy loading for data service and enhance service registration (#233) 2025-07-20 21:37:43 +08:00
samanhappy
b0b0c93337 feat: enable immediate loading of service overrides during registration (#232) 2025-07-20 20:35:00 +08:00
samanhappy
20fd355b87 feat: enhance JSON serialization safety & add dxt upload limit (#230) 2025-07-20 19:18:10 +08:00
dependabot[bot]
4388084704 chore(deps): bump typeorm from 0.3.24 to 0.3.25 (#210)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 09:54:52 +08:00
dependabot[bot]
fe2535461d chore(deps-dev): bump @types/react-dom from 19.1.5 to 19.1.6 (#211)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 09:39:52 +08:00
dependabot[bot]
985598e529 chore(deps): bump pg from 8.16.0 to 8.16.3 (#212)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-05 15:24:37 +08:00
dependabot[bot]
b2b6d0588b chore(deps-dev): bump tailwindcss from 4.1.8 to 4.1.11 (#213)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 14:11:24 +08:00
dependabot[bot]
64628ee3ed chore(deps-dev): bump tsx from 4.19.4 to 4.20.3 (#214)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 18:04:25 +08:00
samanhappy
66d4142039 feat: add variable detection and confirmation dialogs in server forms (#205) 2025-06-29 22:23:42 +08:00
samanhappy
cf72295f99 Refactor UI components across multiple pages for improved styling and consistency (#204) 2025-06-29 22:01:00 +08:00
samanhappy
89f85c73ff fix: resolve race conditions in initializeClientsFromSettings (#201) 2025-06-28 22:11:14 +08:00
samanhappy
adabf1d92b feat:support DXT file server installation (#200) 2025-06-27 14:45:24 +08:00
81 changed files with 5911 additions and 1361 deletions

50
.github/copilot-instructions.md vendored Normal file
View File

@@ -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/`

View File

@@ -8,6 +8,9 @@ on:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
variant: ${{ startsWith(github.ref, 'refs/tags/') && fromJSON('["base", "full"]') || fromJSON('["base"]') }}
@@ -30,16 +33,27 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: endsWith(github.repository, 'mcphub')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: endsWith(github.repository, 'mcphubx')
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: samanhappy/mcphub
images: |
${{ endsWith(github.repository, 'mcphub') && github.repository || '' }}
${{ endsWith(github.repository, 'mcphubx') && format('ghcr.io/{0}', 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/') }}
@@ -48,6 +62,7 @@ jobs:
latest=false
- name: Build and Push Docker Image
if: endsWith(github.repository, 'mcphub') || endsWith(github.repository, 'mcphubx')
uses: docker/build-push-action@v5
with:
context: .

View File

@@ -7,6 +7,7 @@ on:
jobs:
publish-npm:
runs-on: ubuntu-latest
if: endsWith(github.repository, 'mcphub')
steps:
- name: Checkout
uses: actions/checkout@v4

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ yarn-error.log*
.vscode/
*.log
coverage/
data/

View File

@@ -57,7 +57,7 @@ Create a `mcp_settings.json` file to customize your server settings:
**Recommended**: Mount your custom config:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
or run with default settings:

View File

@@ -57,7 +57,7 @@ MCPHub 通过将多个 MCPModel Context Protocol服务器组织为灵活
**推荐**:挂载自定义配置:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
或使用默认配置运行:

View File

@@ -1,13 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Hub Dashboard</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="bg-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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() {
<Route path="/" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/market" element={<MarketPage />} />
<Route path="/market/:serverName" element={<MarketPage />} />
<Route path="/logs" element={<LogsPage />} />

View File

@@ -50,7 +50,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
}
const result = await createGroup(formData.name, formData.description, formData.servers)
if (!result) {
setError(t('groups.createError'))
setIsSubmitting(false)
@@ -69,7 +69,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
{error}
@@ -87,7 +87,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
name="name"
value={formData.name}
onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={t('groups.namePlaceholder')}
required
/>
@@ -109,14 +109,14 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.create')}

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ServerForm from './ServerForm'
import { getApiUrl } from '../utils/runtime';
import { getApiUrl } from '../utils/runtime'
import { detectVariables } from '../utils/variableDetection'
interface AddServerFormProps {
onAdd: () => void
@@ -11,13 +12,26 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
const { t } = useTranslation()
const [modalVisible, setModalVisible] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirmationVisible, setConfirmationVisible] = useState(false)
const [pendingPayload, setPendingPayload] = useState<any>(null)
const [detectedVariables, setDetectedVariables] = useState<string[]>([])
const toggleModal = () => {
setModalVisible(!modalVisible)
setError(null) // Clear any previous errors when toggling modal
setConfirmationVisible(false) // Close confirmation dialog
setPendingPayload(null) // Clear pending payload
}
const handleSubmit = async (payload: any) => {
const handleConfirmSubmit = async () => {
if (pendingPayload) {
await submitServer(pendingPayload)
setConfirmationVisible(false)
setPendingPayload(null)
}
}
const submitServer = async (payload: any) => {
try {
setError(null)
const token = localStorage.getItem('mcphub_token');
@@ -65,11 +79,31 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
}
}
const handleSubmit = async (payload: any) => {
try {
// Check for variables in the payload
const variables = detectVariables(payload)
if (variables.length > 0) {
// Show confirmation dialog
setDetectedVariables(variables)
setPendingPayload(payload)
setConfirmationVisible(true)
} else {
// Submit directly if no variables found
await submitServer(payload)
}
} catch (err) {
console.error('Error processing server submission:', err)
setError(t('errors.serverAdd'))
}
}
return (
<div>
<button
onClick={toggleModal}
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center"
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center btn-primary"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
@@ -87,6 +121,60 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
/>
</div>
)}
{confirmationVisible && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{t('server.confirmVariables')}
</h3>
<p className="text-gray-600 mb-4">
{t('server.variablesDetected')}
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-yellow-800">
{t('server.detectedVariables')}:
</h4>
<ul className="mt-1 text-sm text-yellow-700">
{detectedVariables.map((variable, index) => (
<li key={index} className="font-mono">
${`{${variable}}`}
</li>
))}
</ul>
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">
{t('server.confirmVariablesMessage')}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false)
setPendingPayload(null)
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
>
{t('server.confirmAndAdd')}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -31,17 +31,17 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validate passwords match
if (formData.newPassword !== confirmPassword) {
setError(t('auth.passwordsNotMatch'));
return;
}
setIsLoading(true);
try {
const response = await changePassword(formData);
if (response.success) {
setSuccess(true);
if (onSuccess) {
@@ -60,7 +60,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
return (
<div className="p-6 bg-white rounded-lg shadow-md">
<h2 className="text-xl font-bold mb-4">{t('auth.changePassword')}</h2>
{success ? (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{t('auth.changePasswordSuccess')}
@@ -72,7 +72,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
{error}
</div>
)}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="currentPassword">
{t('auth.currentPassword')}
@@ -81,13 +81,13 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="currentPassword"
name="currentPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={formData.currentPassword}
onChange={handleChange}
required
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="newPassword">
{t('auth.newPassword')}
@@ -96,14 +96,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="newPassword"
name="newPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={formData.newPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirmPassword">
{t('auth.confirmPassword')}
@@ -112,14 +112,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="confirmPassword"
name="confirmPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={confirmPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="flex justify-end space-x-2">
{onCancel && (
<button
@@ -134,7 +134,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
<button
type="submit"
disabled={isLoading}
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 btn-primary"
>
{isLoading ? (
<span className="flex items-center">

View File

@@ -0,0 +1,413 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getApiUrl } from '@/utils/runtime';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
interface DxtUploadFormProps {
onSuccess: (serverConfig: any) => void;
onCancel: () => void;
}
interface DxtUploadResponse {
success: boolean;
data?: {
manifest: any;
extractDir: string;
};
message?: string;
}
const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) => {
const { t } = useTranslation();
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [showServerForm, setShowServerForm] = useState(false);
const [manifestData, setManifestData] = useState<any>(null);
const [extractDir, setExtractDir] = useState<string>('');
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingServerName, setPendingServerName] = useState<string>('');
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.name.endsWith('.dxt')) {
setSelectedFile(file);
setError(null);
} else {
setError(t('dxt.invalidFileType'));
}
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
if (file.name.endsWith('.dxt')) {
setSelectedFile(file);
setError(null);
} else {
setError(t('dxt.invalidFileType'));
}
}
};
const handleUpload = async () => {
if (!selectedFile) {
setError(t('dxt.noFileSelected'));
return;
}
setIsUploading(true);
setError(null);
try {
const formData = new FormData();
formData.append('dxtFile', selectedFile);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/dxt/upload'), {
method: 'POST',
headers: {
'x-auth-token': token || '',
},
body: formData,
});
const result: DxtUploadResponse = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
}
if (result.success && result.data) {
setManifestData(result.data.manifest);
setExtractDir(result.data.extractDir);
setShowServerForm(true);
} else {
throw new Error(result.message || t('dxt.uploadFailed'));
}
} catch (err) {
console.error('DXT upload error:', err);
setError(err instanceof Error ? err.message : t('dxt.uploadFailed'));
} finally {
setIsUploading(false);
}
};
const handleInstallServer = async (serverName: string, forceOverride: boolean = false) => {
setIsUploading(true);
setError(null);
try {
// Convert DXT manifest to MCPHub stdio server configuration
const serverConfig = convertDxtToMcpConfig(manifestData, extractDir, serverName);
const token = localStorage.getItem('mcphub_token');
// First, check if server exists
if (!forceOverride) {
const checkResponse = await fetch(getApiUrl('/servers'), {
method: 'GET',
headers: {
'x-auth-token': token || '',
},
});
if (checkResponse.ok) {
const checkResult = await checkResponse.json();
const existingServer = checkResult.data?.find((server: any) => server.name === serverName);
if (existingServer) {
// Server exists, show confirmation dialog
setPendingServerName(serverName);
setShowConfirmDialog(true);
setIsUploading(false);
return;
}
}
}
// Install or override the server
const method = forceOverride ? 'PUT' : 'POST';
const url = forceOverride ? getApiUrl(`/servers/${encodeURIComponent(serverName)}`) : getApiUrl('/servers');
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
name: serverName,
config: serverConfig,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
}
if (result.success) {
onSuccess(serverConfig);
} else {
throw new Error(result.message || t('dxt.installFailed'));
}
} catch (err) {
console.error('DXT install error:', err);
setError(err instanceof Error ? err.message : t('dxt.installFailed'));
setIsUploading(false);
}
};
const handleConfirmOverride = () => {
setShowConfirmDialog(false);
if (pendingServerName) {
handleInstallServer(pendingServerName, true);
}
};
const handleCancelOverride = () => {
setShowConfirmDialog(false);
setPendingServerName('');
setIsUploading(false);
};
const convertDxtToMcpConfig = (manifest: any, extractPath: string, _serverName: string) => {
const mcpConfig = manifest.server?.mcp_config || {};
// Convert DXT manifest to MCPHub stdio configuration
const config: any = {
type: 'stdio',
command: mcpConfig.command || 'node',
args: (mcpConfig.args || []).map((arg: string) =>
arg.replace('${__dirname}', extractPath)
),
};
// Add environment variables if they exist
if (mcpConfig.env && Object.keys(mcpConfig.env).length > 0) {
config.env = { ...mcpConfig.env };
// Replace ${__dirname} in environment variables
Object.keys(config.env).forEach(key => {
if (typeof config.env[key] === 'string') {
config.env[key] = config.env[key].replace('${__dirname}', extractPath);
}
});
}
return config;
};
if (showServerForm && manifestData) {
return (
<>
<ConfirmDialog
isOpen={showConfirmDialog}
onClose={handleCancelOverride}
onConfirm={handleConfirmOverride}
title={t('dxt.serverExistsTitle')}
message={t('dxt.serverExistsConfirm', { serverName: pendingServerName })}
confirmText={t('dxt.override')}
cancelText={t('common.cancel')}
variant="warning"
/>
<div className={`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 ${showConfirmDialog ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="bg-white shadow rounded-lg p-6 w-full max-w-2xl max-h-screen overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.installServer')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700">{error}</p>
</div>
)}
<div className="space-y-6">
{/* Extension Info */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-medium text-gray-900 mb-2">{t('dxt.extensionInfo')}</h3>
<div className="space-y-2 text-sm">
<div><strong>{t('dxt.name')}:</strong> {manifestData.display_name || manifestData.name}</div>
<div><strong>{t('dxt.version')}:</strong> {manifestData.version}</div>
<div><strong>{t('dxt.description')}:</strong> {manifestData.description}</div>
{manifestData.author && (
<div><strong>{t('dxt.author')}:</strong> {manifestData.author.name}</div>
)}
{manifestData.tools && manifestData.tools.length > 0 && (
<div>
<strong>{t('dxt.tools')}:</strong>
<ul className="list-disc list-inside ml-4">
{manifestData.tools.map((tool: any, index: number) => (
<li key={index}>{tool.name} - {tool.description}</li>
))}
</ul>
</div>
)}
</div>
</div>
{/* Server Configuration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('dxt.serverName')}
</label>
<input
type="text"
id="serverName"
defaultValue={manifestData.name}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
placeholder={t('dxt.serverNamePlaceholder')}
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
disabled={isUploading}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={() => {
const nameInput = document.getElementById('serverName') as HTMLInputElement;
const serverName = nameInput?.value.trim() || manifestData.name;
handleInstallServer(serverName);
}}
disabled={isUploading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isUploading ? (
<>
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('dxt.installing')}
</>
) : (
t('dxt.install')
)}
</button>
</div>
</div>
</div>
</div>
</>
);
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-lg">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.uploadTitle')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700">{error}</p>
</div>
)}
{/* File Drop Zone */}
<div
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${isDragging
? 'border-blue-500 bg-blue-50'
: selectedFile
? 'border-gray-500 '
: 'border-gray-300 hover:border-gray-400'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{selectedFile ? (
<div className="space-y-2">
<svg className="mx-auto h-12 w-12 text-green-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-gray-900 font-medium">{selectedFile.name}</p>
<p className="text-xs text-gray-500">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
) : (
<div className="space-y-2">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<div>
<p className="text-sm text-gray-900">{t('dxt.dropFileHere')}</p>
<p className="text-xs text-gray-500">{t('dxt.orClickToSelect')}</p>
</div>
</div>
)}
<input
type="file"
accept=".dxt"
onChange={handleFileSelect}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
<div className="mt-6 flex justify-end space-x-4">
<button
onClick={onCancel}
disabled={isUploading}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleUpload}
disabled={!selectedFile || isUploading}
className="px-4 py-2 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isUploading ? (
<>
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('dxt.uploading')}
</>
) : (
t('dxt.upload')
)}
</button>
</div>
</div>
</div>
);
};
export default DxtUploadForm;

View File

@@ -38,18 +38,6 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
}))
}
const handleServerToggle = (serverName: string) => {
setFormData(prev => {
const isSelected = prev.servers.includes(serverName)
return {
...prev,
servers: isSelected
? prev.servers.filter(name => name !== serverName)
: [...prev.servers, serverName]
}
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
@@ -67,7 +55,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
description: formData.description,
servers: formData.servers
})
if (!result) {
setError(t('groups.updateError'))
setIsSubmitting(false)
@@ -86,7 +74,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
{error}
@@ -104,7 +92,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
name="name"
value={formData.name}
onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={t('groups.namePlaceholder')}
required
/>
@@ -126,14 +114,14 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.save')}

View File

@@ -68,7 +68,7 @@ const GroupCard = ({
const groupServers = servers.filter(server => group.servers.includes(server.name))
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 ">
<div className="flex justify-between items-center mb-4">
<div>
<div className="flex items-center">
@@ -89,7 +89,7 @@ const GroupCard = ({
)}
</div>
<div className="flex items-center space-x-3">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
{t('groups.serverCount', { count: group.servers.length })}
</div>
<button
@@ -121,7 +121,7 @@ const GroupCard = ({
>
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
<span className={`ml-2 inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
}`}></span>
</div>
))}

View File

@@ -48,25 +48,26 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
// Get badge color based on log type
const getLogTypeColor = (type: string) => {
switch (type) {
case 'error': return 'bg-red-400';
case 'warn': return 'bg-yellow-400';
case 'debug': return 'bg-purple-400';
default: return 'bg-blue-400';
case 'error': return 'bg-red-400/80 text-white';
case 'warn': return 'bg-yellow-400/80 text-gray-900';
case 'debug': return 'bg-purple-400/80 text-white';
case 'info': return 'bg-blue-400/80 text-white';
default: return 'bg-blue-400/80 text-white';
}
};
// Get badge color based on log source
const getSourceColor = (source: string) => {
switch (source) {
case 'main': return 'bg-green-400';
case 'child': return 'bg-orange-400';
default: return 'bg-gray-400';
case 'main': return 'bg-green-400/80 text-white';
case 'child': return 'bg-orange-400/80 text-white';
default: return 'bg-gray-400/80 text-white';
}
};
return (
<div className="flex flex-col h-full">
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
<div className="bg-card p-3 rounded-t-md flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
@@ -74,14 +75,14 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
<input
type="text"
placeholder={t('logs.search')}
className="px-2 py-1 text-sm border rounded"
className="shadow appearance-none border border-gray-200 rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{/* Log type filters */}
<div className="flex gap-1 items-center">
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
{(['debug', 'info', 'error', 'warn'] as const).map(type => (
<Badge
key={type}
variant={typeFilter.includes(type) ? 'default' : 'outline'}
@@ -134,6 +135,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
variant="outline"
size="sm"
onClick={onClear}
className='btn-secondary'
disabled={isLoading || logs.length === 0}
>
{t('logs.clearLogs')}
@@ -164,7 +166,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
filteredLogs.map((log, index) => (
<div
key={`${log.timestamp}-${index}`}
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
className={`py-1 ${log.type === 'error' ? 'text-red-500' :
log.type === 'warn' ? 'text-yellow-500' : ''
}`}
>

View File

@@ -15,31 +15,31 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
if (!server.tags || server.tags.length === 0) {
return { tagsToShow: [], hasMore: false, moreCount: 0 };
}
// Estimate available width in the card (in characters)
const estimatedAvailableWidth = 28; // Estimated number of characters that can fit in one line
// Calculate the character space needed for tags and plus sign (including # and spacing)
const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing
// Loop to determine the maximum number of tags that can be displayed
let totalWidth = 0;
let i = 0;
// First, sort tags by length to prioritize displaying shorter tags
const sortedTags = [...server.tags].sort((a, b) => a.length - b.length);
// Calculate how many tags can fit
for (i = 0; i < sortedTags.length; i++) {
const tagWidth = calculateTagWidth(sortedTags[i]);
// If this tag would make the total width exceed available width, stop adding
if (totalWidth + tagWidth > estimatedAvailableWidth) {
break;
}
totalWidth += tagWidth;
// If this is the last tag but there's still space, no need to show "more"
if (i === sortedTags.length - 1) {
return {
@@ -49,16 +49,16 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
};
}
}
// If there's not enough space to display any tags, show at least one
if (i === 0 && sortedTags.length > 0) {
i = 1;
}
// Calculate space needed for the "more" tag
const moreCount = sortedTags.length - i;
const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length;
// If there's enough remaining space to display the "more" tag
if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) {
return {
@@ -67,7 +67,7 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
moreCount
};
}
// If there's not enough space for even the "more" tag, reduce one tag to make room
return {
tagsToShow: sortedTags.slice(0, Math.max(1, i - 1)),
@@ -79,27 +79,27 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
const { tagsToShow, hasMore, moreCount } = getTagsToDisplay();
return (
<div
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer flex flex-col h-full"
<div
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-all duration-200 cursor-pointer flex flex-col h-full page-card"
onClick={() => onClick(server)}
>
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mr-2">{server.display_name}</h3>
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0">
<span className="text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0 label-primary">
{t('market.official')}
</span>
)}
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
{/* Categories */}
<div className="flex flex-wrap gap-1 mb-2 min-h-[28px]">
{server.categories?.length > 0 ? (
server.categories.map((category, index) => (
<span
<span
key={index}
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
className="bg-gray-100 text-gray-800 text-xs px-2 py-1.5 rounded whitespace-nowrap"
>
{category}
</span>
@@ -108,15 +108,15 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
{/* Tags */}
<div className="relative mb-3 min-h-[28px] overflow-x-auto">
{server.tags?.length > 0 ? (
<div className="flex gap-1 items-center whitespace-nowrap">
{tagsToShow.map((tag, index) => (
<span
<span
key={index}
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0 label-secondary"
>
#{tag}
</span>
@@ -131,8 +131,8 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500 border-t border-gray-100">
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500">
<div className="overflow-hidden">
<span className="whitespace-nowrap">{t('market.by')} </span>
<span className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[120px] inline-block align-bottom">

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, MarketServerInstallation } from '@/types';
import ServerForm from './ServerForm';
import { detectVariables } from '../utils/variableDetection';
import { ServerConfig } from '@/types';
@@ -23,6 +24,9 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmationVisible, setConfirmationVisible] = useState(false);
const [pendingPayload, setPendingPayload] = useState<any>(null);
const [detectedVariables, setDetectedVariables] = useState<string[]>([]);
// Helper function to determine button state
const getButtonProps = () => {
@@ -40,7 +44,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
};
} else {
return {
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white",
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
disabled: false,
text: t('market.install')
};
@@ -50,6 +54,27 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const toggleModal = () => {
setModalVisible(!modalVisible);
setError(null); // Clear any previous errors when toggling modal
setConfirmationVisible(false);
setPendingPayload(null);
};
const handleConfirmInstall = async () => {
if (pendingPayload) {
await proceedWithInstall(pendingPayload);
setConfirmationVisible(false);
setPendingPayload(null);
}
};
const proceedWithInstall = async (payload: any) => {
try {
setError(null);
onInstall(server, payload.config);
setModalVisible(false);
} catch (err) {
console.error('Error installing server:', err);
setError(t('errors.serverInstall'));
}
};
const handleInstall = () => {
@@ -72,24 +97,32 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
} else if (server.installations.default) {
return server.installations.default;
}
// If none of the preferred types are available, get the first available installation type
const installTypes = Object.keys(server.installations);
if (installTypes.length > 0) {
return server.installations[installTypes[0]];
}
return undefined;
};
const handleSubmit = async (payload: any) => {
try {
setError(null);
// Pass the server object and the payload (includes env changes) for installation
onInstall(server, payload.config);
setModalVisible(false);
// Check for variables in the payload
const variables = detectVariables(payload);
if (variables.length > 0) {
// Show confirmation dialog
setDetectedVariables(variables);
setPendingPayload(payload);
setConfirmationVisible(true);
} else {
// Install directly if no variables found
await proceedWithInstall(payload);
}
} catch (err) {
console.error('Error installing server:', err);
console.error('Error processing server installation:', err);
setError(t('errors.serverInstall'));
}
};
@@ -114,15 +147,15 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center flex-wrap">
{server.display_name}
{server.display_name}
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
<span className="text-sm font-normal text-gray-600 ml-4">
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
<a
href={server.repository.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline ml-1"
className="text-blue-500 hover:underline ml-1"
>
{t('market.repository')}
</a>
@@ -132,7 +165,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div className="flex items-center">
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-sm font-medium px-4 py-2 rounded mr-2 flex items-center">
<span className="bg-blue-100 text-blue-800 text-sm font-normal px-4 py-2 rounded mr-2 flex items-center label-primary">
{t('market.official')}
</span>
)}
@@ -169,7 +202,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<h3 className="text-lg font-semibold mb-3">{t('market.arguments')}</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.argumentName')}
@@ -198,7 +231,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
{arg.required ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
<span className="text-gray-600"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@@ -228,7 +261,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
element.classList.toggle('hidden');
}
}}
className="text-sm text-blue-600 hover:underline focus:outline-none ml-2"
className="text-sm text-blue-500 font-normal hover:underline focus:outline-none ml-2"
>
{t('market.viewSchema')}
</button>
@@ -281,17 +314,71 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
initialData={{
name: server.name,
status: 'disconnected',
config: preferredInstallation
config: preferredInstallation
? {
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
: undefined
}}
/>
</div>
)}
{confirmationVisible && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{t('server.confirmVariables')}
</h3>
<p className="text-gray-600 mb-4">
{t('server.variablesDetected')}
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-yellow-800">
{t('server.detectedVariables')}:
</h4>
<ul className="mt-1 text-sm text-yellow-700">
{detectedVariables.map((variable, index) => (
<li key={index} className="font-mono">
${`{${variable}}`}
</li>
))}
</ul>
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">
{t('market.confirmVariablesMessage')}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false)
setPendingPayload(null)
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmInstall}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
>
{t('market.confirmAndInstall')}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -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<PermissionCheckerProps> = ({
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;

View File

@@ -128,7 +128,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
return (
<>
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -138,7 +138,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<StatusBadge status={server.status} />
{/* Tool count display */}
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm">
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
@@ -174,7 +174,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
<button
onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
className="p-1 text-gray-400 hover:text-gray-600 transition-colors btn-secondary"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
@@ -201,7 +201,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<div className="flex space-x-2">
<button
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm"
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{t('server.edit')}
</button>
@@ -211,8 +211,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
}`}
disabled={isToggling}
>
@@ -226,11 +226,11 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
</div>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm btn-danger"
>
{t('server.delete')}
</button>
<button className="text-gray-400 hover:text-gray-600">
<button className="text-gray-400 hover:text-gray-600 btn-secondary">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
</div>

View File

@@ -286,7 +286,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="name"
value={formData.name}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: time-mcp"
required
disabled={isEdit}
@@ -403,7 +403,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi!, url: e.target.value }
}))}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: https://api.example.com/openapi.json"
required={serverType === 'openapi' && formData.openapi?.inputMode === 'url'}
/>
@@ -462,7 +462,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
url: prev.openapi?.url || ''
}
}))}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
>
<option value="none">{t('server.openapi.securityNone')}</option>
<option value="apiKey">{t('server.openapi.securityApiKey')}</option>
@@ -474,7 +474,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* API Key Configuration */}
{formData.openapi?.securityType === 'apiKey' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.apiKeyConfig')}</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
@@ -486,7 +486,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, apiKeyName: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm form-input focus:outline-none"
placeholder="Authorization"
/>
</div>
@@ -498,7 +498,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, apiKeyIn: e.target.value as any, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
>
<option value="header">Header</option>
<option value="query">Query</option>
@@ -514,7 +514,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, apiKeyValue: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="your-api-key"
/>
</div>
@@ -524,7 +524,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* HTTP Authentication Configuration */}
{formData.openapi?.securityType === 'http' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.httpAuthConfig')}</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
@@ -535,7 +535,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, httpScheme: e.target.value as any, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
>
<option value="basic">Basic</option>
<option value="bearer">Bearer</option>
@@ -551,7 +551,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, httpCredentials: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder={formData.openapi?.httpScheme === 'basic' ? 'base64-encoded-credentials' : 'bearer-token'}
/>
</div>
@@ -561,7 +561,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* OAuth2 Configuration */}
{formData.openapi?.securityType === 'oauth2' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.oauth2Config')}</h4>
<div className="grid grid-cols-1 gap-3">
<div>
@@ -573,7 +573,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, oauth2Token: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="access-token"
/>
</div>
@@ -583,7 +583,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* OpenID Connect Configuration */}
{formData.openapi?.securityType === 'openIdConnect' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.openIdConnectConfig')}</h4>
<div className="grid grid-cols-1 gap-3">
<div>
@@ -595,7 +595,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, openIdConnectUrl: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="https://example.com/.well-known/openid_configuration"
/>
</div>
@@ -608,7 +608,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, openIdConnectToken: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="id-token"
/>
</div>
@@ -624,9 +624,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={addHeaderVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
>
+ {t('server.add')}
+
</button>
</div>
{headerVars.map((headerVar, index) => (
@@ -636,7 +636,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.key}
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Authorization"
/>
<span className="flex items-center">:</span>
@@ -644,16 +644,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.value}
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Bearer token..."
/>
</div>
<button
type="button"
onClick={() => removeHeaderVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
>
- {t('server.remove')}
-
</button>
</div>
))}
@@ -671,7 +671,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="url"
value={formData.url}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
required={serverType === 'sse' || serverType === 'streamable-http'}
/>
@@ -685,9 +685,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={addHeaderVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
>
+ {t('server.add')}
+
</button>
</div>
{headerVars.map((headerVar, index) => (
@@ -697,7 +697,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.key}
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Authorization"
/>
<span className="flex items-center">:</span>
@@ -705,16 +705,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.value}
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Bearer token..."
/>
</div>
<button
type="button"
onClick={() => removeHeaderVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
>
- {t('server.remove')}
-
</button>
</div>
))}
@@ -732,7 +732,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="command"
value={formData.command}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: npx"
required={serverType === 'stdio'}
/>
@@ -747,7 +747,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="arguments"
value={formData.arguments}
onChange={(e) => handleArgsChange(e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: -y time-mcp"
required={serverType === 'stdio'}
/>
@@ -761,9 +761,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={addEnvVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
>
+ {t('server.add')}
+
</button>
</div>
{envVars.map((envVar, index) => (
@@ -773,7 +773,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={envVar.key}
onChange={(e) => handleEnvVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder={t('server.key')}
/>
<span className="flex items-center">:</span>
@@ -781,16 +781,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={envVar.value}
onChange={(e) => handleEnvVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder={t('server.value')}
/>
</div>
<button
type="button"
onClick={() => removeEnvVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
>
- {t('server.remove')}
-
</button>
</div>
))}
@@ -802,7 +802,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{serverType !== 'openapi' && (
<div className="mb-4">
<div
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border"
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
onClick={() => setIsRequestOptionsExpanded(!isRequestOptionsExpanded)}
>
<label className="text-gray-700 text-sm font-bold">
@@ -814,7 +814,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</div>
{isRequestOptionsExpanded && (
<div className="border rounded-b p-4 bg-gray-50 border-t-0">
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="timeout">
@@ -825,7 +825,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="timeout"
value={formData.options?.timeout || 60000}
onChange={(e) => handleOptionsChange('timeout', parseInt(e.target.value) || 60000)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="30000"
min="1000"
max="300000"
@@ -842,7 +842,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="maxTotalTimeout"
value={formData.options?.maxTotalTimeout || ''}
onChange={(e) => handleOptionsChange('maxTotalTimeout', e.target.value ? parseInt(e.target.value) : undefined)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="Optional"
min="1000"
/>
@@ -873,13 +873,13 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={onCancel}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2 btn-secondary"
>
{t('server.cancel')}
</button>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded btn-primary"
>
{isEdit ? t('server.save') : t('server.add')}
</button>

View File

@@ -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));
};

View File

@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import GitHubIcon from '@/components/icons/GitHubIcon';
import SponsorIcon from '@/components/icons/SponsorIcon';
@@ -15,13 +14,12 @@ interface HeaderProps {
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
const { t, i18n } = useTranslation();
const { auth } = useAuth();
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
return (
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
<div className="flex justify-between items-center px-4 py-3">
<div className="flex justify-between items-center px-3 py-3">
<div className="flex items-center">
{/* 侧边栏切换按钮 */}
<button

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, useLocation } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { usePermissionCheck } from '../PermissionChecker';
import UserProfileMenu from '@/components/ui/UserProfileMenu';
interface SidebarProps {
@@ -15,11 +17,11 @@ interface MenuItem {
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
const { t } = useTranslation();
const location = useLocation();
const { auth } = useAuth();
// Application version from package.json (accessed via Vite environment variables)
const appVersion = import.meta.env.PACKAGE_VERSION as string;
// Menu item configuration
const menuItems: MenuItem[] = [
{
@@ -50,6 +52,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
</svg>
),
},
...(auth.user?.isAdmin && usePermissionCheck('x') ? [{
path: '/users',
label: t('nav.users'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
),
}] : []),
{
path: '/market',
label: t('nav.market'),
@@ -71,10 +82,9 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
];
return (
<aside
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${
collapsed ? 'w-16' : 'w-64'
}`}
<aside
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${collapsed ? 'w-16' : 'w-64'
}`}
>
{/* Scrollable navigation area */}
<div className="overflow-y-auto flex-grow">
@@ -83,12 +93,11 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md transition-colors ${
isActive
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`
className={({ isActive }) =>
`flex items-center px-2.5 py-2 rounded-lg transition-colors duration-200
${isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'}`
}
end={item.path === '/'}
>
@@ -98,7 +107,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
))}
</nav>
</div>
{/* User profile menu fixed at the bottom */}
<div className="p-3 bg-white dark:bg-gray-800">
<UserProfileMenu collapsed={collapsed} version={appVersion} />

View File

@@ -93,7 +93,7 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
<button
onClick={checkForUpdates}
disabled={isChecking}
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium btn-secondary
${isChecking
? 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600'

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ServerStatus } from '@/types';
import { cn } from '../../utils/cn';
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
@@ -19,11 +18,11 @@ const badgeVariants = {
destructive: 'bg-red-500 text-white hover:bg-red-600',
};
export function Badge({
children,
variant = 'default',
className,
onClick
export function Badge({
children,
variant = 'default',
className,
onClick
}: BadgeProps) {
return (
<span
@@ -43,11 +42,11 @@ export function Badge({
// For backward compatibility with existing code
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
const { t } = useTranslation();
const colors = {
connecting: 'bg-yellow-100 text-yellow-800',
connected: 'bg-green-100 text-green-800',
disconnected: 'bg-red-100 text-red-800',
connecting: 'status-badge-connecting',
connected: 'status-badge-online',
disconnected: 'status-badge-offline',
};
// Map status to translation keys

View File

@@ -0,0 +1,142 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
cancelText,
variant = 'warning'
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
const getVariantStyles = () => {
switch (variant) {
case 'danger':
return {
icon: (
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
),
confirmClass: 'bg-red-600 hover:bg-red-700 text-white',
};
case 'warning':
return {
icon: (
<svg className="w-6 h-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
),
confirmClass: 'bg-yellow-600 hover:bg-yellow-700 text-white',
};
case 'info':
return {
icon: (
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
};
default:
return {
icon: null,
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
};
}
};
const { icon, confirmClass } = getVariantStyles();
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'Enter') {
onConfirm();
}
};
return (
<div
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div
className="bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-message"
>
<div className="p-6">
<div className="flex items-start space-x-3">
{icon && (
<div className="flex-shrink-0">
{icon}
</div>
)}
<div className="flex-1">
{title && (
<h3
id="confirm-dialog-title"
className="text-lg font-medium text-gray-900 mb-2"
>
{title}
</h3>
)}
<p
id="confirm-dialog-message"
className="text-gray-600 leading-relaxed"
>
{message}
</p>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md transition-colors duration-150 btn-secondary"
autoFocus
>
{cancelText || t('common.cancel')}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 ${confirmClass} ${variant === 'danger' ? 'btn-danger' : variant === 'warning' ? 'btn-warning' : 'btn-primary'}`}
>
{confirmText || t('common.confirm')}
</button>
</div>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View File

@@ -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,23 +19,29 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h3 className="text-lg font-medium text-gray-900 mb-3">
{isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
{isUser
? t('users.confirmDelete')
: isGroup
? t('groups.confirmDelete')
: t('server.confirmDelete')}
</h3>
<p className="text-gray-500 mb-6">
{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 })}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 btn-danger"
>
{t('common.delete')}
</button>

View File

@@ -18,9 +18,10 @@ interface DynamicFormProps {
onCancel: () => void;
loading?: boolean;
storageKey?: string; // Optional key for localStorage persistence
title?: string; // Optional title to display instead of default parameters title
}
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey }) => {
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => {
const { t } = useTranslation();
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -284,7 +285,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
@@ -301,7 +302,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
onChange(val);
}}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
/>
);
}
@@ -323,7 +324,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
@@ -340,7 +341,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -358,7 +359,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newArray.splice(index, 1);
handleInputChange(fullPath, newArray);
}}
className="text-red-500 hover:text-red-700 text-sm"
className="text-status-red hover:text-red-700 text-sm"
>
{t('common.remove')}
</button>
@@ -387,7 +388,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={objKey}>
<label className="block text-xs font-medium text-gray-600 mb-1">
{objKey}
{propSchema.items?.required?.includes(objKey) && <span className="text-red-500 ml-1">*</span>}
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>}
</label>
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
const newArray = [...arrayValue];
@@ -406,7 +407,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newArray[index] = e.target.value;
handleInputChange(fullPath, newArray);
}}
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
placeholder={t('tool.enterValue', { type: propSchema.items?.type || 'value' })}
/>
)}
@@ -425,7 +426,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</button>
</div>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // Handle object type
@@ -436,7 +437,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -448,7 +449,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
))}
</div>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} else {
@@ -457,7 +458,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
</label>
{propSchema.description && (
@@ -478,7 +479,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
className={`w-full border rounded-md px-3 py-2 font-mono text-sm ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
rows={4}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
@@ -488,7 +489,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -505,7 +506,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</option>
))}
</select>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} else {
@@ -513,7 +514,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -522,9 +523,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="text"
value={value || ''}
onChange={(e) => handleInputChange(fullPath, e.target.value)}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red' : 'border-gray-200'} focus:outline-none form-input`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
@@ -533,7 +534,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -546,9 +547,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
handleInputChange(fullPath, val);
}}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
@@ -565,13 +566,13 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/>
<label className="ml-2 block text-sm text-gray-700">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
</div>
{propSchema.description && (
<p className="text-xs text-gray-500 mt-1">{propSchema.description}</p>
)}
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // For other types, show as text input with description
@@ -579,7 +580,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
</label>
{propSchema.description && (
@@ -590,9 +591,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
value={value || ''}
onChange={(e) => handleInputChange(fullPath, e.target.value)}
placeholder={t('tool.enterValue', { type: propSchema.type })}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500 form-input`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
};
@@ -624,15 +625,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
return (
<div className="space-y-4">
{/* Mode Toggle */}
<div className="flex justify-between items-center border-b pb-3">
<h3 className="text-lg font-medium text-gray-900">{t('tool.parameters')}</h3>
<div className="flex justify-between items-center pb-3">
<h6 className="text-md font-medium text-gray-900">{title}</h6>
<div className="flex space-x-2">
<button
type="button"
onClick={switchToFormMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.formMode')}
@@ -641,8 +642,8 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="button"
onClick={switchToJsonMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.jsonMode')}
@@ -661,17 +662,17 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)}
placeholder={`{\n "key": "value"\n}`}
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y ${jsonError ? 'border-red-500' : 'border-gray-300'
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${jsonError ? 'border-red-500' : 'border-gray-300'
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{jsonError && <p className="text-red-500 text-xs mt-1">{jsonError}</p>}
{jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>}
</div>
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('tool.cancel')}
</button>
@@ -685,7 +686,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
}
}}
disabled={loading || !!jsonError}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>
@@ -702,14 +703,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('tool.cancel')}
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>

View File

@@ -6,34 +6,33 @@ interface PaginationProps {
onPageChange: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
}) => {
// Generate page buttons
const getPageButtons = () => {
const buttons = [];
const maxDisplayedPages = 5; // Maximum number of page buttons to display
// Always display first page
buttons.push(
<button
key="first"
onClick={() => onPageChange(1)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === 1
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === 1
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
1
</button>
);
// Start range
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
const startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
// If we're showing ellipsis after first page
if (startPage > 2) {
buttons.push(
@@ -42,24 +41,23 @@ const Pagination: React.FC<PaginationProps> = ({
</span>
);
}
// Middle pages
for (let i = startPage; i <= Math.min(totalPages - 1, startPage + maxDisplayedPages - 3); i++) {
buttons.push(
<button
key={i}
onClick={() => onPageChange(i)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === i
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === i
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
{i}
</button>
);
}
// If we're showing ellipsis before last page
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
buttons.push(
@@ -68,24 +66,23 @@ const Pagination: React.FC<PaginationProps> = ({
</span>
);
}
// Always display last page if there's more than one page
if (totalPages > 1) {
buttons.push(
<button
key="last"
onClick={() => onPageChange(totalPages)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === totalPages
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === totalPages
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
{totalPages}
</button>
);
}
return buttons;
};
@@ -99,25 +96,23 @@ const Pagination: React.FC<PaginationProps> = ({
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className={`px-3 py-1 rounded mr-2 ${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 rounded mr-2 ${currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
&laquo; Prev
</button>
<div className="flex">{getPageButtons()}</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className={`px-3 py-1 rounded ml-2 ${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 rounded ml-2 ${currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
Next &raquo;
</button>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/contexts/ThemeContext';
import { Sun, Moon, Monitor } from 'lucide-react';
import { Sun, Moon } from 'lucide-react';
const ThemeSwitch: React.FC = () => {
const { t } = useTranslation();
@@ -9,7 +9,7 @@ const ThemeSwitch: React.FC = () => {
return (
<div className="flex items-center space-x-2">
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setTheme('light')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'

View File

@@ -9,7 +9,6 @@ interface ToggleGroupItemProps {
}
export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
value,
isSelected,
onClick,
children
@@ -21,8 +20,8 @@ export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
aria-checked={isSelected}
className={cn(
"flex w-full items-center justify-between p-2 rounded transition-colors cursor-pointer",
isSelected
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
isSelected
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
: "hover:bg-gray-50 text-gray-700"
)}
onClick={onClick}
@@ -72,7 +71,7 @@ export const ToggleGroup: React.FC<ToggleGroupProps> = ({
<label className="block text-gray-700 text-sm font-bold mb-2">
{label}
</label>
<div className="border rounded shadow max-h-60 overflow-y-auto">
<div className="border border-gray-200 rounded shadow max-h-60 overflow-y-auto">
{options.length === 0 ? (
<p className="text-gray-500 text-sm p-3">{noOptionsText}</p>
) : (
@@ -118,7 +117,7 @@ export const Switch: React.FC<SwitchProps> = ({
disabled={disabled}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500",
checked ? "bg-blue-600" : "bg-gray-200",
checked ? "bg-blue-200" : "bg-gray-100",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
)}
onClick={() => !disabled && onCheckedChange(!checked)}

View File

@@ -130,7 +130,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
}
return (
<div className="bg-white border border-gray-300 shadow rounded-lg p-4 mb-4">
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -144,7 +144,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
<input
ref={descriptionInputRef}
type="text"
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm"
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm focus:outline-none form-input"
value={customDescription}
onChange={handleDescriptionChange}
onKeyDown={handleDescriptionKeyDown}
@@ -155,7 +155,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
}}
/>
<button
className="ml-2 p-1 text-green-600 hover:text-green-800"
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionSave()
@@ -168,7 +168,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
<>
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
<button
className="ml-2 p-1 text-gray-500 hover:text-blue-600 transition-colors"
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionEdit()
@@ -198,7 +198,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
setIsExpanded(true) // Ensure card is expanded when showing run form
setShowRunForm(true)
}}
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors btn-primary"
disabled={isRunning || !tool.enabled}
>
{isRunning ? (
@@ -228,14 +228,14 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
{/* Run Form */}
{showRunForm && (
<div className="border border-gray-300 rounded-lg p-4 bg-blue-50">
<h4 className="text-sm font-medium text-gray-900 mb-3">{t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}</h4>
<div className="border border-gray-300 rounded-lg p-4">
<DynamicForm
schema={tool.inputSchema || { type: 'object' }}
onSubmit={handleRunTool}
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}
/>
{/* Tool Result */}
{result && (

View File

@@ -65,7 +65,6 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
// For other structured content, try to parse as JSON
try {
const jsonString = typeof item === 'string' ? item : JSON.stringify(item, null, 2);
const parsed = typeof item === 'string' ? JSON.parse(item) : item;
return (
@@ -97,9 +96,9 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{result.success ? (
<CheckCircle size={20} className="text-green-500" />
<CheckCircle size={20} className="text-status-green" />
) : (
<XCircle size={20} className="text-red-500" />
<XCircle size={20} className="text-status-red" />
)}
<div>
<h4 className="text-sm font-medium text-gray-900">

View File

@@ -73,7 +73,7 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
}`}
>
<div className="flex-shrink-0 relative">
<div className="w-7 h-7 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
<div className="w-5 h-5 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
<User className="h-4 w-4 text-gray-700 dark:text-gray-300" />
</div>
{showNewVersionInfo && (
@@ -90,7 +90,7 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
</button>
{isOpen && (
<div className="absolute top-0 transform -translate-y-full left-0 w-48 bg-white dark:bg-gray-800 shadow-lg rounded-md py-1 z-50">
<div className="absolute top-0 transform -translate-y-full left-0 w-full min-w-max bg-white border border-gray-200 dark:bg-gray-800 py-1 z-50">
<button
onClick={handleSettingsClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"

View File

@@ -0,0 +1,9 @@
// Predefined permission constants
export const PERMISSIONS = {
// Settings page permissions
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
} as const;
export default PERMISSIONS;

View File

@@ -1,11 +1,22 @@
/* Use project's custom Tailwind import */
@import "tailwindcss";
@import 'tailwindcss';
/* Add some custom styles to verify CSS is working correctly */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
font-family:
'Inter',
'PingFang SC',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
'Roboto',
'Oxygen',
'Ubuntu',
'Cantarell',
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -13,7 +24,7 @@ body {
/* Dark mode override styles - these will apply when dark class is on html element */
.dark body {
background-color: #111827;
background-color: #1f2a37;
color: #e5e7eb;
}
@@ -37,30 +48,435 @@ body {
color: #d1d5db !important;
}
.dark .text-gray-500 {
/* .dark .text-gray-500 {
color: #9ca3af !important;
}
} */
.dark .border-gray-300 {
border-color: #4b5563 !important;
border-color: #2f3b4c !important;
}
.dark .border-gray-200 {
border-color: #2f3b4c !important;
}
.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
border-color: #2f3b4c !important;
}
.dark .bg-gray-100 {
background-color: #374151 !important;
}
/* Specific hover effects for dark mode */
.dark .hover\:bg-gray-100:hover {
background-color: rgba(110, 127, 156, 0.15) !important;
}
.dark .hover\:text-gray-900:hover {
color: rgb(190, 188, 185) !important;
}
.dark .bg-gray-50 {
background-color: #1f2937 !important;
}
.dark .text-blue-700 {
color: white !important;
}
.dark .bg-blue-50 {
background-color: #4b5563 !important;
}
.dark .bg-blue-200 {
background-color: #576476 !important;
}
.dark .shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24) !important;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1) !important;
}
.bg-custom-blue {
background-color: #4a90e2;
background-color: #4a90e2;
}
.text-custom-white {
color: #ffffff;
}
}
.status-badge-online {
background-color: white !important;
color: rgba(129, 199, 132, 0.9) !important;
border: 1px solid #a6d7b7;
}
/* Enhanced status badge styles for dark theme */
.dark .status-badge-online {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
border: 1px solid rgba(76, 175, 80, 0.3);
}
.status-badge-offline {
background-color: white !important;
color: rgba(107, 114, 128, 0.9) !important;
border: 1px solid #d1d5db;
}
.dark .status-badge-offline {
background-color: rgba(107, 114, 128, 0.15) !important;
color: rgba(156, 163, 175, 0.9) !important;
border: 1px solid rgba(107, 114, 128, 0.3);
}
.status-badge-connecting {
background-color: white !important;
color: rgba(255, 213, 79, 0.9) !important;
border: 1px solid #ffd57f;
}
.dark .status-badge-connecting {
background-color: rgba(255, 193, 7, 0.15) !important;
color: rgba(255, 213, 79, 0.9) !important;
border: 1px solid rgba(255, 193, 7, 0.3);
}
/* Enhanced status icons for dark theme */
.dark .status-icon-blue {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
}
.dark .status-icon-green {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
}
.dark .status-icon-red {
background-color: rgba(244, 67, 54, 0.15) !important;
color: rgba(239, 154, 154, 0.9) !important;
}
.dark .status-icon-yellow {
background-color: rgba(255, 193, 7, 0.15) !important;
color: rgba(255, 213, 79, 0.9) !important;
}
/* Enhanced card hover effects */
.dashboard-card {
transition: all 0.3s ease;
border-radius: 12px;
}
.dashboard-card:hover {
transform: translateY(-2px);
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.2),
0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
/* Icon container hover effects */
.icon-container {
transition: all 0.3s ease;
}
.icon-container:hover {
transform: scale(1.05);
filter: brightness(1.1);
}
/* Progress bar enhancements */
.progress-bar-online {
background: linear-gradient(90deg, rgba(76, 175, 80, 0.8), rgba(129, 199, 132, 0.6));
}
.progress-bar-offline {
background: linear-gradient(90deg, rgba(244, 67, 54, 0.8), rgba(239, 154, 154, 0.6));
}
.progress-bar-connecting {
background: linear-gradient(90deg, rgba(255, 193, 7, 0.8), rgba(255, 213, 79, 0.6));
}
/* Table enhancements for dark theme */
.dark .table-container {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dark thead {
background-color: #252d3a !important;
}
.dark tbody tr {
border-bottom: 1px solid #2f3b4c;
}
tbody tr:hover {
background-color: var(--color-gray-100) !important;
transition: background-color 0.2s ease;
}
.dark tbody tr:hover {
background-color: rgba(55, 65, 81, 0.5) !important;
transition: background-color 0.2s ease;
}
/* Error box enhancements for dark theme */
.dark .error-box {
background-color: rgba(244, 67, 54, 0.1) !important;
border-color: rgba(244, 67, 54, 0.3) !important;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.1);
}
.dark .error-box h3 {
color: rgba(239, 154, 154, 0.9) !important;
}
.dark .error-box p {
color: #d1d5db !important;
}
/* Loading container enhancements */
.loading-container {
border-radius: 12px;
backdrop-filter: blur(10px);
}
.dark .loading-container {
background-color: rgba(31, 41, 55, 0.8) !important;
border: 1px solid #2f3b4c;
}
.label-primary {
background-color: var(--color-blue-50) !important;
color: var(--color-blue-500) !important;
}
.dark .label-primary {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
}
.label-secondary {
background-color: var(--color-green-50) !important;
color: var(--color-green-500) !important;
}
.dark .label-secondary {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
}
.btn-primary {
background-color: #60a5fa !important;
color: #ffffff !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(96, 165, 250, 0.2);
}
.btn-primary:hover {
background-color: #3b82f6 !important;
color: #ffffff !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
/* Enhanced button styles for dark theme */
.dark .btn-primary {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
border: 1px solid rgba(59, 130, 246, 0.3);
transition: all 0.3s ease;
}
.dark .btn-primary:hover {
background-color: rgba(59, 130, 246, 0.25) !important;
color: rgba(96, 165, 250, 1) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.btn-secondary {
background-color: #f9fafb !important;
color: #374151 !important;
border: 1px solid #d1d5db !important;
border-radius: 8px;
font-size: 0.875rem;
}
.btn-secondary:hover {
background-color: #e5e7eb !important;
color: #374151 !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dark .btn-secondary {
background-color: rgba(107, 114, 128, 0.15) !important;
color: rgba(156, 163, 175, 0.9) !important;
border: 1px solid rgba(107, 114, 128, 0.3) !important;
transition: all 0.3s ease;
}
.dark .btn-secondary:hover {
background-color: rgba(107, 114, 128, 0.25) !important;
color: rgba(156, 163, 175, 1) !important;
transform: translateY(-1px);
}
.btn-warning {
background-color: var(--color-yellow-100) !important;
color: var(--color-yellow-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-warning:hover {
background-color: var(--color-yellow-200) !important;
color: var(--color-yellow-800) !important;
}
.dark .btn-warning {
background-color: rgba(234, 179, 8, 0.15) !important;
color: rgba(250, 204, 21, 0.9) !important;
border: 1px solid rgba(234, 179, 8, 0.3);
transition: all 0.3s ease;
}
.dark .btn-warning:hover {
background-color: rgba(234, 179, 8, 0.25) !important;
color: rgba(250, 204, 21, 1) !important;
transform: translateY(-1px);
}
.btn-danger {
background-color: var(--color-red-100) !important;
color: var(--color-red-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-danger:hover {
background-color: var(--color-red-200) !important;
color: var(--color-red-800) !important;
}
.dark .btn-danger {
background-color: rgba(244, 67, 54, 0.15) !important;
color: rgba(239, 154, 154, 0.9) !important;
border: 1px solid rgba(244, 67, 54, 0.3);
transition: all 0.3s ease;
}
.dark .btn-danger:hover {
background-color: rgba(244, 67, 54, 0.25) !important;
color: rgba(239, 154, 154, 1) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.2);
}
.form-input {
background-color: #f9fafb !important;
border-color: #d1d5db !important;
color: #374151 !important;
border-radius: 8px;
transition: all 0.3s ease;
}
.form-input:focus {
border-color: rgba(184, 193, 207, 0.5);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Form input enhancements for dark theme */
.dark .form-input {
background-color: #1f2937 !important;
border-color: #2f3b4c !important;
color: #e5e7eb !important;
border-radius: 8px;
}
.dark .form-input:focus {
border-color: rgba(59, 130, 246, 0.5) !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
}
.dark .form-input::placeholder {
color: #9ca3af !important;
}
/* Card spacing and layout improvements */
.page-card {
border-radius: 12px;
transition: all 0.3s ease;
}
.page-card:hover {
transform: translateY(-1px);
}
.dark .page-card {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1);
}
/* Custom text color to match status-icon-red */
.text-status-red {
color: #991b1b; /* Tailwind red-800 for light mode */
}
.dark .text-status-red {
color: rgba(239, 154, 154, 0.9) !important;
}
.border-red {
border-color: #937d7d; /* Tailwind red-800 for light mode */
}
.dark .border-red {
border-color: rgba(188, 161, 161, 0.9) !important;
}
.dark .text-status-green {
color: rgba(129, 199, 132, 0.9) !important;
}
/* Empty state styling */
.dark .empty-state {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
border-radius: 12px;
text-align: center;
padding: 3rem 2rem;
}
.dark .empty-state p {
color: #9ca3af !important;
}
/* Login page enhancements for dark theme */
.dark .login-container {
background-color: #1f2a37 !important;
}
.dark .login-card {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.2),
0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 12px;
}

View File

@@ -117,6 +117,11 @@
"argumentsPlaceholder": "Enter arguments",
"errorDetails": "Error Details",
"viewErrorDetails": "View error details",
"confirmVariables": "Confirm Variable Configuration",
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
"detectedVariables": "Detected Variables",
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue adding server?",
"confirmAndAdd": "Confirm and Add",
"openapi": {
"inputMode": "Input Mode",
"inputModeUrl": "Specification URL",
@@ -168,18 +173,23 @@
"cancel": "Cancel",
"refresh": "Refresh",
"create": "Create",
"creating": "Creating...",
"update": "Update",
"updating": "Updating...",
"submitting": "Submitting...",
"delete": "Delete",
"remove": "Remove",
"copy": "Copy",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed",
"close": "Close"
"close": "Close",
"confirm": "Confirm"
},
"nav": {
"dashboard": "Dashboard",
"servers": "Servers",
"groups": "Groups",
"users": "Users",
"settings": "Settings",
"changePassword": "Change Password",
"market": "Market",
@@ -200,6 +210,9 @@
"groups": {
"title": "Group Management"
},
"users": {
"title": "User Management"
},
"settings": {
"title": "Settings",
"language": "Language",
@@ -296,7 +309,9 @@
"tagFilterError": "Error filtering servers by tag",
"noInstallationMethod": "No installation method available for this server",
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
"perPage": "Per page"
"perPage": "Per page",
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue installing server?",
"confirmAndInstall": "Confirm and Install"
},
"tool": {
"run": "Run",
@@ -366,5 +381,66 @@
"smartRoutingConfigUpdated": "Smart routing configuration updated successfully",
"smartRoutingRequiredFields": "Database URL and OpenAI API Key are required to enable smart routing",
"smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}"
},
"dxt": {
"upload": "Upload",
"uploadTitle": "Upload DXT Extension",
"dropFileHere": "Drop your .dxt file here",
"orClickToSelect": "or click to select from your computer",
"invalidFileType": "Please select a valid .dxt file",
"noFileSelected": "Please select a .dxt file to upload",
"uploading": "Uploading...",
"uploadFailed": "Failed to upload DXT file",
"installServer": "Install MCP Server from DXT",
"extensionInfo": "Extension Information",
"name": "Name",
"version": "Version",
"description": "Description",
"author": "Author",
"tools": "Tools",
"serverName": "Server Name",
"serverNamePlaceholder": "Enter a name for this server",
"install": "Install",
"installing": "Installing...",
"installFailed": "Failed to install server from DXT",
"serverExistsTitle": "Server Already Exists",
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
"override": "Override"
},
"users": {
"add": "Add User",
"addNew": "Add New User",
"edit": "Edit User",
"delete": "Delete User",
"create": "Create User",
"update": "Update User",
"username": "Username",
"password": "Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"adminRole": "Administrator",
"admin": "Admin",
"user": "User",
"permissions": "Permissions",
"adminPermissions": "Full system access",
"userPermissions": "Limited access",
"currentUser": "You",
"noUsers": "No users found",
"adminRequired": "Administrator access required to manage users",
"usernameRequired": "Username is required",
"passwordRequired": "Password is required",
"passwordTooShort": "Password must be at least 6 characters long",
"passwordMismatch": "Passwords do not match",
"usernamePlaceholder": "Enter username",
"passwordPlaceholder": "Enter password",
"newPasswordPlaceholder": "Leave empty to keep current password",
"confirmPasswordPlaceholder": "Confirm new password",
"createError": "Failed to create user",
"updateError": "Failed to update user",
"deleteError": "Failed to delete user",
"statsError": "Failed to fetch user statistics",
"deleteConfirmation": "Are you sure you want to delete user '{{username}}'? This action cannot be undone.",
"confirmDelete": "Delete User",
"deleteWarning": "Are you sure you want to delete user '{{username}}'? This action cannot be undone."
}
}

View File

@@ -117,6 +117,11 @@
"argumentsPlaceholder": "请输入参数",
"errorDetails": "错误详情",
"viewErrorDetails": "查看错误详情",
"confirmVariables": "确认变量配置",
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
"detectedVariables": "检测到的变量",
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续添加服务器?",
"confirmAndAdd": "确认并添加",
"openapi": {
"inputMode": "输入模式",
"inputModeUrl": "规范 URL",
@@ -169,13 +174,17 @@
"cancel": "取消",
"refresh": "刷新",
"create": "创建",
"creating": "创建中...",
"update": "更新",
"updating": "更新中...",
"submitting": "提交中...",
"delete": "删除",
"remove": "移除",
"copy": "复制",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败",
"close": "关闭"
"close": "关闭",
"confirm": "确认"
},
"nav": {
"dashboard": "仪表盘",
@@ -183,6 +192,7 @@
"settings": "设置",
"changePassword": "修改密码",
"groups": "分组",
"users": "用户",
"market": "市场",
"logs": "日志"
},
@@ -211,6 +221,9 @@
"groups": {
"title": "分组管理"
},
"users": {
"title": "用户管理"
},
"market": {
"title": "服务器市场 - (数据来源于 mcpm.sh"
},
@@ -297,12 +310,14 @@
"tagFilterError": "按标签筛选服务器失败",
"noInstallationMethod": "该服务器没有可用的安装方法",
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
"perPage": "每页显示"
"perPage": "每页显示",
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
"confirmAndInstall": "确认并安装"
},
"tool": {
"run": "运行",
"running": "运行中...",
"runTool": "运行工具",
"runTool": "运行",
"cancel": "取消",
"noDescription": "无描述信息",
"inputSchema": "输入模式:",
@@ -368,5 +383,66 @@
"smartRoutingConfigUpdated": "智能路由配置更新成功",
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}"
},
"dxt": {
"upload": "上传",
"uploadTitle": "上传 DXT 扩展",
"dropFileHere": "将 .dxt 文件拖拽到此处",
"orClickToSelect": "或点击从计算机选择",
"invalidFileType": "请选择有效的 .dxt 文件",
"noFileSelected": "请选择要上传的 .dxt 文件",
"uploading": "上传中...",
"uploadFailed": "上传 DXT 文件失败",
"installServer": "从 DXT 安装 MCP 服务器",
"extensionInfo": "扩展信息",
"name": "名称",
"version": "版本",
"description": "描述",
"author": "作者",
"tools": "工具",
"serverName": "服务器名称",
"serverNamePlaceholder": "为此服务器输入名称",
"install": "安装",
"installing": "安装中...",
"installFailed": "从 DXT 安装服务器失败",
"serverExistsTitle": "服务器已存在",
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
"override": "覆盖"
},
"users": {
"add": "添加",
"addNew": "添加新用户",
"edit": "编辑用户",
"delete": "删除用户",
"create": "创建",
"update": "用户",
"username": "用户名",
"password": "密码",
"newPassword": "新密码",
"confirmPassword": "确认密码",
"adminRole": "管理员",
"admin": "管理员",
"user": "用户",
"permissions": "权限",
"adminPermissions": "完全系统访问权限",
"userPermissions": "受限访问权限",
"currentUser": "当前用户",
"noUsers": "没有找到用户",
"adminRequired": "需要管理员权限才能管理用户",
"usernameRequired": "用户名是必需的",
"passwordRequired": "密码是必需的",
"passwordTooShort": "密码至少需要6个字符",
"passwordMismatch": "密码不匹配",
"usernamePlaceholder": "输入用户名",
"passwordPlaceholder": "输入密码",
"newPasswordPlaceholder": "留空保持当前密码",
"confirmPasswordPlaceholder": "确认新密码",
"createError": "创建用户失败",
"updateError": "更新用户失败",
"deleteError": "删除用户失败",
"statsError": "获取用户统计失败",
"deleteConfirmation": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。",
"confirmDelete": "删除用户",
"deleteWarning": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。"
}
}

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useServerData } from '@/hooks/useServerData';
import { ServerStatus } from '@/types';
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
@@ -22,26 +21,20 @@ const DashboardPage: React.FC = () => {
connecting: 'status.connecting'
}
// Calculate percentage for each status (for dashboard display)
const getStatusPercentage = (status: ServerStatus) => {
if (servers.length === 0) return 0;
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
};
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.dashboard.title')}</h1>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
<h3 className="text-status-red text-lg font-medium">{t('app.error')}</h3>
<p className="text-gray-600 mt-1">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
aria-label={t('app.closeButton')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@@ -52,8 +45,8 @@ const DashboardPage: React.FC = () => {
</div>
)}
{isLoading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
{isLoading && (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
@@ -62,12 +55,14 @@ const DashboardPage: React.FC = () => {
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : (
)}
{!isLoading && (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* Total servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
@@ -80,9 +75,9 @@ const DashboardPage: React.FC = () => {
</div>
{/* Online servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-green-100 text-green-800">
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -92,18 +87,12 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${getStatusPercentage('connected')}%` }}
></div>
</div>
</div>
{/* Offline servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-red-100 text-red-800">
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -113,18 +102,12 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-red-500 rounded-full"
style={{ width: `${getStatusPercentage('disconnected')}%` }}
></div>
</div>
</div>
{/* Connecting servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -134,12 +117,7 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-yellow-500 rounded-full"
style={{ width: `${getStatusPercentage('connecting')}%` }}
></div>
</div>
</div>
</div>
)}
@@ -148,20 +126,20 @@ const DashboardPage: React.FC = () => {
{servers.length > 0 && !isLoading && (
<div className="mt-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.name')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.status')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.tools')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.enabled')}
</th>
</tr>
@@ -173,11 +151,11 @@ const DashboardPage: React.FC = () => {
{server.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
? 'bg-green-100 text-green-800'
: server.status === 'disconnected'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
? 'status-badge-online'
: server.status === 'disconnected'
? 'status-badge-offline'
: 'status-badge-connecting'
}`}>
{t(statusTranslations[server.status] || server.status)}
</span>
@@ -189,7 +167,7 @@ const DashboardPage: React.FC = () => {
{server.enabled !== false ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
<span className="text-status-red"></span>
)}
</td>
</tr>

View File

@@ -9,16 +9,16 @@ import GroupCard from '@/components/GroupCard';
const GroupsPage: React.FC = () => {
const { t } = useTranslation();
const {
groups,
loading: groupsLoading,
error: groupError,
const {
groups,
loading: groupsLoading,
error: groupError,
setError: setGroupError,
deleteGroup,
triggerRefresh
} = useGroupData();
const { servers } = useServerData();
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
@@ -54,7 +54,7 @@ const GroupsPage: React.FC = () => {
<div className="flex space-x-4">
<button
onClick={handleAddGroup}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
@@ -65,13 +65,13 @@ const GroupsPage: React.FC = () => {
</div>
{groupError && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<p>{groupError}</p>
</div>
)}
{groupsLoading ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 loading-container">
<div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
@@ -81,7 +81,7 @@ const GroupsPage: React.FC = () => {
</div>
</div>
) : groups.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('groups.noGroups')}</p>
</div>
) : (

View File

@@ -26,7 +26,7 @@ const LoginPage: React.FC = () => {
}
const success = await login(username, password);
if (success) {
navigate('/');
} else {
@@ -40,18 +40,18 @@ const LoginPage: React.FC = () => {
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8 login-container">
<div className="absolute top-4 right-4">
<ThemeSwitch />
</div>
<div className="max-w-md w-full space-y-8">
<div className="max-w-md w-full space-y-8 login-card p-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
{t('auth.loginTitle')}
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div className="space-y-4">
<div>
<label htmlFor="username" className="sr-only">
{t('auth.username')}
@@ -62,7 +62,7 @@ const LoginPage: React.FC = () => {
type="text"
autoComplete="username"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
className="appearance-none relative block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm transition-all duration-200 form-input shadow-sm"
placeholder={t('auth.username')}
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -78,7 +78,7 @@ const LoginPage: React.FC = () => {
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
className="appearance-none relative block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm login-input transition-all duration-200 form-input shadow-sm"
placeholder={t('auth.password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -87,14 +87,14 @@ const LoginPage: React.FC = () => {
</div>
{error && (
<div className="text-red-500 dark:text-red-400 text-sm text-center">{error}</div>
<div className="text-red-500 dark:text-red-400 text-sm text-center error-box p-2 rounded">{error}</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 login-button transition-all duration-200 btn-primary"
>
{loading ? t('auth.loggingIn') : t('auth.login')}
</button>

View File

@@ -11,9 +11,9 @@ const LogsPage: React.FC = () => {
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">{t('pages.logs.title')}</h1>
<h1 className="text-2xl font-bold text-gray-900">{t('pages.logs.title')}</h1>
</div>
<div className="bg-card rounded-md shadow-sm">
<div className="bg-card rounded-md shadow-sm border border-gray-200 page-card">
<LogViewer
logs={logs}
isLoading={loading}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { MarketServer, ServerConfig } from '@/types';
import { useMarketData } from '@/hooks/useMarketData';
import { useToast } from '@/contexts/ToastContext';
@@ -11,15 +11,13 @@ import Pagination from '@/components/ui/Pagination';
const MarketPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { serverName } = useParams<{ serverName?: string }>();
const { showToast } = useToast();
const {
servers,
allServers,
categories,
tags,
loading,
error,
setError,
@@ -42,7 +40,6 @@ const MarketPage: React.FC = () => {
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [installing, setInstalling] = useState(false);
const [showTags, setShowTags] = useState(false);
// Load server details if a server name is in the URL
useEffect(() => {
@@ -59,7 +56,7 @@ const MarketPage: React.FC = () => {
setSelectedServer(null);
}
};
loadServerDetails();
}, [serverName, fetchServerByName, navigate]);
@@ -72,10 +69,6 @@ const MarketPage: React.FC = () => {
filterByCategory(category);
};
const handleTagClick = (tag: string) => {
filterByTag(tag);
};
const handleClearFilters = () => {
setSearchQuery('');
filterByCategory('');
@@ -115,10 +108,6 @@ const MarketPage: React.FC = () => {
changeServersPerPage(newValue);
};
const toggleTagsVisibility = () => {
setShowTags(!showTags);
};
// Render detailed view if a server is selected
if (selectedServer) {
return (
@@ -144,12 +133,12 @@ const MarketPage: React.FC = () => {
</div>
{error && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<div className="flex items-center justify-between">
<p>{error}</p>
<button
onClick={() => setError(null)}
className="text-red-700 hover:text-red-900"
className="text-red-700 hover:text-red-900 transition-colors duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
@@ -160,7 +149,7 @@ const MarketPage: React.FC = () => {
)}
{/* Search bar at the top */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
<div className="flex-grow">
<input
@@ -168,12 +157,12 @@ const MarketPage: React.FC = () => {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('market.searchPlaceholder')}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
/>
</div>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
{t('market.search')}
</button>
@@ -181,7 +170,7 @@ const MarketPage: React.FC = () => {
<button
type="button"
onClick={handleClearFilters}
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50"
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
>
{t('market.clearFilters')}
</button>
@@ -192,14 +181,14 @@ const MarketPage: React.FC = () => {
<div className="flex flex-col md:flex-row gap-6">
{/* Left sidebar for filters (without search) */}
<div className="md:w-48 flex-shrink-0">
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4">
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4 page-card">
{/* Categories */}
{categories.length > 0 ? (
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
{selectedCategory && (
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByCategory('')}>
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterByCategory('')}>
{t('market.clearCategoryFilter')}
</span>
)}
@@ -209,9 +198,9 @@ const MarketPage: React.FC = () => {
<button
key={category}
onClick={() => handleCategoryClick(category)}
className={`px-3 py-2 rounded text-sm text-left ${selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
}`}
>
{category}
@@ -224,7 +213,7 @@ const MarketPage: React.FC = () => {
<div className="mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
</div>
<div className="flex flex-col gap-2 items-center py-4">
<div className="flex flex-col gap-2 items-center py-4 loading-container">
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
@@ -333,7 +322,7 @@ const MarketPage: React.FC = () => {
id="perPage"
value={serversPerPage}
onChange={handleChangeItemsPerPage}
className="border rounded p-1 text-sm"
className="border rounded p-1 text-sm btn-secondary outline-none"
>
<option value="6">6</option>
<option value="9">9</option>

View File

@@ -6,6 +6,7 @@ import ServerCard from '@/components/ServerCard';
import AddServerForm from '@/components/AddServerForm';
import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData';
import DxtUploadForm from '@/components/DxtUploadForm';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
@@ -23,6 +24,7 @@ const ServersPage: React.FC = () => {
} = useServerData();
const [editingServer, setEditingServer] = useState<Server | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showDxtUpload, setShowDxtUpload] = useState(false);
const handleEditClick = async (server: Server) => {
const fullServerData = await handleServerEdit(server);
@@ -47,6 +49,12 @@ const ServersPage: React.FC = () => {
}
};
const handleDxtUploadSuccess = (_serverConfig: any) => {
// Close upload dialog and refresh servers
setShowDxtUpload(false);
triggerRefresh();
};
return (
<div>
<div className="flex justify-between items-center mb-8">
@@ -54,7 +62,7 @@ const ServersPage: React.FC = () => {
<div className="flex space-x-4">
<button
onClick={() => navigate('/market')}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3z" />
@@ -62,10 +70,19 @@ const ServersPage: React.FC = () => {
{t('nav.market')}
</button>
<AddServerForm onAdd={handleServerAdd} />
<button
onClick={() => setShowDxtUpload(true)}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 13H11V9.413l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.413V13H5.5z" />
</svg>
{t('dxt.upload')}
</button>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200 ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
>
{isRefreshing ? (
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -83,7 +100,7 @@ const ServersPage: React.FC = () => {
</div>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
@@ -91,7 +108,7 @@ const ServersPage: React.FC = () => {
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200 btn-secondary"
aria-label={t('app.closeButton')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@@ -103,7 +120,7 @@ const ServersPage: React.FC = () => {
)}
{isLoading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
@@ -113,7 +130,7 @@ const ServersPage: React.FC = () => {
</div>
</div>
) : servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('app.noServers')}</p>
</div>
) : (
@@ -138,6 +155,13 @@ const ServersPage: React.FC = () => {
onCancel={() => setEditingServer(null)}
/>
)}
{showDxtUpload && (
<DxtUploadForm
onSuccess={handleDxtUploadSuccess}
onCancel={() => setShowDxtUpload(false)}
/>
)}
</div>
);
};

View File

@@ -6,6 +6,8 @@ import { Switch } from '@/components/ui/ToggleGroup';
import { useSettingsData } from '@/hooks/useSettingsData';
import { useToast } from '@/contexts/ToastContext';
import { generateRandomKey } from '@/utils/key';
import { PermissionChecker } from '@/components/PermissionChecker';
import { PERMISSIONS } from '@/constants/permissions';
const SettingsPage: React.FC = () => {
const { t, i18n } = useTranslation();
@@ -203,23 +205,23 @@ const SettingsPage: React.FC = () => {
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
{/* Language Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
<div className="flex space-x-3">
<button
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('en')
? 'bg-blue-500 text-white'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('en')
? 'bg-blue-500 text-white btn-primary'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
}`}
onClick={() => handleLanguageChange('en')}
>
English
</button>
<button
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('zh')
? 'bg-blue-500 text-white'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('zh')
? 'bg-blue-500 text-white btn-primary'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
}`}
onClick={() => handleLanguageChange('zh')}
>
@@ -230,129 +232,131 @@ const SettingsPage: React.FC = () => {
</div>
{/* Smart Routing Configuration Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('smartRoutingConfig')}
>
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
<span className="text-gray-500">
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
</span>
</div>
{sectionsVisible.smartRoutingConfig && (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
</div>
<Switch
disabled={loading}
checked={smartRoutingConfig.enabled}
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
/>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.dbUrl}
onChange={(e) => 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"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('dbUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
</h3>
</div>
<div className="flex items-center gap-3">
<input
type="password"
value={tempSmartRoutingConfig.openaiApiKey}
onChange={(e) => 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}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.openaiApiBaseUrl}
onChange={(e) => 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"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
onChange={(e) => 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"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SMART_ROUTING}>
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
<div
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
onClick={() => toggleSection('smartRoutingConfig')}
>
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
<span className="text-gray-500 transition-transform duration-200">
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
</span>
</div>
)}
</div>
{sectionsVisible.smartRoutingConfig && (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
</div>
<Switch
disabled={loading}
checked={smartRoutingConfig.enabled}
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
/>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.dbUrl}
onChange={(e) => 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}
/>
<button
onClick={() => saveSmartRoutingConfig('dbUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
</h3>
</div>
<div className="flex items-center gap-3">
<input
type="password"
value={tempSmartRoutingConfig.openaiApiKey}
onChange={(e) => 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}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.openaiApiBaseUrl}
onChange={(e) => 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}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
onChange={(e) => 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}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
</div>
)}
</div>
</PermissionChecker>
{/* Route Configuration Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
@@ -392,13 +396,13 @@ const SettingsPage: React.FC = () => {
value={tempRoutingConfig.bearerAuthKey}
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
placeholder={t('settings.bearerAuthKeyPlaceholder')}
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"
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 || !routingConfig.enableBearerAuth}
/>
<button
onClick={saveBearerAuthKey}
disabled={loading || !routingConfig.enableBearerAuth}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
@@ -430,86 +434,90 @@ const SettingsPage: React.FC = () => {
/>
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
<p className="text-sm text-gray-500">{t('settings.skipAuthDescription')}</p>
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SKIP_AUTH}>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
<p className="text-sm text-gray-500">{t('settings.skipAuthDescription')}</p>
</div>
<Switch
disabled={loading}
checked={routingConfig.skipAuth}
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
/>
</div>
<Switch
disabled={loading}
checked={routingConfig.skipAuth}
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
/>
</div>
</PermissionChecker>
</div>
)}
</div>
{/* Installation Configuration Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('installConfig')}
>
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
<span className="text-gray-500">
{sectionsVisible.installConfig ? '▼' : '►'}
</span>
</div>
{sectionsVisible.installConfig && (
<div className="space-y-4 mt-4">
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={installConfig.pythonIndexUrl}
onChange={(e) => 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"
disabled={loading}
/>
<button
onClick={() => saveInstallConfig('pythonIndexUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={installConfig.npmRegistry}
onChange={(e) => 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"
disabled={loading}
/>
<button
onClick={() => saveInstallConfig('npmRegistry')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('installConfig')}
>
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
<span className="text-gray-500">
{sectionsVisible.installConfig ? '▼' : '►'}
</span>
</div>
)}
</div>
{sectionsVisible.installConfig && (
<div className="space-y-4 mt-4">
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={installConfig.pythonIndexUrl}
onChange={(e) => 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}
/>
<button
onClick={() => saveInstallConfig('pythonIndexUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={installConfig.npmRegistry}
onChange={(e) => 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}
/>
<button
onClick={() => saveInstallConfig('npmRegistry')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
</div>
)}
</div>
</PermissionChecker>
{/* Change Password */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">

View File

@@ -0,0 +1,9 @@
import React from 'react';
const UsersPage: React.FC = () => {
return (
<div></div>
);
};
export default UsersPage;

View File

@@ -210,6 +210,30 @@ export interface ApiResponse<T = any> {
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 {

View File

@@ -0,0 +1,27 @@
// Utility function to detect ${} variables in server configurations
export const detectVariables = (payload: any): string[] => {
const variables = new Set<string>();
const variableRegex = /\$\{([^}]+)\}/g;
const checkString = (str: string) => {
let match;
while ((match = variableRegex.exec(str)) !== null) {
variables.add(match[1]);
}
};
const checkObject = (obj: any, path: string = '') => {
if (typeof obj === 'string') {
checkString(obj);
} else if (Array.isArray(obj)) {
obj.forEach((item, index) => checkObject(item, `${path}[${index}]`));
} else if (obj && typeof obj === 'object') {
Object.entries(obj).forEach(([key, value]) => {
checkObject(value, path ? `${path}.${key}` : key);
});
}
};
checkObject(payload);
return Array.from(variables);
};

View File

@@ -12,6 +12,7 @@ module.exports = {
'ts-jest',
{
useESM: true,
tsconfig: './tsconfig.test.json',
},
],
},
@@ -37,8 +38,10 @@ module.exports = {
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'],
extensionsToTreatAsEsm: ['.ts'],
testTimeout: 10000,
testTimeout: 30000,
verbose: true,
};

View File

@@ -47,7 +47,10 @@
"dependencies": {
"@apidevtools/swagger-parser": "^11.0.1",
"@modelcontextprotocol/sdk": "^1.12.1",
"@types/adm-zip": "^0.5.7",
"@types/multer": "^1.4.13",
"@types/pg": "^8.15.2",
"adm-zip": "^0.5.16",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"dotenv": "^16.3.1",
@@ -55,6 +58,7 @@
"express": "^4.21.2",
"express-validator": "^7.2.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.1",
"openai": "^4.103.0",
"openapi-types": "^12.1.3",
"pg": "^8.16.0",
@@ -68,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",
@@ -114,5 +120,5 @@
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"packageManager": "pnpm@10.11.0+sha256.a69e9cb077da419d47d18f1dd52e207245b29cac6e076acedbeb8be3b1a67bd7"
}
"packageManager": "pnpm@10.12.4+sha256.cadfd9e6c9fcc2cb76fe7c0779a5250b632898aea5f53d833a73690c77a778d9"
}

1506
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
ignoredBuiltDependencies:
- '@swc/core'

View File

@@ -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) {

View File

@@ -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<void> => {
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<void> => {
// 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<void> => {
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<void> => {
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<void> => {
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<void>
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<void>
// 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<void>
// 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<void>
console.error('Change password error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
};
};

View File

@@ -0,0 +1,151 @@
import { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import AdmZip from 'adm-zip';
import { ApiResponse } from '../types/index.js';
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(process.cwd(), 'data/uploads/dxt');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
const originalName = path.parse(file.originalname).name;
cb(null, `${originalName}-${timestamp}.dxt`);
},
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
if (file.originalname.endsWith('.dxt')) {
cb(null, true);
} else {
cb(new Error('Only .dxt files are allowed'));
}
},
limits: {
fileSize: 500 * 1024 * 1024, // 500MB limit
},
});
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(process.cwd(), 'data/uploads/dxt');
const serverPattern = `server-${serverName}`;
if (fs.existsSync(uploadDir)) {
const files = fs.readdirSync(uploadDir);
files.forEach((file) => {
if (file.startsWith(serverPattern)) {
const filePath = path.join(uploadDir, file);
if (fs.statSync(filePath).isDirectory()) {
fs.rmSync(filePath, { recursive: true, force: true });
console.log(`Cleaned up old DXT server directory: ${filePath}`);
}
}
});
}
} catch (error) {
console.warn('Failed to cleanup old DXT server files:', error);
// Don't fail the installation if cleanup fails
}
};
export const uploadDxtFile = async (req: Request, res: Response): Promise<void> => {
try {
if (!req.file) {
res.status(400).json({
success: false,
message: 'No DXT file uploaded',
});
return;
}
const dxtFilePath = req.file.path;
const timestamp = Date.now();
const tempExtractDir = path.join(path.dirname(dxtFilePath), `temp-extracted-${timestamp}`);
try {
// Extract the DXT file (which is a ZIP archive) to a temporary directory first
const zip = new AdmZip(dxtFilePath);
zip.extractAllTo(tempExtractDir, true);
// Read and validate the manifest.json
const manifestPath = path.join(tempExtractDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error('manifest.json not found in DXT file');
}
const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
const manifest = JSON.parse(manifestContent);
// Validate required fields in manifest
if (!manifest.dxt_version) {
throw new Error('Invalid manifest: missing dxt_version');
}
if (!manifest.name) {
throw new Error('Invalid manifest: missing name');
}
if (!manifest.version) {
throw new Error('Invalid manifest: missing version');
}
if (!manifest.server) {
throw new Error('Invalid manifest: missing server configuration');
}
// Use server name as the final extract directory for automatic version management
const finalExtractDir = path.join(path.dirname(dxtFilePath), `server-${manifest.name}`);
// Clean up any existing version of this server
cleanupOldDxtServer(manifest.name);
// Move the temporary directory to the final location
fs.renameSync(tempExtractDir, finalExtractDir);
console.log(`DXT server extracted to: ${finalExtractDir}`);
// Clean up the uploaded DXT file
fs.unlinkSync(dxtFilePath);
const response: ApiResponse = {
success: true,
data: {
manifest,
extractDir: finalExtractDir,
},
};
res.json(response);
} catch (extractError) {
// Clean up files on error
if (fs.existsSync(dxtFilePath)) {
fs.unlinkSync(dxtFilePath);
}
if (fs.existsSync(tempExtractDir)) {
fs.rmSync(tempExtractDir, { recursive: true, force: true });
}
throw extractError;
}
} catch (error) {
console.error('DXT upload error:', error);
let message = 'Failed to process DXT file';
if (error instanceof Error) {
message = error.message;
}
res.status(500).json({
success: false,
message,
});
}
};

View File

@@ -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,

View File

@@ -3,24 +3,26 @@ import { ApiResponse, AddServerRequest } from '../types/index.js';
import {
getServersInfo,
addServer,
addOrUpdateServer,
removeServer,
updateMcpServer,
notifyToolChanged,
syncToolEmbedding,
toggleServerStatus,
} 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<void> =
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,7 +272,13 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
}
const result = await updateMcpServer(name, config);
// 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();
res.json({

View File

@@ -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<void> => {
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<void> => {
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',
});
}
};

View File

@@ -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);
}
});
}
});

View File

@@ -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<void> => {
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<void> => {
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<IUser | null>;
getUserDataFromContext(dataType: string): Promise<any>;
isCurrentUserAdmin(): Promise<boolean>;
}
export class ContextAwareDataServiceImpl implements ContextAwareDataService {
private getUserContextService() {
return UserContextService.getInstance();
}
async getCurrentUserFromContext(): Promise<IUser | null> {
const userContextService = this.getUserContextService();
return userContextService.getCurrentUser();
}
async getUserDataFromContext(dataType: string): Promise<any> {
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<boolean> {
const userContextService = this.getUserContextService();
return userContextService.isAdmin();
}
}

View File

@@ -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,
@@ -36,6 +44,7 @@ import { login, register, getCurrentUser, changePassword } from '../controllers/
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
import { callTool } from '../controllers/toolController.js';
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
import { auth } from '../middlewares/auth.js';
const router = express.Router();
@@ -64,9 +73,20 @@ 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);
// DXT upload routes
router.post('/dxt/upload', uploadMiddleware, uploadDxtFile);
// Market routes
router.get('/market/servers', getAllMarketServers);
router.get('/market/servers/search', searchMarketServersByQuery);

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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 ['*'];
}
}

View File

@@ -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

View File

@@ -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,214 @@ 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;
if (conf.type === 'streamable-http') {
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.requestInit = {
headers: conf.headers,
};
}
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
} else if (conf.url) {
// SSE transport
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.eventSourceInit = {
headers: conf.headers,
};
options.requestInit = {
headers: conf.headers,
};
}
transport = new SSEClientTransport(new URL(conf.url), options);
} else if (conf.command && conf.args) {
// Stdio transport
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...replaceEnvVars(conf.env || {}),
};
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
const settings = loadSettings();
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
if (
settings.systemConfig?.install?.pythonIndexUrl &&
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
) {
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
}
if (
settings.systemConfig?.install?.npmRegistry &&
(conf.command === 'npm' ||
conf.command === 'npx' ||
conf.command === 'pnpm' ||
conf.command === 'yarn' ||
conf.command === 'node')
) {
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
}
transport = new StdioClientTransport({
command: conf.command,
args: conf.args,
env: env,
stderr: 'pipe',
});
transport.stderr?.on('data', (data) => {
console.log(`[${name}] [child] ${data}`);
});
} else {
throw new Error(`Unable to create transport for server: ${name}`);
}
return transport;
};
// Helper function to handle client.callTool with reconnection logic
const callToolWithReconnect = async (
serverInfo: ServerInfo,
toolParams: any,
options?: any,
maxRetries: number = 1,
): Promise<any> => {
if (!serverInfo.client) {
throw new Error(`Client not found for server: ${serverInfo.name}`);
}
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
return result;
} catch (error: any) {
// Check if error message starts with "Error POSTing to endpoint (HTTP 40"
const isHttp40xError = error?.message?.startsWith?.('Error POSTing to endpoint (HTTP 40');
// Only retry for StreamableHTTPClientTransport
const isStreamableHttp = serverInfo.transport instanceof StreamableHTTPClientTransport;
if (isHttp40xError && attempt < maxRetries && serverInfo.transport && isStreamableHttp) {
console.warn(
`HTTP 40x error detected for StreamableHTTP server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
);
try {
// Close existing connection
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
serverInfo.keepAliveIntervalId = undefined;
}
serverInfo.client.close();
serverInfo.transport.close();
// Get server configuration to recreate transport
const settings = loadSettings();
const conf = settings.mcpServers[serverInfo.name];
if (!conf) {
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
}
// Recreate transport using helper function
const newTransport = createTransportFromConfig(serverInfo.name, conf);
// Create new client
const client = new Client(
{
name: `mcp-client-${serverInfo.name}`,
version: '1.0.0',
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
},
},
);
// Reconnect with new transport
await client.connect(newTransport, serverInfo.options || {});
// Update server info with new client and transport
serverInfo.client = client;
serverInfo.transport = newTransport;
serverInfo.status = 'connected';
// Reload tools list after reconnection
try {
const tools = await client.listTools({}, serverInfo.options || {});
serverInfo.tools = tools.tools.map((tool) => ({
name: `${serverInfo.name}-${tool.name}`,
description: tool.description || '',
inputSchema: tool.inputSchema || {},
}));
// Save tools as vector embeddings for search
saveToolsAsVectorEmbeddings(serverInfo.name, serverInfo.tools);
} catch (listToolsError) {
console.warn(
`Failed to reload tools after reconnection for server ${serverInfo.name}:`,
listToolsError,
);
// Continue anyway, as the connection might still work for the current tool
}
console.log(`Successfully reconnected to server: ${serverInfo.name}`);
// Continue to next attempt
continue;
} catch (reconnectError) {
console.error(`Failed to reconnect to server ${serverInfo.name}:`, reconnectError);
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to reconnect: ${reconnectError}`;
// If this was the last attempt, throw the original error
if (attempt === maxRetries) {
throw error;
}
}
} else {
// Not an HTTP 40x error or no more retries, throw the original error
throw error;
}
}
}
// This should not be reached, but just in case
throw new Error('Unexpected error in callToolWithReconnect');
};
// Initialize MCP server clients
export const initializeClientsFromSettings = async (isInit: boolean): Promise<ServerInfo[]> => {
const settings = loadSettings();
@@ -113,6 +322,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
console.log(`Skipping disabled server: ${name}`);
serverInfos.push({
name,
owner: conf.owner,
status: 'disconnected',
error: null,
tools: [],
@@ -146,6 +356,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
);
serverInfos.push({
name,
owner: conf.owner,
status: 'disconnected',
error: 'Missing OpenAPI specification URL or schema',
tools: [],
@@ -154,21 +365,22 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
continue;
}
// Create server info first and keep reference to it
const serverInfo: ServerInfo = {
name,
owner: conf.owner,
status: 'connecting',
error: null,
tools: [],
createTime: Date.now(),
enabled: conf.enabled === undefined ? true : conf.enabled,
};
serverInfos.push(serverInfo);
try {
// Create OpenAPI client instance
openApiClient = new OpenAPIClient(conf);
// Add server with connecting status first
const serverInfo: ServerInfo = {
name,
status: 'connecting',
error: null,
tools: [],
createTime: Date.now(),
enabled: conf.enabled === undefined ? true : conf.enabled,
};
serverInfos.push(serverInfo);
console.log(`Initializing OpenAPI server: ${name}...`);
// Perform async initialization
@@ -197,91 +409,13 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
} catch (error) {
console.error(`Failed to initialize OpenAPI server ${name}:`, error);
// Find and update the server info if it was already added
const existingServerIndex = serverInfos.findIndex((s) => s.name === name);
if (existingServerIndex !== -1) {
serverInfos[existingServerIndex].status = 'disconnected';
serverInfos[existingServerIndex].error = `Failed to initialize OpenAPI server: ${error}`;
} else {
// Add new server info with error status
serverInfos.push({
name,
status: 'disconnected',
error: `Failed to initialize OpenAPI server: ${error}`,
tools: [],
createTime: Date.now(),
});
}
// Update the already pushed server info with error status
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to initialize OpenAPI server: ${error}`;
continue;
}
} else if (conf.type === 'streamable-http') {
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.requestInit = {
headers: conf.headers,
};
}
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
} else if (conf.url) {
// Default to SSE only when 'conf.type' is not specified and 'conf.url' is available
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.eventSourceInit = {
headers: conf.headers,
};
options.requestInit = {
headers: conf.headers,
};
}
transport = new SSEClientTransport(new URL(conf.url), options);
} else if (conf.command && conf.args) {
// If type is stdio or if command and args are provided without type
const env: Record<string, string> = {
...(process.env as Record<string, string>), // Inherit all environment variables from parent process
...replaceEnvVars(conf.env || {}), // Override with configured env vars
};
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
// Add UV_DEFAULT_INDEX from settings if available (for Python packages)
const settings = loadSettings(); // Add UV_DEFAULT_INDEX from settings if available (for Python packages)
if (
settings.systemConfig?.install?.pythonIndexUrl &&
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
) {
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
}
// Add npm_config_registry from settings if available (for NPM packages)
if (
settings.systemConfig?.install?.npmRegistry &&
(conf.command === 'npm' ||
conf.command === 'npx' ||
conf.command === 'pnpm' ||
conf.command === 'yarn' ||
conf.command === 'node')
) {
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
}
transport = new StdioClientTransport({
command: conf.command,
args: conf.args,
env: env,
stderr: 'pipe',
});
transport.stderr?.on('data', (data) => {
console.log(`[${name}] [child] ${data}`);
});
} else {
console.warn(`Skipping server '${name}': missing required configuration`);
serverInfos.push({
name,
status: 'disconnected',
error: 'Missing required configuration',
tools: [],
createTime: Date.now(),
});
continue;
transport = createTransportFromConfig(name, conf);
}
const client = new Client(
@@ -312,6 +446,20 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
};
// Create server info first and keep reference to it
const serverInfo: ServerInfo = {
name,
owner: conf.owner,
status: 'connecting',
error: null,
tools: [],
client,
transport,
options: requestOptions,
createTime: Date.now(),
};
serverInfos.push(serverInfo);
client
.connect(transport, initRequestOptions || requestOptions)
.then(() => {
@@ -320,11 +468,6 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
.listTools({}, initRequestOptions || requestOptions)
.then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
const serverInfo = getServerByName(name);
if (!serverInfo) {
console.warn(`Server info not found for server: ${name}`);
return;
}
serverInfo.tools = tools.tools.map((tool) => ({
name: `${name}-${tool.name}`,
@@ -344,33 +487,17 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
console.error(
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
);
const serverInfo = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to list tools: ${error.stack} `;
}
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to list tools: ${error.stack} `;
});
})
.catch((error) => {
console.error(
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
);
const serverInfo = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to connect: ${error.stack} `;
}
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to connect: ${error.stack} `;
});
serverInfos.push({
name,
status: 'connecting',
error: null,
tools: [],
client,
transport,
options: requestOptions,
createTime: Date.now(),
});
console.log(`Initialized client for server: ${name}`);
}
@@ -385,7 +512,11 @@ export const registerAllTools = async (isInit: boolean): Promise<void> => {
// Get all server information
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
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;
@@ -513,6 +644,42 @@ export const updateMcpServer = async (
}
};
// Add or update server (supports overriding existing servers for DXT)
export const addOrUpdateServer = async (
name: string,
config: ServerConfig,
allowOverride: boolean = false,
): Promise<{ success: boolean; message?: string }> => {
try {
const settings = loadSettings();
const exists = !!settings.mcpServers[name];
if (exists && !allowOverride) {
return { success: false, message: 'Server name already exists' };
}
// If overriding and this is a DXT server (stdio type with file paths),
// we might want to clean up old files in the future
if (exists && config.type === 'stdio') {
// Close existing server connections
closeServer(name);
// Remove from server infos
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
}
settings.mcpServers[name] = config;
if (!saveSettings(settings)) {
return { success: false, message: 'Failed to save settings' };
}
const action = exists ? 'updated' : 'added';
return { success: true, message: `Server ${action} successfully` };
} catch (error) {
console.error(`Failed to add/update server: ${name}`, error);
return { success: false, message: 'Failed to add/update server' };
}
};
// Close server client and transport
function closeServer(name: string) {
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
@@ -643,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) {
@@ -866,12 +1035,12 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
toolName = toolName.startsWith(`${targetServerInfo.name}-`)
? toolName.replace(`${targetServerInfo.name}-`, '')
: toolName;
const result = await client.callTool(
const result = await callToolWithReconnect(
targetServerInfo,
{
name: toolName,
arguments: finalArgs,
},
undefined,
targetServerInfo.options || {},
);
@@ -921,7 +1090,11 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
request.params.name = request.params.name.startsWith(`${serverInfo.name}-`)
? request.params.name.replace(`${serverInfo.name}-`, '')
: request.params.name;
const result = await client.callTool(request.params, undefined, serverInfo.options || {});
const result = await callToolWithReconnect(
serverInfo,
request.params,
serverInfo.options || {},
);
console.log(`Tool call result: ${JSON.stringify(result)}`);
return result;
} catch (error) {

45
src/services/registry.ts Normal file
View File

@@ -0,0 +1,45 @@
import { createRequire } from 'module';
import { join } from 'path';
type Class<T> = new (...args: any[]) => T;
interface Service<T> {
defaultImpl: Class<T>;
override?: Class<T>;
}
const registry = new Map<string, Service<any>>();
const instances = new Map<string, unknown>();
export function registerService<T>(key: string, entry: Service<T>) {
// Try to load override immediately during registration
const overridePath = join(process.cwd(), 'src', 'services', key + 'x.ts');
try {
const require = createRequire(process.cwd());
const mod = require(overridePath);
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
if (typeof override === 'function') {
entry.override = override;
}
} catch (error) {
// Silently ignore if override doesn't exist
}
registry.set(key, entry);
}
export function getService<T>(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()}`);
// Use override if available, otherwise use default
const Impl = entry.override || entry.defaultImpl;
const instance = new Impl();
instances.set(key, instance);
return instance;
}

10
src/services/services.ts Normal file
View File

@@ -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>('dataService');
}

View File

@@ -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> = {}): 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<typeof loadSettings>).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<typeof loadSettings>).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<typeof loadSettings>).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<typeof loadSettings>).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<typeof loadSettings>).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<any>).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<any>).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<typeof loadSettings>).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<typeof loadSettings>).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<typeof loadSettings>).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<typeof loadSettings>).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');
});
});
});

View File

@@ -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<void> => {
// 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<void> => {
// 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<voi
const { transport, group } = transportData;
req.params.group = group;
req.query.group = group;
console.log(`Received message for sessionId: ${sessionId} in group: ${group}`);
console.log(`Received message for sessionId: ${sessionId} in group: ${group}${username ? ` for user: ${username}` : ''}`);
await (transport as SSEServerTransport).handlePostMessage(req, res);
};
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
// 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;

View File

@@ -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;
}
}

126
src/services/userService.ts Normal file
View File

@@ -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<IUser | null> => {
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<IUser | null> => {
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;
};

View File

@@ -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<string, UserConfig>; // User-specific configurations
}
// Configuration details for an individual server
@@ -107,6 +119,7 @@ export interface ServerConfig {
env?: Record<string, string>; // Environment variables
headers?: Record<string, string>; // 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<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // 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

View File

@@ -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;
};
};

View File

@@ -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 = <T>(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 = <T extends Record<string, any>>(
obj: T,
excludeProps: string[],
): Omit<T, keyof (typeof excludeProps)[number]> => {
const result = { ...obj };
excludeProps.forEach((prop) => {
delete result[prop];
});
return result;
};

View File

@@ -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';

View File

@@ -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<void>((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);
});
});

107
tests/utils/mockSettings.ts Normal file
View File

@@ -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> = {}): 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();
}
};

View File

@@ -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<void> {
if (this.httpServer) {
await new Promise<void>((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<void> {
// 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<void> {
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<void> => {
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<void> => {
return new Promise((resolve) => setTimeout(resolve, ms));
};

View File

@@ -13,7 +13,8 @@
"sourceMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false
"strictPropertyInitialization": false,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts", "dist"]

12
tsconfig.test.json Normal file
View File

@@ -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"]
}