From 86367a4875c6a665bc60602ba6f1ac0c5e993872 Mon Sep 17 00:00:00 2001 From: samanhappy Date: Sun, 19 Oct 2025 21:15:25 +0800 Subject: [PATCH] feat: integrate offcial mcp server registry (#374) --- .prettierrc | 2 +- frontend/src/components/EditServerForm.tsx | 57 +- .../src/components/RegistryServerCard.tsx | 205 +++++ .../src/components/RegistryServerDetail.tsx | 698 ++++++++++++++++++ frontend/src/components/ServerForm.tsx | 679 ++++++++++------- .../src/components/ui/CursorPagination.tsx | 78 ++ frontend/src/contexts/ServerContext.tsx | 240 +++--- frontend/src/hooks/useRegistryData.ts | 283 +++++++ frontend/src/pages/MarketPage.tsx | 425 ++++++++--- frontend/src/types/index.ts | 145 ++++ locales/en.json | 45 +- locales/fr.json | 45 +- locales/zh.json | 45 +- pnpm-lock.yaml | 53 -- src/controllers/registryController.ts | 169 +++++ src/routes/index.ts | 16 +- 16 files changed, 2651 insertions(+), 534 deletions(-) create mode 100644 frontend/src/components/RegistryServerCard.tsx create mode 100644 frontend/src/components/RegistryServerDetail.tsx create mode 100644 frontend/src/components/ui/CursorPagination.tsx create mode 100644 frontend/src/hooks/useRegistryData.ts create mode 100644 src/controllers/registryController.ts diff --git a/.prettierrc b/.prettierrc index 86396de..3ee282f 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "semi": false, + "semi": true, "trailingComma": "all", "singleQuote": true, "printWidth": 100, diff --git a/frontend/src/components/EditServerForm.tsx b/frontend/src/components/EditServerForm.tsx index 47bf39b..bb46dc5 100644 --- a/frontend/src/components/EditServerForm.tsx +++ b/frontend/src/components/EditServerForm.tsx @@ -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(null) + const { t } = useTranslation(); + const [error, setError] = useState(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 (
@@ -57,7 +58,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => { formError={error} />
- ) -} + ); +}; -export default EditServerForm \ No newline at end of file +export default EditServerForm; diff --git a/frontend/src/components/RegistryServerCard.tsx b/frontend/src/components/RegistryServerCard.tsx new file mode 100644 index 0000000..49974b7 --- /dev/null +++ b/frontend/src/components/RegistryServerCard.tsx @@ -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 = ({ 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 ( +
+ {/* Background gradient overlay on hover */} +
+ + {/* Server Header */} +
+
+
+ {/* Icon */} + {icon ? ( + {server.title} { + e.currentTarget.style.display = 'none'; + }} + /> + ) : ( +
+ M +
+ )} + + {/* Title and badges */} +
+

+ {server.name} +

+
+ {isLatest && ( + + {t('registry.latest')} + + )} + + v{server.version} + +
+
+
+
+ + {/* Server Name */} + {/*
+

{server.name}

+
*/} + + {/* Description */} +
+

+ {getDisplayDescription()} +

+
+ + {/* Installation Options Info */} + {totalOptions > 0 && ( +
+
+ {packageCount > 0 && ( +
+ + + + + {packageCount}{' '} + {packageCount === 1 ? t('registry.package') : t('registry.packages')} + +
+ )} + {remoteCount > 0 && ( +
+ + + + + {remoteCount} {remoteCount === 1 ? t('registry.remote') : t('registry.remotes')} + +
+ )} +
+
+ )} + + {/* Footer - fixed at bottom */} +
+
+ {(publishedAt || updatedAt) && ( + <> + + + + {formatDate(updatedAt || publishedAt)} + + )} +
+ +
+ {t('registry.viewDetails')} + + + +
+
+
+
+ ); +}; + +export default RegistryServerCard; diff --git a/frontend/src/components/RegistryServerDetail.tsx b/frontend/src/components/RegistryServerDetail.tsx new file mode 100644 index 0000000..b67131a --- /dev/null +++ b/frontend/src/components/RegistryServerDetail.tsx @@ -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; +} + +const RegistryServerDetail: React.FC = ({ + serverEntry, + onBack, + onInstall, + installing = false, + isInstalled = false, + fetchVersions, +}) => { + const { t } = useTranslation(); + const { server, _meta } = serverEntry; + + const [_selectedVersion, _setSelectedVersion] = useState(server.version); + const [_availableVersions, setAvailableVersions] = useState([]); + const [_loadingVersions, setLoadingVersions] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + const [selectedInstallType, setSelectedInstallType] = useState<'package' | 'remote' | null>(null); + const [selectedOption, setSelectedOption] = useState( + null, + ); + const [installError, setInstallError] = useState(null); + const [expandedSections, setExpandedSections] = useState>({ + 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 = {}; + 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 = {}; + 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 ( +
+
+
+

{pkg.identifier}

+ {pkg.version &&

Version: {pkg.version}

} + {pkg.runtimeHint &&

{pkg.runtimeHint}

} +
+ +
+ + {/* Package details */} + {pkg.registryType && ( +
+ Registry: {pkg.registryType} +
+ )} + + {/* Transport type */} + {pkg.transport && ( +
+ Transport: {pkg.transport.type} + {pkg.transport.url && ({pkg.transport.url})} +
+ )} + + {/* Environment Variables */} + {pkg.environmentVariables && pkg.environmentVariables.length > 0 && ( +
+
+ {t('registry.environmentVariables')}: +
+
+ {pkg.environmentVariables.map((envVar, envIndex) => ( +
+
+ {envVar.name} + {envVar.isRequired && ( + + {t('common.required')} + + )} + {envVar.isSecret && ( + + {t('common.secret')} + + )} +
+ {envVar.description &&

{envVar.description}

} + {envVar.default && ( +

+ {t('common.default')}:{' '} + {envVar.default} +

+ )} +
+ ))} +
+
+ )} + + {/* Package Arguments */} + {pkg.packageArguments && pkg.packageArguments.length > 0 && ( +
+
+ {t('registry.packageArguments')}: +
+
+ {pkg.packageArguments.map((arg, argIndex) => ( +
+
+ {arg.name} + {arg.isRequired && ( + + {t('common.required')} + + )} + {arg.isSecret && ( + + {t('common.secret')} + + )} + {arg.isRepeated && ( + + {t('common.repeated')} + + )} +
+ {arg.description &&

{arg.description}

} + {arg.type && ( +

+ {t('common.type')}:{' '} + {arg.type} +

+ )} + {arg.default && ( +

+ {t('common.default')}:{' '} + {arg.default} +

+ )} + {arg.value && ( +

+ {t('common.value')}:{' '} + {arg.value} +

+ )} + {arg.valueHint && ( +

+ {t('common.valueHint')}:{' '} + {arg.valueHint} +

+ )} + {arg.choices && arg.choices.length > 0 && ( +

+ {t('common.choices')}:{' '} + {arg.choices.join(', ')} +

+ )} +
+ ))} +
+
+ )} +
+ ); + }; + + // Render remote option + const renderRemote = (remote: RegistryRemote, index: number) => { + return ( +
+
+
+

{remote.type}

+

{remote.url}

+
+ +
+ + {/* Headers */} + {remote.headers && remote.headers.length > 0 && ( +
+
{t('registry.headers')}:
+
+ {remote.headers.map((header, headerIndex) => ( +
+
+ {header.name} + {header.isRequired && ( + + {t('common.required')} + + )} + {header.isSecret && ( + + {t('common.secret')} + + )} +
+ {header.description &&

{header.description}

} + {header.value && ( +

+ {t('common.value')}:{' '} + {header.value} +

+ )} + {header.default && ( +

+ {t('common.default')}:{' '} + {header.default} +

+ )} +
+ ))} +
+
+ )} +
+ ); + }; + + return ( +
+ {/* Header */} +
+ + +
+ {/* Icon */} + {icon ? ( + {server.title} { + e.currentTarget.style.display = 'none'; + }} + /> + ) : ( +
+ M +
+ )} + + {/* Title and metadata */} +
+

{server.name}

+ +
+ {officialMeta?.isLatest && ( + + {t('registry.latest')} + + )} + + v{server.version} + + {officialMeta?.status && ( + + {officialMeta.status} + + )} + {/* Dates */} + + {officialMeta?.publishedAt && ( +
+ {t('registry.published')}:{' '} + {formatDate(officialMeta.publishedAt)} +
+ )} + {officialMeta?.updatedAt && ( +
+ {t('registry.updated')}:{' '} + {formatDate(officialMeta.updatedAt)} +
+ )} +
+
+
+
+
+ + {/* Description */} +
+

{t('registry.description')}

+

{server.description}

+
+ + {/* Website */} + {server.websiteUrl && ( +
+

{t('registry.website')}

+ + {server.websiteUrl} + +
+ )} + + {/* Packages */} + {server.packages && server.packages.length > 0 && ( +
+ + {expandedSections.packages && ( +
{server.packages.map(renderPackage)}
+ )} +
+ )} + + {/* Remotes */} + {server.remotes && server.remotes.length > 0 && ( +
+ + {expandedSections.remotes && ( +
{server.remotes.map(renderRemote)}
+ )} +
+ )} + + {/* Repository */} + {server.repository && ( +
+ + {expandedSections.repository && ( +
+ {server.repository.url && ( + + )} + {server.repository.source && ( +
+ Source:{' '} + {server.repository.source} +
+ )} + {server.repository.subfolder && ( +
+ Subfolder:{' '} + {server.repository.subfolder} +
+ )} + {server.repository.id && ( +
+ ID: {server.repository.id} +
+ )} +
+ )} +
+ )} + + {/* Install Modal */} + {modalVisible && selectedOption && selectedInstallType && ( +
+ +
+ )} +
+ ); +}; + +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]; + } +} diff --git a/frontend/src/components/ServerForm.tsx b/frontend/src/components/ServerForm.tsx index 7831a51..83ae79e 100644 --- a/frontend/src/components/ServerForm.tsx +++ b/frontend/src/components/ServerForm.tsx @@ -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({ 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( initialData && initialData.config && initialData.config.env ? Object.entries(initialData.config.env).map(([key, value]) => ({ key, value })) : [], - ) + ); const [headerVars, setHeaderVars] = useState( initialData && initialData.config && initialData.config.headers ? Object.entries(initialData.config.headers).map(([key, value]) => ({ key, value })) : [], - ) + ); - const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState(false) - const [error, setError] = useState(null) - const isEdit = !!initialData + const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState(false); + const [error, setError] = useState(null); + const isEdit = !!initialData; const handleInputChange = (e: React.ChangeEvent) => { - 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 = {} + const env: Record = {}; envVars.forEach(({ key, value }) => { if (key.trim()) { - env[key.trim()] = value + env[key.trim()] = value; } - }) + }); - const headers: Record = {} + const headers: Record = {}; 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 (
@@ -281,9 +330,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{(error || formError) && ( -
- {formError || error} -
+
{formError || error}
)}
@@ -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" /> @@ -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" /> @@ -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' && (
-