feat: mcp npx list (#3409)

* feat: add npm scope search functionality in MCPSettings

- Integrated npx-scope-finder to enable searching for npm packages by scope.
- Added UI elements for inputting npm scope and displaying search results in a table format.
- Enhanced user feedback with loading indicators and messages for search results.

* feat: add key property to package formatting in MCPSettings

- Added a key property to the package formatting logic to ensure unique identification of each package in the results.

* feat: enhance MCPSettings with NPX package list localization

- Added localization support for NPX package list in English, Japanese, Russian, Simplified Chinese, and Traditional Chinese.
- Updated UI elements in MCPSettings to utilize localized strings for package list features, including title, description, and various labels.
- Improved user experience by integrating translations for package-related actions and placeholders.
This commit is contained in:
MyPrototypeWhat 2025-03-16 23:09:40 +08:00 committed by GitHub
parent 45c10fa166
commit e0e1d285e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 282 additions and 77 deletions

View File

@ -83,6 +83,7 @@
"fetch-socks": "^1.3.2", "fetch-socks": "^1.3.2",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"npx-scope-finder": "^1.2.0",
"officeparser": "^4.1.1", "officeparser": "^4.1.1",
"p-queue": "^8.1.0", "p-queue": "^8.1.0",
"tokenx": "^0.4.1", "tokenx": "^0.4.1",

View File

@ -824,7 +824,19 @@
"updateSuccess": "Server updated successfully", "updateSuccess": "Server updated successfully",
"updateError": "Failed to update server", "updateError": "Failed to update server",
"url": "URL", "url": "URL",
"toggleError": "Toggle failed" "toggleError": "Toggle failed",
"npx_list": {
"title": "NPX Package List",
"desc": "Search and add npm packages as MCP servers",
"scope_placeholder": "Enter npm scope (e.g. @your-org)",
"search": "Search",
"package_name": "Package Name",
"description": "Description",
"usage": "Usage",
"npm": "NPM",
"version": "Version",
"actions": "Actions"
}
}, },
"messages.divider": "Show divider between messages", "messages.divider": "Show divider between messages",
"messages.grid_columns": "Message grid display columns", "messages.grid_columns": "Message grid display columns",

View File

@ -824,7 +824,19 @@
"updateSuccess": "サーバーが正常に更新されました", "updateSuccess": "サーバーが正常に更新されました",
"updateError": "サーバーの更新に失敗しました", "updateError": "サーバーの更新に失敗しました",
"url": "URL", "url": "URL",
"toggleError": "切り替えに失敗しました" "toggleError": "切り替えに失敗しました",
"npx_list": {
"title": "NPX パッケージリスト",
"desc": "npm パッケージを検索して MCP サーバーとして追加",
"scope_placeholder": "npm スコープを入力 (例: @your-org)",
"search": "検索",
"package_name": "パッケージ名",
"description": "説明",
"usage": "使用法",
"npm": "NPM",
"version": "バージョン",
"actions": "アクション"
}
}, },
"messages.divider": "メッセージ間に区切り線を表示", "messages.divider": "メッセージ間に区切り線を表示",
"messages.grid_columns": "メッセージグリッドの表示列数", "messages.grid_columns": "メッセージグリッドの表示列数",

View File

@ -824,7 +824,19 @@
"updateSuccess": "Сервер успешно обновлен", "updateSuccess": "Сервер успешно обновлен",
"updateError": "Ошибка обновления сервера", "updateError": "Ошибка обновления сервера",
"url": "URL", "url": "URL",
"toggleError": "Переключение не удалось" "toggleError": "Переключение не удалось",
"npx_list": {
"title": "Список пакетов NPX",
"desc": "Поиск и добавление npm пакетов в качестве MCP серверов",
"scope_placeholder": "Введите область npm (например, @your-org)",
"search": "Поиск",
"package_name": "Имя пакета",
"description": "Описание",
"usage": "Использование",
"npm": "NPM",
"version": "Версия",
"actions": "Действия"
}
}, },
"messages.divider": "Показывать разделитель между сообщениями", "messages.divider": "Показывать разделитель между сообщениями",
"messages.grid_columns": "Количество столбцов сетки сообщений", "messages.grid_columns": "Количество столбцов сетки сообщений",

View File

@ -824,7 +824,19 @@
"updateSuccess": "服务器更新成功", "updateSuccess": "服务器更新成功",
"updateError": "更新服务器失败", "updateError": "更新服务器失败",
"url": "URL", "url": "URL",
"toggleError": "切换失败" "toggleError": "切换失败",
"npx_list": {
"title": "NPX 包列表",
"desc": "搜索并添加 npm 包作为 MCP 服务",
"scope_placeholder": "输入 npm 作用域 (例如 @your-org)",
"search": "搜索",
"package_name": "包名称",
"description": "描述",
"usage": "用法",
"npm": "NPM",
"version": "版本",
"actions": "操作"
}
}, },
"messages.divider": "消息分割线", "messages.divider": "消息分割线",
"messages.grid_columns": "消息网格展示列数", "messages.grid_columns": "消息网格展示列数",

View File

@ -824,7 +824,19 @@
"updateSuccess": "伺服器更新成功", "updateSuccess": "伺服器更新成功",
"updateError": "更新伺服器失敗", "updateError": "更新伺服器失敗",
"url": "URL", "url": "URL",
"toggleError": "切換失敗" "toggleError": "切換失敗",
"npx_list": {
"title": "NPX 包列表",
"desc": "搜索並添加 npm 包作為 MCP 服務",
"scope_placeholder": "輸入 npm 作用域 (例如 @your-org)",
"search": "搜索",
"package_name": "包名稱",
"description": "描述",
"usage": "用法",
"npm": "NPM",
"version": "版本",
"actions": "操作"
}
}, },
"messages.divider": "訊息間顯示分隔線", "messages.divider": "訊息間顯示分隔線",
"messages.grid_columns": "訊息網格展示列數", "messages.grid_columns": "訊息網格展示列數",

View File

@ -1,9 +1,10 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons' import { DeleteOutlined, EditOutlined, PlusOutlined, QuestionCircleOutlined, SearchOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useAppSelector } from '@renderer/store' import { useAppSelector } from '@renderer/store'
import { MCPServer } from '@renderer/types' import { MCPServer } from '@renderer/types'
import { Button, Card, Form, Input, Modal, Radio, Space, Switch, Table, Tag, Tooltip, Typography } from 'antd' import { Button, Card, Form, Input, Modal, Radio, Space, Spin, Switch, Table, Tag, Tooltip, Typography } from 'antd'
import TextArea from 'antd/es/input/TextArea' import TextArea from 'antd/es/input/TextArea'
import { npxFinder } from 'npx-scope-finder'
import { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -20,6 +21,15 @@ interface MCPFormValues {
isActive: boolean isActive: boolean
} }
interface SearchResult {
name: string
description: string
version: string
usage: string
npmLink: string
fullName: string
}
const MCPSettings: FC = () => { const MCPSettings: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
@ -32,6 +42,48 @@ const MCPSettings: FC = () => {
const [form] = Form.useForm<MCPFormValues>() const [form] = Form.useForm<MCPFormValues>()
const [serverType, setServerType] = useState<'sse' | 'stdio'>('stdio') const [serverType, setServerType] = useState<'sse' | 'stdio'>('stdio')
// Add new state variables for npm scope search
const [npmScope, setNpmScope] = useState('')
const [searchLoading, setSearchLoading] = useState(false)
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
// Add new function to handle npm scope search
const handleNpmSearch = async () => {
if (!npmScope.trim()) {
window.message.warning('Please enter an npm scope')
return
}
setSearchLoading(true)
try {
// Call npxFinder to search for packages
const packages = await npxFinder(npmScope)
// Map the packages to our desired format
const formattedResults = packages.map((pkg) => {
return {
key: pkg.name,
name: pkg.name || '',
description: pkg.description || 'No description available',
version: pkg.version || 'Latest',
usage: `npx ${pkg.name}`,
npmLink: pkg.links?.npm || `https://www.npmjs.com/package/${pkg.name}`,
fullName: pkg.name || ''
}
})
setSearchResults(formattedResults)
if (formattedResults.length === 0) {
window.message.info('No packages found for this scope')
}
} catch (error: any) {
window.message.error(`Failed to search npm packages: ${error.message}`)
} finally {
setSearchLoading(false)
}
}
// Watch the serverType field to update the form layout dynamically // Watch the serverType field to update the form layout dynamically
useEffect(() => { useEffect(() => {
const type = form.getFieldValue('serverType') const type = form.getFieldValue('serverType')
@ -44,7 +96,6 @@ const MCPSettings: FC = () => {
form.resetFields() form.resetFields()
form.setFieldsValue({ serverType: 'stdio', isActive: true }) form.setFieldsValue({ serverType: 'stdio', isActive: true })
setServerType('stdio') setServerType('stdio')
setEditingServer(null)
setIsModalVisible(true) setIsModalVisible(true)
} }
@ -284,76 +335,161 @@ const MCPSettings: FC = () => {
})} })}
/> />
</Card> </Card>
<Modal
title={editingServer ? t('settings.mcp.editServer') : t('settings.mcp.addServer')}
open={isModalVisible}
onCancel={handleCancel}
onOk={handleSubmit}
confirmLoading={loading}
maskClosable={false}
width={600}>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label={t('settings.mcp.name')}
rules={[{ required: true, message: t('settings.mcp.nameRequired') }]}>
<Input disabled={!!editingServer} placeholder={t('common.name')} />
</Form.Item>
<Form.Item name="description" label={t('settings.mcp.description')}>
<TextArea rows={2} placeholder={t('common.description')} />
</Form.Item>
<Form.Item
name="serverType"
label={t('settings.mcp.type')}
rules={[{ required: true }]}
initialValue="stdio">
<Radio.Group
onChange={(e) => setServerType(e.target.value)}
options={[
{ label: 'SSE (Server-Sent Events)', value: 'sse' },
{ label: 'STDIO (Standard Input/Output)', value: 'stdio' }
]}
/>
</Form.Item>
{serverType === 'sse' && (
<Form.Item
name="baseUrl"
label={t('settings.mcp.url')}
rules={[{ required: serverType === 'sse', message: t('settings.mcp.baseUrlRequired') }]}
tooltip={t('settings.mcp.baseUrlTooltip')}>
<Input placeholder="http://localhost:3000/sse" />
</Form.Item>
)}
{serverType === 'stdio' && (
<>
<Form.Item
name="command"
label={t('settings.mcp.command')}
rules={[{ required: serverType === 'stdio', message: t('settings.mcp.commandRequired') }]}>
<Input placeholder="uvx or npx" />
</Form.Item>
<Form.Item name="args" label={t('settings.mcp.args')} tooltip={t('settings.mcp.argsTooltip')}>
<TextArea rows={3} placeholder={`arg1\narg2`} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="env" label={t('settings.mcp.env')} tooltip={t('settings.mcp.envTooltip')}>
<TextArea rows={3} placeholder={`KEY1=value1\nKEY2=value2`} style={{ fontFamily: 'monospace' }} />
</Form.Item>
</>
)}
<Form.Item name="isActive" label={t('settings.mcp.active')} valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Form>
</Modal>
</SettingGroup> </SettingGroup>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.mcp.npx_list.title')}</SettingTitle>
<SettingDivider />
<Paragraph type="secondary" style={{ margin: '0 0 20px 0' }}>
{t('settings.mcp.npx_list.desc')}
</Paragraph>
<Space direction="vertical" style={{ width: '100%' }}>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder={t('settings.mcp.npx_list.scope_placeholder')}
value={npmScope}
onChange={(e) => setNpmScope(e.target.value)}
onPressEnter={handleNpmSearch}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={handleNpmSearch} disabled={searchLoading}>
{t('settings.mcp.npx_list.search')}
</Button>
</Space.Compact>
{searchLoading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin />
</div>
) : searchResults.length > 0 ? (
<Table<SearchResult>
dataSource={searchResults}
columns={[
{
title: t('settings.mcp.npx_list.package_name'),
dataIndex: 'name',
key: 'name',
width: '200px'
},
{
title: t('settings.mcp.npx_list.description'),
key: 'description',
render: (_, record: SearchResult) => (
<Space direction="vertical" size="small">
<Text>{record.description}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
{t('settings.mcp.npx_list.usage')}: {record.usage}
</Text>
<a href={record.npmLink} target="_blank" rel="noopener noreferrer" style={{ fontSize: '12px' }}>
{record.npmLink}
</a>
</Space>
)
},
{
title: t('settings.mcp.npx_list.version'),
dataIndex: 'version',
key: 'version',
width: '100px'
},
{
title: t('settings.mcp.npx_list.actions'),
key: 'actions',
width: '100px',
render: (_, record: SearchResult) => (
<Button
type="primary"
size="small"
onClick={() => {
// 创建一个临时的 MCP 服务器对象
const tempServer: MCPServer = {
name: record.name,
description: `${record.description}\n\n${t('settings.mcp.npx_list.usage')}: ${record.usage}\n${t('settings.mcp.npx_list.npm')}: ${record.npmLink}`,
command: 'npx',
args: ['-y', record.fullName],
isActive: true
}
// 使用 showEditModal 函数设置表单值并显示弹窗
showEditModal(tempServer)
}}>
{t('settings.mcp.addServer')}
</Button>
)
}
]}
pagination={false}
size="small"
bordered
/>
) : null}
</Space>
</SettingGroup>
<Modal
title={editingServer ? t('settings.mcp.editServer') : t('settings.mcp.addServer')}
open={isModalVisible}
onCancel={handleCancel}
onOk={handleSubmit}
confirmLoading={loading}
maskClosable={false}
width={600}>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label={t('settings.mcp.name')}
rules={[{ required: true, message: t('settings.mcp.nameRequired') }]}>
<Input disabled={!!editingServer} placeholder={t('common.name')} />
</Form.Item>
<Form.Item name="description" label={t('settings.mcp.description')}>
<TextArea rows={2} placeholder={t('common.description')} />
</Form.Item>
<Form.Item name="serverType" label={t('settings.mcp.type')} rules={[{ required: true }]} initialValue="stdio">
<Radio.Group
onChange={(e) => setServerType(e.target.value)}
options={[
{ label: 'SSE (Server-Sent Events)', value: 'sse' },
{ label: 'STDIO (Standard Input/Output)', value: 'stdio' }
]}
/>
</Form.Item>
{serverType === 'sse' && (
<Form.Item
name="baseUrl"
label={t('settings.mcp.url')}
rules={[{ required: serverType === 'sse', message: t('settings.mcp.baseUrlRequired') }]}
tooltip={t('settings.mcp.baseUrlTooltip')}>
<Input placeholder="http://localhost:3000/sse" />
</Form.Item>
)}
{serverType === 'stdio' && (
<>
<Form.Item
name="command"
label={t('settings.mcp.command')}
rules={[{ required: serverType === 'stdio', message: t('settings.mcp.commandRequired') }]}>
<Input placeholder="uvx or npx" />
</Form.Item>
<Form.Item name="args" label={t('settings.mcp.args')} tooltip={t('settings.mcp.argsTooltip')}>
<TextArea rows={3} placeholder={`arg1\narg2`} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="env" label={t('settings.mcp.env')} tooltip={t('settings.mcp.envTooltip')}>
<TextArea rows={3} placeholder={`KEY1=value1\nKEY2=value2`} style={{ fontFamily: 'monospace' }} />
</Form.Item>
</>
)}
<Form.Item name="isActive" label={t('settings.mcp.active')} valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Form>
</Modal>
</SettingContainer> </SettingContainer>
) )
} }

View File

@ -3235,6 +3235,7 @@ __metadata:
lodash: "npm:^4.17.21" lodash: "npm:^4.17.21"
markdown-it: "npm:^14.1.0" markdown-it: "npm:^14.1.0"
mime: "npm:^4.0.4" mime: "npm:^4.0.4"
npx-scope-finder: "npm:^1.2.0"
officeparser: "npm:^4.1.1" officeparser: "npm:^4.1.1"
openai: "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch" openai: "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch"
p-queue: "npm:^8.1.0" p-queue: "npm:^8.1.0"
@ -11173,6 +11174,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"npx-scope-finder@npm:^1.2.0":
version: 1.2.0
resolution: "npx-scope-finder@npm:1.2.0"
checksum: 10c0/2e397317cfd0bc28de9255a774a4872f89586ae3cdd86a88c3f0c02e4ad834a5750b299d3c14b3c825978f807dff07c0c764e3409f2163dfcaf32ff97696e452
languageName: node
linkType: hard
"number-is-nan@npm:^1.0.0": "number-is-nan@npm:^1.0.0":
version: 1.0.1 version: 1.0.1
resolution: "number-is-nan@npm:1.0.1" resolution: "number-is-nan@npm:1.0.1"