Compare commits

...

5 Commits

15 changed files with 312 additions and 74 deletions

View File

@@ -136,9 +136,9 @@ pnpm dev
- Bug 报告与修复
- 翻译与建议
欢迎加入企微交流共建群
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
<img src="assets/wegroup.png" width="500">
<img src="assets/wexin.png" width="350">
## 📄 许可证

BIN
assets/wexin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -1,10 +1,11 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Server } from '@/types'
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
import Badge from '@/components/ui/Badge'
import ToolCard from '@/components/ui/ToolCard'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
interface ServerCardProps {
server: Server
@@ -15,9 +16,26 @@ interface ServerCardProps {
const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const [isExpanded, setIsExpanded] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isToggling, setIsToggling] = useState(false)
const [showErrorPopover, setShowErrorPopover] = useState(false)
const [copied, setCopied] = useState(false)
const errorPopoverRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (errorPopoverRef.current && !errorPopoverRef.current.contains(event.target as Node)) {
setShowErrorPopover(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
@@ -41,6 +59,44 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
}
}
const handleErrorIconClick = (e: React.MouseEvent) => {
e.stopPropagation()
setShowErrorPopover(!showErrorPopover)
}
const copyToClipboard = (e: React.MouseEvent) => {
e.stopPropagation()
if (!server.error) return
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(server.error).then(() => {
setCopied(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopied(false), 2000)
})
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = server.error
// Avoid scrolling to bottom
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
setCopied(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopied(false), 2000)
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)
}
}
const handleConfirmDelete = () => {
onRemove(server.name)
setShowDeleteDialog(false)
@@ -56,6 +112,59 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
<div className="flex items-center space-x-3">
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
<Badge status={server.status} />
{server.error && (
<div className="relative">
<div
className="cursor-pointer"
onClick={handleErrorIconClick}
aria-label={t('server.viewErrorDetails')}
>
<AlertCircle className="text-red-500 hover:text-red-600" size={18} />
</div>
{showErrorPopover && (
<div
ref={errorPopoverRef}
className="absolute z-10 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-0 w-120"
style={{
left: '-231px',
top: '24px',
maxHeight: '300px',
overflowY: 'auto',
width: '480px',
transform: 'translateX(50%)'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
<div className="flex items-center space-x-2">
<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"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
</div>
<button
onClick={(e) => {
e.stopPropagation()
setShowErrorPopover(false)
}}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="p-4 pt-2">
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">{server.error}</pre>
</div>
</div>
)}
</div>
)}
</div>
<div className="flex space-x-2">
<button

View File

@@ -68,7 +68,9 @@
"namePlaceholder": "Enter server name",
"urlPlaceholder": "Enter server URL",
"commandPlaceholder": "Enter command",
"argumentsPlaceholder": "Enter arguments"
"argumentsPlaceholder": "Enter arguments",
"errorDetails": "Error Details",
"viewErrorDetails": "View error details"
},
"status": {
"online": "Online",
@@ -95,7 +97,9 @@
"create": "Create",
"submitting": "Submitting...",
"delete": "Delete",
"copy": "Copy"
"copy": "Copy",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed"
},
"nav": {
"dashboard": "Dashboard",

View File

@@ -68,7 +68,9 @@
"namePlaceholder": "请输入服务器名称",
"urlPlaceholder": "请输入服务器URL",
"commandPlaceholder": "请输入命令",
"argumentsPlaceholder": "请输入参数"
"argumentsPlaceholder": "请输入参数",
"errorDetails": "错误详情",
"viewErrorDetails": "查看错误详情"
},
"status": {
"online": "在线",
@@ -95,7 +97,9 @@
"create": "创建",
"submitting": "提交中...",
"delete": "删除",
"copy": "复制"
"copy": "复制",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败"
},
"nav": {
"dashboard": "仪表盘",

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Server } from '@/types';
import ServerCard from '@/components/ServerCard';
import AddServerForm from '@/components/AddServerForm';
@@ -8,6 +9,7 @@ import { useServerData } from '@/hooks/useServerData';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const {
servers,
error,
@@ -31,6 +33,7 @@ const ServersPage: React.FC = () => {
const handleEditComplete = () => {
setEditingServer(null);
triggerRefresh();
};
const handleRefresh = async () => {
@@ -49,6 +52,15 @@ const ServersPage: React.FC = () => {
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">{t('pages.servers.title')}</h1>
<div className="flex space-x-4">
<button
onClick={() => navigate('/market')}
className="px-4 py-2 bg-emerald-100 text-emerald-800 rounded hover:bg-emerald-200 flex items-center"
>
<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" />
</svg>
{t('nav.market')}
</button>
<AddServerForm onAdd={handleServerAdd} />
<button
onClick={handleRefresh}

View File

@@ -82,6 +82,7 @@ export interface ServerConfig {
export interface Server {
name: string;
status: ServerStatus;
error?: string;
tools?: Tool[];
config?: ServerConfig;
enabled?: boolean;

View File

@@ -23,7 +23,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.9.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@shadcn/ui": "^0.0.4",

10
pnpm-lock.yaml generated
View File

@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.9.0
version: 1.9.0
specifier: ^1.10.2
version: 1.10.2
'@radix-ui/react-accordion':
specifier: ^1.2.3
version: 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -867,8 +867,8 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@modelcontextprotocol/sdk@1.9.0':
resolution: {integrity: sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==}
'@modelcontextprotocol/sdk@1.10.2':
resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==}
engines: {node: '>=18'}
'@next/env@15.2.4':
@@ -4268,7 +4268,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@modelcontextprotocol/sdk@1.9.0':
'@modelcontextprotocol/sdk@1.10.2':
dependencies:
content-type: 1.0.5
cors: 2.8.5

View File

@@ -7,6 +7,7 @@ dotenv.config();
const defaultConfig = {
port: process.env.PORT || 3000,
initTimeout: process.env.INIT_TIMEOUT || 300000,
timeout: process.env.REQUEST_TIMEOUT || 60000,
mcpHubName: 'mcphub',
mcpHubVersion: '0.0.1',
@@ -42,4 +43,4 @@ export const expandEnvVars = (value: string): string => {
return value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
};
export default defaultConfig;
export default defaultConfig;

View File

@@ -4,7 +4,12 @@ import path from 'path';
import { initMcpServer } from './services/mcpService.js';
import { initMiddlewares } from './middlewares/index.js';
import { initRoutes } from './routes/index.js';
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
import {
handleSseConnection,
handleSseMessage,
handleMcpPostRequest,
handleMcpOtherRequest,
} from './services/sseService.js';
import { migrateUserData } from './utils/migration.js';
import { initializeDefaultUser } from './models/User.js';
@@ -34,6 +39,9 @@ export class AppServer {
console.log('MCP server initialized successfully');
this.app.get('/sse/:group?', (req, res) => handleSseConnection(req, res));
this.app.post('/messages', handleSseMessage);
this.app.post('/mcp/:group?', handleMcpPostRequest);
this.app.get('/mcp/:group?', handleMcpOtherRequest);
this.app.delete('/mcp/:group?', handleMcpOtherRequest);
})
.catch((error) => {
console.error('Error initializing MCP server:', error);

View File

@@ -12,7 +12,15 @@ export const getMarketServers = (): Record<string, MarketServer> => {
try {
const serversJsonPath = getServersJsonPath();
const data = fs.readFileSync(serversJsonPath, 'utf8');
return JSON.parse(data);
const serversObj = JSON.parse(data) as Record<string, MarketServer>;
const sortedEntries = Object.entries(serversObj).sort(([, serverA], [, serverB]) => {
if (serverA.is_official && !serverB.is_official) return -1;
if (!serverA.is_official && serverB.is_official) return 1;
return 0;
});
return Object.fromEntries(sortedEntries);
} catch (error) {
console.error('Failed to load servers from servers.json:', error);
return {};
@@ -29,13 +37,13 @@ export const getMarketServerByName = (name: string): MarketServer | null => {
export const getMarketCategories = (): string[] => {
const servers = getMarketServers();
const categories = new Set<string>();
Object.values(servers).forEach((server) => {
server.categories?.forEach((category) => {
categories.add(category);
});
});
return Array.from(categories).sort();
};
@@ -43,25 +51,28 @@ export const getMarketCategories = (): string[] => {
export const getMarketTags = (): string[] => {
const servers = getMarketServers();
const tags = new Set<string>();
Object.values(servers).forEach((server) => {
server.tags?.forEach((tag) => {
tags.add(tag);
});
});
return Array.from(tags).sort();
};
// Search market servers by query
export const searchMarketServers = (query: string): MarketServer[] => {
const servers = getMarketServers();
const searchTerms = query.toLowerCase().split(' ').filter(term => term.length > 0);
const searchTerms = query
.toLowerCase()
.split(' ')
.filter((term) => term.length > 0);
if (searchTerms.length === 0) {
return Object.values(servers);
}
return Object.values(servers).filter((server) => {
// Search in name, display_name, description, categories, and tags
const searchableText = [
@@ -69,21 +80,23 @@ export const searchMarketServers = (query: string): MarketServer[] => {
server.display_name,
server.description,
...(server.categories || []),
...(server.tags || [])
].join(' ').toLowerCase();
return searchTerms.some(term => searchableText.includes(term));
...(server.tags || []),
]
.join(' ')
.toLowerCase();
return searchTerms.some((term) => searchableText.includes(term));
});
};
// Filter market servers by category
export const filterMarketServersByCategory = (category: string): MarketServer[] => {
const servers = getMarketServers();
if (!category) {
return Object.values(servers);
}
return Object.values(servers).filter((server) => {
return server.categories?.includes(category);
});
@@ -92,12 +105,12 @@ export const filterMarketServersByCategory = (category: string): MarketServer[]
// Filter market servers by tag
export const filterMarketServersByTag = (tag: string): MarketServer[] => {
const servers = getMarketServers();
if (!tag) {
return Object.values(servers);
}
return Object.values(servers).filter((server) => {
return server.tags?.includes(tag);
});
};
};

View File

@@ -6,7 +6,6 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { ServerInfo, ServerConfig } from '../types/index.js';
import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
import config from '../config/index.js';
import { get } from 'http';
import { getGroup } from './sseService.js';
import { getServersInGroup } from './groupService.js';
@@ -14,7 +13,7 @@ let currentServer: Server;
export const initMcpServer = async (name: string, version: string): Promise<void> => {
currentServer = createMcpServer(name, version);
await registerAllTools(currentServer, true);
await registerAllTools(currentServer, true, true);
};
export const setMcpServer = (server: Server): void => {
@@ -26,11 +25,11 @@ export const getMcpServer = (): Server => {
};
export const notifyToolChanged = async () => {
await registerAllTools(currentServer, true);
await registerAllTools(currentServer, true, false);
currentServer
.sendToolListChanged()
.catch((error) => {
console.error('Failed to send tool list changed notification:', error);
console.warn('Failed to send tool list changed notification:', error.message);
})
.then(() => {
console.log('Tool list changed notification sent successfully');
@@ -41,7 +40,7 @@ export const notifyToolChanged = async () => {
let serverInfos: ServerInfo[] = [];
// Initialize MCP server clients
export const initializeClientsFromSettings = (): ServerInfo[] => {
export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] => {
const settings = loadSettings();
const existingServerInfos = serverInfos;
serverInfos = [];
@@ -53,6 +52,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
serverInfos.push({
name,
status: 'disconnected',
error: null,
tools: [],
createTime: Date.now(),
enabled: false,
@@ -89,6 +89,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
serverInfos.push({
name,
status: 'disconnected',
error: 'Missing required configuration',
tools: [],
createTime: Date.now(),
});
@@ -108,16 +109,55 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
},
},
);
client.connect(transport, { timeout: Number(config.timeout) }).catch((error) => {
console.error(`Failed to connect client for server ${name} by error: ${error}`);
const serverInfo = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
}
});
const timeout = isInit ? Number(config.initTimeout) : Number(config.timeout);
client
.connect(transport, { timeout: timeout })
.then(() => {
console.log(`Successfully connected client for server: ${name}`);
client
.listTools({}, { timeout: timeout })
.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: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema || {},
}));
serverInfo.status = 'connected';
serverInfo.error = null;
})
.catch((error) => {
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} `;
}
});
})
.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} `;
}
});
serverInfos.push({
name,
status: 'connecting',
error: null,
tools: [],
client,
transport,
@@ -130,42 +170,24 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
};
// Register all MCP tools
export const registerAllTools = async (server: Server, forceInit: boolean): Promise<void> => {
initializeClientsFromSettings();
for (const serverInfo of serverInfos) {
if (serverInfo.status === 'connected' && !forceInit) continue;
if (!serverInfo.client || !serverInfo.transport) continue;
try {
serverInfo.status = 'connecting';
console.log(`Connecting to server: ${serverInfo.name}...`);
const tools = await serverInfo.client.listTools({}, { timeout: Number(config.timeout) });
serverInfo.tools = tools.tools.map((tool) => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema || {},
}));
serverInfo.status = 'connected';
console.log(`Successfully connected to server: ${serverInfo.name}`);
} catch (error) {
console.error(
`Failed to connect to server for client: ${serverInfo.name} by error: ${error}`,
);
serverInfo.status = 'disconnected';
}
}
export const registerAllTools = async (
server: Server,
forceInit: boolean,
isInit: boolean,
): Promise<void> => {
initializeClientsFromSettings(isInit);
};
// Get all server information
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
const settings = loadSettings();
const infos = serverInfos.map(({ name, status, tools, createTime }) => {
const infos = serverInfos.map(({ name, status, tools, createTime, error }) => {
const serverConfig = settings.mcpServers[name];
const enabled = serverConfig ? serverConfig.enabled !== false : true;
return {
name,
status,
error,
tools,
createTime,
enabled,
@@ -204,7 +226,7 @@ export const addServer = async (
return { success: false, message: 'Failed to save settings' };
}
registerAllTools(currentServer, false);
registerAllTools(currentServer, false, false);
return { success: true, message: 'Server added successfully' };
} catch (error) {
console.error(`Failed to add server: ${name}`, error);

View File

@@ -1,9 +1,13 @@
import { Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { getMcpServer } from './mcpService.js';
import { loadSettings } from '../config/index.js';
const transports: { [sessionId: string]: { transport: SSEServerTransport; group: string } } = {};
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
export const getGroup = (sessionId: string): string => {
return transports[sessionId]?.group || '';
@@ -44,13 +48,72 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
req.query.group = group;
console.log(`Received message for sessionId: ${sessionId} in group: ${group}`);
if (transport) {
await transport.handlePostMessage(req, res);
await (transport as SSEServerTransport).handlePostMessage(req, res);
} else {
console.error(`No transport found for sessionId: ${sessionId}`);
res.status(400).send('No transport found for sessionId');
}
};
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
console.log('Handling MCP post request');
const sessionId = req.headers['mcp-session-id'] as string | undefined;
const group = req.params.group;
const settings = loadSettings();
const routingConfig = settings.systemConfig?.routing || {
enableGlobalRoute: true,
enableGroupNameRoute: true,
};
if (!group && !routingConfig.enableGlobalRoute) {
res.status(403).send('Global routes are disabled. Please specify a group ID.');
return;
}
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId].transport as StreamableHTTPServerTransport;
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
transports[sessionId] = { transport, group };
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete transports[transport.sessionId];
}
};
await getMcpServer().connect(transport);
} else {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
});
return;
}
await transport.handleRequest(req, res, req.body);
};
export const handleMcpOtherRequest = async (req: Request, res: Response) => {
console.log('Handling MCP other request');
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
const { transport } = transports[sessionId];
await (transport as StreamableHTTPServerTransport).handleRequest(req, res);
};
export const getConnectionCount = (): number => {
return Object.keys(transports).length;
};

View File

@@ -100,6 +100,7 @@ export interface ServerConfig {
export interface ServerInfo {
name: string; // Unique name of the server
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
error: string | null; // Error message if any
tools: ToolInfo[]; // List of tools available on the server
client?: Client; // Client instance for communication
transport?: SSEClientTransport | StdioClientTransport; // Transport mechanism used