diff --git a/public/js/app.js b/public/js/app.js index 546643c..7b09636 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,4 +1,4 @@ -const { useState, useEffect } = React; +const { useState, useEffect, Fragment } = React; function Badge({ status }) { const colors = { @@ -42,9 +42,16 @@ function ToolCard({ tool }) { ); } -function ServerCard({ server }) { +function ServerCard({ server, onRemove }) { const [isExpanded, setIsExpanded] = useState(false); + const handleRemove = (e) => { + e.stopPropagation(); // 防止展开/收起行为 + if (confirm(`确定要删除服务器 ${server.name} 吗?`)) { + onRemove(server.name); + } + }; + return (
{server.name}
- +
+ + +
{isExpanded && server.tools && (
@@ -71,9 +86,240 @@ function ServerCard({ server }) { ); } +function AddServerForm({ onAdd }) { + const [formVisible, setFormVisible] = useState(false); + const [serverType, setServerType] = useState('command'); + const [formData, setFormData] = useState({ + name: '', + url: '', + command: 'npx', + args: ['-y', ''], + }); + const [error, setError] = useState(null); + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData({ ...formData, [name]: value }); + }; + + const handleArgsChange = (value) => { + try { + // 尝试解析为数组,如果不是有效的 JSON 格式,则作为单个字符串处理 + let args; + if (value.trim().startsWith('[')) { + args = JSON.parse(value); + } else { + args = ['-y', value]; + } + setFormData({ ...formData, args }); + } catch (err) { + // 如果解析失败,使用默认值 + setFormData({ ...formData, args: ['-y', value] }); + } + }; + + const toggleForm = () => { + setFormVisible(!formVisible); + setError(null); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(null); + + try { + const payload = { + name: formData.name, + config: serverType === 'url' + ? { url: formData.url } + : { command: formData.command, args: formData.args } + }; + + 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) { + setError(result.message || '添加服务器失败'); + return; + } + + // 重置表单 + setFormData({ + name: '', + url: '', + command: 'npx', + args: ['-y', ''], + }); + setFormVisible(false); + + // 通知父组件 + onAdd(); + } catch (err) { + setError('发生错误: ' + err.message); + } + }; + + return ( +
+ {!formVisible ? ( + + ) : ( +
+
+

添加新服务器

+ +
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ +
+
+ setServerType('command')} + className="mr-1" + /> + +
+
+ setServerType('url')} + className="mr-1" + /> + +
+
+
+ + {serverType === 'url' ? ( +
+ + +
+ ) : ( + +
+ + +
+
+ + 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="例如: time-mcp" + required={serverType === 'command'} + /> +

+ 将自动添加 "-y" 参数 +

+
+
+ )} + +
+ + +
+
+
+ )} +
+ ); +} + function App() { const [servers, setServers] = useState([]); const [error, setError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); useEffect(() => { fetch('/api/servers') @@ -90,7 +336,31 @@ function App() { }, 5000); return () => clearInterval(interval); - }, []); + }, [refreshKey]); + + const handleServerAdd = () => { + 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 || `删除服务器 ${serverName} 失败`); + return; + } + + // 刷新服务器列表 + setRefreshKey(prevKey => prevKey + 1); + } catch (err) { + setError('发生错误: ' + err.message); + } + }; if (error) { return ( @@ -99,6 +369,12 @@ function App() {

Error

{error}

+
@@ -109,6 +385,9 @@ function App() {

MCP Hub Dashboard

+ + + {servers.length === 0 ? (

No MCP servers available

@@ -116,7 +395,7 @@ function App() { ) : (
{servers.map((server, index) => ( - + ))}
)} @@ -125,4 +404,5 @@ function App() { ); } +// 使用兼容性更好的渲染方式 ReactDOM.render(, document.getElementById('root')); diff --git a/src/index.ts b/src/index.ts index f5b4462..35267a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import express, { Request, Response } from 'express'; import dotenv from 'dotenv'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; -import { registerAllTools, getServersInfo } from './server.js'; +import { registerAllTools, getServersInfo, getServersSettings, addServer, removeServer } from './server.js'; import path from 'path'; dotenv.config(); @@ -21,6 +21,9 @@ const PORT = process.env.PORT || 3000; // Serve static files from the public directory app.use(express.static('public')); +// Parse JSON request body +app.use(express.json()); + // to support multiple simultaneous connections we have a lookup object from sessionId to transport const transports: { [sessionId: string]: SSEServerTransport } = {}; @@ -30,6 +33,56 @@ app.get('/api/servers', (req: Request, res: Response) => { res.json(serversInfo); }); +// API endpoint to get all server settings +app.get('/api/settings', (req: Request, res: Response) => { + const settings = getServersSettings(); + res.json(settings); +}); + +// API endpoint to add a new server +app.post('/api/servers', async (req: Request, res: Response) => { + const { name, config } = req.body; + + if (!name || typeof name !== 'string') { + return res.status(400).json({ success: false, message: 'Server name is required' }); + } + + if (!config || typeof config !== 'object') { + return res.status(400).json({ success: false, message: 'Server configuration is required' }); + } + + // Validate config has either url or command+args + if (!config.url && (!config.command || !config.args)) { + return res.status(400).json({ + success: false, + message: 'Server configuration must include either a URL or command with arguments', + }); + } + + const success = await addServer(server, name, config); + if (success) { + res.json({ success: true, message: 'Server added successfully' }); + } else { + res.status(400).json({ success: false, message: 'Failed to add server' }); + } +}); + +// API endpoint to remove a server +app.delete('/api/servers/:name', (req: Request, res: Response) => { + const { name } = req.params; + + if (!name) { + return res.status(400).json({ success: false, message: 'Server name is required' }); + } + + const success = removeServer(name); + if (success) { + res.json({ success: true, message: 'Server removed successfully' }); + } else { + res.status(404).json({ success: false, message: 'Server not found or failed to remove' }); + } +}); + app.get('/sse', async (_: Request, res: Response) => { const transport = new SSEServerTransport('/messages', res); transports[transport.sessionId] = transport; diff --git a/src/server.ts b/src/server.ts index 3e9b046..6f705de 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,6 +45,18 @@ function loadSettings(): McpSettings { } } +// Function to save settings to file +export function saveSettings(settings: McpSettings): boolean { + const settingsPath = path.resolve(process.cwd(), 'mcp_settings.json'); + try { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); + return true; + } catch (error) { + console.error(`Failed to save settings to ${settingsPath}:`, error); + return false; + } +} + // Initialize clients and transports from settings function initializeClientsFromSettings(): { servers: string[]; @@ -98,7 +110,7 @@ function initializeClientsFromSettings(): { } // Initialize clients and transports -const { servers, clients, transports } = initializeClientsFromSettings(); +let { servers, clients, transports } = initializeClientsFromSettings(); // Keep track of connected clients and their tools const clientTools: { [clientIndex: number]: ToolInfo[] } = {}; @@ -149,6 +161,78 @@ export function getServersInfo(): ServerInfo[] { })); } +// Add function to get all server settings +export function getServersSettings(): McpSettings { + return loadSettings(); +} + +// Add function to add a new server +export async function addServer(mcpServer: McpServer, name: string, config: { url?: string; command?: string; args?: string[]; env?: Record }): Promise { + try { + // Load current settings + const settings = loadSettings(); + + // Check if server with this name already exists + if (settings.mcpServers[name]) { + return false; + } + + // Add new server to settings + settings.mcpServers[name] = config; + + // Save updated settings + if (!saveSettings(settings)) { + return false; + } + + // Re-initialize clients with updated settings + const result = initializeClientsFromSettings(); + servers = result.servers; + clients = result.clients; + transports = result.transports; + + // Register tools for the new server + await registerAllTools(mcpServer); + + return true; + } catch (error) { + console.error(`Failed to add server: ${name}`, error); + return false; + } +} + +// Add function to remove a server +export function removeServer(name: string): boolean { + try { + // Load current settings + const settings = loadSettings(); + + // Check if server exists + if (!settings.mcpServers[name]) { + return false; + } + + // Remove server from settings + delete settings.mcpServers[name]; + + // Save updated settings + if (!saveSettings(settings)) { + return false; + } + + // Re-initialize clients with updated settings + const result = initializeClientsFromSettings(); + servers = result.servers; + clients = result.clients; + transports = result.transports; + + return true; + } catch (error) { + console.error(`Failed to remove server: ${name}`, error); + return false; + } +} + function cast(inputSchema: unknown): ZodRawShape { if (typeof inputSchema !== 'object' || inputSchema === null) { throw new Error('Invalid input schema');