mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-28 20:49:09 -05:00
Compare commits
5 Commits
copilot/fi
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee301a893f | ||
|
|
a8852f7807 | ||
|
|
d8e127d911 | ||
|
|
f782f69251 | ||
|
|
1c0473183f |
28
README.md
28
README.md
@@ -98,34 +98,6 @@ 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:
|
||||
|
||||
@@ -72,13 +72,9 @@ MCPHub uses several configuration files:
|
||||
|
||||
### Optional Fields
|
||||
|
||||
| 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)|
|
||||
| Field | Type | Default | Description |
|
||||
| -------------- | ------- | --------------- | --------------------------- |
|
||||
| `env` | object | `{}` | Environment variables |
|
||||
|
||||
## Common MCP Server Examples
|
||||
|
||||
@@ -242,68 +238,6 @@ 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
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
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 } from '@/components/icons/LucideIcons'
|
||||
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench, Download } 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
|
||||
@@ -26,6 +27,7 @@ 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
|
||||
@@ -50,6 +52,10 @@ const GroupCard = ({
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const handleInstall = () => {
|
||||
setShowInstallDialog(true)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onDelete(group.id)
|
||||
setShowDeleteDialog(false)
|
||||
@@ -183,6 +189,13 @@ 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"
|
||||
@@ -277,6 +290,20 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
219
frontend/src/components/InstallToClientDialog.tsx
Normal file
219
frontend/src/components/InstallToClientDialog.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
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;
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server } from '@/types';
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check, Download } 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;
|
||||
@@ -25,6 +26,7 @@ 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(() => {
|
||||
@@ -52,6 +54,11 @@ 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;
|
||||
@@ -310,6 +317,13 @@ 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"
|
||||
@@ -398,6 +412,13 @@ 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
Link,
|
||||
FileCode,
|
||||
ChevronDown as DropdownIcon,
|
||||
Wrench
|
||||
Wrench,
|
||||
Download
|
||||
} from 'lucide-react'
|
||||
|
||||
export {
|
||||
@@ -39,7 +40,8 @@ export {
|
||||
Link,
|
||||
FileCode,
|
||||
DropdownIcon,
|
||||
Wrench
|
||||
Wrench,
|
||||
Download
|
||||
}
|
||||
|
||||
const LucideIcons = {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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();
|
||||
@@ -27,6 +28,10 @@ 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);
|
||||
@@ -63,6 +68,31 @@ 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">
|
||||
@@ -116,6 +146,72 @@ 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">
|
||||
@@ -145,13 +241,13 @@ const ServersPage: React.FC = () => {
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
) : (searchResults ? searchResults : servers).length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 empty-state">
|
||||
<p className="text-gray-600">{t('app.noServers')}</p>
|
||||
<p className="text-gray-600">{searchResults ? t('pages.servers.noSearchResults') : t('app.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{servers.map((server, index) => (
|
||||
{(searchResults || servers).map((server, index) => (
|
||||
<ServerCard
|
||||
key={index}
|
||||
server={server}
|
||||
|
||||
@@ -268,7 +268,15 @@
|
||||
"recentServers": "Recent Servers"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Servers Management"
|
||||
"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"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Group Management"
|
||||
@@ -743,5 +751,28 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -268,7 +268,15 @@
|
||||
"recentServers": "Serveurs récents"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Gestion des serveurs"
|
||||
"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é"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gestion des groupes"
|
||||
@@ -743,5 +751,28 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -269,7 +269,15 @@
|
||||
"recentServers": "最近的服务器"
|
||||
},
|
||||
"servers": {
|
||||
"title": "服务器管理"
|
||||
"title": "服务器管理",
|
||||
"semanticSearch": "智能搜索工具...",
|
||||
"semanticSearchPlaceholder": "描述您需要的功能,例如:地图、天气、文件处理",
|
||||
"similarityThreshold": "相似度阈值",
|
||||
"similarityThresholdHelp": "较高值返回更精确结果,较低值返回更广泛匹配",
|
||||
"searchButton": "搜索",
|
||||
"clearSearch": "清除搜索",
|
||||
"searchResults": "找到 {{count}} 个匹配的服务器",
|
||||
"noSearchResults": "未找到匹配的服务器"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
@@ -745,5 +753,28 @@
|
||||
"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": "安装"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -299,6 +300,31 @@ 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>,
|
||||
@@ -310,12 +336,15 @@ 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 = args[param.name];
|
||||
const value = expandedArgs[param.name];
|
||||
if (value !== undefined) {
|
||||
url = url.replace(`{${param.name}}`, String(value));
|
||||
}
|
||||
@@ -326,7 +355,7 @@ export class OpenAPIClient {
|
||||
const queryParamDefs = tool.parameters?.filter((p) => p.in === 'query') || [];
|
||||
|
||||
for (const param of queryParamDefs) {
|
||||
const value = args[param.name];
|
||||
const value = expandedArgs[param.name];
|
||||
if (value !== undefined) {
|
||||
queryParams[param.name] = value;
|
||||
}
|
||||
@@ -340,8 +369,8 @@ export class OpenAPIClient {
|
||||
};
|
||||
|
||||
// Add request body if applicable
|
||||
if (args.body && ['post', 'put', 'patch'].includes(tool.method)) {
|
||||
requestConfig.data = args.body;
|
||||
if (expandedArgs.body && ['post', 'put', 'patch'].includes(tool.method)) {
|
||||
requestConfig.data = expandedArgs.body;
|
||||
}
|
||||
|
||||
// Collect all headers to be sent
|
||||
@@ -350,7 +379,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 = args[param.name];
|
||||
const value = expandedArgs[param.name];
|
||||
if (value !== undefined) {
|
||||
allHeaders[param.name] = String(value);
|
||||
}
|
||||
@@ -383,7 +412,8 @@ export class OpenAPIClient {
|
||||
}
|
||||
|
||||
getTools(): OpenAPIToolInfo[] {
|
||||
return this.tools;
|
||||
// Return a safe copy to avoid circular reference issues
|
||||
return createSafeJSON(this.tools);
|
||||
}
|
||||
|
||||
getSpec(): OpenAPIV3.Document | null {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
toggleServerStatus,
|
||||
} from '../services/mcpService.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||
import { syncAllServerToolsEmbeddings, searchToolsByVector } from '../services/vectorSearchService.js';
|
||||
import { createSafeJSON } from '../utils/serialization.js';
|
||||
|
||||
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
|
||||
@@ -879,3 +879,74 @@ 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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
togglePrompt,
|
||||
updatePromptDescription,
|
||||
updateSystemConfig,
|
||||
searchServers,
|
||||
} from '../controllers/serverController.js';
|
||||
import {
|
||||
getGroups,
|
||||
@@ -93,6 +94,7 @@ 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);
|
||||
|
||||
@@ -369,118 +369,6 @@ 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,
|
||||
@@ -641,6 +529,7 @@ export const initializeClientsFromSettings = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
let transport;
|
||||
let openApiClient;
|
||||
if (expandedConf.type === 'openapi') {
|
||||
// Handle OpenAPI type servers
|
||||
@@ -711,43 +600,10 @@ 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}`,
|
||||
@@ -788,7 +644,6 @@ export const initializeClientsFromSettings = async (
|
||||
transport,
|
||||
options: requestOptions,
|
||||
createTime: Date.now(),
|
||||
connectionMode: connectionMode,
|
||||
config: expandedConf, // Store reference to expanded config
|
||||
};
|
||||
|
||||
@@ -1156,11 +1011,8 @@ 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.enabled !== false &&
|
||||
(server.status === 'connected' || server.connectionMode === 'on-demand'),
|
||||
(server) => server.status === 'connected' && server.enabled !== false,
|
||||
);
|
||||
|
||||
// If a target group is specified, filter servers to only those in the group
|
||||
@@ -1287,10 +1139,6 @@ 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 || {};
|
||||
@@ -1436,11 +1284,10 @@ 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),
|
||||
);
|
||||
}
|
||||
@@ -1516,11 +1363,6 @@ 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}`);
|
||||
@@ -1537,23 +1379,17 @@ 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;
|
||||
|
||||
try {
|
||||
const result = await callToolWithReconnect(
|
||||
targetServerInfo,
|
||||
{
|
||||
name: toolName,
|
||||
arguments: finalArgs,
|
||||
},
|
||||
targetServerInfo.options || {},
|
||||
);
|
||||
const result = await callToolWithReconnect(
|
||||
targetServerInfo,
|
||||
{
|
||||
name: toolName,
|
||||
arguments: finalArgs,
|
||||
},
|
||||
targetServerInfo.options || {},
|
||||
);
|
||||
|
||||
console.log(`Tool invocation result: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
} finally {
|
||||
// Disconnect on-demand server after tool call
|
||||
disconnectOnDemandServer(targetServerInfo);
|
||||
}
|
||||
console.log(`Tool invocation result: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Regular tool handling
|
||||
@@ -1623,11 +1459,6 @@ 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}`);
|
||||
@@ -1638,19 +1469,13 @@ 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;
|
||||
|
||||
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);
|
||||
}
|
||||
const result = await callToolWithReconnect(
|
||||
serverInfo,
|
||||
request.params,
|
||||
serverInfo.options || {},
|
||||
);
|
||||
console.log(`Tool call result: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error handling CallToolRequest: ${error}`);
|
||||
return {
|
||||
|
||||
@@ -204,7 +204,6 @@ 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
|
||||
@@ -313,7 +312,6 @@ 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?: {
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user