Phase G1-1~G1-6: MCP Marketplace Fusion - DB migration, mcp_market_admin.py, Deep Link generator, basic security scanner, McpMarketTab.tsx frontend, Market.tsx integration

This commit is contained in:
qyyfw committed 2026-07-06 11:48:21 +08:00
1 parent 49dcea0a11
commit 909c013e2d
5 files changed
+1985 -1

No files matched your search

+23
View File
@@ -626,6 +626,29 @@ export const adminAPI = {
}).then(res => res.json());
},
// MCP Market (Phase G1)
getMcpMarketStats: () =>
get<any>('/admin/mcp-market/stats'),
getMcpTools: (params?: Record<string, any>) =>
get<any>('/admin/mcp-market/?' + new URLSearchParams(params || {}).toString()),
getMcpToolDetail: (id: string) =>
get<any>('/admin/mcp-market/' + id),
createMcpTool: (data: any) =>
post<any>('/admin/mcp-market/', data),
updateMcpTool: (id: string, data: any) =>
put<any>('/admin/mcp-market/' + id, data),
deleteMcpTool: (id: string) =>
del<any>('/admin/mcp-market/' + id),
scanMcpTool: (id: string) =>
post<any>('/admin/mcp-market/' + id + '/security-scan'),
updateMcpToolSecurity: (id: string, data: any) =>
put<any>('/admin/mcp-market/' + id + '/security-status', data),
regenMcpDeepLink: (id: string) =>
post<any>('/admin/mcp-market/' + id + '/deep-link'),
getMcpManagedServers: () =>
get<any>('/admin/mcp-market/managed/servers'),
seedMcpTools: () =>
post<any>('/admin/mcp-market/seed'),
};
// ===== AI自进化API =====
+7 -1
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import McpMarketTab from './McpMarketTab';
// ============================================================
// Types
@@ -112,7 +113,7 @@ const Icon = ({ name, size = 18, color = 'currentColor' }: { name: string; size?
// Main Component
// ============================================================
const Market: React.FC = () => {
const [activeTab, setActiveTab] = useState<'overview' | 'apps' | 'pending' | 'coupons' | 'reviews'>('overview');
const [activeTab, setActiveTab] = useState<'overview' | 'apps' | 'pending' | 'coupons' | 'reviews' | 'mcp'>('overview');
const [loading, setLoading] = useState(false);
// Overview data
@@ -262,6 +263,7 @@ const Market: React.FC = () => {
{ key: 'pending', label: `待审核${pendingTotal > 0 ? `(${pendingTotal})` : ''}`, icon: 'eye' },
{ key: 'coupons', label: '优惠券', icon: 'tag' },
{ key: 'reviews', label: '用户评价', icon: 'star' },
{ key: 'mcp', label: 'MCP工具', icon: 'tool' },
] as const;
return (
@@ -343,6 +345,10 @@ const Market: React.FC = () => {
<ReviewsList reviews={reviews} />
)}
{!loading && activeTab === 'mcp' && (
<McpMarketTab />
)}
{/* Edit Modal */}
{editApp && (
<EditAppModal app={editApp} onClose={() => setEditApp(null)} onSaved={() => { setEditApp(null); loadApps(); }} />
+729
View File
@@ -0,0 +1,729 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
// ============================================================
// Types
// ============================================================
interface McpTool {
id: string;
app_id: string;
name: string;
description: string | null;
icon: string | null;
category: string;
tags: string | null;
status: string;
publisher_name: string | null;
price: number;
is_member_free: boolean;
featured: boolean;
rating_avg: number;
install_count: number;
version: string;
server_type: string;
runtime: string | null;
command: string | null;
is_managed: boolean;
security_status: string;
security_issues: number;
deep_link: string | null;
package_name: string | null;
package_registry: string | null;
tools_count: number;
created_at: string | null;
}
interface McpToolDetail extends McpTool {
app: {
name: string;
description: string | null;
category: string;
status: string;
tutorial: string | null;
};
args: string[] | null;
env_vars: Record<string, string> | null;
transport_url: string | null;
builtin_name: string | null;
managed_url: string | null;
managed_port: number | null;
package_version: string;
package_repo_url: string | null;
security_report: Record<string, any> | null;
security_scanned_at: string | null;
permissions: Record<string, any> | null;
config_schema: Record<string, any> | null;
install_instructions: string | null;
tools_list: string[] | null;
author_name: string | null;
license: string;
homepage_url: string | null;
}
interface McpStats {
total: number;
published: number;
pending_review: number;
managed_servers: number;
by_security_status: Record<string, number>;
by_server_type: Record<string, number>;
by_category: Record<string, number>;
}
const SERVER_TYPE_LABELS: Record<string, string> = {
stdio: 'STDIO',
sse: 'SSE',
streamable_http: 'HTTP',
builtin: '内置',
};
const SECURITY_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '待审核', color: '#d29922' },
scanning: { label: '扫描中', color: '#58a6ff' },
approved: { label: '已通过', color: '#3fb950' },
rejected: { label: '已拒绝', color: '#f85149' },
failed: { label: '扫描失败', color: '#f85149' },
};
const MCP_CATEGORY_LABELS: Record<string, string> = {
developer: '开发工具', database: '数据库', search: '搜索检索',
file: '文件系统', browser: '浏览器', design: '设计创意',
data: '数据处理', communication: '通信协作', cloud: '云服务',
productivity: '效率工具', other: '其他',
};
// ============================================================
// Main Component
// ============================================================
const McpMarketTab: React.FC = () => {
const [tools, setTools] = useState<McpTool[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<McpStats | null>(null);
const [keyword, setKeyword] = useState('');
const [filterServerType, setFilterServerType] = useState('');
const [filterSecurity, setFilterSecurity] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [detailTool, setDetailTool] = useState<McpToolDetail | null>(null);
const loadStats = useCallback(async () => {
try {
const res = await adminAPI.getMcpMarketStats();
if (res?.code === 0) setStats(res.data);
} catch (e) { console.error(e); }
}, []);
const loadTools = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, any> = { page, page_size: 20 };
if (keyword) params.keyword = keyword;
if (filterServerType) params.server_type = filterServerType;
if (filterSecurity) params.security_status = filterSecurity;
const res = await adminAPI.getMcpTools(params);
if (res?.code === 0) {
setTools(res.data.items || []);
setTotal(res.data.total || 0);
}
} catch (e) { console.error(e); }
setLoading(false);
}, [page, keyword, filterServerType, filterSecurity]);
useEffect(() => { loadStats(); }, [loadStats]);
useEffect(() => { loadTools(); }, [loadTools]);
const handleSeed = async () => {
if (!confirm('确认填充 Goose 内置 MCP 工具到市场?')) return;
try {
const res = await adminAPI.seedMcpTools();
if (res?.code === 0) {
alert(res.message || '填充成功');
loadStats();
loadTools();
}
} catch (e) { alert('操作失败'); }
};
const handleScan = async (id: string) => {
try {
const res = await adminAPI.scanMcpTool(id);
if (res?.code === 0) {
alert(`扫描完成: ${res.data?.status} (${res.data?.issues_count} 个问题)`);
loadTools();
}
} catch (e) { alert('扫描失败'); }
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`确认删除 MCP 工具「${name}」?`)) return;
try {
await adminAPI.deleteMcpTool(id);
loadTools();
loadStats();
} catch (e) { alert('删除失败'); }
};
const handleViewDetail = async (id: string) => {
try {
const res = await adminAPI.getMcpToolDetail(id);
if (res?.code === 0) setDetailTool(res.data);
} catch (e) { alert('加载详情失败'); }
};
const copyDeepLink = (link: string) => {
navigator.clipboard.writeText(link).then(() => {
alert('Deep Link 已复制到剪贴板!');
}).catch(() => {
prompt('请手动复制:', link);
});
};
const totalPages = Math.ceil(total / 20);
return (
<div>
{/* Stats Cards */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '20px' }}>
<StatCard label="MCP 工具总数" value={stats.total} sub={`${stats.published} 已发布`} color="var(--color-primary, #58a6ff)" />
<StatCard label="待审核" value={stats.pending_review} sub="安全审核中" color="#d29922" />
<StatCard label="托管服务器" value={stats.managed_servers} sub="平台托管" color="#3fb950" />
<StatCard label="已通过审核" value={stats.by_security_status?.approved || 0} sub="可发布" color="#bc8cff" />
</div>
)}
{/* Toolbar */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
placeholder="搜索 MCP 工具..."
value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
style={{
flex: 1, minWidth: 200, padding: '8px 12px',
background: 'var(--color-bg-secondary, #161b22)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8, color: 'var(--color-text-primary, #e6edf3)', fontSize: 14,
}}
/>
<select
value={filterServerType}
onChange={(e) => { setFilterServerType(e.target.value); setPage(1); }}
style={selectStyle}
>
<option value="">全部类型</option>
{Object.entries(SERVER_TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<select
value={filterSecurity}
onChange={(e) => { setFilterSecurity(e.target.value); setPage(1); }}
style={selectStyle}
>
<option value="">全部状态</option>
{Object.entries(SECURITY_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
<button onClick={handleSeed} style={btnSecondaryStyle}>填充内置工具</button>
<button onClick={() => setShowCreate(true)} style={btnPrimaryStyle}>+ 创建 MCP 工具</button>
</div>
{/* Tools List */}
{loading ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-tertiary)' }}>加载中...</div>
) : tools.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-tertiary)' }}>
暂无 MCP 工具。点击"填充内置工具"快速导入 Goose 内置扩展。
</div>
) : (
<div style={{ display: 'grid', gap: '12px' }}>
{tools.map(tool => (
<ToolCard
key={tool.id}
tool={tool}
onView={() => handleViewDetail(tool.id)}
onScan={() => handleScan(tool.id)}
onDelete={() => handleDelete(tool.id, tool.name)}
onCopyLink={() => tool.deep_link && copyDeepLink(tool.deep_link)}
/>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)} style={pageBtnStyle}>上一页</button>
<span style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)} style={pageBtnStyle}>下一页</button>
</div>
)}
{/* Create Modal */}
{showCreate && (
<CreateMcpToolModal
onClose={() => setShowCreate(false)}
onSaved={() => { setShowCreate(false); loadTools(); loadStats(); }}
/>
)}
{/* Detail Modal */}
{detailTool && (
<McpToolDetailModal
tool={detailTool}
onClose={() => setDetailTool(null)}
onCopyLink={() => detailTool.deep_link && copyDeepLink(detailTool.deep_link)}
/>
)}
</div>
);
};
// ============================================================
// Sub Components
// ============================================================
const StatCard: React.FC<{ label: string; value: number; sub: string; color: string }> = ({ label, value, sub, color }) => (
<div style={{
background: 'var(--color-bg-secondary, #161b22)',
borderRadius: 12, padding: 20,
border: '1px solid var(--color-border, #30363d)',
}}>
<div style={{ fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 28, fontWeight: 700, color }}>{value}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 4 }}>{sub}</div>
</div>
);
const ToolCard: React.FC<{
tool: McpTool;
onView: () => void;
onScan: () => void;
onDelete: () => void;
onCopyLink: () => void;
}> = ({ tool, onView, onScan, onDelete, onCopyLink }) => {
const sec = SECURITY_LABELS[tool.security_status] || { label: tool.security_status, color: '#8b949e' };
const stLabel = SERVER_TYPE_LABELS[tool.server_type] || tool.server_type;
const catLabel = MCP_CATEGORY_LABELS[tool.category] || tool.category;
return (
<div style={{
background: 'var(--color-bg-secondary, #161b22)',
borderRadius: 12, padding: 16,
border: '1px solid var(--color-border, #30363d)',
display: 'flex', alignItems: 'flex-start', gap: 16,
}}>
{/* Icon */}
<div style={{
width: 48, height: 48, borderRadius: 10, flexShrink: 0,
background: tool.server_type === 'builtin' ? 'linear-gradient(135deg, #58a6ff, #bc8cff)' : 'var(--color-bg-tertiary, #21262d)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, fontWeight: 700, color: '#fff',
}}>
{tool.name.charAt(0).toUpperCase()}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text-primary, #e6edf3)' }}>{tool.name}</span>
<span style={{ ...badgeStyle, background: `${sec.color}22`, color: sec.color }}>{sec.label}</span>
<span style={{ ...badgeStyle, background: '#58a6ff22', color: '#58a6ff' }}>{stLabel}</span>
{tool.is_managed && <span style={{ ...badgeStyle, background: '#3fb95022', color: '#3fb950' }}>托管</span>}
{tool.tools_count > 0 && <span style={{ ...badgeStyle, background: '#8b949e22', color: '#8b949e' }}>{tool.tools_count} tools</span>}
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 6 }}>
{tool.description || '暂无描述'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', display: 'flex', gap: 12 }}>
<span>{catLabel}</span>
{tool.package_name && <span>{tool.package_name}</span>}
{tool.publisher_name && <span>by {tool.publisher_name}</span>}
{tool.security_issues > 0 && <span style={{ color: '#f85149' }}>{tool.security_issues} 个安全问题</span>}
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button onClick={onView} style={btnSmallStyle}>详情</button>
{tool.deep_link && <button onClick={onCopyLink} style={btnSmallStyle}>Deep Link</button>}
<button onClick={onScan} style={btnSmallStyle}>扫描</button>
<button onClick={onDelete} style={{ ...btnSmallStyle, color: '#f85149' }}>删除</button>
</div>
</div>
);
};
const CreateMcpToolModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
const [form, setForm] = useState({
name: '', description: '', category: 'developer',
server_type: 'stdio', runtime: 'node', command: '', args: '',
package_name: '', package_registry: 'npm', package_repo_url: '',
transport_url: '', builtin_name: '',
author_name: '', license: 'MIT', tools_count: 0,
install_instructions: '',
});
const [saving, setSaving] = useState(false);
const handleSubmit = async () => {
if (!form.name) { alert('请输入工具名称'); return; }
if (form.server_type === 'stdio' && !form.command) { alert('STDIO 类型需要启动命令'); return; }
if (form.server_type === 'builtin' && !form.builtin_name) { alert('内置类型需要扩展名称'); return; }
if ((form.server_type === 'sse' || form.server_type === 'streamable_http') && !form.transport_url) { alert('SSE/HTTP 类型需要传输 URL'); return; }
setSaving(true);
try {
const payload: Record<string, any> = { ...form };
if (form.args) {
try { payload.args = JSON.parse(form.args); } catch { payload.args = form.args.split(' '); }
} else {
delete payload.args;
}
if (!form.tools_count) delete payload.tools_count;
if (!form.transport_url) delete payload.transport_url;
if (!form.builtin_name) delete payload.builtin_name;
const res = await adminAPI.createMcpTool(payload);
if (res?.code === 0) {
alert(`创建成功!安全状态: ${res.data?.security_status || 'pending'}`);
onSaved();
} else {
alert(res?.message || '创建失败');
}
} catch (e: any) {
alert('创建失败: ' + (e.message || '未知错误'));
}
setSaving(false);
};
return (
<div style={modalOverlayStyle}>
<div style={modalContentStyle}>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 20, color: 'var(--color-text-primary)' }}>创建 MCP 工具</h2>
<div style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: 8 }}>
<FormField label="工具名称 *">
<input style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如: Filesystem MCP" />
</FormField>
<FormField label="描述">
<textarea style={{ ...inputStyle, minHeight: 60 }} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="分类">
<select style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{Object.entries(MCP_CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</FormField>
<FormField label="服务器类型">
<select style={inputStyle} value={form.server_type} onChange={(e) => setForm({ ...form, server_type: e.target.value })}>
{Object.entries(SERVER_TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</FormField>
</div>
{form.server_type === 'stdio' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="运行时">
<select style={inputStyle} value={form.runtime} onChange={(e) => setForm({ ...form, runtime: e.target.value })}>
<option value="node">Node.js</option><option value="python">Python</option>
<option value="go">Go</option><option value="rust">Rust</option>
<option value="binary">二进制</option>
</select>
</FormField>
<FormField label="启动命令 *">
<input style={inputStyle} value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} placeholder="如: npx" />
</FormField>
</div>
<FormField label="参数 (JSON 数组或空格分隔)">
<input style={inputStyle} value={form.args} onChange={(e) => setForm({ ...form, args: e.target.value })} placeholder='如: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]' />
</FormField>
</>
)}
{form.server_type === 'builtin' && (
<FormField label="内置扩展名 *">
<input style={inputStyle} value={form.builtin_name} onChange={(e) => setForm({ ...form, builtin_name: e.target.value })} placeholder="如: developer" />
</FormField>
)}
{(form.server_type === 'sse' || form.server_type === 'streamable_http') && (
<FormField label="传输 URL *">
<input style={inputStyle} value={form.transport_url} onChange={(e) => setForm({ ...form, transport_url: e.target.value })} placeholder="https://..." />
</FormField>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="包名">
<input style={inputStyle} value={form.package_name} onChange={(e) => setForm({ ...form, package_name: e.target.value })} placeholder="@modelcontextprotocol/server-filesystem" />
</FormField>
<FormField label="包源">
<select style={inputStyle} value={form.package_registry} onChange={(e) => setForm({ ...form, package_registry: e.target.value })}>
<option value="npm">npm</option><option value="pypi">pypi</option>
<option value="github">GitHub</option><option value="custom">自定义</option>
</select>
</FormField>
</div>
<FormField label="仓库 URL">
<input style={inputStyle} value={form.package_repo_url} onChange={(e) => setForm({ ...form, package_repo_url: e.target.value })} placeholder="https://github.com/..." />
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="作者">
<input style={inputStyle} value={form.author_name} onChange={(e) => setForm({ ...form, author_name: e.target.value })} />
</FormField>
<FormField label="许可证">
<input style={inputStyle} value={form.license} onChange={(e) => setForm({ ...form, license: e.target.value })} />
</FormField>
</div>
<FormField label="工具数量">
<input type="number" style={inputStyle} value={form.tools_count} onChange={(e) => setForm({ ...form, tools_count: parseInt(e.target.value) || 0 })} />
</FormField>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', marginTop: 20 }}>
<button onClick={onClose} style={btnSecondaryStyle}>取消</button>
<button onClick={handleSubmit} disabled={saving} style={btnPrimaryStyle}>{saving ? '创建中...' : '创建'}</button>
</div>
</div>
</div>
);
};
const McpToolDetailModal: React.FC<{ tool: McpToolDetail; onClose: () => void; onCopyLink: () => void }> = ({ tool, onClose, onCopyLink }) => {
const sec = SECURITY_LABELS[tool.security_status] || { label: tool.security_status, color: '#8b949e' };
return (
<div style={modalOverlayStyle}>
<div style={{ ...modalContentStyle, maxWidth: 700 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text-primary)' }}>{tool.app.name}</h2>
<button onClick={onClose} style={{ ...btnSmallStyle, fontSize: 18 }}>x</button>
</div>
<div style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: 8 }}>
{/* Badges */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<span style={{ ...badgeStyle, background: `${sec.color}22`, color: sec.color }}>{sec.label}</span>
<span style={{ ...badgeStyle, background: '#58a6ff22', color: '#58a6ff' }}>{SERVER_TYPE_LABELS[tool.server_type] || tool.server_type}</span>
{tool.is_managed && <span style={{ ...badgeStyle, background: '#3fb95022', color: '#3fb950' }}>平台托管</span>}
<span style={{ ...badgeStyle, background: '#8b949e22', color: '#8b949e' }}>{tool.tools_count} 个工具</span>
<span style={{ ...badgeStyle, background: '#bc8cff22', color: '#bc8cff' }}>{tool.license}</span>
</div>
{/* Description */}
{tool.app.description && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>描述</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>{tool.app.description}</div>
</div>
)}
{/* Deep Link */}
{tool.deep_link && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>Goose Deep Link</div>
<div style={{ display: 'flex', gap: 8 }}>
<input readOnly value={tool.deep_link} style={{ ...inputStyle, flex: 1, fontFamily: 'monospace', fontSize: 12 }} />
<button onClick={onCopyLink} style={btnPrimaryStyle}>复制</button>
</div>
</div>
)}
{/* Config */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
{tool.command && <DetailItem label="启动命令" value={tool.command} />}
{tool.runtime && <DetailItem label="运行时" value={tool.runtime} />}
{tool.transport_url && <DetailItem label="传输 URL" value={tool.transport_url} />}
{tool.builtin_name && <DetailItem label="内置扩展" value={tool.builtin_name} />}
{tool.package_name && <DetailItem label="包名" value={tool.package_name} />}
<DetailItem label="版本" value={tool.package_version} />
{tool.author_name && <DetailItem label="作者" value={tool.author_name} />}
{tool.managed_url && <DetailItem label="托管 URL" value={tool.managed_url} />}
</div>
{/* Args */}
{tool.args && tool.args.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>启动参数</div>
<code style={{ ...codeBlockStyle, display: 'block' }}>{JSON.stringify(tool.args, null, 2)}</code>
</div>
)}
{/* Tools List */}
{tool.tools_list && tool.tools_list.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>暴露的工具 ({tool.tools_list.length})</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{tool.tools_list.map((t, i) => (
<span key={i} style={{ ...badgeStyle, background: '#30363d', color: '#8b949e', fontFamily: 'monospace' }}>{t}</span>
))}
</div>
</div>
)}
{/* Permissions */}
{tool.permissions && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>权限声明</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(tool.permissions).map(([k, v]) => (
<span key={k} style={{ ...badgeStyle, background: v ? '#f8514922' : '#30363d', color: v ? '#f85149' : '#8b949e' }}>
{k}: {v ? '是' : '否'}
</span>
))}
</div>
</div>
)}
{/* Security Report */}
{tool.security_report && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>安全扫描报告</div>
<div style={{ ...codeBlockStyle, whiteSpace: 'pre-wrap' }}>{JSON.stringify(tool.security_report, null, 2)}</div>
</div>
)}
{/* Install Instructions */}
{tool.install_instructions && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>安装说明</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', whiteSpace: 'pre-wrap' }}>{tool.install_instructions}</div>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', marginTop: 20 }}>
<button onClick={onClose} style={btnSecondaryStyle}>关闭</button>
</div>
</div>
</div>
);
};
// ============================================================
// Helper Components & Styles
// ============================================================
const FormField: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>{label}</label>
{children}
</div>
);
const DetailItem: React.FC<{ label: string; value: string }> = ({ label, value }) => (
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', fontFamily: 'monospace', wordBreak: 'break-all' }}>{value}</div>
</div>
);
// Style constants
const selectStyle: React.CSSProperties = {
padding: '8px 12px',
background: 'var(--color-bg-secondary, #161b22)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8,
color: 'var(--color-text-primary, #e6edf3)',
fontSize: 14,
cursor: 'pointer',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
background: 'var(--color-bg-tertiary, #21262d)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8,
color: 'var(--color-text-primary, #e6edf3)',
fontSize: 14,
boxSizing: 'border-box',
};
const btnPrimaryStyle: React.CSSProperties = {
padding: '8px 16px',
background: 'var(--color-primary, #58a6ff)',
color: '#fff',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
fontSize: 14,
fontWeight: 500,
};
const btnSecondaryStyle: React.CSSProperties = {
padding: '8px 16px',
background: 'var(--color-bg-tertiary, #21262d)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8,
cursor: 'pointer',
fontSize: 14,
};
const btnSmallStyle: React.CSSProperties = {
padding: '4px 10px',
background: 'var(--color-bg-tertiary, #21262d)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 6,
cursor: 'pointer',
fontSize: 12,
whiteSpace: 'nowrap',
};
const pageBtnStyle: React.CSSProperties = {
padding: '6px 14px',
background: 'var(--color-bg-secondary, #161b22)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 6,
cursor: 'pointer',
fontSize: 14,
};
const badgeStyle: React.CSSProperties = {
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
fontWeight: 500,
display: 'inline-block',
};
const modalOverlayStyle: React.CSSProperties = {
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0, 0, 0, 0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
};
const modalContentStyle: React.CSSProperties = {
background: 'var(--color-bg-secondary, #161b22)',
borderRadius: 16,
padding: 24,
width: '90%',
maxWidth: 600,
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
border: '1px solid var(--color-border, #30363d)',
};
const labelStyle: React.CSSProperties = {
fontSize: 12,
color: 'var(--color-text-tertiary)',
marginBottom: 4,
fontWeight: 500,
};
const codeBlockStyle: React.CSSProperties = {
background: 'var(--color-bg-tertiary, #21262d)',
borderRadius: 8,
padding: 12,
fontSize: 12,
color: 'var(--color-text-secondary)',
fontFamily: 'monospace',
overflow: 'auto',
};
export default McpMarketTab;