mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-19 02:51:47 +08:00
fix(server): mcp api visibility (#15247)
fix #15246 #### PR Dependency Tree * **PR #15247** 👈 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 * **Bug Fixes** * Improved consistency when issuing and validating MCP credential tokens by using a shared token prefix across issuance and parsing. * Preserved correct recognition of standard JWT-based authentication tokens. * **Tests** * Updated MCP credentials coverage to validate behavior through the HTTP API response (instead of direct controller invocation). * Adjusted workspace quota e2e setup to derive restricted limits via entitlements before reconciling quota state. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -5,7 +5,6 @@ import { ProjectRoot } from '@affine-tools/utils/path';
|
|||||||
import { McpAccessMode, PrismaClient } from '@prisma/client';
|
import { McpAccessMode, PrismaClient } from '@prisma/client';
|
||||||
import type { TestFn } from 'ava';
|
import type { TestFn } from 'ava';
|
||||||
import ava from 'ava';
|
import ava from 'ava';
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -43,7 +42,6 @@ import {
|
|||||||
CopilotEmbeddingJob,
|
CopilotEmbeddingJob,
|
||||||
MockEmbeddingClient,
|
MockEmbeddingClient,
|
||||||
} from '../../plugins/copilot/embedding';
|
} from '../../plugins/copilot/embedding';
|
||||||
import { WorkspaceMcpController } from '../../plugins/copilot/mcp/controller';
|
|
||||||
import { McpCredentialService } from '../../plugins/copilot/mcp/credential';
|
import { McpCredentialService } from '../../plugins/copilot/mcp/credential';
|
||||||
import { WorkspaceMcpProvider } from '../../plugins/copilot/mcp/provider';
|
import { WorkspaceMcpProvider } from '../../plugins/copilot/mcp/provider';
|
||||||
import { PromptService } from '../../plugins/copilot/prompt';
|
import { PromptService } from '../../plugins/copilot/prompt';
|
||||||
@@ -84,12 +82,12 @@ import { SubscriptionService } from '../../plugins/payment/service';
|
|||||||
import { SubscriptionStatus } from '../../plugins/payment/types';
|
import { SubscriptionStatus } from '../../plugins/payment/types';
|
||||||
import { installMockCopilotRuntime, MockCopilotProvider } from '../mocks';
|
import { installMockCopilotRuntime, MockCopilotProvider } from '../mocks';
|
||||||
import { TestingPromptService } from '../mocks/prompt-service.mock';
|
import { TestingPromptService } from '../mocks/prompt-service.mock';
|
||||||
import { createTestingModule, TestingModule } from '../utils';
|
import { createTestingApp, TestingApp } from '../utils';
|
||||||
import { singleUserPromptMessages, systemPrompt } from './prompt-test-helper';
|
import { singleUserPromptMessages, systemPrompt } from './prompt-test-helper';
|
||||||
|
|
||||||
type Context = {
|
type Context = {
|
||||||
auth: AuthService;
|
auth: AuthService;
|
||||||
module: TestingModule;
|
module: TestingApp;
|
||||||
db: PrismaClient;
|
db: PrismaClient;
|
||||||
event: EventBus;
|
event: EventBus;
|
||||||
models: Models;
|
models: Models;
|
||||||
@@ -143,7 +141,7 @@ let restoreMockCopilotNativeRuntime: (() => void) | undefined;
|
|||||||
|
|
||||||
test.before(async t => {
|
test.before(async t => {
|
||||||
restoreMockCopilotNativeRuntime = installMockCopilotRuntime();
|
restoreMockCopilotNativeRuntime = installMockCopilotRuntime();
|
||||||
const module = await createTestingModule({
|
const module = await createTestingApp({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.override({
|
ConfigModule.override({
|
||||||
copilot: {
|
copilot: {
|
||||||
@@ -278,28 +276,12 @@ test('MCP credentials stay bound to their endpoint, workspace, and profile', asy
|
|||||||
t.is(authenticated.userId, userId);
|
t.is(authenticated.userId, userId);
|
||||||
await t.throwsAsync(mcpCredentials.authenticate(issued.token, other.id));
|
await t.throwsAsync(mcpCredentials.authenticate(issued.token, other.id));
|
||||||
|
|
||||||
let responseStatus: number | undefined;
|
const response = await t.context.module
|
||||||
let responseBody: unknown;
|
.POST(`/api/workspaces/${ws.id}/mcp`)
|
||||||
const request = {
|
.set('Authorization', `Bearer ${issued.token}`)
|
||||||
body: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||||
get: () => `Bearer ${issued.token}`,
|
.expect(200);
|
||||||
on: () => request,
|
t.like(response.body, {
|
||||||
} 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',
|
jsonrpc: '2.0',
|
||||||
id: 1,
|
id: 1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import test from 'ava';
|
|||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
import { ConfigFactory } from '../../base';
|
import { ConfigFactory } from '../../base';
|
||||||
|
import { EntitlementService } from '../../core/entitlement';
|
||||||
import { QuotaStateService } from '../../core/quota/state';
|
import { QuotaStateService } from '../../core/quota/state';
|
||||||
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
|
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
|
||||||
import { StorageRuntimeProvider } from '../../core/storage-runtime';
|
import { StorageRuntimeProvider } from '../../core/storage-runtime';
|
||||||
@@ -170,22 +171,20 @@ test.after.always(async () => {
|
|||||||
|
|
||||||
async function withRestrictedWorkspaceQuota(workspaceId: string) {
|
async function withRestrictedWorkspaceQuota(workspaceId: string) {
|
||||||
const quotaState = app.get(QuotaStateService);
|
const quotaState = app.get(QuotaStateService);
|
||||||
const blobModel = app.get(BlobModel);
|
const entitlement = app.get(EntitlementService);
|
||||||
const base = await quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
const resolveUserEntitlement =
|
||||||
return Sinon.stub(quotaState, 'reconcileWorkspaceQuotaState').callsFake(
|
entitlement.resolveUserEntitlement.bind(entitlement);
|
||||||
async id => {
|
const stub = Sinon.stub(entitlement, 'resolveUserEntitlement').callsFake(
|
||||||
if (id !== workspaceId) {
|
async userId => {
|
||||||
return base;
|
const resolved = await resolveUserEntitlement(userId);
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...resolved,
|
||||||
blobLimit: BigInt(RESTRICTED_QUOTA.blobLimit),
|
quota: { ...resolved.quota, ...RESTRICTED_QUOTA },
|
||||||
storageQuota: BigInt(RESTRICTED_QUOTA.storageQuota),
|
|
||||||
usedStorageQuota: BigInt(await blobModel.totalSize(workspaceId)),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
await quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
||||||
|
return stub;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('should set blobs', async t => {
|
test('should set blobs', async t => {
|
||||||
|
|||||||
@@ -115,7 +115,3 @@ export class AccessTokenService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLikelyJwt(token: string) {
|
|
||||||
return token.split('.').length === 3;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,16 +23,13 @@ import {
|
|||||||
UnsupportedClientVersion,
|
UnsupportedClientVersion,
|
||||||
} from '../../base';
|
} from '../../base';
|
||||||
import { WEBSOCKET_OPTIONS } from '../../base/websocket';
|
import { WEBSOCKET_OPTIONS } from '../../base/websocket';
|
||||||
import {
|
import { AccessTokenService, SessionAccessTokenError } from './access-token';
|
||||||
AccessTokenService,
|
|
||||||
isLikelyJwt,
|
|
||||||
SessionAccessTokenError,
|
|
||||||
} from './access-token';
|
|
||||||
import { AuthSessionService } from './auth-session';
|
import { AuthSessionService } from './auth-session';
|
||||||
import { extractTokenFromHeader } from './input';
|
import { extractTokenFromHeader } from './input';
|
||||||
import { AuthService } from './service';
|
import { AuthService } from './service';
|
||||||
import { AuthSessionPrincipal, Session } from './session';
|
import { AuthSessionPrincipal, Session } from './session';
|
||||||
import { AuthSessionHttpError } from './session-exchange';
|
import { AuthSessionHttpError } from './session-exchange';
|
||||||
|
import { isLikelyJwt } from './token';
|
||||||
|
|
||||||
const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public');
|
const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public');
|
||||||
const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal');
|
const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal');
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const MCP_CREDENTIAL_TOKEN_PREFIX = 'aff_mcp_v1';
|
||||||
|
|
||||||
|
export function isLikelyJwt(token: string) {
|
||||||
|
return (
|
||||||
|
!token.startsWith(`${MCP_CREDENTIAL_TOKEN_PREFIX}.`) &&
|
||||||
|
token.split('.').length === 3
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@ import { Transactional } from '@nestjs-cls/transactional';
|
|||||||
import type { McpAccessMode } from '@prisma/client';
|
import type { McpAccessMode } from '@prisma/client';
|
||||||
|
|
||||||
import { CryptoHelper, EventBus } from '../../../base';
|
import { CryptoHelper, EventBus } from '../../../base';
|
||||||
|
import { MCP_CREDENTIAL_TOKEN_PREFIX } from '../../../core/auth/token';
|
||||||
import { Models } from '../../../models';
|
import { Models } from '../../../models';
|
||||||
|
|
||||||
const TOKEN_PREFIX = 'aff_mcp_v1';
|
|
||||||
const ALLOWED_EXPIRATION_DAYS = new Set([30, 90, 365]);
|
const ALLOWED_EXPIRATION_DAYS = new Set([30, 90, 365]);
|
||||||
const ROTATION_GRACE_MS = 24 * 60 * 60 * 1000;
|
const ROTATION_GRACE_MS = 24 * 60 * 60 * 1000;
|
||||||
const LAST_USED_WRITE_INTERVAL_MS = 5 * 60 * 1000;
|
const LAST_USED_WRITE_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
@@ -220,7 +220,7 @@ export class McpCredentialService {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
credential: { ...credential, status: this.status(credential) },
|
credential: { ...credential, status: this.status(credential) },
|
||||||
token: `${TOKEN_PREFIX}.${id}.${secret}`,
|
token: `${MCP_CREDENTIAL_TOKEN_PREFIX}.${id}.${secret}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ export class McpCredentialService {
|
|||||||
const parts = token.split('.');
|
const parts = token.split('.');
|
||||||
if (
|
if (
|
||||||
parts.length !== 3 ||
|
parts.length !== 3 ||
|
||||||
parts[0] !== TOKEN_PREFIX ||
|
parts[0] !== MCP_CREDENTIAL_TOKEN_PREFIX ||
|
||||||
!parts[1] ||
|
!parts[1] ||
|
||||||
!parts[2]
|
!parts[2]
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user