mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
feat: Implement tool management features including tool execution and result handling (#152)
This commit is contained in:
@@ -69,9 +69,12 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={toggleModal}
|
onClick={toggleModal}
|
||||||
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4"
|
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
{t('server.addServer')}
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{t('server.add')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{modalVisible && (
|
{modalVisible && (
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
|||||||
const handleToggle = async (e: React.MouseEvent) => {
|
const handleToggle = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (isToggling || !onToggle) return
|
if (isToggling || !onToggle) return
|
||||||
|
|
||||||
setIsToggling(true)
|
setIsToggling(true)
|
||||||
try {
|
try {
|
||||||
await onToggle(server, !(server.enabled !== false))
|
await onToggle(server, !(server.enabled !== false))
|
||||||
@@ -112,26 +112,34 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
|||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
|
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
|
||||||
<StatusBadge status={server.status} />
|
<StatusBadge status={server.status} />
|
||||||
|
|
||||||
|
{/* Tool count display */}
|
||||||
|
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm">
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>{server.tools?.length || 0} {t('server.tools')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{server.error && (
|
{server.error && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={handleErrorIconClick}
|
onClick={handleErrorIconClick}
|
||||||
aria-label={t('server.viewErrorDetails')}
|
aria-label={t('server.viewErrorDetails')}
|
||||||
>
|
>
|
||||||
<AlertCircle className="text-red-500 hover:text-red-600" size={18} />
|
<AlertCircle className="text-red-500 hover:text-red-600" size={18} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showErrorPopover && (
|
{showErrorPopover && (
|
||||||
<div
|
<div
|
||||||
ref={errorPopoverRef}
|
ref={errorPopoverRef}
|
||||||
className="absolute z-10 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-0 w-120"
|
className="absolute z-10 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-0 w-120"
|
||||||
style={{
|
style={{
|
||||||
left: '-231px',
|
left: '-231px',
|
||||||
top: '24px',
|
top: '24px',
|
||||||
maxHeight: '300px',
|
maxHeight: '300px',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
width: '480px',
|
width: '480px',
|
||||||
transform: 'translateX(50%)'
|
transform: 'translateX(50%)'
|
||||||
}}
|
}}
|
||||||
@@ -140,7 +148,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
|||||||
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
|
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
|
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
|
||||||
<button
|
<button
|
||||||
onClick={copyToClipboard}
|
onClick={copyToClipboard}
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
title={t('common.copy')}
|
title={t('common.copy')}
|
||||||
@@ -148,7 +156,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
|||||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setShowErrorPopover(false)
|
setShowErrorPopover(false)
|
||||||
@@ -176,19 +184,18 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
|
||||||
isToggling
|
? 'bg-gray-200 text-gray-500'
|
||||||
? 'bg-gray-200 text-gray-500'
|
: server.enabled !== false
|
||||||
: server.enabled !== false
|
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
}`}
|
||||||
}`}
|
|
||||||
disabled={isToggling}
|
disabled={isToggling}
|
||||||
>
|
>
|
||||||
{isToggling
|
{isToggling
|
||||||
? t('common.processing')
|
? t('common.processing')
|
||||||
: server.enabled !== false
|
: server.enabled !== false
|
||||||
? t('server.disable')
|
? t('server.disable')
|
||||||
: t('server.enable')
|
: t('server.enable')
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
@@ -207,10 +214,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
|||||||
|
|
||||||
{isExpanded && server.tools && (
|
{isExpanded && server.tools && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<h3 className={`text-lg font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h3>
|
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{server.tools.map((tool, index) => (
|
{server.tools.map((tool, index) => (
|
||||||
<ToolCard key={index} tool={tool} />
|
<ToolCard key={index} server={server.name} tool={tool} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info } from 'lucide-react'
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Edit,
|
||||||
|
Trash,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
User,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
Info,
|
||||||
|
Play,
|
||||||
|
Loader,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info }
|
export {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Edit,
|
||||||
|
Trash,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
User,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
Info,
|
||||||
|
Play,
|
||||||
|
Loader,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle
|
||||||
|
}
|
||||||
|
|
||||||
const LucideIcons = {
|
const LucideIcons = {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -12,7 +44,12 @@ const LucideIcons = {
|
|||||||
User,
|
User,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
Info
|
Info,
|
||||||
|
Play,
|
||||||
|
Loader,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LucideIcons
|
export default LucideIcons
|
||||||
363
frontend/src/components/ui/DynamicForm.tsx
Normal file
363
frontend/src/components/ui/DynamicForm.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ToolInputSchema } from '@/types';
|
||||||
|
|
||||||
|
interface JsonSchema {
|
||||||
|
type: string;
|
||||||
|
properties?: Record<string, JsonSchema>;
|
||||||
|
required?: string[];
|
||||||
|
items?: JsonSchema;
|
||||||
|
enum?: any[];
|
||||||
|
description?: string;
|
||||||
|
default?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DynamicFormProps {
|
||||||
|
schema: ToolInputSchema;
|
||||||
|
onSubmit: (values: Record<string, any>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
storageKey?: string; // Optional key for localStorage persistence
|
||||||
|
}
|
||||||
|
|
||||||
|
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, any>>({});
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Convert ToolInputSchema to JsonSchema - memoized to prevent infinite re-renders
|
||||||
|
const jsonSchema = useMemo(() => {
|
||||||
|
const convertToJsonSchema = (schema: ToolInputSchema): JsonSchema => {
|
||||||
|
const convertProperty = (prop: unknown): JsonSchema => {
|
||||||
|
if (typeof prop === 'object' && prop !== null) {
|
||||||
|
const obj = prop as any;
|
||||||
|
return {
|
||||||
|
type: obj.type || 'string',
|
||||||
|
description: obj.description,
|
||||||
|
enum: obj.enum,
|
||||||
|
default: obj.default,
|
||||||
|
properties: obj.properties ? Object.fromEntries(
|
||||||
|
Object.entries(obj.properties).map(([key, value]) => [key, convertProperty(value)])
|
||||||
|
) : undefined,
|
||||||
|
required: obj.required,
|
||||||
|
items: obj.items ? convertProperty(obj.items) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: 'string' };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: schema.type,
|
||||||
|
properties: schema.properties ? Object.fromEntries(
|
||||||
|
Object.entries(schema.properties).map(([key, value]) => [key, convertProperty(value)])
|
||||||
|
) : undefined,
|
||||||
|
required: schema.required,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return convertToJsonSchema(schema);
|
||||||
|
}, [schema]);
|
||||||
|
|
||||||
|
// Initialize form values with defaults or from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeValues = (schema: JsonSchema, path: string = ''): Record<string, any> => {
|
||||||
|
const values: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (schema.type === 'object' && schema.properties) {
|
||||||
|
Object.entries(schema.properties).forEach(([key, propSchema]) => {
|
||||||
|
const fullPath = path ? `${path}.${key}` : key;
|
||||||
|
if (propSchema.default !== undefined) {
|
||||||
|
values[key] = propSchema.default;
|
||||||
|
} else if (propSchema.type === 'string') {
|
||||||
|
values[key] = '';
|
||||||
|
} else if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||||
|
values[key] = 0;
|
||||||
|
} else if (propSchema.type === 'boolean') {
|
||||||
|
values[key] = false;
|
||||||
|
} else if (propSchema.type === 'array') {
|
||||||
|
values[key] = [];
|
||||||
|
} else if (propSchema.type === 'object') {
|
||||||
|
values[key] = initializeValues(propSchema, fullPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
let initialValues = initializeValues(jsonSchema);
|
||||||
|
|
||||||
|
// Try to load saved form data from localStorage
|
||||||
|
if (storageKey) {
|
||||||
|
try {
|
||||||
|
const savedData = localStorage.getItem(storageKey);
|
||||||
|
if (savedData) {
|
||||||
|
const parsedData = JSON.parse(savedData);
|
||||||
|
// Merge saved data with initial values, preserving structure
|
||||||
|
initialValues = { ...initialValues, ...parsedData };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load saved form data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormValues(initialValues);
|
||||||
|
}, [jsonSchema, storageKey]);
|
||||||
|
|
||||||
|
const handleInputChange = (path: string, value: any) => {
|
||||||
|
setFormValues(prev => {
|
||||||
|
const newValues = { ...prev };
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current = newValues;
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
if (!current[keys[i]]) {
|
||||||
|
current[keys[i]] = {};
|
||||||
|
}
|
||||||
|
current = current[keys[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
current[keys[keys.length - 1]] = value;
|
||||||
|
|
||||||
|
// Save to localStorage if storageKey is provided
|
||||||
|
if (storageKey) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(newValues));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save form data to localStorage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newValues;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear error for this field
|
||||||
|
if (errors[path]) {
|
||||||
|
setErrors(prev => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[path];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const validateObject = (schema: JsonSchema, values: any, path: string = '') => {
|
||||||
|
if (schema.type === 'object' && schema.properties) {
|
||||||
|
Object.entries(schema.properties).forEach(([key, propSchema]) => {
|
||||||
|
const fullPath = path ? `${path}.${key}` : key;
|
||||||
|
const value = values?.[key];
|
||||||
|
|
||||||
|
// Check required fields
|
||||||
|
if (schema.required?.includes(key) && (value === undefined || value === null || value === '')) {
|
||||||
|
newErrors[fullPath] = `${key} is required`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate type
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
if (propSchema.type === 'string' && typeof value !== 'string') {
|
||||||
|
newErrors[fullPath] = `${key} must be a string`;
|
||||||
|
} else if (propSchema.type === 'number' && typeof value !== 'number') {
|
||||||
|
newErrors[fullPath] = `${key} must be a number`;
|
||||||
|
} else if (propSchema.type === 'integer' && (!Number.isInteger(value) || typeof value !== 'number')) {
|
||||||
|
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 === 'object' && typeof value === 'object') {
|
||||||
|
validateObject(propSchema, value, fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
validateObject(jsonSchema, formValues);
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (validateForm()) {
|
||||||
|
onSubmit(formValues);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderField = (key: string, propSchema: JsonSchema, path: string = ''): React.ReactNode => {
|
||||||
|
const fullPath = path ? `${path}.${key}` : key;
|
||||||
|
const value = formValues[key];
|
||||||
|
const error = errors[fullPath];
|
||||||
|
|
||||||
|
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>}
|
||||||
|
</label>
|
||||||
|
{propSchema.description && (
|
||||||
|
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleInputChange(fullPath, e.target.value)}
|
||||||
|
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||||
|
>
|
||||||
|
<option value="">{t('tool.selectOption')}</option>
|
||||||
|
{propSchema.enum.map((option, idx) => (
|
||||||
|
<option key={idx} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
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>}
|
||||||
|
</label>
|
||||||
|
{propSchema.description && (
|
||||||
|
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleInputChange(fullPath, e.target.value)}
|
||||||
|
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>}
|
||||||
|
</label>
|
||||||
|
{propSchema.description && (
|
||||||
|
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={propSchema.type === 'integer' ? '1' : 'any'}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
|
||||||
|
handleInputChange(fullPath, val);
|
||||||
|
}}
|
||||||
|
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propSchema.type === 'boolean') {
|
||||||
|
return (
|
||||||
|
<div key={fullPath} className="mb-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value || false}
|
||||||
|
onChange={(e) => handleInputChange(fullPath, e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label className="ml-2 block text-sm text-gray-700">
|
||||||
|
{key}
|
||||||
|
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{propSchema.description && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{propSchema.description}</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>}
|
||||||
|
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
|
||||||
|
</label>
|
||||||
|
{propSchema.description && (
|
||||||
|
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleInputChange(fullPath, e.target.value)}
|
||||||
|
placeholder={t('tool.enterValue', { type: propSchema.type })}
|
||||||
|
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!jsonSchema.properties) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-gray-50 rounded-md">
|
||||||
|
<p className="text-sm text-gray-600">{t('tool.noParameters')}</p>
|
||||||
|
<div className="flex justify-end space-x-2 mt-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={() => onSubmit({})}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DynamicForm;
|
||||||
@@ -1,34 +1,135 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Tool } from '@/types'
|
import { Tool } from '@/types'
|
||||||
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
|
import { ChevronDown, ChevronRight, Play, Loader } from '@/components/icons/LucideIcons'
|
||||||
|
import { callTool, ToolCallResult } from '@/services/toolService'
|
||||||
|
import DynamicForm from './DynamicForm'
|
||||||
|
import ToolResult from './ToolResult'
|
||||||
|
|
||||||
interface ToolCardProps {
|
interface ToolCardProps {
|
||||||
|
server: string
|
||||||
tool: Tool
|
tool: Tool
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolCard = ({ tool }: ToolCardProps) => {
|
const ToolCard = ({ tool, server }: ToolCardProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
const [showRunForm, setShowRunForm] = useState(false)
|
||||||
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
|
const [result, setResult] = useState<ToolCallResult | null>(null)
|
||||||
|
|
||||||
|
// Generate a unique key for localStorage based on tool name and server
|
||||||
|
const getStorageKey = useCallback(() => {
|
||||||
|
return `mcphub_tool_form_${server ? `${server}_` : ''}${tool.name}`
|
||||||
|
}, [tool.name, server])
|
||||||
|
|
||||||
|
// Clear form data from localStorage
|
||||||
|
const clearStoredFormData = useCallback(() => {
|
||||||
|
localStorage.removeItem(getStorageKey())
|
||||||
|
}, [getStorageKey])
|
||||||
|
|
||||||
|
const handleRunTool = async (arguments_: Record<string, any>) => {
|
||||||
|
setIsRunning(true)
|
||||||
|
try {
|
||||||
|
const result = await callTool({
|
||||||
|
toolName: tool.name,
|
||||||
|
arguments: arguments_,
|
||||||
|
}, server)
|
||||||
|
|
||||||
|
setResult(result)
|
||||||
|
// Clear form data on successful submission
|
||||||
|
// clearStoredFormData()
|
||||||
|
} catch (error) {
|
||||||
|
setResult({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelRun = () => {
|
||||||
|
setShowRunForm(false)
|
||||||
|
// Clear form data when cancelled
|
||||||
|
clearStoredFormData()
|
||||||
|
setResult(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseResult = () => {
|
||||||
|
setResult(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow rounded-lg p-4 mb-4">
|
<div className="bg-white border border-gray-300 shadow rounded-lg p-4 mb-4">
|
||||||
<div
|
<div
|
||||||
className="flex justify-between items-center cursor-pointer"
|
className="flex justify-between items-center cursor-pointer"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-medium text-gray-900">{tool.name}</h3>
|
<div className="flex-1">
|
||||||
<button className="text-gray-400 hover:text-gray-600">
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
{tool.name}
|
||||||
</button>
|
<span className="ml-2 text-sm font-normal text-gray-600">
|
||||||
|
{tool.description || t('tool.noDescription')}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsExpanded(true) // Ensure card is expanded when showing run form
|
||||||
|
setShowRunForm(true)
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
|
||||||
|
disabled={isRunning}
|
||||||
|
>
|
||||||
|
{isRunning ? (
|
||||||
|
<Loader size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play size={14} />
|
||||||
|
)}
|
||||||
|
<span>{isRunning ? t('tool.running') : t('tool.run')}</span>
|
||||||
|
</button>
|
||||||
|
<button className="text-gray-400 hover:text-gray-600">
|
||||||
|
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="mt-4">
|
<div className="mt-4 space-y-4">
|
||||||
<p className="text-gray-600 mb-2">{tool.description || 'No description available'}</p>
|
{/* Schema Display */}
|
||||||
<div className="bg-gray-50 rounded p-2">
|
{!showRunForm && (
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Input Schema:</h4>
|
<div className="bg-gray-50 rounded p-3 border border-gray-300">
|
||||||
<pre className="text-xs text-gray-600 overflow-auto">
|
<h4 className="text-sm font-medium text-gray-900 mb-2">{t('tool.inputSchema')}</h4>
|
||||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
<pre className="text-xs text-gray-600 overflow-auto">
|
||||||
</pre>
|
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||||
</div>
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Run Form */}
|
||||||
|
{showRunForm && (
|
||||||
|
<div className="border border-gray-300 rounded-lg p-4 bg-blue-50">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-3">{t('tool.runToolWithName', { name: tool.name })}</h4>
|
||||||
|
<DynamicForm
|
||||||
|
schema={tool.inputSchema || { type: 'object' }}
|
||||||
|
onSubmit={handleRunTool}
|
||||||
|
onCancel={handleCancelRun}
|
||||||
|
loading={isRunning}
|
||||||
|
storageKey={getStorageKey()}
|
||||||
|
/>
|
||||||
|
{/* Tool Result */}
|
||||||
|
{result && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<ToolResult result={result} onClose={handleCloseResult} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
159
frontend/src/components/ui/ToolResult.tsx
Normal file
159
frontend/src/components/ui/ToolResult.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { CheckCircle, XCircle, AlertCircle } from '@/components/icons/LucideIcons';
|
||||||
|
|
||||||
|
interface ToolResultProps {
|
||||||
|
result: {
|
||||||
|
success: boolean;
|
||||||
|
content?: Array<{
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Extract content from data.content
|
||||||
|
const content = result.content;
|
||||||
|
|
||||||
|
const renderContent = (content: any): React.ReactNode => {
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content.map((item, index) => (
|
||||||
|
<div key={index} className="mb-3 last:mb-0">
|
||||||
|
{renderContentItem(item)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderContentItem(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContentItem = (item: any): React.ReactNode => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 rounded-md p-3">
|
||||||
|
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{item}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item === 'object' && item !== null) {
|
||||||
|
if (item.type === 'text' && item.text) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 rounded-md p-3">
|
||||||
|
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{item.text}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'image' && item.data) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 rounded-md p-3">
|
||||||
|
<img
|
||||||
|
src={`data:${item.mimeType || 'image/png'};base64,${item.data}`}
|
||||||
|
alt={t('tool.toolResult')}
|
||||||
|
className="max-w-full h-auto rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other structured content, try to parse as JSON
|
||||||
|
try {
|
||||||
|
const jsonString = typeof item === 'string' ? item : JSON.stringify(item, null, 2);
|
||||||
|
const parsed = typeof item === 'string' ? JSON.parse(item) : item;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 rounded-md p-3">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">{t('tool.jsonResponse')}</div>
|
||||||
|
<pre className="text-sm text-gray-800 overflow-auto">{JSON.stringify(parsed, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// If not valid JSON, show as string
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 rounded-md p-3">
|
||||||
|
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{String(item)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 rounded-md p-3">
|
||||||
|
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{String(item)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-300 rounded-lg bg-white shadow-sm">
|
||||||
|
<div className="border-b border-gray-300 px-4 py-3 bg-gray-50 rounded-t-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{result.success ? (
|
||||||
|
<CheckCircle size={20} className="text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900">
|
||||||
|
{t('tool.execution')} {result.success ? t('tool.successful') : t('tool.failed')}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 text-sm"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
{result.success ? (
|
||||||
|
<div>
|
||||||
|
{result.content && result.content.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600 mb-3">{t('tool.result')}</div>
|
||||||
|
{renderContent(result.content)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500 italic">
|
||||||
|
{t('tool.noContent')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
|
<AlertCircle size={16} className="text-red-500" />
|
||||||
|
<span className="text-sm font-medium text-red-700">{t('tool.error')}</span>
|
||||||
|
</div>
|
||||||
|
{content && content.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600 mb-3">{t('tool.errorDetails')}</div>
|
||||||
|
{renderContent(content)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-red-50 border border-red-300 rounded-md p-3">
|
||||||
|
<pre className="text-sm text-red-800 whitespace-pre-wrap">
|
||||||
|
{result.error || result.message || t('tool.unknownError')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToolResult;
|
||||||
@@ -264,6 +264,28 @@
|
|||||||
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
|
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
|
||||||
"perPage": "Per page"
|
"perPage": "Per page"
|
||||||
},
|
},
|
||||||
|
"tool": {
|
||||||
|
"run": "Run",
|
||||||
|
"running": "Running...",
|
||||||
|
"runTool": "Run Tool",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"noDescription": "No description available",
|
||||||
|
"inputSchema": "Input Schema:",
|
||||||
|
"runToolWithName": "Run Tool: {{name}}",
|
||||||
|
"execution": "Tool Execution",
|
||||||
|
"successful": "Successful",
|
||||||
|
"failed": "Failed",
|
||||||
|
"result": "Result:",
|
||||||
|
"error": "Error",
|
||||||
|
"errorDetails": "Error Details:",
|
||||||
|
"noContent": "Tool executed successfully but returned no content.",
|
||||||
|
"unknownError": "Unknown error occurred",
|
||||||
|
"jsonResponse": "JSON Response:",
|
||||||
|
"toolResult": "Tool result",
|
||||||
|
"noParameters": "This tool does not require any parameters.",
|
||||||
|
"selectOption": "Select an option",
|
||||||
|
"enterValue": "Enter {{type}} value"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"enableGlobalRoute": "Enable Global Route",
|
"enableGlobalRoute": "Enable Global Route",
|
||||||
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",
|
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",
|
||||||
|
|||||||
@@ -265,6 +265,28 @@
|
|||||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
|
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
|
||||||
"perPage": "每页显示"
|
"perPage": "每页显示"
|
||||||
},
|
},
|
||||||
|
"tool": {
|
||||||
|
"run": "运行",
|
||||||
|
"running": "运行中...",
|
||||||
|
"runTool": "运行工具",
|
||||||
|
"cancel": "取消",
|
||||||
|
"noDescription": "无描述信息",
|
||||||
|
"inputSchema": "输入模式:",
|
||||||
|
"runToolWithName": "运行工具:{{name}}",
|
||||||
|
"execution": "工具执行",
|
||||||
|
"successful": "成功",
|
||||||
|
"failed": "失败",
|
||||||
|
"result": "结果:",
|
||||||
|
"error": "错误",
|
||||||
|
"errorDetails": "错误详情:",
|
||||||
|
"noContent": "工具执行成功但未返回内容。",
|
||||||
|
"unknownError": "发生未知错误",
|
||||||
|
"jsonResponse": "JSON 响应:",
|
||||||
|
"toolResult": "工具结果",
|
||||||
|
"noParameters": "此工具不需要任何参数。",
|
||||||
|
"selectOption": "选择一个选项",
|
||||||
|
"enterValue": "输入{{type}}值"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"enableGlobalRoute": "启用全局路由",
|
"enableGlobalRoute": "启用全局路由",
|
||||||
"enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点",
|
"enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点",
|
||||||
|
|||||||
72
frontend/src/services/toolService.ts
Normal file
72
frontend/src/services/toolService.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { getApiUrl } from '../utils/runtime';
|
||||||
|
import { getToken } from './authService';
|
||||||
|
|
||||||
|
export interface ToolCallRequest {
|
||||||
|
toolName: string;
|
||||||
|
arguments?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallResult {
|
||||||
|
success: boolean;
|
||||||
|
content?: Array<{
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call a MCP tool via the call_tool API
|
||||||
|
*/
|
||||||
|
export const callTool = async (
|
||||||
|
request: ToolCallRequest,
|
||||||
|
server?: string,
|
||||||
|
): Promise<ToolCallResult> => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Authentication token not found. Please log in.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the URL with optional server parameter
|
||||||
|
const url = server ? `/tools/call/${server}` : '/tools/call';
|
||||||
|
|
||||||
|
const response = await fetch(getApiUrl(url), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-auth-token': token,
|
||||||
|
Authorization: `Bearer ${token}`, // Add bearer auth for MCP routing
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
toolName: request.toolName,
|
||||||
|
arguments: request.arguments,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: data.message || 'Tool call failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
content: data.data.content || [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling tool:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
86
src/controllers/toolController.ts
Normal file
86
src/controllers/toolController.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { ApiResponse } from '../types/index.js';
|
||||||
|
import { handleCallToolRequest } from '../services/mcpService.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for tool call request
|
||||||
|
*/
|
||||||
|
export interface ToolCallRequest {
|
||||||
|
toolName: string;
|
||||||
|
arguments?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for tool search request
|
||||||
|
*/
|
||||||
|
export interface ToolSearchRequest {
|
||||||
|
query: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for tool call result
|
||||||
|
*/
|
||||||
|
interface ToolCallResult {
|
||||||
|
content?: Array<{
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}>;
|
||||||
|
isError?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call a specific tool with given arguments
|
||||||
|
*/
|
||||||
|
export const callTool = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { server } = req.params;
|
||||||
|
const { toolName, arguments: toolArgs = {} } = req.body as ToolCallRequest;
|
||||||
|
|
||||||
|
if (!toolName) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'toolName is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a mock request structure for handleCallToolRequest
|
||||||
|
const mockRequest = {
|
||||||
|
params: {
|
||||||
|
name: 'call_tool',
|
||||||
|
arguments: {
|
||||||
|
toolName,
|
||||||
|
arguments: toolArgs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const extra = {
|
||||||
|
sessionId: req.headers['x-session-id'] || 'api-session',
|
||||||
|
server: server || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = (await handleCallToolRequest(mockRequest, extra)) as ToolCallResult;
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
content: result.content || [],
|
||||||
|
toolName,
|
||||||
|
arguments: toolArgs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling tool:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to call tool',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
|
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
|
||||||
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
|
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
|
||||||
import { getRuntimeConfig } from '../controllers/configController.js';
|
import { getRuntimeConfig } from '../controllers/configController.js';
|
||||||
|
import { callTool } from '../controllers/toolController.js';
|
||||||
import { auth } from '../middlewares/auth.js';
|
import { auth } from '../middlewares/auth.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -59,6 +60,9 @@ export const initRoutes = (app: express.Application): void => {
|
|||||||
// New route for batch updating servers in a group
|
// New route for batch updating servers in a group
|
||||||
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
|
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
|
||||||
|
|
||||||
|
// Tool management routes
|
||||||
|
router.post('/tools/call/:server', callTool);
|
||||||
|
|
||||||
// Market routes
|
// Market routes
|
||||||
router.get('/market/servers', getAllMarketServers);
|
router.get('/market/servers', getAllMarketServers);
|
||||||
router.get('/market/servers/search', searchMarketServersByQuery);
|
router.get('/market/servers/search', searchMarketServersByQuery);
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ export const toggleServerStatus = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleListToolsRequest = async (_: any, extra: any) => {
|
export const handleListToolsRequest = async (_: any, extra: any) => {
|
||||||
const sessionId = extra.sessionId || '';
|
const sessionId = extra.sessionId || '';
|
||||||
const group = getGroup(sessionId);
|
const group = getGroup(sessionId);
|
||||||
console.log(`Handling ListToolsRequest for group: ${group}`);
|
console.log(`Handling ListToolsRequest for group: ${group}`);
|
||||||
@@ -498,7 +498,7 @@ Available servers: ${serversList}`;
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCallToolRequest = async (request: any, extra: any) => {
|
export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||||
console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
|
console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
|
||||||
try {
|
try {
|
||||||
// Special handling for agent group tools
|
// Special handling for agent group tools
|
||||||
@@ -595,14 +595,17 @@ const handleCallToolRequest = async (request: any, extra: any) => {
|
|||||||
// arguments parameter is now optional
|
// arguments parameter is now optional
|
||||||
|
|
||||||
let targetServerInfo: ServerInfo | undefined;
|
let targetServerInfo: ServerInfo | undefined;
|
||||||
|
if (extra && extra.server) {
|
||||||
// Find the first server that has this tool
|
targetServerInfo = getServerByName(extra.server);
|
||||||
targetServerInfo = serverInfos.find(
|
} else {
|
||||||
(serverInfo) =>
|
// Find the first server that has this tool
|
||||||
serverInfo.status === 'connected' &&
|
targetServerInfo = serverInfos.find(
|
||||||
serverInfo.enabled !== false &&
|
(serverInfo) =>
|
||||||
serverInfo.tools.some((tool) => tool.name === toolName),
|
serverInfo.status === 'connected' &&
|
||||||
);
|
serverInfo.enabled !== false &&
|
||||||
|
serverInfo.tools.some((tool) => tool.name === toolName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!targetServerInfo) {
|
if (!targetServerInfo) {
|
||||||
throw new Error(`No available servers found with tool: ${toolName}`);
|
throw new Error(`No available servers found with tool: ${toolName}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user