From deff3067559a7d3c4a111906da33f1c28c29a79e Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:33:33 +0200 Subject: [PATCH] refactor(mcp): share the MCP configuration logic between desktop and phone The OAuth presets, the endpoint snippets, the OAuth clients, sessions and API tokens with their handlers and the copy tick now live in one useMcpIntegration hook. Two existing differences stay as options: the desktop loads only while the addon is on, and the phone reads the client list back after a create or rotate where the desktop appends locally. --- .../components/Settings/IntegrationsTab.tsx | 270 +------------ .../Settings/useMcpIntegration.test.ts | 227 +++++++++++ .../components/Settings/useMcpIntegration.ts | 356 ++++++++++++++++++ .../mobile/screens/settings/MSettingsMcp.tsx | 283 +++----------- 4 files changed, 643 insertions(+), 493 deletions(-) create mode 100644 client/src/components/Settings/useMcpIntegration.test.ts create mode 100644 client/src/components/Settings/useMcpIntegration.ts diff --git a/client/src/components/Settings/IntegrationsTab.tsx b/client/src/components/Settings/IntegrationsTab.tsx index e764e10c5..0a97916ce 100644 --- a/client/src/components/Settings/IntegrationsTab.tsx +++ b/client/src/components/Settings/IntegrationsTab.tsx @@ -1,107 +1,22 @@ import Section from './Section' -import React, { useEffect, useId, useRef, useState } from 'react' +import React, { useEffect, useId } from 'react' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, ChevronRight, RefreshCw, AlertTriangle, Link2 } from 'lucide-react' -import { authApi, oauthApi } from '../../api/client' import { useAddonStore } from '../../store/addonStore' import PhotoProvidersSection from './PhotoProvidersSection' import AirTrailConnectionSection from './AirTrailConnectionSection' import DawarichConnectionSection from './DawarichConnectionSection' import LlmConnectionSection from './LlmConnectionSection' import ApiKeysSection from './ApiKeysSection' -import { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../api/oauthScopes' import ScopeGroupPicker from '../OAuth/ScopeGroupPicker' import { useAuthStore } from '../../store/authStore' -import { getApiErrorMessage } from '../../utils/apiError' import ConfirmDialog from '../shared/ConfirmDialog' import { Tooltip } from '../shared/Tooltip' import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import { EditorField, INPUT, Segmented, TEXTAREA } from '../shared/dialogParts' import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsHint, StatusPill } from './settingsKit' - -interface OAuthPreset { - id: string - label: string - name: string - uris: string - scopes: string[] -} - -const OAUTH_PRESETS: OAuthPreset[] = [ - { - id: 'claude-web', - label: 'Claude.ai', - name: 'Claude.ai', - uris: 'https://claude.ai/api/mcp/auth_callback', - scopes: PRESET_SCOPES_DEFAULT, - }, - { - id: 'claude-desktop', - label: 'Claude Desktop', - name: 'Claude Desktop', - uris: 'http://localhost', - scopes: PRESET_SCOPES_DEFAULT, - }, - { - id: 'cursor', - label: 'Cursor', - name: 'Cursor', - uris: 'http://localhost', - scopes: PRESET_SCOPES_DEFAULT, - }, - { - id: 'vscode', - label: 'VS Code', - name: 'VS Code / Copilot', - uris: 'http://localhost', - scopes: PRESET_SCOPES_READONLY, - }, - { - id: 'windsurf', - label: 'Windsurf', - name: 'Windsurf', - uris: 'http://localhost', - scopes: PRESET_SCOPES_DEFAULT, - }, - { - id: 'zed', - label: 'Zed', - name: 'Zed', - uris: 'http://localhost', - scopes: PRESET_SCOPES_DEFAULT, - }, -] - - -interface OAuthClient { - id: string - name: string - client_id: string - redirect_uris: string[] - allowed_scopes: string[] - allows_client_credentials: boolean - created_at: string - client_secret?: string // only present on create -} - -interface OAuthSession { - id: number - client_id: string - client_name: string - scopes: string[] - access_token_expires_at: string - refresh_token_expires_at: string - created_at: string -} - -interface McpToken { - id: number - name: string - token_prefix: string - created_at: string - last_used_at: string | null -} +import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration' export default function IntegrationsTab(): React.ReactElement { const S = useIntegrations() @@ -141,186 +56,9 @@ function useIntegrations() { loadAddons() }, [loadAddons]) - // OAuth clients state - const [oauthClients, setOauthClients] = useState([]) - const [oauthSessions, setOauthSessions] = useState([]) - const [oauthCreateOpen, setOauthCreateOpen] = useState(false) - const [oauthNewName, setOauthNewName] = useState('') - const [oauthNewUris, setOauthNewUris] = useState('') - const [oauthNewScopes, setOauthNewScopes] = useState([]) - const [oauthCreating, setOauthCreating] = useState(false) - const [oauthCreatedClient, setOauthCreatedClient] = useState(null) - const [oauthDeleteId, setOauthDeleteId] = useState(null) - const [oauthRevokeId, setOauthRevokeId] = useState(null) - const [oauthRotateId, setOauthRotateId] = useState(null) - const [oauthRotatedSecret, setOauthRotatedSecret] = useState(null) - const [oauthRotating, setOauthRotating] = useState(false) - // oauthScopesOpen is managed internally by ScopeGroupPicker - const [oauthScopesExpanded, setOauthScopesExpanded] = useState>({}) - const [oauthIsMachine, setOauthIsMachine] = useState(false) + const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false }) - // MCP sub-tab state - const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth') - const [configOpenOAuth, setConfigOpenOAuth] = useState(false) - const [configOpenToken, setConfigOpenToken] = useState(false) - - // MCP state - const [mcpTokens, setMcpTokens] = useState([]) - const [mcpModalOpen, setMcpModalOpen] = useState(false) - const [mcpNewName, setMcpNewName] = useState('') - const [mcpCreatedToken, setMcpCreatedToken] = useState(null) - const [mcpCreating, setMcpCreating] = useState(false) - const [mcpDeleteId, setMcpDeleteId] = useState(null) - const [copiedKey, setCopiedKey] = useState(null) - const copyTimerRef = useRef | null>(null) - - useEffect(() => { - return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) } - }, []) - - const mcpEndpoint = `${window.location.origin}/mcp` - const mcpJsonConfigOAuth = `{ - "mcpServers": { - "trek": { - "command": "npx", - "args": [ - "mcp-remote", - "${mcpEndpoint}", - "--static-oauth-client-info", - "{\\"client_id\\": \\"\\", \\"client_secret\\": \\"\\"}" - ] - } - } -}` - const mcpJsonConfig = `{ - "mcpServers": { - "trek": { - "command": "npx", - "args": [ - "mcp-remote", - "${mcpEndpoint}", - "--header", - "Authorization: Bearer " - ] - } - } -}` - - useEffect(() => { - if (mcpEnabled) { - authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {}) - } - }, [mcpEnabled]) - - const handleCreateMcpToken = async () => { - if (!mcpNewName.trim()) return - setMcpCreating(true) - try { - const d = await authApi.mcpTokens.create(mcpNewName.trim()) - setMcpCreatedToken(d.token.raw_token) - setMcpNewName('') - setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev]) - } catch { - toast.error(t('settings.mcp.toast.createError')) - } finally { - setMcpCreating(false) - } - } - - const handleDeleteMcpToken = async (id: number) => { - try { - await authApi.mcpTokens.delete(id) - setMcpTokens(prev => prev.filter(tk => tk.id !== id)) - setMcpDeleteId(null) - toast.success(t('settings.mcp.toast.deleted')) - } catch { - toast.error(t('settings.mcp.toast.deleteError')) - } - } - - const handleCopy = (text: string, key: string) => { - void navigator.clipboard.writeText(text).then(() => { - setCopiedKey(key) - if (copyTimerRef.current) clearTimeout(copyTimerRef.current) - copyTimerRef.current = setTimeout(() => setCopiedKey(null), 2000) - }) - } - - // Load OAuth clients and sessions - useEffect(() => { - if (mcpEnabled) { - oauthApi.clients.list().then(d => setOauthClients(d.clients || [])).catch(() => {}) - oauthApi.sessions.list().then(d => setOauthSessions(d.sessions || [])).catch(() => {}) - } - }, [mcpEnabled]) - - const handleCreateOAuthClient = async () => { - if (!oauthNewName.trim()) return - if (!oauthIsMachine && !oauthNewUris.trim()) return - setOauthCreating(true) - try { - const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean) - const d = await oauthApi.clients.create({ - name: oauthNewName.trim(), - redirect_uris: uris, - allowed_scopes: oauthNewScopes, - ...(oauthIsMachine ? { allows_client_credentials: true } : {}), - }) - setOauthCreatedClient(d.client) - setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }]) - setOauthNewName('') - setOauthNewUris('') - setOauthNewScopes([]) - setOauthIsMachine(false) - } catch (err) { - // The server names the rule that refused the client (a scope, the - // ten-client cap, a redirect URI it will not take); swallowing it left - // "Failed to register OAuth client" and nothing to act on. - toast.error(getApiErrorMessage(err, t('settings.oauth.toast.createError'))) - } finally { - setOauthCreating(false) - } - } - - const handleDeleteOAuthClient = async (id: string) => { - try { - await oauthApi.clients.delete(id) - setOauthClients(prev => prev.filter(c => c.id !== id)) - setOauthDeleteId(null) - toast.success(t('settings.oauth.toast.deleted')) - } catch { - toast.error(t('settings.oauth.toast.deleteError')) - } - } - - const handleRotateSecret = async (id: string) => { - setOauthRotating(true) - try { - const d = await oauthApi.clients.rotate(id) - setOauthRotatedSecret(d.client_secret) - setOauthRotateId(null) - } catch { - toast.error(t('settings.oauth.toast.rotateError')) - } finally { - setOauthRotating(false) - } - } - - const handleRevokeSession = async (id: number) => { - try { - await oauthApi.sessions.revoke(id) - setOauthSessions(prev => prev.filter(s => s.id !== id)) - setOauthRevokeId(null) - toast.success(t('settings.oauth.toast.revoked')) - } catch { - toast.error(t('settings.oauth.toast.revokeError')) - } - } - - - return { - t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession, - } + return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, ...mcp } } type IntegrationsState = ReturnType diff --git a/client/src/components/Settings/useMcpIntegration.test.ts b/client/src/components/Settings/useMcpIntegration.test.ts new file mode 100644 index 000000000..210232be7 --- /dev/null +++ b/client/src/components/Settings/useMcpIntegration.test.ts @@ -0,0 +1,227 @@ +// FE-COMP-MCP-HOOK-001 to -012: the MCP configuration logic the desktop tab and the phone card share. +import { act, renderHook, waitFor } from '@testing-library/react'; +import { authApi, oauthApi } from '../../api/client'; +import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'; + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k, locale: 'en' }) })); + +const CLIENT: OAuthClient = { + id: 'c1', + name: 'Claude', + client_id: 'cid', + redirect_uris: ['https://x'], + allowed_scopes: ['trips:read'], + allows_client_credentials: false, + created_at: '2026-01-01', +}; +const SESSION = { + id: 7, + client_id: 'cid', + client_name: 'Claude', + scopes: [], + access_token_expires_at: '', + refresh_token_expires_at: '', + created_at: '', +}; +const TOKEN = { id: 3, name: 'old', token_prefix: 'trek_', created_at: '2026-01-01', last_used_at: null }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(authApi.mcpTokens, 'list').mockResolvedValue({ tokens: [TOKEN] }); + vi.spyOn(oauthApi.clients, 'list').mockResolvedValue({ clients: [CLIENT] }); + vi.spyOn(oauthApi.sessions, 'list').mockResolvedValue({ sessions: [SESSION] }); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +async function loaded(reloadClientsAfterWrite: boolean) { + const hook = renderHook(() => useMcpIntegration({ enabled: true, reloadClientsAfterWrite })); + await waitFor(() => expect(hook.result.current.oauthSessions).toHaveLength(1)); + await waitFor(() => expect(hook.result.current.mcpTokens).toHaveLength(1)); + return hook; +} + +describe('useMcpIntegration', () => { + it('FE-COMP-MCP-HOOK-001: loads nothing while disabled and everything once enabled', async () => { + const hook = renderHook(({ enabled }) => useMcpIntegration({ enabled, reloadClientsAfterWrite: false }), { + initialProps: { enabled: false }, + }); + expect(authApi.mcpTokens.list).not.toHaveBeenCalled(); + expect(oauthApi.clients.list).not.toHaveBeenCalled(); + hook.rerender({ enabled: true }); + await waitFor(() => expect(hook.result.current.oauthClients).toEqual([CLIENT])); + expect(hook.result.current.mcpTokens).toEqual([TOKEN]); + expect(hook.result.current.oauthSessions).toEqual([SESSION]); + }); + + it('FE-COMP-MCP-HOOK-002: builds the endpoint and both mcp-remote snippets from the origin', () => { + const { result } = renderHook(() => useMcpIntegration({ enabled: false, reloadClientsAfterWrite: false })); + const endpoint = `${window.location.origin}/mcp`; + expect(result.current.mcpEndpoint).toBe(endpoint); + expect(JSON.parse(result.current.mcpJsonConfig).mcpServers.trek.args).toEqual([ + 'mcp-remote', + endpoint, + '--header', + 'Authorization: Bearer ', + ]); + expect(JSON.parse(result.current.mcpJsonConfigOAuth).mcpServers.trek.args[2]).toBe('--static-oauth-client-info'); + expect(OAUTH_PRESETS.map((p) => p.id)).toEqual([ + 'claude-web', + 'claude-desktop', + 'cursor', + 'vscode', + 'windsurf', + 'zed', + ]); + }); + + it('FE-COMP-MCP-HOOK-003: a client needs a name and, unless it is a machine client, redirect URIs', async () => { + const create = vi.spyOn(oauthApi.clients, 'create'); + const { result } = await loaded(false); + act(() => result.current.setOauthNewName('A')); + await act(() => result.current.handleCreateOAuthClient()); + expect(create).not.toHaveBeenCalled(); + }); + + it('FE-COMP-MCP-HOOK-004: the desktop appends the created client locally without its secret and resets the form', async () => { + const created = { ...CLIENT, id: 'c2', client_secret: 's3cret' }; + vi.spyOn(oauthApi.clients, 'create').mockResolvedValue({ client: created }); + const { result } = await loaded(false); + act(() => result.current.setOauthNewName(' New ')); + act(() => result.current.setOauthNewUris('https://a\n\n https://b ')); + act(() => result.current.setOauthNewScopes(['trips:read'])); + vi.mocked(oauthApi.clients.list).mockClear(); + await act(() => result.current.handleCreateOAuthClient()); + expect(oauthApi.clients.create).toHaveBeenCalledWith({ + name: 'New', + redirect_uris: ['https://a', 'https://b'], + allowed_scopes: ['trips:read'], + }); + expect(result.current.oauthCreatedClient).toEqual(created); + expect(result.current.oauthClients).toEqual([CLIENT, { ...created, client_secret: undefined }]); + expect(oauthApi.clients.list).not.toHaveBeenCalled(); + expect(result.current.oauthNewName).toBe(''); + expect(result.current.oauthNewUris).toBe(''); + expect(result.current.oauthNewScopes).toEqual([]); + }); + + it('FE-COMP-MCP-HOOK-005: the phone reads the clients back after a create, and a machine client sends no URIs', async () => { + const created = { ...CLIENT, id: 'c2', client_secret: 's' }; + vi.spyOn(oauthApi.clients, 'create').mockResolvedValue({ client: created }); + const { result } = await loaded(true); + vi.mocked(oauthApi.clients.list).mockResolvedValue({ clients: [CLIENT, { ...created, client_secret: undefined }] }); + act(() => result.current.setOauthNewName('Bot')); + act(() => result.current.setOauthIsMachine(true)); + await act(() => result.current.handleCreateOAuthClient()); + expect(oauthApi.clients.create).toHaveBeenCalledWith({ + name: 'Bot', + redirect_uris: [], + allowed_scopes: [], + allows_client_credentials: true, + }); + expect(oauthApi.clients.list).toHaveBeenCalledTimes(2); + expect(result.current.oauthClients).toHaveLength(2); + expect(result.current.oauthIsMachine).toBe(false); + }); + + it('FE-COMP-MCP-HOOK-006: a refused create toasts the server reason', async () => { + vi.spyOn(oauthApi.clients, 'create').mockRejectedValue({ response: { data: { error: 'Too many clients' } } }); + const { result } = await loaded(false); + act(() => result.current.setOauthNewName('A')); + act(() => result.current.setOauthNewUris('https://a')); + await act(() => result.current.handleCreateOAuthClient()); + expect(toast.error).toHaveBeenCalledWith('Too many clients'); + expect(result.current.oauthCreating).toBe(false); + }); + + it('FE-COMP-MCP-HOOK-007: rotating shows the new secret and only the phone reads the clients back', async () => { + vi.spyOn(oauthApi.clients, 'rotate').mockResolvedValue({ client_secret: 'fresh' }); + const desktop = await loaded(false); + vi.mocked(oauthApi.clients.list).mockClear(); + act(() => desktop.result.current.setOauthRotateId('c1')); + await act(() => desktop.result.current.handleRotateSecret('c1')); + expect(desktop.result.current.oauthRotatedSecret).toBe('fresh'); + expect(desktop.result.current.oauthRotateId).toBeNull(); + expect(oauthApi.clients.list).not.toHaveBeenCalled(); + const phone = await loaded(true); + vi.mocked(oauthApi.clients.list).mockClear(); + await act(() => phone.result.current.handleRotateSecret('c1')); + expect(oauthApi.clients.list).toHaveBeenCalledTimes(1); + }); + + it('FE-COMP-MCP-HOOK-008: a failed rotate toasts and clears the busy flag', async () => { + vi.spyOn(oauthApi.clients, 'rotate').mockRejectedValue(new Error('x')); + const { result } = await loaded(true); + await act(() => result.current.handleRotateSecret('c1')); + expect(toast.error).toHaveBeenCalledWith('settings.oauth.toast.rotateError'); + expect(result.current.oauthRotating).toBe(false); + }); + + it('FE-COMP-MCP-HOOK-009: deleting a client and revoking a session drop the row and close the confirm', async () => { + vi.spyOn(oauthApi.clients, 'delete').mockResolvedValue({}); + vi.spyOn(oauthApi.sessions, 'revoke').mockResolvedValue({}); + const { result } = await loaded(false); + act(() => result.current.setOauthDeleteId('c1')); + await act(() => result.current.handleDeleteOAuthClient('c1')); + expect(result.current.oauthClients).toEqual([]); + expect(result.current.oauthDeleteId).toBeNull(); + expect(toast.success).toHaveBeenCalledWith('settings.oauth.toast.deleted'); + act(() => result.current.setOauthRevokeId(7)); + await act(() => result.current.handleRevokeSession(7)); + expect(result.current.oauthSessions).toEqual([]); + expect(result.current.oauthRevokeId).toBeNull(); + expect(toast.success).toHaveBeenCalledWith('settings.oauth.toast.revoked'); + }); + + it('FE-COMP-MCP-HOOK-010: failed client and session removals keep the rows and toast', async () => { + vi.spyOn(oauthApi.clients, 'delete').mockRejectedValue(new Error('x')); + vi.spyOn(oauthApi.sessions, 'revoke').mockRejectedValue(new Error('x')); + const { result } = await loaded(false); + await act(() => result.current.handleDeleteOAuthClient('c1')); + await act(() => result.current.handleRevokeSession(7)); + expect(result.current.oauthClients).toEqual([CLIENT]); + expect(result.current.oauthSessions).toEqual([SESSION]); + expect(toast.error).toHaveBeenCalledWith('settings.oauth.toast.deleteError'); + expect(toast.error).toHaveBeenCalledWith('settings.oauth.toast.revokeError'); + }); + + it('FE-COMP-MCP-HOOK-011: API tokens are created at the top of the list and deleted by id', async () => { + vi.spyOn(authApi.mcpTokens, 'create').mockResolvedValue({ + token: { id: 9, name: 'new', token_prefix: 'trek_n', created_at: '2026-02-02', raw_token: 'raw' }, + }); + vi.spyOn(authApi.mcpTokens, 'delete').mockResolvedValue({}); + const { result } = await loaded(false); + await act(() => result.current.handleCreateMcpToken()); + expect(authApi.mcpTokens.create).not.toHaveBeenCalled(); + act(() => result.current.setMcpNewName(' new ')); + await act(() => result.current.handleCreateMcpToken()); + expect(authApi.mcpTokens.create).toHaveBeenCalledWith('new'); + expect(result.current.mcpCreatedToken).toBe('raw'); + expect(result.current.mcpTokens.map((tk) => tk.id)).toEqual([9, 3]); + expect(result.current.mcpNewName).toBe(''); + await act(() => result.current.handleDeleteMcpToken(3)); + expect(result.current.mcpTokens.map((tk) => tk.id)).toEqual([9]); + expect(toast.success).toHaveBeenCalledWith('settings.mcp.toast.deleted'); + }); + + it('FE-COMP-MCP-HOOK-012: a copy ticks its key for two seconds', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + const { result } = await loaded(false); + vi.useFakeTimers(); + await act(async () => { + result.current.handleCopy('abc', 'endpoint'); + await Promise.resolve(); + }); + expect(writeText).toHaveBeenCalledWith('abc'); + expect(result.current.copiedKey).toBe('endpoint'); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.copiedKey).toBeNull(); + }); +}); diff --git a/client/src/components/Settings/useMcpIntegration.ts b/client/src/components/Settings/useMcpIntegration.ts new file mode 100644 index 000000000..3984f6bc8 --- /dev/null +++ b/client/src/components/Settings/useMcpIntegration.ts @@ -0,0 +1,356 @@ +import { useEffect, useRef, useState } from 'react'; +import { authApi, oauthApi } from '../../api/client'; +import { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../api/oauthScopes'; +import { useTranslation } from '../../i18n'; +import { getApiErrorMessage } from '../../utils/apiError'; +import { useToast } from '../shared/Toast'; + +export interface OAuthPreset { + id: string; + label: string; + name: string; + uris: string; + scopes: string[]; +} + +export const OAUTH_PRESETS: OAuthPreset[] = [ + { + id: 'claude-web', + label: 'Claude.ai', + name: 'Claude.ai', + uris: 'https://claude.ai/api/mcp/auth_callback', + scopes: PRESET_SCOPES_DEFAULT, + }, + { + id: 'claude-desktop', + label: 'Claude Desktop', + name: 'Claude Desktop', + uris: 'http://localhost', + scopes: PRESET_SCOPES_DEFAULT, + }, + { id: 'cursor', label: 'Cursor', name: 'Cursor', uris: 'http://localhost', scopes: PRESET_SCOPES_DEFAULT }, + { + id: 'vscode', + label: 'VS Code', + name: 'VS Code / Copilot', + uris: 'http://localhost', + scopes: PRESET_SCOPES_READONLY, + }, + { id: 'windsurf', label: 'Windsurf', name: 'Windsurf', uris: 'http://localhost', scopes: PRESET_SCOPES_DEFAULT }, + { id: 'zed', label: 'Zed', name: 'Zed', uris: 'http://localhost', scopes: PRESET_SCOPES_DEFAULT }, +]; + +export interface OAuthClient { + id: string; + name: string; + client_id: string; + redirect_uris: string[]; + allowed_scopes: string[]; + allows_client_credentials: boolean; + created_at: string; + client_secret?: string; // only present on create +} + +export interface OAuthSession { + id: number; + client_id: string; + client_name: string; + scopes: string[]; + access_token_expires_at: string; + refresh_token_expires_at: string; + created_at: string; +} + +export interface McpToken { + id: number; + name: string; + token_prefix: string; + created_at: string; + last_used_at: string | null; +} + +export interface McpIntegrationOptions { + /** Load the tokens, clients and sessions only while this is true (the desktop passes the MCP addon flag). */ + enabled: boolean; + /** + * After a client is created or its secret rotated, read the client list back from + * the server (the phone) instead of appending the new client locally (the desktop). + */ + reloadClientsAfterWrite: boolean; +} + +/** + * The MCP configuration behind the desktop integrations tab and the phone card: + * the endpoint and its mcp-remote snippets, the OAuth 2.1 clients with their + * sessions, the deprecated API tokens, and the copy-to-clipboard tick. Each shell + * renders its own rows, dialogs and sheets over this. + */ +export function useMcpIntegration({ enabled, reloadClientsAfterWrite }: McpIntegrationOptions) { + const { t } = useTranslation(); + const toast = useToast(); + + // OAuth clients state + const [oauthClients, setOauthClients] = useState([]); + const [oauthSessions, setOauthSessions] = useState([]); + const [oauthCreateOpen, setOauthCreateOpen] = useState(false); + const [oauthNewName, setOauthNewName] = useState(''); + const [oauthNewUris, setOauthNewUris] = useState(''); + const [oauthNewScopes, setOauthNewScopes] = useState([]); + const [oauthCreating, setOauthCreating] = useState(false); + const [oauthCreatedClient, setOauthCreatedClient] = useState(null); + const [oauthDeleteId, setOauthDeleteId] = useState(null); + const [oauthRevokeId, setOauthRevokeId] = useState(null); + const [oauthRotateId, setOauthRotateId] = useState(null); + const [oauthRotatedSecret, setOauthRotatedSecret] = useState(null); + const [oauthRotating, setOauthRotating] = useState(false); + const [oauthScopesExpanded, setOauthScopesExpanded] = useState>({}); + const [oauthIsMachine, setOauthIsMachine] = useState(false); + + // MCP sub-tab state + const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth'); + const [configOpenOAuth, setConfigOpenOAuth] = useState(false); + const [configOpenToken, setConfigOpenToken] = useState(false); + + // MCP state + const [mcpTokens, setMcpTokens] = useState([]); + const [mcpModalOpen, setMcpModalOpen] = useState(false); + const [mcpNewName, setMcpNewName] = useState(''); + const [mcpCreatedToken, setMcpCreatedToken] = useState(null); + const [mcpCreating, setMcpCreating] = useState(false); + const [mcpDeleteId, setMcpDeleteId] = useState(null); + const [copiedKey, setCopiedKey] = useState(null); + const copyTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }; + }, []); + + const mcpEndpoint = `${window.location.origin}/mcp`; + const mcpJsonConfigOAuth = `{ + "mcpServers": { + "trek": { + "command": "npx", + "args": [ + "mcp-remote", + "${mcpEndpoint}", + "--static-oauth-client-info", + "{\\"client_id\\": \\"\\", \\"client_secret\\": \\"\\"}" + ] + } + } +}`; + const mcpJsonConfig = `{ + "mcpServers": { + "trek": { + "command": "npx", + "args": [ + "mcp-remote", + "${mcpEndpoint}", + "--header", + "Authorization: Bearer " + ] + } + } +}`; + + // Re-read after create/rotate so the rows show what the server actually stored. + const reloadClients = () => + oauthApi.clients + .list() + .then((d) => setOauthClients(d.clients || [])) + .catch(() => {}); + + useEffect(() => { + if (enabled) { + authApi.mcpTokens + .list() + .then((d) => setMcpTokens(d.tokens || [])) + .catch(() => {}); + } + }, [enabled]); + + const handleCreateMcpToken = async () => { + if (!mcpNewName.trim()) return; + setMcpCreating(true); + try { + const d = await authApi.mcpTokens.create(mcpNewName.trim()); + setMcpCreatedToken(d.token.raw_token); + setMcpNewName(''); + setMcpTokens((prev) => [ + { + id: d.token.id, + name: d.token.name, + token_prefix: d.token.token_prefix, + created_at: d.token.created_at, + last_used_at: null, + }, + ...prev, + ]); + } catch { + toast.error(t('settings.mcp.toast.createError')); + } finally { + setMcpCreating(false); + } + }; + + const handleDeleteMcpToken = async (id: number) => { + try { + await authApi.mcpTokens.delete(id); + setMcpTokens((prev) => prev.filter((tk) => tk.id !== id)); + setMcpDeleteId(null); + toast.success(t('settings.mcp.toast.deleted')); + } catch { + toast.error(t('settings.mcp.toast.deleteError')); + } + }; + + const handleCopy = (text: string, key: string) => { + void navigator.clipboard.writeText(text).then(() => { + setCopiedKey(key); + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopiedKey(null), 2000); + }); + }; + + // Load OAuth clients and sessions + useEffect(() => { + if (enabled) { + oauthApi.clients + .list() + .then((d) => setOauthClients(d.clients || [])) + .catch(() => {}); + oauthApi.sessions + .list() + .then((d) => setOauthSessions(d.sessions || [])) + .catch(() => {}); + } + }, [enabled]); + + const handleCreateOAuthClient = async () => { + if (!oauthNewName.trim()) return; + if (!oauthIsMachine && !oauthNewUris.trim()) return; + setOauthCreating(true); + try { + const uris = oauthIsMachine + ? [] + : oauthNewUris + .split('\n') + .map((u) => u.trim()) + .filter(Boolean); + const d = await oauthApi.clients.create({ + name: oauthNewName.trim(), + redirect_uris: uris, + allowed_scopes: oauthNewScopes, + ...(oauthIsMachine ? { allows_client_credentials: true } : {}), + }); + setOauthCreatedClient(d.client); + if (reloadClientsAfterWrite) await reloadClients(); + else setOauthClients((prev) => [...prev, { ...d.client, client_secret: undefined }]); + setOauthNewName(''); + setOauthNewUris(''); + setOauthNewScopes([]); + setOauthIsMachine(false); + } catch (err) { + // The server names the rule that refused the client (a scope, the + // ten-client cap, a redirect URI it will not take); swallowing it left + // "Failed to register OAuth client" and nothing to act on. + toast.error(getApiErrorMessage(err, t('settings.oauth.toast.createError'))); + } finally { + setOauthCreating(false); + } + }; + + const handleDeleteOAuthClient = async (id: string) => { + try { + await oauthApi.clients.delete(id); + setOauthClients((prev) => prev.filter((c) => c.id !== id)); + setOauthDeleteId(null); + toast.success(t('settings.oauth.toast.deleted')); + } catch { + toast.error(t('settings.oauth.toast.deleteError')); + } + }; + + const handleRotateSecret = async (id: string) => { + setOauthRotating(true); + try { + const d = await oauthApi.clients.rotate(id); + setOauthRotatedSecret(d.client_secret); + setOauthRotateId(null); + if (reloadClientsAfterWrite) await reloadClients(); + } catch { + toast.error(t('settings.oauth.toast.rotateError')); + } finally { + setOauthRotating(false); + } + }; + + const handleRevokeSession = async (id: number) => { + try { + await oauthApi.sessions.revoke(id); + setOauthSessions((prev) => prev.filter((s) => s.id !== id)); + setOauthRevokeId(null); + toast.success(t('settings.oauth.toast.revoked')); + } catch { + toast.error(t('settings.oauth.toast.revokeError')); + } + }; + + return { + oauthClients, + oauthSessions, + oauthCreateOpen, + setOauthCreateOpen, + oauthNewName, + setOauthNewName, + oauthNewUris, + setOauthNewUris, + oauthNewScopes, + setOauthNewScopes, + oauthCreating, + oauthCreatedClient, + setOauthCreatedClient, + oauthDeleteId, + setOauthDeleteId, + oauthRevokeId, + setOauthRevokeId, + oauthRotateId, + setOauthRotateId, + oauthRotatedSecret, + setOauthRotatedSecret, + oauthRotating, + oauthScopesExpanded, + setOauthScopesExpanded, + oauthIsMachine, + setOauthIsMachine, + activeMcpTab, + setActiveMcpTab, + configOpenOAuth, + setConfigOpenOAuth, + configOpenToken, + setConfigOpenToken, + mcpTokens, + mcpModalOpen, + setMcpModalOpen, + mcpNewName, + setMcpNewName, + mcpCreatedToken, + setMcpCreatedToken, + mcpCreating, + mcpDeleteId, + setMcpDeleteId, + copiedKey, + mcpEndpoint, + mcpJsonConfigOAuth, + mcpJsonConfig, + handleCreateMcpToken, + handleDeleteMcpToken, + handleCopy, + handleCreateOAuthClient, + handleDeleteOAuthClient, + handleRotateSecret, + handleRevokeSession, + }; +} diff --git a/client/src/mobile/screens/settings/MSettingsMcp.tsx b/client/src/mobile/screens/settings/MSettingsMcp.tsx index 00942342b..f2593035c 100644 --- a/client/src/mobile/screens/settings/MSettingsMcp.tsx +++ b/client/src/mobile/screens/settings/MSettingsMcp.tsx @@ -1,9 +1,6 @@ -import { useEffect, useRef, useState } from 'react' import { Check, ChevronDown, ChevronRight, Copy, KeyRound, Plus, RefreshCw, Terminal, Trash2 } from 'lucide-react' import { useTranslation } from '../../../i18n' -import { useToast } from '../../../components/shared/Toast' -import { authApi, oauthApi } from '../../../api/client' -import { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../../api/oauthScopes' +import { OAUTH_PRESETS, useMcpIntegration } from '../../../components/Settings/useMcpIntegration' import MScopeGroupPicker from './MScopeGroupPicker' import MSheet from '../../components/MSheet' import MToggle from '../../components/MToggle' @@ -11,53 +8,6 @@ import MChip from '../../components/MChip' import { MSetCard, MSetEyebrow, MSetInput, MSetTextarea, MSetButton, MSetHint, MSetRow } from './MSettingsUi' import MConfirmSheet from './MConfirmSheet' import MSegmented from '../../components/MSegmented' -import { getApiErrorMessage } from '../../../utils/apiError' - -interface OAuthPreset { - id: string - label: string - name: string - uris: string - scopes: string[] -} - -const OAUTH_PRESETS: OAuthPreset[] = [ - { id: 'claude-web', label: 'Claude.ai', name: 'Claude.ai', uris: 'https://claude.ai/api/mcp/auth_callback', scopes: PRESET_SCOPES_DEFAULT }, - { id: 'claude-desktop', label: 'Claude Desktop', name: 'Claude Desktop', uris: 'http://localhost', scopes: PRESET_SCOPES_DEFAULT }, - { id: 'cursor', label: 'Cursor', name: 'Cursor', uris: 'http://localhost', scopes: PRESET_SCOPES_DEFAULT }, - { id: 'vscode', label: 'VS Code', name: 'VS Code / Copilot', uris: 'http://localhost', scopes: PRESET_SCOPES_READONLY }, - { id: 'windsurf', label: 'Windsurf', name: 'Windsurf', uris: 'http://localhost', scopes: PRESET_SCOPES_DEFAULT }, - { id: 'zed', label: 'Zed', name: 'Zed', uris: 'http://localhost', scopes: PRESET_SCOPES_DEFAULT }, -] - -interface OAuthClient { - id: string - name: string - client_id: string - redirect_uris: string[] - allowed_scopes: string[] - allows_client_credentials: boolean - created_at: string - client_secret?: string // only present on create -} - -interface OAuthSession { - id: number - client_id: string - client_name: string - scopes: string[] - access_token_expires_at: string - refresh_token_expires_at: string - created_at: string -} - -interface McpToken { - id: number - name: string - token_prefix: string - created_at: string - last_used_at: string | null -} function CopyButton({ text, copied, onCopy }: { text: string; copied: boolean; onCopy: (text: string) => void }) { return ( @@ -87,182 +37,61 @@ function CodeBlock({ children }: { children: string }) { */ export default function MSettingsMcp() { const { t, locale } = useTranslation() - const toast = useToast() - - const [activeTab, setActiveTab] = useState<'oauth' | 'apitokens'>('oauth') - const [configOpenOAuth, setConfigOpenOAuth] = useState(false) - const [configOpenToken, setConfigOpenToken] = useState(false) - const [copiedKey, setCopiedKey] = useState(null) - const copyTimerRef = useRef | null>(null) - - // OAuth clients - const [clients, setClients] = useState([]) - const [sessions, setSessions] = useState([]) - const [createOpen, setCreateOpen] = useState(false) - const [newName, setNewName] = useState('') - const [newUris, setNewUris] = useState('') - const [newScopes, setNewScopes] = useState([]) - const [isMachine, setIsMachine] = useState(false) - const [creating, setCreating] = useState(false) - const [createdClient, setCreatedClient] = useState(null) - const [deleteId, setDeleteId] = useState(null) - const [revokeId, setRevokeId] = useState(null) - const [rotateId, setRotateId] = useState(null) - const [rotatedSecret, setRotatedSecret] = useState(null) - const [rotating, setRotating] = useState(false) - const [scopesExpanded, setScopesExpanded] = useState>({}) - - // API tokens (deprecated) - const [tokens, setTokens] = useState([]) - const [tokenModalOpen, setTokenModalOpen] = useState(false) - const [tokenNewName, setTokenNewName] = useState('') - const [createdToken, setCreatedToken] = useState(null) - const [tokenCreating, setTokenCreating] = useState(false) - const [tokenDeleteId, setTokenDeleteId] = useState(null) - - useEffect(() => { - return () => { - if (copyTimerRef.current) clearTimeout(copyTimerRef.current) - } - }, []) - - // Re-read after create/rotate so the rows show what the server actually stored. - const reloadClients = () => oauthApi.clients.list().then((d) => setClients(d.clients || [])).catch(() => {}) - - useEffect(() => { - authApi.mcpTokens.list().then((d) => setTokens(d.tokens || [])).catch(() => {}) - reloadClients() - oauthApi.sessions.list().then((d) => setSessions(d.sessions || [])).catch(() => {}) - }, []) - - const mcpEndpoint = `${window.location.origin}/mcp` - const jsonConfigOAuth = `{ - "mcpServers": { - "trek": { - "command": "npx", - "args": [ - "mcp-remote", - "${mcpEndpoint}", - "--static-oauth-client-info", - "{\\"client_id\\": \\"\\", \\"client_secret\\": \\"\\"}" - ] - } - } -}` - const jsonConfigToken = `{ - "mcpServers": { - "trek": { - "command": "npx", - "args": [ - "mcp-remote", - "${mcpEndpoint}", - "--header", - "Authorization: Bearer " - ] - } - } -}` - - const handleCopy = (text: string, key: string) => { - void navigator.clipboard.writeText(text).then(() => { - setCopiedKey(key) - if (copyTimerRef.current) clearTimeout(copyTimerRef.current) - copyTimerRef.current = setTimeout(() => setCopiedKey(null), 2000) - }) - } - - const createClient = async () => { - if (!newName.trim()) return - if (!isMachine && !newUris.trim()) return - setCreating(true) - try { - const uris = isMachine ? [] : newUris.split('\n').map((u) => u.trim()).filter(Boolean) - const d = await oauthApi.clients.create({ - name: newName.trim(), - redirect_uris: uris, - allowed_scopes: newScopes, - ...(isMachine ? { allows_client_credentials: true } : {}), - }) - setCreatedClient(d.client) - await reloadClients() - setNewName('') - setNewUris('') - setNewScopes([]) - setIsMachine(false) - } catch (err) { - // The server names the rule that refused the client (a scope, the - // ten-client cap, a redirect URI it will not take); swallowing it left - // "Failed to register OAuth client" and nothing to act on. - toast.error(getApiErrorMessage(err, t('settings.oauth.toast.createError'))) - } finally { - setCreating(false) - } - } - - const deleteClient = async (id: string) => { - try { - await oauthApi.clients.delete(id) - setClients((prev) => prev.filter((c) => c.id !== id)) - setDeleteId(null) - toast.success(t('settings.oauth.toast.deleted')) - } catch { - toast.error(t('settings.oauth.toast.deleteError')) - } - } - - const rotateSecret = async (id: string) => { - setRotating(true) - try { - const d = await oauthApi.clients.rotate(id) - setRotatedSecret(d.client_secret) - setRotateId(null) - await reloadClients() - } catch { - toast.error(t('settings.oauth.toast.rotateError')) - } finally { - setRotating(false) - } - } - - const revokeSession = async (id: number) => { - try { - await oauthApi.sessions.revoke(id) - setSessions((prev) => prev.filter((s) => s.id !== id)) - setRevokeId(null) - toast.success(t('settings.oauth.toast.revoked')) - } catch { - toast.error(t('settings.oauth.toast.revokeError')) - } - } - - const createToken = async () => { - if (!tokenNewName.trim()) return - setTokenCreating(true) - try { - const d = await authApi.mcpTokens.create(tokenNewName.trim()) - setCreatedToken(d.token.raw_token) - setTokenNewName('') - setTokens((prev) => [ - { id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, - ...prev, - ]) - } catch { - toast.error(t('settings.mcp.toast.createError')) - } finally { - setTokenCreating(false) - } - } - - const deleteToken = async (id: number) => { - try { - await authApi.mcpTokens.delete(id) - setTokens((prev) => prev.filter((tk) => tk.id !== id)) - setTokenDeleteId(null) - toast.success(t('settings.mcp.toast.deleted')) - } catch { - toast.error(t('settings.mcp.toast.deleteError')) - } - } + const { + activeMcpTab: activeTab, + setActiveMcpTab: setActiveTab, + configOpenOAuth, + setConfigOpenOAuth, + configOpenToken, + setConfigOpenToken, + copiedKey, + oauthClients: clients, + oauthSessions: sessions, + oauthCreateOpen: createOpen, + setOauthCreateOpen: setCreateOpen, + oauthNewName: newName, + setOauthNewName: setNewName, + oauthNewUris: newUris, + setOauthNewUris: setNewUris, + oauthNewScopes: newScopes, + setOauthNewScopes: setNewScopes, + oauthIsMachine: isMachine, + setOauthIsMachine: setIsMachine, + oauthCreating: creating, + oauthCreatedClient: createdClient, + setOauthCreatedClient: setCreatedClient, + oauthDeleteId: deleteId, + setOauthDeleteId: setDeleteId, + oauthRevokeId: revokeId, + setOauthRevokeId: setRevokeId, + oauthRotateId: rotateId, + setOauthRotateId: setRotateId, + oauthRotatedSecret: rotatedSecret, + setOauthRotatedSecret: setRotatedSecret, + oauthRotating: rotating, + oauthScopesExpanded: scopesExpanded, + setOauthScopesExpanded: setScopesExpanded, + mcpTokens: tokens, + mcpModalOpen: tokenModalOpen, + setMcpModalOpen: setTokenModalOpen, + mcpNewName: tokenNewName, + setMcpNewName: setTokenNewName, + mcpCreatedToken: createdToken, + setMcpCreatedToken: setCreatedToken, + mcpCreating: tokenCreating, + mcpDeleteId: tokenDeleteId, + setMcpDeleteId: setTokenDeleteId, + mcpEndpoint, + mcpJsonConfigOAuth: jsonConfigOAuth, + mcpJsonConfig: jsonConfigToken, + handleCopy, + handleCreateOAuthClient: createClient, + handleDeleteOAuthClient: deleteClient, + handleRotateSecret: rotateSecret, + handleRevokeSession: revokeSession, + handleCreateMcpToken: createToken, + handleDeleteMcpToken: deleteToken, + } = useMcpIntegration({ enabled: true, reloadClientsAfterWrite: true }) const configToggle = (open: boolean, setOpen: (v: boolean) => void, json: string, copyKey: string, hint: string) => (