Compare commits

...

3 Commits

Author SHA1 Message Date
samanhappy
40af398f68 chore: update @modelcontextprotocol/sdk to version 1.12.1 (#176) 2025-06-10 16:50:28 +08:00
samanhappy
4726f00a22 feat: add request options configuration to server form (#171) 2025-06-10 13:51:01 +08:00
samanhappy
77f64b7b98 feat: enhance DynamicForm to support array and object (#173)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-06-10 13:50:14 +08:00
11 changed files with 582 additions and 88 deletions

View File

@@ -41,7 +41,12 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
args: (initialData && initialData.config && initialData.config.args) || [],
type: getInitialServerType(), // Initialize the type field
env: [],
headers: []
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,
}
})
const [envVars, setEnvVars] = useState<EnvVar[]>(
@@ -56,6 +61,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
: [],
)
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const isEdit = !!initialData
@@ -66,7 +72,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
// Transform space-separated arguments string into array
const handleArgsChange = (value: string) => {
let args = value.split(' ').filter((arg) => arg.trim() !== '')
const args = value.split(' ').filter((arg) => arg.trim() !== '')
setFormData({ ...formData, arguments: value, args })
}
@@ -107,6 +113,17 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
setHeaderVars(newHeaderVars)
}
// Handle options changes
const handleOptionsChange = (field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout', value: number | boolean | undefined) => {
setFormData(prev => ({
...prev,
options: {
...prev.options,
[field]: value
}
}))
}
// Submit handler for server configuration
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -127,6 +144,18 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
}
})
// Prepare options object, only include defined values
const options: any = {}
if (formData.options?.timeout && formData.options.timeout !== 60000) {
options.timeout = formData.options.timeout
}
if (formData.options?.resetTimeoutOnProgress) {
options.resetTimeoutOnProgress = formData.options.resetTimeoutOnProgress
}
if (formData.options?.maxTotalTimeout) {
options.maxTotalTimeout = formData.options.maxTotalTimeout
}
const payload = {
name: formData.name,
config: {
@@ -141,7 +170,8 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
}
)
),
...(Object.keys(options).length > 0 ? { options } : {})
}
}
@@ -365,6 +395,75 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</>
)}
{/* Request Options Configuration */}
<div className="mb-4">
<div
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border"
onClick={() => setIsRequestOptionsExpanded(!isRequestOptionsExpanded)}
>
<label className="text-gray-700 text-sm font-bold">
{t('server.requestOptions')}
</label>
<span className="text-gray-500 text-sm">
{isRequestOptionsExpanded ? '▼' : '▶'}
</span>
</div>
{isRequestOptionsExpanded && (
<div className="border 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">
{t('server.timeout')}
</label>
<input
type="number"
id="timeout"
value={formData.options?.timeout || 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"
placeholder="30000"
min="1000"
max="300000"
/>
<p className="text-xs text-gray-500 mt-1">{t('server.timeoutDescription')}</p>
</div>
<div>
<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)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
placeholder="Optional"
min="1000"
/>
<p className="text-xs text-gray-500 mt-1">{t('server.maxTotalTimeoutDescription')}</p>
</div>
</div>
<div className="mt-3">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.options?.resetTimeoutOnProgress || false}
onChange={(e) => handleOptionsChange('resetTimeoutOnProgress', e.target.checked)}
className="mr-2"
/>
<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')}
</p>
</div>
</div>
)}
</div>
<div className="flex justify-end mt-6">
<button
type="button"

View File

@@ -24,6 +24,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
const { t } = useTranslation();
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isJsonMode, setIsJsonMode] = useState<boolean>(false);
const [jsonText, setJsonText] = useState<string>('');
const [jsonError, setJsonError] = useState<string>('');
// Convert ToolInputSchema to JsonSchema - memoized to prevent infinite re-renders
const jsonSchema = useMemo(() => {
@@ -77,7 +80,13 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
} else if (propSchema.type === 'array') {
values[key] = [];
} else if (propSchema.type === 'object') {
values[key] = initializeValues(propSchema, fullPath);
// For objects with properties, recursively initialize
if (propSchema.properties) {
values[key] = initializeValues(propSchema, fullPath);
} else {
// For objects without properties, initialize as empty object
values[key] = {};
}
}
});
}
@@ -104,6 +113,58 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
setFormValues(initialValues);
}, [jsonSchema, storageKey]);
// Sync JSON text with form values when switching modes
useEffect(() => {
if (isJsonMode && Object.keys(formValues).length > 0) {
setJsonText(JSON.stringify(formValues, null, 2));
setJsonError('');
}
}, [isJsonMode, formValues]);
const handleJsonTextChange = (text: string) => {
setJsonText(text);
setJsonError('');
try {
const parsedJson = JSON.parse(text);
setFormValues(parsedJson);
// Save to localStorage if storageKey is provided
if (storageKey) {
try {
localStorage.setItem(storageKey, JSON.stringify(parsedJson));
} catch (error) {
console.warn('Failed to save form data to localStorage:', error);
}
}
} catch (error) {
setJsonError(t('tool.invalidJsonFormat'));
}
};
const switchToJsonMode = () => {
setJsonText(JSON.stringify(formValues, null, 2));
setJsonError('');
setIsJsonMode(true);
};
const switchToFormMode = () => {
// Validate JSON before switching
if (jsonText.trim()) {
try {
const parsedJson = JSON.parse(jsonText);
setFormValues(parsedJson);
setJsonError('');
setIsJsonMode(false);
} catch (error) {
setJsonError(t('tool.fixJsonBeforeSwitching'));
return;
}
} else {
setIsJsonMode(false);
}
};
const handleInputChange = (path: string, value: any) => {
setFormValues(prev => {
const newValues = { ...prev };
@@ -140,7 +201,6 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
});
}
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
@@ -148,7 +208,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
if (schema.type === 'object' && schema.properties) {
Object.entries(schema.properties).forEach(([key, propSchema]) => {
const fullPath = path ? `${path}.${key}` : key;
const value = values?.[key];
const value = getNestedValue(values, fullPath);
// Check required fields
if (schema.required?.includes(key) && (value === undefined || value === null || value === '')) {
@@ -166,6 +226,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newErrors[fullPath] = `${key} must be an integer`;
} else if (propSchema.type === 'boolean' && typeof value !== 'boolean') {
newErrors[fullPath] = `${key} must be a boolean`;
} else if (propSchema.type === 'array' && Array.isArray(value)) {
// Validate array items
if (propSchema.items) {
value.forEach((item: any, index: number) => {
if (propSchema.items?.type === 'object' && propSchema.items.properties) {
validateObject(propSchema.items, item, `${fullPath}.${index}`);
}
});
}
} else if (propSchema.type === 'object' && typeof value === 'object') {
validateObject(propSchema, value, fullPath);
}
@@ -186,18 +255,240 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
}
};
const getNestedValue = (obj: any, path: string): any => {
return path.split('.').reduce((current, key) => current?.[key], obj);
};
const renderObjectField = (key: string, schema: JsonSchema, currentValue: any, onChange: (value: any) => void): React.ReactNode => {
const value = currentValue?.[key];
if (schema.type === 'string') {
if (schema.enum) {
return (
<select
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">{t('tool.selectOption')}</option>
{schema.enum.map((option: any, idx: number) => (
<option key={idx} value={option}>
{option}
</option>
))}
</select>
);
} else {
return (
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
}
}
if (schema.type === 'number' || schema.type === 'integer') {
return (
<input
type="number"
step={schema.type === 'integer' ? '1' : 'any'}
value={value || ''}
onChange={(e) => {
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
onChange(val);
}}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
);
}
if (schema.type === 'boolean') {
return (
<input
type="checkbox"
checked={value || false}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
);
}
// Default to text input
return (
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
};
const renderField = (key: string, propSchema: JsonSchema, path: string = ''): React.ReactNode => {
const fullPath = path ? `${path}.${key}` : key;
const value = formValues[key];
const error = errors[fullPath];
const value = getNestedValue(formValues, fullPath);
const error = errors[fullPath]; // Handle array type
if (propSchema.type === 'array') {
const arrayValue = getNestedValue(formValues, fullPath) || [];
if (propSchema.type === 'string') {
return (
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)}
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
{arrayValue.map((item: any, index: number) => (
<div key={index} className="mb-3 p-3 bg-white border rounded-md">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-600">{t('tool.item', { index: index + 1 })}</span>
<button
type="button"
onClick={() => {
const newArray = [...arrayValue];
newArray.splice(index, 1);
handleInputChange(fullPath, newArray);
}}
className="text-red-500 hover:text-red-700 text-sm"
>
{t('common.remove')}
</button>
</div>
{propSchema.items?.type === 'string' && propSchema.items.enum ? (
<select
value={item || ''}
onChange={(e) => {
const newArray = [...arrayValue];
newArray[index] = e.target.value;
handleInputChange(fullPath, newArray);
}}
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{t('tool.selectOption')}</option>
{propSchema.items.enum.map((option: any, idx: number) => (
<option key={idx} value={option}>
{option}
</option>
))}
</select>
) : propSchema.items?.type === 'object' && propSchema.items.properties ? (
<div className="space-y-3">
{Object.entries(propSchema.items.properties).map(([objKey, objSchema]) => (
<div key={objKey}>
<label className="block text-xs font-medium text-gray-600 mb-1">
{objKey}
{propSchema.items?.required?.includes(objKey) && <span className="text-red-500 ml-1">*</span>}
</label>
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
const newArray = [...arrayValue];
newArray[index] = { ...newArray[index], [objKey]: newValue };
handleInputChange(fullPath, newArray);
})}
</div>
))}
</div>
) : (
<input
type="text"
value={item || ''}
onChange={(e) => {
const newArray = [...arrayValue];
newArray[index] = e.target.value;
handleInputChange(fullPath, newArray);
}}
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('tool.enterValue', { type: propSchema.items?.type || 'value' })}
/>
)}
</div>
))}
<button
type="button"
onClick={() => {
const newItem = propSchema.items?.type === 'object' ? {} : '';
handleInputChange(fullPath, [...arrayValue, newItem]);
}}
className="w-full mt-2 px-3 py-2 text-sm text-blue-600 border border-blue-300 rounded-md hover:bg-blue-50"
>
{t('tool.addItem', { key })}
</button>
</div>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
} // Handle object type
if (propSchema.type === 'object') {
if (propSchema.properties) {
// Object with defined properties - render as nested form
return (
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)}
<div className="border border-gray-200 rounded-md p-4 bg-gray-50">
{Object.entries(propSchema.properties).map(([objKey, objSchema]) => (
renderField(objKey, objSchema as JsonSchema, fullPath)
))}
</div>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
} else {
// Object without defined properties - render as JSON textarea
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)}
<textarea
value={typeof value === 'object' ? JSON.stringify(value, null, 2) : value || '{}'}
onChange={(e) => {
try {
const parsedValue = JSON.parse(e.target.value);
handleInputChange(fullPath, parsedValue);
} catch (err) {
// Keep the string value if it's not valid JSON yet
handleInputChange(fullPath, e.target.value);
}
}}
placeholder={`{\n "key": "value"\n}`}
className={`w-full border rounded-md px-3 py-2 font-mono text-sm ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
rows={4}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
}
} if (propSchema.type === 'string') {
if (propSchema.enum) {
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -222,7 +513,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -237,14 +528,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</div>
);
}
}
if (propSchema.type === 'number' || propSchema.type === 'integer') {
} if (propSchema.type === 'number' || propSchema.type === 'integer') {
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -276,7 +565,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/>
<label className="ml-2 block text-sm text-gray-700">
{key}
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
</label>
</div>
{propSchema.description && (
@@ -285,14 +574,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
}
// For other types, show as text input with description
} // For other types, show as text input with description
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
</label>
{propSchema.description && (
@@ -335,28 +622,101 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
renderField(key, propSchema)
)}
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
>
{t('tool.cancel')}
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>
<div className="space-y-4">
{/* Mode Toggle */}
<div className="flex justify-between items-center border-b pb-3">
<h3 className="text-lg font-medium text-gray-900">{t('tool.parameters')}</h3>
<div className="flex space-x-2">
<button
type="button"
onClick={switchToFormMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{t('tool.formMode')}
</button>
<button
type="button"
onClick={switchToJsonMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{t('tool.jsonMode')}
</button>
</div>
</div>
</form>
{/* JSON Mode */}
{isJsonMode ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('tool.jsonConfiguration')}
</label>
<textarea
value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)}
placeholder={`{\n "key": "value"\n}`}
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y ${jsonError ? 'border-red-500' : 'border-gray-300'
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{jsonError && <p className="text-red-500 text-xs mt-1">{jsonError}</p>}
</div>
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
>
{t('tool.cancel')}
</button>
<button
onClick={() => {
try {
const parsedJson = JSON.parse(jsonText);
onSubmit(parsedJson);
} catch (error) {
setJsonError(t('tool.invalidJsonFormat'));
}
}}
disabled={loading || !!jsonError}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>
</div>
</div>
) : (
/* Form Mode */
<form onSubmit={handleSubmit} className="space-y-4">
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
renderField(key, propSchema)
)}
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
>
{t('tool.cancel')}
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>
</div>
</form>
)}
</div>
);
};

View File

@@ -99,6 +99,13 @@
"enabled": "Enabled",
"enable": "Enable",
"disable": "Disable",
"requestOptions": "Configuration",
"timeout": "Request Timeout",
"timeoutDescription": "Timeout for requests to the MCP server (ms)",
"maxTotalTimeout": "Maximum Total Timeout",
"maxTotalTimeoutDescription": "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
"resetTimeoutOnProgress": "Reset Timeout on Progress",
"resetTimeoutOnProgressDescription": "Reset timeout on progress notifications",
"remove": "Remove",
"toggleError": "Failed to toggle server {{serverName}}",
"alreadyExists": "Server {{serverName}} already exists",
@@ -137,6 +144,7 @@
"create": "Create",
"submitting": "Submitting...",
"delete": "Delete",
"remove": "Remove",
"copy": "Copy",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed",
@@ -288,7 +296,16 @@
"enabled": "Enabled",
"enableSuccess": "Tool {{name}} enabled successfully",
"disableSuccess": "Tool {{name}} disabled successfully",
"toggleFailed": "Failed to toggle tool status"
"toggleFailed": "Failed to toggle tool status",
"parameters": "Tool Parameters",
"formMode": "Form Mode",
"jsonMode": "JSON Mode",
"jsonConfiguration": "JSON Configuration",
"invalidJsonFormat": "Invalid JSON format",
"fixJsonBeforeSwitching": "Please fix JSON format before switching to form mode",
"item": "Item {{index}}",
"addItem": "Add {{key}} item",
"enterKey": "Enter {{key}}"
},
"settings": {
"enableGlobalRoute": "Enable Global Route",

View File

@@ -99,6 +99,13 @@
"enabled": "已启用",
"enable": "启用",
"disable": "禁用",
"requestOptions": "配置",
"timeout": "请求超时",
"timeoutDescription": "请求超时时间(毫秒)",
"maxTotalTimeout": "最大总超时",
"maxTotalTimeoutDescription": "无论是否有进度通知的最大总超时时间(毫秒)",
"resetTimeoutOnProgress": "收到进度通知时重置超时",
"resetTimeoutOnProgressDescription": "适用于发送周期性进度更新的长时间运行操作",
"remove": "移除",
"toggleError": "切换服务器 {{serverName}} 状态失败",
"alreadyExists": "服务器 {{serverName}} 已经存在",
@@ -138,6 +145,7 @@
"create": "创建",
"submitting": "提交中...",
"delete": "删除",
"remove": "移除",
"copy": "复制",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败",
@@ -289,7 +297,16 @@
"enabled": "已启用",
"enableSuccess": "工具 {{name}} 启用成功",
"disableSuccess": "工具 {{name}} 禁用成功",
"toggleFailed": "切换工具状态失败"
"toggleFailed": "切换工具状态失败",
"parameters": "工具参数",
"formMode": "表单模式",
"jsonMode": "JSON 模式",
"jsonConfiguration": "JSON 配置",
"invalidJsonFormat": "无效的 JSON 格式",
"fixJsonBeforeSwitching": "请修复 JSON 格式后再切换到表单模式",
"item": "项目 {{index}}",
"addItem": "添加 {{key}} 项目",
"enterKey": "输入 {{key}}"
},
"settings": {
"enableGlobalRoute": "启用全局路由",

View File

@@ -80,6 +80,11 @@ export interface ServerConfig {
headers?: Record<string, string>;
enabled?: boolean;
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
options?: {
timeout?: number; // Request timeout in milliseconds
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
maxTotalTimeout?: number; // Maximum total timeout in milliseconds
}; // MCP request options configuration
}
// Server types
@@ -116,6 +121,11 @@ export interface ServerFormData {
type?: 'stdio' | 'sse' | 'streamable-http'; // Added type field
env: EnvVar[];
headers: EnvVar[];
options?: {
timeout?: number;
resetTimeoutOnProgress?: boolean;
maxTotalTimeout?: number;
};
}
// Group form data types

View File

@@ -41,7 +41,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.1",
"@modelcontextprotocol/sdk": "^1.12.1",
"@types/pg": "^8.15.2",
"bcryptjs": "^3.0.2",
"dotenv": "^16.3.1",

2
pnpm-lock.yaml generated
View File

@@ -9,7 +9,7 @@ importers:
.:
dependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.11.1
specifier: ^1.12.1
version: 1.12.1
'@types/pg':
specifier: ^8.15.2

View File

@@ -36,7 +36,7 @@ export const loadSettings = (): McpSettings => {
// Update cache
settingsCache = settings;
console.log(`Loaded settings from ${settingsPath}:`, settings);
console.log(`Loaded settings from ${settingsPath}`);
return settings;
} catch (error) {
console.error(`Failed to load settings from ${settingsPath}:`, error);

View File

@@ -1,37 +1,8 @@
import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import fs from 'fs';
import { auth } from './auth.js';
import { initializeDefaultUser } from '../models/User.js';
import config from '../config/index.js';
// Create __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Try to find the correct frontend file path
const findFrontendPath = (): string => {
// First try development environment path
const devPath = path.join(dirname(__dirname), 'frontend', 'dist', 'index.html');
if (fs.existsSync(devPath)) {
return path.join(dirname(__dirname), 'frontend', 'dist');
}
// Try npm/npx installed path (remove /dist directory)
const npmPath = path.join(dirname(dirname(__dirname)), 'frontend', 'dist', 'index.html');
if (fs.existsSync(npmPath)) {
return path.join(dirname(dirname(__dirname)), 'frontend', 'dist');
}
// If none of the above paths exist, return the most reasonable default path and log a warning
console.warn('Warning: Could not locate frontend files. Using default path.');
return path.join(dirname(__dirname), 'frontend', 'dist');
};
const frontendPath = findFrontendPath();
export const errorHandler = (
err: Error,
_req: Request,
@@ -52,6 +23,7 @@ export const initMiddlewares = (app: express.Application): void => {
app.use((req, res, next) => {
const basePath = config.basePath;
// Only apply JSON parsing for API and auth routes, not for SSE or message endpoints
// TODO exclude sse responses by mcp endpoint
if (
req.path !== `${basePath}/sse` &&
!req.path.startsWith(`${basePath}/sse/`) &&

View File

@@ -218,14 +218,27 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
},
},
);
const timeout = isInit ? Number(config.initTimeout) : Number(config.timeout);
const initRequestOptions = isInit
? {
timeout: Number(config.initTimeout) || 60000,
}
: undefined;
// Get request options from server configuration, with fallbacks
const serverRequestOptions = conf.options || {};
const requestOptions = {
timeout: serverRequestOptions.timeout || 60000,
resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false,
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
};
client
.connect(transport, { timeout: timeout })
.connect(transport, initRequestOptions || requestOptions)
.then(() => {
console.log(`Successfully connected client for server: ${name}`);
client
.listTools({}, { timeout: timeout })
.listTools({}, initRequestOptions || requestOptions)
.then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
const serverInfo = getServerByName(name);
@@ -276,6 +289,7 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
tools: [],
client,
transport,
options: requestOptions,
createTime: Date.now(),
});
console.log(`Initialized client for server: ${name}`);
@@ -696,14 +710,12 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
// Special handling for call_tool
if (request.params.name === 'call_tool') {
let { toolName, arguments: toolArgs = {} } = request.params.arguments || {};
let { toolName } = request.params.arguments || {};
if (!toolName) {
throw new Error('toolName parameter is required');
}
// arguments parameter is now optional
const { arguments: toolArgs = {} } = request.params.arguments || {};
let targetServerInfo: ServerInfo | undefined;
if (extra && extra.server) {
targetServerInfo = getServerByName(extra.server);
@@ -744,10 +756,14 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
toolName = toolName.startsWith(`${targetServerInfo.name}-`)
? toolName.replace(`${targetServerInfo.name}-`, '')
: toolName;
const result = await client.callTool({
name: toolName,
arguments: finalArgs,
});
const result = await client.callTool(
{
name: toolName,
arguments: finalArgs,
},
undefined,
targetServerInfo.options || {},
);
console.log(`Tool invocation result: ${JSON.stringify(result)}`);
return result;
@@ -766,7 +782,7 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
request.params.name = request.params.name.startsWith(`${serverInfo.name}-`)
? request.params.name.replace(`${serverInfo.name}-`, '')
: request.params.name;
const result = await client.callTool(request.params);
const result = await client.callTool(request.params, undefined, serverInfo.options || {});
console.log(`Tool call result: ${JSON.stringify(result)}`);
return result;
} catch (error) {

View File

@@ -2,6 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
import { SmartRoutingConfig } from '../utils/smartRouting.js';
// User interface
@@ -107,6 +108,7 @@ export interface ServerConfig {
enabled?: boolean; // Flag to enable/disable the server
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
}
// Information about a server's status and tools
@@ -117,6 +119,7 @@ export interface ServerInfo {
tools: ToolInfo[]; // List of tools available on the server
client?: Client; // Client instance for communication
transport?: SSEClientTransport | StdioClientTransport | StreamableHTTPClientTransport; // Transport mechanism used
options?: RequestOptions; // Options for requests
createTime: number; // Timestamp of when the server was created
enabled?: boolean; // Flag to indicate if the server is enabled
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval