diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 602a74bf0..1a431edaf 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -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") diff --git a/app/src-tauri/src/mcp_commands.rs b/app/src-tauri/src/mcp_commands.rs new file mode 100644 index 000000000..d9b90642c --- /dev/null +++ b/app/src-tauri/src/mcp_commands.rs @@ -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 { + #[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 { + 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/ → 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 { + 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 { + 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:?}" + ); + } +} diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index 93cbacb58..d7961f91b 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -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 diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index 23c70c4a0..aef409134 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -237,6 +237,22 @@ const developerItems = [ ), }, + { + id: 'mcp-server', + titleKey: 'settings.developerMenu.mcpServer.title', + descriptionKey: 'settings.developerMenu.mcpServer.desc', + route: 'mcp-server', + icon: ( + + + + ), + }, ]; const CoreModeBadge = () => { diff --git a/app/src/components/settings/panels/McpServerPanel.test.tsx b/app/src/components/settings/panels/McpServerPanel.test.tsx new file mode 100644 index 000000000..57acb72e3 --- /dev/null +++ b/app/src/components/settings/panels/McpServerPanel.test.tsx @@ -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(); + + 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(); + + 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(); + + // Claude Desktop is selected by default + await waitFor(() => { + expect(hoisted.invoke).toHaveBeenCalledWith('mcp_resolve_binary_path'); + }); + + // The snippet should be in a
 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();
+
+    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();
+
+    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();
+
+    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();
+
+    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();
+
+    // The button should not appear
+    expect(screen.queryByRole('button', { name: /Open Config File/i })).toBeNull();
+  });
+});
diff --git a/app/src/components/settings/panels/McpServerPanel.tsx b/app/src/components/settings/panels/McpServerPanel.tsx
new file mode 100644
index 000000000..7f4fd6074
--- /dev/null
+++ b/app/src/components/settings/panels/McpServerPanel.tsx
@@ -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(null);
+  const [binaryError, setBinaryError] = useState(null);
+  const [activeClient, setActiveClient] = useState('claude-desktop');
+  const [copied, setCopied] = useState(false);
+  const [openConfigError, setOpenConfigError] = useState(null);
+
+  // Resolve the binary path on mount.
+  useEffect(() => {
+    console.debug('[McpServerPanel] resolving mcp binary path');
+    invoke('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 (
+    
+ + + {/* ----------------------------------------------------------------- */} + {/* Section 1 — Available Tools */} + {/* ----------------------------------------------------------------- */} +
+
+ {t('settings.mcpServer.toolsSectionTitle')} +
+
+ {t('settings.mcpServer.toolsSectionDesc')} +
+
+ {MCP_TOOLS.map(tool => ( +
+ + {tool.name} + + + {tool.description} + +
+ ))} +
+
+ + {/* ----------------------------------------------------------------- */} + {/* Section 2 — Client Configuration */} + {/* ----------------------------------------------------------------- */} +
+
+ {t('settings.mcpServer.configSectionTitle')} +
+
+ {t('settings.mcpServer.configSectionDesc')} +
+ + {/* Client selector tabs */} +
+ {clients.map(client => ( + + ))} +
+ + {/* Binary path error banner */} + {binaryError && ( +
+ {t('settings.mcpServer.binaryPathNotFound')} +
+ )} + + {/* Config file path */} +
+ + {t('settings.mcpServer.configFilePath')}: + + + {configPath} + +
+ + {/* JSON snippet */} +
+
+            {snippet}
+          
+
+ + {/* Action buttons */} +
+ + + {isTauri() && ( + + )} +
+ + {/* Open config error */} + {openConfigError && ( +
+ {t('settings.mcpServer.openConfigError')}: {openConfigError} +
+ )} +
+
+ ); +}; + +export default McpServerPanel; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index abf4d88ee..782cf0c52 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index b61e30873..0a81026ec 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -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 = () => { )} /> {/* Developer Options */} )} /> + )} /> )}