feat(core): improve mcp management (#15221)

#### PR Dependency Tree


* **PR #15221** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

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

* **New Features**
* Added MCP credential management (create/reveal, list, rotate, revoke)
with expiration and status tracking.
* Introduced read-only vs read/write access modes, with read/write
tooling enabled only when permitted.
* Added workspace MCP credential configuration UI, including token
reveal and setup generation.
  * Added MCP credential GraphQL APIs to back the UI.
* **Changes**
* Replaced legacy access-token support with MCP credentials across
authentication and realtime updates.
* **Bug Fixes**
* MCP authentication now reliably rejects revoked, rotated, expired, or
disabled-user credentials.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-12 19:30:44 +08:00
committed by GitHub
parent abf37d3dfa
commit 9b81c6debd
57 changed files with 2180 additions and 1213 deletions
@@ -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 => {
@@ -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;
@@ -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 }),
},
});
}
}
@@ -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 {
@@ -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
@@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { AccessTokenResolver, UserAccessTokenResolver } from './resolver';
@Module({
providers: [AccessTokenResolver, UserAccessTokenResolver],
})
export class AccessTokenModule {}
@@ -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<RevealedAccessToken[]> {
return await this.models.accessToken.list(user.id, true);
}
@Mutation(() => RevealedAccessToken)
async generateUserAccessToken(
@CurrentUser() user: CurrentUser,
@Args('input') input: GenerateAccessTokenInput
): Promise<RevealedAccessToken> {
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<boolean> {
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<AccessToken[]> {
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<RevealedAccessToken[]> {
if (!currentUser || currentUser.id !== user.id) {
throw new ActionForbidden();
}
return await this.models.accessToken.list(user.id, true);
}
}
@@ -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;
}
+4 -29
View File
@@ -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<Session | TokenSession | null> {
): Promise<Session | null> {
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<TokenSession | null> {
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
@@ -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
@@ -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;
};
@@ -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();
@@ -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',
]
);
});
@@ -31,7 +31,6 @@ export {
realtimeDocShareStateRoom,
realtimeNotificationRoom,
realtimeTranscriptTaskRoom,
realtimeUserAccessTokensRoom,
realtimeUserProfileRoom,
realtimeUserQuotaStateRoom,
realtimeUserRoom,
@@ -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',
@@ -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');
}
@@ -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}`
@@ -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<CurrentUserProfileSnapshot> {
@@ -166,22 +133,4 @@ export class UserRealtimeProvider
private async getSettings(userId: string): Promise<UserSettingsSnapshot> {
return await this.models.userSettings.get(userId);
}
private async getAccessTokens(
userId: string
): Promise<AccessTokenSnapshot[]> {
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),
});
}
}
+1 -2
View File
@@ -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';
}
}
@@ -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);
});
@@ -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<UserAccessToken[]>;
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 };
}
}
+2 -2
View File
@@ -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,
@@ -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 },
});
}
}
@@ -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 {}
@@ -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)
@@ -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<string, (typeof credentials)[number]>();
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;
}
}
@@ -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<WorkspaceMcpServer> {
async for(
userId: string,
workspaceId: string,
accessMode: McpAccessMode = McpAccessMode.READ_ONLY
): Promise<WorkspaceMcpServer> {
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',
@@ -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);
}
}
+41 -23
View File
@@ -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!