feat: detect MCP server status and show warning in status bar

Add check_mcp_status Tauri command that detects whether the MCP server
is installed, Claude CLI is missing, or config needs setup. The status
bar shows a warning badge (MCP ⚠) when not installed, clickable to
trigger install. Also available via command palette "Install MCP Server".

Replaces useMcpRegistration with useMcpStatus which combines detection
and registration in a single hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-04 13:11:58 +01:00
parent 1f1e115d70
commit 4dbcc7c298
10 changed files with 424 additions and 5 deletions

View File

@@ -64,6 +64,8 @@ interface AppCommandsConfig {
onRestoreGettingStarted?: () => void
isGettingStartedHidden?: boolean
vaultCount?: number
mcpStatus?: string
onInstallMcp?: () => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -172,6 +174,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onRestoreGettingStarted: config.onRestoreGettingStarted,
isGettingStartedHidden: config.isGettingStartedHidden,
vaultCount: config.vaultCount,
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
})
useKeyboardNavigation({

View File

@@ -19,6 +19,8 @@ interface CommandRegistryConfig {
entries: VaultEntry[]
modifiedCount: number
conflictCount?: number
mcpStatus?: string
onInstallMcp?: () => void
onQuickOpen: () => void
onCreateNote: () => void
@@ -193,6 +195,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onCheckForUpdates,
onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
} = config
const hasActiveNote = activeTabPath !== null
@@ -252,6 +255,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
{ id: 'install-mcp', label: 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install'], enabled: mcpStatus === 'not_installed' && !!onInstallMcp, execute: () => onInstallMcp?.() },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
@@ -269,5 +273,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
])
}

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useMcpStatus } from './useMcpStatus'
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(),
}))
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
describe('useMcpStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts in checking state and resolves to installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('updated')
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
expect(result.current.mcpStatus).toBe('checking')
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
})
it('resolves to not_installed when check returns not_installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('fail'))
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
})
it('resolves to no_claude_cli when check returns no_claude_cli', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('no_claude_cli')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('no cli'))
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('no_claude_cli')
})
})
it('install action calls register_mcp_tools and updates status', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
// Reset to test install action directly
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
return Promise.resolve(null)
})
await act(async () => {
await result.current.installMcp()
})
expect(result.current.mcpStatus).toBe('installed')
expect(onToast).toHaveBeenCalledWith('MCP server installed successfully')
})
it('install action shows error toast on failure', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('disk full'))
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
await act(async () => {
await result.current.installMcp()
})
expect(result.current.mcpStatus).toBe('not_installed')
expect(onToast).toHaveBeenCalledWith(expect.stringContaining('MCP install failed'))
})
it('shows toast when registered for the first time', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
return Promise.resolve(null)
})
const onToast = vi.fn()
renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(onToast).toHaveBeenCalledWith('Laputa registered as MCP tool for Claude Code')
})
})
it('does not show toast when already registered', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('updated')
return Promise.resolve(null)
})
const onToast = vi.fn()
renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('register_mcp_tools', { vaultPath: '/vault' })
})
// 'updated' should not trigger a toast
expect(onToast).not.toHaveBeenCalledWith('Laputa registered as MCP tool for Claude Code')
})
})

72
src/hooks/useMcpStatus.ts Normal file
View File

@@ -0,0 +1,72 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export type McpStatus = 'checking' | 'installed' | 'not_installed' | 'no_claude_cli'
function tauriCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
/**
* Detects MCP server status on vault open and provides an install action.
*
* Combines the old `useMcpRegistration` functionality (auto-register on vault
* switch) with new status detection for the status bar indicator.
*/
export function useMcpStatus(
vaultPath: string,
onToast: (msg: string) => void,
) {
const [status, setStatus] = useState<McpStatus>('checking')
const registeredRef = useRef<string | null>(null)
const onToastRef = useRef(onToast)
useEffect(() => { onToastRef.current = onToast })
// Check MCP status on vault open / vault switch
useEffect(() => {
let cancelled = false
setStatus('checking') // eslint-disable-line react-hooks/set-state-in-effect -- reset to checking on vault switch
tauriCall<string>('check_mcp_status')
.then((result) => {
if (!cancelled) setStatus(result as McpStatus)
})
.catch(() => {
if (!cancelled) setStatus('not_installed')
})
return () => { cancelled = true }
}, [vaultPath])
// Auto-register on vault switch (preserves old useMcpRegistration behavior)
useEffect(() => {
if (registeredRef.current === vaultPath) return
registeredRef.current = vaultPath
tauriCall<string>('register_mcp_tools', { vaultPath })
.then((result) => {
if (result === 'registered') {
onToastRef.current('Laputa registered as MCP tool for Claude Code')
}
setStatus('installed')
})
.catch(() => {
// Non-critical — status check will show the right state
})
}, [vaultPath])
const install = useCallback(async () => {
setStatus('checking')
try {
await tauriCall<string>('register_mcp_tools', { vaultPath })
setStatus('installed')
onToastRef.current('MCP server installed successfully')
} catch (e) {
setStatus('not_installed')
onToastRef.current(`MCP install failed: ${e}`)
}
}, [vaultPath])
return { mcpStatus: status, installMcp: install }
}