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

View File

@@ -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);
});

View File

@@ -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 };
}
}

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,

View File

@@ -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 },
});
}
}