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:
DarkSky
2026-07-15 12:29:50 +08:00
committed by GitHub
parent c61cc6a86f
commit a6b00a93c0
6 changed files with 33 additions and 51 deletions
@@ -5,7 +5,6 @@ import { ProjectRoot } from '@affine-tools/utils/path';
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';
@@ -43,7 +42,6 @@ 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';
@@ -84,12 +82,12 @@ import { SubscriptionService } from '../../plugins/payment/service';
import { SubscriptionStatus } from '../../plugins/payment/types';
import { installMockCopilotRuntime, MockCopilotProvider } from '../mocks';
import { TestingPromptService } from '../mocks/prompt-service.mock';
import { createTestingModule, TestingModule } from '../utils';
import { createTestingApp, TestingApp } from '../utils';
import { singleUserPromptMessages, systemPrompt } from './prompt-test-helper';
type Context = {
auth: AuthService;
module: TestingModule;
module: TestingApp;
db: PrismaClient;
event: EventBus;
models: Models;
@@ -143,7 +141,7 @@ let restoreMockCopilotNativeRuntime: (() => void) | undefined;
test.before(async t => {
restoreMockCopilotNativeRuntime = installMockCopilotRuntime();
const module = await createTestingModule({
const module = await createTestingApp({
imports: [
ConfigModule.override({
copilot: {
@@ -278,28 +276,12 @@ test('MCP credentials stay bound to their endpoint, workspace, and profile', asy
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, {
const response = await t.context.module
.POST(`/api/workspaces/${ws.id}/mcp`)
.set('Authorization', `Bearer ${issued.token}`)
.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
.expect(200);
t.like(response.body, {
jsonrpc: '2.0',
id: 1,
});
@@ -5,6 +5,7 @@ import test from 'ava';
import Sinon from 'sinon';
import { ConfigFactory } from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
import { StorageRuntimeProvider } from '../../core/storage-runtime';
@@ -170,22 +171,20 @@ test.after.always(async () => {
async function withRestrictedWorkspaceQuota(workspaceId: string) {
const quotaState = app.get(QuotaStateService);
const blobModel = app.get(BlobModel);
const base = await quotaState.reconcileWorkspaceQuotaState(workspaceId);
return Sinon.stub(quotaState, 'reconcileWorkspaceQuotaState').callsFake(
async id => {
if (id !== workspaceId) {
return base;
}
const entitlement = app.get(EntitlementService);
const resolveUserEntitlement =
entitlement.resolveUserEntitlement.bind(entitlement);
const stub = Sinon.stub(entitlement, 'resolveUserEntitlement').callsFake(
async userId => {
const resolved = await resolveUserEntitlement(userId);
return {
...base,
blobLimit: BigInt(RESTRICTED_QUOTA.blobLimit),
storageQuota: BigInt(RESTRICTED_QUOTA.storageQuota),
usedStorageQuota: BigInt(await blobModel.totalSize(workspaceId)),
...resolved,
quota: { ...resolved.quota, ...RESTRICTED_QUOTA },
};
}
);
await quotaState.reconcileWorkspaceQuotaState(workspaceId);
return stub;
}
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,
} from '../../base';
import { WEBSOCKET_OPTIONS } from '../../base/websocket';
import {
AccessTokenService,
isLikelyJwt,
SessionAccessTokenError,
} from './access-token';
import { AccessTokenService, SessionAccessTokenError } from './access-token';
import { AuthSessionService } from './auth-session';
import { extractTokenFromHeader } from './input';
import { AuthService } from './service';
import { AuthSessionPrincipal, Session } from './session';
import { AuthSessionHttpError } from './session-exchange';
import { isLikelyJwt } from './token';
const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public');
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 { CryptoHelper, EventBus } from '../../../base';
import { MCP_CREDENTIAL_TOKEN_PREFIX } from '../../../core/auth/token';
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;
@@ -220,7 +220,7 @@ export class McpCredentialService {
});
return {
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('.');
if (
parts.length !== 3 ||
parts[0] !== TOKEN_PREFIX ||
parts[0] !== MCP_CREDENTIAL_TOKEN_PREFIX ||
!parts[1] ||
!parts[2]
) {