feat: integrate offcial mcp server registry (#374)

This commit is contained in:
samanhappy
2025-10-19 21:15:25 +08:00
committed by GitHub
parent bd4c546bba
commit 86367a4875
16 changed files with 2651 additions and 534 deletions

View File

@@ -1,5 +1,5 @@
{
"semi": false,
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,

View File

@@ -1,51 +1,52 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Server } from '@/types'
import { apiPut } from '../utils/fetchInterceptor'
import ServerForm from './ServerForm'
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Server } from '@/types';
import { apiPut } from '../utils/fetchInterceptor';
import ServerForm from './ServerForm';
interface EditServerFormProps {
server: Server
onEdit: () => void
onCancel: () => void
server: Server;
onEdit: () => void;
onCancel: () => void;
}
const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
const { t } = useTranslation()
const [error, setError] = useState<string | null>(null)
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (payload: any) => {
try {
setError(null)
const result = await apiPut(`/servers/${server.name}`, payload)
setError(null);
const encodedServerName = encodeURIComponent(server.name);
const result = await apiPut(`/servers/${encodedServerName}`, payload);
if (!result.success) {
// Use specific error message from the response if available
if (result && result.message) {
setError(result.message)
setError(result.message);
} else {
setError(t('server.updateError', { serverName: server.name }))
setError(t('server.updateError', { serverName: server.name }));
}
return
return;
}
onEdit()
onEdit();
} catch (err) {
console.error('Error updating server:', err)
console.error('Error updating server:', err);
// Use friendly error messages based on error type
if (!navigator.onLine) {
setError(t('errors.network'))
} else if (err instanceof TypeError && (
err.message.includes('NetworkError') ||
err.message.includes('Failed to fetch')
)) {
setError(t('errors.serverConnection'))
setError(t('errors.network'));
} else if (
err instanceof TypeError &&
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
) {
setError(t('errors.serverConnection'));
} else {
setError(t('errors.serverUpdate', { serverName: server.name }))
setError(t('errors.serverUpdate', { serverName: server.name }));
}
}
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
@@ -57,7 +58,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
formError={error}
/>
</div>
)
}
);
};
export default EditServerForm
export default EditServerForm;

View File

@@ -0,0 +1,205 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { RegistryServerEntry } from '@/types';
interface RegistryServerCardProps {
serverEntry: RegistryServerEntry;
onClick: (serverEntry: RegistryServerEntry) => void;
}
const RegistryServerCard: React.FC<RegistryServerCardProps> = ({ serverEntry, onClick }) => {
const { t } = useTranslation();
const { server, _meta } = serverEntry;
const handleClick = () => {
onClick(serverEntry);
};
// Get display description
const getDisplayDescription = () => {
if (server.description && server.description.length <= 150) {
return server.description;
}
return server.description
? server.description.slice(0, 150) + '...'
: t('registry.noDescription');
};
// Format date for display
const formatDate = (dateString?: string) => {
if (!dateString) return '';
try {
const date = new Date(dateString);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}/${month}/${day}`;
} catch {
return '';
}
};
// Get icon to display
const getIcon = () => {
if (server.icons && server.icons.length > 0) {
// Prefer light theme icon
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
return lightIcon || server.icons[0];
}
return null;
};
const icon = getIcon();
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
const isLatest = officialMeta?.isLatest;
const publishedAt = officialMeta?.publishedAt;
const updatedAt = officialMeta?.updatedAt;
// Count packages and remotes
const packageCount = server.packages?.length || 0;
const remoteCount = server.remotes?.length || 0;
const totalOptions = packageCount + remoteCount;
return (
<div
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
onClick={handleClick}
>
{/* Background gradient overlay on hover */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 to-purple-50/0 group-hover:from-blue-50/30 group-hover:to-purple-50/30 transition-all duration-300 pointer-events-none" />
{/* Server Header */}
<div className="relative z-10 flex-1 flex flex-col">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start space-x-3 flex-1">
{/* Icon */}
{icon ? (
<img
src={icon.src}
alt={server.title}
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
) : (
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-xl font-semibold flex-shrink-0">
M
</div>
)}
{/* Title and badges */}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-1 line-clamp-2">
{server.name}
</h3>
<div className="flex flex-wrap gap-1 mb-1">
{isLatest && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{t('registry.latest')}
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
v{server.version}
</span>
</div>
</div>
</div>
</div>
{/* Server Name */}
{/* <div className="mb-2">
<p className="text-xs text-gray-500 font-mono">{server.name}</p>
</div> */}
{/* Description */}
<div className="mb-3 flex-1">
<p className="text-gray-600 text-sm leading-relaxed line-clamp-3">
{getDisplayDescription()}
</p>
</div>
{/* Installation Options Info */}
{totalOptions > 0 && (
<div className="mb-3">
<div className="flex items-center space-x-4">
{packageCount > 0 && (
<div className="flex items-center space-x-1">
<svg
className="w-4 h-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<span className="text-sm text-gray-600">
{packageCount}{' '}
{packageCount === 1 ? t('registry.package') : t('registry.packages')}
</span>
</div>
)}
{remoteCount > 0 && (
<div className="flex items-center space-x-1">
<svg
className="w-4 h-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
<span className="text-sm text-gray-600">
{remoteCount} {remoteCount === 1 ? t('registry.remote') : t('registry.remotes')}
</span>
</div>
)}
</div>
</div>
)}
{/* Footer - fixed at bottom */}
<div className="flex items-center justify-between pt-3 border-t border-gray-100 mt-auto">
<div className="flex items-center space-x-2 text-xs text-gray-500">
{(publishedAt || updatedAt) && (
<>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
clipRule="evenodd"
/>
</svg>
<span>{formatDate(updatedAt || publishedAt)}</span>
</>
)}
</div>
<div className="flex items-center text-blue-600 text-sm font-medium group-hover:text-blue-700 transition-colors">
<span>{t('registry.viewDetails')}</span>
<svg
className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
);
};
export default RegistryServerCard;

View File

@@ -0,0 +1,698 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
RegistryServerEntry,
RegistryPackage,
RegistryRemote,
RegistryServerData,
ServerConfig,
} from '@/types';
import ServerForm from './ServerForm';
interface RegistryServerDetailProps {
serverEntry: RegistryServerEntry;
onBack: () => void;
onInstall?: (server: RegistryServerData, config: ServerConfig) => void;
installing?: boolean;
isInstalled?: boolean;
fetchVersions?: (serverName: string) => Promise<RegistryServerEntry[]>;
}
const RegistryServerDetail: React.FC<RegistryServerDetailProps> = ({
serverEntry,
onBack,
onInstall,
installing = false,
isInstalled = false,
fetchVersions,
}) => {
const { t } = useTranslation();
const { server, _meta } = serverEntry;
const [_selectedVersion, _setSelectedVersion] = useState<string>(server.version);
const [_availableVersions, setAvailableVersions] = useState<RegistryServerEntry[]>([]);
const [_loadingVersions, setLoadingVersions] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [selectedInstallType, setSelectedInstallType] = useState<'package' | 'remote' | null>(null);
const [selectedOption, setSelectedOption] = useState<RegistryPackage | RegistryRemote | null>(
null,
);
const [installError, setInstallError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
packages: true,
remotes: true,
repository: true,
});
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
// Load available versions
useEffect(() => {
const loadVersions = async () => {
if (fetchVersions) {
setLoadingVersions(true);
try {
const versions = await fetchVersions(server.name);
setAvailableVersions(versions);
} catch (error) {
console.error('Failed to load versions:', error);
} finally {
setLoadingVersions(false);
}
}
};
loadVersions();
}, [server.name, fetchVersions]);
// Get icon to display
const getIcon = () => {
if (server.icons && server.icons.length > 0) {
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
return lightIcon || server.icons[0];
}
return null;
};
const icon = getIcon();
// Format date
const formatDate = (dateString?: string) => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString();
} catch {
return '';
}
};
// Toggle section expansion
const toggleSection = (section: string) => {
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
};
// Handle install button click
const handleInstallClick = (
type: 'package' | 'remote',
option: RegistryPackage | RegistryRemote,
) => {
setSelectedInstallType(type);
setSelectedOption(option);
setInstallError(null);
setModalVisible(true);
};
// Handle modal close
const handleModalClose = () => {
setModalVisible(false);
setInstallError(null);
};
// Handle install submission
const handleInstallSubmit = async (payload: any) => {
try {
if (!onInstall || !selectedOption || !selectedInstallType) return;
setInstallError(null);
// Extract the ServerConfig from the payload
const config: ServerConfig = payload.config;
// Call onInstall with server data and config
onInstall(server, config);
setModalVisible(false);
} catch (err) {
console.error('Error installing server:', err);
setInstallError(t('errors.serverInstall'));
}
};
// Build initial data for ServerForm
const getInitialFormData = () => {
if (!selectedOption || !selectedInstallType) return null;
console.log('Building initial form data for:', selectedOption);
if (selectedInstallType === 'package' && 'identifier' in selectedOption) {
const pkg = selectedOption as RegistryPackage;
// Build environment variables from package definition
const env: Record<string, string> = {};
if (pkg.environmentVariables) {
pkg.environmentVariables.forEach((envVar) => {
env[envVar.name] = envVar.default || '';
});
}
const command = getCommand(pkg.registryType);
return {
name: server.name,
status: 'disconnected' as const,
config: {
type: 'stdio' as const,
command: command,
args: getArgs(command, pkg),
env: Object.keys(env).length > 0 ? env : undefined,
},
};
} else if (selectedInstallType === 'remote' && 'url' in selectedOption) {
const remote = selectedOption as RegistryRemote;
// Build headers from remote definition
const headers: Record<string, string> = {};
if (remote.headers) {
remote.headers.forEach((header) => {
headers[header.name] = header.default || header.value || '';
});
}
// Determine transport type - default to streamable-http for remotes
const transportType = remote.type === 'sse' ? ('sse' as const) : ('streamable-http' as const);
return {
name: server.name,
status: 'disconnected' as const,
config: {
type: transportType,
url: remote.url,
headers: Object.keys(headers).length > 0 ? headers : undefined,
},
};
}
return null;
};
// Render package option
const renderPackage = (pkg: RegistryPackage, index: number) => {
return (
<div
key={index}
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{pkg.identifier}</h4>
{pkg.version && <p className="text-sm text-gray-500">Version: {pkg.version}</p>}
{pkg.runtimeHint && <p className="text-sm text-gray-600 mt-1">{pkg.runtimeHint}</p>}
</div>
<button
onClick={() => handleInstallClick('package', pkg)}
disabled={isInstalled || installing}
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
isInstalled
? 'bg-green-600 text-white cursor-default'
: installing
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isInstalled
? t('registry.installed')
: installing
? t('registry.installing')
: t('registry.install')}
</button>
</div>
{/* Package details */}
{pkg.registryType && (
<div className="text-sm text-gray-600 mb-2">
<span className="font-medium">Registry:</span> {pkg.registryType}
</div>
)}
{/* Transport type */}
{pkg.transport && (
<div className="text-sm text-gray-600 mb-2">
<span className="font-medium">Transport:</span> {pkg.transport.type}
{pkg.transport.url && <span className="ml-2 text-gray-500">({pkg.transport.url})</span>}
</div>
)}
{/* Environment Variables */}
{pkg.environmentVariables && pkg.environmentVariables.length > 0 && (
<div className="mt-3 border-t border-gray-200 pt-3">
<h5 className="text-sm font-medium text-gray-700 mb-2">
{t('registry.environmentVariables')}:
</h5>
<div className="space-y-2">
{pkg.environmentVariables.map((envVar, envIndex) => (
<div key={envIndex} className="text-sm">
<div className="flex items-start">
<span className="font-mono text-gray-900 font-medium">{envVar.name}</span>
{envVar.isRequired && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
{t('common.required')}
</span>
)}
{envVar.isSecret && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
{t('common.secret')}
</span>
)}
</div>
{envVar.description && <p className="text-gray-600 mt-1">{envVar.description}</p>}
{envVar.default && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.default')}:</span>{' '}
<span className="font-mono">{envVar.default}</span>
</p>
)}
</div>
))}
</div>
</div>
)}
{/* Package Arguments */}
{pkg.packageArguments && pkg.packageArguments.length > 0 && (
<div className="mt-3 border-t border-gray-200 pt-3">
<h5 className="text-sm font-medium text-gray-700 mb-2">
{t('registry.packageArguments')}:
</h5>
<div className="space-y-2">
{pkg.packageArguments.map((arg, argIndex) => (
<div key={argIndex} className="text-sm">
<div className="flex items-start">
<span className="font-mono text-gray-900 font-medium">{arg.name}</span>
{arg.isRequired && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
{t('common.required')}
</span>
)}
{arg.isSecret && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
{t('common.secret')}
</span>
)}
{arg.isRepeated && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
{t('common.repeated')}
</span>
)}
</div>
{arg.description && <p className="text-gray-600 mt-1">{arg.description}</p>}
{arg.type && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.type')}:</span>{' '}
<span className="font-mono">{arg.type}</span>
</p>
)}
{arg.default && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.default')}:</span>{' '}
<span className="font-mono">{arg.default}</span>
</p>
)}
{arg.value && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.value')}:</span>{' '}
<span className="font-mono">{arg.value}</span>
</p>
)}
{arg.valueHint && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.valueHint')}:</span>{' '}
<span className="font-mono">{arg.valueHint}</span>
</p>
)}
{arg.choices && arg.choices.length > 0 && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.choices')}:</span>{' '}
<span className="font-mono">{arg.choices.join(', ')}</span>
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
);
};
// Render remote option
const renderRemote = (remote: RegistryRemote, index: number) => {
return (
<div
key={index}
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{remote.type}</h4>
<p className="text-sm text-gray-600 mt-1 break-all">{remote.url}</p>
</div>
<button
onClick={() => handleInstallClick('remote', remote)}
disabled={isInstalled || installing}
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
isInstalled
? 'bg-green-600 text-white cursor-default'
: installing
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isInstalled
? t('registry.installed')
: installing
? t('registry.installing')
: t('registry.install')}
</button>
</div>
{/* Headers */}
{remote.headers && remote.headers.length > 0 && (
<div className="mt-3 border-t border-gray-200 pt-3">
<h5 className="text-sm font-medium text-gray-700 mb-2">{t('registry.headers')}:</h5>
<div className="space-y-2">
{remote.headers.map((header, headerIndex) => (
<div key={headerIndex} className="text-sm">
<div className="flex items-start">
<span className="font-mono text-gray-900 font-medium">{header.name}</span>
{header.isRequired && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
{t('common.required')}
</span>
)}
{header.isSecret && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
{t('common.secret')}
</span>
)}
</div>
{header.description && <p className="text-gray-600 mt-1">{header.description}</p>}
{header.value && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.value')}:</span>{' '}
<span className="font-mono">{header.value}</span>
</p>
)}
{header.default && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.default')}:</span>{' '}
<span className="font-mono">{header.default}</span>
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
);
};
return (
<div className="bg-white shadow rounded-lg p-6">
{/* Header */}
<div className="mb-6">
<button
onClick={onBack}
className="flex items-center text-blue-600 hover:text-blue-800 mb-4 transition-colors"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('registry.backToList')}
</button>
<div className="flex items-start space-x-4">
{/* Icon */}
{icon ? (
<img
src={icon.src}
alt={server.title}
className="w-20 h-20 rounded-lg object-cover flex-shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
) : (
<div className="w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-3xl font-semibold flex-shrink-0">
M
</div>
)}
{/* Title and metadata */}
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-900 mb-2">{server.name}</h1>
<div className="flex flex-wrap gap-2 mb-3">
{officialMeta?.isLatest && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
{t('registry.latest')}
</span>
)}
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
v{server.version}
</span>
{officialMeta?.status && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
{officialMeta.status}
</span>
)}
{/* Dates */}
<span className="flex flex-wrap items-center gap-4 text-sm text-gray-600">
{officialMeta?.publishedAt && (
<div>
<span className="font-medium">{t('registry.published')}:</span>{' '}
{formatDate(officialMeta.publishedAt)}
</div>
)}
{officialMeta?.updatedAt && (
<div>
<span className="font-medium">{t('registry.updated')}:</span>{' '}
{formatDate(officialMeta.updatedAt)}
</div>
)}
</span>
</div>
</div>
</div>
</div>
{/* Description */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.description')}</h2>
<p className="text-gray-700 leading-relaxed whitespace-pre-wrap">{server.description}</p>
</div>
{/* Website */}
{server.websiteUrl && (
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.website')}</h2>
<a
href={server.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{server.websiteUrl}
</a>
</div>
)}
{/* Packages */}
{server.packages && server.packages.length > 0 && (
<div className="mb-6">
<button
onClick={() => toggleSection('packages')}
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
>
<span>
{t('registry.packages')} ({server.packages.length})
</span>
<svg
className={`w-5 h-5 transform transition-transform ${expandedSections.packages ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{expandedSections.packages && (
<div className="space-y-3">{server.packages.map(renderPackage)}</div>
)}
</div>
)}
{/* Remotes */}
{server.remotes && server.remotes.length > 0 && (
<div className="mb-6">
<button
onClick={() => toggleSection('remotes')}
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
>
<span>
{t('registry.remotes')} ({server.remotes.length})
</span>
<svg
className={`w-5 h-5 transform transition-transform ${expandedSections.remotes ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{expandedSections.remotes && (
<div className="space-y-3">{server.remotes.map(renderRemote)}</div>
)}
</div>
)}
{/* Repository */}
{server.repository && (
<div className="mb-6">
<button
onClick={() => toggleSection('repository')}
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
>
<span>{t('registry.repository')}</span>
<svg
className={`w-5 h-5 transform transition-transform ${expandedSections.repository ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{expandedSections.repository && (
<div className="border border-gray-200 rounded-lg p-4">
{server.repository.url && (
<div className="mb-2">
<span className="font-medium text-gray-700">URL:</span>{' '}
<a
href={server.repository.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline break-all"
>
{server.repository.url}
</a>
</div>
)}
{server.repository.source && (
<div className="mb-2">
<span className="font-medium text-gray-700">Source:</span>{' '}
{server.repository.source}
</div>
)}
{server.repository.subfolder && (
<div className="mb-2">
<span className="font-medium text-gray-700">Subfolder:</span>{' '}
{server.repository.subfolder}
</div>
)}
{server.repository.id && (
<div>
<span className="font-medium text-gray-700">ID:</span> {server.repository.id}
</div>
)}
</div>
)}
</div>
)}
{/* Install Modal */}
{modalVisible && selectedOption && selectedInstallType && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<ServerForm
onSubmit={handleInstallSubmit}
onCancel={handleModalClose}
modalTitle={t('registry.installServer', { name: server.title || server.name })}
formError={installError}
initialData={getInitialFormData()}
/>
</div>
)}
</div>
);
};
export default RegistryServerDetail;
// Helper function to determine command based on registry type
function getCommand(registryType: string): string {
// Map registry types to appropriate commands
switch (registryType.toLowerCase()) {
case 'pypi':
case 'python':
return 'uvx';
case 'npm':
case 'node':
return 'npx';
case 'oci':
case 'docker':
return 'docker';
default:
return '';
}
}
// Helper function to get appropriate args based on command type and package identifier
function getArgs(command: string, pkg: RegistryPackage): string[] {
const identifier = [pkg.identifier + (pkg.version ? `@${pkg.version}` : '')];
// Build package arguments if available
const packageArgs: string[] = [];
if (pkg.packageArguments && pkg.packageArguments.length > 0) {
pkg.packageArguments.forEach((arg) => {
// Add required arguments or arguments with default values
if (arg.isRequired || arg.default || arg.value) {
const argName = `--${arg.name}`;
// Priority: value > default > placeholder
const argValue = arg.value || arg.default || `\${${arg.name.toUpperCase()}}`;
packageArgs.push(argName, argValue);
}
});
}
// Map commands to appropriate argument patterns
switch (command.toLowerCase()) {
case 'uvx':
// For Python packages: uvx package-name --arg1 value1 --arg2 value2
return [...identifier, ...packageArgs];
case 'npx':
// For Node.js packages: npx package-name --arg1 value1 --arg2 value2
return [...identifier, ...packageArgs];
case 'docker': {
// add envs from environment variables if available
const envs: string[] = [];
if (pkg.environmentVariables) {
pkg.environmentVariables.forEach((env) => {
envs.push('-e', `${env.name}`);
});
}
// For Docker images: docker run -i package-name --arg1 value1 --arg2 value2
return ['run', '-i', '--rm', ...envs, ...identifier, ...packageArgs];
}
default:
// If no specific pattern is defined, return identifier with package args
return [...identifier, ...packageArgs];
}
}

View File

@@ -1,17 +1,23 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Server, EnvVar, ServerFormData } from '@/types'
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Server, EnvVar, ServerFormData } from '@/types';
interface ServerFormProps {
onSubmit: (payload: any) => void
onCancel: () => void
initialData?: Server | null
modalTitle: string
formError?: string | null
onSubmit: (payload: any) => void;
onCancel: () => void;
initialData?: Server | null;
modalTitle: string;
formError?: string | null;
}
const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formError = null }: ServerFormProps) => {
const { t } = useTranslation()
const ServerForm = ({
onSubmit,
onCancel,
initialData = null,
modalTitle,
formError = null,
}: ServerFormProps) => {
const { t } = useTranslation();
// Determine the initial server type from the initialData
const getInitialServerType = () => {
@@ -26,7 +32,19 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
}
};
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(getInitialServerType());
const getInitialServerEnvVars = (data: Server | null): EnvVar[] => {
if (!data || !data.config || !data.config.env) return [];
return Object.entries(data.config.env).map(([key, value]) => ({
key,
value,
description: '', // You can set a default description if needed
}));
};
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(
getInitialServerType(),
);
const [formData, setFormData] = useState<ServerFormData>({
name: (initialData && initialData.name) || '',
@@ -40,149 +58,178 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
: '',
args: (initialData && initialData.config && initialData.config.args) || [],
type: getInitialServerType(), // Initialize the type field
env: [],
env: getInitialServerEnvVars(initialData),
headers: [],
options: {
timeout: (initialData && initialData.config && initialData.config.options && initialData.config.options.timeout) || 60000,
resetTimeoutOnProgress: (initialData && initialData.config && initialData.config.options && initialData.config.options.resetTimeoutOnProgress) || false,
maxTotalTimeout: (initialData && initialData.config && initialData.config.options && initialData.config.options.maxTotalTimeout) || undefined,
timeout:
(initialData &&
initialData.config &&
initialData.config.options &&
initialData.config.options.timeout) ||
60000,
resetTimeoutOnProgress:
(initialData &&
initialData.config &&
initialData.config.options &&
initialData.config.options.resetTimeoutOnProgress) ||
false,
maxTotalTimeout:
(initialData &&
initialData.config &&
initialData.config.options &&
initialData.config.options.maxTotalTimeout) ||
undefined,
},
// OpenAPI configuration initialization
openapi: initialData && initialData.config && initialData.config.openapi ? {
url: initialData.config.openapi.url || '',
schema: initialData.config.openapi.schema ? JSON.stringify(initialData.config.openapi.schema, null, 2) : '',
inputMode: initialData.config.openapi.url ? 'url' : (initialData.config.openapi.schema ? 'schema' : 'url'),
version: initialData.config.openapi.version || '3.1.0',
securityType: initialData.config.openapi.security?.type || 'none',
// API Key initialization
apiKeyName: initialData.config.openapi.security?.apiKey?.name || '',
apiKeyIn: initialData.config.openapi.security?.apiKey?.in || 'header',
apiKeyValue: initialData.config.openapi.security?.apiKey?.value || '',
// HTTP auth initialization
httpScheme: initialData.config.openapi.security?.http?.scheme || 'bearer',
httpCredentials: initialData.config.openapi.security?.http?.credentials || '',
// OAuth2 initialization
oauth2Token: initialData.config.openapi.security?.oauth2?.token || '',
// OpenID Connect initialization
openIdConnectUrl: initialData.config.openapi.security?.openIdConnect?.url || '',
openIdConnectToken: initialData.config.openapi.security?.openIdConnect?.token || '',
// Passthrough headers initialization
passthroughHeaders: initialData.config.openapi.passthroughHeaders ? initialData.config.openapi.passthroughHeaders.join(', ') : '',
} : {
inputMode: 'url',
url: '',
schema: '',
version: '3.1.0',
securityType: 'none',
passthroughHeaders: '',
}
})
openapi:
initialData && initialData.config && initialData.config.openapi
? {
url: initialData.config.openapi.url || '',
schema: initialData.config.openapi.schema
? JSON.stringify(initialData.config.openapi.schema, null, 2)
: '',
inputMode: initialData.config.openapi.url
? 'url'
: initialData.config.openapi.schema
? 'schema'
: 'url',
version: initialData.config.openapi.version || '3.1.0',
securityType: initialData.config.openapi.security?.type || 'none',
// API Key initialization
apiKeyName: initialData.config.openapi.security?.apiKey?.name || '',
apiKeyIn: initialData.config.openapi.security?.apiKey?.in || 'header',
apiKeyValue: initialData.config.openapi.security?.apiKey?.value || '',
// HTTP auth initialization
httpScheme: initialData.config.openapi.security?.http?.scheme || 'bearer',
httpCredentials: initialData.config.openapi.security?.http?.credentials || '',
// OAuth2 initialization
oauth2Token: initialData.config.openapi.security?.oauth2?.token || '',
// OpenID Connect initialization
openIdConnectUrl: initialData.config.openapi.security?.openIdConnect?.url || '',
openIdConnectToken: initialData.config.openapi.security?.openIdConnect?.token || '',
// Passthrough headers initialization
passthroughHeaders: initialData.config.openapi.passthroughHeaders
? initialData.config.openapi.passthroughHeaders.join(', ')
: '',
}
: {
inputMode: 'url',
url: '',
schema: '',
version: '3.1.0',
securityType: 'none',
passthroughHeaders: '',
},
});
const [envVars, setEnvVars] = useState<EnvVar[]>(
initialData && initialData.config && initialData.config.env
? Object.entries(initialData.config.env).map(([key, value]) => ({ key, value }))
: [],
)
);
const [headerVars, setHeaderVars] = useState<EnvVar[]>(
initialData && initialData.config && initialData.config.headers
? Object.entries(initialData.config.headers).map(([key, value]) => ({ key, value }))
: [],
)
);
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const isEdit = !!initialData
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const isEdit = !!initialData;
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFormData({ ...formData, [name]: value })
}
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
};
// Transform space-separated arguments string into array
const handleArgsChange = (value: string) => {
const args = value.split(' ').filter((arg) => arg.trim() !== '')
setFormData({ ...formData, arguments: value, args })
}
const args = value.split(' ').filter((arg) => arg.trim() !== '');
setFormData({ ...formData, arguments: value, args });
};
const updateServerType = (type: 'stdio' | 'sse' | 'streamable-http' | 'openapi') => {
setServerType(type);
setFormData(prev => ({ ...prev, type }));
}
setFormData((prev) => ({ ...prev, type }));
};
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
newEnvVars[index][field] = value
setEnvVars(newEnvVars)
}
const newEnvVars = [...envVars];
newEnvVars[index][field] = value;
setEnvVars(newEnvVars);
};
const addEnvVar = () => {
setEnvVars([...envVars, { key: '', value: '' }])
}
setEnvVars([...envVars, { key: '', value: '' }]);
};
const removeEnvVar = (index: number) => {
const newEnvVars = [...envVars]
newEnvVars.splice(index, 1)
setEnvVars(newEnvVars)
}
const newEnvVars = [...envVars];
newEnvVars.splice(index, 1);
setEnvVars(newEnvVars);
};
const handleHeaderVarChange = (index: number, field: 'key' | 'value', value: string) => {
const newHeaderVars = [...headerVars]
newHeaderVars[index][field] = value
setHeaderVars(newHeaderVars)
}
const newHeaderVars = [...headerVars];
newHeaderVars[index][field] = value;
setHeaderVars(newHeaderVars);
};
const addHeaderVar = () => {
setHeaderVars([...headerVars, { key: '', value: '' }])
}
setHeaderVars([...headerVars, { key: '', value: '' }]);
};
const removeHeaderVar = (index: number) => {
const newHeaderVars = [...headerVars]
newHeaderVars.splice(index, 1)
setHeaderVars(newHeaderVars)
}
const newHeaderVars = [...headerVars];
newHeaderVars.splice(index, 1);
setHeaderVars(newHeaderVars);
};
// Handle options changes
const handleOptionsChange = (field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout', value: number | boolean | undefined) => {
setFormData(prev => ({
const handleOptionsChange = (
field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout',
value: number | boolean | undefined,
) => {
setFormData((prev) => ({
...prev,
options: {
...prev.options,
[field]: value
}
}))
}
[field]: value,
},
}));
};
// Submit handler for server configuration
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
e.preventDefault();
setError(null);
try {
const env: Record<string, string> = {}
const env: Record<string, string> = {};
envVars.forEach(({ key, value }) => {
if (key.trim()) {
env[key.trim()] = value
env[key.trim()] = value;
}
})
});
const headers: Record<string, string> = {}
const headers: Record<string, string> = {};
headerVars.forEach(({ key, value }) => {
if (key.trim()) {
headers[key.trim()] = value
headers[key.trim()] = value;
}
})
});
// Prepare options object, only include defined values
const options: any = {}
const options: any = {};
if (formData.options?.timeout && formData.options.timeout !== 60000) {
options.timeout = formData.options.timeout
options.timeout = formData.options.timeout;
}
if (formData.options?.resetTimeoutOnProgress) {
options.resetTimeoutOnProgress = formData.options.resetTimeoutOnProgress
options.resetTimeoutOnProgress = formData.options.resetTimeoutOnProgress;
}
if (formData.options?.maxTotalTimeout) {
options.maxTotalTimeout = formData.options.maxTotalTimeout
options.maxTotalTimeout = formData.options.maxTotalTimeout;
}
const payload = {
@@ -191,85 +238,87 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type: serverType, // Always include the type
...(serverType === 'openapi'
? {
openapi: (() => {
const openapi: any = {
version: formData.openapi?.version || '3.1.0'
};
// Add URL or schema based on input mode
if (formData.openapi?.inputMode === 'url') {
openapi.url = formData.openapi?.url || '';
} else if (formData.openapi?.inputMode === 'schema' && formData.openapi?.schema) {
try {
openapi.schema = JSON.parse(formData.openapi.schema);
} catch (e) {
throw new Error('Invalid JSON schema format');
}
}
// Add security configuration if provided
if (formData.openapi?.securityType && formData.openapi.securityType !== 'none') {
openapi.security = {
type: formData.openapi.securityType,
...(formData.openapi.securityType === 'apiKey' && {
apiKey: {
name: formData.openapi.apiKeyName || '',
in: formData.openapi.apiKeyIn || 'header',
value: formData.openapi.apiKeyValue || ''
}
}),
...(formData.openapi.securityType === 'http' && {
http: {
scheme: formData.openapi.httpScheme || 'bearer',
credentials: formData.openapi.httpCredentials || ''
}
}),
...(formData.openapi.securityType === 'oauth2' && {
oauth2: {
token: formData.openapi.oauth2Token || ''
}
}),
...(formData.openapi.securityType === 'openIdConnect' && {
openIdConnect: {
url: formData.openapi.openIdConnectUrl || '',
token: formData.openapi.openIdConnectToken || ''
}
})
openapi: (() => {
const openapi: any = {
version: formData.openapi?.version || '3.1.0',
};
}
// Add passthrough headers if provided
if (formData.openapi?.passthroughHeaders && formData.openapi.passthroughHeaders.trim()) {
openapi.passthroughHeaders = formData.openapi.passthroughHeaders
.split(',')
.map(header => header.trim())
.filter(header => header.length > 0);
}
// Add URL or schema based on input mode
if (formData.openapi?.inputMode === 'url') {
openapi.url = formData.openapi?.url || '';
} else if (formData.openapi?.inputMode === 'schema' && formData.openapi?.schema) {
try {
openapi.schema = JSON.parse(formData.openapi.schema);
} catch (e) {
throw new Error('Invalid JSON schema format');
}
}
return openapi;
})(),
...(Object.keys(headers).length > 0 ? { headers } : {})
}
// Add security configuration if provided
if (formData.openapi?.securityType && formData.openapi.securityType !== 'none') {
openapi.security = {
type: formData.openapi.securityType,
...(formData.openapi.securityType === 'apiKey' && {
apiKey: {
name: formData.openapi.apiKeyName || '',
in: formData.openapi.apiKeyIn || 'header',
value: formData.openapi.apiKeyValue || '',
},
}),
...(formData.openapi.securityType === 'http' && {
http: {
scheme: formData.openapi.httpScheme || 'bearer',
credentials: formData.openapi.httpCredentials || '',
},
}),
...(formData.openapi.securityType === 'oauth2' && {
oauth2: {
token: formData.openapi.oauth2Token || '',
},
}),
...(formData.openapi.securityType === 'openIdConnect' && {
openIdConnect: {
url: formData.openapi.openIdConnectUrl || '',
token: formData.openapi.openIdConnectToken || '',
},
}),
};
}
// Add passthrough headers if provided
if (
formData.openapi?.passthroughHeaders &&
formData.openapi.passthroughHeaders.trim()
) {
openapi.passthroughHeaders = formData.openapi.passthroughHeaders
.split(',')
.map((header) => header.trim())
.filter((header) => header.length > 0);
}
return openapi;
})(),
...(Object.keys(headers).length > 0 ? { headers } : {}),
}
: serverType === 'sse' || serverType === 'streamable-http'
? {
url: formData.url,
...(Object.keys(headers).length > 0 ? { headers } : {})
}
url: formData.url,
...(Object.keys(headers).length > 0 ? { headers } : {}),
}
: {
command: formData.command,
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
}
),
...(Object.keys(options).length > 0 ? { options } : {})
}
}
command: formData.command,
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
}),
...(Object.keys(options).length > 0 ? { options } : {}),
},
};
onSubmit(payload)
onSubmit(payload);
} catch (err) {
setError(`Error: ${err instanceof Error ? err.message : String(err)}`)
setError(`Error: ${err instanceof Error ? err.message : String(err)}`);
}
}
};
return (
<div className="bg-white shadow rounded-lg p-6 w-full max-w-xl max-h-screen overflow-y-auto">
@@ -281,9 +330,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</div>
{(error || formError) && (
<div className="bg-red-50 text-red-700 p-3 rounded mb-4">
{formError || error}
</div>
<div className="bg-red-50 text-red-700 p-3 rounded mb-4">{formError || error}</div>
)}
<form onSubmit={handleSubmit}>
@@ -373,10 +420,12 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
name="inputMode"
value="url"
checked={formData.openapi?.inputMode === 'url'}
onChange={() => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi!, inputMode: 'url' }
}))}
onChange={() =>
setFormData((prev) => ({
...prev,
openapi: { ...prev.openapi!, inputMode: 'url' },
}))
}
className="mr-1"
/>
<label htmlFor="input-mode-url">{t('server.openapi.inputModeUrl')}</label>
@@ -388,10 +437,12 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
name="inputMode"
value="schema"
checked={formData.openapi?.inputMode === 'schema'}
onChange={() => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi!, inputMode: 'schema' }
}))}
onChange={() =>
setFormData((prev) => ({
...prev,
openapi: { ...prev.openapi!, inputMode: 'schema' },
}))
}
className="mr-1"
/>
<label htmlFor="input-mode-schema">{t('server.openapi.inputModeSchema')}</label>
@@ -410,10 +461,12 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
name="openapi-url"
id="openapi-url"
value={formData.openapi?.url || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi!, url: e.target.value }
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: { ...prev.openapi!, url: e.target.value },
}))
}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: https://api.example.com/openapi.json"
required={serverType === 'openapi' && formData.openapi?.inputMode === 'url'}
@@ -424,7 +477,10 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* Schema Input */}
{formData.openapi?.inputMode === 'schema' && (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="openapi-schema">
<label
className="block text-gray-700 text-sm font-bold mb-2"
htmlFor="openapi-schema"
>
{t('server.openapi.schema')}
</label>
<textarea
@@ -432,10 +488,12 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="openapi-schema"
rows={10}
value={formData.openapi?.schema || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi!, schema: e.target.value }
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: { ...prev.openapi!, schema: e.target.value },
}))
}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline font-mono text-sm"
placeholder={`{
"openapi": "3.1.0",
@@ -465,14 +523,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</label>
<select
value={formData.openapi?.securityType || 'none'}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: {
...prev.openapi,
securityType: e.target.value as any,
url: prev.openapi?.url || ''
}
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: {
...prev.openapi,
securityType: e.target.value as any,
url: prev.openapi?.url || '',
},
}))
}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
>
<option value="none">{t('server.openapi.securityNone')}</option>
@@ -486,29 +546,47 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* API Key Configuration */}
{formData.openapi?.securityType === 'apiKey' && (
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.apiKeyConfig')}</h4>
<h4 className="text-sm font-medium text-gray-700 mb-3">
{t('server.openapi.apiKeyConfig')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyName')}</label>
<label className="block text-xs text-gray-600 mb-1">
{t('server.openapi.apiKeyName')}
</label>
<input
type="text"
value={formData.openapi?.apiKeyName || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, apiKeyName: e.target.value, url: prev.openapi?.url || '' }
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: {
...prev.openapi,
apiKeyName: e.target.value,
url: prev.openapi?.url || '',
},
}))
}
className="w-full border rounded px-2 py-1 text-sm form-input focus:outline-none"
placeholder="Authorization"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyIn')}</label>
<label className="block text-xs text-gray-600 mb-1">
{t('server.openapi.apiKeyIn')}
</label>
<select
value={formData.openapi?.apiKeyIn || 'header'}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, apiKeyIn: e.target.value as any, url: prev.openapi?.url || '' }
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: {
...prev.openapi,
apiKeyIn: e.target.value as any,
url: prev.openapi?.url || '',
},
}))
}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
>
<option value="header">{t('server.openapi.apiKeyInHeader')}</option>
@@ -517,14 +595,22 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyValue')}</label>
<label className="block text-xs text-gray-600 mb-1">
{t('server.openapi.apiKeyValue')}
</label>
<input
type="password"
value={formData.openapi?.apiKeyValue || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, apiKeyValue: e.target.value, url: prev.openapi?.url || '' }
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: {
...prev.openapi,
apiKeyValue: e.target.value,
url: prev.openapi?.url || '',
},
}))
}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="your-api-key"
/>
@@ -536,16 +622,26 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* HTTP Authentication Configuration */}
{formData.openapi?.securityType === 'http' && (
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.httpAuthConfig')}</h4>
<h4 className="text-sm font-medium text-gray-700 mb-3">
{t('server.openapi.httpAuthConfig')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.httpScheme')}</label>
<label className="block text-xs text-gray-600 mb-1">
{t('server.openapi.httpScheme')}
</label>
<select
value={formData.openapi?.httpScheme || 'bearer'}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, httpScheme: e.target.value as any, url: prev.openapi?.url || '' }
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: {
...prev.openapi,
httpScheme: e.target.value as any,
url: prev.openapi?.url || '',
},
}))
}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
>
<option value="basic">{t('server.openapi.httpSchemeBasic')}</option>
@@ -554,16 +650,28 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.httpCredentials')}</label>
<label className="block text-xs text-gray-600 mb-1">
{t('server.openapi.httpCredentials')}
</label>
<input
type="password"
value={formData.openapi?.httpCredentials || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, httpCredentials: e.target.value, url: prev.openapi?.url || '' }
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: {
...prev.openapi,
httpCredentials: e.target.value,
url: prev.openapi?.url || '',
},
}))
}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder={formData.openapi?.httpScheme === 'basic' ? 'base64-encoded-credentials' : 'bearer-token'}
placeholder={
formData.openapi?.httpScheme === 'basic'
? 'base64-encoded-credentials'
: 'bearer-token'
}
/>
</div>
</div>
@@ -573,17 +681,27 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* OAuth2 Configuration */}
{formData.openapi?.securityType === 'oauth2' && (
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.oauth2Config')}</h4>
<h4 className="text-sm font-medium text-gray-700 mb-3">
{t('server.openapi.oauth2Config')}
</h4>
<div className="grid grid-cols-1 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.oauth2Token')}</label>
<label className="block text-xs text-gray-600 mb-1">
{t('server.openapi.oauth2Token')}
</label>
<input
type="password"
value={formData.openapi?.oauth2Token || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, oauth2Token: e.target.value, url: prev.openapi?.url || '' }
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: {
...prev.openapi,
oauth2Token: e.target.value,
url: prev.openapi?.url || '',
},
}))
}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="access-token"
/>
@@ -595,30 +713,48 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* OpenID Connect Configuration */}
{formData.openapi?.securityType === 'openIdConnect' && (
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.openIdConnectConfig')}</h4>
<h4 className="text-sm font-medium text-gray-700 mb-3">
{t('server.openapi.openIdConnectConfig')}
</h4>
<div className="grid grid-cols-1 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.openIdConnectUrl')}</label>
<label className="block text-xs text-gray-600 mb-1">
{t('server.openapi.openIdConnectUrl')}
</label>
<input
type="url"
value={formData.openapi?.openIdConnectUrl || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, openIdConnectUrl: e.target.value, url: prev.openapi?.url || '' }
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: {
...prev.openapi,
openIdConnectUrl: e.target.value,
url: prev.openapi?.url || '',
},
}))
}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="https://example.com/.well-known/openid_configuration"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.openIdConnectToken')}</label>
<label className="block text-xs text-gray-600 mb-1">
{t('server.openapi.openIdConnectToken')}
</label>
<input
type="password"
value={formData.openapi?.openIdConnectToken || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, openIdConnectToken: e.target.value, url: prev.openapi?.url || '' }
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: {
...prev.openapi,
openIdConnectToken: e.target.value,
url: prev.openapi?.url || '',
},
}))
}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="id-token"
/>
@@ -635,14 +771,22 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<input
type="text"
value={formData.openapi?.passthroughHeaders || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, passthroughHeaders: e.target.value, url: prev.openapi?.url || '' }
}))}
onChange={(e) =>
setFormData((prev) => ({
...prev,
openapi: {
...prev.openapi,
passthroughHeaders: e.target.value,
url: prev.openapi?.url || '',
},
}))
}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="Authorization, X-API-Key, X-Custom-Header"
/>
<p className="text-xs text-gray-500 mt-1">{t('server.openapi.passthroughHeadersHelp')}</p>
<p className="text-xs text-gray-500 mt-1">
{t('server.openapi.passthroughHeadersHelp')}
</p>
</div>
<div className="mb-4">
@@ -701,7 +845,11 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
value={formData.url}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
placeholder={
serverType === 'streamable-http'
? 'e.g.: http://localhost:3000/mcp'
: 'e.g.: http://localhost:3000/sse'
}
required={serverType === 'sse' || serverType === 'streamable-http'}
/>
</div>
@@ -837,23 +985,26 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<label className="text-gray-700 text-sm font-bold">
{t('server.requestOptions')}
</label>
<span className="text-gray-500 text-sm">
{isRequestOptionsExpanded ? '▼' : '▶'}
</span>
<span className="text-gray-500 text-sm">{isRequestOptionsExpanded ? '▼' : '▶'}</span>
</div>
{isRequestOptionsExpanded && (
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="timeout">
<label
className="block text-gray-600 text-sm font-medium mb-1"
htmlFor="timeout"
>
{t('server.timeout')}
</label>
<input
type="number"
id="timeout"
value={formData.options?.timeout || 60000}
onChange={(e) => handleOptionsChange('timeout', parseInt(e.target.value) || 60000)}
onChange={(e) =>
handleOptionsChange('timeout', parseInt(e.target.value) || 60000)
}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="30000"
min="1000"
@@ -863,19 +1014,29 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</div>
<div>
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="maxTotalTimeout">
<label
className="block text-gray-600 text-sm font-medium mb-1"
htmlFor="maxTotalTimeout"
>
{t('server.maxTotalTimeout')}
</label>
<input
type="number"
id="maxTotalTimeout"
value={formData.options?.maxTotalTimeout || ''}
onChange={(e) => handleOptionsChange('maxTotalTimeout', e.target.value ? parseInt(e.target.value) : undefined)}
onChange={(e) =>
handleOptionsChange(
'maxTotalTimeout',
e.target.value ? parseInt(e.target.value) : undefined,
)
}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="Optional"
min="1000"
/>
<p className="text-xs text-gray-500 mt-1">{t('server.maxTotalTimeoutDescription')}</p>
<p className="text-xs text-gray-500 mt-1">
{t('server.maxTotalTimeoutDescription')}
</p>
</div>
</div>
@@ -884,10 +1045,14 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<input
type="checkbox"
checked={formData.options?.resetTimeoutOnProgress || false}
onChange={(e) => handleOptionsChange('resetTimeoutOnProgress', e.target.checked)}
onChange={(e) =>
handleOptionsChange('resetTimeoutOnProgress', e.target.checked)
}
className="mr-2"
/>
<span className="text-gray-600 text-sm">{t('server.resetTimeoutOnProgress')}</span>
<span className="text-gray-600 text-sm">
{t('server.resetTimeoutOnProgress')}
</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-6">
{t('server.resetTimeoutOnProgressDescription')}
@@ -915,7 +1080,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</div>
</form>
</div>
)
}
);
};
export default ServerForm
export default ServerForm;

View File

@@ -0,0 +1,78 @@
import React from 'react';
interface CursorPaginationProps {
currentPage: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
onNextPage: () => void;
onPreviousPage: () => void;
}
const CursorPagination: React.FC<CursorPaginationProps> = ({
currentPage,
hasNextPage,
hasPreviousPage,
onNextPage,
onPreviousPage,
}) => {
return (
<div className="flex items-center justify-center space-x-2 my-6">
{/* Previous button */}
<button
onClick={onPreviousPage}
disabled={!hasPreviousPage}
className={`px-4 py-2 rounded transition-all duration-200 ${
hasPreviousPage
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 inline-block mr-1"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
Prev
</button>
{/* Current page indicator */}
<span className="px-4 py-2 bg-blue-500 text-white rounded btn-primary">
Page {currentPage}
</span>
{/* Next button */}
<button
onClick={onNextPage}
disabled={!hasNextPage}
className={`px-4 py-2 rounded transition-all duration-200 ${
hasNextPage
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
Next
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 inline-block ml-1"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
);
};
export default CursorPagination;

View File

@@ -63,55 +63,58 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
};
// Start normal polling
const startNormalPolling = useCallback((options?: { immediate?: boolean }) => {
const immediate = options?.immediate ?? true;
// Ensure no other timers are running
clearTimer();
const startNormalPolling = useCallback(
(options?: { immediate?: boolean }) => {
const immediate = options?.immediate ?? true;
// Ensure no other timers are running
clearTimer();
const fetchServers = async () => {
try {
console.log('[ServerContext] Fetching servers from API...');
const data = await apiGet('/servers');
const fetchServers = async () => {
try {
console.log('[ServerContext] Fetching servers from API...');
const data = await apiGet('/servers');
// Update last fetch time
lastFetchTimeRef.current = Date.now();
// Update last fetch time
lastFetchTimeRef.current = Date.now();
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
} else if (data && Array.isArray(data)) {
setServers(data);
} else {
console.error('Invalid server data format:', data);
setServers([]);
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
} else if (data && Array.isArray(data)) {
setServers(data);
} else {
console.error('Invalid server data format:', data);
setServers([]);
}
// Reset error state
setError(null);
} catch (err) {
console.error('Error fetching servers during normal polling:', err);
// Use friendly error message
if (!navigator.onLine) {
setError(t('errors.network'));
} else if (
err instanceof TypeError &&
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
) {
setError(t('errors.serverConnection'));
} else {
setError(t('errors.serverFetch'));
}
}
};
// Reset error state
setError(null);
} catch (err) {
console.error('Error fetching servers during normal polling:', err);
// Use friendly error message
if (!navigator.onLine) {
setError(t('errors.network'));
} else if (
err instanceof TypeError &&
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
) {
setError(t('errors.serverConnection'));
} else {
setError(t('errors.serverFetch'));
}
// Execute immediately unless explicitly skipped
if (immediate) {
fetchServers();
}
};
// Execute immediately unless explicitly skipped
if (immediate) {
fetchServers();
}
// Set up regular polling
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
}, [t]);
// Set up regular polling
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
},
[t],
);
// Watch for authentication status changes
useEffect(() => {
@@ -247,14 +250,28 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const timeSinceLastFetch = now - lastFetchTimeRef.current;
// Log who is calling this
console.log('[ServerContext] refreshIfNeeded called, time since last fetch:', timeSinceLastFetch, 'ms');
console.log(
'[ServerContext] refreshIfNeeded called, time since last fetch:',
timeSinceLastFetch,
'ms',
);
// Only refresh if enough time has passed since last fetch
if (timeSinceLastFetch >= MIN_REFRESH_INTERVAL) {
console.log('[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms)');
console.log(
'[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:',
MIN_REFRESH_INTERVAL,
'ms)',
);
triggerRefresh();
} else {
console.log('[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms, time since last:', timeSinceLastFetch, 'ms)');
console.log(
'[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:',
MIN_REFRESH_INTERVAL,
'ms, time since last:',
timeSinceLastFetch,
'ms)',
);
}
}, [triggerRefresh]);
@@ -263,74 +280,85 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
setRefreshKey((prevKey) => prevKey + 1);
}, []);
const handleServerEdit = useCallback(async (server: Server) => {
try {
// Fetch settings to get the full server config before editing
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
await apiGet('/settings');
const handleServerEdit = useCallback(
async (server: Server) => {
try {
// Fetch settings to get the full server config before editing
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
await apiGet('/settings');
if (
settingsData &&
settingsData.success &&
settingsData.data &&
settingsData.data.mcpServers &&
settingsData.data.mcpServers[server.name]
) {
const serverConfig = settingsData.data.mcpServers[server.name];
return {
name: server.name,
status: server.status,
tools: server.tools || [],
config: serverConfig,
};
} else {
console.error('Failed to get server config from settings:', settingsData);
setError(t('server.invalidConfig', { serverName: server.name }));
if (
settingsData &&
settingsData.success &&
settingsData.data &&
settingsData.data.mcpServers &&
settingsData.data.mcpServers[server.name]
) {
const serverConfig = settingsData.data.mcpServers[server.name];
return {
name: server.name,
status: server.status,
tools: server.tools || [],
config: serverConfig,
};
} else {
console.error('Failed to get server config from settings:', settingsData);
setError(t('server.invalidConfig', { serverName: server.name }));
return null;
}
} catch (err) {
console.error('Error fetching server settings:', err);
setError(err instanceof Error ? err.message : String(err));
return null;
}
} catch (err) {
console.error('Error fetching server settings:', err);
setError(err instanceof Error ? err.message : String(err));
return null;
}
}, [t]);
},
[t],
);
const handleServerRemove = useCallback(async (serverName: string) => {
try {
const result = await apiDelete(`/servers/${serverName}`);
const handleServerRemove = useCallback(
async (serverName: string) => {
try {
const encodedServerName = encodeURIComponent(serverName);
const result = await apiDelete(`/servers/${encodedServerName}`);
if (!result || !result.success) {
setError(result?.message || t('server.deleteError', { serverName }));
if (!result || !result.success) {
setError(result?.message || t('server.deleteError', { serverName }));
return false;
}
setRefreshKey((prevKey) => prevKey + 1);
return true;
} catch (err) {
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
return false;
}
},
[t],
);
setRefreshKey((prevKey) => prevKey + 1);
return true;
} catch (err) {
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
return false;
}
}, [t]);
const handleServerToggle = useCallback(
async (server: Server, enabled: boolean) => {
try {
const encodedServerName = encodeURIComponent(server.name);
const result = await apiPost(`/servers/${encodedServerName}/toggle`, { enabled });
const handleServerToggle = useCallback(async (server: Server, enabled: boolean) => {
try {
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
if (!result || !result.success) {
console.error('Failed to toggle server:', result);
setError(result?.message || t('server.toggleError', { serverName: server.name }));
return false;
}
if (!result || !result.success) {
console.error('Failed to toggle server:', result);
setError(result?.message || t('server.toggleError', { serverName: server.name }));
// Update the UI immediately to reflect the change
setRefreshKey((prevKey) => prevKey + 1);
return true;
} catch (err) {
console.error('Error toggling server:', err);
setError(err instanceof Error ? err.message : String(err));
return false;
}
// Update the UI immediately to reflect the change
setRefreshKey((prevKey) => prevKey + 1);
return true;
} catch (err) {
console.error('Error toggling server:', err);
setError(err instanceof Error ? err.message : String(err));
return false;
}
}, [t]);
},
[t],
);
const value: ServerContextType = {
servers,

View File

@@ -0,0 +1,283 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
RegistryServerEntry,
RegistryServersResponse,
RegistryServerVersionResponse,
RegistryServerVersionsResponse,
} from '@/types';
import { apiGet } from '../utils/fetchInterceptor';
export const useRegistryData = () => {
const { t } = useTranslation();
const [servers, setServers] = useState<RegistryServerEntry[]>([]);
const [allServers, setAllServers] = useState<RegistryServerEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
// Cursor-based pagination states
const [currentPage, setCurrentPage] = useState(1);
const [serversPerPage, setServersPerPage] = useState(9);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
const [totalPages] = useState(1); // Legacy support, not used in cursor pagination
// Fetch registry servers with cursor-based pagination
const fetchRegistryServers = useCallback(
async (cursor?: string, search?: string) => {
try {
setLoading(true);
setError(null);
// Build query parameters
const params = new URLSearchParams();
params.append('limit', serversPerPage.toString());
if (cursor) {
params.append('cursor', cursor);
}
const queryToUse = search !== undefined ? search : searchQuery;
if (queryToUse.trim()) {
params.append('search', queryToUse.trim());
}
const response = await apiGet(`/registry/servers?${params.toString()}`);
if (response && response.success && response.data) {
const data: RegistryServersResponse = response.data;
if (data.servers && Array.isArray(data.servers)) {
setServers(data.servers);
// Update pagination state
const hasMore = data.metadata.count === serversPerPage && !!data.metadata.nextCursor;
setHasNextPage(hasMore);
setNextCursor(data.metadata.nextCursor || null);
// For display purposes, keep track of all loaded servers
if (!cursor) {
// First page
setAllServers(data.servers);
} else {
// Subsequent pages - append to all servers
setAllServers((prev) => [...prev, ...data.servers]);
}
} else {
console.error('Invalid registry servers data format:', data);
setError(t('registry.fetchError'));
}
} else {
setError(t('registry.fetchError'));
}
} catch (err) {
console.error('Error fetching registry servers:', err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(errorMessage);
} finally {
setLoading(false);
}
},
[t, serversPerPage],
);
// Navigate to next page
const goToNextPage = useCallback(async () => {
if (!hasNextPage || !nextCursor) return;
// Save current cursor to history for back navigation
const currentCursor = cursorHistory[cursorHistory.length - 1] || '';
setCursorHistory((prev) => [...prev, currentCursor]);
setCurrentPage((prev) => prev + 1);
await fetchRegistryServers(nextCursor, searchQuery);
}, [hasNextPage, nextCursor, cursorHistory, searchQuery, fetchRegistryServers]);
// Navigate to previous page
const goToPreviousPage = useCallback(async () => {
if (currentPage <= 1) return;
// Get the previous cursor from history
const newHistory = [...cursorHistory];
newHistory.pop(); // Remove current position
const previousCursor = newHistory[newHistory.length - 1];
setCursorHistory(newHistory);
setCurrentPage((prev) => prev - 1);
// Fetch with previous cursor (undefined for first page)
await fetchRegistryServers(previousCursor || undefined, searchQuery);
}, [currentPage, cursorHistory, searchQuery, fetchRegistryServers]);
// Change page (legacy support for page number navigation)
const changePage = useCallback(
async (page: number) => {
if (page === currentPage) return;
if (page > currentPage && hasNextPage) {
await goToNextPage();
} else if (page < currentPage && currentPage > 1) {
await goToPreviousPage();
}
},
[currentPage, hasNextPage, goToNextPage, goToPreviousPage],
);
// Change items per page
const changeServersPerPage = useCallback(
async (newServersPerPage: number) => {
setServersPerPage(newServersPerPage);
setCurrentPage(1);
setCursorHistory([]);
setAllServers([]);
await fetchRegistryServers(undefined, searchQuery);
},
[searchQuery, fetchRegistryServers],
);
// Fetch server by name
const fetchServerByName = useCallback(
async (serverName: string) => {
try {
setLoading(true);
setError(null);
// URL encode the server name
const encodedName = encodeURIComponent(serverName);
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
if (response && response.success && response.data) {
const data: RegistryServerVersionsResponse = response.data;
if (data.servers && Array.isArray(data.servers) && data.servers.length > 0) {
// Return the first server entry (should be the latest or specified version)
return data.servers[0];
} else {
console.error('Invalid registry server data format:', data);
setError(t('registry.serverNotFound'));
return null;
}
} else {
setError(t('registry.serverNotFound'));
return null;
}
} catch (err) {
console.error(`Error fetching registry server ${serverName}:`, err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(errorMessage);
return null;
} finally {
setLoading(false);
}
},
[t],
);
// Fetch all versions of a server
const fetchServerVersions = useCallback(async (serverName: string) => {
try {
setError(null);
// URL encode the server name
const encodedName = encodeURIComponent(serverName);
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
if (response && response.success && response.data) {
const data: RegistryServerVersionsResponse = response.data;
if (data.servers && Array.isArray(data.servers)) {
return data.servers;
} else {
console.error('Invalid registry server versions data format:', data);
return [];
}
} else {
return [];
}
} catch (err) {
console.error(`Error fetching versions for server ${serverName}:`, err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(errorMessage);
return [];
}
}, []);
// Fetch specific version of a server
const fetchServerVersion = useCallback(async (serverName: string, version: string) => {
try {
setError(null);
// URL encode the server name and version
const encodedName = encodeURIComponent(serverName);
const encodedVersion = encodeURIComponent(version);
const response = await apiGet(`/registry/servers/${encodedName}/versions/${encodedVersion}`);
if (response && response.success && response.data) {
const data: RegistryServerVersionResponse = response.data;
if (data && data.server) {
return data;
} else {
console.error('Invalid registry server version data format:', data);
return null;
}
} else {
return null;
}
} catch (err) {
console.error(`Error fetching version ${version} for server ${serverName}:`, err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(errorMessage);
return null;
}
}, []);
// Search servers by query (client-side filtering on loaded data)
const searchServers = useCallback(
async (query: string) => {
console.log('Searching registry servers with query:', query);
setSearchQuery(query);
setCurrentPage(1);
setCursorHistory([]);
setAllServers([]);
await fetchRegistryServers(undefined, query);
},
[fetchRegistryServers],
);
// Clear search
const clearSearch = useCallback(async () => {
setSearchQuery('');
setCurrentPage(1);
setCursorHistory([]);
setAllServers([]);
await fetchRegistryServers(undefined, '');
}, [fetchRegistryServers]);
// Initial fetch
useEffect(() => {
fetchRegistryServers(undefined, searchQuery);
// Only run on mount
// eslint-disable-next-line
}, []);
return {
servers,
allServers,
loading,
error,
setError,
searchQuery,
searchServers,
clearSearch,
fetchServerByName,
fetchServerVersions,
fetchServerVersion,
// Cursor-based pagination
currentPage,
totalPages,
hasNextPage,
hasPreviousPage: currentPage > 1,
changePage,
goToNextPage,
goToPreviousPage,
serversPerPage,
changeServersPerPage,
};
};

View File

@@ -1,17 +1,27 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { MarketServer, CloudServer, ServerConfig } from '@/types';
import {
MarketServer,
CloudServer,
ServerConfig,
RegistryServerEntry,
RegistryServerData,
} from '@/types';
import { useMarketData } from '@/hooks/useMarketData';
import { useCloudData } from '@/hooks/useCloudData';
import { useRegistryData } from '@/hooks/useRegistryData';
import { useToast } from '@/contexts/ToastContext';
import { apiPost } from '@/utils/fetchInterceptor';
import MarketServerCard from '@/components/MarketServerCard';
import MarketServerDetail from '@/components/MarketServerDetail';
import CloudServerCard from '@/components/CloudServerCard';
import CloudServerDetail from '@/components/CloudServerDetail';
import RegistryServerCard from '@/components/RegistryServerCard';
import RegistryServerDetail from '@/components/RegistryServerDetail';
import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError';
import Pagination from '@/components/ui/Pagination';
import CursorPagination from '@/components/ui/CursorPagination';
const MarketPage: React.FC = () => {
const { t } = useTranslation();
@@ -19,7 +29,7 @@ const MarketPage: React.FC = () => {
const { serverName } = useParams<{ serverName?: string }>();
const { showToast } = useToast();
// Get tab from URL search params, default to cloud market
// Get tab from URL search params
const [searchParams, setSearchParams] = useSearchParams();
const currentTab = searchParams.get('tab') || 'cloud';
@@ -44,7 +54,7 @@ const MarketPage: React.FC = () => {
totalPages: localTotalPages,
changePage: changeLocalPage,
serversPerPage: localServersPerPage,
changeServersPerPage: changeLocalServersPerPage
changeServersPerPage: changeLocalServersPerPage,
} = useMarketData();
// Cloud market data
@@ -61,29 +71,67 @@ const MarketPage: React.FC = () => {
totalPages: cloudTotalPages,
changePage: changeCloudPage,
serversPerPage: cloudServersPerPage,
changeServersPerPage: changeCloudServersPerPage
changeServersPerPage: changeCloudServersPerPage,
} = useCloudData();
// Registry data
const {
servers: registryServers,
allServers: allRegistryServers,
loading: registryLoading,
error: registryError,
setError: setRegistryError,
searchServers: searchRegistryServers,
clearSearch: clearRegistrySearch,
fetchServerByName: fetchRegistryServerByName,
fetchServerVersions: fetchRegistryServerVersions,
// Cursor-based pagination
currentPage: registryCurrentPage,
totalPages: registryTotalPages,
hasNextPage: registryHasNextPage,
hasPreviousPage: registryHasPreviousPage,
changePage: changeRegistryPage,
goToNextPage: goToRegistryNextPage,
goToPreviousPage: goToRegistryPreviousPage,
serversPerPage: registryServersPerPage,
changeServersPerPage: changeRegistryServersPerPage,
} = useRegistryData();
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
const [selectedCloudServer, setSelectedCloudServer] = useState<CloudServer | null>(null);
const [selectedRegistryServer, setSelectedRegistryServer] = useState<RegistryServerEntry | null>(
null,
);
const [searchQuery, setSearchQuery] = useState('');
const [registrySearchQuery, setRegistrySearchQuery] = useState('');
const [installing, setInstalling] = useState(false);
const [installedCloudServers, setInstalledCloudServers] = useState<Set<string>>(new Set());
const [installedRegistryServers, setInstalledRegistryServers] = useState<Set<string>>(new Set());
// Load server details if a server name is in the URL
useEffect(() => {
const loadServerDetails = async () => {
if (serverName) {
// Determine if it's a cloud or local server based on the current tab
// Determine if it's a cloud, local, or registry server based on the current tab
if (currentTab === 'cloud') {
// Try to find the server in cloud servers
const server = cloudServers.find(s => s.name === serverName);
const server = cloudServers.find((s) => s.name === serverName);
if (server) {
setSelectedCloudServer(server);
} else {
// If server not found, navigate back to market page
navigate('/market?tab=cloud');
}
} else if (currentTab === 'registry') {
console.log('Loading registry server details for:', serverName);
// Registry market
const serverEntry = await fetchRegistryServerByName(serverName);
if (serverEntry) {
setSelectedRegistryServer(serverEntry);
} else {
// If server not found, navigate back to market page
navigate('/market?tab=registry');
}
} else {
// Local market
const server = await fetchLocalServerByName(serverName);
@@ -97,14 +145,22 @@ const MarketPage: React.FC = () => {
} else {
setSelectedServer(null);
setSelectedCloudServer(null);
setSelectedRegistryServer(null);
}
};
loadServerDetails();
}, [serverName, currentTab, cloudServers, fetchLocalServerByName, navigate]);
}, [
serverName,
currentTab,
cloudServers,
fetchLocalServerByName,
fetchRegistryServerByName,
navigate,
]);
// Tab switching handler
const switchTab = (tab: 'local' | 'cloud') => {
const switchTab = (tab: 'local' | 'cloud' | 'registry') => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set('tab', tab);
setSearchParams(newSearchParams);
@@ -118,6 +174,8 @@ const MarketPage: React.FC = () => {
e.preventDefault();
if (currentTab === 'local') {
searchLocalServers(searchQuery);
} else if (currentTab === 'registry') {
searchRegistryServers(registrySearchQuery);
}
// Cloud search is not implemented in the original cloud page
};
@@ -129,18 +187,35 @@ const MarketPage: React.FC = () => {
};
const handleClearFilters = () => {
setSearchQuery('');
if (currentTab === 'local') {
setSearchQuery('');
filterLocalByCategory('');
filterLocalByTag('');
} else if (currentTab === 'registry') {
setRegistrySearchQuery('');
clearRegistrySearch();
}
};
const handleServerClick = (server: MarketServer | CloudServer) => {
const handleServerClick = (server: MarketServer | CloudServer | RegistryServerEntry) => {
if (currentTab === 'cloud') {
navigate(`/market/${server.name}?tab=cloud`);
const cloudServer = server as CloudServer;
navigate(`/market/${cloudServer.name}?tab=cloud`);
} else if (currentTab === 'registry') {
const registryServer = server as RegistryServerEntry;
console.log('Registry server clicked:', registryServer);
const serverName = registryServer.server?.name;
console.log('Server name extracted:', serverName);
if (serverName) {
const targetUrl = `/market/${encodeURIComponent(serverName)}?tab=registry`;
console.log('Navigating to:', targetUrl);
navigate(targetUrl);
} else {
console.error('Server name is undefined in registry server:', registryServer);
}
} else {
navigate(`/market/${server.name}?tab=local`);
const marketServer = server as MarketServer;
navigate(`/market/${marketServer.name}?tab=local`);
}
};
@@ -167,7 +242,7 @@ const MarketPage: React.FC = () => {
const payload = {
name: server.name,
config: config
config: config,
};
const result = await apiPost('/servers', payload);
@@ -179,9 +254,8 @@ const MarketPage: React.FC = () => {
}
// Update installed servers set
setInstalledCloudServers(prev => new Set(prev).add(server.name));
setInstalledCloudServers((prev) => new Set(prev).add(server.name));
showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success');
} catch (error) {
console.error('Error installing cloud server:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -191,7 +265,41 @@ const MarketPage: React.FC = () => {
}
};
const handleCallTool = async (serverName: string, toolName: string, args: Record<string, any>) => {
// Handle registry server installation
const handleRegistryInstall = async (server: RegistryServerData, config: ServerConfig) => {
try {
setInstalling(true);
const payload = {
name: server.name,
config: config,
};
const result = await apiPost('/servers', payload);
if (!result.success) {
const errorMessage = result?.message || t('server.addError');
showToast(errorMessage, 'error');
return;
}
// Update installed servers set
setInstalledRegistryServers((prev) => new Set(prev).add(server.name));
showToast(t('registry.installSuccess', { name: server.title || server.name }), 'success');
} catch (error) {
console.error('Error installing registry server:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
showToast(t('registry.installError', { error: errorMessage }), 'error');
} finally {
setInstalling(false);
}
};
const handleCallTool = async (
serverName: string,
toolName: string,
args: Record<string, any>,
) => {
try {
const result = await callServerTool(serverName, toolName, args);
showToast(t('cloud.toolCallSuccess', { toolName }), 'success');
@@ -208,13 +316,17 @@ const MarketPage: React.FC = () => {
// Helper function to check if error is MCPRouter API key not configured
const isMCPRouterApiKeyError = (errorMessage: string) => {
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
errorMessage.toLowerCase().includes('mcprouter api key not configured');
return (
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
);
};
const handlePageChange = (page: number) => {
if (currentTab === 'local') {
changeLocalPage(page);
} else if (currentTab === 'registry') {
changeRegistryPage(page);
} else {
changeCloudPage(page);
}
@@ -226,6 +338,8 @@ const MarketPage: React.FC = () => {
const newValue = parseInt(e.target.value, 10);
if (currentTab === 'local') {
changeLocalServersPerPage(newValue);
} else if (currentTab === 'registry') {
changeRegistryServersPerPage(newValue);
} else {
changeCloudServersPerPage(newValue);
}
@@ -259,19 +373,50 @@ const MarketPage: React.FC = () => {
);
}
// Render registry server detail if selected
if (selectedRegistryServer) {
return (
<RegistryServerDetail
serverEntry={selectedRegistryServer}
onBack={handleBackToList}
onInstall={handleRegistryInstall}
installing={installing}
isInstalled={installedRegistryServers.has(selectedRegistryServer.server.name)}
fetchVersions={fetchRegistryServerVersions}
/>
);
}
// Get current data based on active tab
const isLocalTab = currentTab === 'local';
const servers = isLocalTab ? localServers : cloudServers;
const allServers = isLocalTab ? allLocalServers : allCloudServers;
const isRegistryTab = currentTab === 'registry';
const servers = isLocalTab ? localServers : isRegistryTab ? registryServers : cloudServers;
const allServers = isLocalTab
? allLocalServers
: isRegistryTab
? allRegistryServers
: allCloudServers;
const categories = isLocalTab ? localCategories : [];
const loading = isLocalTab ? localLoading : cloudLoading;
const error = isLocalTab ? localError : cloudError;
const setError = isLocalTab ? setLocalError : setCloudError;
const loading = isLocalTab ? localLoading : isRegistryTab ? registryLoading : cloudLoading;
const error = isLocalTab ? localError : isRegistryTab ? registryError : cloudError;
const setError = isLocalTab ? setLocalError : isRegistryTab ? setRegistryError : setCloudError;
const selectedCategory = isLocalTab ? selectedLocalCategory : '';
const selectedTag = isLocalTab ? selectedLocalTag : '';
const currentPage = isLocalTab ? localCurrentPage : cloudCurrentPage;
const totalPages = isLocalTab ? localTotalPages : cloudTotalPages;
const serversPerPage = isLocalTab ? localServersPerPage : cloudServersPerPage;
const currentPage = isLocalTab
? localCurrentPage
: isRegistryTab
? registryCurrentPage
: cloudCurrentPage;
const totalPages = isLocalTab
? localTotalPages
: isRegistryTab
? registryTotalPages
: cloudTotalPages;
const serversPerPage = isLocalTab
? localServersPerPage
: isRegistryTab
? registryServersPerPage
: cloudServersPerPage;
return (
<div>
@@ -281,13 +426,15 @@ const MarketPage: React.FC = () => {
<nav className="-mb-px flex space-x-3">
<button
onClick={() => switchTab('cloud')}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${!isLocalTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
!isLocalTab && !isRegistryTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('cloud.title')}
<span className="text-xs text-gray-400 font-normal ml-1">(
<span className="text-xs text-gray-400 font-normal ml-1">
(
<a
href="https://mcprouter.co"
target="_blank"
@@ -301,13 +448,15 @@ const MarketPage: React.FC = () => {
</button>
<button
onClick={() => switchTab('local')}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${isLocalTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
isLocalTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('market.title')}
<span className="text-xs text-gray-400 font-normal ml-1">(
<span className="text-xs text-gray-400 font-normal ml-1">
(
<a
href="https://mcpm.sh"
target="_blank"
@@ -319,6 +468,28 @@ const MarketPage: React.FC = () => {
)
</span>
</button>
<button
onClick={() => switchTab('registry')}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
isRegistryTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('registry.title')}
<span className="text-xs text-gray-400 font-normal ml-1">
(
<a
href="https://registry.modelcontextprotocol.io"
target="_blank"
rel="noopener noreferrer"
className="external-link"
>
{t('registry.official')}
</a>
)
</span>
</button>
</nav>
</div>
</div>
@@ -335,8 +506,17 @@ const MarketPage: React.FC = () => {
onClick={() => setError(null)}
className="text-red-700 hover:text-red-900 transition-colors duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
@@ -345,16 +525,24 @@ const MarketPage: React.FC = () => {
</>
)}
{/* Search bar for local market only */}
{isLocalTab && (
{/* Search bar for local market and registry */}
{(isLocalTab || isRegistryTab) && (
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
<div className="flex-grow">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('market.searchPlaceholder')}
value={isRegistryTab ? registrySearchQuery : searchQuery}
onChange={(e) => {
if (isRegistryTab) {
setRegistrySearchQuery(e.target.value);
} else {
setSearchQuery(e.target.value);
}
}}
placeholder={
isRegistryTab ? t('registry.searchPlaceholder') : t('market.searchPlaceholder')
}
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
/>
</div>
@@ -362,15 +550,16 @@ const MarketPage: React.FC = () => {
type="submit"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
{t('market.search')}
{isRegistryTab ? t('registry.search') : t('market.search')}
</button>
{(searchQuery || selectedCategory || selectedTag) && (
{((isLocalTab && (searchQuery || selectedCategory || selectedTag)) ||
(isRegistryTab && registrySearchQuery)) && (
<button
type="button"
onClick={handleClearFilters}
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
>
{t('market.clearFilters')}
{isRegistryTab ? t('registry.clearFilters') : t('market.clearFilters')}
</button>
)}
</form>
@@ -388,7 +577,10 @@ const MarketPage: React.FC = () => {
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
{selectedCategory && (
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterLocalByCategory('')}>
<span
className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200"
onClick={() => filterLocalByCategory('')}
>
{t('market.clearCategoryFilter')}
</span>
)}
@@ -398,10 +590,11 @@ const MarketPage: React.FC = () => {
<button
key={category}
onClick={() => handleCategoryClick(category)}
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
}`}
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${
selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
}`}
>
{category}
</button>
@@ -414,9 +607,25 @@ const MarketPage: React.FC = () => {
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
</div>
<div className="flex flex-col gap-2 items-center py-4 loading-container">
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<svg
className="animate-spin h-6 w-6 text-blue-500 mb-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p className="text-sm text-gray-600">{t('app.loading')}</p>
</div>
@@ -438,61 +647,110 @@ const MarketPage: React.FC = () => {
{loading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<svg
className="animate-spin h-10 w-10 text-blue-500 mb-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">{isLocalTab ? t('market.noServers') : t('cloud.noServers')}</p>
<p className="text-gray-600">
{isLocalTab
? t('market.noServers')
: isRegistryTab
? t('registry.noServers')
: t('cloud.noServers')}
</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{servers.map((server, index) => (
{servers.map((server, index) =>
isLocalTab ? (
<MarketServerCard
key={index}
server={server as MarketServer}
onClick={handleServerClick}
/>
) : isRegistryTab ? (
<RegistryServerCard
key={index}
serverEntry={server as RegistryServerEntry}
onClick={handleServerClick}
/>
) : (
<CloudServerCard
key={index}
server={server as CloudServer}
onClick={handleServerClick}
/>
)
))}
),
)}
</div>
<div className="flex justify-between items-center mb-4">
<div className="text-sm text-gray-500">
{isLocalTab ? (
t('market.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length
})
<div className="flex items-center mb-4">
<div className="flex-[2] text-sm text-gray-500">
{isLocalTab
? t('market.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length,
})
: isRegistryTab
? t('registry.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: (currentPage - 1) * serversPerPage + servers.length,
total: allServers.length + (registryHasNextPage ? '+' : ''),
})
: t('cloud.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length,
})}
</div>
<div className="flex-[4] flex justify-center">
{isRegistryTab ? (
<CursorPagination
currentPage={currentPage}
hasNextPage={registryHasNextPage}
hasPreviousPage={registryHasPreviousPage}
onNextPage={goToRegistryNextPage}
onPreviousPage={goToRegistryPreviousPage}
/>
) : (
t('cloud.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length
})
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
)}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
<div className="flex items-center space-x-2">
<div className="flex-[2] flex items-center justify-end space-x-2">
<label htmlFor="perPage" className="text-sm text-gray-600">
{isLocalTab ? t('market.perPage') : t('cloud.perPage')}:
{isLocalTab
? t('market.perPage')
: isRegistryTab
? t('registry.perPage')
: t('cloud.perPage')}
:
</label>
<select
id="perPage"
@@ -507,9 +765,6 @@ const MarketPage: React.FC = () => {
</select>
</div>
</div>
<div className="mt-6">
</div>
</>
)}
</div>

View File

@@ -309,3 +309,148 @@ export interface AuthResponse {
user?: IUser;
message?: string;
}
// Official Registry types (from registry.modelcontextprotocol.io)
export interface RegistryVariable {
choices?: string[];
default?: string;
description?: string;
format?: string;
isRequired?: boolean;
isSecret?: boolean;
value?: string;
}
export interface RegistryVariables {
[key: string]: RegistryVariable;
}
export interface RegistryEnvironmentVariable {
choices?: string[];
default?: string;
description?: string;
format?: string;
isRequired?: boolean;
isSecret?: boolean;
name: string;
value?: string;
variables?: RegistryVariables;
}
export interface RegistryPackageArgument {
choices?: string[];
default?: string;
description?: string;
format?: string;
isRepeated?: boolean;
isRequired?: boolean;
isSecret?: boolean;
name: string;
type?: string;
value?: string;
valueHint?: string;
variables?: RegistryVariables;
}
export interface RegistryTransportHeader {
choices?: string[];
default?: string;
description?: string;
format?: string;
isRequired?: boolean;
isSecret?: boolean;
name: string;
value?: string;
variables?: RegistryVariables;
}
export interface RegistryTransport {
headers?: RegistryTransportHeader[];
type: string;
url?: string;
}
export interface RegistryPackage {
environmentVariables?: RegistryEnvironmentVariable[];
fileSha256?: string;
identifier: string;
packageArguments?: RegistryPackageArgument[];
registryBaseUrl?: string;
registryType: string;
runtimeArguments?: RegistryPackageArgument[];
runtimeHint?: string;
transport?: RegistryTransport;
version?: string;
}
export interface RegistryRemote {
headers?: RegistryTransportHeader[];
type: string;
url: string;
}
export interface RegistryRepository {
id?: string;
source?: string;
subfolder?: string;
url?: string;
}
export interface RegistryIcon {
mimeType: string;
sizes?: string[];
src: string;
theme?: string;
}
export interface RegistryServerData {
$schema?: string;
_meta?: {
'io.modelcontextprotocol.registry/publisher-provided'?: Record<string, any>;
};
description: string;
icons?: RegistryIcon[];
name: string;
packages?: RegistryPackage[];
remotes?: RegistryRemote[];
repository?: RegistryRepository;
title: string;
version: string;
websiteUrl?: string;
}
export interface RegistryOfficialMeta {
isLatest?: boolean;
publishedAt?: string;
status?: string;
updatedAt?: string;
}
export interface RegistryServerEntry {
_meta?: {
'io.modelcontextprotocol.registry/official'?: RegistryOfficialMeta;
};
server: RegistryServerData;
}
export interface RegistryMetadata {
count: number;
nextCursor?: string;
}
export interface RegistryServersResponse {
metadata: RegistryMetadata;
servers: RegistryServerEntry[];
}
export interface RegistryServerVersionsResponse {
metadata: RegistryMetadata;
servers: RegistryServerEntry[];
}
export interface RegistryServerVersionResponse {
_meta?: {
'io.modelcontextprotocol.registry/official'?: RegistryOfficialMeta;
};
server: RegistryServerData;
}

View File

@@ -211,7 +211,15 @@
"dismiss": "Dismiss",
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord"
"discord": "Discord",
"required": "Required",
"secret": "Secret",
"default": "Default",
"value": "Value",
"type": "Type",
"repeated": "Repeated",
"valueHint": "Value Hint",
"choices": "Choices"
},
"nav": {
"dashboard": "Dashboard",
@@ -401,6 +409,41 @@
"installSuccess": "Server {{name}} installed successfully",
"installError": "Failed to install server: {{error}}"
},
"registry": {
"title": "Registry",
"official": "Official",
"latest": "Latest",
"description": "Description",
"website": "Website",
"repository": "Repository",
"packages": "Packages",
"package": "package",
"remotes": "Remotes",
"remote": "remote",
"published": "Published",
"updated": "Updated",
"install": "Install",
"installing": "Installing...",
"installed": "Installed",
"installServer": "Install {{name}}",
"installSuccess": "Server {{name}} installed successfully",
"installError": "Failed to install server: {{error}}",
"noDescription": "No description available",
"viewDetails": "View Details",
"backToList": "Back to Registry",
"search": "Search",
"searchPlaceholder": "Search registry servers by name",
"clearFilters": "Clear",
"noServers": "No registry servers found",
"fetchError": "Error fetching registry servers",
"serverNotFound": "Registry server not found",
"showing": "Showing {{from}}-{{to}} of {{total}} registry servers",
"perPage": "Per page",
"environmentVariables": "Environment Variables",
"packageArguments": "Package Arguments",
"runtimeArguments": "Runtime Arguments",
"headers": "Headers"
},
"tool": {
"run": "Run",
"running": "Running...",

View File

@@ -211,7 +211,15 @@
"dismiss": "Rejeter",
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord"
"discord": "Discord",
"required": "Requis",
"secret": "Secret",
"default": "Défaut",
"value": "Valeur",
"type": "Type",
"repeated": "Répété",
"valueHint": "Indice de valeur",
"choices": "Choix"
},
"nav": {
"dashboard": "Tableau de bord",
@@ -401,6 +409,41 @@
"installSuccess": "Serveur {{name}} installé avec succès",
"installError": "Échec de l'installation du serveur : {{error}}"
},
"registry": {
"title": "Registre",
"official": "Officiel",
"latest": "Dernière version",
"description": "Description",
"website": "Site web",
"repository": "Dépôt",
"packages": "Paquets",
"package": "paquet",
"remotes": "Services distants",
"remote": "service distant",
"published": "Publié",
"updated": "Mis à jour",
"install": "Installer",
"installing": "Installation...",
"installed": "Installé",
"installServer": "Installer {{name}}",
"installSuccess": "Serveur {{name}} installé avec succès",
"installError": "Échec de l'installation du serveur : {{error}}",
"noDescription": "Aucune description disponible",
"viewDetails": "Voir les détails",
"backToList": "Retour au registre",
"search": "Rechercher",
"searchPlaceholder": "Rechercher des serveurs par nom",
"clearFilters": "Effacer",
"noServers": "Aucun serveur trouvé dans le registre",
"fetchError": "Erreur lors de la récupération des serveurs du registre",
"serverNotFound": "Serveur du registre non trouvé",
"showing": "Affichage de {{from}}-{{to}} sur {{total}} serveurs du registre",
"perPage": "Par page",
"environmentVariables": "Variables d'environnement",
"packageArguments": "Arguments du paquet",
"runtimeArguments": "Arguments d'exécution",
"headers": "En-têtes"
},
"tool": {
"run": "Exécuter",
"running": "Exécution en cours...",

View File

@@ -212,7 +212,15 @@
"dismiss": "忽略",
"github": "GitHub",
"wechat": "微信",
"discord": "Discord"
"discord": "Discord",
"required": "必填",
"secret": "敏感",
"default": "默认值",
"value": "值",
"type": "类型",
"repeated": "可重复",
"valueHint": "值提示",
"choices": "可选值"
},
"nav": {
"dashboard": "仪表盘",
@@ -402,6 +410,41 @@
"installSuccess": "服务器 {{name}} 安装成功",
"installError": "安装服务器失败:{{error}}"
},
"registry": {
"title": "注册中心",
"official": "官方",
"latest": "最新版本",
"description": "描述",
"website": "网站",
"repository": "代码仓库",
"packages": "安装包",
"package": "安装包",
"remotes": "远程服务",
"remote": "远程服务",
"published": "发布时间",
"updated": "更新时间",
"install": "安装",
"installing": "安装中...",
"installed": "已安装",
"installServer": "安装 {{name}}",
"installSuccess": "服务器 {{name}} 安装成功",
"installError": "安装服务器失败:{{error}}",
"noDescription": "无描述信息",
"viewDetails": "查看详情",
"backToList": "返回注册中心",
"search": "搜索",
"searchPlaceholder": "按名称搜索注册中心服务器",
"clearFilters": "清除",
"noServers": "未找到注册中心服务器",
"fetchError": "获取注册中心服务器失败",
"serverNotFound": "未找到注册中心服务器",
"showing": "显示 {{from}}-{{to}}/{{total}} 个注册中心服务器",
"perPage": "每页显示",
"environmentVariables": "环境变量",
"packageArguments": "安装包参数",
"runtimeArguments": "运行时参数",
"headers": "请求头"
},
"tool": {
"run": "运行",
"running": "运行中...",

53
pnpm-lock.yaml generated
View File

@@ -693,92 +693,78 @@ packages:
resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.0':
resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.0':
resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.0':
resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.0':
resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.0':
resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.0':
resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.3':
resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.3':
resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.3':
resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.3':
resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.3':
resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.3':
resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.3':
resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.3':
resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==}
@@ -970,28 +956,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.5.2':
resolution: {integrity: sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.5.2':
resolution: {integrity: sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.5.2':
resolution: {integrity: sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.5.2':
resolution: {integrity: sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==}
@@ -1209,67 +1191,56 @@ packages:
resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.50.1':
resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.50.1':
resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.50.1':
resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.50.1':
resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.50.1':
resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.50.1':
resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.50.1':
resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.50.1':
resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.50.1':
resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.50.1':
resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.50.1':
resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==}
@@ -1330,28 +1301,24 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.13.5':
resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-x64-gnu@1.13.5':
resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.13.5':
resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.13.5':
resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==}
@@ -1471,56 +1438,48 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.14':
resolution: {integrity: sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.12':
resolution: {integrity: sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-arm64-musl@4.1.14':
resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.12':
resolution: {integrity: sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-gnu@4.1.14':
resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.12':
resolution: {integrity: sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-musl@4.1.14':
resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.12':
resolution: {integrity: sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==}
@@ -1857,49 +1816,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -3246,28 +3197,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}

View File

@@ -0,0 +1,169 @@
import { Request, Response } from 'express';
import { ApiResponse } from '../types/index.js';
const REGISTRY_BASE_URL = 'https://registry.modelcontextprotocol.io/v0.1';
/**
* Get all MCP servers from the official registry
* Proxies the request to avoid CORS issues in the frontend
* Supports cursor-based pagination
*/
export const getAllRegistryServers = async (req: Request, res: Response): Promise<void> => {
try {
const { cursor, limit, search } = req.query;
// Build URL with query parameters
const url = new URL(`${REGISTRY_BASE_URL}/servers`);
if (cursor && typeof cursor === 'string') {
url.searchParams.append('cursor', cursor);
}
if (limit && typeof limit === 'string') {
const limitNum = parseInt(limit, 10);
if (!isNaN(limitNum) && limitNum > 0) {
url.searchParams.append('limit', limit);
}
}
if (search && typeof search === 'string') {
url.searchParams.append('search', search);
}
const response = await fetch(url.toString(), {
headers: {
Accept: 'application/json, application/problem+json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const apiResponse: ApiResponse<typeof data> = {
success: true,
data: data,
};
res.json(apiResponse);
} catch (error) {
console.error('Error fetching registry servers:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to fetch registry servers';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
/**
* Get all versions of a specific MCP server
* Proxies the request to avoid CORS issues in the frontend
*/
export const getRegistryServerVersions = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName } = req.params;
if (!serverName) {
res.status(400).json({
success: false,
message: 'Server name is required',
});
return;
}
// URL encode the server name
const encodedName = encodeURIComponent(serverName);
const response = await fetch(`${REGISTRY_BASE_URL}/servers/${encodedName}/versions`, {
headers: {
Accept: 'application/json, application/problem+json',
},
});
if (!response.ok) {
if (response.status === 404) {
res.status(404).json({
success: false,
message: 'Server not found',
});
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const apiResponse: ApiResponse<typeof data> = {
success: true,
data: data,
};
res.json(apiResponse);
} catch (error) {
console.error('Error fetching registry server versions:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to fetch registry server versions';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
/**
* Get a specific version of an MCP server
* Proxies the request to avoid CORS issues in the frontend
*/
export const getRegistryServerVersion = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, version } = req.params;
if (!serverName || !version) {
res.status(400).json({
success: false,
message: 'Server name and version are required',
});
return;
}
// URL encode the server name and version
const encodedName = encodeURIComponent(serverName);
const encodedVersion = encodeURIComponent(version);
const response = await fetch(
`${REGISTRY_BASE_URL}/servers/${encodedName}/versions/${encodedVersion}`,
{
headers: {
Accept: 'application/json, application/problem+json',
},
},
);
if (!response.ok) {
if (response.status === 404) {
res.status(404).json({
success: false,
message: 'Server version not found',
});
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const apiResponse: ApiResponse<typeof data> = {
success: true,
data: data,
};
res.json(apiResponse);
} catch (error) {
console.error('Error fetching registry server version:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to fetch registry server version';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};

View File

@@ -56,9 +56,18 @@ import {
getCloudServerToolsList,
callCloudTool,
} from '../controllers/cloudController.js';
import {
getAllRegistryServers,
getRegistryServerVersions,
getRegistryServerVersion,
} from '../controllers/registryController.js';
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
import { getRuntimeConfig, getPublicConfig, getMcpSettingsJson } from '../controllers/configController.js';
import {
getRuntimeConfig,
getPublicConfig,
getMcpSettingsJson,
} from '../controllers/configController.js';
import { callTool } from '../controllers/toolController.js';
import { getPrompt } from '../controllers/promptController.js';
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
@@ -144,6 +153,11 @@ export const initRoutes = (app: express.Application): void => {
router.get('/cloud/servers/:serverName/tools', getCloudServerToolsList);
router.post('/cloud/servers/:serverName/tools/:toolName/call', callCloudTool);
// Registry routes (proxy to official MCP registry)
router.get('/registry/servers', getAllRegistryServers);
router.get('/registry/servers/:serverName/versions', getRegistryServerVersions);
router.get('/registry/servers/:serverName/versions/:version', getRegistryServerVersion);
// Log routes
router.get('/logs', getAllLogs);
router.delete('/logs', clearLogs);