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 {