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) => (