接入体验:
- 客户端选择器覆盖 7 个客户端(Claude Desktop/Code、Cursor、Cline、
Windsurf、Codex、GitHub Copilot),自动生成对应配置
- API Key 一键含 key 复制(密码验证后写入剪贴板)
- 公开 /mcp/:projectId/info + Test connection 按钮
- 项目新增 Usage 标签(每日柱状图 + 工具调用分布)
- 导入完成后引导跳转到 MCP 配置
协议层稳定性:
- session 超时清理(5 分钟扫描 / 30 分钟 idle 关闭)
- 错误响应标准化为 { error: { code, message } }
- 调用日志捕获错误信息 + 4KB payload 截断
- McpCallLog 增加 errorMessage 字段(migration)
抽出可复用模块:PasswordRevealPrompt 组件、BarChart 组件、
trends helper(daysWindowStart + getDailyTrends)。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
225 lines
9.8 KiB
TypeScript
225 lines
9.8 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { apiFetch } from '../../lib/api';
|
||
import { useI18n } from '../../lib/i18n';
|
||
import { useLayoutContext } from '../Layout';
|
||
import PasswordRevealPrompt from '../../components/PasswordRevealPrompt';
|
||
import { MCP_CLIENTS, buildClientConfig, sanitizeServerName, type McpClientId } from '../../lib/mcp-clients';
|
||
|
||
type Project = { id: string; name: string };
|
||
|
||
type ApiKeyStatus = { hasKey: boolean; prefix: string | null };
|
||
|
||
type McpInfo = {
|
||
projectName: string;
|
||
openApiVersion: string;
|
||
totalEndpoints: number;
|
||
totalModules: number;
|
||
toolCount: number;
|
||
};
|
||
|
||
type TestStatus =
|
||
| { kind: 'idle' }
|
||
| { kind: 'loading' }
|
||
| { kind: 'ok'; info: McpInfo }
|
||
| { kind: 'error'; message: string };
|
||
|
||
export default function McpIntegration({ project }: { project: Project }) {
|
||
const { t } = useI18n();
|
||
const { onOpenSettings } = useLayoutContext();
|
||
|
||
const mcpUrl = `${window.location.origin}/mcp/${project.id}`;
|
||
const serverName = useMemo(() => sanitizeServerName(project.name), [project.name]);
|
||
|
||
const [activeClient, setActiveClient] = useState<McpClientId>('claude-desktop');
|
||
const [revealedKey, setRevealedKey] = useState<string | null>(null);
|
||
const [passwordPromptOpen, setPasswordPromptOpen] = useState(false);
|
||
const [copied, setCopied] = useState<string | null>(null);
|
||
const [testStatus, setTestStatus] = useState<TestStatus>({ kind: 'idle' });
|
||
|
||
const { data: keyStatus } = useQuery({
|
||
queryKey: ['api-key-status'],
|
||
queryFn: () => apiFetch<ApiKeyStatus>('/auth/api-key/status'),
|
||
});
|
||
|
||
useEffect(() => { setTestStatus({ kind: 'idle' }); }, [mcpUrl]);
|
||
|
||
const client = MCP_CLIENTS.find((c) => c.id === activeClient) ?? MCP_CLIENTS[0];
|
||
const configSnippet = buildClientConfig(client, serverName, mcpUrl, revealedKey);
|
||
|
||
const copyText = (text: string, key: string) => {
|
||
navigator.clipboard.writeText(text);
|
||
setCopied(key);
|
||
setTimeout(() => setCopied(null), 2000);
|
||
};
|
||
|
||
const requestRevealAndCopy = () => {
|
||
if (!keyStatus?.hasKey) return;
|
||
if (revealedKey) {
|
||
copyText(buildClientConfig(client, serverName, mcpUrl, revealedKey), 'config');
|
||
return;
|
||
}
|
||
setPasswordPromptOpen(true);
|
||
};
|
||
|
||
const handleReveal = (apiKey: string) => {
|
||
setRevealedKey(apiKey);
|
||
// Copy synchronously off the user gesture chain — re-using `apiKey` instead
|
||
// of `revealedKey` because state hasn't propagated yet.
|
||
copyText(buildClientConfig(client, serverName, mcpUrl, apiKey), 'config');
|
||
setPasswordPromptOpen(false);
|
||
};
|
||
|
||
const testConnection = async () => {
|
||
setTestStatus({ kind: 'loading' });
|
||
try {
|
||
const res = await fetch(`${mcpUrl}/info`);
|
||
if (!res.ok) {
|
||
setTestStatus({ kind: 'error', message: `HTTP ${res.status}` });
|
||
return;
|
||
}
|
||
const info = (await res.json()) as McpInfo;
|
||
setTestStatus({ kind: 'ok', info });
|
||
} catch (err) {
|
||
setTestStatus({ kind: 'error', message: err instanceof Error ? err.message : 'Network error' });
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-2xl space-y-8">
|
||
{/* MCP URL */}
|
||
<section>
|
||
<p className="section-title">{t('dashboard.mcp.urlTitle')}</p>
|
||
<p className="section-desc mb-3">{t('dashboard.mcp.urlDesc')}</p>
|
||
<div className="flex items-center gap-2">
|
||
<code className="flex-1 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted text-[13px] font-mono text-text-primary truncate">{mcpUrl}</code>
|
||
<button onClick={() => copyText(mcpUrl, 'url')} className="btn-outline shrink-0">
|
||
{copied === 'url' ? t('common.copied') : t('common.copy')}
|
||
</button>
|
||
<button onClick={testConnection} disabled={testStatus.kind === 'loading'} className="btn-outline shrink-0">
|
||
{testStatus.kind === 'loading' ? t('dashboard.mcp.testing') : t('dashboard.mcp.testConnection')}
|
||
</button>
|
||
</div>
|
||
{testStatus.kind === 'ok' && (
|
||
<div className="mt-2.5 p-3 rounded-lg bg-success-muted text-[12px] text-text-secondary flex items-start gap-2">
|
||
<svg className="w-4 h-4 text-success shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>
|
||
<div>
|
||
<div className="font-medium text-success">{t('dashboard.mcp.testOk')}</div>
|
||
<div className="text-text-muted mt-0.5">
|
||
{testStatus.info.projectName} · OpenAPI {testStatus.info.openApiVersion} · {testStatus.info.totalModules} {t('common.modules')} · {testStatus.info.totalEndpoints} {t('common.endpoints')} · {testStatus.info.toolCount} tools
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{testStatus.kind === 'error' && (
|
||
<div className="mt-2.5 p-3 rounded-lg bg-danger-muted text-[12px] text-danger flex items-center gap-2">
|
||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M6 18L18 6M6 6l12 12" /></svg>
|
||
<span>{t('dashboard.mcp.testFail')}: {testStatus.message}</span>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Client selector + Config */}
|
||
<section>
|
||
<p className="section-title">{t('dashboard.mcp.configTitle')}</p>
|
||
<p className="section-desc mb-3">{t('dashboard.mcp.configDesc')}</p>
|
||
|
||
{/* Client tabs */}
|
||
<div className="flex flex-wrap gap-1 p-1 rounded-lg bg-bg-tertiary border border-border-muted mb-3">
|
||
{MCP_CLIENTS.map((c) => (
|
||
<button
|
||
key={c.id}
|
||
onClick={() => setActiveClient(c.id)}
|
||
className={`px-3 py-1.5 rounded-md text-[12px] font-medium transition-all ${
|
||
activeClient === c.id
|
||
? 'bg-bg-elevated text-text-primary shadow-sm'
|
||
: 'text-text-muted hover:text-text-secondary'
|
||
}`}
|
||
>
|
||
{c.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Setup hint */}
|
||
<div className="mb-3 p-3 rounded-lg bg-bg-tertiary border border-border-muted text-[12px] text-text-secondary space-y-1">
|
||
<div>
|
||
<span className="text-text-muted">{t('dashboard.mcp.configLocation')}:</span>
|
||
<code className="font-mono text-text-primary">{client.configPath}</code>
|
||
</div>
|
||
{client.configPathAlt && (
|
||
<div>
|
||
<span className="text-text-muted">{t('dashboard.mcp.configLocationAlt')}:</span>
|
||
<code className="font-mono text-text-primary">{client.configPathAlt}</code>
|
||
</div>
|
||
)}
|
||
<div className="text-text-muted pt-1">{client.restartHint}</div>
|
||
</div>
|
||
|
||
{/* Config snippet */}
|
||
<div className="relative">
|
||
<pre className="code-block text-xs">{configSnippet}</pre>
|
||
<div className="absolute top-2.5 right-2.5 flex gap-1.5">
|
||
{keyStatus?.hasKey && !revealedKey && (
|
||
<button
|
||
onClick={requestRevealAndCopy}
|
||
className="copy-btn"
|
||
title={t('dashboard.mcp.copyWithKey')}
|
||
>
|
||
{t('dashboard.mcp.copyWithKey')}
|
||
</button>
|
||
)}
|
||
<button onClick={() => copyText(configSnippet, 'config')} className="copy-btn">
|
||
{copied === 'config' ? `${t('common.copied')}!` : t('common.copy')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3">
|
||
<PasswordRevealPrompt
|
||
open={passwordPromptOpen}
|
||
promptText={t('dashboard.mcp.revealPrompt')}
|
||
onCancel={() => setPasswordPromptOpen(false)}
|
||
onReveal={handleReveal}
|
||
onSetPasswordClick={() => { setPasswordPromptOpen(false); onOpenSettings(); }}
|
||
/>
|
||
</div>
|
||
|
||
{/* API Key status */}
|
||
{keyStatus && !keyStatus.hasKey && (
|
||
<div className="mt-3 flex items-center gap-3 p-3.5 rounded-lg bg-warning-muted border border-warning/20">
|
||
<svg className="w-4 h-4 text-warning shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||
<p className="text-[13px] text-text-secondary flex-1">{t('dashboard.mcp.noKeyWarning')}</p>
|
||
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5 px-3">
|
||
{t('dashboard.mcp.openSettings')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Available tools */}
|
||
<section>
|
||
<p className="section-title">{t('dashboard.mcp.toolsTitle')}</p>
|
||
<p className="section-desc mb-3">{t('dashboard.mcp.toolsDesc')}</p>
|
||
<div className="space-y-1.5 stagger-children">
|
||
{[
|
||
{ name: 'get_project_overview', desc: t('dashboard.mcp.tool1Desc'), num: '1' },
|
||
{ name: 'list_modules', desc: t('dashboard.mcp.tool2Desc'), num: '2' },
|
||
{ name: 'list_endpoints', desc: t('dashboard.mcp.tool3Desc'), num: '3' },
|
||
{ name: 'get_endpoint_detail', desc: t('dashboard.mcp.tool4Desc'), num: '4' },
|
||
{ name: 'search_endpoints', desc: t('dashboard.mcp.tool5Desc'), num: '5' },
|
||
].map((tool) => (
|
||
<div key={tool.name} className="card px-4 py-3 flex items-start gap-3">
|
||
<span className="w-5 h-5 rounded-full bg-accent-muted text-accent text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">{tool.num}</span>
|
||
<div className="min-w-0">
|
||
<code className="text-[13px] font-mono font-medium text-accent">{tool.name}</code>
|
||
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{tool.desc}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|