Merge pull request #4913 from menloresearch/feat/mcp-setting

feat: MCP settings UI
This commit is contained in:
Faisal Amir 2025-04-15 20:37:00 +07:00 committed by GitHub
commit a0d8d9cb27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 387 additions and 2 deletions

View File

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

View File

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

View File

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

View 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

View 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

View 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

View File

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

View File

@ -19,6 +19,7 @@ export const SettingScreenList = [
'Privacy', 'Privacy',
'Advanced Settings', 'Advanced Settings',
'Engines', 'Engines',
'MCP Servers',
'Extensions', 'Extensions',
] as const ] as const

View File

@ -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',

View File

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