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

@@ -34,6 +34,7 @@
},
"devDependencies": {
"@affine-tools/utils": "workspace:*",
"@affine/auth": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/native": "workspace:*",
"@affine/nbstore": "workspace:*",
@@ -75,7 +76,6 @@
"zod": "^3.25.76"
},
"dependencies": {
"@affine/auth": "workspace:*",
"async-call-rpc": "^6.4.2",
"electron-updater": "^6.8.3",
"link-preview-js": "^4.0.0",

View File

@@ -3,9 +3,27 @@ import path from 'node:path';
import * as esbuild from 'esbuild';
import { config, mode, rootDir } from './common';
import { config, electronDir, mode, rootDir } from './common';
async function assertNoRuntimeWorkspaceDependencies() {
const packageJson = JSON.parse(
await fs.readFile(path.resolve(electronDir, 'package.json'), 'utf8')
) as { dependencies?: Record<string, string> };
const workspaceDependencies = Object.entries(
packageJson.dependencies ?? {}
).filter(([, version]) => version.startsWith('workspace:'));
if (workspaceDependencies.length) {
throw new Error(
`Electron workspace dependencies must be bundled and declared as devDependencies: ${workspaceDependencies
.map(([name]) => name)
.join(', ')}`
);
}
}
async function buildLayers() {
await assertNoRuntimeWorkspaceDependencies();
const common = config();
const define: Record<string, string> = {

View File

@@ -7,8 +7,8 @@
},
"include": ["./src"],
"references": [
{ "path": "../../../common/auth" },
{ "path": "../../../../tools/utils" },
{ "path": "../../../common/auth" },
{ "path": "../../i18n" },
{ "path": "../../native" },
{ "path": "../../../common/nbstore" },

View File

@@ -0,0 +1,209 @@
import { Button, Input, Modal, notify } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import type { McpCredential } from '@affine/core/modules/cloud/services/mcp-credential';
import { McpAccessMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useEffect, useState } from 'react';
import * as styles from './setting-panel.css';
type RevealedCredential = {
credential: McpCredential;
token: string;
};
export const McpCredentialModal = ({
mode,
revealed,
config,
workspaceName,
readWriteAvailable,
onCreate,
onClose,
}: {
mode: 'create' | 'reveal' | null;
revealed: RevealedCredential | null;
config: string;
workspaceName?: string;
readWriteAvailable: boolean;
onCreate: (
name: string,
accessMode: McpAccessMode,
expirationDays: number
) => void | Promise<void>;
onClose: () => void;
}) => {
const t = useI18n();
const [name, setName] = useState('');
const [expirationDays, setExpirationDays] = useState(90);
const [accessMode, setAccessMode] = useState(McpAccessMode.READ_ONLY);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!mode) {
setName('');
setExpirationDays(90);
setAccessMode(McpAccessMode.READ_ONLY);
setSubmitting(false);
}
}, [mode]);
const copy = useAsyncCallback(
async (value: string) => {
await navigator.clipboard.writeText(value);
notify.success({ title: t['Copied to clipboard']() });
},
[t]
);
const submit = useAsyncCallback(async () => {
setSubmitting(true);
try {
await onCreate(name.trim(), accessMode, expirationDays);
} finally {
setSubmitting(false);
}
}, [accessMode, expirationDays, name, onCreate]);
return (
<Modal
open={mode !== null}
onOpenChange={open => {
if (!open) onClose();
}}
contentOptions={{ className: styles.modal }}
>
{mode === 'create' ? (
<>
<div className={styles.modalTitle}>
{t['com.affine.integration.mcp-server.create.title']()}
</div>
<div className={styles.description}>
{t['com.affine.integration.mcp-server.create.description']()}
</div>
<div className={styles.form}>
<label className={styles.field}>
<span>
{t['com.affine.integration.mcp-server.field.label']()}
</span>
<Input
value={name}
maxLength={64}
placeholder="Claude Desktop"
onChange={setName}
autoFocus
/>
</label>
<label className={styles.field}>
<span>
{t['com.affine.integration.mcp-server.field.access']()}
</span>
{readWriteAvailable ? (
<select
className={styles.select}
value={accessMode}
onChange={event =>
setAccessMode(event.currentTarget.value as McpAccessMode)
}
>
<option value={McpAccessMode.READ_ONLY}>
{t['com.affine.integration.mcp-server.access.read-only']()}
</option>
<option value={McpAccessMode.READ_WRITE}>
{t['com.affine.integration.mcp-server.access.read-write']()}
</option>
</select>
) : (
<div className={styles.fixedValue}>
{t['com.affine.integration.mcp-server.access.read-only']()}
<span className={styles.description}>
{t[
'com.affine.integration.mcp-server.access.read-only-desc'
]()}
</span>
</div>
)}
</label>
<label className={styles.field}>
<span>
{t['com.affine.integration.mcp-server.field.expiry']()}
</span>
<select
className={styles.select}
value={expirationDays}
onChange={event =>
setExpirationDays(Number(event.currentTarget.value))
}
>
{[30, 90, 365].map(days => (
<option value={days} key={days}>
{t['com.affine.integration.mcp-server.expiry.days']({
days: days.toString(),
})}
</option>
))}
</select>
</label>
</div>
<div className={styles.modalActions}>
<Button onClick={onClose}>{t['Cancel']()}</Button>
<Button
variant="primary"
disabled={!name.trim() || submitting}
loading={submitting}
onClick={submit}
>
{t['com.affine.integration.mcp-server.action.create']()}
</Button>
</div>
</>
) : revealed ? (
<>
<div className={styles.modalTitle}>
{t['com.affine.integration.mcp-server.reveal.title']()}
</div>
<div className={styles.warning}>
{t['com.affine.integration.mcp-server.reveal.warning']()}
</div>
<div className={styles.summary}>
{revealed.credential.name} · {workspaceName} ·{' '}
{revealed.credential.accessMode === McpAccessMode.READ_WRITE
? t['com.affine.integration.mcp-server.access.read-write']()
: t['com.affine.integration.mcp-server.access.read-only']()}{' '}
· {new Date(revealed.credential.expiresAt).toLocaleString()}
</div>
{revealed.credential.graceEndsAt ? (
<div className={styles.warning}>
{t['com.affine.integration.mcp-server.reveal.old-valid-until']({
date: new Date(
revealed.credential.graceEndsAt
).toLocaleString(),
})}
</div>
) : null}
<div className={styles.codeHeader}>
<span>{t['com.affine.integration.mcp-server.reveal.token']()}</span>
<Button onClick={() => copy(revealed.token)}>
{t['com.affine.integration.mcp-server.action.copy-token']()}
</Button>
</div>
<pre className={styles.preArea}>{revealed.token}</pre>
<div className={styles.codeHeader}>
<span>
{t['com.affine.integration.mcp-server.reveal.config']()}
</span>
<Button variant="primary" onClick={() => copy(config)}>
{t['com.affine.integration.mcp-server.action.copy-json']()}
</Button>
</div>
<pre className={styles.preArea}>{config}</pre>
<div className={styles.modalActions}>
<Button variant="primary" onClick={onClose}>
{t['com.affine.integration.mcp-server.action.done']()}
</Button>
</div>
</>
) : null}
</Modal>
);
};

View File

@@ -2,47 +2,172 @@ import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const connectButton = style({
width: '100%',
marginTop: '24px',
});
export const section = style({
export const stack = style({
display: 'flex',
flexDirection: 'column',
border: `1px solid ${cssVar('borderColor')}`,
borderRadius: '8px',
padding: '8px 16px',
gap: '0px',
marginBottom: '16px',
gap: 24,
});
export const sectionHeader = style({
export const panel = style({
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
borderRadius: 8,
overflow: 'hidden',
background: cssVarV2('layer/background/primary'),
});
export const panelHeader = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
gap: 12,
padding: '12px 16px',
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
});
export const preArea = style({
backgroundColor: cssVarV2('layer/background/secondary'),
padding: '16px 16px',
borderRadius: '8px',
margin: '8px 0',
fontFamily: cssVar('fontMonoFamily'),
overflowX: 'auto',
});
export const sectionDescription = style({
fontSize: 13,
lineHeight: '22px',
color: cssVarV2('text/secondary'),
});
export const sectionTitle = style({
fontSize: 14,
fontWeight: 500,
lineHeight: 1.25,
export const title = style({
fontSize: cssVar('fontSm'),
fontWeight: 600,
color: cssVarV2('text/primary'),
});
export const description = style({
fontSize: cssVar('fontXs'),
lineHeight: '20px',
color: cssVarV2('text/secondary'),
});
export const empty = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
padding: '28px 20px',
textAlign: 'center',
});
export const skeletons = style({
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 16,
});
export const rows = style({ display: 'flex', flexDirection: 'column' });
export const row = style({
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
alignItems: 'center',
gap: 12,
padding: '12px 16px',
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
selectors: { '&:last-child': { borderBottom: 0 } },
});
export const rowDisabled = style({
opacity: 0.55,
background: cssVarV2('layer/background/secondary'),
});
export const rowMain = style({
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const rowTitle = style({
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: cssVar('fontSm'),
fontWeight: 600,
color: cssVarV2('text/primary'),
});
export const tag = style({
borderRadius: 999,
padding: '2px 8px',
fontSize: 11,
lineHeight: '16px',
fontWeight: 400,
color: cssVarV2('text/secondary'),
background: cssVarV2('layer/background/secondary'),
});
export const rowActions = style({ display: 'flex', gap: 8 });
export const capabilities = style({
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
});
export const capability = style({
padding: '14px 16px',
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
borderRight: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
selectors: { '&:last-child': { borderRight: 0 } },
});
export const modal = style({
width: 500,
maxWidth: 'calc(100vw - 32px)',
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: 20,
});
export const modalTitle = style({
fontSize: 18,
fontWeight: 600,
color: cssVarV2('text/primary'),
});
export const form = style({
display: 'flex',
flexDirection: 'column',
gap: 16,
});
export const field = style({
display: 'flex',
flexDirection: 'column',
gap: 6,
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
});
export const fixedValue = style({
display: 'flex',
flexDirection: 'column',
gap: 2,
padding: '8px 10px',
borderRadius: 8,
background: cssVarV2('layer/background/secondary'),
color: cssVarV2('text/primary'),
});
export const select = style({
height: 32,
borderRadius: 8,
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
padding: '0 10px',
background: cssVarV2('layer/background/primary'),
color: cssVarV2('text/primary'),
});
export const warning = style({
padding: 12,
borderRadius: 8,
background: cssVarV2('layer/background/secondary'),
color: cssVarV2('text/primary'),
fontSize: cssVar('fontXs'),
});
export const summary = style({
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
});
export const codeHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: cssVar('fontSm'),
fontWeight: 600,
});
export const preArea = style({
maxHeight: 180,
overflow: 'auto',
margin: 0,
padding: 12,
borderRadius: 8,
background: cssVarV2('layer/background/secondary'),
fontFamily: cssVar('fontMonoFamily'),
fontSize: cssVar('fontXs'),
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
});
export const modalActions = style({
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
});

View File

@@ -1,244 +1,328 @@
import { Button, ErrorMessage, notify, Skeleton } from '@affine/component';
import {
Button,
ErrorMessage,
notify,
Skeleton,
useConfirmModal,
} from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AccessTokenService, ServerService } from '@affine/core/modules/cloud';
import type { AccessToken } from '@affine/core/modules/cloud/stores/access-token';
import {
McpCredentialService,
ServerService,
} from '@affine/core/modules/cloud';
import type { McpCredential } from '@affine/core/modules/cloud/services/mcp-credential';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/error';
import { McpAccessMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { IntegrationSettingHeader } from '../setting';
import { McpCredentialModal } from './credential-modal';
import MCPIcon from './MCP.inline.svg';
import * as styles from './setting-panel.css';
type RevealedCredential = {
credential: McpCredential;
token: string;
};
const formatDate = (value: string) => new Date(value).toLocaleString();
export const McpServerSettingPanel = () => {
return <McpServerSetting />;
};
const McpServerSettingHeader = ({ action }: { action?: ReactNode }) => {
const t = useI18n();
return (
<IntegrationSettingHeader
icon={<img src={MCPIcon} />}
name={t['com.affine.integration.mcp-server.name']()}
desc={t['com.affine.integration.mcp-server.desc']()}
action={action}
/>
);
};
const McpServerSetting = () => {
const workspaceService = useService(WorkspaceService);
const serverService = useService(ServerService);
const credentialsService = useService(McpCredentialService);
const credentials = useLiveData(credentialsService.credentials$);
const loading = useLiveData(credentialsService.loading$);
const error = useLiveData(credentialsService.error$);
const readWriteAvailable = useLiveData(
credentialsService.readWriteAvailable$
);
const workspaceId = workspaceService.workspace.id;
const workspaceName = useLiveData(workspaceService.workspace.name$);
const accessTokenService = useService(AccessTokenService);
const accessTokens = useLiveData(accessTokenService.accessTokens$);
const isRevalidating = useLiveData(accessTokenService.isRevalidating$);
const error = useLiveData(accessTokenService.error$);
const [mutating, setMutating] = useState(false);
const [revealedAccessToken, setRevealedAccessToken] =
useState<AccessToken | null>(null);
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const [modal, setModal] = useState<'create' | 'reveal' | null>(null);
const [revealed, setRevealed] = useState<RevealedCredential | null>(null);
const [mutatingId, setMutatingId] = useState<string | null>(null);
const mcpAccessToken = useMemo(() => {
return accessTokens?.find(token => token.name === 'mcp');
}, [accessTokens]);
const statusLabel = useCallback(
(status: McpCredential['status']) => {
switch (status) {
case 'ACTIVE':
return t['com.affine.integration.mcp-server.status.active']();
case 'ROTATING':
return t['com.affine.integration.mcp-server.status.rotating']();
case 'EXPIRING':
return t['com.affine.integration.mcp-server.status.expiring']();
case 'EXPIRED':
return t['com.affine.integration.mcp-server.status.expired']();
case 'REVOKED':
return t['com.affine.integration.mcp-server.status.revoked']();
default:
return status;
}
},
[t]
);
const hasMcpToken = Boolean(revealedAccessToken || mcpAccessToken);
const hasCopyableToken = Boolean(revealedAccessToken);
const isRedactedDisplay = hasMcpToken && !hasCopyableToken;
const revalidate = useCallback(() => {
// oxlint-disable-next-line @typescript-eslint/no-floating-promises
credentialsService.revalidate(workspaceId);
}, [credentialsService, workspaceId]);
const code = useMemo(() => {
return revealedAccessToken
? JSON.stringify(
{
mcpServers: {
[`affine_workspace_${workspaceService.workspace.id}`]: {
type: 'streamable-http',
url: `${serverService.server.baseUrl}/api/workspaces/${workspaceService.workspace.id}/mcp`,
note: `Read docs from AFFiNE workspace "${workspaceName}"`,
headers: {
Authorization: `Bearer ${revealedAccessToken.token}`,
},
},
},
useEffect(() => revalidate(), [revalidate]);
const config = useMemo(() => {
if (!revealed) return '';
return JSON.stringify(
{
mcpServers: {
[`affine_workspace_${workspaceId}`]: {
type: 'streamable-http',
url: `${serverService.server.baseUrl}/api/workspaces/${workspaceId}/mcp`,
headers: { Authorization: `Bearer ${revealed.token}` },
},
null,
2
)
: null;
}, [revealedAccessToken, workspaceName, workspaceService, serverService]);
const copyJsonDisabled = !code || mutating || isRedactedDisplay;
const copyJsonTooltip = isRedactedDisplay
? t['com.affine.integration.mcp-server.copy-json.disabled-hint']()
: undefined;
const showLoading = accessTokens === null && isRevalidating;
const showError = accessTokens === null && error !== null;
useEffect(() => {
accessTokenService.revalidate();
}, [accessTokenService]);
const handleGenerateAccessToken = useAsyncCallback(async () => {
setMutating(true);
try {
if (mcpAccessToken) {
await accessTokenService.revokeUserAccessToken(mcpAccessToken.id);
}
const createdToken =
await accessTokenService.generateUserAccessToken('mcp');
setRevealedAccessToken(createdToken);
} catch (err) {
notify.error({
error: UserFriendlyError.fromAny(err),
});
} finally {
setMutating(false);
}
}, [accessTokenService, mcpAccessToken]);
const handleRevokeAccessToken = useAsyncCallback(async () => {
setMutating(true);
try {
if (mcpAccessToken) {
await accessTokenService.revokeUserAccessToken(mcpAccessToken.id);
}
setRevealedAccessToken(null);
} catch (err) {
notify.error({
error: UserFriendlyError.fromAny(err),
});
} finally {
setMutating(false);
}
}, [accessTokenService, mcpAccessToken]);
if (showLoading) {
return (
<div>
<McpServerSettingHeader />
<Skeleton />
</div>
},
},
null,
2
);
}
}, [revealed, serverService.server.baseUrl, workspaceId]);
if (showError) {
return (
<div>
<McpServerSettingHeader />
<ErrorMessage>{error}</ErrorMessage>
</div>
);
}
const create = useAsyncCallback(
async (name: string, accessMode: McpAccessMode, expirationDays: number) => {
try {
const result = await credentialsService.create({
workspaceId,
name,
accessMode,
expirationDays,
});
setRevealed(result);
setModal('reveal');
} catch (error) {
notify.error({ error: UserFriendlyError.fromAny(error) });
}
},
[credentialsService, workspaceId]
);
const rotate = useAsyncCallback(
async (credential: McpCredential) => {
setMutatingId(credential.id);
try {
const result = await credentialsService.rotate(
credential.id,
workspaceId,
90
);
setRevealed(result);
setModal('reveal');
} catch (error) {
notify.error({ error: UserFriendlyError.fromAny(error) });
} finally {
setMutatingId(null);
}
},
[credentialsService, workspaceId]
);
const confirmRotate = useCallback(
(credential: McpCredential) => {
openConfirmModal({
title: t['com.affine.integration.mcp-server.rotate.title'](),
description:
t['com.affine.integration.mcp-server.rotate.description'](),
confirmText: t['com.affine.integration.mcp-server.action.rotate'](),
cancelText: t['Cancel'](),
onConfirm: () => rotate(credential),
});
},
[openConfirmModal, rotate, t]
);
const confirmRevoke = useCallback(
(credential: McpCredential) => {
openConfirmModal({
title: t['com.affine.integration.mcp-server.revoke.title']({
name: credential.name,
}),
description:
t['com.affine.integration.mcp-server.revoke.description'](),
confirmText: t['com.affine.integration.mcp-server.action.revoke'](),
cancelText: t['Cancel'](),
confirmButtonOptions: { variant: 'error' },
onConfirm: async () => {
setMutatingId(credential.id);
try {
await credentialsService.revoke(credential.id, workspaceId);
} catch (error) {
notify.error({ error: UserFriendlyError.fromAny(error) });
} finally {
setMutatingId(null);
}
},
});
},
[credentialsService, openConfirmModal, t, workspaceId]
);
return (
<div>
<McpServerSettingHeader />
<div className={styles.stack}>
<IntegrationSettingHeader
icon={<img src={MCPIcon} />}
name={t['com.affine.integration.mcp-server.name']()}
desc={t['com.affine.integration.mcp-server.desc']()}
/>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>Personal access token</div>
{!hasMcpToken ? (
<Button
variant="primary"
onClick={handleGenerateAccessToken}
disabled={mutating}
>
Create New
</Button>
) : (
<Button
variant="error"
onClick={handleRevokeAccessToken}
disabled={mutating}
>
Delete
</Button>
)}
</div>
<p className={styles.sectionDescription}>
This access token is used for the MCP service, please keep this
information secure. Deleting it will invalidate the access token.
</p>
</div>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>Server Config</div>
<Button
variant="primary"
onClick={() => {
if (!code) return;
// oxlint-disable-next-line @typescript-eslint/no-floating-promises
navigator.clipboard.writeText(code);
notify.success({
title: t['Copied to clipboard'](),
});
}}
disabled={copyJsonDisabled}
tooltip={copyJsonTooltip}
>
Copy json
<section className={styles.panel}>
<div className={styles.panelHeader}>
<div>
<div className={styles.title}>
{t['com.affine.integration.mcp-server.credentials.title']()}
</div>
<div className={styles.description}>
{t['com.affine.integration.mcp-server.credentials.description']()}
</div>
</div>
<Button variant="primary" onClick={() => setModal('create')}>
{t['com.affine.integration.mcp-server.action.create']()}
</Button>
</div>
{code ? (
<pre className={styles.preArea}>{code}</pre>
{loading && credentials === null ? (
<div className={styles.skeletons}>
<Skeleton />
<Skeleton />
</div>
) : error && credentials === null ? (
<div className={styles.empty}>
<ErrorMessage>
{t['com.affine.integration.mcp-server.load-error']()}
</ErrorMessage>
<Button onClick={revalidate}>{t['Retry']()}</Button>
</div>
) : credentials?.length ? (
<div className={styles.rows}>
{credentials.map(credential => (
<div
className={`${styles.row} ${
credential.status === 'EXPIRED' ||
credential.status === 'REVOKED'
? styles.rowDisabled
: ''
}`}
key={credential.id}
>
<div className={styles.rowMain}>
<div className={styles.rowTitle}>
{credential.name}
<span className={styles.tag}>
{statusLabel(credential.status)}
</span>
</div>
<div className={styles.description}>
{credential.accessMode === McpAccessMode.READ_WRITE
? t[
'com.affine.integration.mcp-server.access.read-write'
]()
: t[
'com.affine.integration.mcp-server.access.read-only'
]()}{' '}
· •••• {credential.fingerprint} ·{' '}
{t['com.affine.integration.mcp-server.meta.expires']({
date: formatDate(credential.expiresAt),
})}
</div>
<div className={styles.description}>
{t['com.affine.integration.mcp-server.meta.created']({
date: formatDate(credential.createdAt),
})}{' '}
·{' '}
{credential.lastUsedAt
? t['com.affine.integration.mcp-server.meta.last-used']({
date: formatDate(credential.lastUsedAt),
})
: t[
'com.affine.integration.mcp-server.meta.never-used'
]()}
{credential.graceEndsAt
? ` · ${t[
'com.affine.integration.mcp-server.meta.grace-until'
]({ date: formatDate(credential.graceEndsAt) })}`
: null}
</div>
</div>
<div className={styles.rowActions}>
{credential.status !== 'REVOKED' &&
credential.status !== 'EXPIRED' ? (
<>
<Button
onClick={() => confirmRotate(credential)}
disabled={mutatingId === credential.id}
>
{t['com.affine.integration.mcp-server.action.rotate']()}
</Button>
<Button
variant="error"
onClick={() => confirmRevoke(credential)}
disabled={mutatingId === credential.id}
>
{t['com.affine.integration.mcp-server.action.revoke']()}
</Button>
</>
) : null}
</div>
</div>
))}
</div>
) : (
<p
className={styles.sectionDescription}
style={{ textAlign: 'center' }}
>
No access token found, please generate one first.
</p>
<div className={styles.empty}>
<div className={styles.title}>
{t['com.affine.integration.mcp-server.empty.title']()}
</div>
<div className={styles.description}>
{t['com.affine.integration.mcp-server.empty.description']()}
</div>
</div>
)}
</div>
</section>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>Support tools</div>
</div>
<br />
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>doc-read</div>
</div>
<div className={styles.sectionDescription}>
Return the complete text and basic metadata of a single document
identified by docId; use this when the user needs the full content
of a specific file rather than a search result.
<section className={styles.panel}>
<div className={styles.panelHeader}>
<div className={styles.title}>
{t['com.affine.integration.mcp-server.capabilities.title']()}
</div>
</div>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>doc-semantic-search</div>
</div>
<div className={styles.sectionDescription}>
Retrieve conceptually related passages by performing vector-based
semantic similarity search across embedded documents; use this tool
only when exact keyword search fails or the user explicitly needs
meaning-level matches (e.g., paraphrases, synonyms, broader
concepts, recent documents).
</div>
<div className={styles.capabilities}>
{(['read', 'keyword-search', 'semantic-search'] as const).map(key => (
<div className={styles.capability} key={key}>
{t[`com.affine.integration.mcp-server.capabilities.${key}`]()}
</div>
))}
{readWriteAvailable ? (
<div className={styles.capability}>
{t['com.affine.integration.mcp-server.capabilities.write']()}
</div>
) : null}
</div>
</section>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>doc-keyword-search</div>
</div>
<div className={styles.sectionDescription}>
Fuzzy search all workspace documents for the exact keyword or phrase
supplied and return passages ranked by textual match. Use this tool
by default whenever a straightforward term-based or keyword-base
lookup is sufficient.
</div>
</div>
</div>
<McpCredentialModal
mode={modal}
revealed={revealed}
config={config}
workspaceName={workspaceName}
readWriteAvailable={readWriteAvailable}
onCreate={create}
onClose={() => {
setModal(null);
setRevealed(null);
}}
/>
</div>
);
};

View File

@@ -16,7 +16,6 @@ export {
DEFAULT_SELF_HOSTED_SERVER_NAME,
getSelfHostedServerName,
} from './server-name';
export { AccessTokenService } from './services/access-token';
export { AuthService, type DeviceAuthSession } from './services/auth';
export { CaptchaService } from './services/captcha';
export { DefaultServerService } from './services/default-server';
@@ -26,6 +25,7 @@ export { FetchService } from './services/fetch';
export { GraphQLService } from './services/graphql';
export { InvitationService } from './services/invitation';
export { InvoicesService } from './services/invoices';
export { McpCredentialService } from './services/mcp-credential';
export type { PublicUserInfo } from './services/public-user';
export { PublicUserService } from './services/public-user';
export { RealtimeService } from './services/realtime';
@@ -115,8 +115,8 @@ import { NbstoreService } from '../storage';
import { DocScope, DocService, DocsService } from '../doc';
import { DocCreatedByUpdatedBySyncStore } from './stores/doc-created-by-updated-by-sync';
import { GlobalDialogService } from '../dialogs';
import { AccessTokenService } from './services/access-token';
import { AccessTokenStore } from './stores/access-token';
import { McpCredentialService } from './services/mcp-credential';
import { McpCredentialStore } from './stores/mcp-credential';
export function configureCloudModule(framework: Framework) {
configureDefaultAuthProvider(framework);
@@ -194,8 +194,8 @@ export function configureCloudModule(framework: Framework) {
.store(PublicUserStore, [GraphQLService])
.service(UserSettingsService, [UserSettingsStore])
.store(UserSettingsStore, [GraphQLService, NbstoreService])
.service(AccessTokenService, [AccessTokenStore])
.store(AccessTokenStore, [GraphQLService, NbstoreService]);
.service(McpCredentialService, [McpCredentialStore])
.store(McpCredentialStore, [GraphQLService]);
framework
.scope(WorkspaceScope)

View File

@@ -1,54 +0,0 @@
import { Framework } from '@toeverything/infra';
import { Subject } from 'rxjs';
import { describe, expect, test, vi } from 'vitest';
import { AccessTokenStore } from '../stores/access-token';
import { AccessTokenService } from './access-token';
function createStore() {
return {
subscribeUserAccessTokens: vi.fn(() => new Subject()),
listUserAccessTokens: vi.fn().mockResolvedValue([
{
id: 'token-1',
name: 'MCP',
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: null,
},
]),
generateUserAccessToken: vi.fn().mockResolvedValue({
id: 'token-1',
name: 'MCP',
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: null,
token: 'secret-token',
}),
} as unknown as AccessTokenStore;
}
describe('AccessTokenService', () => {
test('does not store generated plaintext token in the long-lived list', async () => {
const framework = new Framework();
framework
.store(AccessTokenStore, createStore())
.service(AccessTokenService, [AccessTokenStore]);
const service = framework.provider().get(AccessTokenService);
const accessToken = await service.generateUserAccessToken('MCP');
expect(accessToken.token).toBe('secret-token');
expect(service.accessTokens$.value).toEqual([
{
id: 'token-1',
name: 'MCP',
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: null,
},
]);
expect(JSON.stringify(service.accessTokens$.value)).not.toContain(
'secret-token'
);
service.dispose();
});
});

View File

@@ -1,85 +0,0 @@
import { LiveData, OnEvent, Service } from '@toeverything/infra';
import { AccountChanged } from '../events/account-changed';
import { RealtimeLiveQuery } from '../realtime/live-query';
import type {
AccessToken,
AccessTokenStore,
ListedAccessToken,
} from '../stores/access-token';
@OnEvent(AccountChanged, e => e.onAccountChanged)
export class AccessTokenService extends Service {
constructor(private readonly accessTokenStore: AccessTokenStore) {
super();
this.liveQuery.start();
}
accessTokens$ = new LiveData<ListedAccessToken[] | null>(null);
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any>(null);
private readonly liveQuery = new RealtimeLiveQuery({
request: signal => this.requestAccessTokens(signal),
subscribe: () => this.accessTokenStore.subscribeUserAccessTokens(),
applySnapshot: accessTokens => {
this.error$.value = null;
this.accessTokens$.value = accessTokens;
},
applyEvent: () => 'revalidate' as const,
onError: error => {
this.error$.value = error;
},
});
async generateUserAccessToken(name: string): Promise<AccessToken> {
const accessToken =
await this.accessTokenStore.generateUserAccessToken(name);
const { token: _token, ...listedAccessToken } = accessToken;
this.accessTokens$.value = [
...(this.accessTokens$.value || []),
listedAccessToken,
];
await this.waitForRevalidation();
return accessToken;
}
async revokeUserAccessToken(id: string) {
await this.accessTokenStore.revokeUserAccessToken(id);
this.accessTokens$.value =
this.accessTokens$.value?.filter(token => token.id !== id) ?? null;
await this.waitForRevalidation();
}
revalidate = () => {
this.liveQuery.revalidate();
};
private onAccountChanged() {
this.accessTokens$.value = null;
this.revalidate();
}
async waitForRevalidation(signal?: AbortSignal) {
this.revalidate();
await this.isRevalidating$.waitFor(
isRevalidating => !isRevalidating,
signal
);
}
override dispose(): void {
super.dispose();
this.liveQuery.dispose();
}
private async requestAccessTokens(signal: AbortSignal) {
this.isRevalidating$.value = true;
try {
return await this.accessTokenStore.listUserAccessTokens(signal);
} finally {
this.isRevalidating$.value = false;
}
}
}

View File

@@ -0,0 +1,58 @@
import type {
CreateMcpCredentialMutationVariables,
McpCredentialsQuery,
} from '@affine/graphql';
import { LiveData, Service } from '@toeverything/infra';
import type { McpCredentialStore } from '../stores/mcp-credential';
export type McpCredential = McpCredentialsQuery['mcpCredentials'][number];
export class McpCredentialService extends Service {
private revalidationId = 0;
constructor(private readonly store: McpCredentialStore) {
super();
}
credentials$ = new LiveData<McpCredential[] | null>(null);
readWriteAvailable$ = new LiveData(false);
loading$ = new LiveData(false);
error$ = new LiveData<unknown>(null);
async revalidate(workspaceId: string) {
const revalidationId = ++this.revalidationId;
this.loading$.value = true;
try {
const result = await this.store.list(workspaceId);
if (revalidationId !== this.revalidationId) return;
this.credentials$.value = result.mcpCredentials;
this.readWriteAvailable$.value = result.mcpCredentialReadWriteAvailable;
this.error$.value = null;
} catch (error) {
if (revalidationId !== this.revalidationId) return;
this.error$.value = error;
} finally {
if (revalidationId === this.revalidationId) {
this.loading$.value = false;
}
}
}
async create(input: CreateMcpCredentialMutationVariables['input']) {
const revealed = await this.store.create(input);
await this.revalidate(input.workspaceId);
return revealed;
}
async rotate(id: string, workspaceId: string, expirationDays: number) {
const revealed = await this.store.rotate(id, workspaceId, expirationDays);
await this.revalidate(workspaceId);
return revealed;
}
async revoke(id: string, workspaceId: string) {
await this.store.revoke(id, workspaceId);
await this.revalidate(workspaceId);
}
}

View File

@@ -1,63 +0,0 @@
import {
generateUserAccessTokenMutation,
revokeUserAccessTokenMutation,
} from '@affine/graphql';
import type { AccessTokenSnapshot } from '@affine/realtime';
import { Store } from '@toeverything/infra';
import type { NbstoreService } from '../../storage';
import type { GraphQLService } from '../services/graphql';
export type AccessToken = AccessTokenSnapshot & { token: string };
export type ListedAccessToken = AccessTokenSnapshot;
export class AccessTokenStore extends Store {
constructor(
private readonly gqlService: GraphQLService,
private readonly nbstoreService: NbstoreService
) {
super();
}
async listUserAccessTokens(
signal?: AbortSignal
): Promise<ListedAccessToken[]> {
const { tokens } = await this.nbstoreService.realtime.request(
'user.access-tokens.get',
{},
{ signal, timeoutMs: 10000 }
);
return tokens;
}
subscribeUserAccessTokens() {
return this.nbstoreService.realtime.subscribe(
'user.access-tokens.changed',
{}
);
}
async generateUserAccessToken(
name: string,
expiresAt?: string,
signal?: AbortSignal
) {
const data = await this.gqlService.gql({
query: generateUserAccessTokenMutation,
variables: { input: { name, expiresAt } },
context: { signal },
});
return data.generateUserAccessToken;
}
async revokeUserAccessToken(id: string, signal?: AbortSignal) {
const data = await this.gqlService.gql({
query: revokeUserAccessTokenMutation,
variables: { id },
context: { signal },
});
return data.revokeUserAccessToken;
}
}

View File

@@ -0,0 +1,56 @@
import type { CreateMcpCredentialMutationVariables } from '@affine/graphql';
import {
createMcpCredentialMutation,
mcpCredentialsQuery,
revokeMcpCredentialMutation,
rotateMcpCredentialMutation,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { GraphQLService } from '../services/graphql';
export class McpCredentialStore extends Store {
constructor(private readonly gqlService: GraphQLService) {
super();
}
async list(workspaceId: string, signal?: AbortSignal) {
const data = await this.gqlService.gql({
query: mcpCredentialsQuery,
variables: { workspaceId },
context: { signal },
});
return data;
}
async create(input: CreateMcpCredentialMutationVariables['input']) {
const data = await this.gqlService.gql({
query: createMcpCredentialMutation,
variables: {
input: {
workspaceId: input.workspaceId,
name: input.name,
accessMode: input.accessMode,
expirationDays: input.expirationDays,
},
},
});
return data.createMcpCredential;
}
async rotate(id: string, workspaceId: string, expirationDays: number) {
const data = await this.gqlService.gql({
query: rotateMcpCredentialMutation,
variables: { id, workspaceId, expirationDays },
});
return data.rotateMcpCredential;
}
async revoke(id: string, workspaceId: string) {
const data = await this.gqlService.gql({
query: revokeMcpCredentialMutation,
variables: { id, workspaceId },
});
return data.revokeMcpCredential;
}
}

View File

@@ -1,28 +1,28 @@
{
"ar": 94,
"ca": 91,
"ar": 92,
"ca": 89,
"da": 4,
"de": 100,
"el-GR": 90,
"de": 98,
"el-GR": 88,
"en": 100,
"es-AR": 90,
"es-CL": 91,
"es": 90,
"fa": 90,
"fr": 94,
"es-AR": 88,
"es-CL": 89,
"es": 88,
"fa": 88,
"fr": 92,
"hi": 1,
"it": 91,
"ja": 90,
"kk": 98,
"ko": 91,
"nb-NO": 45,
"pl": 91,
"pt-BR": 90,
"ru": 92,
"sv-SE": 90,
"tr": 98,
"uk": 90,
"ur": 98,
"it": 89,
"ja": 88,
"kk": 96,
"ko": 89,
"nb-NO": 44,
"pl": 89,
"pt-BR": 88,
"ru": 90,
"sv-SE": 88,
"tr": 96,
"uk": 88,
"ur": 96,
"zh-Hans": 100,
"zh-Hant": 92
"zh-Hant": 90
}

View File

@@ -8882,6 +8882,196 @@ export function useAFFiNEI18N(): {
* `The MCP token is shown only once. Delete and recreate it to copy the JSON configuration.`
*/
["com.affine.integration.mcp-server.copy-json.disabled-hint"](): string;
/**
* `Credentials`
*/
["com.affine.integration.mcp-server.credentials.title"](): string;
/**
* `Use a separate credential for each MCP client so it can be revoked independently.`
*/
["com.affine.integration.mcp-server.credentials.description"](): string;
/**
* `Create credential`
*/
["com.affine.integration.mcp-server.action.create"](): string;
/**
* `Rotate`
*/
["com.affine.integration.mcp-server.action.rotate"](): string;
/**
* `Revoke credential`
*/
["com.affine.integration.mcp-server.action.revoke"](): string;
/**
* `Copy token`
*/
["com.affine.integration.mcp-server.action.copy-token"](): string;
/**
* `Copy JSON`
*/
["com.affine.integration.mcp-server.action.copy-json"](): string;
/**
* `Done`
*/
["com.affine.integration.mcp-server.action.done"](): string;
/**
* `Failed to load MCP credentials.`
*/
["com.affine.integration.mcp-server.load-error"](): string;
/**
* `No MCP credentials`
*/
["com.affine.integration.mcp-server.empty.title"](): string;
/**
* `Create a workspace-bound credential to connect an MCP client.`
*/
["com.affine.integration.mcp-server.empty.description"](): string;
/**
* `Read only`
*/
["com.affine.integration.mcp-server.access.read-only"](): string;
/**
* `Can read and search documents in this workspace using your current permissions.`
*/
["com.affine.integration.mcp-server.access.read-only-desc"](): string;
/**
* `Read and write`
*/
["com.affine.integration.mcp-server.access.read-write"](): string;
/**
* `Expires {{date}}`
*/
["com.affine.integration.mcp-server.meta.expires"](options: {
readonly date: string;
}): string;
/**
* `Created {{date}}`
*/
["com.affine.integration.mcp-server.meta.created"](options: {
readonly date: string;
}): string;
/**
* `Last used {{date}}`
*/
["com.affine.integration.mcp-server.meta.last-used"](options: {
readonly date: string;
}): string;
/**
* `Never used`
*/
["com.affine.integration.mcp-server.meta.never-used"](): string;
/**
* `Old token valid until {{date}}`
*/
["com.affine.integration.mcp-server.meta.grace-until"](options: {
readonly date: string;
}): string;
/**
* `Active`
*/
["com.affine.integration.mcp-server.status.active"](): string;
/**
* `Rotating`
*/
["com.affine.integration.mcp-server.status.rotating"](): string;
/**
* `Expiring`
*/
["com.affine.integration.mcp-server.status.expiring"](): string;
/**
* `Expired`
*/
["com.affine.integration.mcp-server.status.expired"](): string;
/**
* `Revoked`
*/
["com.affine.integration.mcp-server.status.revoked"](): string;
/**
* `Create MCP credential`
*/
["com.affine.integration.mcp-server.create.title"](): string;
/**
* `This credential will only work with this workspace's MCP endpoint.`
*/
["com.affine.integration.mcp-server.create.description"](): string;
/**
* `Label`
*/
["com.affine.integration.mcp-server.field.label"](): string;
/**
* `Access`
*/
["com.affine.integration.mcp-server.field.access"](): string;
/**
* `Expires in`
*/
["com.affine.integration.mcp-server.field.expiry"](): string;
/**
* `{{days}} days`
*/
["com.affine.integration.mcp-server.expiry.days"](options: {
readonly days: string;
}): string;
/**
* `MCP credential created`
*/
["com.affine.integration.mcp-server.reveal.title"](): string;
/**
* `Copy this credential now. You won’t be able to see it again.`
*/
["com.affine.integration.mcp-server.reveal.warning"](): string;
/**
* `The old token remains valid until {{date}}.`
*/
["com.affine.integration.mcp-server.reveal.old-valid-until"](options: {
readonly date: string;
}): string;
/**
* `Credential`
*/
["com.affine.integration.mcp-server.reveal.token"](): string;
/**
* `MCP configuration`
*/
["com.affine.integration.mcp-server.reveal.config"](): string;
/**
* `Rotate this credential?`
*/
["com.affine.integration.mcp-server.rotate.title"](): string;
/**
* `A new token will be created immediately. The old token remains valid for up to 24 hours so you can update the MCP client.`
*/
["com.affine.integration.mcp-server.rotate.description"](): string;
/**
* `Revoke “{{name}}”?`
*/
["com.affine.integration.mcp-server.revoke.title"](options: {
readonly name: string;
}): string;
/**
* `All generations of this credential will stop working immediately. This cannot be undone.`
*/
["com.affine.integration.mcp-server.revoke.description"](): string;
/**
* `Supported capabilities`
*/
["com.affine.integration.mcp-server.capabilities.title"](): string;
/**
* `Read documents`
*/
["com.affine.integration.mcp-server.capabilities.read"](): string;
/**
* `Keyword search`
*/
["com.affine.integration.mcp-server.capabilities.keyword-search"](): string;
/**
* `Semantic search`
*/
["com.affine.integration.mcp-server.capabilities.semantic-search"](): string;
/**
* `Create and update documents`
*/
["com.affine.integration.mcp-server.capabilities.write"](): string;
/**
* `Notes`
*/

View File

@@ -2224,6 +2224,50 @@
"com.affine.integration.mcp-server.name": "MCP Server",
"com.affine.integration.mcp-server.desc": "Enable other MCP Client to search and read the doc of AFFiNE.",
"com.affine.integration.mcp-server.copy-json.disabled-hint": "The MCP token is shown only once. Delete and recreate it to copy the JSON configuration.",
"com.affine.integration.mcp-server.credentials.title": "Credentials",
"com.affine.integration.mcp-server.credentials.description": "Use a separate credential for each MCP client so it can be revoked independently.",
"com.affine.integration.mcp-server.action.create": "Create credential",
"com.affine.integration.mcp-server.action.rotate": "Rotate",
"com.affine.integration.mcp-server.action.revoke": "Revoke credential",
"com.affine.integration.mcp-server.action.copy-token": "Copy token",
"com.affine.integration.mcp-server.action.copy-json": "Copy JSON",
"com.affine.integration.mcp-server.action.done": "Done",
"com.affine.integration.mcp-server.load-error": "Failed to load MCP credentials.",
"com.affine.integration.mcp-server.empty.title": "No MCP credentials",
"com.affine.integration.mcp-server.empty.description": "Create a workspace-bound credential to connect an MCP client.",
"com.affine.integration.mcp-server.access.read-only": "Read only",
"com.affine.integration.mcp-server.access.read-only-desc": "Can read and search documents in this workspace using your current permissions.",
"com.affine.integration.mcp-server.access.read-write": "Read and write",
"com.affine.integration.mcp-server.meta.expires": "Expires {{date}}",
"com.affine.integration.mcp-server.meta.created": "Created {{date}}",
"com.affine.integration.mcp-server.meta.last-used": "Last used {{date}}",
"com.affine.integration.mcp-server.meta.never-used": "Never used",
"com.affine.integration.mcp-server.meta.grace-until": "Old token valid until {{date}}",
"com.affine.integration.mcp-server.status.active": "Active",
"com.affine.integration.mcp-server.status.rotating": "Rotating",
"com.affine.integration.mcp-server.status.expiring": "Expiring",
"com.affine.integration.mcp-server.status.expired": "Expired",
"com.affine.integration.mcp-server.status.revoked": "Revoked",
"com.affine.integration.mcp-server.create.title": "Create MCP credential",
"com.affine.integration.mcp-server.create.description": "This credential will only work with this workspace's MCP endpoint.",
"com.affine.integration.mcp-server.field.label": "Label",
"com.affine.integration.mcp-server.field.access": "Access",
"com.affine.integration.mcp-server.field.expiry": "Expires in",
"com.affine.integration.mcp-server.expiry.days": "{{days}} days",
"com.affine.integration.mcp-server.reveal.title": "MCP credential created",
"com.affine.integration.mcp-server.reveal.warning": "Copy this credential now. You won’t be able to see it again.",
"com.affine.integration.mcp-server.reveal.old-valid-until": "The old token remains valid until {{date}}.",
"com.affine.integration.mcp-server.reveal.token": "Credential",
"com.affine.integration.mcp-server.reveal.config": "MCP configuration",
"com.affine.integration.mcp-server.rotate.title": "Rotate this credential?",
"com.affine.integration.mcp-server.rotate.description": "A new token will be created immediately. The old token remains valid for up to 24 hours so you can update the MCP client.",
"com.affine.integration.mcp-server.revoke.title": "Revoke “{{name}}”?",
"com.affine.integration.mcp-server.revoke.description": "All generations of this credential will stop working immediately. This cannot be undone.",
"com.affine.integration.mcp-server.capabilities.title": "Supported capabilities",
"com.affine.integration.mcp-server.capabilities.read": "Read documents",
"com.affine.integration.mcp-server.capabilities.keyword-search": "Keyword search",
"com.affine.integration.mcp-server.capabilities.semantic-search": "Semantic search",
"com.affine.integration.mcp-server.capabilities.write": "Create and update documents",
"com.affine.audio.notes": "Notes",
"com.affine.audio.transcribing": "Transcribing",
"com.affine.audio.transcribe.non-owner.confirm.title": "Unable to retrieve AI results for others",

View File

@@ -2224,6 +2224,50 @@
"com.affine.integration.mcp-server.name": "MCP服务器",
"com.affine.integration.mcp-server.desc": "允许其他 MCP 客户端搜索和 AFFiNE 的文档。",
"com.affine.integration.mcp-server.copy-json.disabled-hint": "MCP token 仅显示一次。请删除并重新创建,以复制 JSON 配置。",
"com.affine.integration.mcp-server.credentials.title": "凭证",
"com.affine.integration.mcp-server.credentials.description": "为每个 MCP 客户端使用独立凭证,以便单独吊销。",
"com.affine.integration.mcp-server.action.create": "创建凭证",
"com.affine.integration.mcp-server.action.rotate": "轮换",
"com.affine.integration.mcp-server.action.revoke": "吊销凭证",
"com.affine.integration.mcp-server.action.copy-token": "复制 token",
"com.affine.integration.mcp-server.action.copy-json": "复制 JSON",
"com.affine.integration.mcp-server.action.done": "完成",
"com.affine.integration.mcp-server.load-error": "加载 MCP 凭证失败。",
"com.affine.integration.mcp-server.empty.title": "暂无 MCP 凭证",
"com.affine.integration.mcp-server.empty.description": "创建绑定当前工作区的凭证以连接 MCP 客户端。",
"com.affine.integration.mcp-server.access.read-only": "只读",
"com.affine.integration.mcp-server.access.read-only-desc": "可按你的当前权限读取和搜索此工作区的文档。",
"com.affine.integration.mcp-server.access.read-write": "读写",
"com.affine.integration.mcp-server.meta.expires": "{{date}} 到期",
"com.affine.integration.mcp-server.meta.created": "创建于 {{date}}",
"com.affine.integration.mcp-server.meta.last-used": "最近使用于 {{date}}",
"com.affine.integration.mcp-server.meta.never-used": "从未使用",
"com.affine.integration.mcp-server.meta.grace-until": "旧 token 有效至 {{date}}",
"com.affine.integration.mcp-server.status.active": "有效",
"com.affine.integration.mcp-server.status.rotating": "轮换中",
"com.affine.integration.mcp-server.status.expiring": "即将到期",
"com.affine.integration.mcp-server.status.expired": "已到期",
"com.affine.integration.mcp-server.status.revoked": "已吊销",
"com.affine.integration.mcp-server.create.title": "创建 MCP 凭证",
"com.affine.integration.mcp-server.create.description": "此凭证只能用于当前工作区的 MCP endpoint。",
"com.affine.integration.mcp-server.field.label": "名称",
"com.affine.integration.mcp-server.field.access": "权限",
"com.affine.integration.mcp-server.field.expiry": "有效期",
"com.affine.integration.mcp-server.expiry.days": "{{days}} 天",
"com.affine.integration.mcp-server.reveal.title": "MCP 凭证已创建",
"com.affine.integration.mcp-server.reveal.warning": "请立即复制此凭证,关闭后将无法再次查看。",
"com.affine.integration.mcp-server.reveal.old-valid-until": "旧 token 在 {{date}} 前仍然有效。",
"com.affine.integration.mcp-server.reveal.token": "凭证",
"com.affine.integration.mcp-server.reveal.config": "MCP 配置",
"com.affine.integration.mcp-server.rotate.title": "轮换此凭证?",
"com.affine.integration.mcp-server.rotate.description": "系统将立即创建新 token;旧 token 最多保留 24 小时,以便更新 MCP 客户端。",
"com.affine.integration.mcp-server.revoke.title": "吊销“{{name}}”?",
"com.affine.integration.mcp-server.revoke.description": "此凭证的所有世代会立即失效,且无法恢复。",
"com.affine.integration.mcp-server.capabilities.title": "支持的能力",
"com.affine.integration.mcp-server.capabilities.read": "读取文档",
"com.affine.integration.mcp-server.capabilities.keyword-search": "关键词搜索",
"com.affine.integration.mcp-server.capabilities.semantic-search": "语义搜索",
"com.affine.integration.mcp-server.capabilities.write": "创建和更新文档",
"com.affine.audio.notes": "笔记",
"com.affine.audio.transcribing": "转录",
"com.affine.audio.transcribe.non-owner.confirm.title": "无法检索他人的 AI 结果",