mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-18 18:41:52 +08:00
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:
+209
@@ -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>
|
||||
);
|
||||
};
|
||||
+160
-35
@@ -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,
|
||||
});
|
||||
|
||||
+292
-208
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user