refactor frontend code

This commit is contained in:
samanhappy
2025-04-09 11:29:35 +08:00
parent aada2d29de
commit b1d8b1a825
27 changed files with 1822 additions and 787 deletions

2
.gitignore vendored
View File

@@ -4,7 +4,7 @@
.pnp.js
# production
/dist
dist
# env files
.env

View File

@@ -20,7 +20,7 @@ RUN pnpm install @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@la
COPY . .
RUN pnpm build
RUN pnpm frontend:build && pnpm build
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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">
</head>
<body class="bg-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};

156
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,156 @@
import { useState, useEffect } from 'react'
import { Server, ApiResponse } from './types'
import ServerCard from './components/ServerCard'
import AddServerForm from './components/AddServerForm'
import EditServerForm from './components/EditServerForm'
function App() {
const [servers, setServers] = useState<Server[]>([])
const [error, setError] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const [editingServer, setEditingServer] = useState<Server | null>(null)
useEffect(() => {
const fetchServers = async () => {
try {
const response = await fetch('/api/servers')
const data = await response.json()
// 处理API响应中的包装对象提取data字段
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data)
} else if (data && Array.isArray(data)) {
// 兼容性处理如果API直接返回数组
setServers(data)
} else {
// 如果数据格式不符合预期,设置为空数组
console.error('Invalid server data format:', data)
setServers([])
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
fetchServers()
// Poll for updates every 5 seconds
const interval = setInterval(fetchServers, 5000)
return () => clearInterval(interval)
}, [refreshKey])
const handleServerAdd = () => {
setRefreshKey(prevKey => prevKey + 1)
}
const handleServerEdit = (server: Server) => {
// Fetch settings to get the full server config before editing
fetch(`/api/settings`)
.then(response => response.json())
.then((settingsData: ApiResponse<{ mcpServers: Record<string, any> }>) => {
if (
settingsData &&
settingsData.success &&
settingsData.data &&
settingsData.data.mcpServers &&
settingsData.data.mcpServers[server.name]
) {
const serverConfig = settingsData.data.mcpServers[server.name]
const fullServerData = {
name: server.name,
status: server.status,
tools: server.tools || [],
config: serverConfig,
}
console.log('Editing server with config:', fullServerData)
setEditingServer(fullServerData)
} else {
console.error('Failed to get server config from settings:', settingsData)
setError(`Could not find configuration data for ${server.name}`)
}
})
.catch(err => {
console.error('Error fetching server settings:', err)
setError(err instanceof Error ? err.message : String(err))
})
}
const handleEditComplete = () => {
setEditingServer(null)
setRefreshKey(prevKey => prevKey + 1)
}
const handleServerRemove = async (serverName: string) => {
try {
const response = await fetch(`/api/servers/${serverName}`, {
method: 'DELETE',
})
const result = await response.json()
if (!response.ok) {
setError(result.message || `Failed to delete server ${serverName}`)
return
}
setRefreshKey(prevKey => prevKey + 1)
} catch (err) {
setError('Error: ' + (err instanceof Error ? err.message : String(err)))
}
}
if (error) {
return (
<div className="min-h-screen bg-red-50 p-8">
<div className="max-w-3xl mx-auto">
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-red-600 text-xl font-semibold">Error</h2>
<p className="text-gray-600 mt-2">{error}</p>
<button
onClick={() => setError(null)}
className="mt-4 bg-red-100 text-red-800 py-1 px-3 rounded hover:bg-red-200"
>
Close
</button>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-3xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">MCP Hub Dashboard</h1>
<AddServerForm onAdd={handleServerAdd} />
</div>
{servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">No MCP servers available</p>
</div>
) : (
<div className="space-y-6">
{servers.map((server, index) => (
<ServerCard
key={index}
server={server}
onRemove={handleServerRemove}
onEdit={handleServerEdit}
/>
))}
</div>
)}
{editingServer && (
<EditServerForm
server={editingServer}
onEdit={handleEditComplete}
onCancel={() => setEditingServer(null)}
/>
)}
</div>
</div>
)
}
export default App

View File

@@ -0,0 +1,55 @@
import { useState } from 'react'
import ServerForm from './ServerForm'
interface AddServerFormProps {
onAdd: () => void
}
const AddServerForm = ({ onAdd }: AddServerFormProps) => {
const [modalVisible, setModalVisible] = useState(false)
const toggleModal = () => {
setModalVisible(!modalVisible)
}
const handleSubmit = async (payload: any) => {
try {
const response = await fetch('/api/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
alert(result.message || 'Failed to add server')
return
}
setModalVisible(false)
onAdd()
} catch (err) {
alert(`Error: ${err instanceof Error ? err.message : String(err)}`)
}
}
return (
<div>
<button
onClick={toggleModal}
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
>
Add New Server
</button>
{modalVisible && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<ServerForm onSubmit={handleSubmit} onCancel={toggleModal} modalTitle="Add New Server" />
</div>
)}
</div>
)
}
export default AddServerForm

View File

@@ -0,0 +1,44 @@
import { Server } from '@/types'
import ServerForm from './ServerForm'
interface EditServerFormProps {
server: Server
onEdit: () => void
onCancel: () => void
}
const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
const handleSubmit = async (payload: any) => {
try {
const response = await fetch(`/api/servers/${server.name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
alert(result.message || 'Failed to update server')
return
}
onEdit()
} catch (err) {
alert(`Error: ${err instanceof Error ? err.message : String(err)}`)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<ServerForm
onSubmit={handleSubmit}
onCancel={onCancel}
initialData={server}
modalTitle={`Edit Server: ${server.name}`}
/>
</div>
)
}
export default EditServerForm

View File

@@ -0,0 +1,83 @@
import { useState } from 'react'
import { Server } from '@/types'
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
import Badge from '@/components/ui/Badge'
import ToolCard from '@/components/ui/ToolCard'
import DeleteDialog from '@/components/ui/DeleteDialog'
interface ServerCardProps {
server: Server
onRemove: (serverName: string) => void
onEdit: (server: Server) => void
}
const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
const [isExpanded, setIsExpanded] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteDialog(true)
}
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation()
onEdit(server)
}
const handleConfirmDelete = () => {
onRemove(server.name)
setShowDeleteDialog(false)
}
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
<h2 className="text-xl font-semibold text-gray-900">{server.name}</h2>
<Badge status={server.status} />
</div>
<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"
>
Edit
</button>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
>
Delete
</button>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
</div>
</div>
<DeleteDialog
isOpen={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={handleConfirmDelete}
serverName={server.name}
/>
{isExpanded && server.tools && (
<div className="mt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Available Tools</h3>
<div className="space-y-4">
{server.tools.map((tool, index) => (
<ToolCard key={index} tool={tool} />
))}
</div>
</div>
)}
</div>
)
}
export default ServerCard

View File

@@ -0,0 +1,269 @@
import { useState } from 'react'
import { Server, EnvVar, ServerFormData } from '@/types'
interface ServerFormProps {
onSubmit: (payload: any) => void
onCancel: () => void
initialData?: Server | null
modalTitle: string
}
const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: ServerFormProps) => {
const [serverType, setServerType] = useState<'sse' | 'stdio'>(
initialData && initialData.config && initialData.config.url ? 'sse' : 'stdio',
)
const [formData, setFormData] = useState<ServerFormData>({
name: (initialData && initialData.name) || '',
url: (initialData && initialData.config && initialData.config.url) || '',
command: (initialData && initialData.config && initialData.config.command) || '',
arguments:
initialData && initialData.config && initialData.config.args
? Array.isArray(initialData.config.args)
? initialData.config.args.join(' ')
: String(initialData.config.args)
: '',
args: (initialData && initialData.config && initialData.config.args) || [],
})
const [envVars, setEnvVars] = useState<EnvVar[]>(
initialData && initialData.config && initialData.config.env
? Object.entries(initialData.config.env).map(([key, value]) => ({ key, value }))
: [],
)
const [error, setError] = useState<string | null>(null)
const isEdit = !!initialData
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFormData({ ...formData, [name]: value })
}
// Transform space-separated arguments string into array
const handleArgsChange = (value: string) => {
let args = value.split(' ').filter((arg) => arg.trim() !== '')
setFormData({ ...formData, arguments: value, args })
}
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
newEnvVars[index][field] = value
setEnvVars(newEnvVars)
}
const addEnvVar = () => {
setEnvVars([...envVars, { key: '', value: '' }])
}
const removeEnvVar = (index: number) => {
const newEnvVars = [...envVars]
newEnvVars.splice(index, 1)
setEnvVars(newEnvVars)
}
// Submit handler for server configuration
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
try {
const env: Record<string, string> = {}
envVars.forEach(({ key, value }) => {
if (key.trim()) {
env[key.trim()] = value
}
})
const payload = {
name: formData.name,
config:
serverType === 'sse'
? { url: formData.url }
: {
command: formData.command,
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
},
}
onSubmit(payload)
} catch (err) {
setError(`Error: ${err instanceof Error ? err.message : String(err)}`)
}
}
return (
<div className="bg-white shadow rounded-lg p-6 w-full max-w-xl max-h-screen overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900">{modalTitle}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && <div className="bg-red-50 text-red-700 p-3 rounded mb-4">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
Server Name
</label>
<input
type="text"
name="name"
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"
placeholder="e.g.: time-mcp"
required
disabled={isEdit}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">Server Type</label>
<div className="flex space-x-4">
<div>
<input
type="radio"
id="command"
name="serverType"
value="command"
checked={serverType === 'stdio'}
onChange={() => setServerType('stdio')}
className="mr-1"
/>
<label htmlFor="command">stdio</label>
</div>
<div>
<input
type="radio"
id="url"
name="serverType"
value="url"
checked={serverType === 'sse'}
onChange={() => setServerType('sse')}
className="mr-1"
/>
<label htmlFor="url">sse</label>
</div>
</div>
</div>
{serverType === 'sse' ? (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="url">
Server URL
</label>
<input
type="url"
name="url"
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"
placeholder="e.g.: http://localhost:3000/sse"
required={serverType === 'sse'}
/>
</div>
) : (
<>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="command">
Command
</label>
<input
type="text"
name="command"
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"
placeholder="e.g.: npx"
required={serverType === 'stdio'}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="arguments">
Arguments
</label>
<input
type="text"
name="arguments"
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"
placeholder="e.g.: -y time-mcp"
required={serverType === 'stdio'}
/>
</div>
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<label className="block text-gray-700 text-sm font-bold">
Environment Variables
</label>
<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"
>
+ Add
</button>
</div>
{envVars.map((envVar, index) => (
<div key={index} className="flex items-center mb-2">
<div className="flex items-center space-x-2 flex-grow">
<input
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"
placeholder="key"
/>
<span className="flex items-center">:</span>
<input
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"
placeholder="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"
>
- Remove
</button>
</div>
))}
</div>
</>
)}
<div className="flex justify-end mt-6">
<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"
>
Cancel
</button>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
>
{isEdit ? 'Save Changes' : 'Add Server'}
</button>
</div>
</form>
</div>
)
}
export default ServerForm

View File

@@ -0,0 +1,10 @@
import { ChevronDown, ChevronRight } from 'lucide-react'
export { ChevronDown, ChevronRight }
const LucideIcons = {
ChevronDown,
ChevronRight,
}
export default LucideIcons

View File

@@ -0,0 +1,23 @@
import { ServerStatus } from '@/types'
interface BadgeProps {
status: ServerStatus
}
const Badge = ({ status }: BadgeProps) => {
const colors = {
connecting: 'bg-yellow-100 text-yellow-800',
connected: 'bg-green-100 text-green-800',
disconnected: 'bg-red-100 text-red-800',
}
return (
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]}`}
>
{status}
</span>
)
}
export default Badge

View File

@@ -0,0 +1,37 @@
interface DeleteDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
serverName: string
}
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName }: DeleteDialogProps) => {
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-gray-900 mb-4">Delete Server</h3>
<p className="text-gray-700">
Are you sure you want to delete server <strong>{serverName}</strong>? This action cannot be undone.
</p>
<div className="flex justify-end mt-6">
<button
onClick={onClose}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
>
Cancel
</button>
<button
onClick={onConfirm}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded"
>
Delete
</button>
</div>
</div>
</div>
)
}
export default DeleteDialog

View File

@@ -0,0 +1,38 @@
import { useState } from 'react'
import { Tool } from '@/types'
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
interface ToolCardProps {
tool: Tool
}
const ToolCard = ({ tool }: ToolCardProps) => {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="bg-white shadow rounded-lg p-4 mb-4">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<h3 className="text-lg font-medium text-gray-900">{tool.name}</h3>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
</div>
{isExpanded && (
<div className="mt-4">
<p className="text-gray-600 mb-2">{tool.description || 'No description available'}</p>
<div className="bg-gray-50 rounded p-2">
<h4 className="text-sm font-medium text-gray-900 mb-2">Input Schema:</h4>
<pre className="text-xs text-gray-600 overflow-auto">
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</div>
</div>
)}
</div>
)
}
export default ToolCard

20
frontend/src/index.css Normal file
View File

@@ -0,0 +1,20 @@
/* Use standard Tailwind directives */
@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',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.bg-custom-blue {
background-color: #4a90e2;
}
.text-custom-white {
color: #ffffff;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,55 @@
// 服务器状态类型
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
// 工具输入模式类型
export interface ToolInputSchema {
type: string;
properties?: Record<string, any>;
required?: string[];
[key: string]: any;
}
// 工具类型
export interface Tool {
name: string;
description?: string;
inputSchema: ToolInputSchema;
}
// 服务器配置类型
export interface ServerConfig {
url?: string;
command?: string;
args?: string[] | string;
env?: Record<string, string>;
}
// 服务器类型
export interface Server {
name: string;
status: ServerStatus;
tools?: Tool[];
config?: ServerConfig;
}
// 环境变量类型
export interface EnvVar {
key: string;
value: string;
}
// 表单数据类型
export interface ServerFormData {
name: string;
url: string;
command: string;
arguments: string;
args: string[];
}
// API响应类型
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}

31
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import tailwindcss from '@tailwindcss/vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});

View File

@@ -7,10 +7,14 @@
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"backend:dev": "tsx watch src/index.ts",
"lint": "eslint . --ext .ts",
"format": "prettier --write \"src/**/*.ts\"",
"test": "jest"
"test": "jest",
"frontend:dev": "cd frontend && vite",
"frontend:build": "cd frontend && vite build",
"frontend:preview": "cd frontend && vite preview",
"dev": "concurrently \"pnpm backend:dev\" \"pnpm frontend:dev\""
},
"keywords": [
"typescript",
@@ -23,6 +27,7 @@
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@shadcn/ui": "^0.0.4",
"@tailwindcss/vite": "^4.1.3",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.21",
@@ -41,17 +46,21 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.3",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.5",
"@types/node": "^20.8.2",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.2.2",
"eslint": "^8.50.0",
"jest": "^29.7.0",
"prettier": "^3.0.3",
"ts-jest": "^29.1.1",
"ts-node-dev": "^2.0.0",
"tsx": "^4.7.0",
"typescript": "^5.2.2"
"typescript": "^5.2.2",
"vite": "^5.2.6"
}
}
}

925
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +0,0 @@
// Reusable confirmation dialog component for server deletion
// Props:
// - isOpen: boolean - Controls dialog visibility
// - onClose: () => void - Handler for dialog dismissal
// - onConfirm: () => void - Handler for delete confirmation
// - serverName: string - Name of the server to be deleted
window.DeleteDialog = function DeleteDialog({ isOpen, onClose, onConfirm, serverName }) {
return (
<div className={`${isOpen ? 'block' : 'hidden'} fixed inset-0 bg-black bg-opacity-50 z-50`}>
<div className="fixed inset-0 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 max-w-sm mx-auto">
<h3 className="text-lg font-medium leading-6 text-gray-900">Delete Server</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete server {serverName}? This action cannot be undone.
</p>
</div>
<div className="mt-4 flex justify-end space-x-3">
<button
onClick={onClose}
className="inline-flex justify-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={onConfirm}
className="inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,44 +0,0 @@
// Lightweight implementation of Lucide icons without external dependencies
// Each icon component accepts:
// - size: number (default: 24) - Icon dimensions in pixels
// - className: string - Additional CSS classes
const ChevronDown = ({ size = 24, className = "" }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`lucide lucide-chevron-down ${className}`}
>
<path d="m6 9 6 6 6-6"/>
</svg>
);
const ChevronRight = ({ size = 24, className = "" }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`lucide lucide-chevron-right ${className}`}
>
<path d="m9 18 6-6-6-6"/>
</svg>
);
// Export icons to global scope for use in other components
window.LucideIcons = {
ChevronDown,
ChevronRight
};

View File

@@ -1,20 +0,0 @@
<!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>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
</head>
<body class="bg-gray-100">
<div id="root"></div>
<script type="text/babel" src="/components/LucideIcons.jsx"></script>
<script type="text/babel" src="/components/DeleteDialog.jsx"></script>
<script type="text/babel" src="/js/app.js"></script>
</body>
</html>

View File

@@ -1,677 +0,0 @@
const { useState, useEffect, Fragment } = React;
const { ChevronDown, ChevronRight } = window.LucideIcons || {};
// Status badge component with predefined color schemes
function Badge({ status }) {
const colors = {
connecting: 'bg-yellow-100 text-yellow-800',
connected: 'bg-green-100 text-green-800',
disconnected: 'bg-red-100 text-red-800',
};
return (
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]}`}
>
{status}
</span>
);
}
// Displays tool details with expandable input schema
function ToolCard({ tool }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="bg-white shadow rounded-lg p-4 mb-4">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<h3 className="text-lg font-medium text-gray-900">{tool.name}</h3>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? (
ChevronDown ? (
<ChevronDown size={18} />
) : (
'▼'
)
) : ChevronRight ? (
<ChevronRight size={18} />
) : (
'▶'
)}
</button>
</div>
{isExpanded && (
<div className="mt-4">
<p className="text-gray-600 mb-2">{tool.description || 'No description available'}</p>
<div className="bg-gray-50 rounded p-2">
<h4 className="text-sm font-medium text-gray-900 mb-2">Input Schema:</h4>
<pre className="text-xs text-gray-600 overflow-auto">
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</div>
</div>
)}
</div>
);
}
// Delete confirmation dialog component
function DeleteDialog({ isOpen, onClose, onConfirm, serverName }) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-gray-900 mb-4">Confirm Deletion</h3>
<p className="text-gray-700">
Are you sure you want to delete the server <strong>{serverName}</strong>? This action
cannot be undone.
</p>
<div className="flex justify-end mt-6">
<button
onClick={onClose}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
>
Cancel
</button>
<button
onClick={onConfirm}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded"
>
Delete
</button>
</div>
</div>
</div>
);
}
// Main server card component for displaying server status and available tools
function ServerCard({ server, onRemove, onEdit }) {
const [isExpanded, setIsExpanded] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const handleRemove = (e) => {
e.stopPropagation();
setShowDeleteDialog(true);
};
const handleEdit = (e) => {
e.stopPropagation();
onEdit(server);
};
const handleConfirmDelete = () => {
onRemove(server.name);
setShowDeleteDialog(false);
};
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
<h2 className="text-xl font-semibold text-gray-900">{server.name}</h2>
<Badge status={server.status} />
</div>
<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"
>
Edit
</button>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
>
Delete
</button>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? (
ChevronDown ? (
<ChevronDown size={18} />
) : (
'▼'
)
) : ChevronRight ? (
<ChevronRight size={18} />
) : (
'▶'
)}
</button>
</div>
</div>
<DeleteDialog
isOpen={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={handleConfirmDelete}
serverName={server.name}
/>
{isExpanded && server.tools && (
<div className="mt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Available Tools</h3>
<div className="space-y-4">
{server.tools.map((tool, index) => (
<ToolCard key={index} tool={tool} />
))}
</div>
</div>
)}
</div>
);
}
// Form component for adding/editing MCP servers with stdio or SSE protocol support
function ServerForm({ onSubmit, onCancel, initialData = null, modalTitle }) {
const [serverType, setServerType] = useState(
initialData && initialData.config && initialData.config.url ? 'sse' : 'stdio',
);
const [formData, setFormData] = useState({
name: (initialData && initialData.name) || '',
url: (initialData && initialData.config && initialData.config.url) || '',
command: (initialData && initialData.config && initialData.config.command) || '',
arguments:
initialData && initialData.config && initialData.config.args
? Array.isArray(initialData.config.args)
? initialData.config.args.join(' ')
: String(initialData.config.args)
: '',
args: (initialData && initialData.config && initialData.config.args) || [],
});
const [envVars, setEnvVars] = useState(
initialData && initialData.config && initialData.config.env
? Object.entries(initialData.config.env).map(([key, value]) => ({ key, value }))
: [],
);
const [error, setError] = useState(null);
const isEdit = !!initialData;
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
};
// Transform space-separated arguments string into array
const handleArgsChange = (value) => {
let args = value.split(' ').filter((arg) => arg.trim() !== '');
setFormData({ ...formData, arguments: value, args });
};
const handleEnvVarChange = (index, field, value) => {
const newEnvVars = [...envVars];
newEnvVars[index][field] = value;
setEnvVars(newEnvVars);
};
const addEnvVar = () => {
setEnvVars([...envVars, { key: '', value: '' }]);
};
const removeEnvVar = (index) => {
const newEnvVars = [...envVars];
newEnvVars.splice(index, 1);
setEnvVars(newEnvVars);
};
// Submit handler for server configuration
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
try {
const env = {};
envVars.forEach(({ key, value }) => {
if (key.trim()) {
env[key.trim()] = value;
}
});
const payload = {
name: formData.name,
config:
serverType === 'sse'
? { url: formData.url }
: {
command: formData.command,
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
},
};
onSubmit(payload);
} catch (err) {
setError('Error: ' + err.message);
}
};
return (
<div className="bg-white shadow rounded-lg p-6 w-full max-w-xl max-h-screen overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900">{modalTitle}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && <div className="bg-red-50 text-red-700 p-3 rounded mb-4">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
Server Name
</label>
<input
type="text"
name="name"
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"
placeholder="e.g., time-mcp"
required
disabled={isEdit}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">Server Type</label>
<div className="flex space-x-4">
<div>
<input
type="radio"
id="command"
name="serverType"
value="command"
checked={serverType === 'stdio'}
onChange={() => setServerType('stdio')}
className="mr-1"
/>
<label htmlFor="command">stdio</label>
</div>
<div>
<input
type="radio"
id="url"
name="serverType"
value="url"
checked={serverType === 'sse'}
onChange={() => setServerType('sse')}
className="mr-1"
/>
<label htmlFor="url">sse</label>
</div>
</div>
</div>
{serverType === 'sse' ? (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="url">
Server URL
</label>
<input
type="url"
name="url"
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"
placeholder="e.g., http://localhost:3000/sse"
required={serverType === 'sse'}
/>
</div>
) : (
<Fragment>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="command">
Command
</label>
<input
type="text"
name="command"
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"
placeholder="e.g., npx"
required={serverType === 'stdio'}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="arguments">
Arguments
</label>
<input
type="text"
name="arguments"
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"
placeholder="e.g., -y time-mcp"
required={serverType === 'stdio'}
/>
</div>
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<label className="block text-gray-700 text-sm font-bold">
Environment Variables
</label>
<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"
>
+ Add
</button>
</div>
{envVars.map((envVar, index) => (
<div key={index} className="flex items-center mb-2">
<div className="flex items-center space-x-2 flex-grow">
<input
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"
placeholder="key"
/>
<span className="flex items-center">:</span>
<input
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"
placeholder="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"
>
- Del
</button>
</div>
))}
</div>
</Fragment>
)}
<div className="flex justify-end mt-6">
<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"
>
Cancel
</button>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
>
{isEdit ? 'Save Changes' : 'Add Server'}
</button>
</div>
</form>
</div>
);
}
// Form component for adding new MCP servers (wrapper around ServerForm)
function AddServerForm({ onAdd }) {
const [modalVisible, setModalVisible] = useState(false);
const toggleModal = () => {
setModalVisible(!modalVisible);
};
const handleSubmit = async (payload) => {
try {
const response = await fetch('/api/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) {
alert(result.message || 'Failed to add server');
return;
}
setModalVisible(false);
onAdd();
} catch (err) {
alert('Error: ' + err.message);
}
};
return (
<div>
<button
onClick={toggleModal}
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
>
Add New Server
</button>
{modalVisible && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<ServerForm onSubmit={handleSubmit} onCancel={toggleModal} modalTitle="Add New Server" />
</div>
)}
</div>
);
}
// Form component for editing MCP servers (wrapper around ServerForm)
function EditServerForm({ server, onEdit, onCancel }) {
const handleSubmit = async (payload) => {
try {
const response = await fetch(`/api/servers/${server.name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) {
alert(result.message || 'Failed to update server');
return;
}
onEdit();
} catch (err) {
alert('Error: ' + err.message);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<ServerForm
onSubmit={handleSubmit}
onCancel={onCancel}
initialData={server}
modalTitle={`Edit Server: ${server.name}`}
/>
</div>
);
}
// Root application component managing server state and UI
function App() {
const [servers, setServers] = useState([]);
const [error, setError] = useState(null);
const [refreshKey, setRefreshKey] = useState(0);
const [editingServer, setEditingServer] = useState(null);
useEffect(() => {
fetch('/api/servers')
.then((response) => response.json())
.then((data) => {
// 处理API响应中的包装对象提取data字段
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
} else if (data && Array.isArray(data)) {
// 兼容性处理如果API直接返回数组
setServers(data);
} else {
// 如果数据格式不符合预期,设置为空数组
console.error('Invalid server data format:', data);
setServers([]);
}
})
.catch((err) => setError(err.message));
// Poll for updates every 5 seconds
const interval = setInterval(() => {
fetch('/api/servers')
.then((response) => response.json())
.then((data) => {
// 处理API响应中的包装对象提取data字段
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
} else if (data && Array.isArray(data)) {
// 兼容性处理如果API直接返回数组
setServers(data);
} else {
// 如果数据格式不符合预期,设置为空数组
console.error('Invalid server data format:', data);
setServers([]);
}
})
.catch((err) => setError(err.message));
}, 5000);
return () => clearInterval(interval);
}, [refreshKey]);
const handleServerAdd = () => {
setRefreshKey((prevKey) => prevKey + 1);
};
const handleServerEdit = (server) => {
// Fetch settings to get the full server config before editing
fetch(`/api/settings`)
.then((response) => response.json())
.then((settingsData) => {
if (
settingsData &&
settingsData.success &&
settingsData.data &&
settingsData.data.mcpServers &&
settingsData.data.mcpServers[server.name]
) {
const serverConfig = settingsData.data.mcpServers[server.name];
const fullServerData = {
name: server.name,
status: server.status,
tools: server.tools || [],
config: serverConfig,
};
console.log('Editing server with config:', fullServerData);
setEditingServer(fullServerData);
} else {
console.error('Failed to get server config from settings:', settingsData);
setError(`Could not find configuration data for ${server.name}`);
}
})
.catch((err) => {
console.error('Error fetching server settings:', err);
setError(err.message);
});
};
const handleEditComplete = () => {
setEditingServer(null);
setRefreshKey((prevKey) => prevKey + 1);
};
const handleServerRemove = async (serverName) => {
try {
const response = await fetch(`/api/servers/${serverName}`, {
method: 'DELETE',
});
const result = await response.json();
if (!response.ok) {
setError(result.message || `Failed to delete server ${serverName}`);
return;
}
setRefreshKey((prevKey) => prevKey + 1);
} catch (err) {
setError('Error: ' + err.message);
}
};
if (error) {
return (
<div className="min-h-screen bg-red-50 p-8">
<div className="max-w-3xl mx-auto">
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-red-600 text-xl font-semibold">Error</h2>
<p className="text-gray-600 mt-2">{error}</p>
<button
onClick={() => setError(null)}
className="mt-4 bg-red-100 text-red-800 py-1 px-3 rounded hover:bg-red-200"
>
Close
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-3xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">MCP Hub Dashboard</h1>
<AddServerForm onAdd={handleServerAdd} />
</div>
{servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">No MCP servers available</p>
</div>
) : (
<div className="space-y-6">
{servers.map((server, index) => (
<ServerCard
key={index}
server={server}
onRemove={handleServerRemove}
onEdit={handleServerEdit}
/>
))}
</div>
)}
{editingServer && (
<EditServerForm
server={editingServer}
onEdit={handleEditComplete}
onCancel={() => setEditingServer(null)}
/>
)}
</div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

View File

@@ -15,7 +15,7 @@ export const errorHandler = (
};
export const initMiddlewares = (app: express.Application): void => {
app.use(express.static('public'));
app.use(express.static('frontend/dist'));
app.use((req, res, next) => {
if (req.path !== '/sse' && req.path !== '/messages') {
@@ -26,7 +26,7 @@ export const initMiddlewares = (app: express.Application): void => {
});
app.get('/', (_req: Request, res: Response) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
});
app.use(errorHandler);