Compare commits

..

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ee301a893f Add one-click installation dialog for servers and groups
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:25:01 +00:00
copilot-swe-agent[bot]
a8852f7807 Add semantic search UI to servers management page
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:16:34 +00:00
copilot-swe-agent[bot]
d8e127d911 Add semantic search API endpoint for servers
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:12:13 +00:00
copilot-swe-agent[bot]
f782f69251 Fix circular reference issue in OpenAPI tool parameters
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:09:30 +00:00
copilot-swe-agent[bot]
1c0473183f Initial plan 2025-10-31 15:01:56 +00:00
13 changed files with 578 additions and 247 deletions

View File

@@ -1,182 +0,0 @@
# Transport Event Handlers Fix
## Problem Statement
After adding SSE (Server-Sent Events) or Streamable HTTP protocol servers, the server status did not automatically update when connections failed or closed. The status remained "connected" even when the connection was lost.
## Root Cause
The MCP SDK provides `onclose` and `onerror` event handlers for all transport types (SSE, StreamableHTTP, and stdio), but the MCPHub implementation was not setting up these handlers. This meant that:
1. When a connection closed unexpectedly, the server status remained "connected"
2. When transport errors occurred, the status was not updated
3. Users could not see the actual connection state in the dashboard
## Solution
Added a `setupTransportEventHandlers()` helper function that:
1. Sets up `onclose` handler to update status to 'disconnected' when connections close
2. Sets up `onerror` handler to update status and capture error messages
3. Clears keep-alive ping intervals when connections fail
4. Logs connection state changes for debugging
The handlers are set up in two places:
1. After successful initial connection in `initializeClientsFromSettings()`
2. After reconnection in `callToolWithReconnect()`
## Changes Made
### File: `src/services/mcpService.ts`
#### New Function: `setupTransportEventHandlers()`
```typescript
const setupTransportEventHandlers = (serverInfo: ServerInfo): void => {
if (!serverInfo.transport) {
return;
}
// Set up onclose handler to update status when connection closes
serverInfo.transport.onclose = () => {
console.log(`Transport closed for server: ${serverInfo.name}`);
if (serverInfo.status === 'connected') {
serverInfo.status = 'disconnected';
serverInfo.error = 'Connection closed';
}
// Clear keep-alive interval if it exists
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
serverInfo.keepAliveIntervalId = undefined;
}
};
// Set up onerror handler to update status on connection errors
serverInfo.transport.onerror = (error: Error) => {
console.error(`Transport error for server ${serverInfo.name}:`, error);
if (serverInfo.status === 'connected') {
serverInfo.status = 'disconnected';
serverInfo.error = `Transport error: ${error.message}`;
}
// Clear keep-alive interval if it exists
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
serverInfo.keepAliveIntervalId = undefined;
}
};
console.log(`Transport event handlers set up for server: ${serverInfo.name}`);
};
```
#### Integration Points
1. **Initial Connection** - Added call after successful connection:
```typescript
if (!dataError) {
serverInfo.status = 'connected';
serverInfo.error = null;
// Set up transport event handlers for connection monitoring
setupTransportEventHandlers(serverInfo);
// Set up keep-alive ping for SSE connections
setupKeepAlive(serverInfo, expandedConf);
}
```
2. **Reconnection** - Added call after reconnection succeeds:
```typescript
// Update server info with new client and transport
serverInfo.client = client;
serverInfo.transport = newTransport;
serverInfo.status = 'connected';
// Set up transport event handlers for the new connection
setupTransportEventHandlers(serverInfo);
```
## Testing
### Automated Tests
All 169 existing tests pass, including:
- Integration tests for SSE transport (`tests/integration/sse-service-real-client.test.ts`)
- Integration tests for StreamableHTTP transport
- Unit tests for MCP service functionality
### Manual Testing
To manually test the fix:
1. **Add an SSE server** to `mcp_settings.json`:
```json
{
"mcpServers": {
"test-sse-server": {
"type": "sse",
"url": "http://localhost:9999/sse",
"enabled": true
}
}
}
```
2. **Start MCPHub**: `pnpm dev`
3. **Observe the behavior**:
- Server will initially show as "connecting"
- When connection fails (port 9999 not available), status will update to "disconnected"
- Error message will show: "Transport error: ..." or "Connection closed"
4. **Test connection recovery**:
- Start an MCP server on the configured URL
- The status should update to "connected" when available
- Stop the MCP server
- The status should update back to "disconnected"
### StreamableHTTP Testing
1. **Add a StreamableHTTP server** to `mcp_settings.json`:
```json
{
"mcpServers": {
"test-http-server": {
"type": "streamable-http",
"url": "http://localhost:9999/mcp",
"enabled": true
}
}
}
```
2. Follow the same testing steps as SSE
## Benefits
1. **Accurate Status**: Server status now reflects actual connection state
2. **Better UX**: Users can see when connections fail in real-time
3. **Debugging**: Error messages help diagnose connection issues
4. **Resource Management**: Keep-alive intervals are properly cleaned up on connection failures
5. **Consistent Behavior**: All transport types (SSE, StreamableHTTP, stdio) now have proper event handling
## Compatibility
- **Backwards Compatible**: No breaking changes to existing functionality
- **SDK Version**: Requires `@modelcontextprotocol/sdk` v1.20.2 or higher (current version in use)
- **Node.js**: Compatible with all supported Node.js versions
- **Transport Types**: Works with SSEClientTransport, StreamableHTTPClientTransport, and StdioClientTransport
Note: The `onclose` and `onerror` event handlers are part of the Transport interface in the MCP SDK and have been available since early versions. The current implementation has been tested with SDK v1.20.2.
## Future Enhancements
Potential improvements for the future:
1. Add automatic reconnection logic for transient failures
2. Add connection health metrics (uptime, error count)
3. Emit events for UI notifications when status changes
4. Add configurable retry strategies per server

View File

@@ -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>
)
}

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

View File

@@ -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)}
/>
)}
</>
);
};

View File

@@ -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 = {

View File

@@ -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}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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": "安装"
}
}

View File

@@ -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 {

View File

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

View File

@@ -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);

View File

@@ -136,48 +136,6 @@ const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): voi
);
};
// Helper function to clean up server resources on disconnection
const cleanupServerResources = (serverInfo: ServerInfo): void => {
// Clear keep-alive interval if it exists
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
serverInfo.keepAliveIntervalId = undefined;
}
};
// Helper function to set up transport event handlers for connection monitoring
const setupTransportEventHandlers = (serverInfo: ServerInfo): void => {
if (!serverInfo.transport) {
return;
}
// Set up onclose handler to update status when connection closes
serverInfo.transport.onclose = () => {
console.log(`Transport closed for server: ${serverInfo.name}`);
// Update status to disconnected if not already in a terminal state
if (serverInfo.status === 'connected' || serverInfo.status === 'connecting') {
serverInfo.status = 'disconnected';
serverInfo.error = 'Connection closed';
}
cleanupServerResources(serverInfo);
};
// Set up onerror handler to update status on connection errors
serverInfo.transport.onerror = (error: Error) => {
console.error(`Transport error for server ${serverInfo.name}:`, error);
// Update status to disconnected if not already in a terminal state
if (serverInfo.status === 'connected' || serverInfo.status === 'connecting') {
serverInfo.status = 'disconnected';
serverInfo.error = `Transport error: ${error.message}`;
}
cleanupServerResources(serverInfo);
};
console.log(`Transport event handlers set up for server: ${serverInfo.name}`);
};
export const initUpstreamServers = async (): Promise<void> => {
// Initialize OAuth clients for servers with dynamic registration
await initializeAllOAuthClients();
@@ -482,9 +440,6 @@ const callToolWithReconnect = async (
serverInfo.transport = newTransport;
serverInfo.status = 'connected';
// Set up transport event handlers for the new connection
setupTransportEventHandlers(serverInfo);
// Reload tools list after reconnection
try {
const tools = await client.listTools({}, serverInfo.options || {});
@@ -759,9 +714,6 @@ export const initializeClientsFromSettings = async (
serverInfo.status = 'connected';
serverInfo.error = null;
// Set up transport event handlers for connection monitoring
setupTransportEventHandlers(serverInfo);
// Set up keep-alive ping for SSE connections
setupKeepAlive(serverInfo, expandedConf);
} else {