Merge pull request #4913 from menloresearch/feat/mcp-setting
feat: MCP settings UI
This commit is contained in:
commit
a0d8d9cb27
@ -7,6 +7,9 @@ use tauri::{AppHandle, Manager, Runtime, State};
|
|||||||
use super::{server, setup, state::AppState};
|
use super::{server, setup, state::AppState};
|
||||||
|
|
||||||
const CONFIGURATION_FILE_NAME: &str = "settings.json";
|
const CONFIGURATION_FILE_NAME: &str = "settings.json";
|
||||||
|
const DEFAULT_MCP_CONFIG: &str = r#"{
|
||||||
|
"mcpServers": {}
|
||||||
|
}"#;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct AppConfiguration {
|
pub struct AppConfiguration {
|
||||||
@ -352,3 +355,29 @@ pub async fn call_tool(
|
|||||||
|
|
||||||
Err(format!("Tool {} not found", tool_name))
|
Err(format!("Tool {} not found", tool_name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_mcp_configs(app: AppHandle) -> Result<String, String> {
|
||||||
|
let mut path = get_jan_data_folder_path(app);
|
||||||
|
path.push("mcp_config.json");
|
||||||
|
log::info!("read mcp configs, path: {:?}", path);
|
||||||
|
|
||||||
|
// Create default empty config if file doesn't exist
|
||||||
|
if !path.exists() {
|
||||||
|
log::info!("mcp_config.json not found, creating default empty config");
|
||||||
|
fs::write(&path, DEFAULT_MCP_CONFIG)
|
||||||
|
.map_err(|e| format!("Failed to create default MCP config: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||||
|
return Ok(contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_mcp_configs(app: AppHandle, configs: String) -> Result<(), String> {
|
||||||
|
let mut path = get_jan_data_folder_path(app);
|
||||||
|
path.push("mcp_config.json");
|
||||||
|
log::info!("save mcp configs, path: {:?}", path);
|
||||||
|
|
||||||
|
fs::write(path, configs).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@ -39,6 +39,8 @@ pub fn run() {
|
|||||||
core::cmd::app_token,
|
core::cmd::app_token,
|
||||||
core::cmd::start_server,
|
core::cmd::start_server,
|
||||||
core::cmd::stop_server,
|
core::cmd::stop_server,
|
||||||
|
core::cmd::save_mcp_configs,
|
||||||
|
core::cmd::get_mcp_configs,
|
||||||
// MCP commands
|
// MCP commands
|
||||||
core::cmd::get_tools,
|
core::cmd::get_tools,
|
||||||
core::cmd::call_tool,
|
core::cmd::call_tool,
|
||||||
|
|||||||
@ -37,6 +37,7 @@
|
|||||||
"marked": "^9.1.2",
|
"marked": "^9.1.2",
|
||||||
"next": "14.2.3",
|
"next": "14.2.3",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
|
"npx-scope-finder": "^1.3.0",
|
||||||
"openai": "^4.90.0",
|
"openai": "^4.90.0",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
"postcss-url": "10.1.3",
|
"postcss-url": "10.1.3",
|
||||||
|
|||||||
98
web/screens/Settings/MCP/configuration.tsx
Normal file
98
web/screens/Settings/MCP/configuration.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
import { Button, TextArea } from '@janhq/joi'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
|
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
|
||||||
|
|
||||||
|
const MCPConfiguration = () => {
|
||||||
|
const janDataFolderPath = useAtomValue(janDataFolderPathAtom)
|
||||||
|
const [configContent, setConfigContent] = useState('')
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
|
||||||
|
const readConfigFile = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Read the file
|
||||||
|
const content = await window.core?.api.getMcpConfigs()
|
||||||
|
setConfigContent(content)
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reading config file:', err)
|
||||||
|
setError('Failed to read config file')
|
||||||
|
}
|
||||||
|
}, [janDataFolderPath])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (janDataFolderPath) {
|
||||||
|
readConfigFile()
|
||||||
|
}
|
||||||
|
}, [janDataFolderPath, readConfigFile])
|
||||||
|
|
||||||
|
const saveConfigFile = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsSaving(true)
|
||||||
|
setSuccess('')
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
// Validate JSON
|
||||||
|
try {
|
||||||
|
JSON.parse(configContent)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Invalid JSON format')
|
||||||
|
setIsSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await window.core?.api?.saveMcpConfigs({ configs: configContent })
|
||||||
|
|
||||||
|
setSuccess('Config saved successfully')
|
||||||
|
setIsSaving(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving config file:', err)
|
||||||
|
setError('Failed to save config file')
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}, [janDataFolderPath, configContent])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded bg-[hsla(var(--destructive-bg))] px-4 py-3 text-[hsla(var(--destructive-fg))]">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 rounded bg-[hsla(var(--success-bg))] px-4 py-3 text-[hsla(var(--success-fg))]">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4 mt-2">
|
||||||
|
<label className="mb-2 block text-sm font-medium">
|
||||||
|
Configuration File (JSON)
|
||||||
|
</label>
|
||||||
|
<TextArea
|
||||||
|
// className="h-80 w-full rounded border border-gray-800 p-2 font-mono text-sm"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
value={configContent}
|
||||||
|
rows={20}
|
||||||
|
onChange={(e) => {
|
||||||
|
setConfigContent(e.target.value)
|
||||||
|
setSuccess('')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={saveConfigFile} disabled={isSaving}>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Config'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MCPConfiguration
|
||||||
41
web/screens/Settings/MCP/index.tsx
Normal file
41
web/screens/Settings/MCP/index.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
import { ScrollArea, Tabs } from '@janhq/joi'
|
||||||
|
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
|
import MCPConfiguration from './configuration'
|
||||||
|
import MCPSearch from './search'
|
||||||
|
|
||||||
|
import { showScrollBarAtom } from '@/helpers/atoms/Setting.atom'
|
||||||
|
|
||||||
|
const MCP = () => {
|
||||||
|
const [tabValue, setTabValue] = useState('search_mcp')
|
||||||
|
const showScrollBar = useAtomValue(showScrollBarAtom)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea
|
||||||
|
type={showScrollBar ? 'always' : 'scroll'}
|
||||||
|
className="h-full w-full"
|
||||||
|
>
|
||||||
|
<div className="block w-full px-4 pb-4">
|
||||||
|
<div className="sticky top-0 bg-[hsla(var(--app-bg))] py-4">
|
||||||
|
<h2 className="mb-4 text-lg font-bold">MCP servers</h2>
|
||||||
|
<Tabs
|
||||||
|
options={[
|
||||||
|
{ name: 'Search MCP', value: 'search_mcp' },
|
||||||
|
{ name: 'Configuration', value: 'config' },
|
||||||
|
]}
|
||||||
|
tabStyle="segmented"
|
||||||
|
value={tabValue}
|
||||||
|
onValueChange={(value) => setTabValue(value as string)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{tabValue === 'search_mcp' && <MCPSearch />}
|
||||||
|
{tabValue === 'config' && <MCPConfiguration />}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MCP
|
||||||
208
web/screens/Settings/MCP/search.tsx
Normal file
208
web/screens/Settings/MCP/search.tsx
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
import { Button, Input } from '@janhq/joi'
|
||||||
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
import { npxFinder, NPMPackage } from 'npx-scope-finder'
|
||||||
|
|
||||||
|
import { toaster } from '@/containers/Toast'
|
||||||
|
|
||||||
|
interface MCPConfig {
|
||||||
|
mcpServers: {
|
||||||
|
[key: string]: {
|
||||||
|
command: string
|
||||||
|
args: string[]
|
||||||
|
env: Record<string, string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MCPSearch = () => {
|
||||||
|
const [showToast, setShowToast] = useState(false)
|
||||||
|
const [toastMessage, setToastMessage] = useState('')
|
||||||
|
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
||||||
|
const [orgName, setOrgName] = useState('@modelcontextprotocol')
|
||||||
|
const [packages, setPackages] = useState<NPMPackage[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const searchOrganizationPackages = useCallback(async () => {
|
||||||
|
if (!orgName) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
// Remove @ symbol if present at the beginning
|
||||||
|
// const scopeName = orgName.startsWith('@') ? orgName.substring(1) : orgName
|
||||||
|
|
||||||
|
// Use npxFinder to search for packages from the specified organization
|
||||||
|
const result = await npxFinder(orgName)
|
||||||
|
|
||||||
|
setPackages(result || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error searching for packages:', err)
|
||||||
|
setError('Failed to search for packages. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [orgName])
|
||||||
|
|
||||||
|
// Search for packages when the component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
searchOrganizationPackages()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mt-2 text-lg font-bold">NPX Package List</h2>
|
||||||
|
<p className="text-[hsla(var(--text-secondary))]">
|
||||||
|
Search and add npm packages as MCP servers
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
id="orgName"
|
||||||
|
type="text"
|
||||||
|
value={orgName}
|
||||||
|
onChange={(e) => setOrgName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && orgName) {
|
||||||
|
e.preventDefault()
|
||||||
|
searchOrganizationPackages()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="Enter npm scope name (e.g. @janhq)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={searchOrganizationPackages}
|
||||||
|
disabled={loading || !orgName}
|
||||||
|
>
|
||||||
|
{loading ? 'Searching...' : 'Search'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{packages.length > 0 ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
{packages.map((pkg, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="my-2 rounded-xl border border-[hsla(var(--app-border))]"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between border-b border-[hsla(var(--app-border))] px-4 py-3">
|
||||||
|
<span>{pkg.name?.split('/')[1]}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm text-[hsla(var(--text-secondary))]">
|
||||||
|
{pkg.version}
|
||||||
|
</span>
|
||||||
|
<Button theme="icon" onClick={() => handleAddToConfig(pkg)}>
|
||||||
|
<PlusIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p>{pkg.description || 'No description'}</p>
|
||||||
|
<p className="my-2 whitespace-nowrap text-[hsla(var(--text-secondary))]">
|
||||||
|
Usage: npx {pkg.name}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href={`https://www.npmjs.com/package/${pkg.name}`}
|
||||||
|
>{`https://www.npmjs.com/package/${pkg.name}`}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!loading && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p>
|
||||||
|
No packages found. Try searching for a different organization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showToast && (
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-4 right-4 z-50 rounded-md p-4 shadow-lg ${
|
||||||
|
toastType === 'success'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>{toastMessage}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowToast(false)}
|
||||||
|
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Function to add a package to the MCP configuration
|
||||||
|
async function handleAddToConfig(pkg: NPMPackage) {
|
||||||
|
try {
|
||||||
|
// Get current configuration
|
||||||
|
const currentConfig = await window.core?.api.getMcpConfigs()
|
||||||
|
|
||||||
|
// Parse the configuration
|
||||||
|
let config: MCPConfig
|
||||||
|
try {
|
||||||
|
config = JSON.parse(currentConfig || '{"mcpServers": {}}')
|
||||||
|
} catch (err) {
|
||||||
|
// If parsing fails, start with an empty configuration
|
||||||
|
config = { mcpServers: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique server name based on the package name
|
||||||
|
const serverName = pkg.name?.split('/')[1] || 'unknown'
|
||||||
|
|
||||||
|
// Check if this server already exists
|
||||||
|
if (config.mcpServers[serverName]) {
|
||||||
|
toaster({
|
||||||
|
title: `Add ${serverName} success`,
|
||||||
|
description: `Server ${serverName} already exists in configuration`,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new server configuration
|
||||||
|
config.mcpServers[serverName] = {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', pkg.name || ''],
|
||||||
|
env: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated configuration
|
||||||
|
await window.core?.api?.saveMcpConfigs({
|
||||||
|
configs: JSON.stringify(config, null, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
toaster({
|
||||||
|
title: `Add ${serverName} success`,
|
||||||
|
description: `Added ${serverName} to MCP configuration`,
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
toaster({
|
||||||
|
title: `Add ${pkg.name?.split('/')[1] || 'unknown'} failed`,
|
||||||
|
description: `Failed to add package to configuration`,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
console.error('Error adding package to configuration:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MCPSearch
|
||||||
@ -15,6 +15,7 @@ import RemoteEngineSettings from '@/screens/Settings/Engines/RemoteEngineSetting
|
|||||||
import ExtensionSetting from '@/screens/Settings/ExtensionSetting'
|
import ExtensionSetting from '@/screens/Settings/ExtensionSetting'
|
||||||
import Hardware from '@/screens/Settings/Hardware'
|
import Hardware from '@/screens/Settings/Hardware'
|
||||||
import Hotkeys from '@/screens/Settings/Hotkeys'
|
import Hotkeys from '@/screens/Settings/Hotkeys'
|
||||||
|
import MCP from '@/screens/Settings/MCP'
|
||||||
import MyModels from '@/screens/Settings/MyModels'
|
import MyModels from '@/screens/Settings/MyModels'
|
||||||
import Privacy from '@/screens/Settings/Privacy'
|
import Privacy from '@/screens/Settings/Privacy'
|
||||||
|
|
||||||
@ -31,6 +32,9 @@ const SettingDetail = () => {
|
|||||||
case 'Engines':
|
case 'Engines':
|
||||||
return <Engines />
|
return <Engines />
|
||||||
|
|
||||||
|
case 'MCP Servers':
|
||||||
|
return <MCP />
|
||||||
|
|
||||||
case 'Extensions':
|
case 'Extensions':
|
||||||
return <ExtensionCatalog />
|
return <ExtensionCatalog />
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export const SettingScreenList = [
|
|||||||
'Privacy',
|
'Privacy',
|
||||||
'Advanced Settings',
|
'Advanced Settings',
|
||||||
'Engines',
|
'Engines',
|
||||||
|
'MCP Servers',
|
||||||
'Extensions',
|
'Extensions',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|||||||
@ -81,7 +81,7 @@ const MessageContainer: React.FC<
|
|||||||
'group relative mx-auto px-4',
|
'group relative mx-auto px-4',
|
||||||
!(props.metadata && 'parent_id' in props.metadata) && 'py-2',
|
!(props.metadata && 'parent_id' in props.metadata) && 'py-2',
|
||||||
chatWidth === 'compact' && 'max-w-[700px]',
|
chatWidth === 'compact' && 'max-w-[700px]',
|
||||||
isUser && 'pb-4 pt-0'
|
!isUser && 'pb-4 pt-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!(props.metadata && 'parent_id' in props.metadata) && (
|
{!(props.metadata && 'parent_id' in props.metadata) && (
|
||||||
@ -92,7 +92,6 @@ const MessageContainer: React.FC<
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isUser && !isSystem && <LogoMark width={28} />}
|
{!isUser && !isSystem && <LogoMark width={28} />}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'font-extrabold capitalize',
|
'font-extrabold capitalize',
|
||||||
|
|||||||
@ -19,6 +19,8 @@ export const Routes = [
|
|||||||
'getThreadAssistant',
|
'getThreadAssistant',
|
||||||
'createThreadAssistant',
|
'createThreadAssistant',
|
||||||
'modifyThreadAssistant',
|
'modifyThreadAssistant',
|
||||||
|
'saveMcpConfigs',
|
||||||
|
'getMcpConfigs',
|
||||||
].map((r) => ({
|
].map((r) => ({
|
||||||
path: `app`,
|
path: `app`,
|
||||||
route: r,
|
route: r,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user