From 26720d9e49108751a1955602b205cca688a9f013 Mon Sep 17 00:00:00 2001 From: samanhappy Date: Sat, 9 Aug 2025 21:14:26 +0800 Subject: [PATCH] feat: introduce cloud server market (#260) --- frontend/src/App.tsx | 3 + frontend/src/components/CloudServerCard.tsx | 144 +++++ frontend/src/components/CloudServerDetail.tsx | 573 ++++++++++++++++++ .../src/components/MCPRouterApiKeyError.tsx | 92 +++ frontend/src/components/layout/Sidebar.tsx | 9 + frontend/src/hooks/useCloudData.ts | 350 +++++++++++ frontend/src/hooks/useSettingsData.ts | 97 +++ frontend/src/index.css | 24 + frontend/src/pages/CloudPage.tsx | 344 +++++++++++ frontend/src/pages/ServersPage.tsx | 9 + frontend/src/pages/SettingsPage.tsx | 165 ++++- frontend/src/types/index.ts | 21 + frontend/tailwind.config.js | 9 +- locales/en.json | 72 ++- locales/zh.json | 76 ++- package.json | 1 + pnpm-lock.yaml | 12 + src/controllers/cloudController.ts | 273 +++++++++ src/controllers/serverController.ts | 39 +- src/routes/index.ts | 22 + src/services/cloudService.ts | 273 +++++++++ src/types/index.ts | 49 ++ 22 files changed, 2635 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/CloudServerCard.tsx create mode 100644 frontend/src/components/CloudServerDetail.tsx create mode 100644 frontend/src/components/MCPRouterApiKeyError.tsx create mode 100644 frontend/src/hooks/useCloudData.ts create mode 100644 frontend/src/pages/CloudPage.tsx create mode 100644 src/controllers/cloudController.ts create mode 100644 src/services/cloudService.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 85caa60..980f6ea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import GroupsPage from './pages/GroupsPage'; import UsersPage from './pages/UsersPage'; import SettingsPage from './pages/SettingsPage'; import MarketPage from './pages/MarketPage'; +import CloudPage from './pages/CloudPage'; import LogsPage from './pages/LogsPage'; import { getBasePath } from './utils/runtime'; @@ -35,6 +36,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/src/components/CloudServerCard.tsx b/frontend/src/components/CloudServerCard.tsx new file mode 100644 index 0000000..bba4e2c --- /dev/null +++ b/frontend/src/components/CloudServerCard.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { CloudServer } from '@/types'; + +interface CloudServerCardProps { + server: CloudServer; + onClick: (server: CloudServer) => void; +} + +const CloudServerCard: React.FC = ({ server, onClick }) => { + const { t } = useTranslation(); + + const handleClick = () => { + onClick(server); + }; + + // Extract a brief description from content if description is too long + const getDisplayDescription = () => { + if (server.description && server.description.length <= 150) { + return server.description; + } + + // Try to extract a summary from content + if (server.content) { + const lines = server.content.split('\n').filter(line => line.trim()); + for (const line of lines) { + if (line.length > 50 && line.length <= 150) { + return line; + } + } + } + + return server.description ? + server.description.slice(0, 150) + '...' : + t('cloud.noDescription'); + }; + + // Format date for display + const formatDate = (dateString: string) => { + try { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + return `${year}/${month}/${day}`; + } catch { + return ''; + } + }; + + // Get initials for avatar + const getAuthorInitials = (name: string) => { + return name + .split(' ') + .map(word => word.charAt(0)) + .join('') + .toUpperCase() + .slice(0, 2); + }; + + return ( +
+ {/* Background gradient overlay on hover */} +
+ + {/* Server Header */} +
+
+
+

+ {server.title || server.name} +

+ + {/* Author Section */} +
+
+ {getAuthorInitials(server.author_name)} +
+
+

{server.author_name}

+ {server.updated_at && ( +

+ {t('cloud.updated')} {formatDate(server.updated_at)} +

+ )} +
+
+
+ + {/* Server Type Badge */} +
+ + MCP Server + +
+
+ + {/* Description */} +
+

+ {getDisplayDescription()} +

+
+ + {/* Tools Info */} + {server.tools && server.tools.length > 0 && ( +
+
+ + + + + + {server.tools.length} {server.tools.length === 1 ? t('cloud.tool') : t('cloud.tools')} + +
+
+ )} + + {/* Footer - 固定在底部 */} +
+
+ + + + {formatDate(server.created_at)} +
+ +
+ {t('cloud.viewDetails')} + + + +
+
+
+
+ ); +}; + +export default CloudServerCard; diff --git a/frontend/src/components/CloudServerDetail.tsx b/frontend/src/components/CloudServerDetail.tsx new file mode 100644 index 0000000..5179aca --- /dev/null +++ b/frontend/src/components/CloudServerDetail.tsx @@ -0,0 +1,573 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CloudServer, CloudServerTool, ServerConfig } from '@/types'; +import { apiGet } from '@/utils/fetchInterceptor'; +import { useSettingsData } from '@/hooks/useSettingsData'; +import MCPRouterApiKeyError from './MCPRouterApiKeyError'; +import ServerForm from './ServerForm'; + +interface CloudServerDetailProps { + serverName: string; + onBack: () => void; + onCallTool?: (serverName: string, toolName: string, args: Record) => Promise; + fetchServerTools?: (serverName: string) => Promise; + onInstall?: (server: CloudServer, config: ServerConfig) => void; + installing?: boolean; + isInstalled?: boolean; +} + +const CloudServerDetail: React.FC = ({ + serverName, + onBack, + onCallTool, + fetchServerTools, + onInstall, + installing = false, + isInstalled = false +}) => { + const { t } = useTranslation(); + const { mcpRouterConfig } = useSettingsData(); + const [server, setServer] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tools, setTools] = useState([]); + const [loadingTools, setLoadingTools] = useState(false); + const [toolsApiKeyError, setToolsApiKeyError] = useState(false); + const [toolCallLoading, setToolCallLoading] = useState(null); + const [toolCallResults, setToolCallResults] = useState>({}); + const [toolArgs, setToolArgs] = useState>>({}); + const [expandedSchemas, setExpandedSchemas] = useState>({}); + const [modalVisible, setModalVisible] = useState(false); + const [installError, setInstallError] = useState(null); + + // Helper function to check if error is MCPRouter API key not configured + const isMCPRouterApiKeyError = (errorMessage: string) => { + console.error('Checking for MCPRouter API key error:', errorMessage); + return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' || + errorMessage.toLowerCase().includes('mcprouter api key not configured'); + }; + + // Helper function to determine button state for install + const getInstallButtonProps = () => { + if (isInstalled) { + return { + className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white", + disabled: true, + text: t('market.installed') + }; + } else if (installing) { + return { + className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white", + disabled: true, + text: t('market.installing') + }; + } else { + return { + className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white transition-colors", + disabled: false, + text: t('market.install') + }; + } + }; + + // Handle install button click + const handleInstall = () => { + if (!isInstalled && onInstall) { + setModalVisible(true); + setInstallError(null); + } + }; + + // Handle modal close + const handleModalClose = () => { + setModalVisible(false); + setInstallError(null); + }; + + // Handle install form submission + const handleInstallSubmit = async (payload: any) => { + try { + if (!server || !onInstall) return; + + setInstallError(null); + onInstall(server, payload.config); + setModalVisible(false); + } catch (err) { + console.error('Error installing server:', err); + setInstallError(t('errors.serverInstall')); + } + }; + + // Load server details + useEffect(() => { + const loadServerDetails = async () => { + try { + setLoading(true); + setError(null); + const response = await apiGet(`/cloud/servers/${serverName}`); + + if (response && response.success && response.data) { + setServer(response.data); + setTools(response.data.tools || []); + } else { + setError(t('cloud.serverNotFound')); + } + } catch (err) { + console.error('Failed to load server details:', err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + loadServerDetails(); + }, [serverName, t]); + + // Load tools if not already loaded + useEffect(() => { + const loadTools = async () => { + if (server && (!server.tools || server.tools.length === 0) && fetchServerTools) { + setLoadingTools(true); + setToolsApiKeyError(false); + try { + const fetchedTools = await fetchServerTools(server.name); + setTools(fetchedTools); + } catch (error) { + console.error('Failed to load tools:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + if (isMCPRouterApiKeyError(errorMessage)) { + setToolsApiKeyError(true); + } + } finally { + setLoadingTools(false); + } + } + }; + + loadTools(); + }, [server?.name, server?.tools, fetchServerTools]); + + // Format creation date + const formatDate = (dateStr: string) => { + try { + const date = new Date(dateStr); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + } catch { + return dateStr; + } + }; + + // Handle tool argument changes + const handleArgChange = (toolName: string, argName: string, value: any) => { + setToolArgs(prev => ({ + ...prev, + [toolName]: { + ...prev[toolName], + [argName]: value + } + })); + }; + + // Handle tool call + const handleCallTool = async (toolName: string) => { + if (!onCallTool || !server) return; + + setToolCallLoading(toolName); + try { + const args = toolArgs[toolName] || {}; + const result = await onCallTool(server.server_key, toolName, args); + setToolCallResults(prev => ({ + ...prev, + [toolName]: result + })); + } catch (error) { + console.error('Tool call failed:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + setToolCallResults(prev => ({ + ...prev, + [toolName]: { error: errorMessage } + })); + } finally { + setToolCallLoading(null); + } + }; + + // Toggle schema visibility + const toggleSchema = (toolName: string) => { + setExpandedSchemas(prev => ({ + ...prev, + [toolName]: !prev[toolName] + })); + }; + + // Render tool input field based on schema + const renderToolInput = (tool: CloudServerTool, propName: string, propSchema: any) => { + const currentValue = toolArgs[tool.name]?.[propName] || ''; + + const handleChange = (e: React.ChangeEvent) => { + let value: any = e.target.value; + + // Convert value based on schema type + if (propSchema.type === 'number' || propSchema.type === 'integer') { + value = value === '' ? undefined : Number(value); + } else if (propSchema.type === 'boolean') { + value = e.target.value === 'true'; + } + + handleArgChange(tool.name, propName, value); + }; + + if (propSchema.type === 'boolean') { + return ( + + ); + } else if (propSchema.type === 'number' || propSchema.type === 'integer') { + return ( + + ); + } else { + return ( + + ); + } + }; + + return ( +
+
+ +
+ + {loading ? ( +
+
+ + + + +

{t('app.loading')}

+
+
+ ) : error && !isMCPRouterApiKeyError(error) ? ( +
+
+
+ + + +

{error}

+
+
+
+ ) : !server ? ( +
+
+ + + +

{t('cloud.serverNotFound')}

+
+
+ ) : ( +
+ {/* Server Header Card */} +
+
+
+
+

+ {server.title || server.name} +

+
+ + {server.name} + +
+ + + + {t('cloud.by')} {server.author_name} +
+
+
+ +
+
+ {t('cloud.updated')}: {formatDate(server.updated_at)} +
+ {onInstall && !isMCPRouterApiKeyError(error || '') && !toolsApiKeyError && ( + + )} +
+
+
+
+ + {/* Description Card */} +
+

+ + + + {t('cloud.description')} +

+

{server.description}

+
+ + {/* Content Card */} + {server.content && ( +
+

+ + + + {t('cloud.details')} +

+
+
{server.content}
+
+
+ )} + + {/* Tools Card */} +
+

+ + + + + {t('cloud.tools')} + {tools.length > 0 && ( + + {tools.length} + + )} +

+ + {/* Check for API key error */} + {toolsApiKeyError && ( + + )} + + {loadingTools ? ( +
+ + + + + {t('cloud.loadingTools')} +
+ ) : tools.length === 0 && !toolsApiKeyError ? ( +
+ + + +

{t('cloud.noTools')}

+
+ ) : tools.length > 0 ? ( +
+ {tools.map((tool, index) => ( +
+
+
+

+ + TOOL + + {tool.name} +

+

{tool.description}

+
+ {onCallTool && ( + + )} +
+ + {/* Tool inputs */} + {tool.inputSchema && tool.inputSchema.properties && Object.keys(tool.inputSchema.properties).length > 0 && ( +
+
+

{t('cloud.parameters')}

+ +
+ + {/* Schema content */} + {expandedSchemas[tool.name] && ( +
+
+
+                                {JSON.stringify(tool.inputSchema, null, 2)}
+                              
+
+
+ )} + +
+ {Object.entries(tool.inputSchema.properties).map(([propName, propSchema]: [string, any]) => ( +
+ + {propSchema.description && ( +

{propSchema.description}

+ )} + {renderToolInput(tool, propName, propSchema)} +
+ ))} +
+
+ )} + + {/* Tool call result */} + {toolCallResults[tool.name] && ( +
+ {toolCallResults[tool.name].error ? ( + <> + {isMCPRouterApiKeyError(toolCallResults[tool.name].error) ? ( + + ) : ( + <> +

+ + + + {t('cloud.error')} +

+
+
+                                    {toolCallResults[tool.name].error}
+                                  
+
+ + )} + + ) : ( + <> +

+ + + + {t('cloud.result')} +

+
+
+                                {JSON.stringify(toolCallResults[tool.name], null, 2)}
+                              
+
+ + )} +
+ )} +
+ ))} +
+ ) : null} +
+
+ )} + + {/* Install Modal */} + {modalVisible && server && ( +
+ '}`, + 'HTTP-Referer': mcpRouterConfig.referer || '', + 'X-Title': mcpRouterConfig.title || '' + } + } + }} + /> +
+ )} +
+ ); +}; + +export default CloudServerDetail; diff --git a/frontend/src/components/MCPRouterApiKeyError.tsx b/frontend/src/components/MCPRouterApiKeyError.tsx new file mode 100644 index 0000000..8589183 --- /dev/null +++ b/frontend/src/components/MCPRouterApiKeyError.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +const MCPRouterApiKeyError: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleConfigureSettings = () => { + navigate('/settings'); + }; + + const handleGetApiKey = () => { + window.open('https://mcprouter.co', '_blank', 'noopener,noreferrer'); + }; + + return ( +
+
+
+ + + +
+
+

+ {t('cloud.apiKeyNotConfigured')} +

+
+

{t('cloud.apiKeyNotConfiguredDescription')}

+
+
+ + +
+
+
+
+ ); +}; + +export default MCPRouterApiKeyError; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9eeab97..8896776 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -61,6 +61,15 @@ const Sidebar: React.FC = ({ collapsed }) => { ), }] : []), + { + path: '/cloud', + label: t('nav.cloud'), + icon: ( + + + + ), + }, { path: '/market', label: t('nav.market'), diff --git a/frontend/src/hooks/useCloudData.ts b/frontend/src/hooks/useCloudData.ts new file mode 100644 index 0000000..f4ddf03 --- /dev/null +++ b/frontend/src/hooks/useCloudData.ts @@ -0,0 +1,350 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CloudServer, ApiResponse, CloudServerTool } from '@/types'; +import { apiGet, apiPost } from '../utils/fetchInterceptor'; + +export const useCloudData = () => { + const { t } = useTranslation(); + const [servers, setServers] = useState([]); + const [allServers, setAllServers] = useState([]); + const [categories, setCategories] = useState([]); + const [tags, setTags] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(''); + const [selectedTag, setSelectedTag] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentServer, setCurrentServer] = useState(null); + + // Pagination states + const [currentPage, setCurrentPage] = useState(1); + const [serversPerPage, setServersPerPage] = useState(9); + const [totalPages, setTotalPages] = useState(1); + + // Fetch all cloud market servers + const fetchCloudServers = useCallback(async () => { + try { + setLoading(true); + const data: ApiResponse = await apiGet('/cloud/servers'); + + if (data && data.success && Array.isArray(data.data)) { + setAllServers(data.data); + // Apply pagination to the fetched data + applyPagination(data.data, currentPage); + } else { + console.error('Invalid cloud market servers data format:', data); + setError(t('cloud.fetchError')); + } + } catch (err) { + console.error('Error fetching cloud market servers:', err); + const errorMessage = err instanceof Error ? err.message : String(err); + // Keep the original error message for API key errors + if ( + errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' || + errorMessage.toLowerCase().includes('mcprouter api key not configured') + ) { + setError(errorMessage); + } else { + setError(errorMessage); + } + } finally { + setLoading(false); + } + }, [t]); + + // Apply pagination to data + const applyPagination = useCallback( + (data: CloudServer[], page: number, itemsPerPage = serversPerPage) => { + const totalItems = data.length; + const calculatedTotalPages = Math.ceil(totalItems / itemsPerPage); + setTotalPages(calculatedTotalPages); + + // Ensure current page is valid + const validPage = Math.max(1, Math.min(page, calculatedTotalPages)); + if (validPage !== page) { + setCurrentPage(validPage); + } + + const startIndex = (validPage - 1) * itemsPerPage; + const paginatedServers = data.slice(startIndex, startIndex + itemsPerPage); + setServers(paginatedServers); + }, + [serversPerPage], + ); + + // Change page + const changePage = useCallback( + (page: number) => { + setCurrentPage(page); + applyPagination(allServers, page, serversPerPage); + }, + [allServers, applyPagination, serversPerPage], + ); + + // Fetch all categories + const fetchCategories = useCallback(async () => { + try { + const data: ApiResponse = await apiGet('/cloud/categories'); + + if (data && data.success && Array.isArray(data.data)) { + setCategories(data.data); + } else { + console.error('Invalid cloud market categories data format:', data); + } + } catch (err) { + console.error('Error fetching cloud market categories:', err); + } + }, []); + + // Fetch all tags + const fetchTags = useCallback(async () => { + try { + const data: ApiResponse = await apiGet('/cloud/tags'); + + if (data && data.success && Array.isArray(data.data)) { + setTags(data.data); + } else { + console.error('Invalid cloud market tags data format:', data); + } + } catch (err) { + console.error('Error fetching cloud market tags:', err); + } + }, []); + + // Fetch server by name + const fetchServerByName = useCallback( + async (name: string) => { + try { + setLoading(true); + const data: ApiResponse = await apiGet(`/cloud/servers/${name}`); + + if (data && data.success && data.data) { + setCurrentServer(data.data); + return data.data; + } else { + console.error('Invalid cloud server data format:', data); + setError(t('cloud.serverNotFound')); + return null; + } + } catch (err) { + console.error(`Error fetching cloud server ${name}:`, err); + const errorMessage = err instanceof Error ? err.message : String(err); + // Keep the original error message for API key errors + if ( + errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' || + errorMessage.toLowerCase().includes('mcprouter api key not configured') + ) { + setError(errorMessage); + } else { + setError(errorMessage); + } + return null; + } finally { + setLoading(false); + } + }, + [t], + ); + + // Search servers by query + const searchServers = useCallback( + async (query: string) => { + try { + setLoading(true); + setSearchQuery(query); + + if (!query.trim()) { + // Fetch fresh data from server instead of just applying pagination + fetchCloudServers(); + return; + } + + const data: ApiResponse = await apiGet( + `/cloud/servers/search?query=${encodeURIComponent(query)}`, + ); + + if (data && data.success && Array.isArray(data.data)) { + setAllServers(data.data); + setCurrentPage(1); + applyPagination(data.data, 1); + } else { + console.error('Invalid cloud search results format:', data); + setError(t('cloud.searchError')); + } + } catch (err) { + console.error('Error searching cloud servers:', err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, + [t, allServers, applyPagination, fetchCloudServers], + ); + + // Filter servers by category + const filterByCategory = useCallback( + async (category: string) => { + try { + setLoading(true); + setSelectedCategory(category); + setSelectedTag(''); // Reset tag filter when filtering by category + + if (!category) { + fetchCloudServers(); + return; + } + + const data: ApiResponse = await apiGet( + `/cloud/categories/${encodeURIComponent(category)}`, + ); + + if (data && data.success && Array.isArray(data.data)) { + setAllServers(data.data); + setCurrentPage(1); + applyPagination(data.data, 1); + } else { + console.error('Invalid cloud category filter results format:', data); + setError(t('cloud.filterError')); + } + } catch (err) { + console.error('Error filtering cloud servers by category:', err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, + [t, fetchCloudServers, applyPagination], + ); + + // Filter servers by tag + const filterByTag = useCallback( + async (tag: string) => { + try { + setLoading(true); + setSelectedTag(tag); + setSelectedCategory(''); // Reset category filter when filtering by tag + + if (!tag) { + fetchCloudServers(); + return; + } + + const data: ApiResponse = await apiGet( + `/cloud/tags/${encodeURIComponent(tag)}`, + ); + + if (data && data.success && Array.isArray(data.data)) { + setAllServers(data.data); + setCurrentPage(1); + applyPagination(data.data, 1); + } else { + console.error('Invalid cloud tag filter results format:', data); + setError(t('cloud.tagFilterError')); + } + } catch (err) { + console.error('Error filtering cloud servers by tag:', err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, + [t, fetchCloudServers, applyPagination], + ); + + // Fetch tools for a specific server + const fetchServerTools = useCallback(async (serverName: string) => { + try { + const data: ApiResponse = await apiGet( + `/cloud/servers/${serverName}/tools`, + ); + + if (!data.success) { + console.error('Failed to fetch cloud server tools:', data); + throw new Error(data.message || 'Failed to fetch cloud server tools'); + } + + if (data && data.success && Array.isArray(data.data)) { + return data.data; + } else { + console.error('Invalid cloud server tools data format:', data); + return []; + } + } catch (err) { + console.error(`Error fetching tools for cloud server ${serverName}:`, err); + const errorMessage = err instanceof Error ? err.message : String(err); + // Re-throw API key errors so they can be handled by the component + if ( + errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' || + errorMessage.toLowerCase().includes('mcprouter api key not configured') + ) { + throw err; + } + return []; + } + }, []); + + // Call a tool on a cloud server + const callServerTool = useCallback( + async (serverName: string, toolName: string, args: Record) => { + try { + const data = await apiPost(`/cloud/servers/${serverName}/tools/${toolName}/call`, { + arguments: args, + }); + + if (data && data.success) { + return data.data; + } else { + throw new Error(data.message || 'Failed to call tool'); + } + } catch (err) { + console.error(`Error calling tool ${toolName} on cloud server ${serverName}:`, err); + throw err; + } + }, + [], + ); + + // Change servers per page + const changeServersPerPage = useCallback( + (perPage: number) => { + setServersPerPage(perPage); + setCurrentPage(1); + applyPagination(allServers, 1, perPage); + }, + [allServers, applyPagination], + ); + + // Load initial data + useEffect(() => { + fetchCloudServers(); + fetchCategories(); + fetchTags(); + }, [fetchCloudServers, fetchCategories, fetchTags]); + + return { + servers, + allServers, + categories, + tags, + selectedCategory, + selectedTag, + searchQuery, + loading, + error, + setError, + currentServer, + fetchCloudServers: fetchCloudServers, + fetchServerByName, + searchServers, + filterByCategory, + filterByTag, + fetchServerTools, + callServerTool, + // Pagination properties and methods + currentPage, + totalPages, + serversPerPage, + changePage, + changeServersPerPage, + }; +}; diff --git a/frontend/src/hooks/useSettingsData.ts b/frontend/src/hooks/useSettingsData.ts index ea8beab..a8c7da8 100644 --- a/frontend/src/hooks/useSettingsData.ts +++ b/frontend/src/hooks/useSettingsData.ts @@ -27,11 +27,19 @@ interface SmartRoutingConfig { openaiApiEmbeddingModel: string; } +interface MCPRouterConfig { + apiKey: string; + referer: string; + title: string; + baseUrl: string; +} + interface SystemSettings { systemConfig?: { routing?: RoutingConfig; install?: InstallConfig; smartRouting?: SmartRoutingConfig; + mcpRouter?: MCPRouterConfig; }; } @@ -69,6 +77,13 @@ export const useSettingsData = () => { openaiApiEmbeddingModel: '', }); + const [mcpRouterConfig, setMCPRouterConfig] = useState({ + apiKey: '', + referer: 'https://mcphub.app', + title: 'MCPHub', + baseUrl: 'https://api.mcprouter.to/v1', + }); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); @@ -112,6 +127,14 @@ export const useSettingsData = () => { data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '', }); } + if (data.success && data.data?.systemConfig?.mcpRouter) { + setMCPRouterConfig({ + apiKey: data.data.systemConfig.mcpRouter.apiKey || '', + referer: data.data.systemConfig.mcpRouter.referer || 'https://mcphub.app', + title: data.data.systemConfig.mcpRouter.title || 'MCPHub', + baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1', + }); + } } catch (error) { console.error('Failed to fetch settings:', error); setError(error instanceof Error ? error.message : 'Failed to fetch settings'); @@ -290,6 +313,77 @@ export const useSettingsData = () => { } }; + // Update MCPRouter configuration + const updateMCPRouterConfig = async ( + key: T, + value: MCPRouterConfig[T], + ) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + mcpRouter: { + [key]: value, + }, + }); + + if (data.success) { + setMCPRouterConfig({ + ...mcpRouterConfig, + [key]: value, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + showToast(data.message || t('errors.failedToUpdateSystemConfig')); + return false; + } + } catch (error) { + console.error('Failed to update MCPRouter config:', error); + const errorMessage = + error instanceof Error ? error.message : 'Failed to update MCPRouter config'; + setError(errorMessage); + showToast(errorMessage); + return false; + } finally { + setLoading(false); + } + }; + + // Update multiple MCPRouter configuration fields at once + const updateMCPRouterConfigBatch = async (updates: Partial) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + mcpRouter: updates, + }); + + if (data.success) { + setMCPRouterConfig({ + ...mcpRouterConfig, + ...updates, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + showToast(data.message || t('errors.failedToUpdateSystemConfig')); + return false; + } + } catch (error) { + console.error('Failed to update MCPRouter config:', error); + const errorMessage = + error instanceof Error ? error.message : 'Failed to update MCPRouter config'; + setError(errorMessage); + showToast(errorMessage); + return false; + } finally { + setLoading(false); + } + }; + // Fetch settings when the component mounts or refreshKey changes useEffect(() => { fetchSettings(); @@ -309,6 +403,7 @@ export const useSettingsData = () => { setTempRoutingConfig, installConfig, smartRoutingConfig, + mcpRouterConfig, loading, error, setError, @@ -319,5 +414,7 @@ export const useSettingsData = () => { updateSmartRoutingConfig, updateSmartRoutingConfigBatch, updateRoutingConfigBatch, + updateMCPRouterConfig, + updateMCPRouterConfigBatch, }; }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 213f41a..9b41a39 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -442,6 +442,30 @@ tbody tr:hover { color: rgba(239, 154, 154, 0.9) !important; } +/* External link styles */ +.external-link { + color: #2563eb !important; /* Blue-600 for light mode */ + text-decoration: none; + border-bottom: 1px solid transparent; + transition: all 0.2s ease-in-out; + cursor: pointer; +} + +.external-link:hover { + color: #1d4ed8 !important; /* Blue-700 for light mode */ + border-bottom-color: #1d4ed8; + text-decoration: none; +} + +.dark .external-link { + color: #60a5fa !important; /* Blue-400 for dark mode */ +} + +.dark .external-link:hover { + color: #93c5fd !important; /* Blue-300 for dark mode */ + border-bottom-color: #93c5fd; +} + .border-red { border-color: #937d7d; /* Tailwind red-800 for light mode */ } diff --git a/frontend/src/pages/CloudPage.tsx b/frontend/src/pages/CloudPage.tsx new file mode 100644 index 0000000..0b2371c --- /dev/null +++ b/frontend/src/pages/CloudPage.tsx @@ -0,0 +1,344 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; +import { CloudServer, ServerConfig } from '@/types'; +import { useCloudData } from '@/hooks/useCloudData'; +import { useToast } from '@/contexts/ToastContext'; +import { apiPost } from '@/utils/fetchInterceptor'; +import CloudServerCard from '@/components/CloudServerCard'; +import CloudServerDetail from '@/components/CloudServerDetail'; +import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError'; +import Pagination from '@/components/ui/Pagination'; + +const CloudPage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { serverName } = useParams<{ serverName?: string }>(); + const { showToast } = useToast(); + const [installing, setInstalling] = useState(false); + const [installedServers, setInstalledServers] = useState>(new Set()); + + const { + servers, + allServers, + // categories, + loading, + error, + setError, + // searchServers, + // filterByCategory, + // filterByTag, + // selectedCategory, + // selectedTag, + fetchServerTools, + callServerTool, + // Pagination + currentPage, + totalPages, + changePage, + serversPerPage, + changeServersPerPage + } = useCloudData(); + + // const [searchQuery, setSearchQuery] = useState(''); + + // const handleSearch = (e: React.FormEvent) => { + // e.preventDefault(); + // searchServers(searchQuery); + // }; + + // const handleCategoryClick = (category: string) => { + // filterByCategory(category); + // }; + + // const handleClearFilters = () => { + // setSearchQuery(''); + // filterByCategory(''); + // filterByTag(''); + // }; + + const handleServerClick = (server: CloudServer) => { + navigate(`/cloud/${server.name}`); + }; + + const handleBackToList = () => { + navigate('/cloud'); + }; + + const handleCallTool = async (serverName: string, toolName: string, args: Record) => { + try { + const result = await callServerTool(serverName, toolName, args); + showToast(t('cloud.toolCallSuccess', { toolName }), 'success'); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + // Don't show toast for API key errors, let the component handle it + if (!isMCPRouterApiKeyError(errorMessage)) { + showToast(t('cloud.toolCallError', { toolName, error: errorMessage }), 'error'); + } + throw error; + } + }; + + // Helper function to check if error is MCPRouter API key not configured + const isMCPRouterApiKeyError = (errorMessage: string) => { + return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' || + errorMessage.toLowerCase().includes('mcprouter api key not configured'); + }; + + const handlePageChange = (page: number) => { + changePage(page); + // Scroll to top of page when changing pages + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const handleChangeItemsPerPage = (e: React.ChangeEvent) => { + const newValue = parseInt(e.target.value, 10); + changeServersPerPage(newValue); + }; + + // Handle cloud server installation + const handleInstallCloudServer = async (server: CloudServer, config: ServerConfig) => { + try { + setInstalling(true); + + const payload = { + name: server.name, + config: config + }; + + const result = await apiPost('/servers', payload); + + if (!result.success) { + const errorMessage = result?.message || t('server.addError'); + showToast(errorMessage, 'error'); + return; + } + + // Update installed servers set + setInstalledServers(prev => new Set(prev).add(server.name)); + showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success'); + + } catch (error) { + console.error('Error installing cloud server:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + showToast(t('cloud.installError', { error: errorMessage }), 'error'); + } finally { + setInstalling(false); + } + }; + + // Render detailed view if a server name is in the URL + if (serverName) { + return ( + + ); + } + + return ( +
+
+
+

+ {t('cloud.title')} + + {t('cloud.subtitle').includes('提供支持') ? '由 ' : 'Powered by '} + + MCPRouter + + {t('cloud.subtitle').includes('提供支持') ? ' 提供支持' : ''} + +

+
+
+ + {error && ( + <> + {isMCPRouterApiKeyError(error) ? ( + + ) : ( +
+
+

{error}

+ +
+
+ )} + + )} + + {/* Search bar at the top +
+
+
+ setSearchQuery(e.target.value)} + placeholder={t('cloud.searchPlaceholder')} + 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" + /> +
+ + {(searchQuery || selectedCategory || selectedTag) && ( + + )} +
+
+ */} + +
+ {/* Left sidebar for filters +
+
+ {categories.length > 0 ? ( +
+
+

{t('cloud.categories')}

+ {selectedCategory && ( + filterByCategory('')}> + {t('cloud.clearCategoryFilter')} + + )} +
+
+ {categories.map((category) => ( + + ))} +
+
+ ) : loading ? ( +
+
+

{t('cloud.categories')}

+
+
+ + + + +

{t('app.loading')}

+
+
+ ) : ( +
+
+

{t('cloud.categories')}

+
+

{t('cloud.noCategories')}

+
+ )} +
+
+ */} + + {/* Main content area */} +
+ {loading ? ( +
+
+ + + + +

{t('app.loading')}

+
+
+ ) : servers.length === 0 ? ( +
+

{t('cloud.noServers')}

+
+ ) : ( + <> +
+ {servers.map((server, index) => ( + + ))} +
+ +
+
+ {t('cloud.showing', { + from: (currentPage - 1) * serversPerPage + 1, + to: Math.min(currentPage * serversPerPage, allServers.length), + total: allServers.length + })} +
+ +
+ + +
+
+ +
+
+ + )} +
+
+
+ ); +}; + +export default CloudPage; diff --git a/frontend/src/pages/ServersPage.tsx b/frontend/src/pages/ServersPage.tsx index a79f3ea..9d7a6db 100644 --- a/frontend/src/pages/ServersPage.tsx +++ b/frontend/src/pages/ServersPage.tsx @@ -60,6 +60,15 @@ const ServersPage: React.FC = () => {

{t('pages.servers.title')}

+ +
+
+ +
+
+

{t('settings.mcpRouterReferer')}

+

{t('settings.mcpRouterRefererDescription')}

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

{t('settings.mcpRouterTitle')}

+

{t('settings.mcpRouterTitleDescription')}

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

{t('settings.mcpRouterBaseUrl')}

+

{t('settings.mcpRouterBaseUrlDescription')}

+
+
+ handleMCPRouterConfigChange('baseUrl', e.target.value)} + placeholder={t('settings.mcpRouterBaseUrlPlaceholder')} + className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" + disabled={loading} + /> + +
+
+
+ )} + + + {/* Route Configuration Settings */} -
+
toggleSection('routingConfig')} @@ -418,7 +571,7 @@ const SettingsPage: React.FC = () => { {/* Installation Configuration Settings */} -
+
toggleSection('installConfig')} @@ -508,7 +661,7 @@ const SettingsPage: React.FC = () => { {/* Change Password */} -
+
toggleSection('password')} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bcfa03b..1b725ae 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -55,6 +55,27 @@ export interface MarketServer { is_official?: boolean; } +// Cloud Server types (for MCPRouter API) +export interface CloudServer { + created_at: string; + updated_at: string; + name: string; + author_name: string; + title: string; + description: string; + content: string; + server_key: string; + config_name: string; + server_url: string; + tools?: CloudServerTool[]; +} + +export interface CloudServerTool { + name: string; + description: string; + inputSchema: Record; +} + // Tool input schema types export interface ToolInputSchema { type: string; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 9b004be..231f303 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,12 +1,9 @@ /** @type {import('tailwindcss').Config} */ export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], darkMode: 'class', // Use class strategy for dark mode theme: { extend: {}, }, - plugins: [], -} \ No newline at end of file + plugins: [require('@tailwindcss/line-clamp')], +}; diff --git a/locales/en.json b/locales/en.json index 0659e32..5b7baa4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -196,7 +196,8 @@ "users": "Users", "settings": "Settings", "changePassword": "Change Password", - "market": "Market", + "market": "Local Market", + "cloud": "Cloud Market", "logs": "Logs" }, "pages": { @@ -281,7 +282,7 @@ "configureTools": "Configure Tools" }, "market": { - "title": "Server Market", + "title": "Local Market", "official": "Official", "by": "By", "unknown": "Unknown", @@ -324,6 +325,58 @@ "confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue installing server?", "confirmAndInstall": "Confirm and Install" }, + "cloud": { + "title": "Cloud Market", + "subtitle": "Powered by MCPRouter", + "by": "By", + "server": "Server", + "config": "Config", + "created": "Created", + "updated": "Updated", + "available": "Available", + "description": "Description", + "details": "Details", + "tools": "Tools", + "tool": "tool", + "toolsAvailable": "{{count}} tool available||{{count}} tools available", + "loadingTools": "Loading tools...", + "noTools": "No tools available for this server", + "noDescription": "No description available", + "viewDetails": "View Details", + "parameters": "Parameters", + "result": "Result", + "error": "Error", + "callTool": "Call", + "calling": "Calling...", + "toolCallSuccess": "Tool {{toolName}} executed successfully", + "toolCallError": "Failed to call tool {{toolName}}: {{error}}", + "viewSchema": "View Schema", + "backToList": "Back to Cloud Market", + "search": "Search", + "searchPlaceholder": "Search cloud servers by name, title, or author", + "clearFilters": "Clear Filters", + "clearCategoryFilter": "Clear", + "clearTagFilter": "Clear", + "categories": "Categories", + "tags": "Tags", + "noCategories": "No categories found", + "noTags": "No tags found", + "noServers": "No cloud servers found", + "fetchError": "Error fetching cloud servers", + "serverNotFound": "Cloud server not found", + "searchError": "Error searching cloud servers", + "filterError": "Error filtering cloud servers by category", + "tagFilterError": "Error filtering cloud servers by tag", + "showing": "Showing {{from}}-{{to}} of {{total}} cloud servers", + "perPage": "Per page", + "apiKeyNotConfigured": "MCPRouter API key not configured", + "apiKeyNotConfiguredDescription": "To use cloud servers, you need to configure your MCPRouter API key.", + "getApiKey": "Get API Key", + "configureInSettings": "Configure in Settings", + "installServer": "Install {{name}}", + "installSuccess": "Server {{name}} installed successfully", + "installError": "Failed to install server: {{error}}" + }, "tool": { "run": "Run", "running": "Running...", @@ -394,7 +447,20 @@ "openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small", "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}}" + "smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}", + "mcpRouterConfig": "Cloud Market", + "mcpRouterApiKey": "MCPRouter API Key", + "mcpRouterApiKeyDescription": "API key for accessing MCPRouter cloud market services", + "mcpRouterApiKeyPlaceholder": "Enter MCPRouter API key", + "mcpRouterReferer": "Referer", + "mcpRouterRefererDescription": "Referer header for MCPRouter API requests", + "mcpRouterRefererPlaceholder": "https://mcphub.app", + "mcpRouterTitle": "Title", + "mcpRouterTitleDescription": "Title header for MCPRouter API requests", + "mcpRouterTitlePlaceholder": "MCPHub", + "mcpRouterBaseUrl": "Base URL", + "mcpRouterBaseUrlDescription": "Base URL for MCPRouter API", + "mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1" }, "dxt": { "upload": "Upload", diff --git a/locales/zh.json b/locales/zh.json index 4753add..8407932 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -197,7 +197,8 @@ "changePassword": "修改密码", "groups": "分组", "users": "用户", - "market": "市场", + "market": "本地市场", + "cloud": "云端市场", "logs": "日志" }, "pages": { @@ -229,7 +230,7 @@ "title": "用户管理" }, "market": { - "title": "服务器市场 - (数据来源于 mcpm.sh)" + "title": "本地市场 - (数据来源于 mcpm.sh)" }, "logs": { "title": "系统日志" @@ -282,7 +283,7 @@ "configureTools": "配置工具" }, "market": { - "title": "服务器市场", + "title": "本地市场", "official": "官方", "by": "作者", "unknown": "未知", @@ -314,7 +315,7 @@ "required": "必填", "example": "示例", "viewSchema": "查看结构", - "fetchError": "获取服务器市场数据失败", + "fetchError": "获取本地市场服务器数据失败", "serverNotFound": "未找到服务器", "searchError": "搜索服务器失败", "filterError": "按分类筛选服务器失败", @@ -325,6 +326,58 @@ "confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?", "confirmAndInstall": "确认并安装" }, + "cloud": { + "title": "云端市场", + "subtitle": "由 MCPRouter 提供支持", + "by": "作者", + "server": "服务器", + "config": "配置", + "created": "创建时间", + "updated": "更新时间", + "available": "可用", + "description": "描述", + "details": "详细信息", + "tools": "工具", + "tool": "个工具", + "toolsAvailable": "{{count}} 个工具可用", + "loadingTools": "加载工具中...", + "noTools": "该服务器没有可用工具", + "noDescription": "无描述信息", + "viewDetails": "查看详情", + "parameters": "参数", + "result": "结果", + "error": "错误", + "callTool": "调用", + "calling": "调用中...", + "toolCallSuccess": "工具 {{toolName}} 执行成功", + "toolCallError": "调用工具 {{toolName}} 失败:{{error}}", + "viewSchema": "查看结构", + "backToList": "返回云端市场", + "search": "搜索", + "searchPlaceholder": "搜索云端服务器名称、标题或作者", + "clearFilters": "清除筛选", + "clearCategoryFilter": "清除", + "clearTagFilter": "清除", + "categories": "分类", + "tags": "标签", + "noCategories": "未找到分类", + "noTags": "未找到标签", + "noServers": "未找到云端服务器", + "fetchError": "获取云端服务器失败", + "serverNotFound": "未找到云端服务器", + "searchError": "搜索云端服务器失败", + "filterError": "按分类筛选云端服务器失败", + "tagFilterError": "按标签筛选云端服务器失败", + "showing": "显示 {{from}}-{{to}}/{{total}} 个云端服务器", + "perPage": "每页显示", + "apiKeyNotConfigured": "MCPRouter API 密钥未配置", + "apiKeyNotConfiguredDescription": "要使用云端服务器,您需要配置 MCPRouter API 密钥。", + "getApiKey": "获取 API 密钥", + "configureInSettings": "在设置中配置", + "installServer": "安装 {{name}}", + "installSuccess": "服务器 {{name}} 安装成功", + "installError": "安装服务器失败:{{error}}" + }, "tool": { "run": "运行", "running": "运行中...", @@ -396,7 +449,20 @@ "openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small", "smartRoutingConfigUpdated": "智能路由配置更新成功", "smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥", - "smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}" + "smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}", + "mcpRouterConfig": "云端市场", + "mcpRouterApiKey": "MCPRouter API 密钥", + "mcpRouterApiKeyDescription": "用于访问 MCPRouter 云端市场服务的 API 密钥", + "mcpRouterApiKeyPlaceholder": "请输入 MCPRouter API 密钥", + "mcpRouterReferer": "引用地址", + "mcpRouterRefererDescription": "MCPRouter API 请求的引用地址头", + "mcpRouterRefererPlaceholder": "https://mcphub.app", + "mcpRouterTitle": "标题", + "mcpRouterTitleDescription": "MCPRouter API 请求的标题头", + "mcpRouterTitlePlaceholder": "MCPHub", + "mcpRouterBaseUrl": "基础地址", + "mcpRouterBaseUrlDescription": "MCPRouter API 的基础地址", + "mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1" }, "dxt": { "upload": "上传", diff --git a/package.json b/package.json index 3d781d9..424b080 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@shadcn/ui": "^0.0.4", "@swc/core": "^1.13.0", "@swc/jest": "^0.2.39", + "@tailwindcss/line-clamp": "^0.4.4", "@tailwindcss/postcss": "^4.1.3", "@tailwindcss/vite": "^4.1.7", "@types/bcryptjs": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index befe6a1..962789c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: '@swc/jest': specifier: ^0.2.39 version: 0.2.39(@swc/core@1.13.0) + '@tailwindcss/line-clamp': + specifier: ^0.4.4 + version: 0.4.4(tailwindcss@4.1.11) '@tailwindcss/postcss': specifier: ^4.1.3 version: 4.1.11 @@ -1445,6 +1448,11 @@ packages: '@swc/types@0.1.23': resolution: {integrity: sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==} + '@tailwindcss/line-clamp@0.4.4': + resolution: {integrity: sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==} + peerDependencies: + tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1' + '@tailwindcss/node@4.1.11': resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==} @@ -5486,6 +5494,10 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@tailwindcss/line-clamp@0.4.4(tailwindcss@4.1.11)': + dependencies: + tailwindcss: 4.1.11 + '@tailwindcss/node@4.1.11': dependencies: '@ampproject/remapping': 2.3.0 diff --git a/src/controllers/cloudController.ts b/src/controllers/cloudController.ts new file mode 100644 index 0000000..2e6b8da --- /dev/null +++ b/src/controllers/cloudController.ts @@ -0,0 +1,273 @@ +import { Request, Response } from 'express'; +import { ApiResponse, CloudServer, CloudTool } from '../types/index.js'; +import { + getCloudServers, + getCloudServerByName, + getCloudServerTools, + callCloudServerTool, + getCloudCategories, + getCloudTags, + searchCloudServers, + filterCloudServersByCategory, + filterCloudServersByTag, +} from '../services/cloudService.js'; + +// Get all cloud market servers +export const getAllCloudServers = async (_: Request, res: Response): Promise => { + try { + const cloudServers = await getCloudServers(); + const response: ApiResponse = { + success: true, + data: cloudServers, + }; + res.json(response); + } catch (error) { + console.error('Error getting cloud market servers:', error); + const errorMessage = + error instanceof Error ? error.message : 'Failed to get cloud market servers'; + res.status(500).json({ + success: false, + message: errorMessage, + }); + } +}; + +// Get a specific cloud market server by name +export const getCloudServer = async (req: Request, res: Response): Promise => { + try { + const { name } = req.params; + if (!name) { + res.status(400).json({ + success: false, + message: 'Server name is required', + }); + return; + } + + const server = await getCloudServerByName(name); + if (!server) { + res.status(404).json({ + success: false, + message: 'Cloud server not found', + }); + return; + } + + // Fetch tools for this server + try { + const tools = await getCloudServerTools(server.server_key); + server.tools = tools; + } catch (toolError) { + console.warn(`Failed to fetch tools for server ${server.name}:`, toolError); + // Continue without tools + } + + const response: ApiResponse = { + success: true, + data: server, + }; + res.json(response); + } catch (error) { + console.error('Error getting cloud market server:', error); + const errorMessage = + error instanceof Error ? error.message : 'Failed to get cloud market server'; + res.status(500).json({ + success: false, + message: errorMessage, + }); + } +}; + +// Get all cloud market categories +export const getAllCloudCategories = async (_: Request, res: Response): Promise => { + try { + const categories = await getCloudCategories(); + const response: ApiResponse = { + success: true, + data: categories, + }; + res.json(response); + } catch (error) { + console.error('Error getting cloud market categories:', error); + const errorMessage = + error instanceof Error ? error.message : 'Failed to get cloud market categories'; + res.status(500).json({ + success: false, + message: errorMessage, + }); + } +}; + +// Get all cloud market tags +export const getAllCloudTags = async (_: Request, res: Response): Promise => { + try { + const tags = await getCloudTags(); + const response: ApiResponse = { + success: true, + data: tags, + }; + res.json(response); + } catch (error) { + console.error('Error getting cloud market tags:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to get cloud market tags'; + res.status(500).json({ + success: false, + message: errorMessage, + }); + } +}; + +// Search cloud market servers +export const searchCloudServersByQuery = async (req: Request, res: Response): Promise => { + try { + const query = req.query.query as string; + if (!query || query.trim() === '') { + res.status(400).json({ + success: false, + message: 'Search query is required', + }); + return; + } + + const servers = await searchCloudServers(query); + const response: ApiResponse = { + success: true, + data: servers, + }; + res.json(response); + } catch (error) { + console.error('Error searching cloud market servers:', error); + const errorMessage = + error instanceof Error ? error.message : 'Failed to search cloud market servers'; + res.status(500).json({ + success: false, + message: errorMessage, + }); + } +}; + +// Get cloud market servers by category +export const getCloudServersByCategory = async (req: Request, res: Response): Promise => { + try { + const { category } = req.params; + if (!category) { + res.status(400).json({ + success: false, + message: 'Category is required', + }); + return; + } + + const servers = await filterCloudServersByCategory(category); + const response: ApiResponse = { + success: true, + data: servers, + }; + res.json(response); + } catch (error) { + console.error('Error getting cloud market servers by category:', error); + const errorMessage = + error instanceof Error ? error.message : 'Failed to get cloud market servers by category'; + res.status(500).json({ + success: false, + message: errorMessage, + }); + } +}; + +// Get cloud market servers by tag +export const getCloudServersByTag = async (req: Request, res: Response): Promise => { + try { + const { tag } = req.params; + if (!tag) { + res.status(400).json({ + success: false, + message: 'Tag is required', + }); + return; + } + + const servers = await filterCloudServersByTag(tag); + const response: ApiResponse = { + success: true, + data: servers, + }; + res.json(response); + } catch (error) { + console.error('Error getting cloud market servers by tag:', error); + const errorMessage = + error instanceof Error ? error.message : 'Failed to get cloud market servers by tag'; + res.status(500).json({ + success: false, + message: errorMessage, + }); + } +}; + +// Get tools for a specific cloud server +export const getCloudServerToolsList = async (req: Request, res: Response): Promise => { + try { + const { serverName } = req.params; + if (!serverName) { + res.status(400).json({ + success: false, + message: 'Server name is required', + }); + return; + } + + const tools = await getCloudServerTools(serverName); + const response: ApiResponse = { + success: true, + data: tools, + }; + res.json(response); + } catch (error) { + console.error('Error getting cloud server tools:', error); + const errorMessage = + error instanceof Error ? error.message : 'Failed to get cloud server tools'; + res.status(500).json({ + success: false, + message: errorMessage, + }); + } +}; + +// Call a tool on a cloud server +export const callCloudTool = async (req: Request, res: Response): Promise => { + try { + const { serverName, toolName } = req.params; + const { arguments: args } = req.body; + + if (!serverName) { + res.status(400).json({ + success: false, + message: 'Server name is required', + }); + return; + } + + if (!toolName) { + res.status(400).json({ + success: false, + message: 'Tool name is required', + }); + return; + } + + const result = await callCloudServerTool(serverName, toolName, args || {}); + const response: ApiResponse = { + success: true, + data: result, + }; + res.json(response); + } catch (error) { + console.error('Error calling cloud server tool:', error); + const errorMessage = + error instanceof Error ? error.message : 'Failed to call cloud server tool'; + res.status(500).json({ + success: false, + message: errorMessage, + }); + } +}; diff --git a/src/controllers/serverController.ts b/src/controllers/serverController.ts index 7743ef0..9e17f5e 100644 --- a/src/controllers/serverController.ts +++ b/src/controllers/serverController.ts @@ -505,7 +505,7 @@ export const updateToolDescription = async (req: Request, res: Response): Promis export const updateSystemConfig = (req: Request, res: Response): void => { try { - const { routing, install, smartRouting } = req.body; + const { routing, install, smartRouting, mcpRouter } = req.body; const currentUser = (req as any).user; if ( @@ -524,7 +524,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => { typeof smartRouting.dbUrl !== 'string' && typeof smartRouting.openaiApiBaseUrl !== 'string' && typeof smartRouting.openaiApiKey !== 'string' && - typeof smartRouting.openaiApiEmbeddingModel !== 'string')) + typeof smartRouting.openaiApiEmbeddingModel !== 'string')) && + (!mcpRouter || + (typeof mcpRouter.apiKey !== 'string' && + typeof mcpRouter.referer !== 'string' && + typeof mcpRouter.title !== 'string' && + typeof mcpRouter.baseUrl !== 'string')) ) { res.status(400).json({ success: false, @@ -555,6 +560,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => { openaiApiKey: '', openaiApiEmbeddingModel: '', }, + mcpRouter: { + apiKey: '', + referer: 'https://mcphub.app', + title: 'MCPHub', + baseUrl: 'https://api.mcprouter.to/v1', + }, }; } @@ -586,6 +597,15 @@ export const updateSystemConfig = (req: Request, res: Response): void => { }; } + if (!settings.systemConfig.mcpRouter) { + settings.systemConfig.mcpRouter = { + apiKey: '', + referer: 'https://mcphub.app', + title: 'MCPHub', + baseUrl: 'https://api.mcprouter.to/v1', + }; + } + if (routing) { if (typeof routing.enableGlobalRoute === 'boolean') { settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute; @@ -676,6 +696,21 @@ export const updateSystemConfig = (req: Request, res: Response): void => { needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged); } + if (mcpRouter) { + if (typeof mcpRouter.apiKey === 'string') { + settings.systemConfig.mcpRouter.apiKey = mcpRouter.apiKey; + } + if (typeof mcpRouter.referer === 'string') { + settings.systemConfig.mcpRouter.referer = mcpRouter.referer; + } + if (typeof mcpRouter.title === 'string') { + settings.systemConfig.mcpRouter.title = mcpRouter.title; + } + if (typeof mcpRouter.baseUrl === 'string') { + settings.systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl; + } + } + if (saveSettings(settings, currentUser)) { res.json({ success: true, diff --git a/src/routes/index.ts b/src/routes/index.ts index f4789d1..da34ed4 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -43,6 +43,17 @@ import { getMarketServersByCategory, getMarketServersByTag, } from '../controllers/marketController.js'; +import { + getAllCloudServers, + getCloudServer, + getAllCloudCategories, + getAllCloudTags, + searchCloudServersByQuery, + getCloudServersByCategory, + getCloudServersByTag, + getCloudServerToolsList, + callCloudTool, +} from '../controllers/cloudController.js'; import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js'; import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js'; import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js'; @@ -103,6 +114,17 @@ export const initRoutes = (app: express.Application): void => { router.get('/market/tags', getAllMarketTags); router.get('/market/tags/:tag', getMarketServersByTag); + // Cloud Market routes + router.get('/cloud/servers', getAllCloudServers); + router.get('/cloud/servers/search', searchCloudServersByQuery); + router.get('/cloud/servers/:name', getCloudServer); + router.get('/cloud/categories', getAllCloudCategories); + router.get('/cloud/categories/:category', getCloudServersByCategory); + router.get('/cloud/tags', getAllCloudTags); + router.get('/cloud/tags/:tag', getCloudServersByTag); + router.get('/cloud/servers/:serverName/tools', getCloudServerToolsList); + router.post('/cloud/servers/:serverName/tools/:toolName/call', callCloudTool); + // Log routes router.get('/logs', getAllLogs); router.delete('/logs', clearLogs); diff --git a/src/services/cloudService.ts b/src/services/cloudService.ts new file mode 100644 index 0000000..188e1cc --- /dev/null +++ b/src/services/cloudService.ts @@ -0,0 +1,273 @@ +import axios, { AxiosRequestConfig } from 'axios'; +import { + CloudServer, + CloudTool, + MCPRouterResponse, + MCPRouterListServersResponse, + MCPRouterListToolsResponse, + MCPRouterCallToolResponse, +} from '../types/index.js'; +import { loadOriginalSettings } from '../config/index.js'; + +// MCPRouter API default base URL +const DEFAULT_MCPROUTER_API_BASE = 'https://api.mcprouter.to/v1'; + +// Get MCPRouter API config from system configuration +const getMCPRouterConfig = () => { + const settings = loadOriginalSettings(); + const mcpRouterConfig = settings.systemConfig?.mcpRouter; + + return { + apiKey: mcpRouterConfig?.apiKey || process.env.MCPROUTER_API_KEY || '', + referer: mcpRouterConfig?.referer || process.env.MCPROUTER_REFERER || 'https://mcphub.app', + title: mcpRouterConfig?.title || process.env.MCPROUTER_TITLE || 'MCPHub', + baseUrl: + mcpRouterConfig?.baseUrl || process.env.MCPROUTER_API_BASE || DEFAULT_MCPROUTER_API_BASE, + }; +}; + +// Get axios config with MCPRouter headers +const getAxiosConfig = (): AxiosRequestConfig => { + const mcpRouterConfig = getMCPRouterConfig(); + + return { + headers: { + Authorization: mcpRouterConfig.apiKey ? `Bearer ${mcpRouterConfig.apiKey}` : '', + 'HTTP-Referer': mcpRouterConfig.referer || 'https://mcphub.app', + 'X-Title': mcpRouterConfig.title || 'MCPHub', + 'Content-Type': 'application/json', + }, + }; +}; + +// List all available cloud servers +export const getCloudServers = async (): Promise => { + try { + const axiosConfig = getAxiosConfig(); + const mcpRouterConfig = getMCPRouterConfig(); + + const response = await axios.post>( + `${mcpRouterConfig.baseUrl}/list-servers`, + {}, + axiosConfig, + ); + + const data = response.data; + + if (data.code !== 0) { + throw new Error(data.message || 'Failed to fetch servers'); + } + + return data.data.servers || []; + } catch (error) { + console.error('Error fetching cloud market servers:', error); + throw error; + } +}; + +// Get a specific cloud server by name +export const getCloudServerByName = async (name: string): Promise => { + try { + const servers = await getCloudServers(); + return servers.find((server) => server.name === name || server.config_name === name) || null; + } catch (error) { + console.error(`Error fetching cloud server ${name}:`, error); + throw error; + } +}; + +// List tools for a specific cloud server +export const getCloudServerTools = async (serverKey: string): Promise => { + try { + const axiosConfig = getAxiosConfig(); + const mcpRouterConfig = getMCPRouterConfig(); + + if ( + !axiosConfig.headers?.['Authorization'] || + axiosConfig.headers['Authorization'] === 'Bearer ' + ) { + throw new Error('MCPROUTER_API_KEY_NOT_CONFIGURED'); + } + + const response = await axios.post>( + `${mcpRouterConfig.baseUrl}/list-tools`, + { + server: serverKey, + }, + axiosConfig, + ); + + const data = response.data; + if (data.code !== 0) { + throw new Error(data.message || 'Failed to fetch tools'); + } + + return data.data.tools || []; + } catch (error) { + console.error(`Error fetching tools for server ${serverKey}:`, error); + throw error; + } +}; + +// Call a tool on a cloud server +export const callCloudServerTool = async ( + serverName: string, + toolName: string, + args: Record, +): Promise => { + try { + const axiosConfig = getAxiosConfig(); + const mcpRouterConfig = getMCPRouterConfig(); + + if ( + !axiosConfig.headers?.['Authorization'] || + axiosConfig.headers['Authorization'] === 'Bearer ' + ) { + throw new Error('MCPROUTER_API_KEY_NOT_CONFIGURED'); + } + + const response = await axios.post>( + `${mcpRouterConfig.baseUrl}/call-tool`, + { + server: serverName, + name: toolName, + arguments: args, + }, + axiosConfig, + ); + + const data = response.data; + + if (data.code !== 0) { + throw new Error(data.message || 'Failed to call tool'); + } + + return data.data; + } catch (error) { + console.error(`Error calling tool ${toolName} on server ${serverName}:`, error); + throw error; + } +}; + +// Get all categories from cloud servers +export const getCloudCategories = async (): Promise => { + try { + const servers = await getCloudServers(); + const categories = new Set(); + + servers.forEach((server) => { + // Extract categories from content or description + // This is a simple implementation, you might want to parse the content more sophisticatedly + if (server.content) { + const categoryMatches = server.content.match(/category[:\s]*([^,\n]+)/gi); + if (categoryMatches) { + categoryMatches.forEach((match) => { + const category = match.replace(/category[:\s]*/i, '').trim(); + if (category) categories.add(category); + }); + } + } + }); + + return Array.from(categories).sort(); + } catch (error) { + console.error('Error fetching cloud market categories:', error); + throw error; + } +}; + +// Get all tags from cloud servers +export const getCloudTags = async (): Promise => { + try { + const servers = await getCloudServers(); + const tags = new Set(); + + servers.forEach((server) => { + // Extract tags from content or description + if (server.content) { + const tagMatches = server.content.match(/tag[s]?[:\s]*([^,\n]+)/gi); + if (tagMatches) { + tagMatches.forEach((match) => { + const tag = match.replace(/tag[s]?[:\s]*/i, '').trim(); + if (tag) tags.add(tag); + }); + } + } + }); + + return Array.from(tags).sort(); + } catch (error) { + console.error('Error fetching cloud market tags:', error); + throw error; + } +}; + +// Search cloud servers by query +export const searchCloudServers = async (query: string): Promise => { + try { + const servers = await getCloudServers(); + const searchTerms = query + .toLowerCase() + .split(' ') + .filter((term) => term.length > 0); + + if (searchTerms.length === 0) { + return servers; + } + + return servers.filter((server) => { + const searchText = [ + server.name, + server.title, + server.description, + server.content, + server.author_name, + ] + .join(' ') + .toLowerCase(); + + return searchTerms.some((term) => searchText.includes(term)); + }); + } catch (error) { + console.error('Error searching cloud market servers:', error); + throw error; + } +}; + +// Filter cloud servers by category +export const filterCloudServersByCategory = async (category: string): Promise => { + try { + const servers = await getCloudServers(); + + if (!category) { + return servers; + } + + return servers.filter((server) => { + const content = (server.content || '').toLowerCase(); + return content.includes(category.toLowerCase()); + }); + } catch (error) { + console.error('Error filtering cloud market servers by category:', error); + throw error; + } +}; + +// Filter cloud servers by tag +export const filterCloudServersByTag = async (tag: string): Promise => { + try { + const servers = await getCloudServers(); + + if (!tag) { + return servers; + } + + return servers.filter((server) => { + const content = (server.content || '').toLowerCase(); + return content.includes(tag.toLowerCase()); + }); + } catch (error) { + console.error('Error filtering cloud market servers by tag:', error); + throw error; + } +}; diff --git a/src/types/index.ts b/src/types/index.ts index 55f2fb9..f3bcb44 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -81,6 +81,49 @@ export interface MarketServer { is_official?: boolean; } +// Cloud Market Server types (for MCPRouter API) +export interface CloudServer { + created_at: string; + updated_at: string; + name: string; + author_name: string; + title: string; + description: string; + content: string; + server_key: string; + config_name: string; + tools?: CloudTool[]; +} + +export interface CloudTool { + name: string; + description: string; + inputSchema: Record; +} + +// MCPRouter API Response types +export interface MCPRouterResponse { + code: number; + message: string; + data: T; +} + +export interface MCPRouterListServersResponse { + servers: CloudServer[]; +} + +export interface MCPRouterListToolsResponse { + tools: CloudTool[]; +} + +export interface MCPRouterCallToolResponse { + content: Array<{ + type: string; + text: string; + }>; + isError: boolean; +} + export interface SystemConfig { routing?: { enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled @@ -95,6 +138,12 @@ export interface SystemConfig { baseUrl?: string; // Base URL for group card copy operations }; smartRouting?: SmartRoutingConfig; + mcpRouter?: { + apiKey?: string; // MCPRouter API key for authentication + referer?: string; // Referer header for MCPRouter API requests + title?: string; // Title header for MCPRouter API requests + baseUrl?: string; // Base URL for MCPRouter API (default: https://api.mcprouter.to/v1) + }; } export interface UserConfig {