mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-01 01:29:31 +08:00
feat(core): improve mcp management (#15221)
#### PR Dependency Tree * **PR #15221** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added MCP credential management (create/reveal, list, rotate, revoke) with expiration and status tracking. * Introduced read-only vs read/write access modes, with read/write tooling enabled only when permitted. * Added workspace MCP credential configuration UI, including token reveal and setup generation. * Added MCP credential GraphQL APIs to back the UI. * **Changes** * Replaced legacy access-token support with MCP credentials across authentication and realtime updates. * **Bug Fixes** * MCP authentication now reliably rejects revoked, rotated, expired, or disabled-user credentials. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user