feat: Refactor API URL handling and add base path support (#131)

This commit is contained in:
samanhappy
2025-05-27 16:11:35 +08:00
committed by GitHub
parent 37bb3414c8
commit 268ce5cce6
18 changed files with 624 additions and 443 deletions

View File

@@ -18,8 +18,8 @@ RUN npm install -g pnpm
ARG REQUEST_TIMEOUT=60000 ARG REQUEST_TIMEOUT=60000
ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
ARG VITE_BASE_PATH="" ARG BASE_PATH=""
ENV VITE_BASE_PATH=$VITE_BASE_PATH ENV BASE_PATH=$BASE_PATH
ENV PNPM_HOME=/usr/local/share/pnpm ENV PNPM_HOME=/usr/local/share/pnpm
ENV PATH=$PNPM_HOME:$PATH ENV PATH=$PNPM_HOME:$PATH

View File

@@ -1,16 +1,13 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Hub Dashboard</title> <title>MCP Hub Dashboard</title>
<link rel="icon" type="image/x-icon" href="./favicon.ico"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
</head> </head>
<body class="bg-gray-100"> <body class="bg-gray-100">
<div id="root"></div> <div id="root"></div>
<script type="module" src="./src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -15,13 +15,16 @@ import LogsPage from './pages/LogsPage';
// Get base path from environment variable or default to empty string // Get base path from environment variable or default to empty string
const getBasePath = (): string => { const getBasePath = (): string => {
const basePath = import.meta.env.VITE_BASE_PATH || ''; const basePath = import.meta.env.BASE_PATH || '';
return basePath.startsWith('/') ? basePath : ''; // Ensure the path starts with / if it's not empty and doesn't already start with /
if (basePath && !basePath.startsWith('/')) {
return '/' + basePath;
}
return basePath;
}; };
function App() { function App() {
const basename = getBasePath(); const basename = getBasePath();
return ( return (
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ServerForm from './ServerForm' import ServerForm from './ServerForm'
import { getApiUrl } from '../utils/api'
interface AddServerFormProps { interface AddServerFormProps {
onAdd: () => void onAdd: () => void
@@ -20,7 +21,7 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
try { try {
setError(null) setError(null)
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', { const response = await fetch(getApiUrl('/servers'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Group, ApiResponse } from '@/types'; import { Group, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/api';
export const useGroupData = () => { export const useGroupData = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -13,10 +14,10 @@ export const useGroupData = () => {
try { try {
setLoading(true); setLoading(true);
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/groups', { const response = await fetch(getApiUrl('/groups'), {
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
if (!response.ok) { if (!response.ok) {
@@ -44,18 +45,18 @@ export const useGroupData = () => {
// Trigger a refresh of the groups data // Trigger a refresh of the groups data
const triggerRefresh = useCallback(() => { const triggerRefresh = useCallback(() => {
setRefreshKey(prev => prev + 1); setRefreshKey((prev) => prev + 1);
}, []); }, []);
// Create a new group with server associations // Create a new group with server associations
const createGroup = async (name: string, description?: string, servers: string[] = []) => { const createGroup = async (name: string, description?: string, servers: string[] = []) => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/groups', { const response = await fetch(getApiUrl('/groups'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-auth-token': token || '' 'x-auth-token': token || '',
}, },
body: JSON.stringify({ name, description, servers }), body: JSON.stringify({ name, description, servers }),
}); });
@@ -76,14 +77,17 @@ export const useGroupData = () => {
}; };
// Update an existing group with server associations // Update an existing group with server associations
const updateGroup = async (id: string, data: { name?: string; description?: string; servers?: string[] }) => { const updateGroup = async (
id: string,
data: { name?: string; description?: string; servers?: string[] },
) => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${id}`, { const response = await fetch(getApiUrl(`/groups/${id}`), {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-auth-token': token || '' 'x-auth-token': token || '',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
@@ -107,11 +111,11 @@ export const useGroupData = () => {
const updateGroupServers = async (groupId: string, servers: string[]) => { const updateGroupServers = async (groupId: string, servers: string[]) => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers/batch`, { const response = await fetch(getApiUrl(`/groups/${groupId}/servers/batch`), {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-auth-token': token || '' 'x-auth-token': token || '',
}, },
body: JSON.stringify({ servers }), body: JSON.stringify({ servers }),
}); });
@@ -135,11 +139,11 @@ export const useGroupData = () => {
const deleteGroup = async (id: string) => { const deleteGroup = async (id: string) => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${id}`, { const response = await fetch(getApiUrl(`/groups/${id}`), {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
const result = await response.json(); const result = await response.json();
@@ -161,11 +165,11 @@ export const useGroupData = () => {
const addServerToGroup = async (groupId: string, serverName: string) => { const addServerToGroup = async (groupId: string, serverName: string) => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers`, { const response = await fetch(getApiUrl(`/groups/${groupId}/servers`), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-auth-token': token || '' 'x-auth-token': token || '',
}, },
body: JSON.stringify({ serverName }), body: JSON.stringify({ serverName }),
}); });
@@ -189,11 +193,11 @@ export const useGroupData = () => {
const removeServerFromGroup = async (groupId: string, serverName: string) => { const removeServerFromGroup = async (groupId: string, serverName: string) => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers/${serverName}`, { const response = await fetch(getApiUrl(`/groups/${groupId}/servers/${serverName}`), {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
const result: ApiResponse<Group> = await response.json(); const result: ApiResponse<Group> = await response.json();
@@ -227,6 +231,6 @@ export const useGroupData = () => {
updateGroupServers, updateGroupServers,
deleteGroup, deleteGroup,
addServerToGroup, addServerToGroup,
removeServerFromGroup removeServerFromGroup,
}; };
}; };

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MarketServer, ApiResponse } from '@/types'; import { MarketServer, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/api';
export const useMarketData = () => { export const useMarketData = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -26,10 +27,10 @@ export const useMarketData = () => {
try { try {
setLoading(true); setLoading(true);
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/market/servers', { const response = await fetch(getApiUrl('/market/servers'), {
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
if (!response.ok) { if (!response.ok) {
@@ -55,7 +56,8 @@ export const useMarketData = () => {
}, [t]); }, [t]);
// Apply pagination to data // Apply pagination to data
const applyPagination = useCallback((data: MarketServer[], page: number, itemsPerPage = serversPerPage) => { const applyPagination = useCallback(
(data: MarketServer[], page: number, itemsPerPage = serversPerPage) => {
const totalItems = data.length; const totalItems = data.length;
const calculatedTotalPages = Math.ceil(totalItems / itemsPerPage); const calculatedTotalPages = Math.ceil(totalItems / itemsPerPage);
setTotalPages(calculatedTotalPages); setTotalPages(calculatedTotalPages);
@@ -69,22 +71,27 @@ export const useMarketData = () => {
const startIndex = (validPage - 1) * itemsPerPage; const startIndex = (validPage - 1) * itemsPerPage;
const paginatedServers = data.slice(startIndex, startIndex + itemsPerPage); const paginatedServers = data.slice(startIndex, startIndex + itemsPerPage);
setServers(paginatedServers); setServers(paginatedServers);
}, [serversPerPage]); },
[serversPerPage],
);
// Change page // Change page
const changePage = useCallback((page: number) => { const changePage = useCallback(
(page: number) => {
setCurrentPage(page); setCurrentPage(page);
applyPagination(allServers, page, serversPerPage); applyPagination(allServers, page, serversPerPage);
}, [allServers, applyPagination, serversPerPage]); },
[allServers, applyPagination, serversPerPage],
);
// Fetch all categories // Fetch all categories
const fetchCategories = useCallback(async () => { const fetchCategories = useCallback(async () => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/market/categories', { const response = await fetch(getApiUrl('/market/categories'), {
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
if (!response.ok) { if (!response.ok) {
@@ -107,10 +114,10 @@ export const useMarketData = () => {
const fetchTags = useCallback(async () => { const fetchTags = useCallback(async () => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/market/tags', { const response = await fetch(getApiUrl('/market/tags'), {
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
if (!response.ok) { if (!response.ok) {
@@ -130,14 +137,15 @@ export const useMarketData = () => {
}, []); }, []);
// Fetch server by name // Fetch server by name
const fetchServerByName = useCallback(async (name: string) => { const fetchServerByName = useCallback(
async (name: string) => {
try { try {
setLoading(true); setLoading(true);
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/market/servers/${name}`, { const response = await fetch(getApiUrl(`/market/servers/${name}`), {
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
if (!response.ok) { if (!response.ok) {
@@ -161,10 +169,13 @@ export const useMarketData = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [t]); },
[t],
);
// Search servers by query // Search servers by query
const searchServers = useCallback(async (query: string) => { const searchServers = useCallback(
async (query: string) => {
try { try {
setLoading(true); setLoading(true);
setSearchQuery(query); setSearchQuery(query);
@@ -176,11 +187,14 @@ export const useMarketData = () => {
} }
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/market/servers/search?query=${encodeURIComponent(query)}`, { const response = await fetch(
getApiUrl(`/market/servers/search?query=${encodeURIComponent(query)}`),
{
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); },
);
if (!response.ok) { if (!response.ok) {
throw new Error(`Status: ${response.status}`); throw new Error(`Status: ${response.status}`);
@@ -202,10 +216,13 @@ export const useMarketData = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [t, allServers, applyPagination, fetchMarketServers]); },
[t, allServers, applyPagination, fetchMarketServers],
);
// Filter servers by category // Filter servers by category
const filterByCategory = useCallback(async (category: string) => { const filterByCategory = useCallback(
async (category: string) => {
try { try {
setLoading(true); setLoading(true);
setSelectedCategory(category); setSelectedCategory(category);
@@ -217,11 +234,14 @@ export const useMarketData = () => {
} }
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/market/categories/${encodeURIComponent(category)}`, { const response = await fetch(
getApiUrl(`/market/categories/${encodeURIComponent(category)}`),
{
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); },
);
if (!response.ok) { if (!response.ok) {
throw new Error(`Status: ${response.status}`); throw new Error(`Status: ${response.status}`);
@@ -243,10 +263,13 @@ export const useMarketData = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [t, fetchMarketServers, applyPagination]); },
[t, fetchMarketServers, applyPagination],
);
// Filter servers by tag // Filter servers by tag
const filterByTag = useCallback(async (tag: string) => { const filterByTag = useCallback(
async (tag: string) => {
try { try {
setLoading(true); setLoading(true);
setSelectedTag(tag); setSelectedTag(tag);
@@ -258,10 +281,10 @@ export const useMarketData = () => {
} }
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/market/tags/${encodeURIComponent(tag)}`, { const response = await fetch(getApiUrl(`/market/tags/${encodeURIComponent(tag)}`), {
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
if (!response.ok) { if (!response.ok) {
@@ -284,16 +307,18 @@ export const useMarketData = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [t, fetchMarketServers, applyPagination]); },
[t, fetchMarketServers, applyPagination],
);
// Fetch installed servers // Fetch installed servers
const fetchInstalledServers = useCallback(async () => { const fetchInstalledServers = useCallback(async () => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', { const response = await fetch(getApiUrl('/servers'), {
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
if (!response.ok) { if (!response.ok) {
@@ -313,14 +338,22 @@ export const useMarketData = () => {
}, []); }, []);
// Check if a server is already installed // Check if a server is already installed
const isServerInstalled = useCallback((serverName: string) => { const isServerInstalled = useCallback(
(serverName: string) => {
return installedServers.includes(serverName); return installedServers.includes(serverName);
}, [installedServers]); },
[installedServers],
);
// Install server to the local environment // Install server to the local environment
const installServer = useCallback(async (server: MarketServer) => { const installServer = useCallback(
async (server: MarketServer) => {
try { try {
const installType = server.installations?.npm ? 'npm' : Object.keys(server.installations || {}).length > 0 ? Object.keys(server.installations)[0] : null; const installType = server.installations?.npm
? 'npm'
: Object.keys(server.installations || {}).length > 0
? Object.keys(server.installations)[0]
: null;
if (!installType || !server.installations?.[installType]) { if (!installType || !server.installations?.[installType]) {
setError(t('market.noInstallationMethod')); setError(t('market.noInstallationMethod'));
@@ -335,17 +368,17 @@ export const useMarketData = () => {
config: { config: {
command: installation.command, command: installation.command,
args: installation.args, args: installation.args,
env: installation.env || {} env: installation.env || {},
} },
}; };
// Call the createServer API // Call the createServer API
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', { const response = await fetch(getApiUrl('/servers'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-auth-token': token || '' 'x-auth-token': token || '',
}, },
body: JSON.stringify(serverConfig), body: JSON.stringify(serverConfig),
}); });
@@ -363,14 +396,19 @@ export const useMarketData = () => {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
return false; return false;
} }
}, [t, fetchInstalledServers]); },
[t, fetchInstalledServers],
);
// Change servers per page // Change servers per page
const changeServersPerPage = useCallback((perPage: number) => { const changeServersPerPage = useCallback(
(perPage: number) => {
setServersPerPage(perPage); setServersPerPage(perPage);
setCurrentPage(1); setCurrentPage(1);
applyPagination(allServers, 1, perPage); applyPagination(allServers, 1, perPage);
}, [allServers, applyPagination]); },
[allServers, applyPagination],
);
// Load initial data // Load initial data
useEffect(() => { useEffect(() => {
@@ -405,6 +443,6 @@ export const useMarketData = () => {
changePage, changePage,
changeServersPerPage, changeServersPerPage,
// Installed servers methods // Installed servers methods
isServerInstalled isServerInstalled,
}; };
}; };

View File

@@ -1,18 +1,19 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Server, ApiResponse } from '@/types'; import { Server, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/api';
// Configuration options // Configuration options
const CONFIG = { const CONFIG = {
// Initialization phase configuration // Initialization phase configuration
startup: { startup: {
maxAttempts: 60, // Maximum number of attempts during initialization maxAttempts: 60, // Maximum number of attempts during initialization
pollingInterval: 3000 // Polling interval during initialization (3 seconds) pollingInterval: 3000, // Polling interval during initialization (3 seconds)
}, },
// Normal operation phase configuration // Normal operation phase configuration
normal: { normal: {
pollingInterval: 10000 // Polling interval during normal operation (10 seconds) pollingInterval: 10000, // Polling interval during normal operation (10 seconds)
} },
}; };
export const useServerData = () => { export const useServerData = () => {
@@ -44,10 +45,10 @@ export const useServerData = () => {
const fetchServers = async () => { const fetchServers = async () => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', { const response = await fetch(getApiUrl('/servers'), {
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
const data = await response.json(); const data = await response.json();
@@ -68,10 +69,10 @@ export const useServerData = () => {
// Use friendly error message // Use friendly error message
if (!navigator.onLine) { if (!navigator.onLine) {
setError(t('errors.network')); setError(t('errors.network'));
} else if (err instanceof TypeError && ( } else if (
err.message.includes('NetworkError') || err instanceof TypeError &&
err.message.includes('Failed to fetch') (err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
)) { ) {
setError(t('errors.serverConnection')); setError(t('errors.serverConnection'));
} else { } else {
setError(t('errors.serverFetch')); setError(t('errors.serverFetch'));
@@ -97,10 +98,10 @@ export const useServerData = () => {
const fetchInitialData = async () => { const fetchInitialData = async () => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', { const response = await fetch(getApiUrl('/servers'), {
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
const data = await response.json(); const data = await response.json();
@@ -191,12 +192,12 @@ export const useServerData = () => {
} }
// Change in refreshKey will trigger useEffect to run again // Change in refreshKey will trigger useEffect to run again
setRefreshKey(prevKey => prevKey + 1); setRefreshKey((prevKey) => prevKey + 1);
}; };
// Server related operations // Server related operations
const handleServerAdd = () => { const handleServerAdd = () => {
setRefreshKey(prevKey => prevKey + 1); setRefreshKey((prevKey) => prevKey + 1);
}; };
const handleServerEdit = async (server: Server) => { const handleServerEdit = async (server: Server) => {
@@ -205,8 +206,8 @@ export const useServerData = () => {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/settings`, { const response = await fetch(`/api/settings`, {
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json(); const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json();
@@ -243,8 +244,8 @@ export const useServerData = () => {
const response = await fetch(`/api/servers/${serverName}`, { const response = await fetch(`/api/servers/${serverName}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'x-auth-token': token || '' 'x-auth-token': token || '',
} },
}); });
const result = await response.json(); const result = await response.json();
@@ -253,7 +254,7 @@ export const useServerData = () => {
return false; return false;
} }
setRefreshKey(prevKey => prevKey + 1); setRefreshKey((prevKey) => prevKey + 1);
return true; return true;
} catch (err) { } catch (err) {
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err))); setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
@@ -268,7 +269,7 @@ export const useServerData = () => {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-auth-token': token || '' 'x-auth-token': token || '',
}, },
body: JSON.stringify({ enabled }), body: JSON.stringify({ enabled }),
}); });
@@ -282,7 +283,7 @@ export const useServerData = () => {
} }
// Update the UI immediately to reflect the change // Update the UI immediately to reflect the change
setRefreshKey(prevKey => prevKey + 1); setRefreshKey((prevKey) => prevKey + 1);
return true; return true;
} catch (err) { } catch (err) {
console.error('Error toggling server:', err); console.error('Error toggling server:', err);
@@ -301,6 +302,6 @@ export const useServerData = () => {
handleServerAdd, handleServerAdd,
handleServerEdit, handleServerEdit,
handleServerRemove, handleServerRemove,
handleServerToggle handleServerToggle,
}; };
}; };

View File

@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ApiResponse } from '@/types'; import { ApiResponse } from '@/types';
import { useToast } from '@/contexts/ToastContext'; import { useToast } from '@/contexts/ToastContext';
import { getApiUrl } from '../utils/api';
// Define types for the settings data // Define types for the settings data
interface RoutingConfig { interface RoutingConfig {
@@ -80,7 +81,7 @@ export const useSettingsData = () => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/settings', { const response = await fetch(getApiUrl('/settings'), {
headers: { headers: {
'x-auth-token': token || '', 'x-auth-token': token || '',
}, },
@@ -136,7 +137,7 @@ export const useSettingsData = () => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/system-config', { const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -183,7 +184,7 @@ export const useSettingsData = () => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/system-config', { const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -233,7 +234,7 @@ export const useSettingsData = () => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/system-config', { const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -283,7 +284,7 @@ export const useSettingsData = () => {
try { try {
const token = localStorage.getItem('mcphub_token'); const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/system-config', { const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,7 +1,10 @@
import { AuthResponse, LoginCredentials, RegisterCredentials, ChangePasswordCredentials } from '../types'; import {
AuthResponse,
// Base URL for API requests LoginCredentials,
const API_URL = ''; RegisterCredentials,
ChangePasswordCredentials,
} from '../types';
import { getApiUrl } from '../utils/api';
// Token key in localStorage // Token key in localStorage
const TOKEN_KEY = 'mcphub_token'; const TOKEN_KEY = 'mcphub_token';
@@ -24,7 +27,8 @@ export const removeToken = (): void => {
// Login user // Login user
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => { export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
try { try {
const response = await fetch(`${API_URL}/auth/login`, { console.log(getApiUrl('/auth/login'));
const response = await fetch(getApiUrl('/auth/login'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -51,7 +55,7 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
// Register user // Register user
export const register = async (credentials: RegisterCredentials): Promise<AuthResponse> => { export const register = async (credentials: RegisterCredentials): Promise<AuthResponse> => {
try { try {
const response = await fetch(`${API_URL}/auth/register`, { const response = await fetch(getApiUrl('/auth/register'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -87,7 +91,7 @@ export const getCurrentUser = async (): Promise<AuthResponse> => {
} }
try { try {
const response = await fetch(`${API_URL}/auth/user`, { const response = await fetch(getApiUrl('/auth/user'), {
method: 'GET', method: 'GET',
headers: { headers: {
'x-auth-token': token, 'x-auth-token': token,
@@ -105,7 +109,9 @@ export const getCurrentUser = async (): Promise<AuthResponse> => {
}; };
// Change password // Change password
export const changePassword = async (credentials: ChangePasswordCredentials): Promise<AuthResponse> => { export const changePassword = async (
credentials: ChangePasswordCredentials,
): Promise<AuthResponse> => {
const token = getToken(); const token = getToken();
if (!token) { if (!token) {
@@ -116,7 +122,7 @@ export const changePassword = async (credentials: ChangePasswordCredentials): Pr
} }
try { try {
const response = await fetch(`${API_URL}/auth/change-password`, { const response = await fetch(getApiUrl('/auth/change-password'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getToken } from './authService'; // Import getToken function import { getToken } from './authService'; // Import getToken function
import { getApiUrl } from '../utils/api';
export interface LogEntry { export interface LogEntry {
timestamp: number; timestamp: number;
@@ -18,10 +19,10 @@ export const fetchLogs = async (): Promise<LogEntry[]> => {
throw new Error('Authentication token not found. Please log in.'); throw new Error('Authentication token not found. Please log in.');
} }
const response = await fetch('/api/logs', { const response = await fetch(getApiUrl('/logs'), {
headers: { headers: {
'x-auth-token': token 'x-auth-token': token,
} },
}); });
const result = await response.json(); const result = await response.json();
@@ -46,11 +47,11 @@ export const clearLogs = async (): Promise<void> => {
throw new Error('Authentication token not found. Please log in.'); throw new Error('Authentication token not found. Please log in.');
} }
const response = await fetch('/api/logs', { const response = await fetch(getApiUrl('/logs'), {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'x-auth-token': token 'x-auth-token': token,
} },
}); });
const result = await response.json(); const result = await response.json();
@@ -90,7 +91,7 @@ export const useLogs = () => {
} }
// Connect to SSE endpoint with auth token in URL // Connect to SSE endpoint with auth token in URL
eventSource = new EventSource(`/api/logs/stream?token=${token}`); eventSource = new EventSource(getApiUrl(`/logs/stream?token=${token}`));
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
if (!isMounted) return; if (!isMounted) return;
@@ -102,7 +103,7 @@ export const useLogs = () => {
setLogs(data.logs); setLogs(data.logs);
setLoading(false); setLoading(false);
} else if (data.type === 'log') { } else if (data.type === 'log') {
setLogs(prevLogs => [...prevLogs, data.log]); setLogs((prevLogs) => [...prevLogs, data.log]);
} }
} catch (err) { } catch (err) {
console.error('Error parsing SSE message:', err); console.error('Error parsing SSE message:', err);

27
frontend/src/utils/api.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* API utility functions for constructing URLs with proper base path support
*/
/**
* Get the API base URL including base path and /api prefix
* @returns The complete API base URL
*/
export const getApiBaseUrl = (): string => {
const basePath = import.meta.env.BASE_PATH || '';
// Ensure the path starts with / if it's not empty and doesn't already start with /
const normalizedBasePath = basePath && !basePath.startsWith('/') ? '/' + basePath : basePath;
// Always append /api to the base path for API endpoints
return normalizedBasePath + '/api';
};
/**
* Construct a full API URL with the given endpoint
* @param endpoint - The API endpoint (should start with /, e.g., '/auth/login')
* @returns The complete API URL
*/
export const getApiUrl = (endpoint: string): string => {
const baseUrl = getApiBaseUrl();
// Ensure endpoint starts with /
const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : '/' + endpoint;
return baseUrl + normalizedEndpoint;
};

View File

@@ -3,7 +3,7 @@
interface ImportMeta { interface ImportMeta {
readonly env: { readonly env: {
readonly PACKAGE_VERSION: string; readonly PACKAGE_VERSION: string;
readonly VITE_BASE_PATH?: string; // Add base path environment variable readonly BASE_PATH?: string; // Add base path environment variable
// Add other custom env variables here if needed // Add other custom env variables here if needed
[key: string]: any; [key: string]: any;
}; };

View File

@@ -8,9 +8,12 @@ import { readFileSync } from 'fs';
// Get package.json version // Get package.json version
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8')); const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'));
// Get base path from environment variable
const basePath = process.env.BASE_PATH || '';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: './', // Use relative paths for assets base: basePath || './', // Use base path or relative paths for assets
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
@@ -18,19 +21,20 @@ export default defineConfig({
}, },
}, },
define: { define: {
// Make package version available as global variable // Make package version and base path available as global variables
'import.meta.env.PACKAGE_VERSION': JSON.stringify(packageJson.version), 'import.meta.env.PACKAGE_VERSION': JSON.stringify(packageJson.version),
'import.meta.env.BASE_PATH': JSON.stringify(basePath),
}, },
build: { build: {
sourcemap: true, // Enable source maps for production build sourcemap: true, // Enable source maps for production build
}, },
server: { server: {
proxy: { proxy: {
'/api': { [`${basePath}/api`]: {
target: 'http://localhost:3000', target: 'http://localhost:3000',
changeOrigin: true, changeOrigin: true,
}, },
'/auth': { [`${basePath}/auth`]: {
target: 'http://localhost:3000', target: 'http://localhost:3000',
changeOrigin: true, changeOrigin: true,
}, },

72
nginx.conf.example Normal file
View File

@@ -0,0 +1,72 @@
# Nginx configuration example for MCPHub with subpath routing
# This example shows how to deploy MCPHub under a subpath like /mcphub
server {
listen 80;
server_name your-domain.com;
# MCPHub under /mcphub subpath
location /mcphub/ {
# Remove the subpath prefix before forwarding to MCPHub
rewrite ^/mcphub/(.*)$ /$1 break;
proxy_pass http://mcphub:3000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Important: Disable buffering for SSE connections
proxy_buffering off;
proxy_cache off;
# Support for Server-Sent Events (SSE)
proxy_read_timeout 24h;
proxy_send_timeout 24h;
}
# Alternative configuration if you want to keep the subpath in the backend
# In this case, set BASE_PATH=/mcphub
# location /mcphub/ {
# proxy_pass http://mcphub:3000/mcphub/;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_cache_bypass $http_upgrade;
#
# # Important: Disable buffering for SSE connections
# proxy_buffering off;
# proxy_cache off;
#
# # Support for Server-Sent Events (SSE)
# proxy_read_timeout 24h;
# proxy_send_timeout 24h;
# }
}
# Docker Compose example with subpath
# version: '3.8'
# services:
# mcphub:
# image: samanhappy/mcphub
# environment:
# - BASE_PATH=/mcphub
# volumes:
# - ./mcp_settings.json:/app/mcp_settings.json
#
# nginx:
# image: nginx:alpine
# ports:
# - "80:80"
# volumes:
# - ./nginx.conf:/etc/nginx/conf.d/default.conf
# depends_on:
# - mcphub

View File

@@ -10,6 +10,7 @@ const defaultConfig = {
port: process.env.PORT || 3000, port: process.env.PORT || 3000,
initTimeout: process.env.INIT_TIMEOUT || 300000, initTimeout: process.env.INIT_TIMEOUT || 300000,
timeout: process.env.REQUEST_TIMEOUT || 60000, timeout: process.env.REQUEST_TIMEOUT || 60000,
basePath: process.env.BASE_PATH || '',
mcpHubName: 'mcphub', mcpHubName: 'mcphub',
mcpHubVersion: getPackageVersion(), mcpHubVersion: getPackageVersion(),
}; };

View File

@@ -5,6 +5,7 @@ import { dirname } from 'path';
import fs from 'fs'; import fs from 'fs';
import { auth } from './auth.js'; import { auth } from './auth.js';
import { initializeDefaultUser } from '../models/User.js'; import { initializeDefaultUser } from '../models/User.js';
import config from '../config/index.js';
// Create __dirname equivalent for ES modules // Create __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -35,7 +36,7 @@ export const errorHandler = (
err: Error, err: Error,
_req: Request, _req: Request,
res: Response, res: Response,
_next: NextFunction _next: NextFunction,
): void => { ): void => {
console.error('Unhandled error:', err); console.error('Unhandled error:', err);
res.status(500).json({ res.status(500).json({
@@ -46,10 +47,17 @@ export const errorHandler = (
export const initMiddlewares = (app: express.Application): void => { export const initMiddlewares = (app: express.Application): void => {
// Serve static files from the dynamically determined frontend path // Serve static files from the dynamically determined frontend path
app.use(express.static(frontendPath)); // Note: Static files will be handled by the server directly, not here
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.path !== '/sse' && req.path !== '/messages') { const basePath = config.basePath;
// Only apply JSON parsing for API and auth routes, not for SSE or message endpoints
if (
req.path !== `${basePath}/sse` &&
req.path !== `${basePath}/messages` &&
!req.path.startsWith(`${basePath}/sse/`) &&
!req.path.startsWith(`${basePath}/mcp/`)
) {
express.json()(req, res, next); express.json()(req, res, next);
} else { } else {
next(); next();
@@ -57,16 +65,18 @@ export const initMiddlewares = (app: express.Application): void => {
}); });
// Initialize default admin user if no users exist // Initialize default admin user if no users exist
initializeDefaultUser().catch(err => { initializeDefaultUser().catch((err) => {
console.error('Error initializing default user:', err); console.error('Error initializing default user:', err);
}); });
// Protect all API routes with authentication middleware // Protect API routes with authentication middleware, but exclude auth endpoints
app.use('/api', auth); app.use(`${config.basePath}/api`, (req, res, next) => {
// Skip authentication for login and register endpoints
app.get('/', (_req: Request, res: Response) => { if (req.path === '/auth/login' || req.path === '/auth/register') {
// Serve the frontend application next();
res.sendFile(path.join(frontendPath, 'index.html')); } else {
auth(req, res, next);
}
}); });
app.use(errorHandler); app.use(errorHandler);

View File

@@ -1,5 +1,6 @@
import express from 'express'; import express from 'express';
import { check } from 'express-validator'; import { check } from 'express-validator';
import config from '../config/index.js';
import { import {
getAllServers, getAllServers,
getAllSettings, getAllSettings,
@@ -7,7 +8,7 @@ import {
updateServer, updateServer,
deleteServer, deleteServer,
toggleServer, toggleServer,
updateSystemConfig updateSystemConfig,
} from '../controllers/serverController.js'; } from '../controllers/serverController.js';
import { import {
getGroups, getGroups,
@@ -18,7 +19,7 @@ import {
addServerToExistingGroup, addServerToExistingGroup,
removeServerFromExistingGroup, removeServerFromExistingGroup,
getGroupServers, getGroupServers,
updateGroupServersBatch updateGroupServersBatch,
} from '../controllers/groupController.js'; } from '../controllers/groupController.js';
import { import {
getAllMarketServers, getAllMarketServers,
@@ -27,19 +28,10 @@ import {
getAllMarketTags, getAllMarketTags,
searchMarketServersByQuery, searchMarketServersByQuery,
getMarketServersByCategory, getMarketServersByCategory,
getMarketServersByTag getMarketServersByTag,
} from '../controllers/marketController.js'; } from '../controllers/marketController.js';
import { import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
login, import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
register,
getCurrentUser,
changePassword
} from '../controllers/authController.js';
import {
getAllLogs,
clearLogs,
streamLogs
} from '../controllers/logController.js';
import { auth } from '../middlewares/auth.js'; import { auth } from '../middlewares/auth.js';
const router = express.Router(); const router = express.Router();
@@ -80,27 +72,39 @@ export const initRoutes = (app: express.Application): void => {
router.delete('/logs', clearLogs); router.delete('/logs', clearLogs);
router.get('/logs/stream', streamLogs); router.get('/logs/stream', streamLogs);
// Auth routes (these will NOT be protected by auth middleware) // Auth routes - move to router instead of app directly
app.post('/auth/login', [ router.post(
'/auth/login',
[
check('username', 'Username is required').not().isEmpty(), check('username', 'Username is required').not().isEmpty(),
check('password', 'Password is required').not().isEmpty(), check('password', 'Password is required').not().isEmpty(),
], login); ],
login,
);
app.post('/auth/register', [ router.post(
'/auth/register',
[
check('username', 'Username is required').not().isEmpty(), check('username', 'Username is required').not().isEmpty(),
check('password', 'Password must be at least 6 characters').isLength({ min: 6 }), check('password', 'Password must be at least 6 characters').isLength({ min: 6 }),
], register); ],
register,
);
app.get('/auth/user', auth, getCurrentUser); router.get('/auth/user', auth, getCurrentUser);
// Add change password route // Add change password route
app.post('/auth/change-password', [ router.post(
'/auth/change-password',
[
auth, auth,
check('currentPassword', 'Current password is required').not().isEmpty(), check('currentPassword', 'Current password is required').not().isEmpty(),
check('newPassword', 'New password must be at least 6 characters').isLength({ min: 6 }), check('newPassword', 'New password must be at least 6 characters').isLength({ min: 6 }),
], changePassword); ],
changePassword,
);
app.use('/api', router); app.use(`${config.basePath}/api`, router);
}; };
export default router; export default router;

View File

@@ -22,10 +22,12 @@ export class AppServer {
private app: express.Application; private app: express.Application;
private port: number | string; private port: number | string;
private frontendPath: string | null = null; private frontendPath: string | null = null;
private basePath: string;
constructor() { constructor() {
this.app = express(); this.app = express();
this.port = config.port; this.port = config.port;
this.basePath = config.basePath;
} }
async initialize(): Promise<void> { async initialize(): Promise<void> {
@@ -40,11 +42,11 @@ export class AppServer {
initUpstreamServers() initUpstreamServers()
.then(() => { .then(() => {
console.log('MCP server initialized successfully'); console.log('MCP server initialized successfully');
this.app.get('/sse/:group?', (req, res) => handleSseConnection(req, res)); this.app.get(`${this.basePath}/sse/:group?`, (req, res) => handleSseConnection(req, res));
this.app.post('/messages', handleSseMessage); this.app.post(`${this.basePath}/messages`, handleSseMessage);
this.app.post('/mcp/:group?', handleMcpPostRequest); this.app.post(`${this.basePath}/mcp/:group?`, handleMcpPostRequest);
this.app.get('/mcp/:group?', handleMcpOtherRequest); this.app.get(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest);
this.app.delete('/mcp/:group?', handleMcpOtherRequest); this.app.delete(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest);
}) })
.catch((error) => { .catch((error) => {
console.error('Error initializing MCP server:', error); console.error('Error initializing MCP server:', error);
@@ -66,17 +68,26 @@ export class AppServer {
if (this.frontendPath) { if (this.frontendPath) {
console.log(`Serving frontend from: ${this.frontendPath}`); console.log(`Serving frontend from: ${this.frontendPath}`);
this.app.use(express.static(this.frontendPath)); // Serve static files with base path
this.app.use(this.basePath, express.static(this.frontendPath));
// Add the wildcard route for SPA // Add the wildcard route for SPA with base path
if (fs.existsSync(path.join(this.frontendPath, 'index.html'))) { if (fs.existsSync(path.join(this.frontendPath, 'index.html'))) {
this.app.get('*', (_req, res) => { this.app.get(`${this.basePath}/*`, (_req, res) => {
res.sendFile(path.join(this.frontendPath!, 'index.html')); res.sendFile(path.join(this.frontendPath!, 'index.html'));
}); });
// Also handle root redirect if base path is set
if (this.basePath) {
this.app.get('/', (_req, res) => {
res.redirect(this.basePath);
});
}
} }
} else { } else {
console.warn('Frontend dist directory not found. Server will run without frontend.'); console.warn('Frontend dist directory not found. Server will run without frontend.');
this.app.get('/', (_req, res) => { const rootPath = this.basePath || '/';
this.app.get(rootPath, (_req, res) => {
res res
.status(404) .status(404)
.send('Frontend not found. MCPHub API is running, but the UI is not available.'); .send('Frontend not found. MCPHub API is running, but the UI is not available.');