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:
1 parent
49dcea0a11
commit
909c013e2d
5 files changed
+1985
-1
No files matched your search
@@ -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 =====
|
||||
|
||||
@@ -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(); }} />
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user