mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-01 01:29:31 +08:00
feat(core): improve mcp management (#15221)
#### PR Dependency Tree * **PR #15221** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added MCP credential management (create/reveal, list, rotate, revoke) with expiration and status tracking. * Introduced read-only vs read/write access modes, with read/write tooling enabled only when permitted. * Added workspace MCP credential configuration UI, including token reveal and setup generation. * Added MCP credential GraphQL APIs to back the UI. * **Changes** * Replaced legacy access-token support with MCP credentials across authentication and realtime updates. * **Bug Fixes** * MCP authentication now reliably rejects revoked, rotated, expired, or disabled-user credentials. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -16,7 +16,6 @@ export {
|
||||
DEFAULT_SELF_HOSTED_SERVER_NAME,
|
||||
getSelfHostedServerName,
|
||||
} from './server-name';
|
||||
export { AccessTokenService } from './services/access-token';
|
||||
export { AuthService, type DeviceAuthSession } from './services/auth';
|
||||
export { CaptchaService } from './services/captcha';
|
||||
export { DefaultServerService } from './services/default-server';
|
||||
@@ -26,6 +25,7 @@ export { FetchService } from './services/fetch';
|
||||
export { GraphQLService } from './services/graphql';
|
||||
export { InvitationService } from './services/invitation';
|
||||
export { InvoicesService } from './services/invoices';
|
||||
export { McpCredentialService } from './services/mcp-credential';
|
||||
export type { PublicUserInfo } from './services/public-user';
|
||||
export { PublicUserService } from './services/public-user';
|
||||
export { RealtimeService } from './services/realtime';
|
||||
@@ -115,8 +115,8 @@ import { NbstoreService } from '../storage';
|
||||
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';
|
||||
import { McpCredentialService } from './services/mcp-credential';
|
||||
import { McpCredentialStore } from './stores/mcp-credential';
|
||||
|
||||
export function configureCloudModule(framework: Framework) {
|
||||
configureDefaultAuthProvider(framework);
|
||||
@@ -194,8 +194,8 @@ export function configureCloudModule(framework: Framework) {
|
||||
.store(PublicUserStore, [GraphQLService])
|
||||
.service(UserSettingsService, [UserSettingsStore])
|
||||
.store(UserSettingsStore, [GraphQLService, NbstoreService])
|
||||
.service(AccessTokenService, [AccessTokenStore])
|
||||
.store(AccessTokenStore, [GraphQLService, NbstoreService]);
|
||||
.service(McpCredentialService, [McpCredentialStore])
|
||||
.store(McpCredentialStore, [GraphQLService]);
|
||||
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Framework } from '@toeverything/infra';
|
||||
import { Subject } from 'rxjs';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AccessTokenStore } from '../stores/access-token';
|
||||
import { AccessTokenService } from './access-token';
|
||||
|
||||
function createStore() {
|
||||
return {
|
||||
subscribeUserAccessTokens: vi.fn(() => new Subject()),
|
||||
listUserAccessTokens: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'token-1',
|
||||
name: 'MCP',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
]),
|
||||
generateUserAccessToken: vi.fn().mockResolvedValue({
|
||||
id: 'token-1',
|
||||
name: 'MCP',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: null,
|
||||
token: 'secret-token',
|
||||
}),
|
||||
} as unknown as AccessTokenStore;
|
||||
}
|
||||
|
||||
describe('AccessTokenService', () => {
|
||||
test('does not store generated plaintext token in the long-lived list', async () => {
|
||||
const framework = new Framework();
|
||||
framework
|
||||
.store(AccessTokenStore, createStore())
|
||||
.service(AccessTokenService, [AccessTokenStore]);
|
||||
const service = framework.provider().get(AccessTokenService);
|
||||
|
||||
const accessToken = await service.generateUserAccessToken('MCP');
|
||||
|
||||
expect(accessToken.token).toBe('secret-token');
|
||||
expect(service.accessTokens$.value).toEqual([
|
||||
{
|
||||
id: 'token-1',
|
||||
name: 'MCP',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(service.accessTokens$.value)).not.toContain(
|
||||
'secret-token'
|
||||
);
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { LiveData, OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import { AccountChanged } from '../events/account-changed';
|
||||
import { RealtimeLiveQuery } from '../realtime/live-query';
|
||||
import type {
|
||||
AccessToken,
|
||||
AccessTokenStore,
|
||||
ListedAccessToken,
|
||||
} from '../stores/access-token';
|
||||
|
||||
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
||||
export class AccessTokenService extends Service {
|
||||
constructor(private readonly accessTokenStore: AccessTokenStore) {
|
||||
super();
|
||||
this.liveQuery.start();
|
||||
}
|
||||
|
||||
accessTokens$ = new LiveData<ListedAccessToken[] | null>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
private readonly liveQuery = new RealtimeLiveQuery({
|
||||
request: signal => this.requestAccessTokens(signal),
|
||||
subscribe: () => this.accessTokenStore.subscribeUserAccessTokens(),
|
||||
applySnapshot: accessTokens => {
|
||||
this.error$.value = null;
|
||||
this.accessTokens$.value = accessTokens;
|
||||
},
|
||||
applyEvent: () => 'revalidate' as const,
|
||||
onError: error => {
|
||||
this.error$.value = error;
|
||||
},
|
||||
});
|
||||
|
||||
async generateUserAccessToken(name: string): Promise<AccessToken> {
|
||||
const accessToken =
|
||||
await this.accessTokenStore.generateUserAccessToken(name);
|
||||
const { token: _token, ...listedAccessToken } = accessToken;
|
||||
this.accessTokens$.value = [
|
||||
...(this.accessTokens$.value || []),
|
||||
listedAccessToken,
|
||||
];
|
||||
|
||||
await this.waitForRevalidation();
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
this.liveQuery.revalidate();
|
||||
};
|
||||
|
||||
private onAccountChanged() {
|
||||
this.accessTokens$.value = null;
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
async waitForRevalidation(signal?: AbortSignal) {
|
||||
this.revalidate();
|
||||
await this.isRevalidating$.waitFor(
|
||||
isRevalidating => !isRevalidating,
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.liveQuery.dispose();
|
||||
}
|
||||
|
||||
private async requestAccessTokens(signal: AbortSignal) {
|
||||
this.isRevalidating$.value = true;
|
||||
try {
|
||||
return await this.accessTokenStore.listUserAccessTokens(signal);
|
||||
} finally {
|
||||
this.isRevalidating$.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type {
|
||||
CreateMcpCredentialMutationVariables,
|
||||
McpCredentialsQuery,
|
||||
} from '@affine/graphql';
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
|
||||
import type { McpCredentialStore } from '../stores/mcp-credential';
|
||||
|
||||
export type McpCredential = McpCredentialsQuery['mcpCredentials'][number];
|
||||
|
||||
export class McpCredentialService extends Service {
|
||||
private revalidationId = 0;
|
||||
|
||||
constructor(private readonly store: McpCredentialStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
credentials$ = new LiveData<McpCredential[] | null>(null);
|
||||
readWriteAvailable$ = new LiveData(false);
|
||||
loading$ = new LiveData(false);
|
||||
error$ = new LiveData<unknown>(null);
|
||||
|
||||
async revalidate(workspaceId: string) {
|
||||
const revalidationId = ++this.revalidationId;
|
||||
this.loading$.value = true;
|
||||
try {
|
||||
const result = await this.store.list(workspaceId);
|
||||
if (revalidationId !== this.revalidationId) return;
|
||||
this.credentials$.value = result.mcpCredentials;
|
||||
this.readWriteAvailable$.value = result.mcpCredentialReadWriteAvailable;
|
||||
this.error$.value = null;
|
||||
} catch (error) {
|
||||
if (revalidationId !== this.revalidationId) return;
|
||||
this.error$.value = error;
|
||||
} finally {
|
||||
if (revalidationId === this.revalidationId) {
|
||||
this.loading$.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateMcpCredentialMutationVariables['input']) {
|
||||
const revealed = await this.store.create(input);
|
||||
await this.revalidate(input.workspaceId);
|
||||
return revealed;
|
||||
}
|
||||
|
||||
async rotate(id: string, workspaceId: string, expirationDays: number) {
|
||||
const revealed = await this.store.rotate(id, workspaceId, expirationDays);
|
||||
await this.revalidate(workspaceId);
|
||||
return revealed;
|
||||
}
|
||||
|
||||
async revoke(id: string, workspaceId: string) {
|
||||
await this.store.revoke(id, workspaceId);
|
||||
await this.revalidate(workspaceId);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import {
|
||||
generateUserAccessTokenMutation,
|
||||
revokeUserAccessTokenMutation,
|
||||
} from '@affine/graphql';
|
||||
import type { AccessTokenSnapshot } from '@affine/realtime';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { NbstoreService } from '../../storage';
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export type AccessToken = AccessTokenSnapshot & { token: string };
|
||||
export type ListedAccessToken = AccessTokenSnapshot;
|
||||
|
||||
export class AccessTokenStore extends Store {
|
||||
constructor(
|
||||
private readonly gqlService: GraphQLService,
|
||||
private readonly nbstoreService: NbstoreService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async listUserAccessTokens(
|
||||
signal?: AbortSignal
|
||||
): Promise<ListedAccessToken[]> {
|
||||
const { tokens } = await this.nbstoreService.realtime.request(
|
||||
'user.access-tokens.get',
|
||||
{},
|
||||
{ signal, timeoutMs: 10000 }
|
||||
);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
subscribeUserAccessTokens() {
|
||||
return this.nbstoreService.realtime.subscribe(
|
||||
'user.access-tokens.changed',
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CreateMcpCredentialMutationVariables } from '@affine/graphql';
|
||||
import {
|
||||
createMcpCredentialMutation,
|
||||
mcpCredentialsQuery,
|
||||
revokeMcpCredentialMutation,
|
||||
rotateMcpCredentialMutation,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export class McpCredentialStore extends Store {
|
||||
constructor(private readonly gqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async list(workspaceId: string, signal?: AbortSignal) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: mcpCredentialsQuery,
|
||||
variables: { workspaceId },
|
||||
context: { signal },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async create(input: CreateMcpCredentialMutationVariables['input']) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: createMcpCredentialMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: input.workspaceId,
|
||||
name: input.name,
|
||||
accessMode: input.accessMode,
|
||||
expirationDays: input.expirationDays,
|
||||
},
|
||||
},
|
||||
});
|
||||
return data.createMcpCredential;
|
||||
}
|
||||
|
||||
async rotate(id: string, workspaceId: string, expirationDays: number) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: rotateMcpCredentialMutation,
|
||||
variables: { id, workspaceId, expirationDays },
|
||||
});
|
||||
return data.rotateMcpCredential;
|
||||
}
|
||||
|
||||
async revoke(id: string, workspaceId: string) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: revokeMcpCredentialMutation,
|
||||
variables: { id, workspaceId },
|
||||
});
|
||||
return data.revokeMcpCredential;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user