diff --git a/packages/backend/server/src/core/access-token/resolver.ts b/packages/backend/server/src/core/access-token/resolver.ts index 4bedb252f8..b80a83d27c 100644 --- a/packages/backend/server/src/core/access-token/resolver.ts +++ b/packages/backend/server/src/core/access-token/resolver.ts @@ -50,6 +50,13 @@ export class AccessTokenResolver { return await this.models.accessToken.list(user.id); } + @Query(() => [RevealedAccessToken]) + async revealedAccessTokens( + @CurrentUser() user: CurrentUser + ): Promise { + return await this.models.accessToken.list(user.id, true); + } + @Mutation(() => RevealedAccessToken) async generateUserAccessToken( @CurrentUser() user: CurrentUser, diff --git a/packages/backend/server/src/models/access-token.ts b/packages/backend/server/src/models/access-token.ts index c299f9f1b0..58d1a7fcf9 100644 --- a/packages/backend/server/src/models/access-token.ts +++ b/packages/backend/server/src/models/access-token.ts @@ -15,13 +15,14 @@ export class AccessTokenModel extends BaseModel { super(); } - async list(userId: string) { + async list(userId: string, revealed: boolean = false) { return await this.db.accessToken.findMany({ select: { id: true, name: true, createdAt: true, expiresAt: true, + token: revealed, }, where: { userId, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 6db883acca..3f2d10b010 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -1596,6 +1596,7 @@ type Query { """query workspace embedding status""" queryWorkspaceEmbeddingStatus(workspaceId: String!): ContextWorkspaceEmbeddingStatus! + revealedAccessTokens: [RevealedAccessToken!]! """server config""" serverConfig: ServerConfigType! diff --git a/packages/common/graphql/src/graphql/access-token/list.gql b/packages/common/graphql/src/graphql/access-token/list.gql index e3ebeeec8d..13dd31027d 100644 --- a/packages/common/graphql/src/graphql/access-token/list.gql +++ b/packages/common/graphql/src/graphql/access-token/list.gql @@ -1,8 +1,9 @@ query listUserAccessTokens { - accessTokens { + revealedAccessTokens { id name createdAt expiresAt + token } } \ No newline at end of file diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 4ad96df004..4f43d4dbfa 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -88,11 +88,12 @@ export const listUserAccessTokensQuery = { id: 'listUserAccessTokensQuery' as const, op: 'listUserAccessTokens', query: `query listUserAccessTokens { - accessTokens { + revealedAccessTokens { id name createdAt expiresAt + token } }`, }; diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 325592dfea..07adda5dd1 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -2166,6 +2166,7 @@ export interface Query { publicUserById: Maybe; /** query workspace embedding status */ queryWorkspaceEmbeddingStatus: ContextWorkspaceEmbeddingStatus; + revealedAccessTokens: Array; /** server config */ serverConfig: ServerConfigType; /** Get user by email */ @@ -3089,12 +3090,13 @@ export type ListUserAccessTokensQueryVariables = Exact<{ export type ListUserAccessTokensQuery = { __typename?: 'Query'; - accessTokens: Array<{ - __typename?: 'AccessToken'; + revealedAccessTokens: Array<{ + __typename?: 'RevealedAccessToken'; id: string; name: string; createdAt: string; expiresAt: string | null; + token: string; }>; }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx index 619b5eb1bb..23a7e32d6b 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx @@ -1,11 +1,11 @@ -import type { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { IntegrationTypeIcon } from '@affine/core/modules/integration'; import type { I18nString } from '@affine/i18n'; import { Logo1Icon, TodayIcon } from '@blocksuite/icons/rc'; -import { LiveData } from '@toeverything/infra'; import type { ReactNode } from 'react'; import { CalendarSettingPanel } from './calendar/setting-panel'; +import MCPIcon from './mcp-server/MCP.inline.svg'; +import { McpServerSettingPanel } from './mcp-server/setting-panel'; import { ReadwiseSettingPanel } from './readwise/setting-panel'; type IntegrationCard = { @@ -13,6 +13,7 @@ type IntegrationCard = { name: I18nString; desc: I18nString; icon: ReactNode; + cloud?: boolean; } & ( | { setting: ReactNode; @@ -37,6 +38,14 @@ const INTEGRATION_LIST = [ icon: , setting: , }, + { + id: 'mcp-server' as const, + name: 'com.affine.integration.mcp-server.name', + desc: 'com.affine.integration.mcp-server.desc', + icon: , + setting: , + cloud: true, + }, { id: 'web-clipper' as const, name: 'com.affine.integration.web-clipper.name', @@ -55,13 +64,11 @@ export type IntegrationItem = Exclude & { id: IntegrationId; }; -export function getAllowedIntegrationList$( - _featureFlagService: FeatureFlagService -) { - return LiveData.computed(() => { - return INTEGRATION_LIST.filter(item => { - if (!item) return false; - return true; - }) as IntegrationItem[]; - }); +export function getAllowedIntegrationList(isCloudWorkspace: boolean) { + return INTEGRATION_LIST.filter(item => { + if (!item) return false; + const requiredCloud = 'cloud' in item && item.cloud; + if (requiredCloud && !isCloudWorkspace) return false; + return true; + }) as IntegrationItem[]; } diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx index 5d6e1080e6..7dc9a187c2 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx @@ -1,7 +1,7 @@ import { SettingHeader } from '@affine/component/setting-components'; -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; +import { WorkspaceService } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; -import { useLiveData, useService } from '@toeverything/infra'; +import { useService } from '@toeverything/infra'; import { type ReactNode, useCallback, useMemo, useState } from 'react'; import { SubPageProvider, useSubPageIsland } from '../../sub-page'; @@ -10,22 +10,21 @@ import { IntegrationCardContent, IntegrationCardHeader, } from './card'; -import { getAllowedIntegrationList$ } from './constants'; +import { getAllowedIntegrationList } from './constants'; import { type IntegrationItem } from './constants'; import { list } from './index.css'; export const IntegrationSetting = () => { const t = useI18n(); const [opened, setOpened] = useState(null); - const featureFlagService = useService(FeatureFlagService); + const workspaceService = useService(WorkspaceService); + const isCloudWorkspace = workspaceService.workspace.flavour !== 'local'; + console.log('isCloudWorkspace', isCloudWorkspace); - const integrationList = useLiveData( - useMemo( - () => getAllowedIntegrationList$(featureFlagService), - [featureFlagService] - ) + const integrationList = useMemo( + () => getAllowedIntegrationList(isCloudWorkspace), + [isCloudWorkspace] ); - const handleCardClick = useCallback((card: IntegrationItem) => { if ('setting' in card && card.setting) { setOpened(card.id); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/MCP.inline.svg b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/MCP.inline.svg new file mode 100644 index 0000000000..0b857d323c --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/MCP.inline.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.css.ts new file mode 100644 index 0000000000..1cea68d35d --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.css.ts @@ -0,0 +1,48 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const connectButton = style({ + width: '100%', + marginTop: '24px', +}); + +export const section = style({ + display: 'flex', + flexDirection: 'column', + border: `1px solid ${cssVar('borderColor')}`, + borderRadius: '8px', + padding: '8px 16px', + gap: '0px', + marginBottom: '16px', +}); + +export const sectionHeader = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: '8px', +}); + +export const preArea = style({ + backgroundColor: cssVarV2('layer/background/secondary'), + padding: '16px 16px', + borderRadius: '8px', + margin: '8px 0', + fontFamily: cssVar('fontMonoFamily'), + overflowX: 'auto', +}); + +export const sectionDescription = style({ + fontSize: 13, + lineHeight: '22px', + color: cssVarV2('text/secondary'), +}); + +export const sectionTitle = style({ + fontSize: 14, + fontWeight: 500, + lineHeight: 1.25, + color: cssVarV2('text/primary'), +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.tsx new file mode 100644 index 0000000000..a7de24543e --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.tsx @@ -0,0 +1,227 @@ +import { Button, ErrorMessage, notify, Skeleton } from '@affine/component'; +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { AccessTokenService, ServerService } from '@affine/core/modules/cloud'; +import { WorkspaceService } from '@affine/core/modules/workspace'; +import { UserFriendlyError } from '@affine/error'; +import { useI18n } from '@affine/i18n'; +import { useLiveData, useService } from '@toeverything/infra'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; + +import { IntegrationSettingHeader } from '../setting'; +import MCPIcon from './MCP.inline.svg'; +import * as styles from './setting-panel.css'; + +export const McpServerSettingPanel = () => { + return ; +}; + +const McpServerSettingHeader = ({ action }: { action?: ReactNode }) => { + const t = useI18n(); + + return ( + } + name={t['com.affine.integration.mcp-server.name']()} + desc={t['com.affine.integration.mcp-server.desc']()} + action={action} + /> + ); +}; + +const McpServerSetting = () => { + const workspaceService = useService(WorkspaceService); + const serverService = useService(ServerService); + const workspaceName = useLiveData(workspaceService.workspace.name$); + const accessTokenService = useService(AccessTokenService); + const accessTokens = useLiveData(accessTokenService.accessTokens$); + const isRevalidating = useLiveData(accessTokenService.isRevalidating$); + const error = useLiveData(accessTokenService.error$); + const [mutating, setMutating] = useState(false); + const t = useI18n(); + + const mcpAccessToken = useMemo(() => { + return accessTokens?.find(token => token.name === 'mcp'); + }, [accessTokens]); + + const code = useMemo(() => { + return mcpAccessToken + ? JSON.stringify( + { + mcpServers: { + [`${workspaceName} - AFFiNE`]: { + type: 'streamable-http', + url: `${serverService.server.baseUrl}/api/workspaces/${workspaceService.workspace.id}/mcp`, + note: 'Read docs from AFFiNE workspace', + headers: { + Authorization: `Bearer ${mcpAccessToken.token}`, + }, + }, + }, + }, + null, + 2 + ) + : null; + }, [mcpAccessToken, workspaceName, workspaceService, serverService]); + + const showLoading = accessTokens === null && isRevalidating; + const showError = accessTokens === null && error !== null; + + useEffect(() => { + accessTokenService.revalidate(); + }, [accessTokenService]); + + const handleGenerateAccessToken = useAsyncCallback(async () => { + setMutating(true); + try { + if (mcpAccessToken) { + await accessTokenService.revokeUserAccessToken(mcpAccessToken.id); + } + await accessTokenService.generateUserAccessToken('mcp'); + } catch (err) { + notify.error({ + error: UserFriendlyError.fromAny(err), + }); + } finally { + setMutating(false); + } + }, [accessTokenService, mcpAccessToken]); + + const handleRevokeAccessToken = useAsyncCallback(async () => { + setMutating(true); + try { + if (mcpAccessToken) { + await accessTokenService.revokeUserAccessToken(mcpAccessToken.id); + } + } catch (err) { + notify.error({ + error: UserFriendlyError.fromAny(err), + }); + } finally { + setMutating(false); + } + }, [accessTokenService, mcpAccessToken]); + + if (showLoading) { + return ( +
+ + +
+ ); + } + + if (showError) { + return ( +
+ + {error} +
+ ); + } + + return ( +
+ + +
+
+
Personal access token
+ {!mcpAccessToken ? ( + + ) : ( + + )} +
+

+ This access token is used for the MCP service, please keep this + information secure. Deleting it will invalidate the access token. +

+
+ +
+
+
Server Config
+ +
+ {code ? ( +
{code}
+ ) : ( +

+ No access token found, please generate one first. +

+ )} +
+ +
+
+
Support tools
+
+
+ +
+
+
doc-read
+
+
+ Return the complete text and basic metadata of a single document + identified by docId; use this when the user needs the full content + of a specific file rather than a search result. +
+
+ +
+
+
doc-semantic-search
+
+
+ Retrieve conceptually related passages by performing vector-based + semantic similarity search across embedded documents; use this tool + only when exact keyword search fails or the user explicitly needs + meaning-level matches (e.g., paraphrases, synonyms, broader + concepts, recent documents). +
+
+ +
+
+
doc-keyword-search
+
+
+ Fuzzy search all workspace documents for the exact keyword or phrase + supplied and return passages ranked by textual match. Use this tool + by default whenever a straightforward term-based or keyword-base + lookup is sufficient. +
+
+
+
+ ); +}; diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index 04a6d0ab59..adedec6f7c 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -7,6 +7,7 @@ export { AccountLoggedOut } from './events/account-logged-out'; export { AuthProvider } from './provider/auth'; export { ValidatorProvider } from './provider/validator'; export { ServerScope } from './scopes/server'; +export { AccessTokenService } from './services/access-token'; export { AuthService } from './services/auth'; export { CaptchaService } from './services/captcha'; export { DefaultServerService } from './services/default-server'; @@ -102,6 +103,8 @@ import { WorkspacePermissionService } from '../permissions'; import { DocScope, DocService, DocsService } from '../doc'; import { DocCreatedByUpdatedBySyncStore } from './stores/doc-created-by-updated-by-sync'; import { GlobalDialogService } from '../dialogs'; +import { AccessTokenService } from './services/access-token'; +import { AccessTokenStore } from './stores/access-token'; export function configureCloudModule(framework: Framework) { configureDefaultAuthProvider(framework); @@ -171,7 +174,9 @@ export function configureCloudModule(framework: Framework) { .service(PublicUserService, [PublicUserStore]) .store(PublicUserStore, [GraphQLService]) .service(UserSettingsService, [UserSettingsStore]) - .store(UserSettingsStore, [GraphQLService]); + .store(UserSettingsStore, [GraphQLService]) + .service(AccessTokenService, [AccessTokenStore]) + .store(AccessTokenStore, [GraphQLService]); framework .scope(WorkspaceScope) diff --git a/packages/frontend/core/src/modules/cloud/services/access-token.ts b/packages/frontend/core/src/modules/cloud/services/access-token.ts new file mode 100644 index 0000000000..2ed4b124ff --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/access-token.ts @@ -0,0 +1,80 @@ +import { + effect, + exhaustMapWithTrailing, + fromPromise, + LiveData, + onComplete, + OnEvent, + onStart, + Service, + smartRetry, +} from '@toeverything/infra'; +import { catchError, EMPTY, tap } from 'rxjs'; + +import { AccountChanged } from '../events/account-changed'; +import type { AccessToken, AccessTokenStore } from '../stores/access-token'; + +@OnEvent(AccountChanged, e => e.onAccountChanged) +export class AccessTokenService extends Service { + constructor(private readonly accessTokenStore: AccessTokenStore) { + super(); + } + + accessTokens$ = new LiveData(null); + isRevalidating$ = new LiveData(false); + error$ = new LiveData(null); + + async generateUserAccessToken(name: string) { + const accessToken = + await this.accessTokenStore.generateUserAccessToken(name); + this.accessTokens$.value = [ + ...(this.accessTokens$.value || []), + accessToken as AccessToken, + ]; + + await this.waitForRevalidation(); + } + + async revokeUserAccessToken(id: string) { + await this.accessTokenStore.revokeUserAccessToken(id); + this.accessTokens$.value = + this.accessTokens$.value?.filter(token => token.id !== id) ?? null; + await this.waitForRevalidation(); + } + + revalidate = effect( + exhaustMapWithTrailing(() => { + return fromPromise(() => { + return this.accessTokenStore.listUserAccessTokens(); + }).pipe( + smartRetry(), + tap(accessTokens => { + this.accessTokens$.value = accessTokens; + }), + catchError(error => { + this.error$.value = error; + return EMPTY; + }), + onStart(() => { + this.isRevalidating$.value = true; + }), + onComplete(() => { + this.isRevalidating$.value = false; + }) + ); + }) + ); + + private onAccountChanged() { + this.accessTokens$.value = null; + this.revalidate(); + } + + async waitForRevalidation(signal?: AbortSignal) { + this.revalidate(); + await this.isRevalidating$.waitFor( + isRevalidating => !isRevalidating, + signal + ); + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/access-token.ts b/packages/frontend/core/src/modules/cloud/stores/access-token.ts new file mode 100644 index 0000000000..d1d5d95f3d --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/access-token.ts @@ -0,0 +1,51 @@ +import { + generateUserAccessTokenMutation, + type ListUserAccessTokensQuery, + listUserAccessTokensQuery, + revokeUserAccessTokenMutation, +} from '@affine/graphql'; +import { Store } from '@toeverything/infra'; + +import type { GraphQLService } from '../services/graphql'; + +export type AccessToken = + ListUserAccessTokensQuery['revealedAccessTokens'][number]; + +export class AccessTokenStore extends Store { + constructor(private readonly gqlService: GraphQLService) { + super(); + } + + async listUserAccessTokens(signal?: AbortSignal): Promise { + const data = await this.gqlService.gql({ + query: listUserAccessTokensQuery, + context: { signal }, + }); + + return data.revealedAccessTokens; + } + + async generateUserAccessToken( + name: string, + expiresAt?: string, + signal?: AbortSignal + ) { + const data = await this.gqlService.gql({ + query: generateUserAccessTokenMutation, + variables: { input: { name, expiresAt } }, + context: { signal }, + }); + + return data.generateUserAccessToken; + } + + async revokeUserAccessToken(id: string, signal?: AbortSignal) { + const data = await this.gqlService.gql({ + query: revokeUserAccessTokenMutation, + variables: { id }, + context: { signal }, + }); + + return data.revokeUserAccessToken; + } +} diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 2c9c0765fe..d9dbd536c9 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -99,6 +99,10 @@ export function useAFFiNEI18N(): { * `Copied link to clipboard` */ ["Copied link to clipboard"](): string; + /** + * `Copied to clipboard` + */ + ["Copied to clipboard"](): string; /** * `Copy` */ @@ -8180,6 +8184,14 @@ export function useAFFiNEI18N(): { ["com.affine.integration.calendar.unsubscribe-content"](options: { readonly name: string; }): string; + /** + * `MCP Server` + */ + ["com.affine.integration.mcp-server.name"](): string; + /** + * `Enable other MCP Client to search and read the doc of AFFiNE.` + */ + ["com.affine.integration.mcp-server.desc"](): string; /** * `Notes` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 5fa6256f86..a50e399622 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -15,6 +15,7 @@ "Continue": "Continue", "Convert to ": "Convert to ", "Copied link to clipboard": "Copied link to clipboard", + "Copied to clipboard": "Copied to clipboard", "Copy": "Copy", "Create": "Create", "Created": "Created", @@ -2053,6 +2054,8 @@ "com.affine.integration.calendar.show-events-desc": "Enabling this setting allows you to connect your calendar events to your Journal in AFFiNE", "com.affine.integration.calendar.show-all-day-events": "Show all day event", "com.affine.integration.calendar.unsubscribe-content": "Are you sure you want to unsubscribe \"{{name}}\"? Unsubscribing this account will remove its data from Journal.", + "com.affine.integration.mcp-server.name": "MCP Server", + "com.affine.integration.mcp-server.desc": "Enable other MCP Client to search and read the doc of AFFiNE.", "com.affine.audio.notes": "Notes", "com.affine.audio.transcribing": "Transcribing", "com.affine.audio.transcribe.non-owner.confirm.title": "Unable to retrieve AI results for others",