diff --git a/packages/backend/server/migrations/20260712093000_mcp_credentials/migration.sql b/packages/backend/server/migrations/20260712093000_mcp_credentials/migration.sql new file mode 100644 index 0000000000..c33b3dc253 --- /dev/null +++ b/packages/backend/server/migrations/20260712093000_mcp_credentials/migration.sql @@ -0,0 +1,30 @@ +DROP TABLE IF EXISTS "access_tokens"; + +CREATE TYPE "McpAccessMode" AS ENUM ('READ_ONLY', 'READ_WRITE'); + +CREATE TABLE "mcp_credentials" ( + "id" VARCHAR NOT NULL, + "family_id" VARCHAR NOT NULL, + "generation" INTEGER NOT NULL DEFAULT 0, + "name" VARCHAR NOT NULL, + "secret_hash" VARCHAR NOT NULL, + "fingerprint" VARCHAR NOT NULL, + "user_id" VARCHAR NOT NULL, + "workspace_id" VARCHAR NOT NULL, + "access_mode" "McpAccessMode" NOT NULL DEFAULT 'READ_ONLY', + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "last_used_at" TIMESTAMPTZ(3), + "revoked_at" TIMESTAMPTZ(3), + "replaced_by_id" VARCHAR, + "grace_ends_at" TIMESTAMPTZ(3), + CONSTRAINT "mcp_credentials_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "mcp_credentials_secret_hash_key" ON "mcp_credentials"("secret_hash"); +CREATE UNIQUE INDEX "mcp_credentials_family_id_generation_key" ON "mcp_credentials"("family_id", "generation"); +CREATE INDEX "mcp_credentials_user_id_workspace_id_idx" ON "mcp_credentials"("user_id", "workspace_id"); +CREATE INDEX "mcp_credentials_expires_at_idx" ON "mcp_credentials"("expires_at"); + +ALTER TABLE "mcp_credentials" ADD CONSTRAINT "mcp_credentials_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "mcp_credentials" ADD CONSTRAINT "mcp_credentials_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 479dfc65ed..1f6d27984b 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -36,6 +36,7 @@ model User { docPermissions WorkspaceDocUserRole[] connectedAccounts ConnectedAccount[] calendarAccounts CalendarAccount[] + mcpCredentials McpCredential[] sessions UserSession[] aiSessions AiSession[] appConfigs AppConfig[] @@ -51,7 +52,6 @@ model User { comments Comment[] replies Reply[] commentAttachments CommentAttachment[] @relation("createdCommentAttachments") - AccessToken AccessToken[] workspaceCalendars WorkspaceCalendar[] workspaceMemberLastAccesses WorkspaceMemberLastAccess[] quotaState EffectiveUserQuotaState? @@ -217,6 +217,7 @@ model Workspace { projectedInvitations WorkspaceInvitation[] docAccessPolicies DocAccessPolicy[] docGrants DocGrant[] + mcpCredentials McpCredential[] @@index([lastCheckEmbeddings]) @@index([createdAt]) @@ -1495,18 +1496,35 @@ model CommentAttachment { @@map("comment_attachments") } -model AccessToken { - id String @id @default(uuid()) @db.VarChar - name String @db.VarChar - token String @unique @db.VarChar - userId String @map("user_id") @db.VarChar - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) +enum McpAccessMode { + READ_ONLY + READ_WRITE +} - user User @relation(fields: [userId], references: [id], onDelete: Cascade) +model McpCredential { + id String @id @default(uuid()) @db.VarChar + familyId String @map("family_id") @db.VarChar + generation Int @default(0) + name String @db.VarChar + secretHash String @unique @map("secret_hash") @db.VarChar + fingerprint String @db.VarChar + userId String @map("user_id") @db.VarChar + workspaceId String @map("workspace_id") @db.VarChar + accessMode McpAccessMode @default(READ_ONLY) @map("access_mode") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) + replacedById String? @map("replaced_by_id") @db.VarChar + graceEndsAt DateTime? @map("grace_ends_at") @db.Timestamptz(3) - @@index([userId]) - @@map("access_tokens") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@unique([familyId, generation]) + @@index([userId, workspaceId]) + @@index([expiresAt]) + @@map("mcp_credentials") } model CalendarAccount { diff --git a/packages/backend/server/src/__tests__/auth/guard.spec.ts b/packages/backend/server/src/__tests__/auth/guard.spec.ts index efeded7cf9..46b6fafe67 100644 --- a/packages/backend/server/src/__tests__/auth/guard.spec.ts +++ b/packages/backend/server/src/__tests__/auth/guard.spec.ts @@ -146,21 +146,11 @@ test('should reject a legacy bearer session id', async t => { .get('/private') .set('Authorization', `Bearer ${t.context.sessionId}`) .expect(HttpStatus.UNAUTHORIZED); - t.pass(); -}); - -test('should be able to visit private api with personal access token', async t => { - const accessToken = await t.context.models.accessToken.create({ - userId: t.context.u1.id, - name: 'test', - }); - - const res = await request(t.context.server) + await request(t.context.server) .get('/private') - .set('Authorization', `Bearer ${accessToken.token}`) - .expect(HttpStatus.OK); - - t.is(res.body.user.id, t.context.u1.id); + .set('Authorization', 'Bearer aff_mcp_v1.selector.secret') + .expect(HttpStatus.UNAUTHORIZED); + t.pass(); }); test('should be able to visit private api with auth-session access jwt', async t => { diff --git a/packages/backend/server/src/__tests__/copilot/copilot.spec.ts b/packages/backend/server/src/__tests__/copilot/copilot.spec.ts index 67a7f82561..7d0c902b18 100644 --- a/packages/backend/server/src/__tests__/copilot/copilot.spec.ts +++ b/packages/backend/server/src/__tests__/copilot/copilot.spec.ts @@ -2,9 +2,10 @@ import { createHash, randomUUID } from 'node:crypto'; import { Readable } from 'node:stream'; import { ProjectRoot } from '@affine-tools/utils/path'; -import { PrismaClient } from '@prisma/client'; +import { McpAccessMode, PrismaClient } from '@prisma/client'; import type { TestFn } from 'ava'; import ava from 'ava'; +import type { Request, Response } from 'express'; import { nanoid } from 'nanoid'; import Sinon from 'sinon'; import { z } from 'zod'; @@ -42,6 +43,9 @@ import { CopilotEmbeddingJob, MockEmbeddingClient, } from '../../plugins/copilot/embedding'; +import { WorkspaceMcpController } from '../../plugins/copilot/mcp/controller'; +import { McpCredentialService } from '../../plugins/copilot/mcp/credential'; +import { WorkspaceMcpProvider } from '../../plugins/copilot/mcp/provider'; import { PromptService } from '../../plugins/copilot/prompt'; import { CopilotProviderFactory, @@ -110,6 +114,8 @@ type Context = { cronJobs: CopilotCronJobs; subscription: SubscriptionService; quotaState: QuotaStateService; + mcpCredentials: McpCredentialService; + mcpProvider: WorkspaceMcpProvider; }; const buildTurn = ( @@ -234,6 +240,8 @@ test.before(async t => { t.context.cronJobs = cronJobs; t.context.subscription = subscription; t.context.quotaState = quotaState; + t.context.mcpCredentials = module.get(McpCredentialService); + t.context.mcpProvider = module.get(WorkspaceMcpProvider); await module.initTestingDB(); }); @@ -254,6 +262,86 @@ test.after.always(async t => { await t.context.module?.close(); }); +test('MCP credentials stay bound to their endpoint, workspace, and profile', async t => { + const { db, mcpCredentials, mcpProvider, models, workspace } = t.context; + const ws = await workspace.create(userId); + const other = await workspace.create(userId); + const issued = await mcpCredentials.create({ + userId, + workspaceId: ws.id, + name: 'Claude Desktop', + accessMode: McpAccessMode.READ_ONLY, + expirationDays: 90, + }); + + const authenticated = await mcpCredentials.authenticate(issued.token, ws.id); + t.is(authenticated.userId, userId); + await t.throwsAsync(mcpCredentials.authenticate(issued.token, other.id)); + + let responseStatus: number | undefined; + let responseBody: unknown; + const request = { + body: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + get: () => `Bearer ${issued.token}`, + on: () => request, + } as unknown as Request; + const response = { + status: (status: number) => { + responseStatus = status; + return response; + }, + json: (body: unknown) => { + responseBody = body; + return response; + }, + } as unknown as Response; + await t.context.module + .get(WorkspaceMcpController) + .mcp(request, response, ws.id); + t.is(responseStatus, 200); + t.like(responseBody as object, { + jsonrpc: '2.0', + id: 1, + }); + + const server = await mcpProvider.for(userId, ws.id, McpAccessMode.READ_ONLY); + t.deepEqual( + server.tools.map(tool => tool.name), + ['read_document', 'semantic_search', 'keyword_search'] + ); + + const rotated = await mcpCredentials.rotate( + issued.credential.id, + userId, + ws.id, + 30 + ); + const listed = await mcpCredentials.list(userId, ws.id); + t.is(listed.length, 1); + t.is(listed[0].status, 'ROTATING'); + await mcpCredentials.authenticate(issued.token, ws.id); + await mcpCredentials.revoke(rotated.credential.id, userId, ws.id); + await t.throwsAsync(mcpCredentials.authenticate(issued.token, ws.id)); + await t.throwsAsync(mcpCredentials.authenticate(rotated.token, ws.id)); + + const disabled = await mcpCredentials.create({ + userId, + workspaceId: ws.id, + name: 'Disabled user', + accessMode: McpAccessMode.READ_ONLY, + expirationDays: 30, + }); + await models.user.update(userId, { disabled: true }); + await t.throwsAsync(mcpCredentials.authenticate(disabled.token, ws.id)); + + await models.user.update(userId, { disabled: false }); + await db.mcpCredential.update({ + where: { id: disabled.credential.id }, + data: { expiresAt: new Date(0) }, + }); + await t.throwsAsync(mcpCredentials.authenticate(disabled.token, ws.id)); +}); + test('should reject context file uploads after workspace write access is revoked', async t => { const { auth, context, models, prompt, session, storage, workspace } = t.context; diff --git a/packages/backend/server/src/__tests__/mocks/access-token.mock.ts b/packages/backend/server/src/__tests__/mocks/access-token.mock.ts deleted file mode 100644 index b1bf3315ba..0000000000 --- a/packages/backend/server/src/__tests__/mocks/access-token.mock.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { faker } from '@faker-js/faker'; -import type { AccessToken } from '@prisma/client'; -import { Prisma } from '@prisma/client'; - -import { Mocker } from './factory'; - -export type MockAccessTokenInput = Omit< - Prisma.AccessTokenUncheckedCreateInput, - 'token' ->; - -export type MockedAccessToken = AccessToken; - -export class MockAccessToken extends Mocker< - MockAccessTokenInput, - MockedAccessToken -> { - override async create(input: MockAccessTokenInput) { - return await this.db.accessToken.create({ - data: { - ...input, - name: input.name ?? faker.lorem.word(), - token: 'ut_' + faker.string.hexadecimal({ length: 37 }), - }, - }); - } -} diff --git a/packages/backend/server/src/__tests__/mocks/index.ts b/packages/backend/server/src/__tests__/mocks/index.ts index 28417c42e1..291af092c5 100644 --- a/packages/backend/server/src/__tests__/mocks/index.ts +++ b/packages/backend/server/src/__tests__/mocks/index.ts @@ -5,7 +5,6 @@ export * from './user.mock'; export * from './workspace.mock'; export * from './workspace-user.mock'; -import { MockAccessToken } from './access-token.mock'; import { installMockCopilotRuntime, MockCopilotProvider } from './copilot.mock'; import { MockDocMeta } from './doc-meta.mock'; import { MockDocSnapshot } from './doc-snapshot.mock'; @@ -28,7 +27,6 @@ export const Mockers = { DocMeta: MockDocMeta, DocSnapshot: MockDocSnapshot, DocUser: MockDocUser, - AccessToken: MockAccessToken, }; export { diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index aeeb49fc13..32e09037d5 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -27,7 +27,6 @@ import { PrismaModule } from './base/prisma'; import { RedisModule } from './base/redis'; import { RateLimiterModule } from './base/throttler'; import { WebSocketModule } from './base/websocket'; -import { AccessTokenModule } from './core/access-token'; import { AuthModule } from './core/auth'; import { BackendRuntimeModule } from './core/backend-runtime'; import { CommentModule } from './core/comment'; @@ -209,7 +208,6 @@ export function buildAppModule(env: Env) { CalendarModule, TelemetryModule, CommentModule, - AccessTokenModule, QueueDashboardModule ) // doc service and front service diff --git a/packages/backend/server/src/core/access-token/index.ts b/packages/backend/server/src/core/access-token/index.ts deleted file mode 100644 index 0fd0bcc3c9..0000000000 --- a/packages/backend/server/src/core/access-token/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { AccessTokenResolver, UserAccessTokenResolver } from './resolver'; - -@Module({ - providers: [AccessTokenResolver, UserAccessTokenResolver], -}) -export class AccessTokenModule {} diff --git a/packages/backend/server/src/core/access-token/resolver.ts b/packages/backend/server/src/core/access-token/resolver.ts deleted file mode 100644 index 2c9d3dfcba..0000000000 --- a/packages/backend/server/src/core/access-token/resolver.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - Args, - Field, - InputType, - Mutation, - Parent, - Query, - ResolveField, - Resolver, -} from '@nestjs/graphql'; - -import { ActionForbidden, EventBus } from '../../base'; -import { Models } from '../../models'; -import { CurrentUser } from '../auth/session'; -import { UserType } from '../user'; -import { AccessToken, RevealedAccessToken } from './types'; - -@InputType() -class GenerateAccessTokenInput { - @Field() - name!: string; - - @Field(() => Date, { nullable: true }) - expiresAt!: Date | null; -} - -@Resolver(() => AccessToken) -export class AccessTokenResolver { - constructor( - private readonly models: Models, - private readonly event: EventBus - ) {} - - @Query(() => [RevealedAccessToken], { - deprecationReason: 'use currentUser.revealedAccessTokens', - }) - async revealedAccessTokens( - @CurrentUser() user: CurrentUser - ): Promise { - return await this.models.accessToken.list(user.id, true); - } - - @Mutation(() => RevealedAccessToken) - async generateUserAccessToken( - @CurrentUser() user: CurrentUser, - @Args('input') input: GenerateAccessTokenInput - ): Promise { - const token = await this.models.accessToken.create({ - userId: user.id, - name: input.name, - expiresAt: input.expiresAt, - }); - this.event.emit('user.access_token.created', { userId: user.id }); - return token; - } - - @Mutation(() => Boolean) - async revokeUserAccessToken( - @CurrentUser() user: CurrentUser, - @Args('id') id: string - ): Promise { - await this.models.accessToken.revoke(id, user.id); - this.event.emit('user.access_token.revoked', { userId: user.id }); - return true; - } -} - -@Resolver(() => UserType) -export class UserAccessTokenResolver { - constructor(private readonly models: Models) {} - - @ResolveField(() => [AccessToken]) - async accessTokens( - @CurrentUser() currentUser: CurrentUser, - @Parent() user: UserType - ): Promise { - if (!currentUser || currentUser.id !== user.id) { - throw new ActionForbidden(); - } - return await this.models.accessToken.list(user.id); - } - - @ResolveField(() => [RevealedAccessToken]) - async revealedAccessTokens( - @CurrentUser() currentUser: CurrentUser, - @Parent() user: UserType - ): Promise { - if (!currentUser || currentUser.id !== user.id) { - throw new ActionForbidden(); - } - return await this.models.accessToken.list(user.id, true); - } -} diff --git a/packages/backend/server/src/core/access-token/types.ts b/packages/backend/server/src/core/access-token/types.ts deleted file mode 100644 index 63979ed88b..0000000000 --- a/packages/backend/server/src/core/access-token/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Field, ObjectType } from '@nestjs/graphql'; - -@ObjectType() -export class AccessToken { - @Field() - id!: string; - - @Field() - name!: string; - - @Field() - createdAt!: Date; - - @Field(() => Date, { nullable: true }) - expiresAt!: Date | null; -} - -@ObjectType() -export class RevealedAccessToken extends AccessToken { - @Field() - token!: string; -} diff --git a/packages/backend/server/src/core/auth/guard.ts b/packages/backend/server/src/core/auth/guard.ts index d294a7dda3..568c28e8c8 100644 --- a/packages/backend/server/src/core/auth/guard.ts +++ b/packages/backend/server/src/core/auth/guard.ts @@ -31,7 +31,7 @@ import { import { AuthSessionService } from './auth-session'; import { extractTokenFromHeader } from './input'; import { AuthService } from './service'; -import { AuthSessionPrincipal, Session, TokenSession } from './session'; +import { AuthSessionPrincipal, Session } from './session'; import { AuthSessionHttpError } from './session-exchange'; const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public'); @@ -41,8 +41,7 @@ const INTERNAL_ACCESS_TOKEN_CLOCK_SKEW_MS = 30 * 1000; type AuthenticatedRequestSession = | { type: 'jwt'; session: Session } - | { type: 'cookie_session'; session: Session } - | { type: 'access_token'; token: TokenSession }; + | { type: 'cookie_session'; session: Session }; @Injectable() export class AuthGuard implements CanActivate, OnModuleInit { @@ -126,11 +125,9 @@ export class AuthGuard implements CanActivate, OnModuleInit { req: Request, res?: Response, isPublic = false - ): Promise { + ): Promise { const result = await this.resolveRequestSession(req, res, isPublic); - return result?.type === 'access_token' - ? result.token - : (result?.session ?? null); + return result?.session ?? null; } private async resolveRequestSession( @@ -153,11 +150,6 @@ export class AuthGuard implements CanActivate, OnModuleInit { } } - if (bearer) { - const token = await this.signInWithAccessToken(req); - return token ? { type: 'access_token', token } : null; - } - const session = await this.signInWithCookie(req, res, isPublic); return session ? { type: 'cookie_session', session } : null; } @@ -270,23 +262,6 @@ export class AuthGuard implements CanActivate, OnModuleInit { }); } - async signInWithAccessToken(req: Request): Promise { - if (req.token) { - return req.token; - } - - const tokenSession = await this.auth.getTokenSessionFromRequest(req); - - if (tokenSession) { - req.token = { ...tokenSession.token, user: tokenSession.user }; - req.authType = 'access_token'; - - return req.token; - } - - return null; - } - private getVersionRange(versionRange: string): semver.Range | null { if (this.cachedVersionRange.has(versionRange)) { // oxlint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index cdff69f8ca..ecad47cef1 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -14,7 +14,6 @@ import { createDevUsers } from './dev'; import type { VerifiedIdentity } from './identity'; import { CSRF_COOKIE_NAME, - extractTokenFromHeader, getSessionOptionsFromRequest, SESSION_COOKIE_NAME, USER_COOKIE_NAME, @@ -277,36 +276,6 @@ export class AuthService implements OnApplicationBootstrap { return session; } - async getTokenSessionFromRequest(req: Request) { - const tokenHeader = req.headers.authorization; - if (!tokenHeader) { - return null; - } - - const tokenValue = extractTokenFromHeader(tokenHeader); - - if (!tokenValue) { - return null; - } - - const token = await this.models.accessToken.getByToken(tokenValue); - - if (token) { - const user = await this.models.user.get(token.userId); - - if (!user) { - return null; - } - - return { - token, - user: sessionUser(user), - }; - } - - return null; - } - async changePassword( id: string, newPassword: string diff --git a/packages/backend/server/src/core/auth/session.ts b/packages/backend/server/src/core/auth/session.ts index 89c89bc80a..b3dc78681d 100644 --- a/packages/backend/server/src/core/auth/session.ts +++ b/packages/backend/server/src/core/auth/session.ts @@ -1,6 +1,5 @@ import type { ExecutionContext } from '@nestjs/common'; import { createParamDecorator } from '@nestjs/common'; -import { AccessToken } from '@prisma/client'; import { getRequestResponseFromContext } from '../../base'; import type { User, UserSession } from '../../models'; @@ -42,7 +41,7 @@ import type { User, UserSession } from '../../models'; export const CurrentUser = createParamDecorator( (_: unknown, context: ExecutionContext) => { const req = getRequestResponseFromContext(context).req; - return req.session?.user ?? req.token?.user; + return req.session?.user; } ); @@ -70,7 +69,3 @@ export type AuthSessionPrincipal = Session & { authSessionId: string; authenticatedAt: Date; }; - -export type TokenSession = AccessToken & { - user: CurrentUser; -}; diff --git a/packages/backend/server/src/core/queue-dashboard/__tests__/security.spec.ts b/packages/backend/server/src/core/queue-dashboard/__tests__/security.spec.ts index abedb51999..4561c16e84 100644 --- a/packages/backend/server/src/core/queue-dashboard/__tests__/security.spec.ts +++ b/packages/backend/server/src/core/queue-dashboard/__tests__/security.spec.ts @@ -7,9 +7,8 @@ import { Models } from '../../../models'; const test = ava as TestFn<{ app: TestingApp; models: Models; - allowlistedAdminToken: string; - nonAllowlistedAdminToken: string; - userToken: string; + allowlistedAdminEmail: string; + userEmail: string; }>; test.before(async t => { @@ -34,38 +33,14 @@ test.beforeEach(async t => { 'administrator', 'test' ); - const allowlistedAdminToken = await t.context.models.accessToken.create({ - userId: allowlistedAdmin.id, - name: 'test', - }); - t.context.allowlistedAdminToken = allowlistedAdminToken.token; - - const nonAllowlistedAdmin = await t.context.models.user.create({ - email: 'admin2@affine.pro', - password: '1', - emailVerifiedAt: new Date(), - }); - await t.context.models.userFeature.add( - nonAllowlistedAdmin.id, - 'administrator', - 'test' - ); - const nonAllowlistedAdminToken = await t.context.models.accessToken.create({ - userId: nonAllowlistedAdmin.id, - name: 'test', - }); - t.context.nonAllowlistedAdminToken = nonAllowlistedAdminToken.token; + t.context.allowlistedAdminEmail = allowlistedAdmin.email; const user = await t.context.models.user.create({ email: 'user@affine.pro', password: '1', emailVerifiedAt: new Date(), }); - const userToken = await t.context.models.accessToken.create({ - userId: user.id, - name: 'test', - }); - t.context.userToken = userToken.token; + t.context.userEmail = user.email; }); test.after.always(async t => { @@ -74,16 +49,20 @@ test.after.always(async t => { test('should return 404 for non-admin user', async t => { await t.context.app - .GET('/api/queue') - .set('Authorization', `Bearer ${t.context.userToken}`) - .expect(404); + .POST('/api/auth/sign-in') + .send({ email: t.context.userEmail, password: '1' }) + .expect(200); + await t.context.app.GET('/api/queue').expect(404); t.pass(); }); test('should allow allowlisted admin', async t => { + await t.context.app + .POST('/api/auth/sign-in') + .send({ email: t.context.allowlistedAdminEmail, password: '1' }) + .expect(200); await t.context.app .GET('/api/queue') - .set('Authorization', `Bearer ${t.context.allowlistedAdminToken}`) .expect(200) .expect('Content-Type', /text\/html/); t.pass(); diff --git a/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts b/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts index 98d6c34487..ff8b6a8df7 100644 --- a/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts +++ b/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts @@ -40,7 +40,6 @@ import { realtimeDocShareStateRoom, realtimeNotificationRoom, realtimeTranscriptTaskRoom, - realtimeUserAccessTokensRoom, realtimeUserProfileRoom, realtimeUserSettingsRoom, realtimeWorkspaceAccessRoom, @@ -298,7 +297,6 @@ test('room helpers produce stable realtime room names', t => { t.is(realtimeDocGrantsRoom('space', 'doc'), 'workspace:space:doc:doc:grants'); t.is(realtimeUserProfileRoom('u1'), 'user:u1:profile'); t.is(realtimeUserSettingsRoom('u1'), 'user:u1:settings'); - t.is(realtimeUserAccessTokensRoom('u1'), 'user:u1:access-tokens'); t.is( realtimeTranscriptTaskRoom('space', 'task'), 'copilot:transcript:space:task' @@ -775,16 +773,6 @@ test('user realtime provider snapshots private profile settings and access token userFeature: { list: async () => ['administrator'], }, - accessToken: { - list: async () => [ - { - id: 'token', - name: 'Token', - createdAt: new Date('2026-01-01T00:00:00.000Z'), - expiresAt: null, - }, - ], - }, }; new UserRealtimeProvider(models as never, registry).onModuleInit(); @@ -814,10 +802,6 @@ test('user realtime provider snapshots private profile settings and access token registry.getTopic('user.settings.changed').room(user, {}), realtimeUserSettingsRoom('u1') ); - t.is( - registry.getTopic('user.access-tokens.changed').room(user, {}), - realtimeUserAccessTokensRoom('u1') - ); t.deepEqual(await registry.getRequest('user.settings.get').handle(user, {}), { settings: { receiveInvitationEmail: true, @@ -825,19 +809,6 @@ test('user realtime provider snapshots private profile settings and access token receiveCommentEmail: true, }, }); - t.deepEqual( - await registry.getRequest('user.access-tokens.get').handle(user, {}), - { - tokens: [ - { - id: 'token', - name: 'Token', - createdAt: '2026-01-01T00:00:00.000Z', - expiresAt: null, - }, - ], - } - ); }); test('new realtime providers publish changed events from domain events', t => { @@ -894,13 +865,6 @@ test('new realtime providers publish changed events from domain events', t => { userId: 'u2', }); - const userProvider = new UserRealtimeProvider( - {} as never, - undefined, - publisher - ); - userProvider.onUserAccessTokenCreated({ userId: 'u1' }); - t.deepEqual( published.map(args => args[0]), [ @@ -909,7 +873,6 @@ test('new realtime providers publish changed events from domain events', t => { 'workspace.invite-link.changed', 'doc.share-state.changed', 'doc.grants.changed', - 'user.access-tokens.changed', ] ); }); diff --git a/packages/backend/server/src/core/realtime/index.ts b/packages/backend/server/src/core/realtime/index.ts index bac8b8d097..667c2bd8bd 100644 --- a/packages/backend/server/src/core/realtime/index.ts +++ b/packages/backend/server/src/core/realtime/index.ts @@ -31,7 +31,6 @@ export { realtimeDocShareStateRoom, realtimeNotificationRoom, realtimeTranscriptTaskRoom, - realtimeUserAccessTokensRoom, realtimeUserProfileRoom, realtimeUserQuotaStateRoom, realtimeUserRoom, diff --git a/packages/backend/server/src/core/realtime/required-handlers.ts b/packages/backend/server/src/core/realtime/required-handlers.ts index 5c6279c699..6285100b6a 100644 --- a/packages/backend/server/src/core/realtime/required-handlers.ts +++ b/packages/backend/server/src/core/realtime/required-handlers.ts @@ -9,7 +9,6 @@ export const REALTIME_GATEWAY_REQUIRED_REQUESTS = [ 'doc.grants.get', 'user.profile.get', 'user.settings.get', - 'user.access-tokens.get', 'notification.count.get', 'comment.changes.get', 'workspace.embedding.progress.get', @@ -27,7 +26,6 @@ export const REALTIME_GATEWAY_REQUIRED_TOPICS = [ 'doc.grants.changed', 'user.profile.changed', 'user.settings.changed', - 'user.access-tokens.changed', 'notification.count.changed', 'comment.changed', 'workspace.embedding.progress.changed', diff --git a/packages/backend/server/src/core/realtime/rooms.ts b/packages/backend/server/src/core/realtime/rooms.ts index 0c10617f9a..81d57b1c37 100644 --- a/packages/backend/server/src/core/realtime/rooms.ts +++ b/packages/backend/server/src/core/realtime/rooms.ts @@ -72,7 +72,3 @@ export function realtimeUserProfileRoom(userId: string) { export function realtimeUserSettingsRoom(userId: string) { return realtimeUserRoom(userId, 'settings'); } - -export function realtimeUserAccessTokensRoom(userId: string) { - return realtimeUserRoom(userId, 'access-tokens'); -} diff --git a/packages/backend/server/src/core/sync/gateway.ts b/packages/backend/server/src/core/sync/gateway.ts index 3b2443918b..f42705abc9 100644 --- a/packages/backend/server/src/core/sync/gateway.ts +++ b/packages/backend/server/src/core/sync/gateway.ts @@ -353,7 +353,7 @@ export class SpaceSyncGateway private attachPresenceUserId(client: Socket): string | null { const request = client.request as Request; - const userId = request.session?.user.id ?? request.token?.user.id; + const userId = request.session?.user.id; if (typeof userId !== 'string' || !userId) { this.logger.warn( `Unable to resolve authenticated user id for socket ${client.id}` diff --git a/packages/backend/server/src/core/user/realtime.ts b/packages/backend/server/src/core/user/realtime.ts index 9d2fa949cb..58d7dfc7fe 100644 --- a/packages/backend/server/src/core/user/realtime.ts +++ b/packages/backend/server/src/core/user/realtime.ts @@ -1,5 +1,4 @@ import type { - AccessTokenSnapshot, CurrentUserProfileSnapshot, UserSettingsSnapshot, } from '@affine/realtime'; @@ -14,7 +13,6 @@ import { registerRealtimeLiveQuery } from '../realtime/provider'; import { RealtimePublisher } from '../realtime/publisher'; import { RealtimeRegistry } from '../realtime/registry'; import { - realtimeUserAccessTokensRoom, realtimeUserProfileRoom, realtimeUserSettingsRoom, } from '../realtime/rooms'; @@ -85,27 +83,6 @@ export class UserRealtimeProvider }, }, }); - - registerRealtimeLiveQuery(this.registry, { - request: { - name: 'user.access-tokens.get', - input: emptyInput, - handle: async user => ({ - tokens: await this.getAccessTokens(assertAuthenticated(user).id), - }), - }, - topic: { - name: 'user.access-tokens.changed', - input: emptyInput, - authorize: async () => {}, - room: user => { - if (!user) { - throw new Error('Authenticated user is required'); - } - return realtimeUserAccessTokensRoom(user.id); - }, - }, - }); } @OnEvent('user.updated', { suppressError: true }) @@ -125,16 +102,6 @@ export class UserRealtimeProvider ); } - @OnEvent('user.access_token.created', { suppressError: true }) - onUserAccessTokenCreated({ userId }: Events['user.access_token.created']) { - this.publishAccessTokens(userId, 'access-token-created'); - } - - @OnEvent('user.access_token.revoked', { suppressError: true }) - onUserAccessTokenRevoked({ userId }: Events['user.access_token.revoked']) { - this.publishAccessTokens(userId, 'access-token-revoked'); - } - private async getProfile( userId: string ): Promise { @@ -166,22 +133,4 @@ export class UserRealtimeProvider private async getSettings(userId: string): Promise { return await this.models.userSettings.get(userId); } - - private async getAccessTokens( - userId: string - ): Promise { - const tokens = await this.models.accessToken.list(userId); - return tokens.map(token => ({ - id: token.id, - name: token.name, - createdAt: token.createdAt.toISOString(), - expiresAt: token.expiresAt?.toISOString() ?? null, - })); - } - - private publishAccessTokens(userId: string, reason: string) { - this.publisher?.publishChanged('user.access-tokens.changed', {}, reason, { - room: realtimeUserAccessTokensRoom(userId), - }); - } } diff --git a/packages/backend/server/src/global.d.ts b/packages/backend/server/src/global.d.ts index 5585dcd67b..8a90ad75f9 100644 --- a/packages/backend/server/src/global.d.ts +++ b/packages/backend/server/src/global.d.ts @@ -1,8 +1,7 @@ declare namespace Express { interface Request { session?: import('./core/auth/session').Session; - token?: import('./core/auth/session').TokenSession; - authType?: 'jwt' | 'session' | 'access_token'; + authType?: 'jwt' | 'session'; } } diff --git a/packages/backend/server/src/models/__tests__/access-token.spec.ts b/packages/backend/server/src/models/__tests__/access-token.spec.ts deleted file mode 100644 index 7bd6ac931c..0000000000 --- a/packages/backend/server/src/models/__tests__/access-token.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -import test from 'ava'; - -import { createModule } from '../../__tests__/create-module'; -import { Mockers } from '../../__tests__/mocks'; -import { Due } from '../../base'; -import { Models } from '../index'; - -const module = await createModule(); -const models = module.get(Models); - -test.after.always(async () => { - await module.close(); -}); - -test('should create access token', async t => { - const user = await module.create(Mockers.User); - - const token = await models.accessToken.create({ - userId: user.id, - name: 'test', - }); - - t.is(token.userId, user.id); - t.is(token.name, 'test'); - t.truthy(token.token); - t.true(token.token.startsWith('ut_')); - t.truthy(token.createdAt); - t.is(token.expiresAt, null); - - const row = await module.get(PrismaClient).accessToken.findUnique({ - where: { id: token.id }, - }); - t.truthy(row); - t.regex(row!.token, /^[0-9a-f]{64}$/); - t.not(row!.token, token.token); -}); - -test('should create access token with expiration', async t => { - const user = await module.create(Mockers.User); - - const token = await models.accessToken.create({ - userId: user.id, - name: 'test', - expiresAt: Due.after('30d'), - }); - - t.truthy(token.expiresAt); - t.truthy(token.expiresAt! > new Date()); -}); - -test('should list access tokens without token value', async t => { - const user = await module.create(Mockers.User); - await module.create(Mockers.AccessToken, { userId: user.id }, 3); - - const listed = await models.accessToken.list(user.id); - t.is(listed.length, 3); - // @ts-expect-error not exists - t.is(listed[0].token, undefined); -}); - -test('should not reveal access token value after creation', async t => { - const user = await module.create(Mockers.User); - - const token = await models.accessToken.create({ - userId: user.id, - name: 'test', - }); - - const listed = await models.accessToken.list(user.id, true); - const found = listed.find(item => item.id === token.id); - - t.truthy(found); - t.is(found!.token, '[REDACTED]'); - t.not(found!.token, token.token); -}); - -test('should be able to revoke access token', async t => { - const user = await module.create(Mockers.User); - const token = await module.create(Mockers.AccessToken, { userId: user.id }); - - await models.accessToken.revoke(token.id, user.id); - - const listed = await models.accessToken.list(user.id); - t.is(listed.length, 0); -}); - -test('should be able to get access token by token value', async t => { - const user = await module.create(Mockers.User); - const token = await models.accessToken.create({ - userId: user.id, - name: 'test', - }); - - const found = await models.accessToken.getByToken(token.token); - t.is(found?.id, token.id); - t.is(found?.userId, user.id); - t.is(found?.name, token.name); -}); - -test('should not get expired access token', async t => { - const user = await module.create(Mockers.User); - const token = await models.accessToken.create({ - userId: user.id, - name: 'test', - expiresAt: Due.before('1s'), - }); - - const found = await models.accessToken.getByToken(token.token); - t.is(found, null); -}); diff --git a/packages/backend/server/src/models/access-token.ts b/packages/backend/server/src/models/access-token.ts deleted file mode 100644 index 571a7b2c57..0000000000 --- a/packages/backend/server/src/models/access-token.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { CryptoHelper } from '../base'; -import { BaseModel } from './base'; - -const REDACTED_TOKEN = '[REDACTED]'; - -declare global { - interface Events { - 'user.access_token.created': { - userId: string; - }; - 'user.access_token.revoked': { - userId: string; - }; - } -} - -export interface CreateAccessTokenInput { - userId: string; - name: string; - expiresAt?: Date | null; -} - -type UserAccessToken = { - id: string; - name: string; - createdAt: Date; - expiresAt: Date | null; -}; - -@Injectable() -export class AccessTokenModel extends BaseModel { - constructor(private readonly crypto: CryptoHelper) { - super(); - } - - async list(userId: string, revealed?: false): Promise; - async list( - userId: string, - revealed: true - ): Promise<(UserAccessToken & { token: string })[]>; - async list(userId: string, revealed: boolean = false) { - const tokens = await this.db.accessToken.findMany({ - select: { id: true, name: true, createdAt: true, expiresAt: true }, - where: { userId }, - }); - - if (!revealed) return tokens; - - return tokens.map(row => ({ ...row, token: REDACTED_TOKEN })); - } - - async create(input: CreateAccessTokenInput) { - const token = `ut_${this.crypto.randomBytes(32).toString('base64url')}`; - const tokenHash = this.crypto.sha256(token).toString('hex'); - - const created = await this.db.accessToken.create({ - data: { token: tokenHash, ...input }, - }); - - // NOTE: we only return the plaintext token once, at creation time. - return { ...created, token }; - } - - async revoke(id: string, userId: string) { - await this.db.accessToken.deleteMany({ - where: { - id, - userId, - }, - }); - } - - async getByToken(token: string) { - const tokenHash = this.crypto.sha256(token).toString('hex'); - - const condition = [{ expiresAt: null }, { expiresAt: { gt: new Date() } }]; - const found = await this.db.accessToken.findUnique({ - where: { token: tokenHash, OR: condition }, - }); - - if (found) return found; - - // Compatibility: lazy-migrate old plaintext tokens in DB. - const legacy = await this.db.accessToken.findUnique({ - where: { token, OR: condition }, - }); - - if (!legacy) return null; - - await this.db.accessToken.update({ - where: { id: legacy.id }, - data: { token: tokenHash }, - }); - - return { ...legacy, token: tokenHash }; - } -} diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 8d1f447938..06a777acfc 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -7,7 +7,6 @@ import { import { ModuleRef } from '@nestjs/core'; import { ApplyType } from '../base'; -import { AccessTokenModel } from './access-token'; import { AuthSessionModel } from './auth-session'; import { BlobModel } from './blob'; import { CalendarAccountModel } from './calendar-account'; @@ -31,6 +30,7 @@ import { FeatureModel } from './feature'; import { HistoryModel } from './history'; import { MagicLinkOtpModel } from './magic-link-otp'; import { MailDeliveryModel } from './mail-delivery'; +import { McpCredentialModel } from './mcp-credential'; import { NotificationModel } from './notification'; import { PermissionProjectionModel } from './permission-projection'; import { @@ -91,7 +91,7 @@ const MODELS = { comment: CommentModel, commentAttachment: CommentAttachmentModel, blob: BlobModel, - accessToken: AccessTokenModel, + mcpCredential: McpCredentialModel, calendarAccount: CalendarAccountModel, calendarSubscription: CalendarSubscriptionModel, calendarEvent: CalendarEventModel, diff --git a/packages/backend/server/src/models/mcp-credential.ts b/packages/backend/server/src/models/mcp-credential.ts new file mode 100644 index 0000000000..586f8cceb7 --- /dev/null +++ b/packages/backend/server/src/models/mcp-credential.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; +import type { McpAccessMode } from '@prisma/client'; + +import { BaseModel } from './base'; + +export type CreateMcpCredential = { + id: string; + familyId: string; + generation: number; + name: string; + secretHash: string; + fingerprint: string; + userId: string; + workspaceId: string; + accessMode: McpAccessMode; + expiresAt: Date; + graceEndsAt?: Date | null; +}; + +@Injectable() +export class McpCredentialModel extends BaseModel { + list(userId: string, workspaceId: string) { + return this.db.mcpCredential.findMany({ + where: { userId, workspaceId }, + orderBy: [{ familyId: 'asc' }, { generation: 'desc' }], + }); + } + + create(input: CreateMcpCredential) { + return this.db.mcpCredential.create({ data: input }); + } + + get(id: string) { + return this.db.mcpCredential.findUnique({ where: { id } }); + } + + async revokeFamily(familyId: string, userId: string, workspaceId: string) { + return await this.db.mcpCredential.updateMany({ + where: { familyId, userId, workspaceId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + async replace( + id: string, + userId: string, + workspaceId: string, + replacedById: string, + expiresAt: Date + ) { + return await this.db.mcpCredential.updateMany({ + where: { + id, + userId, + workspaceId, + revokedAt: null, + replacedById: null, + }, + data: { replacedById, expiresAt }, + }); + } + + async authenticate(id: string, workspaceId: string) { + const now = new Date(); + return await this.db.mcpCredential.findFirst({ + where: { + id, + workspaceId, + revokedAt: null, + expiresAt: { gt: now }, + user: { disabled: false }, + }, + include: { user: true }, + }); + } + + async touch(id: string, before: Date, now: Date) { + await this.db.mcpCredential.updateMany({ + where: { + id, + OR: [{ lastUsedAt: null }, { lastUsedAt: { lt: before } }], + }, + data: { lastUsedAt: now }, + }); + } +} diff --git a/packages/backend/server/src/plugins/copilot/index.ts b/packages/backend/server/src/plugins/copilot/index.ts index f22247b987..25398e2704 100644 --- a/packages/backend/server/src/plugins/copilot/index.ts +++ b/packages/backend/server/src/plugins/copilot/index.ts @@ -12,6 +12,8 @@ import { WorkspaceModule } from '../../core/workspaces'; import { IndexerModule } from '../indexer'; import { CopilotController } from './controller'; import { WorkspaceMcpController } from './mcp/controller'; +import { McpCredentialService } from './mcp/credential'; +import { McpCredentialResolver } from './mcp/resolver'; import { COPILOT_API_PROVIDERS, COPILOT_CONTEXT_REALTIME_PROVIDERS, @@ -69,7 +71,13 @@ export class CopilotFeatureModule {} export class CopilotApiModule {} @Module({ - imports: [CopilotKernelModule, CopilotFeatureModule, CopilotApiModule], + imports: [ + PermissionModule, + CopilotKernelModule, + CopilotFeatureModule, + CopilotApiModule, + ], + providers: [McpCredentialService, McpCredentialResolver], controllers: [CopilotController, WorkspaceMcpController], }) export class CopilotModule {} diff --git a/packages/backend/server/src/plugins/copilot/mcp/controller.ts b/packages/backend/server/src/plugins/copilot/mcp/controller.ts index 0688bbc4bd..3d2b4e0ff4 100644 --- a/packages/backend/server/src/plugins/copilot/mcp/controller.ts +++ b/packages/backend/server/src/plugins/copilot/mcp/controller.ts @@ -3,6 +3,7 @@ import { Delete, Get, HttpCode, + HttpException, HttpStatus, Logger, Param, @@ -12,8 +13,10 @@ import { } from '@nestjs/common'; import type { Request, Response } from 'express'; -import { Throttle } from '../../../base'; -import { CurrentUser } from '../../../core/auth'; +import { ActionForbidden, Throttle } from '../../../base'; +import { Public } from '../../../core/auth'; +import { extractTokenFromHeader } from '../../../core/auth/input'; +import { McpCredentialService } from './credential'; import { WorkspaceMcpProvider, type WorkspaceMcpServer } from './provider'; type JsonRpcId = string | number | null; @@ -47,28 +50,42 @@ const SUPPORTED_PROTOCOL_VERSIONS = new Set([ export class WorkspaceMcpController { private readonly logger = new Logger(WorkspaceMcpController.name); - constructor(private readonly provider: WorkspaceMcpProvider) {} + constructor( + private readonly provider: WorkspaceMcpProvider, + private readonly credentials: McpCredentialService + ) {} @Get('/') @Delete('/') + @Public() @HttpCode(HttpStatus.METHOD_NOT_ALLOWED) async STATELESS_MCP_ENDPOINT() { return this.errorResponse(null, -32000, 'Method not allowed.'); } @Throttle('default') + @Public() @Post('/') async mcp( @Req() req: Request, @Res() res: Response, - @CurrentUser() user: CurrentUser, @Param('workspaceId') workspaceId: string ) { const abortController = new AbortController(); req.on('close', () => abortController.abort()); try { - const server = await this.provider.for(user.id, workspaceId); + const token = + extractTokenFromHeader(req.get('authorization') ?? '') ?? ''; + const credential = await this.credentials.authenticate( + token, + workspaceId + ); + const server = await this.provider.for( + credential.userId, + workspaceId, + credential.accessMode + ); const body = req.body as unknown; const isBatch = Array.isArray(body); const messages = isBatch ? body : [body]; @@ -111,6 +128,18 @@ export class WorkspaceMcpController { res.status(HttpStatus.OK).json(isBatch ? responses : responses[0]); } catch (error) { + if (error instanceof HttpException) { + res + .status(error.getStatus()) + .json(this.errorResponse(null, -32000, 'Authentication failed')); + return; + } + if (error instanceof ActionForbidden) { + res + .status(HttpStatus.FORBIDDEN) + .json(this.errorResponse(null, -32000, 'Access denied')); + return; + } this.logger.error('Failed to handle MCP request', error); res .status(HttpStatus.INTERNAL_SERVER_ERROR) diff --git a/packages/backend/server/src/plugins/copilot/mcp/credential.ts b/packages/backend/server/src/plugins/copilot/mcp/credential.ts new file mode 100644 index 0000000000..2dc28aedf3 --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/mcp/credential.ts @@ -0,0 +1,256 @@ +import { randomUUID } from 'node:crypto'; + +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import type { McpAccessMode } from '@prisma/client'; + +import { CryptoHelper, EventBus } from '../../../base'; +import { Models } from '../../../models'; + +const TOKEN_PREFIX = 'aff_mcp_v1'; +const ALLOWED_EXPIRATION_DAYS = new Set([30, 90, 365]); +const ROTATION_GRACE_MS = 24 * 60 * 60 * 1000; +const LAST_USED_WRITE_INTERVAL_MS = 5 * 60 * 1000; + +declare global { + interface Events { + 'mcp.credential.created': { + credentialId: string; + userId: string; + workspaceId: string; + }; + 'mcp.credential.rotated': { + credentialId: string; + replacedById: string; + userId: string; + workspaceId: string; + }; + 'mcp.credential.revoked': { + credentialId: string; + userId: string; + workspaceId: string; + }; + } +} + +type IssueMcpCredential = { + userId: string; + workspaceId: string; + name: string; + accessMode: McpAccessMode; + expirationDays: number; +}; + +@Injectable() +export class McpCredentialService { + constructor( + private readonly models: Models, + private readonly crypto: CryptoHelper, + private readonly event: EventBus + ) {} + + async list(userId: string, workspaceId: string) { + const credentials = await this.models.mcpCredential.list( + userId, + workspaceId + ); + const latest = new Map(); + for (const credential of credentials) { + if (!latest.has(credential.familyId)) { + latest.set(credential.familyId, credential); + } + } + return [...latest.values()] + .map(credential => { + const status = this.status(credential); + return { + ...credential, + graceEndsAt: status === 'ROTATING' ? credential.graceEndsAt : null, + status, + }; + }) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + } + + async create(input: IssueMcpCredential) { + const issued = await this.issue(input); + this.event.emit('mcp.credential.created', { + credentialId: issued.credential.id, + userId: input.userId, + workspaceId: input.workspaceId, + }); + return issued; + } + + @Transactional() + async rotate( + id: string, + userId: string, + workspaceId: string, + expirationDays: number + ) { + const current = await this.models.mcpCredential.get(id); + if ( + !current || + current.userId !== userId || + current.workspaceId !== workspaceId || + current.revokedAt || + current.replacedById || + current.expiresAt <= new Date() + ) { + throw new BadRequestException('MCP credential not found'); + } + + const maximumGraceEnd = new Date(Date.now() + ROTATION_GRACE_MS); + const graceEnd = + current.expiresAt < maximumGraceEnd ? current.expiresAt : maximumGraceEnd; + const issued = await this.issue({ + userId, + workspaceId, + name: current.name, + accessMode: current.accessMode, + expirationDays, + familyId: current.familyId, + generation: current.generation + 1, + graceEndsAt: graceEnd, + }); + const replaced = await this.models.mcpCredential.replace( + current.id, + userId, + workspaceId, + issued.credential.id, + graceEnd + ); + if (replaced.count !== 1) { + throw new BadRequestException('MCP credential not found'); + } + this.event.emit('mcp.credential.rotated', { + credentialId: current.id, + replacedById: issued.credential.id, + userId, + workspaceId, + }); + return issued; + } + + async revoke(id: string, userId: string, workspaceId: string) { + const credential = await this.models.mcpCredential.get(id); + if ( + !credential || + credential.userId !== userId || + credential.workspaceId !== workspaceId + ) { + return false; + } + const result = await this.models.mcpCredential.revokeFamily( + credential.familyId, + userId, + workspaceId + ); + if (result.count) { + this.event.emit('mcp.credential.revoked', { + credentialId: id, + userId, + workspaceId, + }); + } + return result.count > 0; + } + + async authenticate(token: string, workspaceId: string) { + const parsed = this.parse(token); + if (!parsed) throw new UnauthorizedException(); + + const credential = await this.models.mcpCredential.authenticate( + parsed.id, + workspaceId + ); + if (!credential) throw new UnauthorizedException(); + + const actualHash = this.crypto.sha256(parsed.secret).toString('hex'); + if (!this.crypto.compare(actualHash, credential.secretHash)) { + throw new UnauthorizedException(); + } + + const now = new Date(); + await this.models.mcpCredential.touch( + credential.id, + new Date(now.getTime() - LAST_USED_WRITE_INTERVAL_MS), + now + ); + return credential; + } + + private async issue( + input: IssueMcpCredential & { + familyId?: string; + generation?: number; + graceEndsAt?: Date; + } + ) { + if (!ALLOWED_EXPIRATION_DAYS.has(input.expirationDays)) { + throw new BadRequestException('Unsupported MCP credential expiration'); + } + const name = input.name.trim(); + if (!name || name.length > 64) { + throw new BadRequestException('MCP credential name is required'); + } + + const id = randomUUID(); + const secret = this.crypto.randomBytes(32).toString('base64url'); + const secretHash = this.crypto.sha256(secret).toString('hex'); + const credential = await this.models.mcpCredential.create({ + id, + familyId: input.familyId ?? id, + generation: input.generation ?? 0, + name, + secretHash, + fingerprint: secretHash.slice(0, 12), + userId: input.userId, + workspaceId: input.workspaceId, + accessMode: input.accessMode, + expiresAt: new Date( + Date.now() + input.expirationDays * 24 * 60 * 60 * 1000 + ), + graceEndsAt: input.graceEndsAt, + }); + return { + credential: { ...credential, status: this.status(credential) }, + token: `${TOKEN_PREFIX}.${id}.${secret}`, + }; + } + + private parse(token: string) { + const parts = token.split('.'); + if ( + parts.length !== 3 || + parts[0] !== TOKEN_PREFIX || + !parts[1] || + !parts[2] + ) { + return null; + } + return { id: parts[1], secret: parts[2] }; + } + + private status(credential: { + revokedAt: Date | null; + expiresAt: Date; + graceEndsAt: Date | null; + }) { + const now = Date.now(); + if (credential.revokedAt) return 'REVOKED' as const; + if (credential.expiresAt.getTime() <= now) return 'EXPIRED' as const; + if (credential.graceEndsAt && credential.graceEndsAt.getTime() > now) { + return 'ROTATING' as const; + } + if (credential.expiresAt.getTime() - now <= 7 * 24 * 60 * 60 * 1000) { + return 'EXPIRING' as const; + } + return 'ACTIVE' as const; + } +} diff --git a/packages/backend/server/src/plugins/copilot/mcp/provider.ts b/packages/backend/server/src/plugins/copilot/mcp/provider.ts index 16927cef43..7df9d899cb 100644 --- a/packages/backend/server/src/plugins/copilot/mcp/provider.ts +++ b/packages/backend/server/src/plugins/copilot/mcp/provider.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { McpAccessMode } from '@prisma/client'; import { pick } from 'lodash-es'; import z from 'zod/v3'; @@ -106,7 +107,11 @@ export class WorkspaceMcpProvider { private readonly indexer: IndexerService ) {} - async for(userId: string, workspaceId: string): Promise { + async for( + userId: string, + workspaceId: string, + accessMode: McpAccessMode = McpAccessMode.READ_ONLY + ): Promise { await this.ac.user(userId).workspace(workspaceId).assert('Workspace.Read'); const readDocument = defineTool({ @@ -249,7 +254,10 @@ export class WorkspaceMcpProvider { const tools = [readDocument, semanticSearch, keywordSearch]; - if (env.dev || env.namespaces.canary) { + if ( + accessMode === McpAccessMode.READ_WRITE && + (env.dev || env.namespaces.canary) + ) { const createDocument = defineTool({ name: 'create_document', title: 'Create Document', diff --git a/packages/backend/server/src/plugins/copilot/mcp/resolver.ts b/packages/backend/server/src/plugins/copilot/mcp/resolver.ts new file mode 100644 index 0000000000..37d6263a64 --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/mcp/resolver.ts @@ -0,0 +1,158 @@ +import { BadRequestException } from '@nestjs/common'; +import { + Args, + Field, + ID, + InputType, + Int, + Mutation, + ObjectType, + Query, + registerEnumType, + Resolver, +} from '@nestjs/graphql'; +import { McpAccessMode } from '@prisma/client'; + +import { CurrentUser } from '../../../core/auth'; +import { PermissionAccess } from '../../../core/permission'; +import { McpCredentialService } from './credential'; + +registerEnumType(McpAccessMode, { name: 'McpAccessMode' }); + +enum McpCredentialStatus { + ACTIVE = 'ACTIVE', + ROTATING = 'ROTATING', + EXPIRING = 'EXPIRING', + EXPIRED = 'EXPIRED', + REVOKED = 'REVOKED', +} + +registerEnumType(McpCredentialStatus, { name: 'McpCredentialStatus' }); + +@ObjectType() +export class McpCredentialType { + @Field(() => ID) + id!: string; + + @Field() + name!: string; + + @Field() + workspaceId!: string; + + @Field(() => McpAccessMode) + accessMode!: McpAccessMode; + + @Field() + fingerprint!: string; + + @Field(() => Date) + createdAt!: Date; + + @Field(() => Date) + expiresAt!: Date; + + @Field(() => Date, { nullable: true }) + lastUsedAt!: Date | null; + + @Field(() => Date, { nullable: true }) + revokedAt!: Date | null; + + @Field(() => Date, { nullable: true }) + graceEndsAt!: Date | null; + + @Field(() => McpCredentialStatus) + status!: McpCredentialStatus; +} + +@ObjectType() +class RevealedMcpCredentialType { + @Field(() => McpCredentialType) + credential!: McpCredentialType; + + @Field() + token!: string; +} + +@InputType() +class CreateMcpCredentialInput { + @Field() + workspaceId!: string; + + @Field() + name!: string; + + @Field(() => McpAccessMode, { defaultValue: McpAccessMode.READ_ONLY }) + accessMode!: McpAccessMode; + + @Field(() => Int, { defaultValue: 90 }) + expirationDays!: number; +} + +@Resolver() +export class McpCredentialResolver { + constructor( + private readonly credentials: McpCredentialService, + private readonly ac: PermissionAccess + ) {} + + @Query(() => [McpCredentialType]) + async mcpCredentials( + @CurrentUser() user: CurrentUser, + @Args('workspaceId') workspaceId: string + ) { + await this.ac.user(user.id).workspace(workspaceId).assert('Workspace.Read'); + return await this.credentials.list(user.id, workspaceId); + } + + @Query(() => Boolean) + mcpCredentialReadWriteAvailable() { + return env.dev || env.namespaces.canary; + } + + @Mutation(() => RevealedMcpCredentialType) + async createMcpCredential( + @CurrentUser() user: CurrentUser, + @Args('input') input: CreateMcpCredentialInput + ) { + if ( + input.accessMode === McpAccessMode.READ_WRITE && + !env.dev && + !env.namespaces.canary + ) { + throw new BadRequestException('MCP write tools are not available'); + } + await this.ac + .user(user.id) + .workspace(input.workspaceId) + .assert('Workspace.Read'); + return await this.credentials.create({ ...input, userId: user.id }); + } + + @Mutation(() => RevealedMcpCredentialType) + async rotateMcpCredential( + @CurrentUser() user: CurrentUser, + @Args('id', { type: () => ID }) id: string, + @Args('workspaceId') workspaceId: string, + @Args('expirationDays', { type: () => Int, defaultValue: 90 }) + expirationDays: number + ) { + await this.ac.user(user.id).workspace(workspaceId).assert('Workspace.Read'); + return await this.credentials.rotate( + id, + user.id, + workspaceId, + expirationDays + ); + } + + @Mutation(() => Boolean) + async revokeMcpCredential( + @CurrentUser() user: CurrentUser, + @Args('id', { type: () => ID }) id: string, + @Args('workspaceId') workspaceId: string + ) { + await this.ac.user(user.id).workspace(workspaceId).assert('Workspace.Read'); + return await this.credentials.revoke(id, user.id, workspaceId); + } +} diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 778bffc3c8..16e851d578 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -2,13 +2,6 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ -type AccessToken { - createdAt: DateTime! - expiresAt: DateTime - id: String! - name: String! -} - input AddContextBlobInput { blobId: String! contextId: String! @@ -810,6 +803,13 @@ input CreateCheckoutSessionInput { variant: SubscriptionVariant } +input CreateMcpCredentialInput { + accessMode: McpAccessMode! = READ_ONLY + expirationDays: Int! = 90 + name: String! + workspaceId: String! +} + input CreateUserInput { email: String! name: String @@ -1196,11 +1196,6 @@ input ForkChatSessionInput { workspaceId: String! } -input GenerateAccessTokenInput { - expiresAt: DateTime - name: String! -} - input GrantDocUserRolesInput { docId: String! role: DocRole! @@ -1533,6 +1528,33 @@ input ManageUserInput { name: String } +enum McpAccessMode { + READ_ONLY + READ_WRITE +} + +enum McpCredentialStatus { + ACTIVE + EXPIRED + EXPIRING + REVOKED + ROTATING +} + +type McpCredentialType { + accessMode: McpAccessMode! + createdAt: DateTime! + expiresAt: DateTime! + fingerprint: String! + graceEndsAt: DateTime + id: ID! + lastUsedAt: DateTime + name: String! + revokedAt: DateTime + status: McpCredentialStatus! + workspaceId: String! +} + type MeetingActionItemType { deadline: String description: String! @@ -1657,6 +1679,7 @@ type Mutation { """Create a stripe customer portal to manage payment methods""" createCustomerPortal: String! createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): InviteLink! + createMcpCredential(input: CreateMcpCredentialInput!): RevealedMcpCredentialType! createReply(input: ReplyCreateInput!): ReplyObjectType! createSelfhostWorkspaceCustomerPortal(workspaceId: String!): String! @@ -1688,7 +1711,6 @@ type Mutation { """Create a chat session""" forkCopilotSession(options: ForkChatSessionInput!): String! generateLicenseKey(sessionId: String!): String! - generateUserAccessToken(input: GenerateAccessTokenInput!): RevealedAccessToken! grantCommercialEntitlement(plan: String!, quantity: Int, targetId: String!, targetType: String!): Boolean! grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean! grantMember(permission: Permission!, userId: String!, workspaceId: String!): Boolean! @@ -1749,10 +1771,11 @@ type Mutation { revokeCommercialEntitlement(targetId: String!, targetType: String!): Boolean! revokeDocUserRoles(input: RevokeDocUserRoleInput!): Boolean! revokeInviteLink(workspaceId: String!): Boolean! + revokeMcpCredential(id: ID!, workspaceId: String!): Boolean! revokeMember(userId: String!, workspaceId: String!): Boolean! revokePublicDoc(docId: String!, workspaceId: String!): DocType! - revokeUserAccessToken(id: String!): Boolean! rotateAuthSigningKey(expectedActiveKeyId: String!): [AuthSigningKeyType!]! + rotateMcpCredential(expirationDays: Int! = 90, id: ID!, workspaceId: String!): RevealedMcpCredentialType! sendChangeEmail(callbackUrl: String!): Boolean! sendChangePasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean! sendSetPasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean! @@ -2031,6 +2054,8 @@ type Query { """get workspace invitation info""" getInviteInfo(inviteId: String!): InvitationType! + mcpCredentialReadWriteAvailable: Boolean! + mcpCredentials(workspaceId: String!): [McpCredentialType!]! prices: [SubscriptionPrice!]! """Get public user by id""" @@ -2038,7 +2063,6 @@ type Query { """query workspace embedding status""" queryWorkspaceEmbeddingStatus(workspaceId: String!): ContextWorkspaceEmbeddingStatus! @deprecated(reason: "Use realtime subscription \"workspace.embedding.progress.changed\" instead.") - revealedAccessTokens: [RevealedAccessToken!]! @deprecated(reason: "use currentUser.revealedAccessTokens") """server config""" serverConfig: ServerConfigType! @@ -2173,11 +2197,8 @@ type ResponseTooLargeErrorDataType { receivedBytes: Int! } -type RevealedAccessToken { - createdAt: DateTime! - expiresAt: DateTime - id: String! - name: String! +type RevealedMcpCredentialType { + credential: McpCredentialType! token: String! } @@ -2700,8 +2721,6 @@ type UserSettingsType { } type UserType { - accessTokens: [AccessToken!]! - """User avatar url""" avatarUrl: String calendarAccounts: [CalendarAccountObjectType!]! @@ -2737,7 +2756,6 @@ type UserType { notifications(pagination: PaginationInput!): PaginatedNotificationObjectType! quota: UserQuotaType! quotaUsage: UserQuotaUsageType! - revealedAccessTokens: [RevealedAccessToken!]! """Get user settings""" settings: UserSettingsType! diff --git a/packages/common/graphql/src/graphql/access-token/create.gql b/packages/common/graphql/src/graphql/access-token/create.gql deleted file mode 100644 index 804b879d78..0000000000 --- a/packages/common/graphql/src/graphql/access-token/create.gql +++ /dev/null @@ -1,9 +0,0 @@ -mutation generateUserAccessToken($input: GenerateAccessTokenInput!) { - generateUserAccessToken(input: $input) { - id - name - token - createdAt - expiresAt - } -} diff --git a/packages/common/graphql/src/graphql/access-token/revoke.gql b/packages/common/graphql/src/graphql/access-token/revoke.gql deleted file mode 100644 index 187c18f1f7..0000000000 --- a/packages/common/graphql/src/graphql/access-token/revoke.gql +++ /dev/null @@ -1,3 +0,0 @@ -mutation revokeUserAccessToken($id: String!) { - revokeUserAccessToken(id: $id) -} diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 3efee8dc26..2c408cd8f3 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -67,28 +67,6 @@ export const licenseBodyFragment = `fragment licenseBody on License { validatedAt variant }`; -export const generateUserAccessTokenMutation = { - id: 'generateUserAccessTokenMutation' as const, - op: 'generateUserAccessToken', - query: `mutation generateUserAccessToken($input: GenerateAccessTokenInput!) { - generateUserAccessToken(input: $input) { - id - name - token - createdAt - expiresAt - } -}`, -}; - -export const revokeUserAccessTokenMutation = { - id: 'revokeUserAccessTokenMutation' as const, - op: 'revokeUserAccessToken', - query: `mutation revokeUserAccessToken($id: String!) { - revokeUserAccessToken(id: $id) -}`, -}; - export const adminAllSharedLinksQuery = { id: 'adminAllSharedLinksQuery' as const, op: 'adminAllSharedLinks', @@ -2574,6 +2552,85 @@ export const listNotificationsQuery = { }`, }; +export const createMcpCredentialMutation = { + id: 'createMcpCredentialMutation' as const, + op: 'createMcpCredential', + query: `mutation createMcpCredential($input: CreateMcpCredentialInput!) { + createMcpCredential(input: $input) { + credential { + id + name + workspaceId + accessMode + fingerprint + createdAt + expiresAt + lastUsedAt + revokedAt + graceEndsAt + status + } + token + } +}`, +}; + +export const mcpCredentialsQuery = { + id: 'mcpCredentialsQuery' as const, + op: 'mcpCredentials', + query: `query mcpCredentials($workspaceId: String!) { + mcpCredentialReadWriteAvailable + mcpCredentials(workspaceId: $workspaceId) { + id + name + workspaceId + accessMode + fingerprint + createdAt + expiresAt + lastUsedAt + revokedAt + graceEndsAt + status + } +}`, +}; + +export const revokeMcpCredentialMutation = { + id: 'revokeMcpCredentialMutation' as const, + op: 'revokeMcpCredential', + query: `mutation revokeMcpCredential($id: ID!, $workspaceId: String!) { + revokeMcpCredential(id: $id, workspaceId: $workspaceId) +}`, +}; + +export const rotateMcpCredentialMutation = { + id: 'rotateMcpCredentialMutation' as const, + op: 'rotateMcpCredential', + query: `mutation rotateMcpCredential($id: ID!, $workspaceId: String!, $expirationDays: Int!) { + rotateMcpCredential( + id: $id + workspaceId: $workspaceId + expirationDays: $expirationDays + ) { + credential { + id + name + workspaceId + accessMode + fingerprint + createdAt + expiresAt + lastUsedAt + revokedAt + graceEndsAt + status + } + token + } +}`, +}; + export const mentionUserMutation = { id: 'mentionUserMutation' as const, op: 'mentionUser', diff --git a/packages/common/graphql/src/graphql/mcp-credentials/create.gql b/packages/common/graphql/src/graphql/mcp-credentials/create.gql new file mode 100644 index 0000000000..d7f24591c9 --- /dev/null +++ b/packages/common/graphql/src/graphql/mcp-credentials/create.gql @@ -0,0 +1,18 @@ +mutation createMcpCredential($input: CreateMcpCredentialInput!) { + createMcpCredential(input: $input) { + credential { + id + name + workspaceId + accessMode + fingerprint + createdAt + expiresAt + lastUsedAt + revokedAt + graceEndsAt + status + } + token + } +} diff --git a/packages/common/graphql/src/graphql/mcp-credentials/list.gql b/packages/common/graphql/src/graphql/mcp-credentials/list.gql new file mode 100644 index 0000000000..0d18922528 --- /dev/null +++ b/packages/common/graphql/src/graphql/mcp-credentials/list.gql @@ -0,0 +1,16 @@ +query mcpCredentials($workspaceId: String!) { + mcpCredentialReadWriteAvailable + mcpCredentials(workspaceId: $workspaceId) { + id + name + workspaceId + accessMode + fingerprint + createdAt + expiresAt + lastUsedAt + revokedAt + graceEndsAt + status + } +} diff --git a/packages/common/graphql/src/graphql/mcp-credentials/revoke.gql b/packages/common/graphql/src/graphql/mcp-credentials/revoke.gql new file mode 100644 index 0000000000..c3681bbbdf --- /dev/null +++ b/packages/common/graphql/src/graphql/mcp-credentials/revoke.gql @@ -0,0 +1,3 @@ +mutation revokeMcpCredential($id: ID!, $workspaceId: String!) { + revokeMcpCredential(id: $id, workspaceId: $workspaceId) +} diff --git a/packages/common/graphql/src/graphql/mcp-credentials/rotate.gql b/packages/common/graphql/src/graphql/mcp-credentials/rotate.gql new file mode 100644 index 0000000000..2260c2e161 --- /dev/null +++ b/packages/common/graphql/src/graphql/mcp-credentials/rotate.gql @@ -0,0 +1,26 @@ +mutation rotateMcpCredential( + $id: ID! + $workspaceId: String! + $expirationDays: Int! +) { + rotateMcpCredential( + id: $id + workspaceId: $workspaceId + expirationDays: $expirationDays + ) { + credential { + id + name + workspaceId + accessMode + fingerprint + createdAt + expiresAt + lastUsedAt + revokedAt + graceEndsAt + status + } + token + } +} diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index da4c5027d0..0e35efa3af 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -37,14 +37,6 @@ export interface Scalars { Upload: { input: File; output: File }; } -export interface AccessToken { - __typename?: 'AccessToken'; - createdAt: Scalars['DateTime']['output']; - expiresAt: Maybe; - id: Scalars['String']['output']; - name: Scalars['String']['output']; -} - export interface AddContextBlobInput { blobId: Scalars['String']['input']; contextId: Scalars['String']['input']; @@ -954,6 +946,13 @@ export interface CreateCheckoutSessionInput { variant?: InputMaybe; } +export interface CreateMcpCredentialInput { + accessMode?: McpAccessMode; + expirationDays?: Scalars['Int']['input']; + name: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + export interface CreateUserInput { email: Scalars['String']['input']; name?: InputMaybe; @@ -1424,11 +1423,6 @@ export interface ForkChatSessionInput { workspaceId: Scalars['String']['input']; } -export interface GenerateAccessTokenInput { - expiresAt?: InputMaybe; - name: Scalars['String']['input']; -} - export interface GrantDocUserRolesInput { docId: Scalars['String']['input']; role: DocRole; @@ -1754,6 +1748,34 @@ export interface ManageUserInput { name?: InputMaybe; } +export enum McpAccessMode { + READ_ONLY = 'READ_ONLY', + READ_WRITE = 'READ_WRITE', +} + +export enum McpCredentialStatus { + ACTIVE = 'ACTIVE', + EXPIRED = 'EXPIRED', + EXPIRING = 'EXPIRING', + REVOKED = 'REVOKED', + ROTATING = 'ROTATING', +} + +export interface McpCredentialType { + __typename?: 'McpCredentialType'; + accessMode: McpAccessMode; + createdAt: Scalars['DateTime']['output']; + expiresAt: Scalars['DateTime']['output']; + fingerprint: Scalars['String']['output']; + graceEndsAt: Maybe; + id: Scalars['ID']['output']; + lastUsedAt: Maybe; + name: Scalars['String']['output']; + revokedAt: Maybe; + status: McpCredentialStatus; + workspaceId: Scalars['String']['output']; +} + export interface MeetingActionItemType { __typename?: 'MeetingActionItemType'; deadline: Maybe; @@ -1870,6 +1892,7 @@ export interface Mutation { /** Create a stripe customer portal to manage payment methods */ createCustomerPortal: Scalars['String']['output']; createInviteLink: InviteLink; + createMcpCredential: RevealedMcpCredentialType; createReply: ReplyObjectType; createSelfhostWorkspaceCustomerPortal: Scalars['String']['output']; /** Create a new user */ @@ -1894,7 +1917,6 @@ export interface Mutation { /** Create a chat session */ forkCopilotSession: Scalars['String']['output']; generateLicenseKey: Scalars['String']['output']; - generateUserAccessToken: RevealedAccessToken; grantCommercialEntitlement: Scalars['Boolean']['output']; grantDocUserRoles: Scalars['Boolean']['output']; grantMember: Scalars['Boolean']['output']; @@ -1941,10 +1963,11 @@ export interface Mutation { revokeCommercialEntitlement: Scalars['Boolean']['output']; revokeDocUserRoles: Scalars['Boolean']['output']; revokeInviteLink: Scalars['Boolean']['output']; + revokeMcpCredential: Scalars['Boolean']['output']; revokeMember: Scalars['Boolean']['output']; revokePublicDoc: DocType; - revokeUserAccessToken: Scalars['Boolean']['output']; rotateAuthSigningKey: Array; + rotateMcpCredential: RevealedMcpCredentialType; sendChangeEmail: Scalars['Boolean']['output']; sendChangePasswordEmail: Scalars['Boolean']['output']; sendSetPasswordEmail: Scalars['Boolean']['output']; @@ -2115,6 +2138,10 @@ export interface MutationCreateInviteLinkArgs { workspaceId: Scalars['String']['input']; } +export interface MutationCreateMcpCredentialArgs { + input: CreateMcpCredentialInput; +} + export interface MutationCreateReplyArgs { input: ReplyCreateInput; } @@ -2183,10 +2210,6 @@ export interface MutationGenerateLicenseKeyArgs { sessionId: Scalars['String']['input']; } -export interface MutationGenerateUserAccessTokenArgs { - input: GenerateAccessTokenInput; -} - export interface MutationGrantCommercialEntitlementArgs { plan: Scalars['String']['input']; quantity?: InputMaybe; @@ -2322,6 +2345,11 @@ export interface MutationRevokeInviteLinkArgs { workspaceId: Scalars['String']['input']; } +export interface MutationRevokeMcpCredentialArgs { + id: Scalars['ID']['input']; + workspaceId: Scalars['String']['input']; +} + export interface MutationRevokeMemberArgs { userId: Scalars['String']['input']; workspaceId: Scalars['String']['input']; @@ -2332,14 +2360,16 @@ export interface MutationRevokePublicDocArgs { workspaceId: Scalars['String']['input']; } -export interface MutationRevokeUserAccessTokenArgs { - id: Scalars['String']['input']; -} - export interface MutationRotateAuthSigningKeyArgs { expectedActiveKeyId: Scalars['String']['input']; } +export interface MutationRotateMcpCredentialArgs { + expirationDays?: Scalars['Int']['input']; + id: Scalars['ID']['input']; + workspaceId: Scalars['String']['input']; +} + export interface MutationSendChangeEmailArgs { callbackUrl: Scalars['String']['input']; } @@ -2706,6 +2736,8 @@ export interface Query { error: ErrorDataUnion; /** get workspace invitation info */ getInviteInfo: InvitationType; + mcpCredentialReadWriteAvailable: Scalars['Boolean']['output']; + mcpCredentials: Array; prices: Array; /** Get public user by id */ publicUserById: Maybe; @@ -2714,8 +2746,6 @@ export interface Query { * @deprecated Use realtime subscription "workspace.embedding.progress.changed" instead. */ queryWorkspaceEmbeddingStatus: ContextWorkspaceEmbeddingStatus; - /** @deprecated use currentUser.revealedAccessTokens */ - revealedAccessTokens: Array; /** server config */ serverConfig: ServerConfigType; /** Get user by email */ @@ -2774,6 +2804,10 @@ export interface QueryGetInviteInfoArgs { inviteId: Scalars['String']['input']; } +export interface QueryMcpCredentialsArgs { + workspaceId: Scalars['String']['input']; +} + export interface QueryPublicUserByIdArgs { id: Scalars['String']['input']; } @@ -2914,12 +2948,9 @@ export interface ResponseTooLargeErrorDataType { receivedBytes: Scalars['Int']['output']; } -export interface RevealedAccessToken { - __typename?: 'RevealedAccessToken'; - createdAt: Scalars['DateTime']['output']; - expiresAt: Maybe; - id: Scalars['String']['output']; - name: Scalars['String']['output']; +export interface RevealedMcpCredentialType { + __typename?: 'RevealedMcpCredentialType'; + credential: McpCredentialType; token: Scalars['String']['output']; } @@ -3450,7 +3481,6 @@ export interface UserSettingsType { export interface UserType { __typename?: 'UserType'; - accessTokens: Array; /** User avatar url */ avatarUrl: Maybe; calendarAccounts: Array; @@ -3480,7 +3510,6 @@ export interface UserType { notifications: PaginatedNotificationObjectType; quota: UserQuotaType; quotaUsage: UserQuotaUsageType; - revealedAccessTokens: Array; /** Get user settings */ settings: UserSettingsType; subscriptions: Array; @@ -3842,31 +3871,6 @@ export interface TokenType { token: Scalars['String']['output']; } -export type GenerateUserAccessTokenMutationVariables = Exact<{ - input: GenerateAccessTokenInput; -}>; - -export type GenerateUserAccessTokenMutation = { - __typename?: 'Mutation'; - generateUserAccessToken: { - __typename?: 'RevealedAccessToken'; - id: string; - name: string; - token: string; - createdAt: string; - expiresAt: string | null; - }; -}; - -export type RevokeUserAccessTokenMutationVariables = Exact<{ - id: Scalars['String']['input']; -}>; - -export type RevokeUserAccessTokenMutation = { - __typename?: 'Mutation'; - revokeUserAccessToken: boolean; -}; - export type AdminAllSharedLinksQueryVariables = Exact<{ pagination: PaginationInput; filter?: InputMaybe; @@ -7227,6 +7231,93 @@ export type ListNotificationsQuery = { } | null; }; +export type CreateMcpCredentialMutationVariables = Exact<{ + input: CreateMcpCredentialInput; +}>; + +export type CreateMcpCredentialMutation = { + __typename?: 'Mutation'; + createMcpCredential: { + __typename?: 'RevealedMcpCredentialType'; + token: string; + credential: { + __typename?: 'McpCredentialType'; + id: string; + name: string; + workspaceId: string; + accessMode: McpAccessMode; + fingerprint: string; + createdAt: string; + expiresAt: string; + lastUsedAt: string | null; + revokedAt: string | null; + graceEndsAt: string | null; + status: McpCredentialStatus; + }; + }; +}; + +export type McpCredentialsQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + +export type McpCredentialsQuery = { + __typename?: 'Query'; + mcpCredentialReadWriteAvailable: boolean; + mcpCredentials: Array<{ + __typename?: 'McpCredentialType'; + id: string; + name: string; + workspaceId: string; + accessMode: McpAccessMode; + fingerprint: string; + createdAt: string; + expiresAt: string; + lastUsedAt: string | null; + revokedAt: string | null; + graceEndsAt: string | null; + status: McpCredentialStatus; + }>; +}; + +export type RevokeMcpCredentialMutationVariables = Exact<{ + id: Scalars['ID']['input']; + workspaceId: Scalars['String']['input']; +}>; + +export type RevokeMcpCredentialMutation = { + __typename?: 'Mutation'; + revokeMcpCredential: boolean; +}; + +export type RotateMcpCredentialMutationVariables = Exact<{ + id: Scalars['ID']['input']; + workspaceId: Scalars['String']['input']; + expirationDays: Scalars['Int']['input']; +}>; + +export type RotateMcpCredentialMutation = { + __typename?: 'Mutation'; + rotateMcpCredential: { + __typename?: 'RevealedMcpCredentialType'; + token: string; + credential: { + __typename?: 'McpCredentialType'; + id: string; + name: string; + workspaceId: string; + accessMode: McpAccessMode; + fingerprint: string; + createdAt: string; + expiresAt: string; + lastUsedAt: string | null; + revokedAt: string | null; + graceEndsAt: string | null; + status: McpCredentialStatus; + }; + }; +}; + export type MentionUserMutationVariables = Exact<{ input: MentionInput; }>; @@ -8262,6 +8353,11 @@ export type Queries = variables: ListNotificationsQueryVariables; response: ListNotificationsQuery; } + | { + name: 'mcpCredentialsQuery'; + variables: McpCredentialsQueryVariables; + response: McpCredentialsQuery; + } | { name: 'pricesQuery'; variables: PricesQueryVariables; @@ -8304,16 +8400,6 @@ export type Queries = }; export type Mutations = - | { - name: 'generateUserAccessTokenMutation'; - variables: GenerateUserAccessTokenMutationVariables; - response: GenerateUserAccessTokenMutation; - } - | { - name: 'revokeUserAccessTokenMutation'; - variables: RevokeUserAccessTokenMutationVariables; - response: RevokeUserAccessTokenMutation; - } | { name: 'adminUpdateWorkspaceMutation'; variables: AdminUpdateWorkspaceMutationVariables; @@ -8669,6 +8755,21 @@ export type Mutations = variables: PreviewLicenseMutationVariables; response: PreviewLicenseMutation; } + | { + name: 'createMcpCredentialMutation'; + variables: CreateMcpCredentialMutationVariables; + response: CreateMcpCredentialMutation; + } + | { + name: 'revokeMcpCredentialMutation'; + variables: RevokeMcpCredentialMutationVariables; + response: RevokeMcpCredentialMutation; + } + | { + name: 'rotateMcpCredentialMutation'; + variables: RotateMcpCredentialMutationVariables; + response: RotateMcpCredentialMutation; + } | { name: 'mentionUserMutation'; variables: MentionUserMutationVariables; diff --git a/packages/common/realtime/src/index.ts b/packages/common/realtime/src/index.ts index 4a99e705af..333140f18c 100644 --- a/packages/common/realtime/src/index.ts +++ b/packages/common/realtime/src/index.ts @@ -41,10 +41,6 @@ export interface RealtimeRequestMap { input: Record; output: { settings: UserSettingsSnapshot }; }; - 'user.access-tokens.get': { - input: Record; - output: { tokens: AccessTokenSnapshot[] }; - }; 'notification.count.get': { input: Record; output: { count: number }; @@ -180,13 +176,6 @@ export interface UserSettingsSnapshot { receiveCommentEmail: boolean; } -export interface AccessTokenSnapshot { - id: string; - name: string; - createdAt: string; - expiresAt: string | null; -} - export type CommentChangeActionSnapshot = 'update' | 'delete'; export interface CommentChangeSnapshot { @@ -285,10 +274,6 @@ export interface RealtimeTopicMap { input: Record; event: { changed: true; reason: string }; }; - 'user.access-tokens.changed': { - input: Record; - event: { changed: true; reason: string }; - }; 'notification.count.changed': { input: Record; event: { diff --git a/packages/frontend/apps/electron/package.json b/packages/frontend/apps/electron/package.json index ac0bfabf27..e700d5c0bf 100644 --- a/packages/frontend/apps/electron/package.json +++ b/packages/frontend/apps/electron/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@affine-tools/utils": "workspace:*", + "@affine/auth": "workspace:*", "@affine/i18n": "workspace:*", "@affine/native": "workspace:*", "@affine/nbstore": "workspace:*", @@ -75,7 +76,6 @@ "zod": "^3.25.76" }, "dependencies": { - "@affine/auth": "workspace:*", "async-call-rpc": "^6.4.2", "electron-updater": "^6.8.3", "link-preview-js": "^4.0.0", diff --git a/packages/frontend/apps/electron/scripts/build-layers.ts b/packages/frontend/apps/electron/scripts/build-layers.ts index 0d76f6c1f8..e5295ea668 100644 --- a/packages/frontend/apps/electron/scripts/build-layers.ts +++ b/packages/frontend/apps/electron/scripts/build-layers.ts @@ -3,9 +3,27 @@ import path from 'node:path'; import * as esbuild from 'esbuild'; -import { config, mode, rootDir } from './common'; +import { config, electronDir, mode, rootDir } from './common'; + +async function assertNoRuntimeWorkspaceDependencies() { + const packageJson = JSON.parse( + await fs.readFile(path.resolve(electronDir, 'package.json'), 'utf8') + ) as { dependencies?: Record }; + const workspaceDependencies = Object.entries( + packageJson.dependencies ?? {} + ).filter(([, version]) => version.startsWith('workspace:')); + + if (workspaceDependencies.length) { + throw new Error( + `Electron workspace dependencies must be bundled and declared as devDependencies: ${workspaceDependencies + .map(([name]) => name) + .join(', ')}` + ); + } +} async function buildLayers() { + await assertNoRuntimeWorkspaceDependencies(); const common = config(); const define: Record = { diff --git a/packages/frontend/apps/electron/tsconfig.json b/packages/frontend/apps/electron/tsconfig.json index a1c3d7d801..0026f9410b 100644 --- a/packages/frontend/apps/electron/tsconfig.json +++ b/packages/frontend/apps/electron/tsconfig.json @@ -7,8 +7,8 @@ }, "include": ["./src"], "references": [ - { "path": "../../../common/auth" }, { "path": "../../../../tools/utils" }, + { "path": "../../../common/auth" }, { "path": "../../i18n" }, { "path": "../../native" }, { "path": "../../../common/nbstore" }, diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/credential-modal.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/credential-modal.tsx new file mode 100644 index 0000000000..883e4091bd --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/credential-modal.tsx @@ -0,0 +1,209 @@ +import { Button, Input, Modal, notify } from '@affine/component'; +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import type { McpCredential } from '@affine/core/modules/cloud/services/mcp-credential'; +import { McpAccessMode } from '@affine/graphql'; +import { useI18n } from '@affine/i18n'; +import { useEffect, useState } from 'react'; + +import * as styles from './setting-panel.css'; + +type RevealedCredential = { + credential: McpCredential; + token: string; +}; + +export const McpCredentialModal = ({ + mode, + revealed, + config, + workspaceName, + readWriteAvailable, + onCreate, + onClose, +}: { + mode: 'create' | 'reveal' | null; + revealed: RevealedCredential | null; + config: string; + workspaceName?: string; + readWriteAvailable: boolean; + onCreate: ( + name: string, + accessMode: McpAccessMode, + expirationDays: number + ) => void | Promise; + onClose: () => void; +}) => { + const t = useI18n(); + const [name, setName] = useState(''); + const [expirationDays, setExpirationDays] = useState(90); + const [accessMode, setAccessMode] = useState(McpAccessMode.READ_ONLY); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!mode) { + setName(''); + setExpirationDays(90); + setAccessMode(McpAccessMode.READ_ONLY); + setSubmitting(false); + } + }, [mode]); + + const copy = useAsyncCallback( + async (value: string) => { + await navigator.clipboard.writeText(value); + notify.success({ title: t['Copied to clipboard']() }); + }, + [t] + ); + + const submit = useAsyncCallback(async () => { + setSubmitting(true); + try { + await onCreate(name.trim(), accessMode, expirationDays); + } finally { + setSubmitting(false); + } + }, [accessMode, expirationDays, name, onCreate]); + + return ( + { + if (!open) onClose(); + }} + contentOptions={{ className: styles.modal }} + > + {mode === 'create' ? ( + <> +
+ {t['com.affine.integration.mcp-server.create.title']()} +
+
+ {t['com.affine.integration.mcp-server.create.description']()} +
+
+ + + +
+
+ + +
+ + ) : revealed ? ( + <> +
+ {t['com.affine.integration.mcp-server.reveal.title']()} +
+
+ {t['com.affine.integration.mcp-server.reveal.warning']()} +
+
+ {revealed.credential.name} · {workspaceName} ·{' '} + {revealed.credential.accessMode === McpAccessMode.READ_WRITE + ? t['com.affine.integration.mcp-server.access.read-write']() + : t['com.affine.integration.mcp-server.access.read-only']()}{' '} + · {new Date(revealed.credential.expiresAt).toLocaleString()} +
+ {revealed.credential.graceEndsAt ? ( +
+ {t['com.affine.integration.mcp-server.reveal.old-valid-until']({ + date: new Date( + revealed.credential.graceEndsAt + ).toLocaleString(), + })} +
+ ) : null} +
+ {t['com.affine.integration.mcp-server.reveal.token']()} + +
+
{revealed.token}
+
+ + {t['com.affine.integration.mcp-server.reveal.config']()} + + +
+
{config}
+
+ +
+ + ) : null} +
+ ); +}; 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 index 1cea68d35d..5386f051e8 100644 --- 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 @@ -2,47 +2,172 @@ 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({ +export const stack = style({ display: 'flex', flexDirection: 'column', - border: `1px solid ${cssVar('borderColor')}`, - borderRadius: '8px', - padding: '8px 16px', - gap: '0px', - marginBottom: '16px', + gap: 24, }); - -export const sectionHeader = style({ +export const panel = style({ + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + borderRadius: 8, + overflow: 'hidden', + background: cssVarV2('layer/background/primary'), +}); +export const panelHeader = style({ display: 'flex', - flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - gap: '8px', + gap: 12, + padding: '12px 16px', + borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`, }); - -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, +export const title = style({ + fontSize: cssVar('fontSm'), + fontWeight: 600, color: cssVarV2('text/primary'), }); +export const description = style({ + fontSize: cssVar('fontXs'), + lineHeight: '20px', + color: cssVarV2('text/secondary'), +}); +export const empty = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 8, + padding: '28px 20px', + textAlign: 'center', +}); +export const skeletons = style({ + display: 'flex', + flexDirection: 'column', + gap: 12, + padding: 16, +}); +export const rows = style({ display: 'flex', flexDirection: 'column' }); +export const row = style({ + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + alignItems: 'center', + gap: 12, + padding: '12px 16px', + borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + selectors: { '&:last-child': { borderBottom: 0 } }, +}); +export const rowDisabled = style({ + opacity: 0.55, + background: cssVarV2('layer/background/secondary'), +}); +export const rowMain = style({ + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: 4, +}); +export const rowTitle = style({ + display: 'flex', + alignItems: 'center', + gap: 8, + fontSize: cssVar('fontSm'), + fontWeight: 600, + color: cssVarV2('text/primary'), +}); +export const tag = style({ + borderRadius: 999, + padding: '2px 8px', + fontSize: 11, + lineHeight: '16px', + fontWeight: 400, + color: cssVarV2('text/secondary'), + background: cssVarV2('layer/background/secondary'), +}); +export const rowActions = style({ display: 'flex', gap: 8 }); +export const capabilities = style({ + display: 'grid', + gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', +}); +export const capability = style({ + padding: '14px 16px', + fontSize: cssVar('fontXs'), + color: cssVarV2('text/secondary'), + borderRight: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + selectors: { '&:last-child': { borderRight: 0 } }, +}); +export const modal = style({ + width: 500, + maxWidth: 'calc(100vw - 32px)', + display: 'flex', + flexDirection: 'column', + gap: 16, + padding: 20, +}); +export const modalTitle = style({ + fontSize: 18, + fontWeight: 600, + color: cssVarV2('text/primary'), +}); +export const form = style({ + display: 'flex', + flexDirection: 'column', + gap: 16, +}); +export const field = style({ + display: 'flex', + flexDirection: 'column', + gap: 6, + fontSize: cssVar('fontXs'), + color: cssVarV2('text/secondary'), +}); +export const fixedValue = style({ + display: 'flex', + flexDirection: 'column', + gap: 2, + padding: '8px 10px', + borderRadius: 8, + background: cssVarV2('layer/background/secondary'), + color: cssVarV2('text/primary'), +}); +export const select = style({ + height: 32, + borderRadius: 8, + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + padding: '0 10px', + background: cssVarV2('layer/background/primary'), + color: cssVarV2('text/primary'), +}); +export const warning = style({ + padding: 12, + borderRadius: 8, + background: cssVarV2('layer/background/secondary'), + color: cssVarV2('text/primary'), + fontSize: cssVar('fontXs'), +}); +export const summary = style({ + fontSize: cssVar('fontXs'), + color: cssVarV2('text/secondary'), +}); +export const codeHeader = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + fontSize: cssVar('fontSm'), + fontWeight: 600, +}); +export const preArea = style({ + maxHeight: 180, + overflow: 'auto', + margin: 0, + padding: 12, + borderRadius: 8, + background: cssVarV2('layer/background/secondary'), + fontFamily: cssVar('fontMonoFamily'), + fontSize: cssVar('fontXs'), + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', +}); +export const modalActions = style({ + display: 'flex', + justifyContent: 'flex-end', + gap: 8, +}); 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 index 1a7e2b7197..3a560b1306 100644 --- 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 @@ -1,244 +1,328 @@ -import { Button, ErrorMessage, notify, Skeleton } from '@affine/component'; +import { + Button, + ErrorMessage, + notify, + Skeleton, + useConfirmModal, +} from '@affine/component'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; -import { AccessTokenService, ServerService } from '@affine/core/modules/cloud'; -import type { AccessToken } from '@affine/core/modules/cloud/stores/access-token'; +import { + McpCredentialService, + ServerService, +} from '@affine/core/modules/cloud'; +import type { McpCredential } from '@affine/core/modules/cloud/services/mcp-credential'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { UserFriendlyError } from '@affine/error'; +import { McpAccessMode } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; -import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { IntegrationSettingHeader } from '../setting'; +import { McpCredentialModal } from './credential-modal'; import MCPIcon from './MCP.inline.svg'; import * as styles from './setting-panel.css'; +type RevealedCredential = { + credential: McpCredential; + token: string; +}; + +const formatDate = (value: string) => new Date(value).toLocaleString(); + 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 credentialsService = useService(McpCredentialService); + const credentials = useLiveData(credentialsService.credentials$); + const loading = useLiveData(credentialsService.loading$); + const error = useLiveData(credentialsService.error$); + const readWriteAvailable = useLiveData( + credentialsService.readWriteAvailable$ + ); + const workspaceId = workspaceService.workspace.id; 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 [revealedAccessToken, setRevealedAccessToken] = - useState(null); - const t = useI18n(); + const { openConfirmModal } = useConfirmModal(); + const [modal, setModal] = useState<'create' | 'reveal' | null>(null); + const [revealed, setRevealed] = useState(null); + const [mutatingId, setMutatingId] = useState(null); - const mcpAccessToken = useMemo(() => { - return accessTokens?.find(token => token.name === 'mcp'); - }, [accessTokens]); + const statusLabel = useCallback( + (status: McpCredential['status']) => { + switch (status) { + case 'ACTIVE': + return t['com.affine.integration.mcp-server.status.active'](); + case 'ROTATING': + return t['com.affine.integration.mcp-server.status.rotating'](); + case 'EXPIRING': + return t['com.affine.integration.mcp-server.status.expiring'](); + case 'EXPIRED': + return t['com.affine.integration.mcp-server.status.expired'](); + case 'REVOKED': + return t['com.affine.integration.mcp-server.status.revoked'](); + default: + return status; + } + }, + [t] + ); - const hasMcpToken = Boolean(revealedAccessToken || mcpAccessToken); - const hasCopyableToken = Boolean(revealedAccessToken); - const isRedactedDisplay = hasMcpToken && !hasCopyableToken; + const revalidate = useCallback(() => { + // oxlint-disable-next-line @typescript-eslint/no-floating-promises + credentialsService.revalidate(workspaceId); + }, [credentialsService, workspaceId]); - const code = useMemo(() => { - return revealedAccessToken - ? JSON.stringify( - { - mcpServers: { - [`affine_workspace_${workspaceService.workspace.id}`]: { - type: 'streamable-http', - url: `${serverService.server.baseUrl}/api/workspaces/${workspaceService.workspace.id}/mcp`, - note: `Read docs from AFFiNE workspace "${workspaceName}"`, - headers: { - Authorization: `Bearer ${revealedAccessToken.token}`, - }, - }, - }, + useEffect(() => revalidate(), [revalidate]); + + const config = useMemo(() => { + if (!revealed) return ''; + return JSON.stringify( + { + mcpServers: { + [`affine_workspace_${workspaceId}`]: { + type: 'streamable-http', + url: `${serverService.server.baseUrl}/api/workspaces/${workspaceId}/mcp`, + headers: { Authorization: `Bearer ${revealed.token}` }, }, - null, - 2 - ) - : null; - }, [revealedAccessToken, workspaceName, workspaceService, serverService]); - - const copyJsonDisabled = !code || mutating || isRedactedDisplay; - const copyJsonTooltip = isRedactedDisplay - ? t['com.affine.integration.mcp-server.copy-json.disabled-hint']() - : undefined; - - 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); - } - const createdToken = - await accessTokenService.generateUserAccessToken('mcp'); - setRevealedAccessToken(createdToken); - } 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); - } - setRevealedAccessToken(null); - } catch (err) { - notify.error({ - error: UserFriendlyError.fromAny(err), - }); - } finally { - setMutating(false); - } - }, [accessTokenService, mcpAccessToken]); - - if (showLoading) { - return ( -
- - -
+ }, + }, + null, + 2 ); - } + }, [revealed, serverService.server.baseUrl, workspaceId]); - if (showError) { - return ( -
- - {error} -
- ); - } + const create = useAsyncCallback( + async (name: string, accessMode: McpAccessMode, expirationDays: number) => { + try { + const result = await credentialsService.create({ + workspaceId, + name, + accessMode, + expirationDays, + }); + setRevealed(result); + setModal('reveal'); + } catch (error) { + notify.error({ error: UserFriendlyError.fromAny(error) }); + } + }, + [credentialsService, workspaceId] + ); + + const rotate = useAsyncCallback( + async (credential: McpCredential) => { + setMutatingId(credential.id); + try { + const result = await credentialsService.rotate( + credential.id, + workspaceId, + 90 + ); + setRevealed(result); + setModal('reveal'); + } catch (error) { + notify.error({ error: UserFriendlyError.fromAny(error) }); + } finally { + setMutatingId(null); + } + }, + [credentialsService, workspaceId] + ); + + const confirmRotate = useCallback( + (credential: McpCredential) => { + openConfirmModal({ + title: t['com.affine.integration.mcp-server.rotate.title'](), + description: + t['com.affine.integration.mcp-server.rotate.description'](), + confirmText: t['com.affine.integration.mcp-server.action.rotate'](), + cancelText: t['Cancel'](), + onConfirm: () => rotate(credential), + }); + }, + [openConfirmModal, rotate, t] + ); + + const confirmRevoke = useCallback( + (credential: McpCredential) => { + openConfirmModal({ + title: t['com.affine.integration.mcp-server.revoke.title']({ + name: credential.name, + }), + description: + t['com.affine.integration.mcp-server.revoke.description'](), + confirmText: t['com.affine.integration.mcp-server.action.revoke'](), + cancelText: t['Cancel'](), + confirmButtonOptions: { variant: 'error' }, + onConfirm: async () => { + setMutatingId(credential.id); + try { + await credentialsService.revoke(credential.id, workspaceId); + } catch (error) { + notify.error({ error: UserFriendlyError.fromAny(error) }); + } finally { + setMutatingId(null); + } + }, + }); + }, + [credentialsService, openConfirmModal, t, workspaceId] + ); return ( -
- +
+ } + name={t['com.affine.integration.mcp-server.name']()} + desc={t['com.affine.integration.mcp-server.desc']()} + /> -
-
-
Personal access token
- {!hasMcpToken ? ( - - ) : ( - - )} -
-

- 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}
+ + {loading && credentials === null ? ( +
+ + +
+ ) : error && credentials === null ? ( +
+ + {t['com.affine.integration.mcp-server.load-error']()} + + +
+ ) : credentials?.length ? ( +
+ {credentials.map(credential => ( +
+
+
+ {credential.name} + + {statusLabel(credential.status)} + +
+
+ {credential.accessMode === McpAccessMode.READ_WRITE + ? t[ + 'com.affine.integration.mcp-server.access.read-write' + ]() + : t[ + 'com.affine.integration.mcp-server.access.read-only' + ]()}{' '} + · •••• {credential.fingerprint} ·{' '} + {t['com.affine.integration.mcp-server.meta.expires']({ + date: formatDate(credential.expiresAt), + })} +
+
+ {t['com.affine.integration.mcp-server.meta.created']({ + date: formatDate(credential.createdAt), + })}{' '} + ·{' '} + {credential.lastUsedAt + ? t['com.affine.integration.mcp-server.meta.last-used']({ + date: formatDate(credential.lastUsedAt), + }) + : t[ + 'com.affine.integration.mcp-server.meta.never-used' + ]()} + {credential.graceEndsAt + ? ` · ${t[ + 'com.affine.integration.mcp-server.meta.grace-until' + ]({ date: formatDate(credential.graceEndsAt) })}` + : null} +
+
+
+ {credential.status !== 'REVOKED' && + credential.status !== 'EXPIRED' ? ( + <> + + + + ) : null} +
+
+ ))} +
) : ( -

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

+
+
+ {t['com.affine.integration.mcp-server.empty.title']()} +
+
+ {t['com.affine.integration.mcp-server.empty.description']()} +
+
)} -
+ -
-
-
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. +
+
+
+ {t['com.affine.integration.mcp-server.capabilities.title']()}
- -
-
-
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). -
+
+ {(['read', 'keyword-search', 'semantic-search'] as const).map(key => ( +
+ {t[`com.affine.integration.mcp-server.capabilities.${key}`]()} +
+ ))} + {readWriteAvailable ? ( +
+ {t['com.affine.integration.mcp-server.capabilities.write']()} +
+ ) : null}
+
-
-
-
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. -
-
-
+ { + setModal(null); + setRevealed(null); + }} + />
); }; diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index 87dd42420a..a075da923f 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -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) diff --git a/packages/frontend/core/src/modules/cloud/services/access-token.spec.ts b/packages/frontend/core/src/modules/cloud/services/access-token.spec.ts deleted file mode 100644 index 6ba6521c86..0000000000 --- a/packages/frontend/core/src/modules/cloud/services/access-token.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/frontend/core/src/modules/cloud/services/access-token.ts b/packages/frontend/core/src/modules/cloud/services/access-token.ts deleted file mode 100644 index ed41c44b9c..0000000000 --- a/packages/frontend/core/src/modules/cloud/services/access-token.ts +++ /dev/null @@ -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(null); - isRevalidating$ = new LiveData(false); - error$ = new LiveData(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 { - 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; - } - } -} diff --git a/packages/frontend/core/src/modules/cloud/services/mcp-credential.ts b/packages/frontend/core/src/modules/cloud/services/mcp-credential.ts new file mode 100644 index 0000000000..6376821ad0 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/mcp-credential.ts @@ -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(null); + readWriteAvailable$ = new LiveData(false); + loading$ = new LiveData(false); + error$ = new LiveData(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); + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/access-token.ts b/packages/frontend/core/src/modules/cloud/stores/access-token.ts deleted file mode 100644 index 4278f28759..0000000000 --- a/packages/frontend/core/src/modules/cloud/stores/access-token.ts +++ /dev/null @@ -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 { - 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; - } -} diff --git a/packages/frontend/core/src/modules/cloud/stores/mcp-credential.ts b/packages/frontend/core/src/modules/cloud/stores/mcp-credential.ts new file mode 100644 index 0000000000..cf792c45a4 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/mcp-credential.ts @@ -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; + } +} diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index d455b4a905..a03223d906 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,28 +1,28 @@ { - "ar": 94, - "ca": 91, + "ar": 92, + "ca": 89, "da": 4, - "de": 100, - "el-GR": 90, + "de": 98, + "el-GR": 88, "en": 100, - "es-AR": 90, - "es-CL": 91, - "es": 90, - "fa": 90, - "fr": 94, + "es-AR": 88, + "es-CL": 89, + "es": 88, + "fa": 88, + "fr": 92, "hi": 1, - "it": 91, - "ja": 90, - "kk": 98, - "ko": 91, - "nb-NO": 45, - "pl": 91, - "pt-BR": 90, - "ru": 92, - "sv-SE": 90, - "tr": 98, - "uk": 90, - "ur": 98, + "it": 89, + "ja": 88, + "kk": 96, + "ko": 89, + "nb-NO": 44, + "pl": 89, + "pt-BR": 88, + "ru": 90, + "sv-SE": 88, + "tr": 96, + "uk": 88, + "ur": 96, "zh-Hans": 100, - "zh-Hant": 92 + "zh-Hant": 90 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 68d930043c..05a716999a 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8882,6 +8882,196 @@ export function useAFFiNEI18N(): { * `The MCP token is shown only once. Delete and recreate it to copy the JSON configuration.` */ ["com.affine.integration.mcp-server.copy-json.disabled-hint"](): string; + /** + * `Credentials` + */ + ["com.affine.integration.mcp-server.credentials.title"](): string; + /** + * `Use a separate credential for each MCP client so it can be revoked independently.` + */ + ["com.affine.integration.mcp-server.credentials.description"](): string; + /** + * `Create credential` + */ + ["com.affine.integration.mcp-server.action.create"](): string; + /** + * `Rotate` + */ + ["com.affine.integration.mcp-server.action.rotate"](): string; + /** + * `Revoke credential` + */ + ["com.affine.integration.mcp-server.action.revoke"](): string; + /** + * `Copy token` + */ + ["com.affine.integration.mcp-server.action.copy-token"](): string; + /** + * `Copy JSON` + */ + ["com.affine.integration.mcp-server.action.copy-json"](): string; + /** + * `Done` + */ + ["com.affine.integration.mcp-server.action.done"](): string; + /** + * `Failed to load MCP credentials.` + */ + ["com.affine.integration.mcp-server.load-error"](): string; + /** + * `No MCP credentials` + */ + ["com.affine.integration.mcp-server.empty.title"](): string; + /** + * `Create a workspace-bound credential to connect an MCP client.` + */ + ["com.affine.integration.mcp-server.empty.description"](): string; + /** + * `Read only` + */ + ["com.affine.integration.mcp-server.access.read-only"](): string; + /** + * `Can read and search documents in this workspace using your current permissions.` + */ + ["com.affine.integration.mcp-server.access.read-only-desc"](): string; + /** + * `Read and write` + */ + ["com.affine.integration.mcp-server.access.read-write"](): string; + /** + * `Expires {{date}}` + */ + ["com.affine.integration.mcp-server.meta.expires"](options: { + readonly date: string; + }): string; + /** + * `Created {{date}}` + */ + ["com.affine.integration.mcp-server.meta.created"](options: { + readonly date: string; + }): string; + /** + * `Last used {{date}}` + */ + ["com.affine.integration.mcp-server.meta.last-used"](options: { + readonly date: string; + }): string; + /** + * `Never used` + */ + ["com.affine.integration.mcp-server.meta.never-used"](): string; + /** + * `Old token valid until {{date}}` + */ + ["com.affine.integration.mcp-server.meta.grace-until"](options: { + readonly date: string; + }): string; + /** + * `Active` + */ + ["com.affine.integration.mcp-server.status.active"](): string; + /** + * `Rotating` + */ + ["com.affine.integration.mcp-server.status.rotating"](): string; + /** + * `Expiring` + */ + ["com.affine.integration.mcp-server.status.expiring"](): string; + /** + * `Expired` + */ + ["com.affine.integration.mcp-server.status.expired"](): string; + /** + * `Revoked` + */ + ["com.affine.integration.mcp-server.status.revoked"](): string; + /** + * `Create MCP credential` + */ + ["com.affine.integration.mcp-server.create.title"](): string; + /** + * `This credential will only work with this workspace's MCP endpoint.` + */ + ["com.affine.integration.mcp-server.create.description"](): string; + /** + * `Label` + */ + ["com.affine.integration.mcp-server.field.label"](): string; + /** + * `Access` + */ + ["com.affine.integration.mcp-server.field.access"](): string; + /** + * `Expires in` + */ + ["com.affine.integration.mcp-server.field.expiry"](): string; + /** + * `{{days}} days` + */ + ["com.affine.integration.mcp-server.expiry.days"](options: { + readonly days: string; + }): string; + /** + * `MCP credential created` + */ + ["com.affine.integration.mcp-server.reveal.title"](): string; + /** + * `Copy this credential now. You won’t be able to see it again.` + */ + ["com.affine.integration.mcp-server.reveal.warning"](): string; + /** + * `The old token remains valid until {{date}}.` + */ + ["com.affine.integration.mcp-server.reveal.old-valid-until"](options: { + readonly date: string; + }): string; + /** + * `Credential` + */ + ["com.affine.integration.mcp-server.reveal.token"](): string; + /** + * `MCP configuration` + */ + ["com.affine.integration.mcp-server.reveal.config"](): string; + /** + * `Rotate this credential?` + */ + ["com.affine.integration.mcp-server.rotate.title"](): string; + /** + * `A new token will be created immediately. The old token remains valid for up to 24 hours so you can update the MCP client.` + */ + ["com.affine.integration.mcp-server.rotate.description"](): string; + /** + * `Revoke “{{name}}”?` + */ + ["com.affine.integration.mcp-server.revoke.title"](options: { + readonly name: string; + }): string; + /** + * `All generations of this credential will stop working immediately. This cannot be undone.` + */ + ["com.affine.integration.mcp-server.revoke.description"](): string; + /** + * `Supported capabilities` + */ + ["com.affine.integration.mcp-server.capabilities.title"](): string; + /** + * `Read documents` + */ + ["com.affine.integration.mcp-server.capabilities.read"](): string; + /** + * `Keyword search` + */ + ["com.affine.integration.mcp-server.capabilities.keyword-search"](): string; + /** + * `Semantic search` + */ + ["com.affine.integration.mcp-server.capabilities.semantic-search"](): string; + /** + * `Create and update documents` + */ + ["com.affine.integration.mcp-server.capabilities.write"](): string; /** * `Notes` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 7ddd7f0930..225d20b911 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2224,6 +2224,50 @@ "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.integration.mcp-server.copy-json.disabled-hint": "The MCP token is shown only once. Delete and recreate it to copy the JSON configuration.", + "com.affine.integration.mcp-server.credentials.title": "Credentials", + "com.affine.integration.mcp-server.credentials.description": "Use a separate credential for each MCP client so it can be revoked independently.", + "com.affine.integration.mcp-server.action.create": "Create credential", + "com.affine.integration.mcp-server.action.rotate": "Rotate", + "com.affine.integration.mcp-server.action.revoke": "Revoke credential", + "com.affine.integration.mcp-server.action.copy-token": "Copy token", + "com.affine.integration.mcp-server.action.copy-json": "Copy JSON", + "com.affine.integration.mcp-server.action.done": "Done", + "com.affine.integration.mcp-server.load-error": "Failed to load MCP credentials.", + "com.affine.integration.mcp-server.empty.title": "No MCP credentials", + "com.affine.integration.mcp-server.empty.description": "Create a workspace-bound credential to connect an MCP client.", + "com.affine.integration.mcp-server.access.read-only": "Read only", + "com.affine.integration.mcp-server.access.read-only-desc": "Can read and search documents in this workspace using your current permissions.", + "com.affine.integration.mcp-server.access.read-write": "Read and write", + "com.affine.integration.mcp-server.meta.expires": "Expires {{date}}", + "com.affine.integration.mcp-server.meta.created": "Created {{date}}", + "com.affine.integration.mcp-server.meta.last-used": "Last used {{date}}", + "com.affine.integration.mcp-server.meta.never-used": "Never used", + "com.affine.integration.mcp-server.meta.grace-until": "Old token valid until {{date}}", + "com.affine.integration.mcp-server.status.active": "Active", + "com.affine.integration.mcp-server.status.rotating": "Rotating", + "com.affine.integration.mcp-server.status.expiring": "Expiring", + "com.affine.integration.mcp-server.status.expired": "Expired", + "com.affine.integration.mcp-server.status.revoked": "Revoked", + "com.affine.integration.mcp-server.create.title": "Create MCP credential", + "com.affine.integration.mcp-server.create.description": "This credential will only work with this workspace's MCP endpoint.", + "com.affine.integration.mcp-server.field.label": "Label", + "com.affine.integration.mcp-server.field.access": "Access", + "com.affine.integration.mcp-server.field.expiry": "Expires in", + "com.affine.integration.mcp-server.expiry.days": "{{days}} days", + "com.affine.integration.mcp-server.reveal.title": "MCP credential created", + "com.affine.integration.mcp-server.reveal.warning": "Copy this credential now. You won’t be able to see it again.", + "com.affine.integration.mcp-server.reveal.old-valid-until": "The old token remains valid until {{date}}.", + "com.affine.integration.mcp-server.reveal.token": "Credential", + "com.affine.integration.mcp-server.reveal.config": "MCP configuration", + "com.affine.integration.mcp-server.rotate.title": "Rotate this credential?", + "com.affine.integration.mcp-server.rotate.description": "A new token will be created immediately. The old token remains valid for up to 24 hours so you can update the MCP client.", + "com.affine.integration.mcp-server.revoke.title": "Revoke “{{name}}”?", + "com.affine.integration.mcp-server.revoke.description": "All generations of this credential will stop working immediately. This cannot be undone.", + "com.affine.integration.mcp-server.capabilities.title": "Supported capabilities", + "com.affine.integration.mcp-server.capabilities.read": "Read documents", + "com.affine.integration.mcp-server.capabilities.keyword-search": "Keyword search", + "com.affine.integration.mcp-server.capabilities.semantic-search": "Semantic search", + "com.affine.integration.mcp-server.capabilities.write": "Create and update documents", "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", diff --git a/packages/frontend/i18n/src/resources/zh-Hans.json b/packages/frontend/i18n/src/resources/zh-Hans.json index 7cb85614bf..d581f9d8b4 100644 --- a/packages/frontend/i18n/src/resources/zh-Hans.json +++ b/packages/frontend/i18n/src/resources/zh-Hans.json @@ -2224,6 +2224,50 @@ "com.affine.integration.mcp-server.name": "MCP服务器", "com.affine.integration.mcp-server.desc": "允许其他 MCP 客户端搜索和 AFFiNE 的文档。", "com.affine.integration.mcp-server.copy-json.disabled-hint": "MCP token 仅显示一次。请删除并重新创建,以复制 JSON 配置。", + "com.affine.integration.mcp-server.credentials.title": "凭证", + "com.affine.integration.mcp-server.credentials.description": "为每个 MCP 客户端使用独立凭证,以便单独吊销。", + "com.affine.integration.mcp-server.action.create": "创建凭证", + "com.affine.integration.mcp-server.action.rotate": "轮换", + "com.affine.integration.mcp-server.action.revoke": "吊销凭证", + "com.affine.integration.mcp-server.action.copy-token": "复制 token", + "com.affine.integration.mcp-server.action.copy-json": "复制 JSON", + "com.affine.integration.mcp-server.action.done": "完成", + "com.affine.integration.mcp-server.load-error": "加载 MCP 凭证失败。", + "com.affine.integration.mcp-server.empty.title": "暂无 MCP 凭证", + "com.affine.integration.mcp-server.empty.description": "创建绑定当前工作区的凭证以连接 MCP 客户端。", + "com.affine.integration.mcp-server.access.read-only": "只读", + "com.affine.integration.mcp-server.access.read-only-desc": "可按你的当前权限读取和搜索此工作区的文档。", + "com.affine.integration.mcp-server.access.read-write": "读写", + "com.affine.integration.mcp-server.meta.expires": "{{date}} 到期", + "com.affine.integration.mcp-server.meta.created": "创建于 {{date}}", + "com.affine.integration.mcp-server.meta.last-used": "最近使用于 {{date}}", + "com.affine.integration.mcp-server.meta.never-used": "从未使用", + "com.affine.integration.mcp-server.meta.grace-until": "旧 token 有效至 {{date}}", + "com.affine.integration.mcp-server.status.active": "有效", + "com.affine.integration.mcp-server.status.rotating": "轮换中", + "com.affine.integration.mcp-server.status.expiring": "即将到期", + "com.affine.integration.mcp-server.status.expired": "已到期", + "com.affine.integration.mcp-server.status.revoked": "已吊销", + "com.affine.integration.mcp-server.create.title": "创建 MCP 凭证", + "com.affine.integration.mcp-server.create.description": "此凭证只能用于当前工作区的 MCP endpoint。", + "com.affine.integration.mcp-server.field.label": "名称", + "com.affine.integration.mcp-server.field.access": "权限", + "com.affine.integration.mcp-server.field.expiry": "有效期", + "com.affine.integration.mcp-server.expiry.days": "{{days}} 天", + "com.affine.integration.mcp-server.reveal.title": "MCP 凭证已创建", + "com.affine.integration.mcp-server.reveal.warning": "请立即复制此凭证,关闭后将无法再次查看。", + "com.affine.integration.mcp-server.reveal.old-valid-until": "旧 token 在 {{date}} 前仍然有效。", + "com.affine.integration.mcp-server.reveal.token": "凭证", + "com.affine.integration.mcp-server.reveal.config": "MCP 配置", + "com.affine.integration.mcp-server.rotate.title": "轮换此凭证?", + "com.affine.integration.mcp-server.rotate.description": "系统将立即创建新 token;旧 token 最多保留 24 小时,以便更新 MCP 客户端。", + "com.affine.integration.mcp-server.revoke.title": "吊销“{{name}}”?", + "com.affine.integration.mcp-server.revoke.description": "此凭证的所有世代会立即失效,且无法恢复。", + "com.affine.integration.mcp-server.capabilities.title": "支持的能力", + "com.affine.integration.mcp-server.capabilities.read": "读取文档", + "com.affine.integration.mcp-server.capabilities.keyword-search": "关键词搜索", + "com.affine.integration.mcp-server.capabilities.semantic-search": "语义搜索", + "com.affine.integration.mcp-server.capabilities.write": "创建和更新文档", "com.affine.audio.notes": "笔记", "com.affine.audio.transcribing": "转录", "com.affine.audio.transcribe.non-owner.confirm.title": "无法检索他人的 AI 结果", diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index 743abd14cc..c7acd05812 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -1209,8 +1209,8 @@ export const PackageList = [ location: 'packages/frontend/apps/electron', name: '@affine/electron', workspaceDependencies: [ - 'packages/common/auth', 'tools/utils', + 'packages/common/auth', 'packages/frontend/i18n', 'packages/frontend/native', 'packages/common/nbstore',