From 800a3c3ed0746e216e2c04fd46b4aace4e61c78b Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 21 Oct 2026 03:07:29 +0300 Subject: [PATCH 1/3] feat: add MCP tokens section to user settings Let users create personal MCP tokens for AI assistants (Claude, Cursor) and manage them from the settings page. Works with the /user/mcp-tokens routes and the /mcp endpoint in notes.api. - Create a token with a label and an optional "Allow editing" switch (read-only by default). - Show the new token once with copy buttons and setup snippets for Claude Code, Cursor and Claude custom connectors. - List tokens with permission, preview, last usage and request count, and revoke them with confirmation. Co-Authored-By: Claude Opus 5.5 --- src/application/i18n/messages/en.json | 35 +++ src/application/services/useMcpTokens.ts | 75 ++++++ src/domain/entities/McpToken.ts | 59 ++++ src/domain/index.ts | 5 +- src/domain/mcpToken.repository.interface.ts | 24 ++ src/domain/mcpToken.service.ts | 41 +++ src/infrastructure/index.ts | 8 + src/infrastructure/mcpToken.repository.ts | 49 ++++ src/presentation/components/mcp/McpTokens.vue | 254 ++++++++++++++++++ src/presentation/pages/Settings.vue | 3 + 10 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 src/application/services/useMcpTokens.ts create mode 100644 src/domain/entities/McpToken.ts create mode 100644 src/domain/mcpToken.repository.interface.ts create mode 100644 src/domain/mcpToken.service.ts create mode 100644 src/infrastructure/mcpToken.repository.ts create mode 100644 src/presentation/components/mcp/McpTokens.vue diff --git a/src/application/i18n/messages/en.json b/src/application/i18n/messages/en.json index 5c39816e..2effb7de 100644 --- a/src/application/i18n/messages/en.json +++ b/src/application/i18n/messages/en.json @@ -39,6 +39,41 @@ "title": "Accent theme", "caption": "Primary buttons and highlights" } + }, + "mcp": { + "title": "MCP", + "copy": "Copy", + "newToken": { + "title": "New token", + "caption": "Tokens let AI assistants like Claude or Cursor work with your notes via MCP", + "labelPlaceholder": "Token name, e.g. Claude Code on laptop", + "allowEditing": "Allow editing", + "allowEditingCaption": "Without it the assistant can only read notes", + "create": "Create token" + }, + "createdToken": { + "title": "Your new token", + "caption": "Copy it now: you will not be able to see it again" + }, + "instructions": { + "claudeCode": "Run in the terminal", + "cursor": "Add to ~/.cursor/mcp.json", + "claude": "Settings → Connectors → Add custom connector: paste the URL and add the request header" + }, + "permission": { + "read": "Read only", + "write": "Read and edit" + }, + "tokens": { + "title": "Tokens", + "caption": "Revoke a token to disconnect the assistant that uses it", + "lastUsed": "used {time}", + "neverUsed": "never used", + "requests": "no requests | {n} request | {n} requests", + "revoke": "Revoke", + "revokeConfirmationTitle": "Revoke token?", + "revokeConfirmation": "Assistants using \"{label}\" will lose access to your notes" + } } }, "noteSettings": { diff --git a/src/application/services/useMcpTokens.ts b/src/application/services/useMcpTokens.ts new file mode 100644 index 00000000..b875ac45 --- /dev/null +++ b/src/application/services/useMcpTokens.ts @@ -0,0 +1,75 @@ +import type { Ref } from 'vue'; +import { onMounted, ref } from 'vue'; +import type { McpToken, McpTokenPermission } from '@/domain/entities/McpToken'; +import { mcpTokenService } from '@/domain'; + +interface UseMcpTokensComposableState { + /** + * MCP tokens of the current user, newest first + */ + tokens: Ref; + + /** + * Creates new MCP token and adds it to the list + * @param label - label set by user + * @param permission - what MCP client can do with notes + * @returns plain token, it is not available later + */ + createToken(label: string, permission: McpTokenPermission): Promise; + + /** + * Revokes MCP token and removes it from the list + * @param id - token id + */ + revokeToken(id: McpToken['id']): Promise; +} + +/** + * Methods for managing MCP tokens of the current user + */ +export default function useMcpTokens(): UseMcpTokensComposableState { + /** + * MCP tokens of the current user + */ + const tokens = ref([]); + + /** + * Loads MCP tokens of the current user + */ + async function loadTokens(): Promise { + tokens.value = await mcpTokenService.getTokens(); + } + + /** + * Creates new MCP token and adds it to the list + * @param label - label set by user + * @param permission - what MCP client can do with notes + */ + async function createToken(label: string, permission: McpTokenPermission): Promise { + const { token, item } = await mcpTokenService.createToken(label, permission); + + tokens.value = [item, ...tokens.value]; + + return token; + } + + /** + * Revokes MCP token and removes it from the list + * @param id - token id + */ + async function revokeToken(id: McpToken['id']): Promise { + await mcpTokenService.revokeToken(id); + + tokens.value = tokens.value.filter(token => token.id !== id); + } + + onMounted(async () => { + await loadTokens(); + }); + + return { + tokens, + createToken, + revokeToken, + }; +} diff --git a/src/domain/entities/McpToken.ts b/src/domain/entities/McpToken.ts new file mode 100644 index 00000000..c479139c --- /dev/null +++ b/src/domain/entities/McpToken.ts @@ -0,0 +1,59 @@ +/** + * What MCP client can do with notes of the token owner + */ +export type McpTokenPermission = 'read' | 'write'; + +/** + * Personal token used by MCP clients (Claude, Cursor, etc.) to access user notes + */ +export interface McpToken { + /** + * Token id + */ + id: number; + + /** + * Last characters of the token, used to tell tokens apart + */ + preview: string; + + /** + * What MCP client can do with notes of the token owner + */ + permission: McpTokenPermission; + + /** + * Label set by user, e.g. "Claude Code on laptop" + */ + label: string; + + /** + * Last time token was used to call MCP tool + */ + lastUsedAt: string | null; + + /** + * How many times token was used to call MCP tools + */ + requestCount: number; + + /** + * When token was created + */ + createdAt: string; +} + +/** + * Token returned right after creation. Plain token is shown only once + */ +export interface CreatedMcpToken { + /** + * Plain token to paste into MCP client config + */ + token: string; + + /** + * Created token record + */ + item: McpToken; +} diff --git a/src/domain/index.ts b/src/domain/index.ts index 3767533c..917d4b7c 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -10,6 +10,7 @@ import EditorToolsService from '@/domain/editorTools.service'; import WorkspaceService from './workspace.service'; import TeamService from './team.service'; import NoteHistoryService from './noteHistory.service'; +import McpTokenService from './mcpToken.service'; /** * Get API url from environment */ @@ -38,6 +39,7 @@ const userService = new UserService(eventBus, repositories.user); const marketplaceService = new MarketplaceService(repositories.marketplace); const teamService = new TeamService(repositories.team); const noteHistoryService = new NoteHistoryService(repositories.noteHistory); +const mcpTokenService = new McpTokenService(repositories.mcpToken); /** * App State — is a read-only combination of app Stores. @@ -63,5 +65,6 @@ export { marketplaceService, workspaceService, teamService, - noteHistoryService + noteHistoryService, + mcpTokenService }; diff --git a/src/domain/mcpToken.repository.interface.ts b/src/domain/mcpToken.repository.interface.ts new file mode 100644 index 00000000..596000be --- /dev/null +++ b/src/domain/mcpToken.repository.interface.ts @@ -0,0 +1,24 @@ +import type { CreatedMcpToken, McpToken, McpTokenPermission } from './entities/McpToken'; + +/** + * Interface of the MCP tokens repository + */ +export default interface McpTokenRepositoryInterface { + /** + * Loads all MCP tokens of the current user + */ + getTokens(): Promise; + + /** + * Creates new MCP token + * @param label - label set by user + * @param permission - what MCP client can do with notes + */ + createToken(label: string, permission: McpTokenPermission): Promise; + + /** + * Revokes MCP token + * @param id - token id + */ + revokeToken(id: McpToken['id']): Promise; +} diff --git a/src/domain/mcpToken.service.ts b/src/domain/mcpToken.service.ts new file mode 100644 index 00000000..198ab016 --- /dev/null +++ b/src/domain/mcpToken.service.ts @@ -0,0 +1,41 @@ +import type { CreatedMcpToken, McpToken, McpTokenPermission } from './entities/McpToken'; +import type McpTokenRepository from './mcpToken.repository.interface'; + +/** + * MCP token service class, used for logic handling of the data, + * Also used for data delivery from repository to application service + */ +export default class McpTokenService { + /** + * MCP token repository instance + */ + private readonly mcpTokenRepository: McpTokenRepository; + + constructor(mcpTokenRepository: McpTokenRepository) { + this.mcpTokenRepository = mcpTokenRepository; + } + + /** + * Loads all MCP tokens of the current user + */ + public async getTokens(): Promise { + return await this.mcpTokenRepository.getTokens(); + } + + /** + * Creates new MCP token + * @param label - label set by user + * @param permission - what MCP client can do with notes + */ + public async createToken(label: string, permission: McpTokenPermission): Promise { + return await this.mcpTokenRepository.createToken(label, permission); + } + + /** + * Revokes MCP token + * @param id - token id + */ + public async revokeToken(id: McpToken['id']): Promise { + await this.mcpTokenRepository.revokeToken(id); + } +} diff --git a/src/infrastructure/index.ts b/src/infrastructure/index.ts index f84ef11f..b1798a16 100644 --- a/src/infrastructure/index.ts +++ b/src/infrastructure/index.ts @@ -18,6 +18,7 @@ import EditorToolsTransport from '@/infrastructure/transport/editorTools.transpo import NoteAttachmentUploaderRepository from './noteAttachmentUploader.repository'; import TeamRepository from '@/infrastructure/team.repository'; import NoteHistoryRepository from './noteHistory.repository'; +import McpTokenRepository from './mcpToken.repository'; /** * Repositories @@ -72,6 +73,11 @@ export interface Repositories { * Working with note history */ noteHistory: NoteHistoryRepository; + + /** + * Working with MCP tokens + */ + mcpToken: McpTokenRepository; } /** @@ -142,6 +148,7 @@ export function init(noteApiUrl: string, eventBus: EventBus): Repositories { const workspaceRepository = new WorkspaceRepository(openedPagesStore); const teamRepository = new TeamRepository(notesApiTransport); const noteHistoryRepository = new NoteHistoryRepository(notesApiTransport); + const mcpTokenRepository = new McpTokenRepository(notesApiTransport); return { note: noteRepository, @@ -154,5 +161,6 @@ export function init(noteApiUrl: string, eventBus: EventBus): Repositories { workspace: workspaceRepository, team: teamRepository, noteHistory: noteHistoryRepository, + mcpToken: mcpTokenRepository, }; } diff --git a/src/infrastructure/mcpToken.repository.ts b/src/infrastructure/mcpToken.repository.ts new file mode 100644 index 00000000..7e21cae8 --- /dev/null +++ b/src/infrastructure/mcpToken.repository.ts @@ -0,0 +1,49 @@ +import type McpTokenRepositoryInterface from '@/domain/mcpToken.repository.interface'; +import type NotesApiTransport from './transport/notes-api'; +import type { CreatedMcpToken, McpToken, McpTokenPermission } from '@/domain/entities/McpToken'; + +/** + * MCP tokens repository class used for data delivery from transport to service + */ +export default class McpTokenRepository implements McpTokenRepositoryInterface { + /** + * Notes api transport instance + */ + private readonly transport: NotesApiTransport; + + constructor(notesApiTransport: NotesApiTransport) { + this.transport = notesApiTransport; + } + + /** + * Loads all MCP tokens of the current user + */ + public async getTokens(): Promise { + const response = await this.transport.get<{ items: McpToken[] }>('/user/mcp-tokens'); + + return response.items; + } + + /** + * Creates new MCP token + * @param label - label set by user + * @param permission - what MCP client can do with notes + */ + public async createToken(label: string, permission: McpTokenPermission): Promise { + return await this.transport.post({ + endpoint: '/user/mcp-tokens', + data: { + label, + permission, + }, + }); + } + + /** + * Revokes MCP token + * @param id - token id + */ + public async revokeToken(id: McpToken['id']): Promise { + await this.transport.delete<{ isDeleted: boolean }>(`/user/mcp-tokens/${id}`); + } +} diff --git a/src/presentation/components/mcp/McpTokens.vue b/src/presentation/components/mcp/McpTokens.vue new file mode 100644 index 00000000..ffda6561 --- /dev/null +++ b/src/presentation/components/mcp/McpTokens.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/src/presentation/pages/Settings.vue b/src/presentation/pages/Settings.vue index 65b077fd..c8049d13 100644 --- a/src/presentation/pages/Settings.vue +++ b/src/presentation/pages/Settings.vue @@ -156,6 +156,8 @@ + + @@ -171,6 +173,7 @@ import { useAppState } from '@/application/services/useAppState'; import { useHead } from 'unhead'; import { ref } from 'vue'; import useNavbar from '@/application/services/useNavbar'; +import McpTokens from '@/presentation/components/mcp/McpTokens.vue'; const { user, userEditorTools } = useAppState(); const { t } = useI18n(); From 9b2ef645199032175c523c85776645dcc64a6838 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 21 Oct 2026 03:19:39 +0300 Subject: [PATCH 2/3] fix: handle MCP tokens loading state and load errors - Disable "Create token" until the initial token list is loaded, so a late list response can't replace a just-created token. - Show "Could not load tokens" with a retry button when loading fails, instead of looking the same as having no tokens. Co-Authored-By: Claude Opus 5.5 --- src/application/i18n/messages/en.json | 2 + src/application/services/useMcpTokens.ts | 40 ++++++++++++++++++- src/presentation/components/mcp/McpTokens.vue | 23 +++++++++-- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/application/i18n/messages/en.json b/src/application/i18n/messages/en.json index 2effb7de..ec64ece2 100644 --- a/src/application/i18n/messages/en.json +++ b/src/application/i18n/messages/en.json @@ -70,6 +70,8 @@ "lastUsed": "used {time}", "neverUsed": "never used", "requests": "no requests | {n} request | {n} requests", + "loadError": "Could not load tokens", + "retry": "Try again", "revoke": "Revoke", "revokeConfirmationTitle": "Revoke token?", "revokeConfirmation": "Assistants using \"{label}\" will lose access to your notes" diff --git a/src/application/services/useMcpTokens.ts b/src/application/services/useMcpTokens.ts index b875ac45..c914bf6f 100644 --- a/src/application/services/useMcpTokens.ts +++ b/src/application/services/useMcpTokens.ts @@ -9,6 +9,22 @@ interface UseMcpTokensComposableState { */ tokens: Ref; + /** + * Whether tokens are being loaded. Tokens should not be created meanwhile: + * the loaded list would replace the created token + */ + isLoading: Ref; + + /** + * Whether tokens failed to load + */ + hasLoadError: Ref; + + /** + * Loads MCP tokens of the current user + */ + loadTokens(): Promise; + /** * Creates new MCP token and adds it to the list * @param label - label set by user @@ -33,11 +49,30 @@ export default function useMcpTokens(): UseMcpTokensComposableState { */ const tokens = ref([]); + /** + * Whether tokens are being loaded + */ + const isLoading = ref(true); + + /** + * Whether tokens failed to load + */ + const hasLoadError = ref(false); + /** * Loads MCP tokens of the current user */ async function loadTokens(): Promise { - tokens.value = await mcpTokenService.getTokens(); + isLoading.value = true; + hasLoadError.value = false; + + try { + tokens.value = await mcpTokenService.getTokens(); + } catch { + hasLoadError.value = true; + } finally { + isLoading.value = false; + } } /** @@ -69,6 +104,9 @@ export default function useMcpTokens(): UseMcpTokensComposableState { return { tokens, + isLoading, + hasLoadError, + loadTokens, createToken, revokeToken, }; diff --git a/src/presentation/components/mcp/McpTokens.vue b/src/presentation/components/mcp/McpTokens.vue index ffda6561..1907c14e 100644 --- a/src/presentation/components/mcp/McpTokens.vue +++ b/src/presentation/components/mcp/McpTokens.vue @@ -23,7 +23,7 @@
-
{{ instruction.snippet }}
+
{{ instruction.snippet }}
@@ -119,14 +119,14 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useClipboard } from '@vueuse/core'; import { Button, Fieldset, Input, Row, Section, Switch, useConfirm } from '@codexteam/ui/vue'; -import useMcpTokens from '@/application/services/useMcpTokens'; +import useUserMcpSettings from '@/application/services/useUserMcpSettings'; import type { McpToken } from '@/domain/entities/McpToken'; import { getTimeFromNow } from '@/infrastructure/utils/date'; const { t } = useI18n(); const { copy } = useClipboard(); const { confirm } = useConfirm(); -const { tokens, isLoading, hasLoadError, loadTokens, createToken, revokeToken } = useMcpTokens(); +const { tokens, isLoading, hasLoadError, loadTokens, createToken, revokeToken } = useUserMcpSettings(); /** * Label of the new token @@ -261,10 +261,6 @@ async function revokeClicked(token: McpToken): Promise { overflow-x: auto; border-radius: var(--radius-field); background-color: var(--base--bg-primary); - color: var(--base--text); - font-family: 'JetBrains Mono', Consolas, monospace; - font-size: 0.866rem; - line-height: 145%; white-space: pre; } }