Add Chinese localization support and i18n middleware (#253)

This commit is contained in:
samanhappy
2025-08-03 11:53:04 +08:00
committed by GitHub
parent a6cea2ad3f
commit 63b356b8d7
33 changed files with 766 additions and 602 deletions

View File

@@ -21,6 +21,9 @@ ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
ARG BASE_PATH=""
ENV BASE_PATH=$BASE_PATH
ARG READONLY=false
ENV READONLY=$READONLY
ENV PNPM_HOME=/usr/local/share/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN mkdir -p $PNPM_HOME && \

View File

@@ -50,9 +50,8 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
}
const result = await createGroup(formData.name, formData.description, formData.servers)
if (!result) {
setError(t('groups.createError'))
if (!result || !result.success) {
setError(result?.message || t('groups.createError'))
setIsSubmitting(false)
return
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ServerForm from './ServerForm'
import { getApiUrl } from '../utils/runtime'
import { apiPost } from '../utils/fetchInterceptor'
import { detectVariables } from '../utils/variableDetection'
interface AddServerFormProps {
@@ -34,26 +34,12 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
const submitServer = async (payload: any) => {
try {
setError(null)
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify(payload),
})
const result = await apiPost('/servers', payload)
const result = await response.json()
if (!response.ok) {
if (!result.success) {
// Use specific error message from the response if available
if (result && result.message) {
setError(result.message)
} else if (response.status === 400) {
setError(t('server.invalidData'))
} else if (response.status === 409) {
setError(t('server.alreadyExists', { serverName: payload.name }))
} else {
setError(t('server.addError'))
}

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiPost, apiGet, apiPut, fetchWithInterceptors } from '@/utils/fetchInterceptor';
import { getApiUrl } from '@/utils/runtime';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
@@ -81,12 +82,8 @@ const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) =>
const formData = new FormData();
formData.append('dxtFile', selectedFile);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/dxt/upload'), {
const response = await fetchWithInterceptors(getApiUrl('/dxt/upload'), {
method: 'POST',
headers: {
'x-auth-token': token || '',
},
body: formData,
});
@@ -119,19 +116,11 @@ const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) =>
// Convert DXT manifest to MCPHub stdio server configuration
const serverConfig = convertDxtToMcpConfig(manifestData, extractDir, serverName);
const token = localStorage.getItem('mcphub_token');
// First, check if server exists
if (!forceOverride) {
const checkResponse = await fetch(getApiUrl('/servers'), {
method: 'GET',
headers: {
'x-auth-token': token || '',
},
});
const checkResult = await apiGet('/servers');
if (checkResponse.ok) {
const checkResult = await checkResponse.json();
if (checkResult.success) {
const existingServer = checkResult.data?.find((server: any) => server.name === serverName);
if (existingServer) {
@@ -145,25 +134,17 @@ const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) =>
}
// Install or override the server
const method = forceOverride ? 'PUT' : 'POST';
const url = forceOverride ? getApiUrl(`/servers/${encodeURIComponent(serverName)}`) : getApiUrl('/servers');
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
let result;
if (forceOverride) {
result = await apiPut(`/servers/${encodeURIComponent(serverName)}`, {
name: serverName,
config: serverConfig,
});
} else {
result = await apiPost('/servers', {
name: serverName,
config: serverConfig,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
}
if (result.success) {

View File

@@ -56,8 +56,8 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
servers: formData.servers
})
if (!result) {
setError(t('groups.updateError'))
if (!result || !result.success) {
setError(result?.message || t('groups.updateError'))
setIsSubmitting(false)
return
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Server } from '@/types'
import { getApiUrl } from '../utils/runtime'
import { apiPut } from '../utils/fetchInterceptor'
import ServerForm from './ServerForm'
interface EditServerFormProps {
@@ -17,26 +17,12 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
const handleSubmit = async (payload: any) => {
try {
setError(null)
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/servers/${server.name}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify(payload),
})
const result = await apiPut(`/servers/${server.name}`, payload)
const result = await response.json()
if (!response.ok) {
if (!result.success) {
// Use specific error message from the response if available
if (result && result.message) {
setError(result.message)
} else if (response.status === 404) {
setError(t('server.notFound', { serverName: server.name }))
} else if (response.status === 400) {
setError(t('server.invalidData'))
} else {
setError(t('server.updateError', { serverName: server.name }))
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, ApiResponse, IGroupServerConfig } from '@/types';
import { getApiUrl } from '../utils/runtime';
import { apiGet, apiPost, apiPut, apiDelete } from '../utils/fetchInterceptor';
export const useGroupData = () => {
const { t } = useTranslation();
@@ -13,18 +13,7 @@ export const useGroupData = () => {
const fetchGroups = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/groups'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<Group[]> = await response.json();
const data: ApiResponse<Group[]> = await apiGet('/groups');
if (data && data.success && Array.isArray(data.data)) {
setGroups(data.data);
@@ -55,25 +44,16 @@ export const useGroupData = () => {
servers: string[] | IGroupServerConfig[] = [],
) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/groups'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ name, description, servers }),
});
const result: ApiResponse<Group> = await apiPost('/groups', { name, description, servers });
console.log('Group created successfully:', result);
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.createError'));
return null;
if (!result || !result.success) {
setError(result?.message || t('groups.createError'));
return result;
}
triggerRefresh();
return result.data || null;
return result || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create group');
return null;
@@ -86,25 +66,14 @@ export const useGroupData = () => {
data: { name?: string; description?: string; servers?: string[] | IGroupServerConfig[] },
) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${id}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify(data),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.updateError'));
return null;
const result: ApiResponse<Group> = await apiPut(`/groups/${id}`, data);
if (!result || !result.success) {
setError(result?.message || t('groups.updateError'));
return result;
}
triggerRefresh();
return result.data || null;
return result || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update group');
return null;
@@ -114,20 +83,12 @@ export const useGroupData = () => {
// Update servers in a group (for batch updates)
const updateGroupServers = async (groupId: string, servers: string[] | IGroupServerConfig[]) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/batch`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ servers }),
const result: ApiResponse<Group> = await apiPut(`/groups/${groupId}/servers/batch`, {
servers,
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.updateError'));
if (!result || !result.success) {
setError(result?.message || t('groups.updateError'));
return null;
}
@@ -142,46 +103,29 @@ export const useGroupData = () => {
// Delete a group
const deleteGroup = async (id: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${id}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || '',
},
});
const result = await response.json();
if (!response.ok) {
setError(result.message || t('groups.deleteError'));
return false;
const result = await apiDelete(`/groups/${id}`);
if (!result || !result.success) {
setError(result?.message || t('groups.deleteError'));
return result;
}
triggerRefresh();
return true;
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete group');
return false;
return null;
}
};
// Add server to a group
const addServerToGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${groupId}/servers`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ serverName }),
const result: ApiResponse<Group> = await apiPost(`/groups/${groupId}/servers`, {
serverName,
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverAddError'));
if (!result || !result.success) {
setError(result?.message || t('groups.serverAddError'));
return null;
}
@@ -196,18 +140,12 @@ export const useGroupData = () => {
// Remove server from group
const removeServerFromGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/${serverName}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || '',
},
});
const result: ApiResponse<Group> = await apiDelete(
`/groups/${groupId}/servers/${serverName}`,
);
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverRemoveError'));
if (!result || !result.success) {
setError(result?.message || t('groups.serverRemoveError'));
return null;
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, ApiResponse, ServerConfig } from '@/types';
import { getApiUrl } from '../utils/runtime';
import { apiGet, apiPost } from '../utils/fetchInterceptor';
export const useMarketData = () => {
const { t } = useTranslation();
@@ -26,18 +26,7 @@ export const useMarketData = () => {
const fetchMarketServers = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/servers'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
const data: ApiResponse<MarketServer[]> = await apiGet('/market/servers');
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
@@ -87,18 +76,7 @@ export const useMarketData = () => {
// Fetch all categories
const fetchCategories = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/categories'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<string[]> = await response.json();
const data: ApiResponse<string[]> = await apiGet('/market/categories');
if (data && data.success && Array.isArray(data.data)) {
setCategories(data.data);
@@ -113,18 +91,7 @@ export const useMarketData = () => {
// Fetch all tags
const fetchTags = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/tags'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<string[]> = await response.json();
const data: ApiResponse<string[]> = await apiGet('/market/tags');
if (data && data.success && Array.isArray(data.data)) {
setTags(data.data);
@@ -141,18 +108,7 @@ export const useMarketData = () => {
async (name: string) => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/market/servers/${name}`), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer> = await response.json();
const data: ApiResponse<MarketServer> = await apiGet(`/market/servers/${name}`);
if (data && data.success && data.data) {
setCurrentServer(data.data);
@@ -186,22 +142,10 @@ export const useMarketData = () => {
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(
getApiUrl(`/market/servers/search?query=${encodeURIComponent(query)}`),
{
headers: {
'x-auth-token': token || '',
},
},
const data: ApiResponse<MarketServer[]> = await apiGet(
`/market/servers/search?query=${encodeURIComponent(query)}`,
);
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
@@ -233,22 +177,10 @@ export const useMarketData = () => {
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(
getApiUrl(`/market/categories/${encodeURIComponent(category)}`),
{
headers: {
'x-auth-token': token || '',
},
},
const data: ApiResponse<MarketServer[]> = await apiGet(
`/market/categories/${encodeURIComponent(category)}`,
);
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
@@ -280,18 +212,9 @@ export const useMarketData = () => {
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/market/tags/${encodeURIComponent(tag)}`), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
const data: ApiResponse<MarketServer[]> = await apiGet(
`/market/tags/${encodeURIComponent(tag)}`,
);
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
@@ -314,18 +237,7 @@ export const useMarketData = () => {
// Fetch installed servers
const fetchInstalledServers = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data = await response.json();
const data = await apiGet<{ success: boolean; data: any[] }>('/servers');
if (data && data.success && Array.isArray(data.data)) {
// Extract server names
@@ -365,27 +277,24 @@ export const useMarketData = () => {
// Prepare server configuration, merging with customConfig
const serverConfig = {
name: server.name,
config: customConfig.type === 'stdio' ? {
config:
customConfig.type === 'stdio'
? {
command: customConfig.command || installation.command || '',
args: customConfig.args || installation.args || [],
env: { ...installation.env, ...customConfig.env },
} : customConfig
}
: customConfig,
};
// Call the createServer API
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify(serverConfig),
});
const result = await apiPost<{ success: boolean; message?: string }>(
'/servers',
serverConfig,
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Status: ${response.status}`);
if (!result.success) {
throw new Error(result.message || 'Failed to install server');
}
// Update installed servers list after successful installation

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Server, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/runtime';
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
// Configuration options
const CONFIG = {
@@ -44,13 +44,7 @@ export const useServerData = () => {
const fetchServers = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || '',
},
});
const data = await response.json();
const data = await apiGet('/servers');
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
@@ -97,13 +91,7 @@ export const useServerData = () => {
// Initialization phase request function
const fetchInitialData = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || '',
},
});
const data = await response.json();
const data = await apiGet('/servers');
// Handle API response wrapper object, extract data field
if (data && data.success && Array.isArray(data.data)) {
@@ -203,14 +191,8 @@ export const useServerData = () => {
const handleServerEdit = async (server: Server) => {
try {
// Fetch settings to get the full server config before editing
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/settings'), {
headers: {
'x-auth-token': token || '',
},
});
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json();
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
await apiGet('/settings');
if (
settingsData &&
@@ -240,17 +222,10 @@ export const useServerData = () => {
const handleServerRemove = async (serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/servers/${serverName}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || '',
},
});
const result = await response.json();
const result = await apiDelete(`/servers/${serverName}`);
if (!response.ok) {
setError(result.message || t('server.deleteError', { serverName }));
if (!result || !result.success) {
setError(result?.message || t('server.deleteError', { serverName }));
return false;
}
@@ -264,21 +239,11 @@ export const useServerData = () => {
const handleServerToggle = async (server: Server, enabled: boolean) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/servers/${server.name}/toggle`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ enabled }),
});
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
const result = await response.json();
if (!response.ok) {
if (!result || !result.success) {
console.error('Failed to toggle server:', result);
setError(t('server.toggleError', { serverName: server.name }));
setError(result?.message || t('server.toggleError', { serverName: server.name }));
return false;
}

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiResponse } from '@/types';
import { useToast } from '@/contexts/ToastContext';
import { getApiUrl } from '../utils/runtime';
import { apiGet, apiPut } from '../utils/fetchInterceptor';
// Define types for the settings data
interface RoutingConfig {
@@ -84,18 +84,7 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/settings'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data: ApiResponse<SystemSettings> = await response.json();
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
if (data.success && data.data?.systemConfig?.routing) {
setRoutingConfig({
@@ -134,34 +123,17 @@ export const useSettingsData = () => {
}, [t]); // 移除 showToast 依赖
// Update routing configuration
const updateRoutingConfig = async <T extends keyof RoutingConfig>(
key: T,
value: RoutingConfig[T],
) => {
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
const data = await apiPut('/system-config', {
routing: {
[key]: value,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setRoutingConfig({
...routingConfig,
@@ -170,7 +142,7 @@ export const useSettingsData = () => {
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateRouteConfig'));
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
return false;
}
} catch (error) {
@@ -189,26 +161,12 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
const data = await apiPut('/system-config', {
install: {
[key]: value,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setInstallConfig({
...installConfig,
@@ -217,7 +175,7 @@ export const useSettingsData = () => {
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateSystemConfig'));
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
@@ -239,27 +197,12 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
const data = await apiPut('/system-config', {
smartRouting: {
[key]: value,
},
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
@@ -289,25 +232,10 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
const data = await apiPut('/system-config', {
smartRouting: updates,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
@@ -337,24 +265,10 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
const data = await apiPut('/system-config', {
routing: updates,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setRoutingConfig({
...routingConfig,
@@ -363,7 +277,7 @@ export const useSettingsData = () => {
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateRouteConfig'));
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
return false;
}
} catch (error) {

View File

@@ -2,9 +2,9 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import translations
import enTranslation from './locales/en.json';
import zhTranslation from './locales/zh.json';
// Import shared translations from root locales directory
import enTranslation from '../../locales/en.json';
import zhTranslation from '../../locales/zh.json';
i18n
// Detect user language
@@ -15,11 +15,11 @@ i18n
.init({
resources: {
en: {
translation: enTranslation
translation: enTranslation,
},
zh: {
translation: zhTranslation
}
translation: zhTranslation,
},
},
fallbackLng: 'en',
debug: process.env.NODE_ENV === 'development',
@@ -36,7 +36,7 @@ i18n
order: ['localStorage', 'cookie', 'htmlTag', 'navigator'],
// Cache the language in localStorage
caches: ['localStorage', 'cookie'],
}
},
});
export default i18n;

View File

@@ -4,6 +4,8 @@ import App from './App';
import './index.css';
// Import the i18n configuration
import './i18n';
// Setup fetch interceptors
import './utils/setupInterceptors';
import { loadRuntimeConfig } from './utils/runtime';
// Load runtime configuration before starting the app

View File

@@ -32,9 +32,9 @@ const GroupsPage: React.FC = () => {
};
const handleDeleteGroup = async (groupId: string) => {
const success = await deleteGroup(groupId);
if (!success) {
setGroupError(t('groups.deleteError'));
const result = await deleteGroup(groupId);
if (!result || !result.success) {
setGroupError(result?.message || t('groups.deleteError'));
}
};

View File

@@ -103,7 +103,6 @@ const ServersPage: React.FC = () => {
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
<p className="text-gray-600 mt-1">{error}</p>
</div>
<button

View File

@@ -4,45 +4,27 @@ import {
RegisterCredentials,
ChangePasswordCredentials,
} from '../types';
import { getApiUrl } from '../utils/runtime';
import { apiPost, apiGet } from '../utils/fetchInterceptor';
import { getToken, setToken, removeToken } from '../utils/interceptors';
// Token key in localStorage
const TOKEN_KEY = 'mcphub_token';
// Get token from localStorage
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
};
// Set token in localStorage
export const setToken = (token: string): void => {
localStorage.setItem(TOKEN_KEY, token);
};
// Remove token from localStorage
export const removeToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
// Export token management functions
export { getToken, setToken, removeToken };
// Login user
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
try {
console.log(getApiUrl('/auth/login'));
const response = await fetch(getApiUrl('/auth/login'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
const response = await apiPost<AuthResponse>('/auth/login', credentials);
const data: AuthResponse = await response.json();
if (data.success && data.token) {
setToken(data.token);
// The auth API returns data directly, not wrapped in a data field
if (response.success && response.token) {
setToken(response.token);
return response;
}
return data;
return {
success: false,
message: response.message || 'Login failed',
};
} catch (error) {
console.error('Login error:', error);
return {
@@ -55,21 +37,17 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
// Register user
export const register = async (credentials: RegisterCredentials): Promise<AuthResponse> => {
try {
const response = await fetch(getApiUrl('/auth/register'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
const response = await apiPost<AuthResponse>('/auth/register', credentials);
const data: AuthResponse = await response.json();
if (data.success && data.token) {
setToken(data.token);
if (response.success && response.token) {
setToken(response.token);
return response;
}
return data;
return {
success: false,
message: response.message || 'Registration failed',
};
} catch (error) {
console.error('Register error:', error);
return {
@@ -91,14 +69,8 @@ export const getCurrentUser = async (): Promise<AuthResponse> => {
}
try {
const response = await fetch(getApiUrl('/auth/user'), {
method: 'GET',
headers: {
'x-auth-token': token,
},
});
return await response.json();
const response = await apiGet<AuthResponse>('/auth/user');
return response;
} catch (error) {
console.error('Get current user error:', error);
return {
@@ -122,16 +94,8 @@ export const changePassword = async (
}
try {
const response = await fetch(getApiUrl('/auth/change-password'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token,
},
body: JSON.stringify(credentials),
});
return await response.json();
const response = await apiPost<AuthResponse>('/auth/change-password', credentials);
return response;
} catch (error) {
console.error('Change password error:', error);
return {

View File

@@ -1,4 +1,5 @@
import { getApiUrl, getBasePath } from '../utils/runtime';
import { apiGet, fetchWithInterceptors } from '../utils/fetchInterceptor';
import { getBasePath } from '../utils/runtime';
export interface SystemConfig {
routing?: {
@@ -43,7 +44,7 @@ export interface SystemConfigResponse {
export const getPublicConfig = async (): Promise<{ skipAuth: boolean }> => {
try {
const basePath = getBasePath();
const response = await fetch(`${basePath}/public-config`, {
const response = await fetchWithInterceptors(`${basePath}/public-config`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -69,16 +70,10 @@ export const getPublicConfig = async (): Promise<{ skipAuth: boolean }> => {
*/
export const getSystemConfigPublic = async (): Promise<SystemConfig | null> => {
try {
const response = await fetch(getApiUrl('/settings'), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const response = await apiGet<SystemConfigResponse>('/settings');
if (response.ok) {
const data: SystemConfigResponse = await response.json();
return data.data?.systemConfig || null;
if (response.success) {
return response.data?.systemConfig || null;
}
return null;

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { getToken } from './authService'; // Import getToken function
import { apiGet, apiDelete } from '../utils/fetchInterceptor';
import { getApiUrl } from '../utils/runtime';
import { getToken } from '../utils/interceptors';
export interface LogEntry {
timestamp: number;
@@ -13,21 +14,13 @@ export interface LogEntry {
// Fetch all logs
export const fetchLogs = async (): Promise<LogEntry[]> => {
try {
// Get authentication token
const token = getToken();
const response = await fetch(getApiUrl('/logs'), {
headers: {
'x-auth-token': token || '',
},
});
const response = await apiGet<{ success: boolean; data: LogEntry[]; error?: string }>('/logs');
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch logs');
if (!response.success) {
throw new Error(response.error || 'Failed to fetch logs');
}
return result.data;
return response.data;
} catch (error) {
console.error('Error fetching logs:', error);
throw error;
@@ -37,19 +30,10 @@ export const fetchLogs = async (): Promise<LogEntry[]> => {
// Clear all logs
export const clearLogs = async (): Promise<void> => {
try {
// Get authentication token
const token = getToken();
const response = await fetch(getApiUrl('/logs'), {
method: 'DELETE',
headers: {
'x-auth-token': token || '',
},
});
const response = await apiDelete<{ success: boolean; error?: string }>('/logs');
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to clear logs');
if (!response.success) {
throw new Error(response.error || 'Failed to clear logs');
}
} catch (error) {
console.error('Error clearing logs:', error);

View File

@@ -1,5 +1,4 @@
import { getApiUrl } from '../utils/runtime';
import { getToken } from './authService';
import { apiPost, apiPut } from '../utils/fetchInterceptor';
export interface ToolCallRequest {
toolName: string;
@@ -25,38 +24,32 @@ export const callTool = async (
server?: string,
): Promise<ToolCallResult> => {
try {
const token = getToken();
// Construct the URL with optional server parameter
const url = server ? `/tools/call/${server}` : '/tools/call';
const response = await fetch(getApiUrl(url), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '', // Include token for authentication
Authorization: `Bearer ${token}`, // Add bearer auth for MCP routing
},
body: JSON.stringify({
const response = await apiPost<any>(
url,
{
toolName: request.toolName,
arguments: request.arguments,
}),
});
},
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
},
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
if (!response.success) {
return {
success: false,
error: data.message || 'Tool call failed',
error: response.message || 'Tool call failed',
};
}
return {
success: true,
content: data.data.content || [],
content: response.data?.content || [],
};
} catch (error) {
console.error('Error calling tool:', error);
@@ -76,25 +69,19 @@ export const toggleTool = async (
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
const token = getToken();
const response = await fetch(getApiUrl(`/servers/${serverName}/tools/${toolName}/toggle`), {
method: 'POST',
const response = await apiPost<any>(
`/servers/${serverName}/tools/${toolName}/toggle`,
{ enabled },
{
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
},
body: JSON.stringify({ enabled }),
});
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return {
success: data.success,
error: data.success ? undefined : data.message,
success: response.success,
error: response.success ? undefined : response.message,
};
} catch (error) {
console.error('Error toggling tool:', error);
@@ -114,28 +101,19 @@ export const updateToolDescription = async (
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
const token = getToken();
const response = await fetch(
getApiUrl(`/servers/${serverName}/tools/${toolName}/description`),
const response = await apiPut<any>(
`/servers/${serverName}/tools/${toolName}/description`,
{ description },
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
Authorization: `Bearer ${token || ''}`,
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
},
body: JSON.stringify({ description }),
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return {
success: data.success,
error: data.success ? undefined : data.message,
success: response.success,
error: response.success ? undefined : response.message,
};
} catch (error) {
console.error('Error updating tool description:', error);

View File

@@ -0,0 +1,174 @@
import { getApiUrl } from './runtime';
// Define the interceptor interface
export interface FetchInterceptor {
request?: (url: string, config: RequestInit) => Promise<{ url: string; config: RequestInit }>;
response?: (response: Response) => Promise<Response>;
error?: (error: Error) => Promise<Error>;
}
// Define the enhanced fetch response interface
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
// Global interceptors store
const interceptors: FetchInterceptor[] = [];
// Add an interceptor
export const addInterceptor = (interceptor: FetchInterceptor): void => {
interceptors.push(interceptor);
};
// Remove an interceptor
export const removeInterceptor = (interceptor: FetchInterceptor): void => {
const index = interceptors.indexOf(interceptor);
if (index > -1) {
interceptors.splice(index, 1);
}
};
// Clear all interceptors
export const clearInterceptors = (): void => {
interceptors.length = 0;
};
// Enhanced fetch function with interceptors
export const fetchWithInterceptors = async (
input: string | URL | Request,
init: RequestInit = {},
): Promise<Response> => {
let url = input.toString();
let config = { ...init };
try {
// Apply request interceptors
for (const interceptor of interceptors) {
if (interceptor.request) {
const result = await interceptor.request(url, config);
url = result.url;
config = result.config;
}
}
// Make the actual fetch request
let response = await fetch(url, config);
// Apply response interceptors
for (const interceptor of interceptors) {
if (interceptor.response) {
response = await interceptor.response(response);
}
}
return response;
} catch (error) {
let processedError = error as Error;
// Apply error interceptors
for (const interceptor of interceptors) {
if (interceptor.error) {
processedError = await interceptor.error(processedError);
}
}
throw processedError;
}
};
// Convenience function for API calls with automatic URL construction
export const apiRequest = async <T = any>(endpoint: string, init: RequestInit = {}): Promise<T> => {
try {
const url = getApiUrl(endpoint);
const response = await fetchWithInterceptors(url, init);
// Try to parse JSON response
let data: T;
try {
data = await response.json();
} catch (parseError) {
// If JSON parsing fails, create a generic response
const genericResponse = {
success: response.ok,
message: response.ok
? 'Request successful'
: `HTTP ${response.status}: ${response.statusText}`,
};
data = genericResponse as T;
}
// If response is not ok, but no explicit error in parsed data
if (!response.ok && typeof data === 'object' && data !== null) {
const responseObj = data as any;
if (responseObj.success !== false) {
responseObj.success = false;
responseObj.message =
responseObj.message || `HTTP ${response.status}: ${response.statusText}`;
}
}
return data;
} catch (error) {
console.error('API request error:', error);
const errorResponse = {
success: false,
message: error instanceof Error ? error.message : 'An unknown error occurred',
};
return errorResponse as T;
}
};
// Convenience methods for common HTTP methods
export const apiGet = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
apiRequest<T>(endpoint, { ...init, method: 'GET' });
export const apiPost = <T = any>(
endpoint: string,
data?: any,
init: Omit<RequestInit, 'method' | 'body'> = {},
) =>
apiRequest<T>(endpoint, {
...init,
method: 'POST',
headers: {
'Content-Type': 'application/json',
...init.headers,
},
body: data ? JSON.stringify(data) : undefined,
});
export const apiPut = <T = any>(
endpoint: string,
data?: any,
init: Omit<RequestInit, 'method' | 'body'> = {},
) =>
apiRequest<T>(endpoint, {
...init,
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...init.headers,
},
body: data ? JSON.stringify(data) : undefined,
});
export const apiDelete = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
apiRequest<T>(endpoint, { ...init, method: 'DELETE' });
export const apiPatch = <T = any>(
endpoint: string,
data?: any,
init: Omit<RequestInit, 'method' | 'body'> = {},
) =>
apiRequest<T>(endpoint, {
...init,
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...init.headers,
},
body: data ? JSON.stringify(data) : undefined,
});

View File

@@ -0,0 +1,99 @@
import { addInterceptor, removeInterceptor, type FetchInterceptor } from './fetchInterceptor';
// Token key in localStorage
const TOKEN_KEY = 'mcphub_token';
// Get token from localStorage
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
};
// Set token in localStorage
export const setToken = (token: string): void => {
localStorage.setItem(TOKEN_KEY, token);
};
// Remove token from localStorage
export const removeToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
// Auth interceptor for automatically adding authorization headers
export const authInterceptor: FetchInterceptor = {
request: async (url: string, config: RequestInit) => {
const headers = new Headers(config.headers);
const language = localStorage.getItem('i18nextLng') || 'en';
headers.set('Accept-Language', language);
const token = getToken();
if (token) {
headers.set('x-auth-token', token);
}
return {
url,
config: {
...config,
headers,
},
};
},
response: async (response: Response) => {
// Handle unauthorized responses
if (response.status === 401) {
// Token might be expired or invalid, remove it
removeToken();
// You could also trigger a redirect to login page here
// window.location.href = '/login';
}
return response;
},
error: async (error: Error) => {
console.error('Auth interceptor error:', error);
return error;
},
};
// Install the auth interceptor
export const installAuthInterceptor = (): void => {
addInterceptor(authInterceptor);
};
// Uninstall the auth interceptor
export const uninstallAuthInterceptor = (): void => {
removeInterceptor(authInterceptor);
};
// Logging interceptor for development
export const loggingInterceptor: FetchInterceptor = {
request: async (url: string, config: RequestInit) => {
console.log(`🚀 [${config.method || 'GET'}] ${url}`, config);
return { url, config };
},
response: async (response: Response) => {
console.log(`✅ [${response.status}] ${response.url}`);
return response;
},
error: async (error: Error) => {
console.error(`❌ Fetch error:`, error);
return error;
},
};
// Install the logging interceptor (only in development)
export const installLoggingInterceptor = (): void => {
if (process.env.NODE_ENV === 'development') {
addInterceptor(loggingInterceptor);
}
};
// Uninstall the logging interceptor
export const uninstallLoggingInterceptor = (): void => {
removeInterceptor(loggingInterceptor);
};

View File

@@ -0,0 +1,19 @@
import { installAuthInterceptor, installLoggingInterceptor } from './interceptors';
/**
* Setup all default interceptors for the application
* This should be called once when the app initializes
*/
export const setupInterceptors = (): void => {
// Install auth interceptor for automatic token handling
installAuthInterceptor();
// Install logging interceptor in development mode
installLoggingInterceptor();
};
/**
* Initialize interceptors automatically when this module is imported
* This ensures interceptors are set up as early as possible
*/
setupInterceptors();

View File

@@ -456,5 +456,63 @@
"deleteConfirmation": "Are you sure you want to delete user '{{username}}'? This action cannot be undone.",
"confirmDelete": "Delete User",
"deleteWarning": "Are you sure you want to delete user '{{username}}'? This action cannot be undone."
},
"api": {
"errors": {
"readonly": "Readonly for demo environment",
"serverNameRequired": "Server name is required",
"serverConfigRequired": "Server configuration is required",
"serverConfigInvalid": "Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments",
"serverTypeInvalid": "Server type must be one of: stdio, sse, streamable-http, openapi",
"urlRequiredForType": "URL is required for {{type}} server type",
"openapiSpecRequired": "OpenAPI specification URL or schema is required for openapi server type",
"headersInvalidFormat": "Headers must be an object",
"headersNotSupportedForStdio": "Headers are not supported for stdio server type",
"serverNotFound": "Server not found",
"failedToRemoveServer": "Server not found or failed to remove",
"internalServerError": "Internal server error",
"failedToGetServers": "Failed to get servers information",
"failedToGetServerSettings": "Failed to get server settings",
"failedToGetServerConfig": "Failed to get server configuration",
"failedToSaveSettings": "Failed to save settings",
"toolNameRequired": "Server name and tool name are required",
"descriptionMustBeString": "Description must be a string",
"groupIdRequired": "Group ID is required",
"groupNameRequired": "Group name is required",
"groupNotFound": "Group not found",
"groupIdAndServerNameRequired": "Group ID and server name are required",
"groupOrServerNotFound": "Group or server not found",
"toolsMustBeAllOrArray": "Tools must be \"all\" or an array of strings",
"serverNameAndToolNameRequired": "Server name and tool name are required",
"usernameRequired": "Username is required",
"userNotFound": "User not found",
"failedToGetUsers": "Failed to get users information",
"failedToGetUserInfo": "Failed to get user information",
"failedToGetUserStats": "Failed to get user statistics",
"marketServerNameRequired": "Server name is required",
"marketServerNotFound": "Market server not found",
"failedToGetMarketServers": "Failed to get market servers information",
"failedToGetMarketServer": "Failed to get market server information",
"failedToGetMarketCategories": "Failed to get market categories",
"failedToGetMarketTags": "Failed to get market tags",
"failedToSearchMarketServers": "Failed to search market servers",
"failedToFilterMarketServers": "Failed to filter market servers",
"failedToProcessDxtFile": "Failed to process DXT file"
},
"success": {
"serverCreated": "Server created successfully",
"serverUpdated": "Server updated successfully",
"serverRemoved": "Server removed successfully",
"serverToggled": "Server status toggled successfully",
"toolToggled": "Tool {{name}} {{action}} successfully",
"toolDescriptionUpdated": "Tool {{name}} description updated successfully",
"systemConfigUpdated": "System configuration updated successfully",
"groupCreated": "Group created successfully",
"groupUpdated": "Group updated successfully",
"groupDeleted": "Group deleted successfully",
"serverAddedToGroup": "Server added to group successfully",
"serverRemovedFromGroup": "Server removed from group successfully",
"serverToolsUpdated": "Server tools updated successfully"
}
}
}

View File

@@ -458,5 +458,63 @@
"deleteConfirmation": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。",
"confirmDelete": "删除用户",
"deleteWarning": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。"
},
"api": {
"errors": {
"readonly": "演示环境无法修改数据",
"serverNameRequired": "服务器名称是必需的",
"serverConfigRequired": "服务器配置是必需的",
"serverConfigInvalid": "服务器配置必须包含 URL、OpenAPI 规范 URL 或模式,或者带参数的命令",
"serverTypeInvalid": "服务器类型必须是以下之一stdio、sse、streamable-http、openapi",
"urlRequiredForType": "{{type}} 服务器类型需要 URL",
"openapiSpecRequired": "openapi 服务器类型需要 OpenAPI 规范 URL 或模式",
"headersInvalidFormat": "请求头必须是对象格式",
"headersNotSupportedForStdio": "stdio 服务器类型不支持请求头",
"serverNotFound": "找不到服务器",
"failedToRemoveServer": "找不到服务器或删除失败",
"internalServerError": "服务器内部错误",
"failedToGetServers": "获取服务器信息失败",
"failedToGetServerSettings": "获取服务器设置失败",
"failedToGetServerConfig": "获取服务器配置失败",
"failedToSaveSettings": "保存设置失败",
"toolNameRequired": "服务器名称和工具名称是必需的",
"descriptionMustBeString": "描述必须是字符串",
"groupIdRequired": "分组 ID 是必需的",
"groupNameRequired": "分组名称是必需的",
"groupNotFound": "找不到分组",
"groupIdAndServerNameRequired": "分组 ID 和服务器名称是必需的",
"groupOrServerNotFound": "找不到分组或服务器",
"toolsMustBeAllOrArray": "工具必须是 \"all\" 或字符串数组",
"serverNameAndToolNameRequired": "服务器名称和工具名称是必需的",
"usernameRequired": "用户名是必需的",
"userNotFound": "找不到用户",
"failedToGetUsers": "获取用户信息失败",
"failedToGetUserInfo": "获取用户信息失败",
"failedToGetUserStats": "获取用户统计信息失败",
"marketServerNameRequired": "服务器名称是必需的",
"marketServerNotFound": "找不到市场服务器",
"failedToGetMarketServers": "获取市场服务器信息失败",
"failedToGetMarketServer": "获取市场服务器信息失败",
"failedToGetMarketCategories": "获取市场类别失败",
"failedToGetMarketTags": "获取市场标签失败",
"failedToSearchMarketServers": "搜索市场服务器失败",
"failedToFilterMarketServers": "过滤市场服务器失败",
"failedToProcessDxtFile": "处理 DXT 文件失败"
},
"success": {
"serverCreated": "服务器创建成功",
"serverUpdated": "服务器更新成功",
"serverRemoved": "服务器删除成功",
"serverToggled": "服务器状态切换成功",
"toolToggled": "工具 {{name}} {{action}} 成功",
"toolDescriptionUpdated": "工具 {{name}} 描述更新成功",
"systemConfigUpdated": "系统配置更新成功",
"groupCreated": "分组创建成功",
"groupUpdated": "分组更新成功",
"groupDeleted": "分组删除成功",
"serverAddedToGroup": "服务器添加到分组成功",
"serverRemovedFromGroup": "服务器从分组移除成功",
"serverToolsUpdated": "服务器工具更新成功"
}
}
}

View File

@@ -57,6 +57,7 @@
"dotenv-expand": "^12.0.2",
"express": "^4.21.2",
"express-validator": "^7.2.1",
"i18next-fs-backend": "^2.6.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.1",
"openai": "^4.103.0",

8
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
express-validator:
specifier: ^7.2.1
version: 7.2.1
i18next-fs-backend:
specifier: ^2.6.0
version: 2.6.0
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
@@ -2669,6 +2672,9 @@ packages:
i18next-browser-languagedetector@8.2.0:
resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==}
i18next-fs-backend@2.6.0:
resolution: {integrity: sha512-3ZlhNoF9yxnM8pa8bWp5120/Ob6t4lVl1l/tbLmkml/ei3ud8IWySCHt2lrY5xWRlSU5D9IV2sm5bEbGuTqwTw==}
i18next@24.2.3:
resolution: {integrity: sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==}
peerDependencies:
@@ -6902,6 +6908,8 @@ snapshots:
dependencies:
'@babel/runtime': 7.27.0
i18next-fs-backend@2.6.0: {}
i18next@24.2.3(typescript@5.8.3):
dependencies:
'@babel/runtime': 7.27.0

View File

@@ -13,6 +13,7 @@ const defaultConfig = {
initTimeout: process.env.INIT_TIMEOUT || 300000,
timeout: process.env.REQUEST_TIMEOUT || 60000,
basePath: process.env.BASE_PATH || '',
readonly: 'true' === process.env.READONLY || false,
mcpHubName: 'mcphub',
mcpHubVersion: getPackageVersion(),
};

View File

@@ -18,10 +18,17 @@ const TOKEN_EXPIRY = '24h';
// Login user
export const login = async (req: Request, res: Response): Promise<void> => {
// Get translation function from request
const t = (req as any).t;
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
res.status(400).json({
success: false,
message: t('api.errors.validation_failed'),
errors: errors.array(),
});
return;
}
@@ -32,7 +39,10 @@ export const login = async (req: Request, res: Response): Promise<void> => {
const user = findUserByUsername(username);
if (!user) {
res.status(401).json({ success: false, message: 'Invalid credentials' });
res.status(401).json({
success: false,
message: t('api.errors.invalid_credentials'),
});
return;
}
@@ -40,7 +50,10 @@ export const login = async (req: Request, res: Response): Promise<void> => {
const isPasswordValid = await verifyPassword(password, user.password);
if (!isPasswordValid) {
res.status(401).json({ success: false, message: 'Invalid credentials' });
res.status(401).json({
success: false,
message: t('api.errors.invalid_credentials'),
});
return;
}
@@ -56,6 +69,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
if (err) throw err;
res.json({
success: true,
message: t('api.success.login_successful'),
token,
user: {
username: user.username,
@@ -66,16 +80,26 @@ export const login = async (req: Request, res: Response): Promise<void> => {
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ success: false, message: 'Server error' });
res.status(500).json({
success: false,
message: t('api.errors.server_error'),
});
}
};
// Register new user
export const register = async (req: Request, res: Response): Promise<void> => {
// Get translation function from request
const t = (req as any).t;
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
res.status(400).json({
success: false,
message: t('api.errors.validation_failed'),
errors: errors.array(),
});
return;
}

View File

@@ -1,6 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { loadSettings } from '../config/index.js';
import defaultConfig from '../config/index.js';
// Default secret key - in production, use an environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
@@ -18,8 +19,30 @@ const validateBearerAuth = (req: Request, routingConfig: any): boolean => {
return authHeader.substring(7) === routingConfig.bearerAuthKey;
};
const readonlyAllowPaths = ['/tools/call/'];
const checkReadonly = (req: Request): boolean => {
if (!defaultConfig.readonly) {
return true;
}
for (const path of readonlyAllowPaths) {
if (req.path.startsWith(defaultConfig.basePath + path)) {
return true;
}
}
return req.method === 'GET';
};
// Middleware to authenticate JWT token
export const auth = (req: Request, res: Response, next: NextFunction): void => {
const t = (req as any).t;
if (!checkReadonly(req)) {
res.status(403).json({ success: false, message: t('api.errors.readonly') });
return;
}
// Check if authentication is disabled globally
const routingConfig = loadSettings().systemConfig?.routing || {
enableGlobalRoute: true,

41
src/middlewares/i18n.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Request, Response, NextFunction } from 'express';
import { getT } from '../utils/i18n.js';
/**
* i18n middleware to detect user language and attach translation function to request
*/
export const i18nMiddleware = (req: Request, res: Response, next: NextFunction) => {
// Detect language from various sources (prioritized)
const acceptLanguage = req.headers['accept-language'];
const customLanguageHeader = req.headers['x-language'] as string;
const languageFromQuery = req.query.lang as string;
// Default to English
let detectedLanguage = 'en';
// Priority order: query parameter > custom header > accept-language header
if (languageFromQuery) {
detectedLanguage = languageFromQuery;
} else if (customLanguageHeader) {
detectedLanguage = customLanguageHeader;
} else if (acceptLanguage) {
// Parse accept-language header and get primary language
const primaryLanguage = acceptLanguage.split(',')[0].split('-')[0].trim();
detectedLanguage = primaryLanguage;
}
// Normalize language code (ensure we support it)
const supportedLanguages = ['en', 'zh'];
if (!supportedLanguages.includes(detectedLanguage)) {
detectedLanguage = 'en'; // fallback to English
}
// Set language in request (using any type to avoid TypeScript issues)
(req as any).language = detectedLanguage;
// Get translation function for the detected language
const t = getT(detectedLanguage);
(req as any).t = t;
next();
};

View File

@@ -1,6 +1,7 @@
import express, { Request, Response, NextFunction } from 'express';
import { auth } from './auth.js';
import { userContextMiddleware } from './userContext.js';
import { i18nMiddleware } from './i18n.js';
import { initializeDefaultUser } from '../models/User.js';
import config from '../config/index.js';
@@ -18,6 +19,9 @@ export const errorHandler = (
};
export const initMiddlewares = (app: express.Application): void => {
// Apply i18n middleware first to detect language for all requests
app.use(i18nMiddleware);
// Serve static files from the dynamically determined frontend path
// Note: Static files will be handled by the server directly, not here

View File

@@ -5,6 +5,7 @@ import fs from 'fs';
import { initUpstreamServers, connected } from './services/mcpService.js';
import { initMiddlewares } from './middlewares/index.js';
import { initRoutes } from './routes/index.js';
import { initI18n } from './utils/i18n.js';
import {
handleSseConnection,
handleSseMessage,
@@ -31,6 +32,10 @@ export class AppServer {
async initialize(): Promise<void> {
try {
// Initialize i18n before other components
await initI18n();
console.log('i18n initialized successfully');
// Initialize default admin user if no users exist
await initializeDefaultUser();

5
src/types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
// Custom types for Express Request
export interface I18nRequest extends Request {
language?: string;
t: (key: string, options?: any) => string;
}

41
src/utils/i18n.ts Normal file
View File

@@ -0,0 +1,41 @@
import i18n from 'i18next';
import Backend from 'i18next-fs-backend';
import path from 'path';
// Initialize i18n for backend
const initI18n = async () => {
return i18n.use(Backend).init({
lng: 'en', // default language
fallbackLng: 'en',
backend: {
// Path to translation files
loadPath: path.join(process.cwd(), 'locales', '{{lng}}.json'),
},
interpolation: {
escapeValue: false, // not needed for server side
},
// Enable debug mode for development
debug: false,
// Preload languages
preload: ['en', 'zh'],
// Use sync mode for server
initImmediate: false,
});
};
// Get translation function for a specific language
export const getT = (language?: string) => {
if (language && language !== i18n.language) {
i18n.changeLanguage(language);
}
return i18n.t.bind(i18n);
};
// Initialize and export
export { initI18n };
export default i18n;