mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
10 Commits
v0.10.0
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee301a893f | ||
|
|
a8852f7807 | ||
|
|
d8e127d911 | ||
|
|
f782f69251 | ||
|
|
1c0473183f | ||
|
|
44e0309fd4 | ||
|
|
7e570a900a | ||
|
|
6268a02c0e | ||
|
|
695d663939 | ||
|
|
d595e5d874 |
22
Dockerfile
22
Dockerfile
@@ -9,9 +9,25 @@ RUN apt-get update && apt-get install -y curl gnupg git \
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
ENV PNPM_HOME=/usr/local/share/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN mkdir -p $PNPM_HOME && \
|
||||
ENV MCP_DATA_DIR=/app/data
|
||||
ENV MCP_SERVERS_DIR=$MCP_DATA_DIR/servers
|
||||
ENV MCP_NPM_DIR=$MCP_SERVERS_DIR/npm
|
||||
ENV MCP_PYTHON_DIR=$MCP_SERVERS_DIR/python
|
||||
ENV PNPM_HOME=$MCP_DATA_DIR/pnpm
|
||||
ENV NPM_CONFIG_PREFIX=$MCP_DATA_DIR/npm-global
|
||||
ENV NPM_CONFIG_CACHE=$MCP_DATA_DIR/npm-cache
|
||||
ENV UV_TOOL_DIR=$MCP_DATA_DIR/uv/tools
|
||||
ENV UV_CACHE_DIR=$MCP_DATA_DIR/uv/cache
|
||||
ENV PATH=$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH
|
||||
RUN mkdir -p \
|
||||
$PNPM_HOME \
|
||||
$NPM_CONFIG_PREFIX/bin \
|
||||
$NPM_CONFIG_PREFIX/lib/node_modules \
|
||||
$NPM_CONFIG_CACHE \
|
||||
$UV_TOOL_DIR \
|
||||
$UV_CACHE_DIR \
|
||||
$MCP_NPM_DIR \
|
||||
$MCP_PYTHON_DIR && \
|
||||
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
|
||||
|
||||
ARG INSTALL_EXT=false
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
DATA_DIR=${MCP_DATA_DIR:-/app/data}
|
||||
SERVERS_DIR=${MCP_SERVERS_DIR:-$DATA_DIR/servers}
|
||||
NPM_SERVER_DIR=${MCP_NPM_DIR:-$SERVERS_DIR/npm}
|
||||
PYTHON_SERVER_DIR=${MCP_PYTHON_DIR:-$SERVERS_DIR/python}
|
||||
PNPM_HOME=${PNPM_HOME:-$DATA_DIR/pnpm}
|
||||
NPM_CONFIG_PREFIX=${NPM_CONFIG_PREFIX:-$DATA_DIR/npm-global}
|
||||
NPM_CONFIG_CACHE=${NPM_CONFIG_CACHE:-$DATA_DIR/npm-cache}
|
||||
UV_TOOL_DIR=${UV_TOOL_DIR:-$DATA_DIR/uv/tools}
|
||||
UV_CACHE_DIR=${UV_CACHE_DIR:-$DATA_DIR/uv/cache}
|
||||
|
||||
mkdir -p \
|
||||
"$PNPM_HOME" \
|
||||
"$NPM_CONFIG_PREFIX/bin" \
|
||||
"$NPM_CONFIG_PREFIX/lib/node_modules" \
|
||||
"$NPM_CONFIG_CACHE" \
|
||||
"$UV_TOOL_DIR" \
|
||||
"$UV_CACHE_DIR" \
|
||||
"$NPM_SERVER_DIR" \
|
||||
"$PYTHON_SERVER_DIR"
|
||||
|
||||
export PATH="$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH"
|
||||
|
||||
NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||
echo "Setting npm registry to ${NPM_REGISTRY}"
|
||||
npm config set registry "$NPM_REGISTRY"
|
||||
|
||||
@@ -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;
|
||||
@@ -19,7 +19,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
onBack,
|
||||
onInstall,
|
||||
installing = false,
|
||||
isInstalled = false
|
||||
isInstalled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -32,21 +32,23 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
const getButtonProps = () => {
|
||||
if (isInstalled) {
|
||||
return {
|
||||
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
|
||||
className: 'bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white',
|
||||
disabled: true,
|
||||
text: t('market.installed')
|
||||
text: t('market.installed'),
|
||||
};
|
||||
} else if (installing) {
|
||||
return {
|
||||
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
|
||||
className:
|
||||
'bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white',
|
||||
disabled: true,
|
||||
text: t('market.installing')
|
||||
text: t('market.installing'),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
|
||||
className:
|
||||
'bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary',
|
||||
disabled: false,
|
||||
text: t('market.install')
|
||||
text: t('market.install'),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -133,12 +135,18 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-gray-600 hover:text-gray-900 flex items-center"
|
||||
>
|
||||
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
<button onClick={onBack} className="text-gray-600 hover:text-gray-900 flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 mr-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t('market.backToList')}
|
||||
</button>
|
||||
@@ -150,7 +158,8 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
{server.display_name}
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
|
||||
<span className="text-sm font-normal text-gray-600 ml-4">
|
||||
{t('market.author')}: {server.author.name} • {t('market.license')}: {server.license} •
|
||||
{t('market.author')}: {server.author?.name || t('market.unknown')} •{' '}
|
||||
{t('market.license')}: {server.license} •
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
@@ -182,18 +191,24 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<p className="text-gray-700 mb-6">{server.description}</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.categories')} & {t('market.tags')}</h3>
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
{t('market.categories')} & {t('market.tags')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{server.categories?.map((category, index) => (
|
||||
<span key={`cat-${index}`} className="bg-gray-100 text-gray-800 px-3 py-1 rounded">
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
{server.tags && server.tags.map((tag, index) => (
|
||||
<span key={`tag-${index}`} className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{server.tags &&
|
||||
server.tags.map((tag, index) => (
|
||||
<span
|
||||
key={`tag-${index}`}
|
||||
className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,9 +239,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{name}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{arg.description}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{arg.description}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{arg.required ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
@@ -268,7 +281,10 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
</h4>
|
||||
<p className="text-gray-600 mb-2">{tool.description}</p>
|
||||
<div className="mt-2">
|
||||
<pre id={`schema-${index}`} className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2">
|
||||
<pre
|
||||
id={`schema-${index}`}
|
||||
className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2"
|
||||
>
|
||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -285,9 +301,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<div key={index} className="border border-gray-200 rounded p-4">
|
||||
<h4 className="font-medium mb-2">{example.title}</h4>
|
||||
<p className="text-gray-600 mb-2">{example.description}</p>
|
||||
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">
|
||||
{example.prompt}
|
||||
</pre>
|
||||
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">{example.prompt}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -316,11 +330,11 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
status: 'disconnected',
|
||||
config: preferredInstallation
|
||||
? {
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {}
|
||||
}
|
||||
: undefined
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {},
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -332,14 +346,16 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{t('server.confirmVariables')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('server.variablesDetected')}
|
||||
</p>
|
||||
<p className="text-gray-600 mb-4">{t('server.variablesDetected')}</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
@@ -356,14 +372,12 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
{t('market.confirmVariablesMessage')}
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm mb-6">{t('market.confirmVariablesMessage')}</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmationVisible(false)
|
||||
setPendingPayload(null)
|
||||
setConfirmationVisible(false);
|
||||
setPendingPayload(null);
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
|
||||
>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -287,9 +287,13 @@ export const useCloudData = () => {
|
||||
const callServerTool = useCallback(
|
||||
async (serverName: string, toolName: string, args: Record<string, any>) => {
|
||||
try {
|
||||
const data = await apiPost(`/cloud/servers/${serverName}/tools/${toolName}/call`, {
|
||||
arguments: args,
|
||||
});
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const data = await apiPost(
|
||||
`/cloud/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/call`,
|
||||
{
|
||||
arguments: args,
|
||||
},
|
||||
);
|
||||
|
||||
if (data && data.success) {
|
||||
return data.data;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -59,8 +59,9 @@ export const getPrompt = async (
|
||||
server?: string,
|
||||
): Promise<GetPromptResult> => {
|
||||
try {
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost(
|
||||
`/mcp/${server}/prompts/${encodeURIComponent(request.promptName)}`,
|
||||
`/mcp/${encodeURIComponent(server || '')}/prompts/${encodeURIComponent(request.promptName)}`,
|
||||
{
|
||||
name: request.promptName,
|
||||
arguments: request.arguments,
|
||||
@@ -94,9 +95,13 @@ export const togglePrompt = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const response = await apiPost<any>(`/servers/${serverName}/prompts/${promptName}/toggle`, {
|
||||
enabled,
|
||||
});
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost<any>(
|
||||
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/toggle`,
|
||||
{
|
||||
enabled,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
@@ -120,8 +125,9 @@ export const updatePromptDescription = async (
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${serverName}/prompts/${promptName}/description`,
|
||||
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/description`,
|
||||
{ description },
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -25,7 +25,10 @@ export const callTool = async (
|
||||
): Promise<ToolCallResult> => {
|
||||
try {
|
||||
// Construct the URL with optional server parameter
|
||||
const url = server ? `/tools/${server}/${request.toolName}` : '/tools/call';
|
||||
// URL-encode server and tool names to handle slashes in names (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const url = server
|
||||
? `/tools/${encodeURIComponent(server)}/${encodeURIComponent(request.toolName)}`
|
||||
: '/tools/call';
|
||||
|
||||
const response = await apiPost<any>(url, request.arguments, {
|
||||
headers: {
|
||||
@@ -62,8 +65,9 @@ export const toggleTool = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/toggle`,
|
||||
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`,
|
||||
{ enabled },
|
||||
{
|
||||
headers: {
|
||||
@@ -94,8 +98,9 @@ export const updateToolDescription = async (
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/description`,
|
||||
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/description`,
|
||||
{ description },
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -207,7 +207,8 @@ export const getCloudServersByTag = async (req: Request, res: Response): Promise
|
||||
// Get tools for a specific cloud server
|
||||
export const getCloudServerToolsList = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName } = req.params;
|
||||
// Decode URL-encoded parameter to handle slashes in server name
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
if (!serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -236,7 +237,9 @@ export const getCloudServerToolsList = async (req: Request, res: Response): Prom
|
||||
// Call a tool on a cloud server
|
||||
export const callCloudTool = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { arguments: args } = req.body;
|
||||
|
||||
if (!serverName) {
|
||||
|
||||
@@ -8,82 +8,13 @@ import {
|
||||
import { getServerByName } from '../services/mcpService.js';
|
||||
import { getGroupByIdOrName } from '../services/groupService.js';
|
||||
import { getNameSeparator } from '../config/index.js';
|
||||
import { convertParametersToTypes } from '../utils/parameterConversion.js';
|
||||
|
||||
/**
|
||||
* Controller for OpenAPI generation endpoints
|
||||
* Provides OpenAPI specifications for MCP tools to enable OpenWebUI integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert query parameters to their proper types based on the tool's input schema
|
||||
*/
|
||||
function convertQueryParametersToTypes(
|
||||
queryParams: Record<string, any>,
|
||||
inputSchema: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map((item) => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and return OpenAPI specification
|
||||
* GET /api/openapi.json
|
||||
@@ -167,7 +98,9 @@ export const getOpenAPIStats = async (req: Request, res: Response): Promise<void
|
||||
*/
|
||||
export const executeToolViaOpenAPI = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
|
||||
// Import handleCallToolRequest function
|
||||
const { handleCallToolRequest } = await import('../services/mcpService.js');
|
||||
@@ -189,7 +122,7 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
|
||||
|
||||
// Prepare arguments from query params (GET) or body (POST)
|
||||
let args = req.method === 'GET' ? req.query : req.body || {};
|
||||
args = convertQueryParametersToTypes(args, inputSchema);
|
||||
args = convertParametersToTypes(args, inputSchema);
|
||||
|
||||
// Create a mock request structure that matches what handleCallToolRequest expects
|
||||
const mockRequest = {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { handleGetPromptRequest } from '../services/mcpService.js';
|
||||
*/
|
||||
export const getPrompt = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
if (!serverName || !promptName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
||||
@@ -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> => {
|
||||
@@ -375,7 +375,9 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
// Toggle tool status for a specific server
|
||||
export const toggleTool = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!serverName || !toolName) {
|
||||
@@ -437,7 +439,9 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
|
||||
// Update tool description for a specific server
|
||||
export const updateToolDescription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { description } = req.body;
|
||||
|
||||
if (!serverName || !toolName) {
|
||||
@@ -747,7 +751,9 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
// Toggle prompt status for a specific server
|
||||
export const togglePrompt = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!serverName || !promptName) {
|
||||
@@ -809,7 +815,9 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
|
||||
// Update prompt description for a specific server
|
||||
export const updatePromptDescription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
const { description } = req.body;
|
||||
|
||||
if (!serverName || !promptName) {
|
||||
@@ -871,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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import { handleCallToolRequest } from '../services/mcpService.js';
|
||||
import { handleCallToolRequest, getServerByName } from '../services/mcpService.js';
|
||||
import { convertParametersToTypes } from '../utils/parameterConversion.js';
|
||||
import { getNameSeparator } from '../config/index.js';
|
||||
|
||||
/**
|
||||
* Interface for tool call request
|
||||
@@ -47,13 +49,31 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the server info to access the tool's input schema
|
||||
const serverInfo = getServerByName(server);
|
||||
let inputSchema: Record<string, any> = {};
|
||||
|
||||
if (serverInfo) {
|
||||
// Find the tool in the server's tools list
|
||||
const fullToolName = `${server}${getNameSeparator()}${toolName}`;
|
||||
const tool = serverInfo.tools.find(
|
||||
(t: any) => t.name === fullToolName || t.name === toolName,
|
||||
);
|
||||
if (tool && tool.inputSchema) {
|
||||
inputSchema = tool.inputSchema as Record<string, any>;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert parameters to proper types based on the tool's input schema
|
||||
const convertedArgs = convertParametersToTypes(toolArgs, inputSchema);
|
||||
|
||||
// Create a mock request structure for handleCallToolRequest
|
||||
const mockRequest = {
|
||||
params: {
|
||||
name: 'call_tool',
|
||||
arguments: {
|
||||
toolName,
|
||||
arguments: toolArgs,
|
||||
arguments: convertedArgs,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -71,7 +91,7 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
|
||||
data: {
|
||||
content: result.content || [],
|
||||
toolName,
|
||||
arguments: toolArgs,
|
||||
arguments: convertedArgs,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -78,28 +78,28 @@ export class AppServer {
|
||||
console.log('MCP server initialized successfully');
|
||||
|
||||
// Original routes (global and group-based)
|
||||
this.app.get(`${this.basePath}/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||
this.app.get(`${this.basePath}/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(`${this.basePath}/messages`, sseUserContextMiddleware, handleSseMessage);
|
||||
this.app.post(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
`${this.basePath}/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpPostRequest,
|
||||
);
|
||||
this.app.get(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
`${this.basePath}/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
this.app.delete(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
`${this.basePath}/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
|
||||
// User-scoped routes with user context middleware
|
||||
this.app.get(`${this.basePath}/:user/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||
this.app.get(`${this.basePath}/:user/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(
|
||||
@@ -108,17 +108,17 @@ export class AppServer {
|
||||
handleSseMessage,
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
`${this.basePath}/:user/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpPostRequest,
|
||||
);
|
||||
this.app.get(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
`${this.basePath}/:user/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
this.app.delete(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
`${this.basePath}/:user/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
@@ -31,6 +33,77 @@ const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const ensureDirExists = (dir: string | undefined): string => {
|
||||
if (!dir) {
|
||||
throw new Error('Directory path is undefined');
|
||||
}
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getDataRootDir = (): string => {
|
||||
return ensureDirExists(process.env.MCP_DATA_DIR || path.join(process.cwd(), 'data'));
|
||||
};
|
||||
|
||||
const getServersStorageRoot = (): string => {
|
||||
return ensureDirExists(process.env.MCP_SERVERS_DIR || path.join(getDataRootDir(), 'servers'));
|
||||
};
|
||||
|
||||
const getNpmBaseDir = (): string => {
|
||||
return ensureDirExists(process.env.MCP_NPM_DIR || path.join(getServersStorageRoot(), 'npm'));
|
||||
};
|
||||
|
||||
const getPythonBaseDir = (): string => {
|
||||
return ensureDirExists(
|
||||
process.env.MCP_PYTHON_DIR || path.join(getServersStorageRoot(), 'python'),
|
||||
);
|
||||
};
|
||||
|
||||
const getNpmCacheDir = (): string => {
|
||||
return ensureDirExists(process.env.NPM_CONFIG_CACHE || path.join(getDataRootDir(), 'npm-cache'));
|
||||
};
|
||||
|
||||
const getNpmPrefixDir = (): string => {
|
||||
const dir = ensureDirExists(
|
||||
process.env.NPM_CONFIG_PREFIX || path.join(getDataRootDir(), 'npm-global'),
|
||||
);
|
||||
ensureDirExists(path.join(dir, 'bin'));
|
||||
ensureDirExists(path.join(dir, 'lib', 'node_modules'));
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getUvCacheDir = (): string => {
|
||||
return ensureDirExists(process.env.UV_CACHE_DIR || path.join(getDataRootDir(), 'uv', 'cache'));
|
||||
};
|
||||
|
||||
const getUvToolDir = (): string => {
|
||||
const dir = ensureDirExists(process.env.UV_TOOL_DIR || path.join(getDataRootDir(), 'uv', 'tools'));
|
||||
ensureDirExists(path.join(dir, 'bin'));
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getServerInstallDir = (serverName: string, kind: 'npm' | 'python'): string => {
|
||||
const baseDir = kind === 'npm' ? getNpmBaseDir() : getPythonBaseDir();
|
||||
return ensureDirExists(path.join(baseDir, serverName));
|
||||
};
|
||||
|
||||
const prependToPath = (currentPath: string, dir: string): string => {
|
||||
if (!dir) {
|
||||
return currentPath;
|
||||
}
|
||||
const delimiter = path.delimiter;
|
||||
const segments = currentPath ? currentPath.split(delimiter) : [];
|
||||
if (segments.includes(dir)) {
|
||||
return currentPath;
|
||||
}
|
||||
return currentPath ? `${dir}${delimiter}${currentPath}` : dir;
|
||||
};
|
||||
|
||||
const NODE_COMMANDS = new Set(['npm', 'npx', 'pnpm', 'yarn', 'node', 'bun', 'bunx']);
|
||||
const PYTHON_COMMANDS = new Set(['uv', 'uvx', 'python', 'pip', 'pip3', 'pipx']);
|
||||
|
||||
// Helper function to set up keep-alive ping for SSE connections
|
||||
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
|
||||
// Only set up keep-alive for SSE connections
|
||||
@@ -213,7 +286,7 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
||||
...(process.env as Record<string, string>),
|
||||
...replaceEnvVars(conf.env || {}),
|
||||
};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
env['PATH'] = expandEnvVars(env['PATH'] || process.env.PATH || '');
|
||||
|
||||
const settings = loadSettings();
|
||||
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
|
||||
@@ -235,9 +308,52 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
// Ensure stdio servers use persistent directories under /app/data (or configured override)
|
||||
let workingDirectory = os.homedir();
|
||||
const commandLower = conf.command.toLowerCase();
|
||||
|
||||
if (NODE_COMMANDS.has(commandLower)) {
|
||||
const serverDir = getServerInstallDir(name, 'npm');
|
||||
workingDirectory = serverDir;
|
||||
|
||||
const npmCacheDir = getNpmCacheDir();
|
||||
const npmPrefixDir = getNpmPrefixDir();
|
||||
|
||||
if (!env['npm_config_cache']) {
|
||||
env['npm_config_cache'] = npmCacheDir;
|
||||
}
|
||||
if (!env['NPM_CONFIG_CACHE']) {
|
||||
env['NPM_CONFIG_CACHE'] = env['npm_config_cache'];
|
||||
}
|
||||
|
||||
if (!env['npm_config_prefix']) {
|
||||
env['npm_config_prefix'] = npmPrefixDir;
|
||||
}
|
||||
if (!env['NPM_CONFIG_PREFIX']) {
|
||||
env['NPM_CONFIG_PREFIX'] = env['npm_config_prefix'];
|
||||
}
|
||||
|
||||
env['PATH'] = prependToPath(env['PATH'], path.join(env['npm_config_prefix'], 'bin'));
|
||||
} else if (PYTHON_COMMANDS.has(commandLower)) {
|
||||
const serverDir = getServerInstallDir(name, 'python');
|
||||
workingDirectory = serverDir;
|
||||
|
||||
const uvCacheDir = getUvCacheDir();
|
||||
const uvToolDir = getUvToolDir();
|
||||
|
||||
if (!env['UV_CACHE_DIR']) {
|
||||
env['UV_CACHE_DIR'] = uvCacheDir;
|
||||
}
|
||||
if (!env['UV_TOOL_DIR']) {
|
||||
env['UV_TOOL_DIR'] = uvToolDir;
|
||||
}
|
||||
|
||||
env['PATH'] = prependToPath(env['PATH'], path.join(env['UV_TOOL_DIR'], 'bin'));
|
||||
}
|
||||
|
||||
// Expand environment variables in command
|
||||
transport = new StdioClientTransport({
|
||||
cwd: os.homedir(),
|
||||
cwd: workingDirectory,
|
||||
command: conf.command,
|
||||
args: replaceEnvVars(conf.args) as string[],
|
||||
env: env,
|
||||
|
||||
@@ -225,13 +225,22 @@ export async function generateOpenAPISpec(
|
||||
|
||||
// Generate paths from tools
|
||||
const paths: OpenAPIV3.PathsObject = {};
|
||||
const separator = getNameSeparator();
|
||||
|
||||
for (const { tool, serverName } of allTools) {
|
||||
const operation = generateOperationFromTool(tool, serverName);
|
||||
const { requestBody } = convertToolSchemaToOpenAPI(tool);
|
||||
|
||||
// Create path for the tool
|
||||
const pathName = `/tools/${serverName}/${tool.name}`;
|
||||
// Extract the tool name without server prefix
|
||||
// Tool names are in format: serverName + separator + toolName
|
||||
const prefix = `${serverName}${separator}`;
|
||||
const toolNameOnly = tool.name.startsWith(prefix)
|
||||
? tool.name.substring(prefix.length)
|
||||
: tool.name;
|
||||
|
||||
// Create path for the tool with URL-encoded server and tool names
|
||||
// This handles cases where names contain slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const pathName = `/tools/${encodeURIComponent(serverName)}/${encodeURIComponent(toolNameOnly)}`;
|
||||
const method = requestBody ? 'post' : 'get';
|
||||
|
||||
if (!paths[pathName]) {
|
||||
|
||||
93
src/utils/parameterConversion.ts
Normal file
93
src/utils/parameterConversion.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Utility functions for converting parameter types based on JSON schema definitions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert parameters to their proper types based on the tool's input schema
|
||||
* This ensures that form-submitted string values are converted to the correct types
|
||||
* (e.g., numbers, booleans, arrays) before being passed to MCP tools.
|
||||
*
|
||||
* @param params - The parameters to convert (typically from form submission)
|
||||
* @param inputSchema - The JSON schema definition for the tool's input
|
||||
* @returns The converted parameters with proper types
|
||||
*/
|
||||
export function convertParametersToTypes(
|
||||
params: Record<string, any>,
|
||||
inputSchema: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return params;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map((item) => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
// Handle object conversion if needed
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
convertedParams[key] = JSON.parse(value);
|
||||
} catch {
|
||||
// If parsing fails, keep as is
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
@@ -1,73 +1,7 @@
|
||||
// Simple unit test to validate the type conversion logic
|
||||
describe('Parameter Type Conversion Logic', () => {
|
||||
// Extract the conversion function for testing
|
||||
function convertQueryParametersToTypes(
|
||||
queryParams: Record<string, any>,
|
||||
inputSchema: Record<string, any>
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map(item => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
import { convertParametersToTypes } from '../../src/utils/parameterConversion.js';
|
||||
|
||||
// Integration tests for OpenAPI controller's parameter type conversion
|
||||
describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
|
||||
test('should convert integer parameters correctly', () => {
|
||||
const queryParams = {
|
||||
limit: '5',
|
||||
@@ -84,7 +18,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 5, // Converted to integer
|
||||
@@ -107,7 +41,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
price: 19.99,
|
||||
@@ -133,7 +67,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
@@ -157,7 +91,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
tags: ['tag1', 'tag2', 'tag3'],
|
||||
@@ -171,7 +105,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
name: 'test'
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, {});
|
||||
const result = convertParametersToTypes(queryParams, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: '5', // Should remain as string
|
||||
@@ -192,7 +126,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 5, // Converted based on schema
|
||||
@@ -214,7 +148,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 'not-a-number', // Should remain as string when conversion fails
|
||||
@@ -299,4 +233,16 @@ describe('OpenAPI Granular Endpoints', () => {
|
||||
const group = mockGetGroupByIdOrName('nonexistent');
|
||||
expect(group).toBeNull();
|
||||
});
|
||||
|
||||
test('should decode URL-encoded server and tool names with slashes', () => {
|
||||
// Test that URL-encoded names with slashes are properly decoded
|
||||
const encodedServerName = 'com.atlassian%2Fatlassian-mcp-server';
|
||||
const encodedToolName = 'atlassianUserInfo';
|
||||
|
||||
const decodedServerName = decodeURIComponent(encodedServerName);
|
||||
const decodedToolName = decodeURIComponent(encodedToolName);
|
||||
|
||||
expect(decodedServerName).toBe('com.atlassian/atlassian-mcp-server');
|
||||
expect(decodedToolName).toBe('atlassianUserInfo');
|
||||
});
|
||||
});
|
||||
98
tests/integration/server-smart-routing.test.ts
Normal file
98
tests/integration/server-smart-routing.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||
import request from 'supertest';
|
||||
|
||||
const handleSseConnectionMock = jest.fn();
|
||||
const handleSseMessageMock = jest.fn();
|
||||
const handleMcpPostRequestMock = jest.fn();
|
||||
const handleMcpOtherRequestMock = jest.fn();
|
||||
const sseUserContextMiddlewareMock = jest.fn((_req, _res, next) => next());
|
||||
|
||||
jest.mock('../../src/utils/i18n.js', () => ({
|
||||
__esModule: true,
|
||||
initI18n: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/models/User.js', () => ({
|
||||
__esModule: true,
|
||||
initializeDefaultUser: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/oauthService.js', () => ({
|
||||
__esModule: true,
|
||||
initOAuthProvider: jest.fn(),
|
||||
getOAuthRouter: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/middlewares/index.js', () => ({
|
||||
__esModule: true,
|
||||
initMiddlewares: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/routes/index.js', () => ({
|
||||
__esModule: true,
|
||||
initRoutes: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/mcpService.js', () => ({
|
||||
__esModule: true,
|
||||
initUpstreamServers: jest.fn().mockResolvedValue(undefined),
|
||||
connected: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/sseService.js', () => ({
|
||||
__esModule: true,
|
||||
handleSseConnection: handleSseConnectionMock,
|
||||
handleSseMessage: handleSseMessageMock,
|
||||
handleMcpPostRequest: handleMcpPostRequestMock,
|
||||
handleMcpOtherRequest: handleMcpOtherRequestMock,
|
||||
}));
|
||||
|
||||
jest.mock('../../src/middlewares/userContext.js', () => ({
|
||||
__esModule: true,
|
||||
userContextMiddleware: jest.fn((_req, _res, next) => next()),
|
||||
sseUserContextMiddleware: sseUserContextMiddlewareMock,
|
||||
}));
|
||||
|
||||
import { AppServer } from '../../src/server.js';
|
||||
|
||||
const flushPromises = async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
};
|
||||
|
||||
describe('AppServer smart routing group paths', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleMcpPostRequestMock.mockImplementation(async (_req, res) => {
|
||||
res.status(204).send();
|
||||
});
|
||||
sseUserContextMiddlewareMock.mockImplementation((_req, _res, next) => next());
|
||||
});
|
||||
|
||||
const createApp = async () => {
|
||||
const appServer = new AppServer();
|
||||
await appServer.initialize();
|
||||
await flushPromises();
|
||||
return appServer.getApp();
|
||||
};
|
||||
|
||||
it('routes global MCP requests with nested smart group segments', async () => {
|
||||
const app = await createApp();
|
||||
|
||||
await request(app).post('/mcp/$smart/test-group').send({}).expect(204);
|
||||
|
||||
expect(handleMcpPostRequestMock).toHaveBeenCalledTimes(1);
|
||||
const [req] = handleMcpPostRequestMock.mock.calls[0];
|
||||
expect(req.params.group).toBe('$smart/test-group');
|
||||
});
|
||||
|
||||
it('routes user-scoped MCP requests with nested smart group segments', async () => {
|
||||
const app = await createApp();
|
||||
|
||||
await request(app).post('/alice/mcp/$smart/staging').send({}).expect(204);
|
||||
|
||||
expect(handleMcpPostRequestMock).toHaveBeenCalledTimes(1);
|
||||
const [req] = handleMcpPostRequestMock.mock.calls[0];
|
||||
expect(req.params.group).toBe('$smart/staging');
|
||||
expect(req.params.user).toBe('alice');
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,27 @@ describe('OpenAPI Generator Service', () => {
|
||||
expect(spec).toHaveProperty('paths');
|
||||
expect(typeof spec.paths).toBe('object');
|
||||
});
|
||||
|
||||
it('should URL-encode server and tool names with slashes in paths', async () => {
|
||||
const spec = await generateOpenAPISpec();
|
||||
|
||||
// Check if any paths contain URL-encoded values
|
||||
// Paths with slashes in server/tool names should be encoded
|
||||
const paths = Object.keys(spec.paths);
|
||||
|
||||
// If there are any servers with slashes, verify encoding
|
||||
// e.g., "com.atlassian/atlassian-mcp-server" should become "com.atlassian%2Fatlassian-mcp-server"
|
||||
for (const path of paths) {
|
||||
// Path should not have unencoded slashes in the middle segments
|
||||
// Valid format: /tools/{encoded-server}/{encoded-tool}
|
||||
const pathSegments = path.split('/').filter((s) => s.length > 0);
|
||||
if (pathSegments[0] === 'tools' && pathSegments.length >= 3) {
|
||||
// The server name (segment 1) and tool name (segment 2+) should not create extra segments
|
||||
// If properly encoded, there should be exactly 3 segments: ['tools', serverName, toolName]
|
||||
expect(pathSegments.length).toBe(3);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolStats', () => {
|
||||
|
||||
259
tests/utils/parameterConversion.test.ts
Normal file
259
tests/utils/parameterConversion.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { convertParametersToTypes } from '../../src/utils/parameterConversion.js';
|
||||
|
||||
describe('Parameter Conversion Utilities', () => {
|
||||
describe('convertParametersToTypes', () => {
|
||||
it('should convert string to number when schema type is number', () => {
|
||||
const params = { count: '42' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.count).toBe(42);
|
||||
expect(typeof result.count).toBe('number');
|
||||
});
|
||||
|
||||
it('should convert string to integer when schema type is integer', () => {
|
||||
const params = { age: '25' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'integer' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.age).toBe(25);
|
||||
expect(typeof result.age).toBe('number');
|
||||
expect(Number.isInteger(result.age)).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert string to boolean when schema type is boolean', () => {
|
||||
const params = { enabled: 'true', disabled: 'false', flag: '1' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
disabled: { type: 'boolean' },
|
||||
flag: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.flag).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert comma-separated string to array when schema type is array', () => {
|
||||
const params = { tags: 'one,two,three' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: { type: 'array' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(Array.isArray(result.tags)).toBe(true);
|
||||
expect(result.tags).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
it('should parse JSON string to object when schema type is object', () => {
|
||||
const params = { config: '{"key": "value", "nested": {"prop": 123}}' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(typeof result.config).toBe('object');
|
||||
expect(result.config).toEqual({ key: 'value', nested: { prop: 123 } });
|
||||
});
|
||||
|
||||
it('should keep values unchanged when they already have the correct type', () => {
|
||||
const params = { count: 42, enabled: true, tags: ['a', 'b'] };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
enabled: { type: 'boolean' },
|
||||
tags: { type: 'array' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.count).toBe(42);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.tags).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should keep string values unchanged when schema type is string', () => {
|
||||
const params = { name: 'John Doe' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.name).toBe('John Doe');
|
||||
expect(typeof result.name).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle parameters without schema definition', () => {
|
||||
const params = { unknown: 'value' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
known: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.unknown).toBe('value');
|
||||
});
|
||||
|
||||
it('should return original params when schema has no properties', () => {
|
||||
const params = { key: 'value' };
|
||||
const schema = { type: 'object' };
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result).toEqual(params);
|
||||
});
|
||||
|
||||
it('should return original params when schema is null or undefined', () => {
|
||||
const params = { key: 'value' };
|
||||
|
||||
const resultNull = convertParametersToTypes(params, null as any);
|
||||
const resultUndefined = convertParametersToTypes(params, undefined as any);
|
||||
|
||||
expect(resultNull).toEqual(params);
|
||||
expect(resultUndefined).toEqual(params);
|
||||
});
|
||||
|
||||
it('should handle invalid number conversion gracefully', () => {
|
||||
const params = { count: 'not-a-number' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
// When conversion fails, it should keep original value
|
||||
expect(result.count).toBe('not-a-number');
|
||||
});
|
||||
|
||||
it('should handle invalid JSON string for object gracefully', () => {
|
||||
const params = { config: '{invalid json}' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
// When JSON parsing fails, it should keep original value
|
||||
expect(result.config).toBe('{invalid json}');
|
||||
});
|
||||
|
||||
it('should handle mixed parameter types correctly', () => {
|
||||
const params = {
|
||||
name: 'Test',
|
||||
count: '10',
|
||||
price: '19.99',
|
||||
enabled: 'true',
|
||||
tags: 'tag1,tag2',
|
||||
config: '{"nested": true}',
|
||||
};
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
count: { type: 'integer' },
|
||||
price: { type: 'number' },
|
||||
enabled: { type: 'boolean' },
|
||||
tags: { type: 'array' },
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.name).toBe('Test');
|
||||
expect(result.count).toBe(10);
|
||||
expect(result.price).toBe(19.99);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.tags).toEqual(['tag1', 'tag2']);
|
||||
expect(result.config).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it('should handle empty string values', () => {
|
||||
const params = { name: '', count: '', enabled: '' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
count: { type: 'number' },
|
||||
enabled: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.name).toBe('');
|
||||
// Empty string should remain as empty string for number (NaN check keeps original)
|
||||
expect(result.count).toBe('');
|
||||
// Empty string converts to false for boolean
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle array that is already an array', () => {
|
||||
const params = { tags: ['existing', 'array'] };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: { type: 'array' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.tags).toEqual(['existing', 'array']);
|
||||
});
|
||||
|
||||
it('should handle object that is already an object', () => {
|
||||
const params = { config: { key: 'value' } };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.config).toEqual({ key: 'value' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user