mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
feat(settings): add MCP server configuration panel (#2030)
Adds a Settings panel under Developer Options that lets users configure external MCP clients (Claude Desktop, Cursor, Codex, Zed) to connect to the openhuman-core mcp stdio server — removing the need to hand-edit vendor JSON files. - New Tauri commands: mcp_resolve_binary_path (returns binary path + OS), mcp_open_client_config (opens per-OS config file in system editor) - React panel with tool list (10 tools), per-client JSON snippet generator, copy-to-clipboard, and Tauri-gated "Open Config File" button - OS-aware config file paths for all four supported clients - 8 Vitest tests + 9 Rust unit tests; all checks pass Closes #2030
This commit is contained in:
@@ -16,6 +16,7 @@ mod gmessages_scanner;
|
||||
mod imessage_scanner;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mascot_native_window;
|
||||
mod mcp_commands;
|
||||
mod meet_audio;
|
||||
mod meet_call;
|
||||
mod meet_scanner;
|
||||
@@ -2955,7 +2956,9 @@ pub fn run() {
|
||||
meet_call::meet_call_close_window,
|
||||
companion_commands::register_companion_hotkey,
|
||||
companion_commands::unregister_companion_hotkey,
|
||||
companion_commands::companion_activate
|
||||
companion_commands::companion_activate,
|
||||
mcp_commands::mcp_resolve_binary_path,
|
||||
mcp_commands::mcp_open_client_config
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
//! Tauri commands for MCP server configuration.
|
||||
//!
|
||||
//! Exposes two commands to the frontend:
|
||||
//! - `mcp_resolve_binary_path` — locate the `openhuman-core` binary on disk.
|
||||
//! - `mcp_open_client_config` — open a supported MCP client's config file in
|
||||
//! the system default editor so the user can paste the generated snippet.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Information returned to the frontend about the MCP server binary.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct McpBinaryInfo {
|
||||
/// Absolute path to the `openhuman-core` binary.
|
||||
pub path: String,
|
||||
/// OS string: `"macos"` | `"windows"` | `"linux"`.
|
||||
pub os: String,
|
||||
}
|
||||
|
||||
/// Compute the current platform string at compile time.
|
||||
fn current_os() -> &'static str {
|
||||
#[cfg(target_os = "macos")]
|
||||
return "macos";
|
||||
#[cfg(target_os = "windows")]
|
||||
return "windows";
|
||||
#[cfg(target_os = "linux")]
|
||||
return "linux";
|
||||
}
|
||||
|
||||
/// Walk up from `start` until we find a directory containing
|
||||
/// `target/debug/openhuman-core[.exe]`. Returns the full path to the binary
|
||||
/// when found, or `None` if the tree is exhausted.
|
||||
fn find_debug_binary_walking_up(start: &std::path::Path) -> Option<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let bin_name = "openhuman-core.exe";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let bin_name = "openhuman-core";
|
||||
|
||||
let mut dir = start.to_path_buf();
|
||||
loop {
|
||||
let candidate = dir.join("target").join("debug").join(bin_name);
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
if !dir.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the absolute path to the `openhuman-core` binary.
|
||||
///
|
||||
/// In dev builds (`cfg!(debug_assertions)`) we:
|
||||
/// 1. Check `OPENHUMAN_CORE_BINARY_PATH` env var first.
|
||||
/// 2. Walk up from `current_exe()` looking for `target/debug/openhuman-core`.
|
||||
///
|
||||
/// In release builds the binary is a sibling of the shell executable:
|
||||
/// - macOS: `../MacOS/openhuman-core` relative to the host exe.
|
||||
/// - Windows / Linux: same directory as the host exe.
|
||||
fn resolve_binary_path() -> Result<PathBuf, String> {
|
||||
log::debug!("[mcp_commands] mcp_resolve_binary_path: resolving binary path");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let bin_name = "openhuman-core.exe";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let bin_name = "openhuman-core";
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
// Dev mode: env override takes priority.
|
||||
if let Ok(env_path) = std::env::var("OPENHUMAN_CORE_BINARY_PATH") {
|
||||
if !env_path.is_empty() {
|
||||
let p = PathBuf::from(&env_path);
|
||||
if p.exists() {
|
||||
log::debug!(
|
||||
"[mcp_commands] mcp_resolve_binary_path: using OPENHUMAN_CORE_BINARY_PATH={}",
|
||||
env_path
|
||||
);
|
||||
return Ok(p);
|
||||
}
|
||||
log::warn!(
|
||||
"[mcp_commands] OPENHUMAN_CORE_BINARY_PATH set to {env_path} but file not found; falling back to walk"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe()
|
||||
.map_err(|e| format!("current_exe failed: {e}"))?;
|
||||
let start = exe
|
||||
.parent()
|
||||
.ok_or_else(|| "current_exe has no parent directory".to_string())?;
|
||||
|
||||
let candidate = find_debug_binary_walking_up(start)
|
||||
.ok_or_else(|| format!("could not find target/debug/{bin_name} walking up from {}", start.display()))?;
|
||||
|
||||
log::debug!(
|
||||
"[mcp_commands] mcp_resolve_binary_path: dev binary found at {}",
|
||||
candidate.display()
|
||||
);
|
||||
return Ok(candidate);
|
||||
}
|
||||
|
||||
// Release mode: sibling binary.
|
||||
let exe = std::env::current_exe()
|
||||
.map_err(|e| format!("current_exe failed: {e}"))?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let candidate = {
|
||||
// macOS .app: Contents/MacOS/<host> → Contents/MacOS/openhuman-core
|
||||
exe.parent()
|
||||
.map(|p| p.join(bin_name))
|
||||
.ok_or_else(|| "current_exe has no parent directory".to_string())?
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let candidate = exe
|
||||
.parent()
|
||||
.map(|p| p.join(bin_name))
|
||||
.ok_or_else(|| "current_exe has no parent directory".to_string())?;
|
||||
|
||||
if !candidate.exists() {
|
||||
return Err(format!(
|
||||
"openhuman-core binary not found at expected path: {}",
|
||||
candidate.display()
|
||||
));
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[mcp_commands] mcp_resolve_binary_path: release binary at {}",
|
||||
candidate.display()
|
||||
);
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
/// Tauri command — resolve the `openhuman-core` binary path and OS name.
|
||||
///
|
||||
/// The frontend uses the returned path to generate client config JSON snippets
|
||||
/// that tell MCP clients (Claude Desktop, Cursor, Codex, Zed) how to spawn the
|
||||
/// stdio MCP server.
|
||||
#[tauri::command]
|
||||
pub fn mcp_resolve_binary_path() -> Result<McpBinaryInfo, String> {
|
||||
log::debug!("[mcp_commands] mcp_resolve_binary_path: command entry");
|
||||
let path = resolve_binary_path()?;
|
||||
let info = McpBinaryInfo {
|
||||
path: path.display().to_string(),
|
||||
os: current_os().to_string(),
|
||||
};
|
||||
log::debug!(
|
||||
"[mcp_commands] mcp_resolve_binary_path: resolved path={} os={}",
|
||||
info.path,
|
||||
info.os
|
||||
);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Return the OS-specific config file path for a given MCP client.
|
||||
///
|
||||
/// Extracted as a pure function so it can be tested independently of the Tauri
|
||||
/// command wrapper (which calls `open`/`xdg-open`).
|
||||
pub fn config_path_for_client(client: &str, os: &str) -> Result<PathBuf, String> {
|
||||
let home = directories::UserDirs::new()
|
||||
.map(|d| d.home_dir().to_path_buf())
|
||||
.ok_or_else(|| "could not determine home directory".to_string())?;
|
||||
|
||||
let path = match (client, os) {
|
||||
// Claude Desktop
|
||||
("claude-desktop", "macos") => {
|
||||
home.join("Library/Application Support/Claude/claude_desktop_config.json")
|
||||
}
|
||||
("claude-desktop", "windows") => {
|
||||
// %APPDATA%\Claude\claude_desktop_config.json
|
||||
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| {
|
||||
home.join("AppData/Roaming").display().to_string()
|
||||
});
|
||||
PathBuf::from(appdata).join("Claude").join("claude_desktop_config.json")
|
||||
}
|
||||
("claude-desktop", _) => {
|
||||
// Linux and other Unix
|
||||
home.join(".config/Claude/claude_desktop_config.json")
|
||||
}
|
||||
|
||||
// Cursor
|
||||
("cursor", "windows") => {
|
||||
let userprofile = std::env::var("USERPROFILE").unwrap_or_else(|_| {
|
||||
home.display().to_string()
|
||||
});
|
||||
PathBuf::from(userprofile).join(".cursor").join("mcp.json")
|
||||
}
|
||||
("cursor", _) => home.join(".cursor/mcp.json"),
|
||||
|
||||
// Codex — same path on all platforms
|
||||
("codex", _) => home.join(".codex/config.json"),
|
||||
|
||||
// Zed
|
||||
("zed", "macos") => {
|
||||
home.join("Library/Application Support/Zed/settings.json")
|
||||
}
|
||||
("zed", "windows") => {
|
||||
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| {
|
||||
home.join("AppData/Roaming").display().to_string()
|
||||
});
|
||||
PathBuf::from(appdata).join("Zed").join("settings.json")
|
||||
}
|
||||
("zed", _) => home.join(".config/zed/settings.json"),
|
||||
|
||||
_ => {
|
||||
return Err(format!("Unknown MCP client: {client}"));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Tauri command — open a supported MCP client's config file in the system
|
||||
/// default editor. Creates the file (and parent dirs) if it does not exist.
|
||||
///
|
||||
/// Supported `client` values: `"claude-desktop"`, `"cursor"`, `"codex"`, `"zed"`.
|
||||
#[tauri::command]
|
||||
pub fn mcp_open_client_config(client: String) -> Result<(), String> {
|
||||
log::debug!("[mcp_commands] mcp_open_client_config: client={client}");
|
||||
|
||||
let os = current_os();
|
||||
let path = config_path_for_client(&client, os)?;
|
||||
|
||||
log::debug!(
|
||||
"[mcp_commands] mcp_open_client_config: resolved path={} for client={}",
|
||||
path.display(),
|
||||
client
|
||||
);
|
||||
|
||||
// Ensure the file exists so the editor has something to open.
|
||||
if !path.exists() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("failed to create config directory {}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&path, b"")
|
||||
.map_err(|e| format!("failed to create config file {}: {e}", path.display()))?;
|
||||
log::debug!(
|
||||
"[mcp_commands] mcp_open_client_config: created empty file at {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let result = std::process::Command::new("open").arg(&path).spawn();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let result = std::process::Command::new("explorer").arg(&path).spawn();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let result = std::process::Command::new("xdg-open").arg(&path).spawn();
|
||||
|
||||
result
|
||||
.map(|_| {
|
||||
log::debug!(
|
||||
"[mcp_commands] mcp_open_client_config: opened {} for client={}",
|
||||
path.display(),
|
||||
client
|
||||
);
|
||||
})
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"failed to open config file {} for client {client}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// config_path_for_client — pure path resolution tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn config_path_claude_desktop_macos() {
|
||||
let path = config_path_for_client("claude-desktop", "macos")
|
||||
.expect("should resolve on macos");
|
||||
let s = path.display().to_string();
|
||||
assert!(
|
||||
s.contains("Library/Application Support/Claude/claude_desktop_config.json"),
|
||||
"unexpected path: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_claude_desktop_linux() {
|
||||
let path = config_path_for_client("claude-desktop", "linux")
|
||||
.expect("should resolve on linux");
|
||||
let s = path.display().to_string();
|
||||
assert!(s.contains(".config/Claude/claude_desktop_config.json"), "unexpected path: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_cursor_macos() {
|
||||
let path = config_path_for_client("cursor", "macos").expect("should resolve");
|
||||
let s = path.display().to_string();
|
||||
assert!(s.ends_with(".cursor/mcp.json"), "unexpected path: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_cursor_linux() {
|
||||
let path = config_path_for_client("cursor", "linux").expect("should resolve");
|
||||
let s = path.display().to_string();
|
||||
assert!(s.ends_with(".cursor/mcp.json"), "unexpected path: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_codex_all_platforms() {
|
||||
for os in &["macos", "windows", "linux"] {
|
||||
let path = config_path_for_client("codex", os)
|
||||
.unwrap_or_else(|_| panic!("should resolve for os={os}"));
|
||||
let s = path.display().to_string();
|
||||
assert!(s.ends_with(".codex/config.json"), "unexpected path for os={os}: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_zed_macos() {
|
||||
let path = config_path_for_client("zed", "macos").expect("should resolve");
|
||||
let s = path.display().to_string();
|
||||
assert!(
|
||||
s.contains("Library/Application Support/Zed/settings.json"),
|
||||
"unexpected path: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_zed_linux() {
|
||||
let path = config_path_for_client("zed", "linux").expect("should resolve");
|
||||
let s = path.display().to_string();
|
||||
assert!(s.contains(".config/zed/settings.json"), "unexpected path: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_unknown_client_returns_err() {
|
||||
let result = config_path_for_client("unknown-client", "macos");
|
||||
assert!(result.is_err(), "unknown client should return Err");
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("Unknown MCP client: unknown-client"),
|
||||
"unexpected error message: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// mcp_resolve_binary_path — integration-style test (path must exist in dev)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// In debug builds (the only mode in which `cargo test` runs), the binary
|
||||
/// path resolver should either find `OPENHUMAN_CORE_BINARY_PATH` or locate
|
||||
/// `target/debug/openhuman-core` by walking up from the test executable.
|
||||
///
|
||||
/// We only assert the path *contains* `openhuman-core` — the binary may or
|
||||
/// may not exist on disk in a fresh checkout, so we don't assert `Ok` here;
|
||||
/// instead we verify the error message is sensible when the file is absent.
|
||||
#[test]
|
||||
fn binary_path_result_contains_openhuman_core() {
|
||||
match resolve_binary_path() {
|
||||
Ok(p) => {
|
||||
let s = p.display().to_string();
|
||||
assert!(
|
||||
s.contains("openhuman-core"),
|
||||
"resolved path should contain 'openhuman-core', got: {s}"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// Acceptable in a clean CI checkout where the binary hasn't
|
||||
// been built yet. The error must be descriptive.
|
||||
assert!(
|
||||
e.contains("openhuman-core") || e.contains("current_exe") || e.contains("target"),
|
||||
"error message should reference the binary or path: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_debug_binary_returns_none_for_empty_dir() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
// Walk up from a fresh tempdir in the system temp folder — no ancestor
|
||||
// of /tmp (or equivalent) will contain target/debug/openhuman-core.
|
||||
let result = find_debug_binary_walking_up(dir.path());
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"expected None walking up from an empty tempdir, got: {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,8 @@ export type SettingsRoute =
|
||||
| 'intelligence'
|
||||
| 'webhooks-triggers'
|
||||
| 'composio-triggers'
|
||||
| 'composio-routing';
|
||||
| 'composio-routing'
|
||||
| 'mcp-server';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
@@ -113,6 +114,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
if (path.includes('/settings/notifications')) return 'notifications';
|
||||
if (path.includes('/settings/mascot')) return 'mascot';
|
||||
if (path.includes('/settings/appearance')) return 'appearance';
|
||||
if (path.includes('/settings/mcp-server')) return 'mcp-server';
|
||||
return 'home';
|
||||
};
|
||||
|
||||
@@ -219,6 +221,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
case 'composio-triggers':
|
||||
case 'composio-routing':
|
||||
case 'notification-routing':
|
||||
case 'mcp-server':
|
||||
return [settingsCrumb, developerCrumb];
|
||||
|
||||
// Developer options section page
|
||||
|
||||
@@ -237,6 +237,22 @@ const developerItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'mcp-server',
|
||||
titleKey: 'settings.developerMenu.mcpServer.title',
|
||||
descriptionKey: 'settings.developerMenu.mcpServer.desc',
|
||||
route: 'mcp-server',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const CoreModeBadge = () => {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Tests for McpServerPanel — MCP client configuration UI.
|
||||
*
|
||||
* Covers: tool list rendering, client tab selector, JSON snippet shape for
|
||||
* each client, copy-to-clipboard, binary-path error fallback, open-config
|
||||
* invoke, and Tauri-gate on the "Open Config File" button.
|
||||
*/
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks — must be defined before any imports that trigger the module
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
isTauri: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: hoisted.invoke,
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/tauriCommands/common', () => ({
|
||||
isTauri: hoisted.isTauri,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateToSettings: vi.fn(),
|
||||
navigateBack: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BINARY_PATH = '/usr/local/bin/openhuman-core';
|
||||
const DEFAULT_BINARY_INFO = { path: BINARY_PATH, os: 'macos' };
|
||||
|
||||
async function importPanel() {
|
||||
const mod = await import('./McpServerPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
function setupClipboard() {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
return writeText;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('McpServerPanel — tool list', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.invoke.mockReset();
|
||||
hoisted.invoke.mockResolvedValue(DEFAULT_BINARY_INFO);
|
||||
hoisted.isTauri.mockReset();
|
||||
hoisted.isTauri.mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('renders the panel with all 10 tool names visible', async () => {
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
const expectedTools = [
|
||||
'core.list_tools',
|
||||
'core.tool_instructions',
|
||||
'agent.list_subagents',
|
||||
'agent.run_subagent',
|
||||
'memory.search',
|
||||
'memory.recall',
|
||||
'tree.read_chunk',
|
||||
'tree.browse',
|
||||
'tree.top_entities',
|
||||
'tree.list_sources',
|
||||
];
|
||||
|
||||
for (const tool of expectedTools) {
|
||||
expect(screen.getByText(tool)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpServerPanel — client tabs', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.invoke.mockReset();
|
||||
hoisted.invoke.mockResolvedValue(DEFAULT_BINARY_INFO);
|
||||
hoisted.isTauri.mockReset();
|
||||
hoisted.isTauri.mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('renders all four client tabs', async () => {
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
expect(screen.getByRole('tab', { name: /Claude Desktop/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /Cursor/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /Codex/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /Zed/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpServerPanel — JSON snippets', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.invoke.mockReset();
|
||||
hoisted.invoke.mockResolvedValue(DEFAULT_BINARY_INFO);
|
||||
hoisted.isTauri.mockReset();
|
||||
hoisted.isTauri.mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('Claude Desktop snippet contains mcpServers key', async () => {
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
// Claude Desktop is selected by default
|
||||
await waitFor(() => {
|
||||
expect(hoisted.invoke).toHaveBeenCalledWith('mcp_resolve_binary_path');
|
||||
});
|
||||
|
||||
// The snippet should be in a <pre> element
|
||||
const preEl = document.querySelector('pre');
|
||||
expect(preEl).not.toBeNull();
|
||||
const content = preEl!.textContent ?? '';
|
||||
expect(content).toContain('mcpServers');
|
||||
expect(content).toContain(BINARY_PATH);
|
||||
});
|
||||
|
||||
test('Zed snippet contains context_servers key', async () => {
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hoisted.invoke).toHaveBeenCalledWith('mcp_resolve_binary_path');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Zed/i }));
|
||||
|
||||
const preEl = document.querySelector('pre');
|
||||
expect(preEl).not.toBeNull();
|
||||
const content = preEl!.textContent ?? '';
|
||||
expect(content).toContain('context_servers');
|
||||
expect(content).toContain(BINARY_PATH);
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpServerPanel — copy to clipboard', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.invoke.mockReset();
|
||||
hoisted.invoke.mockResolvedValue(DEFAULT_BINARY_INFO);
|
||||
hoisted.isTauri.mockReset();
|
||||
hoisted.isTauri.mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('copy button calls clipboard.writeText with JSON containing the binary path', async () => {
|
||||
const writeText = setupClipboard();
|
||||
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hoisted.invoke).toHaveBeenCalledWith('mcp_resolve_binary_path');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Copy to Clipboard/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const written: string = writeText.mock.calls[0][0];
|
||||
expect(written).toContain(BINARY_PATH);
|
||||
expect(written).toContain('mcpServers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpServerPanel — binary path error', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.invoke.mockReset();
|
||||
hoisted.isTauri.mockReset();
|
||||
hoisted.isTauri.mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('shows fallback placeholder when binary resolution fails', async () => {
|
||||
hoisted.invoke.mockRejectedValue(new Error('binary not found on disk'));
|
||||
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Binary not found — run: cargo build --bin openhuman-core/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpServerPanel — open config file', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.invoke.mockReset();
|
||||
hoisted.invoke.mockResolvedValue(DEFAULT_BINARY_INFO);
|
||||
hoisted.isTauri.mockReset();
|
||||
hoisted.isTauri.mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('Open Config File calls invoke with the active client', async () => {
|
||||
hoisted.invoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'mcp_resolve_binary_path') return Promise.resolve(DEFAULT_BINARY_INFO);
|
||||
if (cmd === 'mcp_open_client_config') return Promise.resolve();
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hoisted.invoke).toHaveBeenCalledWith('mcp_resolve_binary_path');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open Config File/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hoisted.invoke).toHaveBeenCalledWith('mcp_open_client_config', {
|
||||
client: 'claude-desktop',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Open Config File button is hidden when not in Tauri', async () => {
|
||||
hoisted.isTauri.mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
// The button should not appear
|
||||
expect(screen.queryByRole('button', { name: /Open Config File/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { isTauri } from '../../../utils/tauriCommands/common';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface McpBinaryInfo {
|
||||
path: string;
|
||||
os: string;
|
||||
}
|
||||
|
||||
type McpClient = 'claude-desktop' | 'cursor' | 'codex' | 'zed';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static tool catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MCP_TOOLS: { name: string; description: string }[] = [
|
||||
{ name: 'core.list_tools', description: 'List all available MCP tools' },
|
||||
{ name: 'core.tool_instructions', description: 'Get usage instructions for a tool' },
|
||||
{ name: 'agent.list_subagents', description: 'List available subagents' },
|
||||
{ name: 'agent.run_subagent', description: 'Run a subagent with a prompt' },
|
||||
{ name: 'memory.search', description: 'Search memory by semantic query' },
|
||||
{ name: 'memory.recall', description: 'Recall specific memories by ID' },
|
||||
{ name: 'tree.read_chunk', description: 'Read a memory tree chunk' },
|
||||
{ name: 'tree.browse', description: 'Browse the memory tree structure' },
|
||||
{ name: 'tree.top_entities', description: 'Get top entities from memory tree' },
|
||||
{ name: 'tree.list_sources', description: 'List memory tree sources' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config path helpers (mirrored from Rust for display only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function configFilePathFor(client: McpClient, os: string): string {
|
||||
const isWindows = os === 'windows';
|
||||
const isMac = os === 'macos';
|
||||
|
||||
switch (client) {
|
||||
case 'claude-desktop':
|
||||
if (isMac) return '~/Library/Application Support/Claude/claude_desktop_config.json';
|
||||
if (isWindows) return '%APPDATA%\\Claude\\claude_desktop_config.json';
|
||||
return '~/.config/Claude/claude_desktop_config.json';
|
||||
case 'cursor':
|
||||
if (isWindows) return '%USERPROFILE%\\.cursor\\mcp.json';
|
||||
return '~/.cursor/mcp.json';
|
||||
case 'codex':
|
||||
return '~/.codex/config.json';
|
||||
case 'zed':
|
||||
if (isMac) return '~/Library/Application Support/Zed/settings.json';
|
||||
if (isWindows) return '%APPDATA%\\Zed\\settings.json';
|
||||
return '~/.config/zed/settings.json';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON snippet builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildSnippet(client: McpClient, binaryPath: string): string {
|
||||
if (client === 'zed') {
|
||||
return JSON.stringify(
|
||||
{
|
||||
context_servers: {
|
||||
openhuman: {
|
||||
command: {
|
||||
path: binaryPath,
|
||||
args: ['mcp'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
// Claude Desktop, Cursor, Codex
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
openhuman: {
|
||||
command: binaryPath,
|
||||
args: ['mcp'],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// McpServerPanel component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const McpServerPanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
|
||||
const [binaryInfo, setBinaryInfo] = useState<McpBinaryInfo | null>(null);
|
||||
const [binaryError, setBinaryError] = useState<string | null>(null);
|
||||
const [activeClient, setActiveClient] = useState<McpClient>('claude-desktop');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [openConfigError, setOpenConfigError] = useState<string | null>(null);
|
||||
|
||||
// Resolve the binary path on mount.
|
||||
useEffect(() => {
|
||||
console.debug('[McpServerPanel] resolving mcp binary path');
|
||||
invoke<McpBinaryInfo>('mcp_resolve_binary_path')
|
||||
.then(info => {
|
||||
console.debug('[McpServerPanel] mcp binary resolved:', info.path, 'os:', info.os);
|
||||
setBinaryInfo(info);
|
||||
setBinaryError(null);
|
||||
})
|
||||
.catch(err => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.debug('[McpServerPanel] mcp binary resolution failed:', msg);
|
||||
setBinaryError(msg);
|
||||
setBinaryInfo(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const binaryPath = binaryInfo?.path ?? null;
|
||||
const os = binaryInfo?.os ?? 'macos';
|
||||
const displayPath = binaryPath ?? t('settings.mcpServer.binaryPathNotFound');
|
||||
const snippet = buildSnippet(activeClient, displayPath);
|
||||
const configPath = configFilePathFor(activeClient, os);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard write failed — silently ignore.
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenConfig = async () => {
|
||||
setOpenConfigError(null);
|
||||
try {
|
||||
await invoke('mcp_open_client_config', { client: activeClient });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setOpenConfigError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const clients: { id: McpClient; label: string }[] = [
|
||||
{ id: 'claude-desktop', label: t('settings.mcpServer.clientClaudeDesktop') },
|
||||
{ id: 'cursor', label: t('settings.mcpServer.clientCursor') },
|
||||
{ id: 'codex', label: t('settings.mcpServer.clientCodex') },
|
||||
{ id: 'zed', label: t('settings.mcpServer.clientZed') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title={t('settings.mcpServer.title')}
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 1 — Available Tools */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-neutral-100 mb-0.5">
|
||||
{t('settings.mcpServer.toolsSectionTitle')}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-neutral-400 mb-3">
|
||||
{t('settings.mcpServer.toolsSectionDesc')}
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 divide-y divide-stone-100 dark:divide-neutral-800 overflow-hidden">
|
||||
{MCP_TOOLS.map(tool => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex items-start gap-3 px-4 py-2.5 bg-white dark:bg-neutral-900">
|
||||
<span className="font-mono text-xs text-primary-700 dark:text-primary-400 mt-0.5 shrink-0">
|
||||
{tool.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-600 dark:text-neutral-400">
|
||||
{tool.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 2 — Client Configuration */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div className="px-4 pt-4 pb-6">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-neutral-100 mb-0.5">
|
||||
{t('settings.mcpServer.configSectionTitle')}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-neutral-400 mb-3">
|
||||
{t('settings.mcpServer.configSectionDesc')}
|
||||
</div>
|
||||
|
||||
{/* Client selector tabs */}
|
||||
<div
|
||||
className="flex gap-1 mb-4 flex-wrap"
|
||||
role="tablist"
|
||||
aria-label="MCP client selector">
|
||||
{clients.map(client => (
|
||||
<button
|
||||
key={client.id}
|
||||
role="tab"
|
||||
aria-selected={activeClient === client.id}
|
||||
onClick={() => {
|
||||
setActiveClient(client.id);
|
||||
setOpenConfigError(null);
|
||||
}}
|
||||
className={[
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
|
||||
activeClient === client.id
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-stone-100 dark:bg-neutral-800 text-slate-700 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700',
|
||||
].join(' ')}>
|
||||
{client.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Binary path error banner */}
|
||||
{binaryError && (
|
||||
<div className="mb-3 px-3 py-2 rounded-lg border border-coral-300 dark:border-coral-500/40 bg-coral-50 dark:bg-coral-500/10 text-xs text-coral-900 dark:text-coral-300">
|
||||
{t('settings.mcpServer.binaryPathNotFound')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config file path */}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 dark:text-neutral-400 shrink-0">
|
||||
{t('settings.mcpServer.configFilePath')}:
|
||||
</span>
|
||||
<span className="text-xs font-mono text-slate-700 dark:text-neutral-300 truncate">
|
||||
{configPath}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* JSON snippet */}
|
||||
<div className="rounded-xl overflow-hidden border border-stone-200 dark:border-neutral-800 mb-3">
|
||||
<pre className="bg-stone-50 dark:bg-neutral-900/60 px-4 py-3 text-xs font-mono text-slate-800 dark:text-neutral-200 overflow-x-auto whitespace-pre leading-relaxed">
|
||||
{snippet}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-700 hover:bg-slate-600 text-white transition-colors shrink-0">
|
||||
{copied ? t('settings.mcpServer.copied') : t('settings.mcpServer.copySnippet')}
|
||||
</button>
|
||||
|
||||
{isTauri() && (
|
||||
<button
|
||||
onClick={handleOpenConfig}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-stone-100 dark:bg-neutral-800 text-slate-700 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700 transition-colors shrink-0">
|
||||
{t('settings.mcpServer.openConfigFile')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open config error */}
|
||||
{openConfigError && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="mt-2 text-xs text-coral-600 dark:text-coral-300">
|
||||
{t('settings.mcpServer.openConfigError')}: {openConfigError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default McpServerPanel;
|
||||
@@ -1936,6 +1936,27 @@ const en: TranslationMap = {
|
||||
'settings.developerMenu.integrationTriggers.title': 'Integration Triggers',
|
||||
'settings.developerMenu.integrationTriggers.desc':
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
'settings.developerMenu.mcpServer.title': 'MCP Server',
|
||||
'settings.developerMenu.mcpServer.desc':
|
||||
'Configure external MCP clients to connect to OpenHuman',
|
||||
'settings.mcpServer.title': 'MCP Server',
|
||||
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
|
||||
'settings.mcpServer.toolsSectionDesc':
|
||||
'Tools exposed via the MCP stdio server when running openhuman-core mcp',
|
||||
'settings.mcpServer.configSectionTitle': 'Client Configuration',
|
||||
'settings.mcpServer.configSectionDesc':
|
||||
'Select your MCP client to generate the correct configuration snippet',
|
||||
'settings.mcpServer.copySnippet': 'Copy to Clipboard',
|
||||
'settings.mcpServer.copied': 'Copied!',
|
||||
'settings.mcpServer.openConfigFile': 'Open Config File',
|
||||
'settings.mcpServer.binaryPathNotFound':
|
||||
'Binary not found — run: cargo build --bin openhuman-core',
|
||||
'settings.mcpServer.openConfigError': 'Failed to open config file',
|
||||
'settings.mcpServer.clientClaudeDesktop': 'Claude Desktop',
|
||||
'settings.mcpServer.clientCursor': 'Cursor',
|
||||
'settings.mcpServer.clientCodex': 'Codex',
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Config file',
|
||||
'settings.appearance.menuDesc': 'Pick light, dark, or match your system theme',
|
||||
'settings.appearance.title': 'Appearance',
|
||||
'settings.appearance.themeHeading': 'Theme',
|
||||
|
||||
@@ -14,6 +14,7 @@ import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePan
|
||||
import ConnectionsPanel from '../components/settings/panels/ConnectionsPanel';
|
||||
import CronJobsPanel from '../components/settings/panels/CronJobsPanel';
|
||||
import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel';
|
||||
import McpServerPanel from '../components/settings/panels/McpServerPanel';
|
||||
import LocalModelDebugPanel from '../components/settings/panels/LocalModelDebugPanel';
|
||||
import MascotPanel from '../components/settings/panels/MascotPanel';
|
||||
import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel';
|
||||
@@ -352,6 +353,7 @@ const Settings = () => {
|
||||
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
|
||||
{/* Developer Options */}
|
||||
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
|
||||
<Route path="mcp-server" element={wrapSettingsPage(<McpServerPanel />)} />
|
||||
<Route
|
||||
path="notification-routing"
|
||||
element={wrapSettingsPage(<NotificationRoutingPanel />)}
|
||||
|
||||
Reference in New Issue
Block a user