Files
agent-fox/packages/web/src/pages/tabs/McpIntegration.tsx
YANG JIANKUAN 95198e6a07 feat: 完善 MCP 接入体验与协议层稳定性
接入体验:
- 客户端选择器覆盖 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>
2026-05-02 12:52:47 +08:00

225 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}