feat(core): mcp server setting (#13630)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* MCP Server integration available in cloud workspaces with a dedicated
settings panel.
* Manage personal access tokens: generate/revoke tokens and view
revealed token.
  * One-click copy of a prefilled server configuration JSON.
  * New query to fetch revealed access tokens.

* **Improvements**
  * Integration list adapts to workspace type (cloud vs. local).
* More reliable token refresh with clearer loading, error and
revalidation states.

* **Localization**
* Added “Copied to clipboard” message and MCP Server name/description
translations.

* **Chores**
  * Updated icon dependency across many packages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-09-24 16:02:47 +08:00
committed by GitHub
parent ca9811792d
commit 2f118206cc
16 changed files with 475 additions and 27 deletions
@@ -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)
@@ -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<AccessToken[] | null>(null);
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any>(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
);
}
}
@@ -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<AccessToken[]> {
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;
}
}