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.
This commit is contained in:
Maurice
2026-10-09 10:33:33 +02:00
parent 5ee7cc052c
commit deff306755
4 changed files with 643 additions and 493 deletions
@@ -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<OAuthClient[]>([])
const [oauthSessions, setOauthSessions] = useState<OAuthSession[]>([])
const [oauthCreateOpen, setOauthCreateOpen] = useState(false)
const [oauthNewName, setOauthNewName] = useState('')
const [oauthNewUris, setOauthNewUris] = useState('')
const [oauthNewScopes, setOauthNewScopes] = useState<string[]>([])
const [oauthCreating, setOauthCreating] = useState(false)
const [oauthCreatedClient, setOauthCreatedClient] = useState<OAuthClient | null>(null)
const [oauthDeleteId, setOauthDeleteId] = useState<string | null>(null)
const [oauthRevokeId, setOauthRevokeId] = useState<number | null>(null)
const [oauthRotateId, setOauthRotateId] = useState<string | null>(null)
const [oauthRotatedSecret, setOauthRotatedSecret] = useState<string | null>(null)
const [oauthRotating, setOauthRotating] = useState(false)
// oauthScopesOpen is managed internally by ScopeGroupPicker
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
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<McpToken[]>([])
const [mcpModalOpen, setMcpModalOpen] = useState(false)
const [mcpNewName, setMcpNewName] = useState('')
const [mcpCreatedToken, setMcpCreatedToken] = useState<string | null>(null)
const [mcpCreating, setMcpCreating] = useState(false)
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | 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\\": \\"<your_client_id>\\", \\"client_secret\\": \\"<your_client_secret>\\"}"
]
}
}
}`
const mcpJsonConfig = `{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"${mcpEndpoint}",
"--header",
"Authorization: Bearer <your_token>"
]
}
}
}`
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<typeof useIntegrations>
@@ -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 <your_token>',
]);
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();
});
});
@@ -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<OAuthClient[]>([]);
const [oauthSessions, setOauthSessions] = useState<OAuthSession[]>([]);
const [oauthCreateOpen, setOauthCreateOpen] = useState(false);
const [oauthNewName, setOauthNewName] = useState('');
const [oauthNewUris, setOauthNewUris] = useState('');
const [oauthNewScopes, setOauthNewScopes] = useState<string[]>([]);
const [oauthCreating, setOauthCreating] = useState(false);
const [oauthCreatedClient, setOauthCreatedClient] = useState<OAuthClient | null>(null);
const [oauthDeleteId, setOauthDeleteId] = useState<string | null>(null);
const [oauthRevokeId, setOauthRevokeId] = useState<number | null>(null);
const [oauthRotateId, setOauthRotateId] = useState<string | null>(null);
const [oauthRotatedSecret, setOauthRotatedSecret] = useState<string | null>(null);
const [oauthRotating, setOauthRotating] = useState(false);
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({});
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<McpToken[]>([]);
const [mcpModalOpen, setMcpModalOpen] = useState(false);
const [mcpNewName, setMcpNewName] = useState('');
const [mcpCreatedToken, setMcpCreatedToken] = useState<string | null>(null);
const [mcpCreating, setMcpCreating] = useState(false);
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | 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\\": \\"<your_client_id>\\", \\"client_secret\\": \\"<your_client_secret>\\"}"
]
}
}
}`;
const mcpJsonConfig = `{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"${mcpEndpoint}",
"--header",
"Authorization: Bearer <your_token>"
]
}
}
}`;
// 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,
};
}
@@ -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<string | null>(null)
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// OAuth clients
const [clients, setClients] = useState<OAuthClient[]>([])
const [sessions, setSessions] = useState<OAuthSession[]>([])
const [createOpen, setCreateOpen] = useState(false)
const [newName, setNewName] = useState('')
const [newUris, setNewUris] = useState('')
const [newScopes, setNewScopes] = useState<string[]>([])
const [isMachine, setIsMachine] = useState(false)
const [creating, setCreating] = useState(false)
const [createdClient, setCreatedClient] = useState<OAuthClient | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [revokeId, setRevokeId] = useState<number | null>(null)
const [rotateId, setRotateId] = useState<string | null>(null)
const [rotatedSecret, setRotatedSecret] = useState<string | null>(null)
const [rotating, setRotating] = useState(false)
const [scopesExpanded, setScopesExpanded] = useState<Record<string, boolean>>({})
// API tokens (deprecated)
const [tokens, setTokens] = useState<McpToken[]>([])
const [tokenModalOpen, setTokenModalOpen] = useState(false)
const [tokenNewName, setTokenNewName] = useState('')
const [createdToken, setCreatedToken] = useState<string | null>(null)
const [tokenCreating, setTokenCreating] = useState(false)
const [tokenDeleteId, setTokenDeleteId] = useState<number | null>(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\\": \\"<your_client_id>\\", \\"client_secret\\": \\"<your_client_secret>\\"}"
]
}
}
}`
const jsonConfigToken = `{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"${mcpEndpoint}",
"--header",
"Authorization: Bearer <your_token>"
]
}
}
}`
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) => (
<div className="overflow-hidden rounded-xl border border-[color:var(--m-rowbr)]">