feat: Implement tool management features including tool execution and result handling (#152)

This commit is contained in:
samanhappy
2025-05-31 22:00:05 +08:00
committed by GitHub
parent d2bbadea83
commit 65c95aaa0b
12 changed files with 935 additions and 56 deletions

View File

@@ -69,9 +69,12 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
<div>
<button
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>
{modalVisible && (

View File

@@ -50,7 +50,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
const handleToggle = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isToggling || !onToggle) return
setIsToggling(true)
try {
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">
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
<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 && (
<div className="relative">
<div
className="cursor-pointer"
<div
className="cursor-pointer"
onClick={handleErrorIconClick}
aria-label={t('server.viewErrorDetails')}
>
<AlertCircle className="text-red-500 hover:text-red-600" size={18} />
</div>
{showErrorPopover && (
<div
<div
ref={errorPopoverRef}
className="absolute z-10 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-0 w-120"
style={{
left: '-231px',
top: '24px',
maxHeight: '300px',
overflowY: 'auto',
style={{
left: '-231px',
top: '24px',
maxHeight: '300px',
overflowY: 'auto',
width: '480px',
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 items-center space-x-2">
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
<button
<button
onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
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} />}
</button>
</div>
<button
<button
onClick={(e) => {
e.stopPropagation()
setShowErrorPopover(false)
@@ -176,19 +184,18 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
<div className="flex items-center">
<button
onClick={handleToggle}
className={`px-3 py-1 text-sm rounded transition-colors ${
isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}`}
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}`}
disabled={isToggling}
>
{isToggling
{isToggling
? t('common.processing')
: server.enabled !== false
? t('server.disable')
: server.enabled !== false
? t('server.disable')
: t('server.enable')
}
</button>
@@ -207,10 +214,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
{isExpanded && server.tools && (
<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">
{server.tools.map((tool, index) => (
<ToolCard key={index} tool={tool} />
<ToolCard key={index} server={server.name} tool={tool} />
))}
</div>
</div>

View File

@@ -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 = {
ChevronDown,
@@ -12,7 +44,12 @@ const LucideIcons = {
User,
Settings,
LogOut,
Info
Info,
Play,
Loader,
CheckCircle,
XCircle,
AlertCircle
}
export default LucideIcons

View 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;

View File

@@ -1,34 +1,135 @@
import { useState } from 'react'
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
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 {
server: string
tool: Tool
}
const ToolCard = ({ tool }: ToolCardProps) => {
const ToolCard = ({ tool, server }: ToolCardProps) => {
const { t } = useTranslation()
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 (
<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
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<h3 className="text-lg font-medium text-gray-900">{tool.name}</h3>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900">
{tool.name}
<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>
{isExpanded && (
<div className="mt-4">
<p className="text-gray-600 mb-2">{tool.description || 'No description available'}</p>
<div className="bg-gray-50 rounded p-2">
<h4 className="text-sm font-medium text-gray-900 mb-2">Input Schema:</h4>
<pre className="text-xs text-gray-600 overflow-auto">
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</div>
<div className="mt-4 space-y-4">
{/* Schema Display */}
{!showRunForm && (
<div className="bg-gray-50 rounded p-3 border border-gray-300">
<h4 className="text-sm font-medium text-gray-900 mb-2">{t('tool.inputSchema')}</h4>
<pre className="text-xs text-gray-600 overflow-auto">
{JSON.stringify(tool.inputSchema, null, 2)}
</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>

View 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;

View File

@@ -264,6 +264,28 @@
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
"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": {
"enableGlobalRoute": "Enable Global Route",
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",

View File

@@ -265,6 +265,28 @@
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
"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": {
"enableGlobalRoute": "启用全局路由",
"enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点",

View 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',
};
}
};