Compare commits

..

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
3a9ea9bc4b Address code review feedback - improve error handling and add clarifying comments
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:25:04 +00:00
copilot-swe-agent[bot]
3acdd99664 Add example configuration file and fix linting issue
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:22:11 +00:00
copilot-swe-agent[bot]
4ac875860c Add comprehensive tests and documentation for on-demand connection mode
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:19:04 +00:00
copilot-swe-agent[bot]
7b9e9da7bc Add connectionMode support for ephemeral (on-demand) MCP servers
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:15:12 +00:00
copilot-swe-agent[bot]
cd7e2a23a3 Initial plan 2025-10-31 15:00:55 +00:00
17 changed files with 714 additions and 603 deletions

View File

@@ -98,6 +98,34 @@ Manual registration example:
For manual providers, create the OAuth App in the upstream console, set the redirect URI to `http://localhost:3000/oauth/callback` (or your deployed domain), and then plug the credentials into the dashboard or config file.
#### Connection Modes (Optional)
MCPHub supports two connection strategies:
- **`persistent` (default)**: Maintains long-running connections for stateful servers
- **`on-demand`**: Connects only when needed, ideal for ephemeral servers that exit after operations
Example for one-time use servers:
```json
{
"mcpServers": {
"pdf-reader": {
"command": "npx",
"args": ["-y", "pdf-mcp-server"],
"connectionMode": "on-demand"
}
}
}
```
Use `on-demand` mode for servers that:
- Don't support long-running connections
- Exit automatically after handling requests
- Experience "Connection closed" errors
See the [Configuration Guide](docs/configuration/mcp-settings.mdx) for more details.
### Docker Deployment
**Recommended**: Mount your custom config:

View File

@@ -72,9 +72,13 @@ MCPHub uses several configuration files:
### Optional Fields
| Field | Type | Default | Description |
| -------------- | ------- | --------------- | --------------------------- |
| `env` | object | `{}` | Environment variables |
| Field | Type | Default | Description |
| ---------------- | ------- | --------------- | --------------------------------------------------------------------- |
| `env` | object | `{}` | Environment variables |
| `connectionMode` | string | `"persistent"` | Connection strategy: `"persistent"` or `"on-demand"` |
| `enabled` | boolean | `true` | Enable/disable the server |
| `keepAliveInterval` | number | `60000` | Keep-alive ping interval for SSE connections (milliseconds) |
| `options` | object | `{}` | MCP request options (timeout, resetTimeoutOnProgress, maxTotalTimeout)|
## Common MCP Server Examples
@@ -238,6 +242,68 @@ MCPHub uses several configuration files:
}
```
## Connection Modes
MCPHub supports two connection strategies for MCP servers:
### Persistent Connection (Default)
Persistent mode maintains a long-running connection to the MCP server. This is the default and recommended mode for most servers.
**Use cases:**
- Servers that maintain state between requests
- Servers with slow startup times
- Servers designed for long-running connections
**Example:**
```json
{
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"connectionMode": "persistent",
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
}
}
}
```
### On-Demand Connection
On-demand mode connects only when a tool is invoked, then disconnects immediately after. This is ideal for servers that:
- Don't support long-running connections
- Are designed for one-time use
- Exit automatically after handling requests
**Use cases:**
- PDF processing tools that exit after each operation
- One-time command-line utilities
- Servers with connection stability issues
- Resource-intensive servers that shouldn't run continuously
**Example:**
```json
{
"pdf-reader": {
"command": "npx",
"args": ["-y", "pdf-mcp-server"],
"connectionMode": "on-demand",
"env": {
"PDF_CACHE_DIR": "/tmp/pdf-cache"
}
}
}
```
**Benefits of on-demand mode:**
- Avoids "Connection closed" errors for ephemeral services
- Reduces resource usage for infrequently used tools
- Better suited for stateless operations
- Handles servers that automatically exit after operations
**Note:** On-demand servers briefly connect during initialization to discover available tools, then disconnect. The connection is re-established only when a tool from that server is actually invoked.
## Advanced Configuration
### Environment Variable Substitution

View File

@@ -0,0 +1,61 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"description": "Example MCP settings showing different connection modes",
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"connectionMode": "persistent",
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
},
"enabled": true
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"],
"connectionMode": "persistent",
"enabled": true
},
"pdf-reader": {
"command": "npx",
"args": ["-y", "pdf-mcp-server"],
"connectionMode": "on-demand",
"env": {
"PDF_CACHE_DIR": "/tmp/pdf-cache"
},
"enabled": true
},
"image-processor": {
"command": "python",
"args": ["-m", "image_mcp_server"],
"connectionMode": "on-demand",
"env": {
"IMAGE_OUTPUT_DIR": "/tmp/images"
},
"enabled": true
},
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"],
"enabled": true
},
"slack": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"connectionMode": "persistent",
"env": {
"SLACK_BOT_TOKEN": "${SLACK_BOT_TOKEN}",
"SLACK_TEAM_ID": "${SLACK_TEAM_ID}"
},
"enabled": true
}
},
"users": [
{
"username": "admin",
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
"isAdmin": true
}
]
}

View File

@@ -1,11 +1,10 @@
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, Server, IGroupServerConfig } from '@/types'
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench, Download } from '@/components/icons/LucideIcons'
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench } from '@/components/icons/LucideIcons'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
import { useSettingsData } from '@/hooks/useSettingsData'
import InstallToClientDialog from '@/components/InstallToClientDialog'
interface GroupCardProps {
group: Group
@@ -27,7 +26,6 @@ const GroupCard = ({
const [copied, setCopied] = useState(false)
const [showCopyDropdown, setShowCopyDropdown] = useState(false)
const [expandedServer, setExpandedServer] = useState<string | null>(null)
const [showInstallDialog, setShowInstallDialog] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
@@ -52,10 +50,6 @@ const GroupCard = ({
setShowDeleteDialog(true)
}
const handleInstall = () => {
setShowInstallDialog(true)
}
const handleConfirmDelete = () => {
onDelete(group.id)
setShowDeleteDialog(false)
@@ -189,13 +183,6 @@ const GroupCard = ({
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
{t('groups.serverCount', { count: group.servers.length })}
</div>
<button
onClick={handleInstall}
className="text-purple-500 hover:text-purple-700"
title={t('install.installButton')}
>
<Download size={18} />
</button>
<button
onClick={handleEdit}
className="text-gray-500 hover:text-gray-700"
@@ -290,20 +277,6 @@ const GroupCard = ({
serverName={group.name}
isGroup={true}
/>
{showInstallDialog && installConfig && (
<InstallToClientDialog
groupId={group.id}
groupName={group.name}
config={{
type: 'streamable-http',
url: `${installConfig.protocol}://${installConfig.baseUrl}${installConfig.basePath}/mcp/${group.id}`,
headers: {
Authorization: `Bearer ${installConfig.token}`
}
}}
onClose={() => setShowInstallDialog(false)}
/>
)}
</div>
)
}

View File

@@ -1,219 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Copy, Check } from 'lucide-react';
interface InstallToClientDialogProps {
serverName?: string;
groupId?: string;
groupName?: string;
config: any;
onClose: () => void;
}
const InstallToClientDialog: React.FC<InstallToClientDialogProps> = ({
serverName,
groupId,
groupName,
config,
onClose,
}) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'cursor' | 'claude-code' | 'claude-desktop'>('cursor');
const [copied, setCopied] = useState(false);
// Generate configuration based on the active tab
const generateConfig = () => {
if (groupId) {
// For groups, generate group-based configuration
return {
mcpServers: {
[`mcphub-${groupId}`]: config,
},
};
} else {
// For individual servers
return {
mcpServers: {
[serverName || 'mcp-server']: config,
},
};
}
};
const configJson = JSON.stringify(generateConfig(), null, 2);
const handleCopyConfig = () => {
navigator.clipboard.writeText(configJson).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
// Generate deep link for Cursor (if supported in the future)
const handleInstallToCursor = () => {
// For now, just copy the config since deep linking may not be widely supported
handleCopyConfig();
// In the future, this could be:
// const deepLink = `cursor://install-mcp?config=${encodeURIComponent(configJson)}`;
// window.open(deepLink, '_blank');
};
const getStepsList = () => {
const displayName = groupName || serverName || 'MCP server';
switch (activeTab) {
case 'cursor':
return [
t('install.step1Cursor'),
t('install.step2Cursor'),
t('install.step3Cursor'),
t('install.step4Cursor', { name: displayName }),
];
case 'claude-code':
return [
t('install.step1ClaudeCode'),
t('install.step2ClaudeCode'),
t('install.step3ClaudeCode'),
t('install.step4ClaudeCode', { name: displayName }),
];
case 'claude-desktop':
return [
t('install.step1ClaudeDesktop'),
t('install.step2ClaudeDesktop'),
t('install.step3ClaudeDesktop'),
t('install.step4ClaudeDesktop', { name: displayName }),
];
default:
return [];
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden">
<div className="flex justify-between items-center p-6 border-b">
<h2 className="text-2xl font-bold text-gray-900">
{groupId
? t('install.installGroupTitle', { name: groupName })
: t('install.installServerTitle', { name: serverName })}
</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 transition-colors duration-200"
aria-label={t('common.close')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="overflow-y-auto max-h-[calc(90vh-140px)]">
{/* Tab Navigation */}
<div className="border-b border-gray-200 px-6 pt-4">
<nav className="-mb-px flex space-x-4">
<button
onClick={() => setActiveTab('cursor')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
activeTab === 'cursor'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Cursor
</button>
<button
onClick={() => setActiveTab('claude-code')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
activeTab === 'claude-code'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Claude Code
</button>
<button
onClick={() => setActiveTab('claude-desktop')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
activeTab === 'claude-desktop'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Claude Desktop
</button>
</nav>
</div>
{/* Configuration Display */}
<div className="p-6 space-y-6">
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h3 className="text-sm font-medium text-gray-700">{t('install.configCode')}</h3>
<button
onClick={handleCopyConfig}
className="flex items-center space-x-2 px-3 py-1.5 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 transition-colors duration-200 text-sm"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
<span>{copied ? t('common.copied') : t('install.copyConfig')}</span>
</button>
</div>
<pre className="bg-white border border-gray-200 rounded p-4 text-xs overflow-x-auto">
<code>{configJson}</code>
</pre>
</div>
{/* Installation Steps */}
<div className="bg-blue-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900 mb-3">{t('install.steps')}</h3>
<ol className="space-y-3">
{getStepsList().map((step, index) => (
<li key={index} className="flex items-start space-x-3">
<span className="flex-shrink-0 w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-medium">
{index + 1}
</span>
<span className="text-sm text-blue-900 pt-0.5">{step}</span>
</li>
))}
</ol>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-between items-center p-6 border-t bg-gray-50">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-100 transition-colors duration-200"
>
{t('common.close')}
</button>
<button
onClick={handleInstallToCursor}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 flex items-center space-x-2"
>
<Copy size={16} />
<span>
{activeTab === 'cursor' && t('install.installToCursor', { name: groupName || serverName })}
{activeTab === 'claude-code' && t('install.installToClaudeCode', { name: groupName || serverName })}
{activeTab === 'claude-desktop' && t('install.installToClaudeDesktop', { name: groupName || serverName })}
</span>
</button>
</div>
</div>
</div>
);
};
export default InstallToClientDialog;

View File

@@ -1,14 +1,13 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Server } from '@/types';
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check, Download } from 'lucide-react';
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';
import { useSettingsData } from '@/hooks/useSettingsData';
import InstallToClientDialog from '@/components/InstallToClientDialog';
interface ServerCardProps {
server: Server;
@@ -26,7 +25,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
const [isToggling, setIsToggling] = useState(false);
const [showErrorPopover, setShowErrorPopover] = useState(false);
const [copied, setCopied] = useState(false);
const [showInstallDialog, setShowInstallDialog] = useState(false);
const errorPopoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -54,11 +52,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
onEdit(server);
};
const handleInstall = (e: React.MouseEvent) => {
e.stopPropagation();
setShowInstallDialog(true);
};
const handleToggle = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isToggling || !onToggle) return;
@@ -317,13 +310,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<button onClick={handleCopyServerConfig} className={`px-3 py-1 btn-secondary`}>
{t('server.copy')}
</button>
<button
onClick={handleInstall}
className="px-3 py-1 bg-purple-100 text-purple-800 rounded hover:bg-purple-200 text-sm btn-primary flex items-center space-x-1"
>
<Download size={14} />
<span>{t('install.installButton')}</span>
</button>
<button
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
@@ -412,13 +398,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
onConfirm={handleConfirmDelete}
serverName={server.name}
/>
{showInstallDialog && server.config && (
<InstallToClientDialog
serverName={server.name}
config={server.config}
onClose={() => setShowInstallDialog(false)}
/>
)}
</>
);
};

View File

@@ -17,8 +17,7 @@ import {
Link,
FileCode,
ChevronDown as DropdownIcon,
Wrench,
Download
Wrench
} from 'lucide-react'
export {
@@ -40,8 +39,7 @@ export {
Link,
FileCode,
DropdownIcon,
Wrench,
Download
Wrench
}
const LucideIcons = {

View File

@@ -8,7 +8,6 @@ import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData';
import DxtUploadForm from '@/components/DxtUploadForm';
import JSONImportForm from '@/components/JSONImportForm';
import { apiGet } from '@/utils/fetchInterceptor';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
@@ -28,10 +27,6 @@ const ServersPage: React.FC = () => {
const [isRefreshing, setIsRefreshing] = useState(false);
const [showDxtUpload, setShowDxtUpload] = useState(false);
const [showJsonImport, setShowJsonImport] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [similarityThreshold, setSimilarityThreshold] = useState(0.65);
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<Server[] | null>(null);
const handleEditClick = async (server: Server) => {
const fullServerData = await handleServerEdit(server);
@@ -68,31 +63,6 @@ const ServersPage: React.FC = () => {
triggerRefresh();
};
const handleSemanticSearch = async () => {
if (!searchQuery.trim()) {
return;
}
setIsSearching(true);
try {
const result = await apiGet(`/servers/search?query=${encodeURIComponent(searchQuery)}&threshold=${similarityThreshold}`);
if (result.success && result.data) {
setSearchResults(result.data.servers);
} else {
setError(result.message || 'Search failed');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed');
} finally {
setIsSearching(false);
}
};
const handleClearSearch = () => {
setSearchQuery('');
setSearchResults(null);
};
return (
<div>
<div className="flex justify-between items-center mb-8">
@@ -146,72 +116,6 @@ const ServersPage: React.FC = () => {
</div>
</div>
{/* Semantic Search Section */}
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
<div className="space-y-4">
<div className="flex space-x-4">
<div className="flex-grow">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSemanticSearch()}
placeholder={t('pages.servers.semanticSearchPlaceholder')}
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
/>
</div>
<button
onClick={handleSemanticSearch}
disabled={isSearching || !searchQuery.trim()}
className="px-6 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSearching ? (
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<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="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clipRule="evenodd" />
</svg>
)}
{t('pages.servers.searchButton')}
</button>
{searchResults && (
<button
onClick={handleClearSearch}
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
>
{t('pages.servers.clearSearch')}
</button>
)}
</div>
<div className="flex items-center space-x-4">
<label className="text-sm text-gray-700 font-medium min-w-max">{t('pages.servers.similarityThreshold')}: {similarityThreshold.toFixed(2)}</label>
<input
type="range"
min="0"
max="1"
step="0.05"
value={similarityThreshold}
onChange={(e) => setSimilarityThreshold(parseFloat(e.target.value))}
className="flex-grow h-2 bg-blue-200 rounded-lg appearance-none cursor-pointer"
/>
<span className="text-xs text-gray-500">{t('pages.servers.similarityThresholdHelp')}</span>
</div>
</div>
</div>
{searchResults && (
<div className="mb-4 bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
<p className="text-blue-800">
{searchResults.length > 0
? t('pages.servers.searchResults', { count: searchResults.length })
: t('pages.servers.noSearchResults')}
</p>
</div>
)}
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
@@ -241,13 +145,13 @@ const ServersPage: React.FC = () => {
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : (searchResults ? searchResults : servers).length === 0 ? (
) : servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{searchResults ? t('pages.servers.noSearchResults') : t('app.noServers')}</p>
<p className="text-gray-600">{t('app.noServers')}</p>
</div>
) : (
<div className="space-y-6">
{(searchResults || servers).map((server, index) => (
{servers.map((server, index) => (
<ServerCard
key={index}
server={server}

View File

@@ -268,15 +268,7 @@
"recentServers": "Recent Servers"
},
"servers": {
"title": "Servers Management",
"semanticSearch": "Intelligent search for tools...",
"semanticSearchPlaceholder": "Describe the functionality you need, e.g.: maps, weather, file processing",
"similarityThreshold": "Similarity Threshold",
"similarityThresholdHelp": "Higher values return more precise results, lower values return broader matches",
"searchButton": "Search",
"clearSearch": "Clear Search",
"searchResults": "Found {{count}} matching server(s)",
"noSearchResults": "No matching servers found"
"title": "Servers Management"
},
"groups": {
"title": "Group Management"
@@ -751,28 +743,5 @@
"internalError": "Internal Error",
"internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.",
"closeWindow": "Close Window"
},
"install": {
"installServerTitle": "Install Server to {{name}}",
"installGroupTitle": "Install Group {{name}}",
"configCode": "Configuration Code",
"copyConfig": "Copy Configuration",
"steps": "Installation Steps",
"step1Cursor": "Copy the configuration code above",
"step2Cursor": "Open Cursor, go to Settings > Features > MCP",
"step3Cursor": "Click 'Add New MCP Server' to add a new server",
"step4Cursor": "Paste the configuration in the appropriate location and restart Cursor",
"step1ClaudeCode": "Copy the configuration code above",
"step2ClaudeCode": "Open Claude Code, go to Settings > Features > MCP",
"step3ClaudeCode": "Click 'Add New MCP Server' to add a new server",
"step4ClaudeCode": "Paste the configuration in the appropriate location and restart Claude Code",
"step1ClaudeDesktop": "Copy the configuration code above",
"step2ClaudeDesktop": "Open Claude Desktop, go to Settings > Developer",
"step3ClaudeDesktop": "Click 'Edit Config' to edit the configuration file",
"step4ClaudeDesktop": "Paste the configuration in the mcpServers section and restart Claude Desktop",
"installToCursor": "Add {{name}} MCP server to Cursor",
"installToClaudeCode": "Add {{name}} MCP server to Claude Code",
"installToClaudeDesktop": "Add {{name}} MCP server to Claude Desktop",
"installButton": "Install"
}
}

View File

@@ -268,15 +268,7 @@
"recentServers": "Serveurs récents"
},
"servers": {
"title": "Gestion des serveurs",
"semanticSearch": "Recherche intelligente d'outils...",
"semanticSearchPlaceholder": "Décrivez la fonctionnalité dont vous avez besoin, par ex. : cartes, météo, traitement de fichiers",
"similarityThreshold": "Seuil de similarité",
"similarityThresholdHelp": "Des valeurs plus élevées renvoient des résultats plus précis, des valeurs plus faibles des correspondances plus larges",
"searchButton": "Rechercher",
"clearSearch": "Effacer la recherche",
"searchResults": "{{count}} serveur(s) correspondant(s) trouvé(s)",
"noSearchResults": "Aucun serveur correspondant trouvé"
"title": "Gestion des serveurs"
},
"groups": {
"title": "Gestion des groupes"
@@ -751,28 +743,5 @@
"internalError": "Erreur interne",
"internalErrorMessage": "Une erreur inattendue s'est produite lors du traitement du callback OAuth.",
"closeWindow": "Fermer la fenêtre"
},
"install": {
"installServerTitle": "Installer le serveur sur {{name}}",
"installGroupTitle": "Installer le groupe {{name}}",
"configCode": "Code de configuration",
"copyConfig": "Copier la configuration",
"steps": "Étapes d'installation",
"step1Cursor": "Copiez le code de configuration ci-dessus",
"step2Cursor": "Ouvrez Cursor, allez dans Paramètres > Features > MCP",
"step3Cursor": "Cliquez sur 'Add New MCP Server' pour ajouter un nouveau serveur",
"step4Cursor": "Collez la configuration à l'emplacement approprié et redémarrez Cursor",
"step1ClaudeCode": "Copiez le code de configuration ci-dessus",
"step2ClaudeCode": "Ouvrez Claude Code, allez dans Paramètres > Features > MCP",
"step3ClaudeCode": "Cliquez sur 'Add New MCP Server' pour ajouter un nouveau serveur",
"step4ClaudeCode": "Collez la configuration à l'emplacement approprié et redémarrez Claude Code",
"step1ClaudeDesktop": "Copiez le code de configuration ci-dessus",
"step2ClaudeDesktop": "Ouvrez Claude Desktop, allez dans Paramètres > Développeur",
"step3ClaudeDesktop": "Cliquez sur 'Edit Config' pour modifier le fichier de configuration",
"step4ClaudeDesktop": "Collez la configuration dans la section mcpServers et redémarrez Claude Desktop",
"installToCursor": "Ajouter le serveur MCP {{name}} à Cursor",
"installToClaudeCode": "Ajouter le serveur MCP {{name}} à Claude Code",
"installToClaudeDesktop": "Ajouter le serveur MCP {{name}} à Claude Desktop",
"installButton": "Installer"
}
}

View File

@@ -269,15 +269,7 @@
"recentServers": "最近的服务器"
},
"servers": {
"title": "服务器管理",
"semanticSearch": "智能搜索工具...",
"semanticSearchPlaceholder": "描述您需要的功能,例如:地图、天气、文件处理",
"similarityThreshold": "相似度阈值",
"similarityThresholdHelp": "较高值返回更精确结果,较低值返回更广泛匹配",
"searchButton": "搜索",
"clearSearch": "清除搜索",
"searchResults": "找到 {{count}} 个匹配的服务器",
"noSearchResults": "未找到匹配的服务器"
"title": "服务器管理"
},
"settings": {
"title": "设置",
@@ -753,28 +745,5 @@
"internalError": "内部错误",
"internalErrorMessage": "处理 OAuth 回调时发生意外错误。",
"closeWindow": "关闭窗口"
},
"install": {
"installServerTitle": "安装服务器到 {{name}}",
"installGroupTitle": "安装分组 {{name}}",
"configCode": "配置代码",
"copyConfig": "复制配置",
"steps": "安装步骤",
"step1Cursor": "复制上面的配置代码",
"step2Cursor": "打开 Cursor进入设置 > Features > MCP",
"step3Cursor": "点击 'Add New MCP Server' 添加新服务器",
"step4Cursor": "将配置粘贴到相应位置并重启 Cursor",
"step1ClaudeCode": "复制上面的配置代码",
"step2ClaudeCode": "打开 Claude Code进入设置 > Features > MCP",
"step3ClaudeCode": "点击 'Add New MCP Server' 添加新服务器",
"step4ClaudeCode": "将配置粘贴到相应位置并重启 Claude Code",
"step1ClaudeDesktop": "复制上面的配置代码",
"step2ClaudeDesktop": "打开 Claude Desktop进入设置 > Developer",
"step3ClaudeDesktop": "点击 'Edit Config' 编辑配置文件",
"step4ClaudeDesktop": "将配置粘贴到 mcpServers 部分并重启 Claude Desktop",
"installToCursor": "添加 {{name}} MCP 服务器到 Cursor",
"installToClaudeCode": "添加 {{name}} MCP 服务器到 Claude Code",
"installToClaudeDesktop": "添加 {{name}} MCP 服务器到 Claude Desktop",
"installButton": "安装"
}
}

View File

@@ -2,7 +2,6 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import SwaggerParser from '@apidevtools/swagger-parser';
import { OpenAPIV3 } from 'openapi-types';
import { ServerConfig, OpenAPISecurityConfig } from '../types/index.js';
import { createSafeJSON } from '../utils/serialization.js';
export interface OpenAPIToolInfo {
name: string;
@@ -300,31 +299,6 @@ export class OpenAPIClient {
return schema;
}
/**
* Expands parameters that may have been stringified due to circular reference handling
* This reverses the '[Circular Reference]' placeholder back to proper values when possible
*/
private expandParameter(value: unknown): unknown {
if (typeof value === 'string' && value === '[Circular Reference]') {
// Return undefined for circular references to avoid sending invalid data
return undefined;
}
if (typeof value === 'object' && value !== null) {
if (Array.isArray(value)) {
return value.map((item) => this.expandParameter(item));
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
const expanded = this.expandParameter(val);
if (expanded !== undefined) {
result[key] = expanded;
}
}
return result;
}
return value;
}
async callTool(
toolName: string,
args: Record<string, unknown>,
@@ -336,15 +310,12 @@ export class OpenAPIClient {
}
try {
// Expand any circular reference placeholders in arguments
const expandedArgs = this.expandParameter(args) as Record<string, unknown>;
// Build the request URL with path parameters
let url = tool.path;
const pathParams = tool.parameters?.filter((p) => p.in === 'path') || [];
for (const param of pathParams) {
const value = expandedArgs[param.name];
const value = args[param.name];
if (value !== undefined) {
url = url.replace(`{${param.name}}`, String(value));
}
@@ -355,7 +326,7 @@ export class OpenAPIClient {
const queryParamDefs = tool.parameters?.filter((p) => p.in === 'query') || [];
for (const param of queryParamDefs) {
const value = expandedArgs[param.name];
const value = args[param.name];
if (value !== undefined) {
queryParams[param.name] = value;
}
@@ -369,8 +340,8 @@ export class OpenAPIClient {
};
// Add request body if applicable
if (expandedArgs.body && ['post', 'put', 'patch'].includes(tool.method)) {
requestConfig.data = expandedArgs.body;
if (args.body && ['post', 'put', 'patch'].includes(tool.method)) {
requestConfig.data = args.body;
}
// Collect all headers to be sent
@@ -379,7 +350,7 @@ export class OpenAPIClient {
// Add headers if any header parameters are defined
const headerParams = tool.parameters?.filter((p) => p.in === 'header') || [];
for (const param of headerParams) {
const value = expandedArgs[param.name];
const value = args[param.name];
if (value !== undefined) {
allHeaders[param.name] = String(value);
}
@@ -412,8 +383,7 @@ export class OpenAPIClient {
}
getTools(): OpenAPIToolInfo[] {
// Return a safe copy to avoid circular reference issues
return createSafeJSON(this.tools);
return this.tools;
}
getSpec(): OpenAPIV3.Document | null {

View File

@@ -10,7 +10,7 @@ import {
toggleServerStatus,
} from '../services/mcpService.js';
import { loadSettings, saveSettings } from '../config/index.js';
import { syncAllServerToolsEmbeddings, searchToolsByVector } from '../services/vectorSearchService.js';
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
import { createSafeJSON } from '../utils/serialization.js';
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
@@ -879,74 +879,3 @@ export const updatePromptDescription = async (req: Request, res: Response): Prom
});
}
};
/**
* Search servers by semantic query using vector embeddings
* This searches through server tools and returns servers that match the query
*/
export const searchServers = async (req: Request, res: Response): Promise<void> => {
try {
const { query, limit = 10, threshold = 0.65 } = req.query;
if (!query || typeof query !== 'string') {
res.status(400).json({
success: false,
message: 'Search query is required',
});
return;
}
// Parse limit and threshold
const limitNum = typeof limit === 'string' ? parseInt(limit, 10) : Number(limit);
const thresholdNum = typeof threshold === 'string' ? parseFloat(threshold) : Number(threshold);
// Validate limit and threshold
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
res.status(400).json({
success: false,
message: 'Limit must be between 1 and 100',
});
return;
}
if (isNaN(thresholdNum) || thresholdNum < 0 || thresholdNum > 1) {
res.status(400).json({
success: false,
message: 'Threshold must be between 0 and 1',
});
return;
}
// Search for tools that match the query
const searchResults = await searchToolsByVector(query, limitNum, thresholdNum);
// Extract unique server names from search results
const serverNames = Array.from(new Set(searchResults.map((result) => result.serverName)));
// Get full server information for the matching servers
const allServers = await getServersInfo();
const matchingServers = allServers.filter((server) => serverNames.includes(server.name));
const response: ApiResponse = {
success: true,
data: {
servers: createSafeJSON(matchingServers),
matches: searchResults.map((result) => ({
serverName: result.serverName,
toolName: result.toolName,
similarity: result.similarity,
})),
query,
threshold: thresholdNum,
},
};
res.json(response);
} catch (error) {
console.error('Failed to search servers:', error);
res.status(500).json({
success: false,
message: 'Failed to search servers',
});
}
};

View File

@@ -13,7 +13,6 @@ import {
togglePrompt,
updatePromptDescription,
updateSystemConfig,
searchServers,
} from '../controllers/serverController.js';
import {
getGroups,
@@ -94,7 +93,6 @@ export const initRoutes = (app: express.Application): void => {
// API routes protected by auth middleware in middlewares/index.ts
router.get('/servers', getAllServers);
router.get('/servers/search', searchServers);
router.get('/settings', getAllSettings);
router.post('/servers', createServer);
router.put('/servers/:name', updateServer);

View File

@@ -369,6 +369,118 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
return transport;
};
// Helper function to connect an on-demand server temporarily
const connectOnDemandServer = async (serverInfo: ServerInfo): Promise<void> => {
if (!serverInfo.config) {
throw new Error(`Server configuration not found for on-demand server: ${serverInfo.name}`);
}
console.log(`Connecting on-demand server: ${serverInfo.name}`);
// Create transport
const transport = await createTransportFromConfig(serverInfo.name, serverInfo.config);
// Create client
const client = new Client(
{
name: `mcp-client-${serverInfo.name}`,
version: '1.0.0',
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
},
},
);
// Get request options from server configuration
const serverRequestOptions = serverInfo.config.options || {};
const requestOptions = {
timeout: serverRequestOptions.timeout || 60000,
resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false,
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
};
// Connect the client
await client.connect(transport, requestOptions);
// Update server info with client and transport
serverInfo.client = client;
serverInfo.transport = transport;
serverInfo.options = requestOptions;
serverInfo.status = 'connected';
console.log(`Successfully connected on-demand server: ${serverInfo.name}`);
// List tools if not already loaded
if (serverInfo.tools.length === 0) {
const capabilities = client.getServerCapabilities();
if (capabilities?.tools) {
try {
const tools = await client.listTools({}, requestOptions);
serverInfo.tools = tools.tools.map((tool) => ({
name: `${serverInfo.name}${getNameSeparator()}${tool.name}`,
description: tool.description || '',
inputSchema: cleanInputSchema(tool.inputSchema || {}),
}));
// Save tools as vector embeddings for search
saveToolsAsVectorEmbeddings(serverInfo.name, serverInfo.tools);
console.log(`Loaded ${serverInfo.tools.length} tools for on-demand server: ${serverInfo.name}`);
} catch (error) {
console.warn(`Failed to list tools for on-demand server ${serverInfo.name}:`, error);
}
}
// List prompts if available
if (capabilities?.prompts) {
try {
const prompts = await client.listPrompts({}, requestOptions);
serverInfo.prompts = prompts.prompts.map((prompt) => ({
name: `${serverInfo.name}${getNameSeparator()}${prompt.name}`,
title: prompt.title,
description: prompt.description,
arguments: prompt.arguments,
}));
console.log(`Loaded ${serverInfo.prompts.length} prompts for on-demand server: ${serverInfo.name}`);
} catch (error) {
console.warn(`Failed to list prompts for on-demand server ${serverInfo.name}:`, error);
}
}
}
};
// Helper function to disconnect an on-demand server
const disconnectOnDemandServer = (serverInfo: ServerInfo): void => {
if (serverInfo.connectionMode !== 'on-demand') {
return;
}
console.log(`Disconnecting on-demand server: ${serverInfo.name}`);
try {
if (serverInfo.client) {
serverInfo.client.close();
serverInfo.client = undefined;
}
if (serverInfo.transport) {
serverInfo.transport.close();
serverInfo.transport = undefined;
}
serverInfo.status = 'disconnected';
console.log(`Successfully disconnected on-demand server: ${serverInfo.name}`);
} catch (error) {
// Log disconnect errors but don't throw - this is cleanup code that shouldn't fail the request
// The connection is likely already closed if we get an error here
console.warn(`Error disconnecting on-demand server ${serverInfo.name}:`, error);
// Force status to disconnected even if cleanup had errors
serverInfo.status = 'disconnected';
serverInfo.client = undefined;
serverInfo.transport = undefined;
}
};
// Helper function to handle client.callTool with reconnection logic
const callToolWithReconnect = async (
serverInfo: ServerInfo,
@@ -529,7 +641,6 @@ export const initializeClientsFromSettings = async (
continue;
}
let transport;
let openApiClient;
if (expandedConf.type === 'openapi') {
// Handle OpenAPI type servers
@@ -600,10 +711,43 @@ export const initializeClientsFromSettings = async (
serverInfo.error = `Failed to initialize OpenAPI server: ${error}`;
continue;
}
} else {
transport = await createTransportFromConfig(name, expandedConf);
}
// Handle on-demand connection mode servers
// These servers connect briefly to get tools list, then disconnect
const connectionMode = expandedConf.connectionMode || 'persistent';
if (connectionMode === 'on-demand') {
console.log(`Initializing on-demand server: ${name}`);
const serverInfo: ServerInfo = {
name,
owner: expandedConf.owner,
status: 'disconnected',
error: null,
tools: [],
prompts: [],
createTime: Date.now(),
enabled: expandedConf.enabled === undefined ? true : expandedConf.enabled,
connectionMode: 'on-demand',
config: expandedConf,
};
nextServerInfos.push(serverInfo);
// Connect briefly to get tools list, then disconnect
try {
await connectOnDemandServer(serverInfo);
console.log(`Successfully initialized on-demand server: ${name} with ${serverInfo.tools.length} tools`);
// Disconnect immediately after getting tools
disconnectOnDemandServer(serverInfo);
} catch (error) {
console.error(`Failed to initialize on-demand server ${name}:`, error);
serverInfo.error = `Failed to initialize: ${error}`;
}
continue;
}
// Create transport for persistent connection mode servers (not OpenAPI, already handled above)
const transport = await createTransportFromConfig(name, expandedConf);
const client = new Client(
{
name: `mcp-client-${name}`,
@@ -644,6 +788,7 @@ export const initializeClientsFromSettings = async (
transport,
options: requestOptions,
createTime: Date.now(),
connectionMode: connectionMode,
config: expandedConf, // Store reference to expanded config
};
@@ -1011,8 +1156,11 @@ export const handleListToolsRequest = async (_: any, extra: any) => {
const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined;
// Get info about available servers, filtered by target group if specified
// Include both connected persistent servers and on-demand servers (even if disconnected)
let availableServers = serverInfos.filter(
(server) => server.status === 'connected' && server.enabled !== false,
(server) =>
server.enabled !== false &&
(server.status === 'connected' || server.connectionMode === 'on-demand'),
);
// If a target group is specified, filter servers to only those in the group
@@ -1139,6 +1287,10 @@ Available servers: ${serversList}`,
export const handleCallToolRequest = async (request: any, extra: any) => {
console.log(`Handling CallToolRequest for tool: ${JSON.stringify(request.params)}`);
try {
// Note: On-demand server connection and disconnection are handled in the specific
// code paths below (call_tool and regular tool handling) with try-finally blocks.
// This outer try-catch only handles errors from operations that don't connect servers.
// Special handling for agent group tools
if (request.params.name === 'search_tools') {
const { query, limit = 10 } = request.params.arguments || {};
@@ -1284,10 +1436,11 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
targetServerInfo = getServerByName(extra.server);
} else {
// Find the first server that has this tool
// Include both connected servers and on-demand servers (even if disconnected)
targetServerInfo = serverInfos.find(
(serverInfo) =>
serverInfo.status === 'connected' &&
serverInfo.enabled !== false &&
(serverInfo.status === 'connected' || serverInfo.connectionMode === 'on-demand') &&
serverInfo.tools.some((tool) => tool.name === toolName),
);
}
@@ -1363,6 +1516,11 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
}
// Call the tool on the target server (MCP servers)
// Connect on-demand server if needed
if (targetServerInfo.connectionMode === 'on-demand' && !targetServerInfo.client) {
await connectOnDemandServer(targetServerInfo);
}
const client = targetServerInfo.client;
if (!client) {
throw new Error(`Client not found for server: ${targetServerInfo.name}`);
@@ -1379,17 +1537,23 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
const separator = getNameSeparator();
const prefix = `${targetServerInfo.name}${separator}`;
toolName = toolName.startsWith(prefix) ? toolName.substring(prefix.length) : toolName;
const result = await callToolWithReconnect(
targetServerInfo,
{
name: toolName,
arguments: finalArgs,
},
targetServerInfo.options || {},
);
try {
const result = await callToolWithReconnect(
targetServerInfo,
{
name: toolName,
arguments: finalArgs,
},
targetServerInfo.options || {},
);
console.log(`Tool invocation result: ${JSON.stringify(result)}`);
return result;
console.log(`Tool invocation result: ${JSON.stringify(result)}`);
return result;
} finally {
// Disconnect on-demand server after tool call
disconnectOnDemandServer(targetServerInfo);
}
}
// Regular tool handling
@@ -1459,6 +1623,11 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
}
// Handle MCP servers
// Connect on-demand server if needed
if (serverInfo.connectionMode === 'on-demand' && !serverInfo.client) {
await connectOnDemandServer(serverInfo);
}
const client = serverInfo.client;
if (!client) {
throw new Error(`Client not found for server: ${serverInfo.name}`);
@@ -1469,13 +1638,19 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
request.params.name = request.params.name.startsWith(prefix)
? request.params.name.substring(prefix.length)
: request.params.name;
const result = await callToolWithReconnect(
serverInfo,
request.params,
serverInfo.options || {},
);
console.log(`Tool call result: ${JSON.stringify(result)}`);
return result;
try {
const result = await callToolWithReconnect(
serverInfo,
request.params,
serverInfo.options || {},
);
console.log(`Tool call result: ${JSON.stringify(result)}`);
return result;
} finally {
// Disconnect on-demand server after tool call
disconnectOnDemandServer(serverInfo);
}
} catch (error) {
console.error(`Error handling CallToolRequest: ${error}`);
return {

View File

@@ -204,6 +204,7 @@ export interface ServerConfig {
enabled?: boolean; // Flag to enable/disable the server
owner?: string; // Owner of the server, defaults to 'admin' user
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
connectionMode?: 'persistent' | 'on-demand'; // Connection strategy: 'persistent' maintains long-running connections (default), 'on-demand' connects only when tools are called
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
@@ -312,6 +313,7 @@ export interface ServerInfo {
options?: RequestOptions; // Options for requests
createTime: number; // Timestamp of when the server was created
enabled?: boolean; // Flag to indicate if the server is enabled
connectionMode?: 'persistent' | 'on-demand'; // Connection strategy for this server
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval
config?: ServerConfig; // Reference to the original server configuration for OpenAPI passthrough headers
oauth?: {

View File

@@ -0,0 +1,340 @@
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
// Mock dependencies before importing mcpService
jest.mock('../../src/services/oauthService.js', () => ({
initializeAllOAuthClients: jest.fn(),
}));
jest.mock('../../src/services/oauthClientRegistration.js', () => ({
registerOAuthClient: jest.fn(),
}));
jest.mock('../../src/services/mcpOAuthProvider.js', () => ({
createOAuthProvider: jest.fn(),
}));
jest.mock('../../src/services/groupService.js', () => ({
getServersInGroup: jest.fn(),
getServerConfigInGroup: jest.fn(),
}));
jest.mock('../../src/services/sseService.js', () => ({
getGroup: jest.fn(),
}));
jest.mock('../../src/services/vectorSearchService.js', () => ({
saveToolsAsVectorEmbeddings: jest.fn(),
searchToolsByVector: jest.fn(() => Promise.resolve([])),
}));
jest.mock('../../src/services/services.js', () => ({
getDataService: jest.fn(() => ({
filterData: (data: any) => data,
})),
}));
jest.mock('../../src/config/index.js', () => ({
default: {
mcpHubName: 'test-hub',
mcpHubVersion: '1.0.0',
initTimeout: 60000,
},
loadSettings: jest.fn(() => ({})),
expandEnvVars: jest.fn((val: string) => val),
replaceEnvVars: jest.fn((obj: any) => obj),
getNameSeparator: jest.fn(() => '-'),
}));
// Mock Client
const mockClient = {
connect: jest.fn(),
close: jest.fn(),
listTools: jest.fn(),
listPrompts: jest.fn(),
getServerCapabilities: jest.fn(() => ({
tools: {},
prompts: {},
})),
callTool: jest.fn(),
};
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
Client: jest.fn(() => mockClient),
}));
// Mock StdioClientTransport
const mockTransport = {
close: jest.fn(),
stderr: null,
};
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
StdioClientTransport: jest.fn(() => mockTransport),
}));
// Mock DAO
const mockServerDao = {
findAll: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
exists: jest.fn(),
setEnabled: jest.fn(),
};
jest.mock('../../src/dao/index.js', () => ({
getServerDao: jest.fn(() => mockServerDao),
}));
import { initializeClientsFromSettings, handleCallToolRequest } from '../../src/services/mcpService.js';
describe('On-Demand MCP Server Connection Mode', () => {
beforeEach(() => {
jest.clearAllMocks();
mockClient.connect.mockResolvedValue(undefined);
mockClient.close.mockReturnValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [
{
name: 'test-tool',
description: 'Test tool',
inputSchema: { type: 'object' },
},
],
});
mockClient.listPrompts.mockResolvedValue({
prompts: [],
});
mockClient.callTool.mockResolvedValue({
content: [{ type: 'text', text: 'Success' }],
});
mockTransport.close.mockReturnValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Server Initialization', () => {
it('should not maintain persistent connection for on-demand servers', async () => {
mockServerDao.findAll.mockResolvedValue([
{
name: 'on-demand-server',
command: 'node',
args: ['test.js'],
connectionMode: 'on-demand',
enabled: true,
},
]);
const serverInfos = await initializeClientsFromSettings(true);
expect(serverInfos).toHaveLength(1);
expect(serverInfos[0].name).toBe('on-demand-server');
expect(serverInfos[0].connectionMode).toBe('on-demand');
expect(serverInfos[0].status).toBe('disconnected');
// Should connect once to get tools, then disconnect
expect(mockClient.connect).toHaveBeenCalledTimes(1);
expect(mockTransport.close).toHaveBeenCalledTimes(1);
});
it('should load tools during initialization for on-demand servers', async () => {
mockServerDao.findAll.mockResolvedValue([
{
name: 'on-demand-server',
command: 'node',
args: ['test.js'],
connectionMode: 'on-demand',
enabled: true,
},
]);
const serverInfos = await initializeClientsFromSettings(true);
expect(serverInfos[0].tools).toHaveLength(1);
expect(serverInfos[0].tools[0].name).toBe('on-demand-server-test-tool');
expect(mockClient.listTools).toHaveBeenCalled();
});
it('should maintain persistent connection for default connection mode', async () => {
mockServerDao.findAll.mockResolvedValue([
{
name: 'persistent-server',
command: 'node',
args: ['test.js'],
enabled: true,
},
]);
const serverInfos = await initializeClientsFromSettings(true);
expect(serverInfos).toHaveLength(1);
expect(serverInfos[0].connectionMode).toBe('persistent');
expect(mockClient.connect).toHaveBeenCalledTimes(1);
// Should not disconnect immediately
expect(mockTransport.close).not.toHaveBeenCalled();
});
it('should handle initialization errors for on-demand servers gracefully', async () => {
mockClient.connect.mockRejectedValueOnce(new Error('Connection failed'));
mockServerDao.findAll.mockResolvedValue([
{
name: 'failing-server',
command: 'node',
args: ['test.js'],
connectionMode: 'on-demand',
enabled: true,
},
]);
const serverInfos = await initializeClientsFromSettings(true);
expect(serverInfos).toHaveLength(1);
expect(serverInfos[0].status).toBe('disconnected');
expect(serverInfos[0].error).toContain('Failed to initialize');
});
});
describe('Tool Invocation with On-Demand Servers', () => {
beforeEach(async () => {
// Set up server infos with an on-demand server that's disconnected
mockServerDao.findAll.mockResolvedValue([
{
name: 'on-demand-server',
command: 'node',
args: ['test.js'],
connectionMode: 'on-demand',
enabled: true,
},
]);
// Initialize to get the server set up
await initializeClientsFromSettings(true);
// Clear mocks after initialization
jest.clearAllMocks();
// Reset mock implementations
mockClient.connect.mockResolvedValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [
{
name: 'test-tool',
description: 'Test tool',
inputSchema: { type: 'object' },
},
],
});
mockClient.callTool.mockResolvedValue({
content: [{ type: 'text', text: 'Success' }],
});
});
it('should connect on-demand server before tool invocation', async () => {
const request = {
params: {
name: 'on-demand-server-test-tool',
arguments: { arg1: 'value1' },
},
};
await handleCallToolRequest(request, {});
// Should connect before calling the tool
expect(mockClient.connect).toHaveBeenCalledTimes(1);
expect(mockClient.callTool).toHaveBeenCalledWith(
{
name: 'test-tool',
arguments: { arg1: 'value1' },
},
undefined,
expect.any(Object),
);
});
it('should disconnect on-demand server after tool invocation', async () => {
const request = {
params: {
name: 'on-demand-server-test-tool',
arguments: {},
},
};
await handleCallToolRequest(request, {});
// Should disconnect after calling the tool
expect(mockTransport.close).toHaveBeenCalledTimes(1);
expect(mockClient.close).toHaveBeenCalledTimes(1);
});
it('should disconnect on-demand server even if tool invocation fails', async () => {
mockClient.callTool.mockRejectedValueOnce(new Error('Tool execution failed'));
const request = {
params: {
name: 'on-demand-server-test-tool',
arguments: {},
},
};
try {
await handleCallToolRequest(request, {});
} catch (error) {
// Expected to fail
}
// Should still disconnect after error
expect(mockTransport.close).toHaveBeenCalledTimes(1);
expect(mockClient.close).toHaveBeenCalledTimes(1);
});
it('should return error for call_tool if server not found', async () => {
const request = {
params: {
name: 'call_tool',
arguments: {
toolName: 'nonexistent-server-tool',
arguments: {},
},
},
};
const result = await handleCallToolRequest(request, {});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('No available servers found');
});
});
describe('Mixed Server Modes', () => {
it('should handle both persistent and on-demand servers together', async () => {
mockServerDao.findAll.mockResolvedValue([
{
name: 'persistent-server',
command: 'node',
args: ['persistent.js'],
enabled: true,
},
{
name: 'on-demand-server',
command: 'node',
args: ['on-demand.js'],
connectionMode: 'on-demand',
enabled: true,
},
]);
const serverInfos = await initializeClientsFromSettings(true);
expect(serverInfos).toHaveLength(2);
const persistentServer = serverInfos.find(s => s.name === 'persistent-server');
const onDemandServer = serverInfos.find(s => s.name === 'on-demand-server');
expect(persistentServer?.connectionMode).toBe('persistent');
expect(onDemandServer?.connectionMode).toBe('on-demand');
expect(onDemandServer?.status).toBe('disconnected');
});
});
});