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:
DarkSky
2026-07-12 19:30:44 +08:00
committed by GitHub
parent abf37d3dfa
commit 9b81c6debd
57 changed files with 2180 additions and 1213 deletions
@@ -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;
}
}