Add prompt management functionality to MCP server (#281)

This commit is contained in:
samanhappy
2025-08-20 14:23:55 +08:00
committed by GitHub
parent 81c3091a5c
commit 6020611f57
15 changed files with 1247 additions and 44 deletions

View File

@@ -4,6 +4,7 @@ import { Server } from '@/types'
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
import { StatusBadge } from '@/components/ui/Badge'
import ToolCard from '@/components/ui/ToolCard'
import PromptCard from '@/components/ui/PromptCard'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
@@ -107,7 +108,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
try {
const { toggleTool } = await import('@/services/toolService')
const result = await toggleTool(server.name, toolName, enabled)
if (result.success) {
showToast(
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
@@ -126,6 +126,28 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
}
}
const handlePromptToggle = async (promptName: string, enabled: boolean) => {
try {
const { togglePrompt } = await import('@/services/promptService')
const result = await togglePrompt(server.name, promptName, enabled)
if (result.success) {
showToast(
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }),
'success'
)
// Trigger refresh to update the prompt's state in the UI
if (onRefresh) {
onRefresh()
}
} else {
showToast(result.error || t('tool.toggleFailed'), 'error')
}
} catch (error) {
console.error('Error toggling prompt:', error)
showToast(t('tool.toggleFailed'), 'error')
}
}
return (
<>
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}>
@@ -145,6 +167,15 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<span>{server.tools?.length || 0} {t('server.tools')}</span>
</div>
{/* Prompt count display */}
<div className="flex items-center px-2 py-1 bg-purple-50 text-purple-700 rounded-full text-sm btn-primary">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
<path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z" />
</svg>
<span>{server.prompts?.length || 0} {t('server.prompts')}</span>
</div>
{server.error && (
<div className="relative">
<div
@@ -236,15 +267,35 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
</div>
</div>
{isExpanded && server.tools && (
<div className="mt-6">
<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} server={server.name} tool={tool} onToggle={handleToolToggle} />
))}
</div>
</div>
{isExpanded && (
<>
{server.tools && (
<div className="mt-6">
<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} server={server.name} tool={tool} onToggle={handleToolToggle} />
))}
</div>
</div>
)}
{server.prompts && (
<div className="mt-6">
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.prompts')}</h6>
<div className="space-y-4">
{server.prompts.map((prompt, index) => (
<PromptCard
key={index}
server={server.name}
prompt={prompt}
onToggle={handlePromptToggle}
/>
))}
</div>
</div>
)}
</>
)}
</div>

View File

@@ -0,0 +1,300 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Prompt } from '@/types'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
import { Switch } from './ToggleGroup'
import { getPrompt, PromptCallResult } from '@/services/promptService'
import DynamicForm from './DynamicForm'
import PromptResult from './PromptResult'
interface PromptCardProps {
server: string
prompt: Prompt
onToggle?: (promptName: string, enabled: boolean) => void
onDescriptionUpdate?: (promptName: string, description: string) => void
}
const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCardProps) => {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false)
const [showRunForm, setShowRunForm] = useState(false)
const [isRunning, setIsRunning] = useState(false)
const [result, setResult] = useState<PromptCallResult | null>(null)
const [isEditingDescription, setIsEditingDescription] = useState(false)
const [customDescription, setCustomDescription] = useState(prompt.description || '')
const descriptionInputRef = useRef<HTMLInputElement>(null)
const descriptionTextRef = useRef<HTMLSpanElement>(null)
const [textWidth, setTextWidth] = useState<number>(0)
// Focus the input when editing mode is activated
useEffect(() => {
if (isEditingDescription && descriptionInputRef.current) {
descriptionInputRef.current.focus()
// Set input width to match text width
if (textWidth > 0) {
descriptionInputRef.current.style.width = `${textWidth + 20}px` // Add some padding
}
}
}, [isEditingDescription, textWidth])
// Measure text width when not editing
useEffect(() => {
if (!isEditingDescription && descriptionTextRef.current) {
setTextWidth(descriptionTextRef.current.offsetWidth)
}
}, [isEditingDescription, customDescription])
// Generate a unique key for localStorage based on prompt name and server
const getStorageKey = useCallback(() => {
return `mcphub_prompt_form_${server ? `${server}_` : ''}${prompt.name}`
}, [prompt.name, server])
// Clear form data from localStorage
const clearStoredFormData = useCallback(() => {
localStorage.removeItem(getStorageKey())
}, [getStorageKey])
const handleToggle = (enabled: boolean) => {
if (onToggle) {
onToggle(prompt.name, enabled)
}
}
const handleDescriptionEdit = () => {
setIsEditingDescription(true)
}
const handleDescriptionSave = async () => {
// For now, we'll just update the local state
// In a real implementation, you would call an API to update the description
setIsEditingDescription(false)
if (onDescriptionUpdate) {
onDescriptionUpdate(prompt.name, customDescription)
}
}
const handleDescriptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomDescription(e.target.value)
}
const handleDescriptionKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleDescriptionSave()
} else if (e.key === 'Escape') {
setCustomDescription(prompt.description || '')
setIsEditingDescription(false)
}
}
const handleGetPrompt = async (arguments_: Record<string, any>) => {
setIsRunning(true)
try {
const result = await getPrompt({ promptName: prompt.name, arguments: arguments_ }, server)
console.log('GetPrompt result:', result)
setResult({
success: result.success,
data: result.data,
error: result.error
})
// 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)
}
// Convert prompt arguments to ToolInputSchema format for DynamicForm
const convertToSchema = () => {
if (!prompt.arguments || prompt.arguments.length === 0) {
return { type: 'object', properties: {}, required: [] }
}
const properties: Record<string, any> = {}
const required: string[] = []
prompt.arguments.forEach(arg => {
properties[arg.name] = {
type: 'string', // Default to string for prompts
description: arg.description || ''
}
if (arg.required) {
required.push(arg.name)
}
})
return {
type: 'object',
properties,
required
}
}
return (
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900">
{prompt.name.replace(server + '-', '')}
{prompt.title && (
<span className="ml-2 text-sm font-normal text-gray-600">
{prompt.title}
</span>
)}
<span className="ml-2 text-sm font-normal text-gray-500 inline-flex items-center">
{isEditingDescription ? (
<>
<input
ref={descriptionInputRef}
type="text"
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm focus:outline-none form-input"
value={customDescription}
onChange={handleDescriptionChange}
onKeyDown={handleDescriptionKeyDown}
onClick={(e) => e.stopPropagation()}
style={{
minWidth: '100px',
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto'
}}
/>
<button
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionSave()
}}
>
<Check size={16} />
</button>
</>
) : (
<>
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
<button
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionEdit()
}}
>
<Edit size={14} />
</button>
</>
)}
</span>
</h3>
</div>
<div className="flex items-center space-x-2">
<div
className="flex items-center space-x-2"
onClick={(e) => e.stopPropagation()}
>
{prompt.enabled !== undefined && (
<Switch
checked={prompt.enabled}
onCheckedChange={handleToggle}
disabled={isRunning}
/>
)}
</div>
<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 btn-primary"
disabled={isRunning || !prompt.enabled}
>
{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 space-y-4">
{/* Run Form */}
{showRunForm && (
<div className="border border-gray-300 rounded-lg p-4">
<DynamicForm
schema={convertToSchema()}
onSubmit={handleGetPrompt}
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('prompt.runPromptWithName', { name: prompt.name.replace(server + '-', '') })}
/>
{/* Prompt Result */}
{result && (
<div className="mt-4">
<PromptResult result={result} onClose={handleCloseResult} />
</div>
)}
</div>
)}
{/* Arguments Display (when not showing form) */}
{!showRunForm && prompt.arguments && prompt.arguments.length > 0 && (
<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.parameters')}</h4>
<div className="space-y-2">
{prompt.arguments.map((arg, index) => (
<div key={index} className="flex items-start">
<div className="flex-1">
<div className="flex items-center">
<span className="font-medium text-gray-700">{arg.name}</span>
{arg.required && <span className="text-red-500 ml-1">*</span>}
</div>
{arg.description && (
<p className="text-sm text-gray-600 mt-1">{arg.description}</p>
)}
</div>
<div className="text-xs text-gray-500 ml-2">
{arg.title || ''}
</div>
</div>
))}
</div>
</div>
)}
{/* Result Display (when not showing form) */}
{!showRunForm && result && (
<div className="mt-4">
<PromptResult result={result} onClose={handleCloseResult} />
</div>
)}
</div>
)}
</div>
)
}
export default PromptCard

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CheckCircle, XCircle, AlertCircle } from '@/components/icons/LucideIcons';
interface PromptResultProps {
result: {
success: boolean;
data?: any;
error?: string;
message?: string;
};
onClose: () => void;
}
const PromptResult: React.FC<PromptResultProps> = ({ result, onClose }) => {
const { t } = useTranslation();
const renderContent = (content: any): React.ReactNode => {
if (typeof content === 'string') {
return (
<div className="bg-gray-50 rounded-md p-3">
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{content}</pre>
</div>
);
}
if (typeof content === 'object' && content !== null) {
// Handle the specific prompt data structure
if (content.description || content.messages) {
return (
<div className="space-y-4">
{content.description && (
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2">{t('prompt.description')}</h4>
<div className="bg-gray-50 rounded-md p-3">
<p className="text-sm text-gray-800">{content.description}</p>
</div>
</div>
)}
{content.messages && (
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2">{t('prompt.messages')}</h4>
<div className="space-y-3">
{content.messages.map((message: any, index: number) => (
<div key={index} className="bg-gray-50 rounded-md p-3">
<div className="flex items-center mb-2">
<span className="inline-block w-16 text-xs font-medium text-gray-500">
{message.role}:
</span>
</div>
{typeof message.content === 'string' ? (
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">
{message.content}
</pre>
) : typeof message.content === 'object' && message.content.type === 'text' ? (
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">
{message.content.text}
</pre>
) : (
<pre className="text-sm text-gray-800 overflow-auto">
{JSON.stringify(message.content, null, 2)}
</pre>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
// For other structured content, try to parse as JSON
try {
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
return (
<div className="bg-gray-50 rounded-md p-3">
<div className="text-xs text-gray-500 mb-2">{t('prompt.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(content)}</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(content)}</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-status-green" />
) : (
<XCircle size={20} className="text-status-red" />
)}
<div>
<h4 className="text-sm font-medium text-gray-900">
{t('prompt.execution')} {result.success ? t('prompt.successful') : t('prompt.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.data ? (
<div>
<div className="text-sm text-gray-600 mb-3">{t('prompt.result')}</div>
{renderContent(result.data)}
</div>
) : (
<div className="text-sm text-gray-500 italic">
{t('prompt.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('prompt.error')}</span>
</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('prompt.unknownError')}
</pre>
</div>
</div>
)}
</div>
</div>
);
};
export default PromptResult;

View File

@@ -139,6 +139,9 @@ const DashboardPage: React.FC = () => {
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.tools')}
</th>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.prompts')}
</th>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.enabled')}
</th>
@@ -163,6 +166,9 @@ const DashboardPage: React.FC = () => {
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{server.tools?.length || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{server.prompts?.length || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{server.enabled !== false ? (
<span className="text-green-600"></span>

View File

@@ -0,0 +1,144 @@
import { apiPost, apiPut } from '../utils/fetchInterceptor';
export interface PromptCallRequest {
promptName: string;
arguments?: Record<string, any>;
}
export interface PromptCallResult {
success: boolean;
data?: any;
error?: string;
message?: string;
}
// GetPrompt result types
export interface GetPromptResult {
success: boolean;
data?: any;
error?: string;
}
/**
* Call a MCP prompt via the call_prompt API
*/
export const callPrompt = async (
request: PromptCallRequest,
server?: string,
): Promise<PromptCallResult> => {
try {
// Construct the URL with optional server parameter
const url = server ? `/prompts/call/${server}` : '/prompts/call';
const response = await apiPost<any>(url, {
promptName: request.promptName,
arguments: request.arguments,
});
if (!response.success) {
return {
success: false,
error: response.message || 'Prompt call failed',
};
}
return {
success: true,
data: response.data,
};
} catch (error) {
console.error('Error calling prompt:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
};
export const getPrompt = async (
request: PromptCallRequest,
server?: string,
): Promise<GetPromptResult> => {
try {
const response = await apiPost(
`/mcp/${server}/prompts/${encodeURIComponent(request.promptName)}`,
{
name: request.promptName,
arguments: request.arguments,
},
);
// apiPost already returns parsed data, not a Response object
if (!response.success) {
throw new Error(`Failed to get prompt: ${response.message || 'Unknown error'}`);
}
return {
success: true,
data: response.data,
};
} catch (error) {
console.error('Error getting prompt:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
};
/**
* Toggle a prompt's enabled state for a specific server
*/
export const togglePrompt = async (
serverName: string,
promptName: string,
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
const response = await apiPost<any>(`/servers/${serverName}/prompts/${promptName}/toggle`, {
enabled,
});
return {
success: response.success,
error: response.success ? undefined : response.message,
};
} catch (error) {
console.error('Error toggling prompt:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
};
/**
* Update a prompt's description for a specific server
*/
export const updatePromptDescription = async (
serverName: string,
promptName: string,
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
const response = await apiPut<any>(
`/servers/${serverName}/prompts/${promptName}/description`,
{ description },
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
},
},
);
return {
success: response.success,
error: response.success ? undefined : response.message,
};
} catch (error) {
console.error('Error updating prompt description:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
};

View File

@@ -91,6 +91,20 @@ export interface Tool {
enabled?: boolean;
}
// Prompt types
export interface Prompt {
name: string;
title?: string;
description?: string;
arguments?: Array<{
name: string;
title?: string;
description?: string;
required?: boolean;
}>;
enabled?: boolean;
}
// Server config types
export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi';
@@ -101,6 +115,7 @@ 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
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
options?: {
timeout?: number; // Request timeout in milliseconds
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
@@ -153,6 +168,7 @@ export interface Server {
status: ServerStatus;
error?: string;
tools?: Tool[];
prompts?: Prompt[];
config?: ServerConfig;
enabled?: boolean;
}