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:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
151
src/hooks/useMcpStatus.test.ts
Normal file
151
src/hooks/useMcpStatus.test.ts
Normal 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
72
src/hooks/useMcpStatus.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user