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

126
QWEN.md Normal file
View File

@@ -0,0 +1,126 @@
# MCPHub Project Overview
## Project Summary
MCPHub is a centralized hub server for managing multiple Model Context Protocol (MCP) servers. It allows organizing these servers into flexible Streamable HTTP (SSE) endpoints, supporting access to all servers, individual servers, or logical server groups. It provides a web dashboard for monitoring and managing servers, along with features like authentication, group-based access control, and Smart Routing using vector semantic search.
## Technology Stack
### Backend
- **Language:** TypeScript (Node.js)
- **Framework:** Express
- **Key Libraries:**
- `@modelcontextprotocol/sdk`: Core library for MCP interactions.
- `typeorm`: ORM for database interactions.
- `pg` & `pgvector`: PostgreSQL database and vector support.
- `jsonwebtoken` & `bcryptjs`: Authentication (JWT) and password hashing.
- `openai`: For embedding generation in Smart Routing.
- Various utility and validation libraries (e.g., `dotenv`, `express-validator`, `uuid`).
### Frontend
- **Framework:** React (via Vite)
- **Language:** TypeScript
- **UI Library:** Tailwind CSS
- **Routing:** `react-router-dom`
- **Internationalization:** `i18next`
- **Component Structure:** Modular components and pages within `frontend/src`.
### Infrastructure
- **Build Tool:** `pnpm` (package manager and script runner).
- **Containerization:** Docker (`Dockerfile` provided).
- **Process Management:** Not explicitly defined in core files, but likely managed by Docker or host system.
## Key Features
- **MCP Server Management:** Configure, start, stop, and monitor multiple upstream MCP servers via `stdio`, `SSE`, or `Streamable HTTP` protocols.
- **Centralized Dashboard:** Web UI for server status, group management, user administration, and logs.
- **Flexible Endpoints:**
- Global MCP/SSE endpoint (`/mcp`, `/sse`) for all enabled servers.
- Group-based endpoints (`/mcp/{group}`, `/sse/{group}`).
- Server-specific endpoints (`/mcp/{server}`, `/sse/{server}`).
- Smart Routing endpoint (`/mcp/$smart`, `/sse/$smart`) using vector search.
- **Authentication & Authorization:** JWT-based user authentication with role-based access control (admin/user).
- **Group Management:** Logical grouping of servers for targeted access and permission control.
- **Smart Routing (Experimental):** Uses pgvector and OpenAI embeddings to semantically search and find relevant tools across all connected servers.
- **Configuration:** Managed via `mcp_settings.json`.
- **Logging:** Server logs are captured and viewable via the dashboard.
- **Marketplace Integration:** Access to a marketplace of MCP servers (`servers.json`).
## Project Structure
```
C:\code\mcphub\
├───src\ # Backend source code (TypeScript)
├───frontend\ # Frontend source code (React/TypeScript)
│ ├───src\
│ ├───components\ # Reusable UI components
│ ├───pages\ # Top-level page components
│ ├───contexts\ # React contexts (Auth, Theme, Toast)
│ ├───layouts\ # Page layouts
│ ├───utils\ # Frontend utilities
│ └───...
├───dist\ # Compiled backend output
├───frontend\dist\ # Compiled frontend output
├───tests\ # Backend tests
├───docs\ # Documentation
├───scripts\ # Utility scripts
├───bin\ # CLI entry points
├───assets\ # Static assets (e.g., images for README)
├───.github\ # GitHub workflows
├───.vscode\ # VS Code settings
├───mcp_settings.json # Main configuration file for MCP servers and users
├───servers.json # Marketplace server definitions
├───package.json # Node.js project definition, dependencies, and scripts
├───pnpm-lock.yaml # Dependency lock file
├───tsconfig.json # TypeScript compiler configuration (Backend)
├───README.md # Project documentation
├───Dockerfile # Docker image definition
└───...
```
## Building and Running
### Prerequisites
- Node.js (>=18.0.0 or >=20.0.0)
- pnpm
- Python 3.13 (for some upstream servers and uvx)
- Docker (optional, for containerized deployment)
- PostgreSQL with pgvector (optional, for Smart Routing)
### Local Development
1. Clone the repository.
2. Install dependencies: `pnpm install`.
3. Start development servers: `pnpm dev`.
- This runs `pnpm backend:dev` (Node.js with `tsx watch`) and `pnpm frontend:dev` (Vite dev server) concurrently.
- Access the dashboard at `http://localhost:5173` (Vite default) or the configured port/path.
### Production Build
1. Install dependencies: `pnpm install`.
2. Build the project: `pnpm build`.
- This runs `pnpm backend:build` (TypeScript compilation to `dist/`) and `pnpm frontend:build` (Vite build to `frontend/dist/`).
3. Start the production server: `pnpm start`.
- This runs `node dist/index.js`.
### Docker Deployment
- Pull the image: `docker pull samanhappy/mcphub`.
- Run with default settings: `docker run -p 3000:3000 samanhappy/mcphub`.
- Run with custom config: `docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub`.
- Access the dashboard at `http://localhost:3000`.
## Configuration
The main configuration file is `mcp_settings.json`. It defines:
- `mcpServers`: A map of server configurations (command, args, env, URL, etc.).
- `users`: A list of user accounts (username, hashed password, admin status).
- `groups`: A map of server groups.
- `systemConfig`: System-wide settings (e.g., proxy, registry, installation options).
## Development Conventions
- **Language:** TypeScript for both backend and frontend.
- **Backend Style:** Modular structure with clear separation of concerns (controllers, services, models, middlewares, routes, config, utils).
- **Frontend Style:** Component-based React architecture with contexts for state management.
- **Database:** TypeORM with PostgreSQL is used, leveraging decorators for entity definition.
- **Testing:** Uses `jest` for backend testing.
- **Linting/Formatting:** Uses `eslint` and `prettier`.
- **Scripts:** Defined in `package.json` under the `scripts` section for common tasks (dev, build, start, test, lint, format).

View File

@@ -4,6 +4,7 @@ import { Server } from '@/types'
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react' import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
import { StatusBadge } from '@/components/ui/Badge' import { StatusBadge } from '@/components/ui/Badge'
import ToolCard from '@/components/ui/ToolCard' import ToolCard from '@/components/ui/ToolCard'
import PromptCard from '@/components/ui/PromptCard'
import DeleteDialog from '@/components/ui/DeleteDialog' import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext' import { useToast } from '@/contexts/ToastContext'
@@ -107,7 +108,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
try { try {
const { toggleTool } = await import('@/services/toolService') const { toggleTool } = await import('@/services/toolService')
const result = await toggleTool(server.name, toolName, enabled) const result = await toggleTool(server.name, toolName, enabled)
if (result.success) { if (result.success) {
showToast( showToast(
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }), 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 ( return (
<> <>
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}> <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> <span>{server.tools?.length || 0} {t('server.tools')}</span>
</div> </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 && ( {server.error && (
<div className="relative"> <div className="relative">
<div <div
@@ -236,15 +267,35 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
</div> </div>
</div> </div>
{isExpanded && server.tools && ( {isExpanded && (
<div className="mt-6"> <>
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6> {server.tools && (
<div className="space-y-4"> <div className="mt-6">
{server.tools.map((tool, index) => ( <h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6>
<ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} /> <div className="space-y-4">
))} {server.tools.map((tool, index) => (
</div> <ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} />
</div> ))}
</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> </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"> <th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.tools')} {t('server.tools')}
</th> </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"> <th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.enabled')} {t('server.enabled')}
</th> </th>
@@ -163,6 +166,9 @@ const DashboardPage: React.FC = () => {
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{server.tools?.length || 0} {server.tools?.length || 0}
</td> </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"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{server.enabled !== false ? ( {server.enabled !== false ? (
<span className="text-green-600"></span> <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; 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 // Server config types
export interface ServerConfig { export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi'; type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi';
@@ -101,6 +115,7 @@ export interface ServerConfig {
headers?: Record<string, string>; headers?: Record<string, string>;
enabled?: boolean; enabled?: boolean;
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions 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?: { options?: {
timeout?: number; // Request timeout in milliseconds timeout?: number; // Request timeout in milliseconds
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
@@ -153,6 +168,7 @@ export interface Server {
status: ServerStatus; status: ServerStatus;
error?: string; error?: string;
tools?: Tool[]; tools?: Tool[];
prompts?: Prompt[];
config?: ServerConfig; config?: ServerConfig;
enabled?: boolean; enabled?: boolean;
} }

View File

@@ -80,6 +80,7 @@
"deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.", "deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.",
"status": "Status", "status": "Status",
"tools": "Tools", "tools": "Tools",
"prompts": "Prompts",
"name": "Server Name", "name": "Server Name",
"url": "Server URL", "url": "Server URL",
"apiKey": "API Key", "apiKey": "API Key",
@@ -414,6 +415,23 @@
"addItem": "Add {{key}} item", "addItem": "Add {{key}} item",
"enterKey": "Enter {{key}}" "enterKey": "Enter {{key}}"
}, },
"prompt": {
"run": "Get",
"running": "Getting...",
"result": "Prompt Result",
"error": "Prompt Error",
"execution": "Prompt Execution",
"successful": "Successful",
"failed": "Failed",
"errorDetails": "Error Details:",
"noContent": "Prompt executed successfully but returned no content.",
"unknownError": "Unknown error occurred",
"jsonResponse": "JSON Response:",
"description": "Description",
"messages": "Messages",
"noDescription": "No description available",
"runPromptWithName": "Get Prompt: {{name}}"
},
"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",

View File

@@ -80,6 +80,7 @@
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。", "deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
"status": "状态", "status": "状态",
"tools": "工具", "tools": "工具",
"prompts": "提示词",
"name": "服务器名称", "name": "服务器名称",
"url": "服务器 URL", "url": "服务器 URL",
"apiKey": "API 密钥", "apiKey": "API 密钥",
@@ -415,6 +416,23 @@
"addItem": "添加 {{key}} 项目", "addItem": "添加 {{key}} 项目",
"enterKey": "输入 {{key}}" "enterKey": "输入 {{key}}"
}, },
"prompt": {
"run": "获取",
"running": "获取中...",
"result": "提示词结果",
"error": "提示词错误",
"execution": "提示词执行",
"successful": "成功",
"failed": "失败",
"errorDetails": "错误详情:",
"noContent": "提示词执行成功但未返回内容。",
"unknownError": "发生未知错误",
"jsonResponse": "JSON 响应:",
"description": "描述",
"messages": "消息",
"noDescription": "无描述信息",
"runPromptWithName": "获取提示词: {{name}}"
},
"settings": { "settings": {
"enableGlobalRoute": "启用全局路由", "enableGlobalRoute": "启用全局路由",
"enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点", "enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点",

View File

@@ -0,0 +1,45 @@
import { Request, Response } from 'express';
import { ApiResponse } from '../types/index.js';
import { handleGetPromptRequest } from '../services/mcpService.js';
/**
* Get a specific prompt by server and prompt name
*/
export const getPrompt = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, promptName } = req.params;
if (!serverName || !promptName) {
res.status(400).json({
success: false,
message: 'serverName and promptName are required',
});
return;
}
const promptArgs = {
params: req.body as { [key: string]: any }
};
const result = await handleGetPromptRequest(promptArgs, serverName);
if (result.isError) {
res.status(500).json({
success: false,
message: 'Failed to get prompt',
});
return;
}
const response: ApiResponse = {
success: true,
data: result,
};
res.json(response);
} catch (error) {
console.error('Error getting prompt:', error);
res.status(500).json({
success: false,
message: 'Failed to get prompt',
error: error instanceof Error ? error.message : 'Unknown error occurred',
});
}
};

View File

@@ -739,3 +739,131 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
}); });
} }
}; };
// Toggle prompt status for a specific server
export const togglePrompt = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, promptName } = req.params;
const { enabled } = req.body;
if (!serverName || !promptName) {
res.status(400).json({
success: false,
message: 'Server name and prompt name are required',
});
return;
}
if (typeof enabled !== 'boolean') {
res.status(400).json({
success: false,
message: 'Enabled status must be a boolean',
});
return;
}
const settings = loadSettings();
if (!settings.mcpServers[serverName]) {
res.status(404).json({
success: false,
message: 'Server not found',
});
return;
}
// Initialize prompts config if it doesn't exist
if (!settings.mcpServers[serverName].prompts) {
settings.mcpServers[serverName].prompts = {};
}
// Set the prompt's enabled state
settings.mcpServers[serverName].prompts![promptName] = { enabled };
if (!saveSettings(settings)) {
res.status(500).json({
success: false,
message: 'Failed to save settings',
});
return;
}
// Notify that tools have changed (as prompts are part of the tool listing)
notifyToolChanged();
res.json({
success: true,
message: `Prompt ${promptName} ${enabled ? 'enabled' : 'disabled'} successfully`,
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Update prompt description for a specific server
export const updatePromptDescription = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, promptName } = req.params;
const { description } = req.body;
if (!serverName || !promptName) {
res.status(400).json({
success: false,
message: 'Server name and prompt name are required',
});
return;
}
if (typeof description !== 'string') {
res.status(400).json({
success: false,
message: 'Description must be a string',
});
return;
}
const settings = loadSettings();
if (!settings.mcpServers[serverName]) {
res.status(404).json({
success: false,
message: 'Server not found',
});
return;
}
// Initialize prompts config if it doesn't exist
if (!settings.mcpServers[serverName].prompts) {
settings.mcpServers[serverName].prompts = {};
}
// Set the prompt's description
if (!settings.mcpServers[serverName].prompts![promptName]) {
settings.mcpServers[serverName].prompts![promptName] = { enabled: true };
}
settings.mcpServers[serverName].prompts![promptName].description = description;
if (!saveSettings(settings)) {
res.status(500).json({
success: false,
message: 'Failed to save settings',
});
return;
}
// Notify that tools have changed (as prompts are part of the tool listing)
notifyToolChanged();
res.json({
success: true,
message: `Prompt ${promptName} description updated successfully`,
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};

View File

@@ -10,6 +10,8 @@ import {
toggleServer, toggleServer,
toggleTool, toggleTool,
updateToolDescription, updateToolDescription,
togglePrompt,
updatePromptDescription,
updateSystemConfig, updateSystemConfig,
} from '../controllers/serverController.js'; } from '../controllers/serverController.js';
import { import {
@@ -58,6 +60,7 @@ import { login, register, getCurrentUser, changePassword } from '../controllers/
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js'; import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js'; import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
import { callTool } from '../controllers/toolController.js'; import { callTool } from '../controllers/toolController.js';
import { getPrompt } from '../controllers/promptController.js';
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js'; import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
import { healthCheck } from '../controllers/healthController.js'; import { healthCheck } from '../controllers/healthController.js';
import { auth } from '../middlewares/auth.js'; import { auth } from '../middlewares/auth.js';
@@ -77,6 +80,8 @@ export const initRoutes = (app: express.Application): void => {
router.post('/servers/:name/toggle', toggleServer); router.post('/servers/:name/toggle', toggleServer);
router.post('/servers/:serverName/tools/:toolName/toggle', toggleTool); router.post('/servers/:serverName/tools/:toolName/toggle', toggleTool);
router.put('/servers/:serverName/tools/:toolName/description', updateToolDescription); router.put('/servers/:serverName/tools/:toolName/description', updateToolDescription);
router.post('/servers/:serverName/prompts/:promptName/toggle', togglePrompt);
router.put('/servers/:serverName/prompts/:promptName/description', updatePromptDescription);
router.put('/system-config', updateSystemConfig); router.put('/system-config', updateSystemConfig);
// Group management routes // Group management routes
@@ -106,6 +111,9 @@ export const initRoutes = (app: express.Application): void => {
// Tool management routes // Tool management routes
router.post('/tools/call/:server', callTool); router.post('/tools/call/:server', callTool);
// Prompt management routes
router.post('/mcp/:serverName/prompts/:promptName', getPrompt);
// DXT upload routes // DXT upload routes
router.post('/dxt/upload', uploadMiddleware, uploadDxtFile); router.post('/dxt/upload', uploadMiddleware, uploadDxtFile);

View File

@@ -1,10 +1,16 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
ServerCapabilities,
} from '@modelcontextprotocol/sdk/types.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ServerInfo, ServerConfig, ToolInfo } from '../types/index.js'; import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
import { loadSettings, saveSettings, expandEnvVars, replaceEnvVars } from '../config/index.js'; import { loadSettings, saveSettings, expandEnvVars, replaceEnvVars } from '../config/index.js';
import config from '../config/index.js'; import config from '../config/index.js';
import { getGroup } from './sseService.js'; import { getGroup } from './sseService.js';
@@ -343,6 +349,7 @@ export const initializeClientsFromSettings = async (
status: 'disconnected', status: 'disconnected',
error: null, error: null,
tools: [], tools: [],
prompts: [],
createTime: Date.now(), createTime: Date.now(),
enabled: false, enabled: false,
}); });
@@ -376,6 +383,7 @@ export const initializeClientsFromSettings = async (
status: 'disconnected', status: 'disconnected',
error: 'Missing OpenAPI specification URL or schema', error: 'Missing OpenAPI specification URL or schema',
tools: [], tools: [],
prompts: [],
createTime: Date.now(), createTime: Date.now(),
}); });
continue; continue;
@@ -388,6 +396,7 @@ export const initializeClientsFromSettings = async (
status: 'connecting', status: 'connecting',
error: null, error: null,
tools: [], tools: [],
prompts: [],
createTime: Date.now(), createTime: Date.now(),
enabled: conf.enabled === undefined ? true : conf.enabled, enabled: conf.enabled === undefined ? true : conf.enabled,
}; };
@@ -404,7 +413,7 @@ export const initializeClientsFromSettings = async (
// Convert OpenAPI tools to MCP tool format // Convert OpenAPI tools to MCP tool format
const openApiTools = openApiClient.getTools(); const openApiTools = openApiClient.getTools();
const mcpTools: ToolInfo[] = openApiTools.map((tool) => ({ const mcpTools: Tool[] = openApiTools.map((tool) => ({
name: `${name}-${tool.name}`, name: `${name}-${tool.name}`,
description: tool.description, description: tool.description,
inputSchema: cleanInputSchema(tool.inputSchema), inputSchema: cleanInputSchema(tool.inputSchema),
@@ -469,6 +478,7 @@ export const initializeClientsFromSettings = async (
status: 'connecting', status: 'connecting',
error: null, error: null,
tools: [], tools: [],
prompts: [],
client, client,
transport, transport,
options: requestOptions, options: requestOptions,
@@ -480,32 +490,63 @@ export const initializeClientsFromSettings = async (
.connect(transport, initRequestOptions || requestOptions) .connect(transport, initRequestOptions || requestOptions)
.then(() => { .then(() => {
console.log(`Successfully connected client for server: ${name}`); console.log(`Successfully connected client for server: ${name}`);
client const capabilities: ServerCapabilities | undefined = client.getServerCapabilities();
.listTools({}, initRequestOptions || requestOptions) console.log(`Server capabilities: ${JSON.stringify(capabilities)}`);
.then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
serverInfo.tools = tools.tools.map((tool) => ({ let dataError: Error | null = null;
name: `${name}-${tool.name}`, if (capabilities?.tools) {
description: tool.description || '', client
inputSchema: cleanInputSchema(tool.inputSchema || {}), .listTools({}, initRequestOptions || requestOptions)
})); .then((tools) => {
serverInfo.status = 'connected'; console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
serverInfo.error = null; serverInfo.tools = tools.tools.map((tool) => ({
name: `${name}-${tool.name}`,
description: tool.description || '',
inputSchema: cleanInputSchema(tool.inputSchema || {}),
}));
// Save tools as vector embeddings for search
saveToolsAsVectorEmbeddings(name, serverInfo.tools);
})
.catch((error) => {
console.error(
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
);
dataError = error;
});
}
// Set up keep-alive ping for SSE connections if (capabilities?.prompts) {
setupKeepAlive(serverInfo, conf); client
.listPrompts({}, initRequestOptions || requestOptions)
.then((prompts) => {
console.log(
`Successfully listed ${prompts.prompts.length} prompts for server: ${name}`,
);
serverInfo.prompts = prompts.prompts.map((prompt) => ({
name: `${name}-${prompt.name}`,
title: prompt.title,
description: prompt.description,
arguments: prompt.arguments,
}));
})
.catch((error) => {
console.error(
`Failed to list prompts for server ${name} by error: ${error} with stack: ${error.stack}`,
);
dataError = error;
});
}
// Save tools as vector embeddings for search if (!dataError) {
saveToolsAsVectorEmbeddings(name, serverInfo.tools); serverInfo.status = 'connected';
}) serverInfo.error = null;
.catch((error) => {
console.error( // Set up keep-alive ping for SSE connections
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`, setupKeepAlive(serverInfo, conf);
); } else {
serverInfo.status = 'disconnected'; serverInfo.status = 'disconnected';
serverInfo.error = `Failed to list tools: ${error.stack} `; serverInfo.error = `Failed to list data: ${dataError} `;
}); }
}) })
.catch((error) => { .catch((error) => {
console.error( console.error(
@@ -532,7 +573,7 @@ export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] =>
const filterServerInfos: ServerInfo[] = dataService.filterData const filterServerInfos: ServerInfo[] = dataService.filterData
? dataService.filterData(serverInfos) ? dataService.filterData(serverInfos)
: serverInfos; : serverInfos;
const infos = filterServerInfos.map(({ name, status, tools, createTime, error }) => { const infos = filterServerInfos.map(({ name, status, tools, prompts, createTime, error }) => {
const serverConfig = settings.mcpServers[name]; const serverConfig = settings.mcpServers[name];
const enabled = serverConfig ? serverConfig.enabled !== false : true; const enabled = serverConfig ? serverConfig.enabled !== false : true;
@@ -546,11 +587,21 @@ export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] =>
}; };
}); });
const promptsWithEnabled = prompts.map((prompt) => {
const promptConfig = serverConfig?.prompts?.[prompt.name];
return {
...prompt,
description: promptConfig?.description || prompt.description, // Use custom description if available
enabled: promptConfig?.enabled !== false, // Default to true if not explicitly disabled
};
});
return { return {
name, name,
status, status,
error, error,
tools: toolsWithEnabled, tools: toolsWithEnabled,
prompts: promptsWithEnabled,
createTime, createTime,
enabled, enabled,
}; };
@@ -568,7 +619,7 @@ const getServerByName = (name: string): ServerInfo | undefined => {
}; };
// Filter tools by server configuration // Filter tools by server configuration
const filterToolsByConfig = (serverName: string, tools: ToolInfo[]): ToolInfo[] => { const filterToolsByConfig = (serverName: string, tools: Tool[]): Tool[] => {
const settings = loadSettings(); const settings = loadSettings();
const serverConfig = settings.mcpServers[serverName]; const serverConfig = settings.mcpServers[serverName];
@@ -948,7 +999,7 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
if (tool.name) { if (tool.name) {
const serverName = searchResults.find((r) => r.toolName === tool.name)?.serverName; const serverName = searchResults.find((r) => r.toolName === tool.name)?.serverName;
if (serverName) { if (serverName) {
const enabledTools = filterToolsByConfig(serverName, [tool as ToolInfo]); const enabledTools = filterToolsByConfig(serverName, [tool as Tool]);
return enabledTools.length > 0; return enabledTools.length > 0;
} }
} }
@@ -1139,6 +1190,119 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
} }
}; };
export const handleGetPromptRequest = async (request: any, extra: any) => {
try {
const { name, arguments: promptArgs } = request.params;
let server: ServerInfo | undefined;
if (extra && extra.server) {
server = getServerByName(extra.server);
} else {
// Find the first server that has this tool
server = serverInfos.find(
(serverInfo) =>
serverInfo.status === 'connected' &&
serverInfo.enabled !== false &&
serverInfo.prompts.find((prompt) => prompt.name === name),
);
}
if (!server) {
throw new Error(`Server not found: ${name}`);
}
// Remove server prefix from prompt name if present
const cleanPromptName = name.startsWith(`${server.name}-`)
? name.replace(`${server.name}-`, '')
: name;
const promptParams = {
name: cleanPromptName || '',
arguments: promptArgs,
};
// Log the final promptParams
console.log(`Calling getPrompt with params: ${JSON.stringify(promptParams)}`);
const prompt = await server.client?.getPrompt(promptParams);
console.log(`Received prompt: ${JSON.stringify(prompt)}`);
if (!prompt) {
throw new Error(`Prompt not found: ${cleanPromptName}`);
}
return prompt;
} catch (error) {
console.error(`Error handling GetPromptRequest: ${error}`);
return {
content: [
{
type: 'text',
text: `Error: ${error}`,
},
],
isError: true,
};
}
};
export const handleListPromptsRequest = async (_: any, extra: any) => {
const sessionId = extra.sessionId || '';
const group = getGroup(sessionId);
console.log(`Handling ListPromptsRequest for group: ${group}`);
const allServerInfos = getDataService()
.filterData(serverInfos)
.filter((serverInfo) => {
if (serverInfo.enabled === false) return false;
if (!group) return true;
const serversInGroup = getServersInGroup(group);
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
return serversInGroup.includes(serverInfo.name);
});
const allPrompts: any[] = [];
for (const serverInfo of allServerInfos) {
if (serverInfo.prompts && serverInfo.prompts.length > 0) {
// Filter prompts based on server configuration
const settings = loadSettings();
const serverConfig = settings.mcpServers[serverInfo.name];
let enabledPrompts = serverInfo.prompts;
if (serverConfig && serverConfig.prompts) {
enabledPrompts = serverInfo.prompts.filter((prompt: any) => {
const promptConfig = serverConfig.prompts?.[prompt.name];
// If prompt is not in config, it's enabled by default
return promptConfig?.enabled !== false;
});
}
// If this is a group request, apply group-level prompt filtering
if (group) {
const serverConfigInGroup = getServerConfigInGroup(group, serverInfo.name);
if (
serverConfigInGroup &&
serverConfigInGroup.tools !== 'all' &&
Array.isArray(serverConfigInGroup.tools)
) {
// Note: Group config uses 'tools' field but we're filtering prompts here
// This might be a design decision to control access at the server level
}
}
// Apply custom descriptions from server configuration
const promptsWithCustomDescriptions = enabledPrompts.map((prompt: any) => {
const promptConfig = serverConfig?.prompts?.[prompt.name];
return {
...prompt,
description: promptConfig?.description || prompt.description, // Use custom description if available
};
});
allPrompts.push(...promptsWithCustomDescriptions);
}
}
return {
prompts: allPrompts,
};
};
// Create McpServer instance // Create McpServer instance
export const createMcpServer = (name: string, version: string, group?: string): Server => { export const createMcpServer = (name: string, version: string, group?: string): Server => {
// Determine server name based on routing type // Determine server name based on routing type
@@ -1157,8 +1321,13 @@ export const createMcpServer = (name: string, version: string, group?: string):
} }
// If no group, use default name (global routing) // If no group, use default name (global routing)
const server = new Server({ name: serverName, version }, { capabilities: { tools: {} } }); const server = new Server(
{ name: serverName, version },
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, handleListToolsRequest); server.setRequestHandler(ListToolsRequestSchema, handleListToolsRequest);
server.setRequestHandler(CallToolRequestSchema, handleCallToolRequest); server.setRequestHandler(CallToolRequestSchema, handleCallToolRequest);
server.setRequestHandler(GetPromptRequestSchema, handleGetPromptRequest);
server.setRequestHandler(ListPromptsRequestSchema, handleListPromptsRequest);
return server; return server;
}; };

View File

@@ -1,6 +1,6 @@
import { getRepositoryFactory } from '../db/index.js'; import { getRepositoryFactory } from '../db/index.js';
import { VectorEmbeddingRepository } from '../db/repositories/index.js'; import { VectorEmbeddingRepository } from '../db/repositories/index.js';
import { ToolInfo } from '../types/index.js'; import { Tool } from '../types/index.js';
import { getAppDataSource, initializeDatabase } from '../db/connection.js'; import { getAppDataSource, initializeDatabase } from '../db/connection.js';
import { getSmartRoutingConfig } from '../utils/smartRouting.js'; import { getSmartRoutingConfig } from '../utils/smartRouting.js';
import OpenAI from 'openai'; import OpenAI from 'openai';
@@ -190,7 +190,7 @@ function generateFallbackEmbedding(text: string): number[] {
*/ */
export const saveToolsAsVectorEmbeddings = async ( export const saveToolsAsVectorEmbeddings = async (
serverName: string, serverName: string,
tools: ToolInfo[], tools: Tool[],
): Promise<void> => { ): Promise<void> => {
try { try {
if (tools.length === 0) { if (tools.length === 0) {

View File

@@ -178,6 +178,7 @@ export interface ServerConfig {
owner?: string; // Owner of the server, defaults to 'admin' user owner?: string; // Owner of the server, defaults to 'admin' user
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers) keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions 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?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
// OpenAPI specific configuration // OpenAPI specific configuration
openapi?: { openapi?: {
@@ -226,7 +227,8 @@ export interface ServerInfo {
owner?: string; // Owner of the server, defaults to 'admin' user owner?: string; // Owner of the server, defaults to 'admin' user
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
error: string | null; // Error message if any error: string | null; // Error message if any
tools: ToolInfo[]; // List of tools available on the server tools: Tool[]; // List of tools available on the server
prompts: Prompt[]; // List of prompts available on the server
client?: Client; // Client instance for communication (MCP clients) client?: Client; // Client instance for communication (MCP clients)
transport?: SSEClientTransport | StdioClientTransport | StreamableHTTPClientTransport; // Transport mechanism used transport?: SSEClientTransport | StdioClientTransport | StreamableHTTPClientTransport; // Transport mechanism used
openApiClient?: any; // OpenAPI client instance for openapi type servers openApiClient?: any; // OpenAPI client instance for openapi type servers
@@ -237,13 +239,27 @@ export interface ServerInfo {
} }
// Details about a tool available on the server // Details about a tool available on the server
export interface ToolInfo { export interface Tool {
name: string; // Name of the tool name: string; // Name of the tool
description: string; // Brief description of the tool description: string; // Brief description of the tool
inputSchema: Record<string, unknown>; // Input schema for the tool inputSchema: Record<string, unknown>; // Input schema for the tool
enabled?: boolean; // Whether the tool is enabled (optional, defaults to true) enabled?: boolean; // Whether the tool is enabled (optional, defaults to true)
} }
export interface Prompt {
name: string; // Name of the prompt
title?: string; // Title of the prompt
description?: string; // Brief description of the prompt
arguments?: PromptArgument[]; // Input schema for the prompt
}
export interface PromptArgument {
name: string; // Name of the argument
title?: string; // Title of the argument
description?: string; // Brief description of the argument
required?: boolean; // Whether the argument is required
}
// Standardized API response structure // Standardized API response structure
export interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
success: boolean; // Indicates if the operation was successful success: boolean; // Indicates if the operation was successful