diff --git a/src/application/i18n/messages/en.json b/src/application/i18n/messages/en.json index 5c39816e..ec64ece2 100644 --- a/src/application/i18n/messages/en.json +++ b/src/application/i18n/messages/en.json @@ -39,6 +39,43 @@ "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", + "loadError": "Could not load tokens", + "retry": "Try again", + "revoke": "Revoke", + "revokeConfirmationTitle": "Revoke token?", + "revokeConfirmation": "Assistants using \"{label}\" will lose access to your notes" + } } }, "noteSettings": { diff --git a/src/application/services/useUserMcpSettings.ts b/src/application/services/useUserMcpSettings.ts new file mode 100644 index 00000000..33109e70 --- /dev/null +++ b/src/application/services/useUserMcpSettings.ts @@ -0,0 +1,113 @@ +import type { Ref } from 'vue'; +import { onMounted, ref } from 'vue'; +import type { McpToken, McpTokenPermission } from '@/domain/entities/McpToken'; +import { userSettingsService } from '@/domain'; + +interface UseUserMcpSettingsComposableState { + /** + * MCP tokens of the current user, newest first + */ + 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 + * @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 useUserMcpSettings(): UseUserMcpSettingsComposableState { + /** + * MCP tokens of the current user + */ + 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 { + isLoading.value = true; + hasLoadError.value = false; + + try { + tokens.value = await userSettingsService.getMcpTokens(); + } catch { + hasLoadError.value = true; + } finally { + isLoading.value = false; + } + } + + /** + * 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 userSettingsService.createMcpToken(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 userSettingsService.revokeMcpToken(id); + + tokens.value = tokens.value.filter(token => token.id !== id); + } + + onMounted(async () => { + await loadTokens(); + }); + + return { + tokens, + isLoading, + hasLoadError, + loadTokens, + 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..c6308e4e 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 UserSettingsService from './userSettings.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 userSettingsService = new UserSettingsService(repositories.userSettings); /** * App State — is a read-only combination of app Stores. @@ -63,5 +65,6 @@ export { marketplaceService, workspaceService, teamService, - noteHistoryService + noteHistoryService, + userSettingsService }; diff --git a/src/domain/userSettings.repository.interface.ts b/src/domain/userSettings.repository.interface.ts new file mode 100644 index 00000000..c04a47c4 --- /dev/null +++ b/src/domain/userSettings.repository.interface.ts @@ -0,0 +1,24 @@ +import type { CreatedMcpToken, McpToken, McpTokenPermission } from './entities/McpToken'; + +/** + * Interface of the user settings repository + */ +export default interface UserSettingsRepositoryInterface { + /** + * Loads all MCP tokens of the current user + */ + getMcpTokens(): Promise; + + /** + * Creates new MCP token + * @param label - label set by user + * @param permission - what MCP client can do with notes + */ + createMcpToken(label: string, permission: McpTokenPermission): Promise; + + /** + * Revokes MCP token + * @param id - token id + */ + revokeMcpToken(id: McpToken['id']): Promise; +} diff --git a/src/domain/userSettings.service.ts b/src/domain/userSettings.service.ts new file mode 100644 index 00000000..e04a0ab1 --- /dev/null +++ b/src/domain/userSettings.service.ts @@ -0,0 +1,41 @@ +import type { CreatedMcpToken, McpToken, McpTokenPermission } from './entities/McpToken'; +import type UserSettingsRepository from './userSettings.repository.interface'; + +/** + * User settings service class, used for logic handling of the data, + * Also used for data delivery from repository to application service + */ +export default class UserSettingsService { + /** + * User settings repository instance + */ + private readonly userSettingsRepository: UserSettingsRepository; + + constructor(userSettingsRepository: UserSettingsRepository) { + this.userSettingsRepository = userSettingsRepository; + } + + /** + * Loads all MCP tokens of the current user + */ + public async getMcpTokens(): Promise { + return await this.userSettingsRepository.getMcpTokens(); + } + + /** + * Creates new MCP token + * @param label - label set by user + * @param permission - what MCP client can do with notes + */ + public async createMcpToken(label: string, permission: McpTokenPermission): Promise { + return await this.userSettingsRepository.createMcpToken(label, permission); + } + + /** + * Revokes MCP token + * @param id - token id + */ + public async revokeMcpToken(id: McpToken['id']): Promise { + await this.userSettingsRepository.revokeMcpToken(id); + } +} diff --git a/src/infrastructure/index.ts b/src/infrastructure/index.ts index f84ef11f..5c10d341 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 UserSettingsRepository from './userSettings.repository'; /** * Repositories @@ -72,6 +73,11 @@ export interface Repositories { * Working with note history */ noteHistory: NoteHistoryRepository; + + /** + * Working with user settings + */ + userSettings: UserSettingsRepository; } /** @@ -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 userSettingsRepository = new UserSettingsRepository(notesApiTransport); return { note: noteRepository, @@ -154,5 +161,6 @@ export function init(noteApiUrl: string, eventBus: EventBus): Repositories { workspace: workspaceRepository, team: teamRepository, noteHistory: noteHistoryRepository, + userSettings: userSettingsRepository, }; } diff --git a/src/infrastructure/userSettings.repository.ts b/src/infrastructure/userSettings.repository.ts new file mode 100644 index 00000000..e111b6dc --- /dev/null +++ b/src/infrastructure/userSettings.repository.ts @@ -0,0 +1,49 @@ +import type UserSettingsRepositoryInterface from '@/domain/userSettings.repository.interface'; +import type NotesApiTransport from './transport/notes-api'; +import type { CreatedMcpToken, McpToken, McpTokenPermission } from '@/domain/entities/McpToken'; + +/** + * User settings repository class used for data delivery from transport to service + */ +export default class UserSettingsRepository implements UserSettingsRepositoryInterface { + /** + * Notes api transport instance + */ + private readonly transport: NotesApiTransport; + + constructor(notesApiTransport: NotesApiTransport) { + this.transport = notesApiTransport; + } + + /** + * Loads all MCP tokens of the current user + */ + public async getMcpTokens(): 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 createMcpToken(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 revokeMcpToken(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..7acaf7d2 --- /dev/null +++ b/src/presentation/components/mcp/McpTokens.vue @@ -0,0 +1,267 @@ + + + + + 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();