mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 10:49:35 -05:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9304653c34 | ||
|
|
b5685b7010 | ||
|
|
89c37b2f02 | ||
|
|
c316cb896e | ||
|
|
bc3c8facfa | ||
|
|
69afb865c0 | ||
|
|
ba30d88840 |
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Group, Server } from '@/types'
|
||||
import { Edit, Trash, Copy, Check } from '@/components/icons/LucideIcons'
|
||||
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon } from '@/components/icons/LucideIcons'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
|
||||
interface GroupCardProps {
|
||||
group: Group
|
||||
@@ -20,8 +21,25 @@ const GroupCard = ({
|
||||
}: GroupCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const { installConfig } = useSettingsData()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showCopyDropdown, setShowCopyDropdown] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowCopyDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit(group)
|
||||
@@ -36,16 +54,18 @@ const GroupCard = ({
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
const copyToClipboard = (text: string) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(group.id).then(() => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true)
|
||||
setShowCopyDropdown(false)
|
||||
showToast(t('common.copySuccess'), 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = group.id
|
||||
textArea.value = text
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
@@ -55,6 +75,8 @@ const GroupCard = ({
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopied(true)
|
||||
setShowCopyDropdown(false)
|
||||
showToast(t('common.copySuccess'), 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
@@ -64,6 +86,28 @@ const GroupCard = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyId = () => {
|
||||
copyToClipboard(group.id)
|
||||
}
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
copyToClipboard(`${installConfig.baseUrl}/mcp/${group.id}`)
|
||||
}
|
||||
|
||||
const handleCopyJson = () => {
|
||||
const jsonConfig = {
|
||||
mcpServers: {
|
||||
mcphub: {
|
||||
url: `${installConfig.baseUrl}/mcp/${group.id}`,
|
||||
headers: {
|
||||
Authorization: "Bearer <your-access-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
copyToClipboard(JSON.stringify(jsonConfig, null, 2))
|
||||
}
|
||||
|
||||
// Get servers that belong to this group
|
||||
const groupServers = servers.filter(server => group.servers.includes(server.name))
|
||||
|
||||
@@ -75,13 +119,42 @@ const GroupCard = ({
|
||||
<h2 className="text-xl font-semibold text-gray-800">{group.name}</h2>
|
||||
<div className="flex items-center ml-3">
|
||||
<span className="text-xs text-gray-500 mr-1">{group.id}</span>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowCopyDropdown(!showCopyDropdown)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors flex items-center"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
<DropdownIcon size={12} className="ml-1" />
|
||||
</button>
|
||||
|
||||
{showCopyDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white shadow-lg rounded-md border border-gray-200 py-1 z-10 min-w-[140px]">
|
||||
<button
|
||||
onClick={handleCopyId}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
|
||||
>
|
||||
<Copy size={12} className="mr-2" />
|
||||
{t('common.copyId')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
|
||||
>
|
||||
<Link size={12} className="mr-2" />
|
||||
{t('common.copyUrl')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyJson}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
|
||||
>
|
||||
<FileCode size={12} className="mr-2" />
|
||||
{t('common.copyJson')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{group.description && (
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Link,
|
||||
FileCode,
|
||||
ChevronDown as DropdownIcon
|
||||
} from 'lucide-react'
|
||||
|
||||
export {
|
||||
@@ -31,7 +34,10 @@ export {
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Link,
|
||||
FileCode,
|
||||
DropdownIcon
|
||||
}
|
||||
|
||||
const LucideIcons = {
|
||||
@@ -49,7 +55,10 @@ const LucideIcons = {
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Link,
|
||||
FileCode,
|
||||
DropdownIcon
|
||||
}
|
||||
|
||||
export default LucideIcons
|
||||
@@ -16,6 +16,7 @@ interface RoutingConfig {
|
||||
interface InstallConfig {
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface SmartRoutingConfig {
|
||||
@@ -57,6 +58,7 @@ export const useSettingsData = () => {
|
||||
const [installConfig, setInstallConfig] = useState<InstallConfig>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
});
|
||||
|
||||
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
|
||||
@@ -108,6 +110,7 @@ export const useSettingsData = () => {
|
||||
setInstallConfig({
|
||||
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
|
||||
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
|
||||
baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000',
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.smartRouting) {
|
||||
|
||||
@@ -180,6 +180,9 @@
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"copy": "Copy",
|
||||
"copyId": "Copy ID",
|
||||
"copyUrl": "Copy URL",
|
||||
"copyJson": "Copy JSON",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed",
|
||||
"close": "Close",
|
||||
@@ -366,6 +369,9 @@
|
||||
"npmRegistry": "NPM Registry URL",
|
||||
"npmRegistryDescription": "Set npm_config_registry environment variable for NPM package installation",
|
||||
"npmRegistryPlaceholder": "e.g. https://registry.npmjs.org/",
|
||||
"baseUrl": "Base URL",
|
||||
"baseUrlDescription": "Base URL for MCP requests",
|
||||
"baseUrlPlaceholder": "e.g. http://localhost:3000",
|
||||
"installConfig": "Installation",
|
||||
"systemConfigUpdated": "System configuration updated successfully",
|
||||
"enableSmartRouting": "Enable Smart Routing",
|
||||
|
||||
@@ -181,6 +181,9 @@
|
||||
"delete": "删除",
|
||||
"remove": "移除",
|
||||
"copy": "复制",
|
||||
"copyId": "复制ID",
|
||||
"copyUrl": "复制URL",
|
||||
"copyJson": "复制JSON",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"close": "关闭",
|
||||
@@ -367,6 +370,9 @@
|
||||
"npmRegistry": "NPM 仓库地址",
|
||||
"npmRegistryDescription": "设置 npm_config_registry 环境变量,用于 NPM 包安装",
|
||||
"npmRegistryPlaceholder": "例如: https://registry.npmmirror.com/",
|
||||
"baseUrl": "基础地址",
|
||||
"baseUrlDescription": "用于 MCP 请求的基础地址",
|
||||
"baseUrlPlaceholder": "例如: http://localhost:3000",
|
||||
"installConfig": "安装配置",
|
||||
"systemConfigUpdated": "系统配置更新成功",
|
||||
"enableSmartRouting": "启用智能路由",
|
||||
|
||||
@@ -23,9 +23,11 @@ const SettingsPage: React.FC = () => {
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
@@ -125,14 +127,14 @@ const SettingsPage: React.FC = () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry', value: string) => {
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl', value: string) => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry') => {
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
@@ -467,6 +469,30 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
{sectionsVisible.installConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.baseUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.baseUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.baseUrl}
|
||||
onChange={(e) => handleInstallConfigChange('baseUrl', e.target.value)}
|
||||
placeholder={t('settings.baseUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('baseUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
|
||||
|
||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -28,7 +28,7 @@ importers:
|
||||
version: 0.5.16
|
||||
axios:
|
||||
specifier: ^1.10.0
|
||||
version: 1.10.0
|
||||
version: 1.11.0
|
||||
bcryptjs:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
@@ -49,7 +49,7 @@ importers:
|
||||
version: 9.0.2
|
||||
multer:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
version: 2.0.2
|
||||
openai:
|
||||
specifier: ^4.103.0
|
||||
version: 4.104.0(zod@3.25.67)
|
||||
@@ -1865,8 +1865,8 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
axios@1.10.0:
|
||||
resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==}
|
||||
axios@1.11.0:
|
||||
resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==}
|
||||
|
||||
babel-jest@29.7.0:
|
||||
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
|
||||
@@ -2518,8 +2518,8 @@ packages:
|
||||
form-data-encoder@1.7.2:
|
||||
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
|
||||
|
||||
form-data@4.0.2:
|
||||
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
|
||||
form-data@4.0.4:
|
||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
formdata-node@4.4.1:
|
||||
@@ -3286,8 +3286,8 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
multer@2.0.1:
|
||||
resolution: {integrity: sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==}
|
||||
multer@2.0.2:
|
||||
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
|
||||
nanoid@3.3.11:
|
||||
@@ -3965,10 +3965,12 @@ packages:
|
||||
superagent@10.2.1:
|
||||
resolution: {integrity: sha512-O+PCv11lgTNJUzy49teNAWLjBZfc+A1enOwTpLlH6/rsvKcTwcdTT8m9azGkVqM7HBl5jpyZ7KTPhHweokBcdg==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net
|
||||
|
||||
supertest@7.1.1:
|
||||
resolution: {integrity: sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
@@ -5662,7 +5664,7 @@ snapshots:
|
||||
'@types/node-fetch@2.6.12':
|
||||
dependencies:
|
||||
'@types/node': 22.15.34
|
||||
form-data: 4.0.2
|
||||
form-data: 4.0.4
|
||||
|
||||
'@types/node@18.19.113':
|
||||
dependencies:
|
||||
@@ -5714,7 +5716,7 @@ snapshots:
|
||||
'@types/cookiejar': 2.1.5
|
||||
'@types/methods': 1.1.4
|
||||
'@types/node': 22.15.34
|
||||
form-data: 4.0.2
|
||||
form-data: 4.0.4
|
||||
|
||||
'@types/supertest@6.0.3':
|
||||
dependencies:
|
||||
@@ -5932,10 +5934,10 @@ snapshots:
|
||||
postcss: 8.5.6
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
axios@1.10.0:
|
||||
axios@1.11.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.9
|
||||
form-data: 4.0.2
|
||||
form-data: 4.0.4
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
@@ -6741,11 +6743,12 @@ snapshots:
|
||||
|
||||
form-data-encoder@1.7.2: {}
|
||||
|
||||
form-data@4.0.2:
|
||||
form-data@4.0.4:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
formdata-node@4.4.1:
|
||||
@@ -7648,7 +7651,7 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
multer@2.0.1:
|
||||
multer@2.0.2:
|
||||
dependencies:
|
||||
append-field: 1.0.0
|
||||
busboy: 1.6.0
|
||||
@@ -8334,7 +8337,7 @@ snapshots:
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.4.1
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.2
|
||||
form-data: 4.0.4
|
||||
formidable: 3.5.4
|
||||
methods: 1.1.2
|
||||
mime: 2.6.0
|
||||
|
||||
200
src/clients/__tests__/openapi-operation-name.test.ts
Normal file
200
src/clients/__tests__/openapi-operation-name.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { OpenAPIClient } from '../openapi.js';
|
||||
import { ServerConfig } from '../../types/index.js';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
describe('OpenAPIClient - Operation Name Generation', () => {
|
||||
describe('generateOperationName', () => {
|
||||
test('should generate operation name from method and path', async () => {
|
||||
const config: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
schema: {
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
paths: {
|
||||
'/users': {
|
||||
get: {
|
||||
summary: 'Get users',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
post: {
|
||||
summary: 'Create user',
|
||||
responses: { '201': { description: 'Created' } },
|
||||
},
|
||||
},
|
||||
'/users/{id}': {
|
||||
get: {
|
||||
summary: 'Get user by ID',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
delete: {
|
||||
summary: 'Delete user',
|
||||
responses: { '204': { description: 'Deleted' } },
|
||||
},
|
||||
},
|
||||
'/admin/settings': {
|
||||
get: {
|
||||
summary: 'Get admin settings',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
'/': {
|
||||
get: {
|
||||
summary: 'Root endpoint',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenAPIV3.Document,
|
||||
},
|
||||
};
|
||||
|
||||
const testClient = new OpenAPIClient(config);
|
||||
await testClient.initialize();
|
||||
const tools = testClient.getTools();
|
||||
|
||||
// Verify generated operation names
|
||||
expect(tools).toHaveLength(6);
|
||||
|
||||
const toolNames = tools.map((t) => t.name).sort();
|
||||
expect(toolNames).toEqual(
|
||||
[
|
||||
'delete_users',
|
||||
'get_admin_settings',
|
||||
'get_root',
|
||||
'get_users',
|
||||
'post_users',
|
||||
'get_users1', // Second GET /users/{id}, will add numeric suffix
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('should use operationId when available and generate name when missing', async () => {
|
||||
const config: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
schema: {
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
paths: {
|
||||
'/users': {
|
||||
get: {
|
||||
operationId: 'listUsers',
|
||||
summary: 'Get users',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
post: {
|
||||
// No operationId, should generate post_users
|
||||
summary: 'Create user',
|
||||
responses: { '201': { description: 'Created' } },
|
||||
},
|
||||
},
|
||||
'/users/{id}': {
|
||||
get: {
|
||||
operationId: 'getUserById',
|
||||
summary: 'Get user by ID',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenAPIV3.Document,
|
||||
},
|
||||
};
|
||||
|
||||
const testClient = new OpenAPIClient(config);
|
||||
await testClient.initialize();
|
||||
const tools = testClient.getTools();
|
||||
|
||||
expect(tools).toHaveLength(3);
|
||||
|
||||
const toolsByName = tools.reduce(
|
||||
(acc, tool) => {
|
||||
acc[tool.name] = tool;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
// Those with operationId should use the original operationId
|
||||
expect(toolsByName['listUsers']).toBeDefined();
|
||||
expect(toolsByName['listUsers'].operationId).toBe('listUsers');
|
||||
expect(toolsByName['getUserById']).toBeDefined();
|
||||
expect(toolsByName['getUserById'].operationId).toBe('getUserById');
|
||||
|
||||
// Those without operationId should generate names
|
||||
expect(toolsByName['post_users']).toBeDefined();
|
||||
expect(toolsByName['post_users'].operationId).toBe('post_users');
|
||||
});
|
||||
|
||||
test('should handle duplicate generated names with counter', async () => {
|
||||
const config: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
schema: {
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
paths: {
|
||||
'/users': {
|
||||
get: {
|
||||
summary: 'Get users',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
'/users/': {
|
||||
get: {
|
||||
summary: 'Get users with trailing slash',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenAPIV3.Document,
|
||||
},
|
||||
};
|
||||
|
||||
const testClient = new OpenAPIClient(config);
|
||||
await testClient.initialize();
|
||||
const tools = testClient.getTools();
|
||||
|
||||
expect(tools).toHaveLength(2);
|
||||
|
||||
const toolNames = tools.map((t) => t.name).sort();
|
||||
expect(toolNames).toEqual(['get_users', 'get_users1']);
|
||||
});
|
||||
|
||||
test('should handle complex paths with parameters and special characters', async () => {
|
||||
const config: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
schema: {
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
paths: {
|
||||
'/api/v1/users/{user-id}/posts/{post_id}': {
|
||||
get: {
|
||||
summary: 'Get user post',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
'/api-v2/user-profiles': {
|
||||
post: {
|
||||
summary: 'Create user profile',
|
||||
responses: { '201': { description: 'Created' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenAPIV3.Document,
|
||||
},
|
||||
};
|
||||
|
||||
const testClient = new OpenAPIClient(config);
|
||||
await testClient.initialize();
|
||||
const tools = testClient.getTools();
|
||||
|
||||
expect(tools).toHaveLength(2);
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
expect(toolNames).toContain('get_api_v1_users_posts'); // Path parameters removed, special characters cleaned
|
||||
expect(toolNames).toContain('post_apiv2_userprofiles'); // Hyphens and underscores cleaned, lowercase with underscores
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export class OpenAPIClient {
|
||||
throw new Error('OpenAPI URL or schema is required');
|
||||
}
|
||||
|
||||
// 初始 baseUrl,将在 initialize() 中从 OpenAPI servers 字段更新
|
||||
// Initial baseUrl, will be updated from OpenAPI servers field in initialize()
|
||||
this.baseUrl = config.openapi?.url ? this.extractBaseUrl(config.openapi.url) : '';
|
||||
this.securityConfig = config.openapi.security;
|
||||
|
||||
@@ -117,7 +117,7 @@ export class OpenAPIClient {
|
||||
throw new Error('Either OpenAPI URL or schema must be provided');
|
||||
}
|
||||
|
||||
// 从 OpenAPI servers 字段更新 baseUrl
|
||||
// Update baseUrl from OpenAPI servers field
|
||||
this.updateBaseUrlFromServers();
|
||||
|
||||
this.extractTools();
|
||||
@@ -127,33 +127,48 @@ export class OpenAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
private generateOperationName(method: string, path: string): string {
|
||||
// Clean path, remove parameter brackets and special characters
|
||||
const cleanPath = path
|
||||
.replace(/\{[^}]+\}/g, '') // Remove {param} format parameters
|
||||
.replace(/[^\w/]/g, '') // Remove special characters, keep alphanumeric and slashes
|
||||
.split('/')
|
||||
.filter((segment) => segment.length > 0) // Remove empty segments
|
||||
.map((segment) => segment.toLowerCase()) // Convert to lowercase
|
||||
.join('_'); // Join with underscores
|
||||
|
||||
// Convert method to lowercase and combine with path
|
||||
const methodName = method.toLowerCase();
|
||||
return `${methodName}_${cleanPath || 'root'}`;
|
||||
}
|
||||
|
||||
private updateBaseUrlFromServers(): void {
|
||||
if (!this.spec?.servers || this.spec.servers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取第一个 server 的 URL
|
||||
// Get the first server's URL
|
||||
const serverUrl = this.spec.servers[0].url;
|
||||
|
||||
// 如果是相对路径,需要与原始 spec URL 结合
|
||||
// If it's a relative path, combine with original spec URL
|
||||
if (serverUrl.startsWith('/')) {
|
||||
// 相对路径,使用原始 spec URL 的协议和主机
|
||||
// Relative path, use protocol and host from original spec URL
|
||||
if (this.config.openapi?.url) {
|
||||
const originalUrl = new URL(this.config.openapi.url);
|
||||
this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}${serverUrl}`;
|
||||
}
|
||||
} else if (serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) {
|
||||
// 绝对路径
|
||||
// Absolute path
|
||||
this.baseUrl = serverUrl;
|
||||
} else {
|
||||
// 相对路径但不以 / 开头,可能是相对于当前路径
|
||||
// Relative path but doesn't start with /, might be relative to current path
|
||||
if (this.config.openapi?.url) {
|
||||
const originalUrl = new URL(this.config.openapi.url);
|
||||
this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}/${serverUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 HTTP 客户端的 baseURL
|
||||
// Update HTTP client's baseURL
|
||||
this.httpClient.defaults.baseURL = this.baseUrl;
|
||||
}
|
||||
|
||||
@@ -163,6 +178,7 @@ export class OpenAPIClient {
|
||||
}
|
||||
|
||||
this.tools = [];
|
||||
const generatedNames = new Set<string>(); // Used to ensure generated names are unique
|
||||
|
||||
for (const [path, pathItem] of Object.entries(this.spec.paths)) {
|
||||
if (!pathItem) continue;
|
||||
@@ -180,14 +196,33 @@ export class OpenAPIClient {
|
||||
|
||||
for (const method of methods) {
|
||||
const operation = pathItem[method] as OpenAPIV3.OperationObject | undefined;
|
||||
if (!operation || !operation.operationId) continue;
|
||||
if (!operation) continue;
|
||||
|
||||
// Generate operation name: use operationId first, otherwise generate unique name
|
||||
let operationName: string;
|
||||
if (operation.operationId) {
|
||||
operationName = operation.operationId;
|
||||
} else {
|
||||
operationName = this.generateOperationName(method, path);
|
||||
|
||||
// Ensure name uniqueness, add numeric suffix if duplicate
|
||||
let uniqueName = operationName;
|
||||
let counter = 1;
|
||||
while (generatedNames.has(uniqueName) || this.tools.some((t) => t.name === uniqueName)) {
|
||||
uniqueName = `${operationName}${counter}`;
|
||||
counter++;
|
||||
}
|
||||
operationName = uniqueName;
|
||||
}
|
||||
|
||||
generatedNames.add(operationName);
|
||||
|
||||
const tool: OpenAPIToolInfo = {
|
||||
name: operation.operationId,
|
||||
name: operationName,
|
||||
description:
|
||||
operation.summary || operation.description || `${method.toUpperCase()} ${path}`,
|
||||
inputSchema: this.generateInputSchema(operation, path, method as string),
|
||||
operationId: operation.operationId,
|
||||
operationId: operation.operationId || operationName,
|
||||
method: method as string,
|
||||
path,
|
||||
parameters: operation.parameters as OpenAPIV3.ParameterObject[],
|
||||
|
||||
@@ -89,17 +89,42 @@ export const getSettingsCacheInfo = (): { hasCache: boolean } => {
|
||||
};
|
||||
};
|
||||
|
||||
export const replaceEnvVars = (env: Record<string, any>): Record<string, any> => {
|
||||
const res: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (typeof value === 'string') {
|
||||
res[key] = expandEnvVars(value);
|
||||
} else {
|
||||
res[key] = String(value);
|
||||
export function replaceEnvVars(input: Record<string, any>): Record<string, any>;
|
||||
export function replaceEnvVars(input: string[] | undefined): string[];
|
||||
export function replaceEnvVars(input: string): string;
|
||||
export function replaceEnvVars(
|
||||
input: Record<string, any> | string[] | string | undefined,
|
||||
): Record<string, any> | string[] | string {
|
||||
// Handle object input
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||
const res: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (typeof value === 'string') {
|
||||
res[key] = expandEnvVars(value);
|
||||
} else {
|
||||
res[key] = String(value);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
// Handle array input
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => expandEnvVars(item));
|
||||
}
|
||||
|
||||
// Handle string input
|
||||
if (typeof input === 'string') {
|
||||
return expandEnvVars(input);
|
||||
}
|
||||
|
||||
// Handle undefined/null array input
|
||||
if (input === undefined || input === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export const expandEnvVars = (value: string): string => {
|
||||
if (typeof value !== 'string') {
|
||||
|
||||
@@ -108,6 +108,9 @@ export const uploadDxtFile = async (req: Request, res: Response): Promise<void>
|
||||
|
||||
// Clean up any existing version of this server
|
||||
cleanupOldDxtServer(manifest.name);
|
||||
if (!fs.existsSync(finalExtractDir)) {
|
||||
fs.mkdirSync(finalExtractDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Move the temporary directory to the final location
|
||||
fs.renameSync(tempExtractDir, finalExtractDir);
|
||||
|
||||
@@ -515,7 +515,9 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
typeof routing.bearerAuthKey !== 'string' &&
|
||||
typeof routing.skipAuth !== 'boolean')) &&
|
||||
(!install ||
|
||||
(typeof install.pythonIndexUrl !== 'string' && typeof install.npmRegistry !== 'string')) &&
|
||||
(typeof install.pythonIndexUrl !== 'string' &&
|
||||
typeof install.npmRegistry !== 'string' &&
|
||||
typeof install.baseUrl !== 'string')) &&
|
||||
(!smartRouting ||
|
||||
(typeof smartRouting.enabled !== 'boolean' &&
|
||||
typeof smartRouting.dbUrl !== 'string' &&
|
||||
@@ -543,6 +545,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
install: {
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
},
|
||||
smartRouting: {
|
||||
enabled: false,
|
||||
@@ -568,6 +571,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
settings.systemConfig.install = {
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -610,6 +614,9 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
if (typeof install.npmRegistry === 'string') {
|
||||
settings.systemConfig.install.npmRegistry = install.npmRegistry;
|
||||
}
|
||||
if (typeof install.baseUrl === 'string') {
|
||||
settings.systemConfig.install.baseUrl = install.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Track smartRouting state and configuration changes
|
||||
|
||||
@@ -183,7 +183,7 @@ const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: conf.command,
|
||||
args: conf.args,
|
||||
args: replaceEnvVars(conf.args) as string[],
|
||||
env: env,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface SystemConfig {
|
||||
install?: {
|
||||
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
||||
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
||||
baseUrl?: string; // Base URL for group card copy operations
|
||||
};
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user