1533 lines
65 KiB
TypeScript
1533 lines
65 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Server,
|
|
Plus,
|
|
Play,
|
|
CheckCircle2,
|
|
XCircle,
|
|
RefreshCw,
|
|
Trash2,
|
|
Edit,
|
|
Power,
|
|
Globe,
|
|
Code2,
|
|
Webhook,
|
|
Cpu,
|
|
Download,
|
|
Upload,
|
|
Search,
|
|
Sparkles,
|
|
ExternalLink,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Sliders,
|
|
Layers,
|
|
Terminal,
|
|
CloudSun,
|
|
Coins,
|
|
Newspaper,
|
|
Database,
|
|
Github,
|
|
MessageSquare,
|
|
FileCode,
|
|
ShieldCheck,
|
|
Zap,
|
|
Compass,
|
|
Copy,
|
|
Check,
|
|
Radio,
|
|
Key,
|
|
Repeat,
|
|
} from 'lucide-react';
|
|
import { MCPServerDefinition, ToolDefinition, ServerStats } from '../types';
|
|
import { RoadSignModal } from './RoadSignModal';
|
|
import { PackageRunnerModal } from './PackageRunnerModal';
|
|
import { MCPSkillGuideModal } from './MCPSkillGuideModal';
|
|
|
|
interface MCPHubTabProps {
|
|
stats: ServerStats | null;
|
|
onRefreshStats?: () => void;
|
|
onNavigateToStudio?: (prompt?: string) => void;
|
|
}
|
|
|
|
export const MCPHubTab: React.FC<MCPHubTabProps> = ({
|
|
stats,
|
|
onRefreshStats,
|
|
onNavigateToStudio,
|
|
}) => {
|
|
const [servers, setServers] = useState<MCPServerDefinition[]>([]);
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
const [filterCategory, setFilterCategory] = useState<string>('all');
|
|
const [filterType, setFilterType] = useState<string>('all');
|
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
|
const [activeTab, setActiveTab] = useState<'servers' | 'road_signs' | 'marketplace'>('servers');
|
|
|
|
// Modal states
|
|
const [isAddModalOpen, setIsAddModalOpen] = useState<boolean>(false);
|
|
const [isRoadSignModalOpen, setIsRoadSignModalOpen] = useState<boolean>(false);
|
|
const [editingRoadSignServer, setEditingRoadSignServer] = useState<MCPServerDefinition | null>(null);
|
|
const [isPackageRunnerModalOpen, setIsPackageRunnerModalOpen] = useState<boolean>(false);
|
|
const [isSkillGuideModalOpen, setIsSkillGuideModalOpen] = useState<boolean>(false);
|
|
const [isImportModalOpen, setIsImportModalOpen] = useState<boolean>(false);
|
|
const [importJsonText, setImportJsonText] = useState<string>('');
|
|
const [pingingServerId, setPingingServerId] = useState<string | null>(null);
|
|
const [expandedServerId, setExpandedServerId] = useState<string | null>('builtin-mcp-core');
|
|
const [copiedConnectSnippet, setCopiedConnectSnippet] = useState<string | null>(null);
|
|
|
|
// New server form state
|
|
const [newServerType, setNewServerType] = useState<'remote_http' | 'webhook' | 'custom_script'>('remote_http');
|
|
const [formName, setFormName] = useState<string>('');
|
|
const [formDescription, setFormDescription] = useState<string>('');
|
|
const [formCategory, setFormCategory] = useState<MCPServerDefinition['category']>('custom');
|
|
// Remote HTTP fields
|
|
const [formEndpointUrl, setFormEndpointUrl] = useState<string>('');
|
|
const [formHeadersText, setFormHeadersText] = useState<string>('{\n "Authorization": "Bearer MY_API_TOKEN"\n}');
|
|
// Webhook fields
|
|
const [formWebhookUrl, setFormWebhookUrl] = useState<string>('https://api.example.com/v1/resource?id={{id}}');
|
|
const [formWebhookMethod, setFormWebhookMethod] = useState<'GET' | 'POST' | 'PUT'>('GET');
|
|
const [formWebhookBody, setFormWebhookBody] = useState<string>('{\n "message": "{{message}}"\n}');
|
|
// Custom Script fields
|
|
const [formToolName, setFormToolName] = useState<string>('my_custom_tool');
|
|
const [formToolDesc, setFormToolDesc] = useState<string>('Executes custom calculation or data processing logic.');
|
|
const [formCustomScript, setFormCustomScript] = useState<string>(`// Available arguments in 'args' object (e.g. args.text, args.number)\nconst text = args.text || '';\nreturn {\n status: 'processed',\n length: text.length,\n uppercase: text.toUpperCase(),\n timestamp: new Date().toISOString()\n};`);
|
|
const [formSchemaJson, setFormSchemaJson] = useState<string>(`{\n "type": "object",\n "properties": {\n "text": { "type": "string", "description": "Input text to process" }\n },\n "required": ["text"]\n}`);
|
|
|
|
// Test execution state
|
|
const [testTool, setTestTool] = useState<{ name: string; schema: any } | null>(null);
|
|
const [testArgsJson, setTestArgsJson] = useState<string>('{}');
|
|
const [testResult, setTestResult] = useState<any>(null);
|
|
const [isExecuting, setIsExecuting] = useState<boolean>(false);
|
|
|
|
const fetchServers = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res = await fetch('/api/v1/mcp/servers');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setServers(data);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch MCP servers:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchServers();
|
|
}, []);
|
|
|
|
const handleToggleServer = async (id: string, currentEnabled: boolean) => {
|
|
try {
|
|
const res = await fetch(`/api/v1/mcp/servers/${id}/toggle`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: !currentEnabled }),
|
|
});
|
|
if (res.ok) {
|
|
fetchServers();
|
|
if (onRefreshStats) onRefreshStats();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to toggle server:', err);
|
|
}
|
|
};
|
|
|
|
const handlePingServer = async (id: string) => {
|
|
setPingingServerId(id);
|
|
try {
|
|
const res = await fetch(`/api/v1/mcp/servers/${id}/ping`, { method: 'POST' });
|
|
if (res.ok) {
|
|
const pingResult = await res.json();
|
|
setServers((prev) =>
|
|
prev.map((s) =>
|
|
s.id === id
|
|
? {
|
|
...s,
|
|
healthStatus: pingResult.success ? 'healthy' : 'unreachable',
|
|
lastPingMs: pingResult.latencyMs,
|
|
lastPingAt: new Date().toISOString(),
|
|
}
|
|
: s
|
|
)
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to ping server:', err);
|
|
} finally {
|
|
setPingingServerId(null);
|
|
}
|
|
};
|
|
|
|
const handleDeleteServer = async (id: string) => {
|
|
if (!confirm('Are you sure you want to remove this MCP server registration?')) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/mcp/servers/${id}`, { method: 'DELETE' });
|
|
if (res.ok) {
|
|
fetchServers();
|
|
if (onRefreshStats) onRefreshStats();
|
|
} else {
|
|
const err = await res.json();
|
|
alert(err.error || 'Failed to delete server');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to delete server:', err);
|
|
}
|
|
};
|
|
|
|
const handleSaveRoadSign = async (roadSignServer: Partial<MCPServerDefinition>) => {
|
|
const isUpdate = Boolean(roadSignServer.id && servers.some((s) => s.id === roadSignServer.id));
|
|
const url = isUpdate ? `/api/v1/mcp/servers/${roadSignServer.id}` : '/api/v1/mcp/servers';
|
|
const method = isUpdate ? 'PUT' : 'POST';
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(roadSignServer),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || 'Failed to save Road Sign server');
|
|
}
|
|
|
|
await fetchServers();
|
|
if (onRefreshStats) onRefreshStats();
|
|
};
|
|
|
|
const handleCreateServer = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
let parsedSchema = { type: 'object', properties: {} };
|
|
try {
|
|
parsedSchema = JSON.parse(formSchemaJson);
|
|
} catch (err) {
|
|
alert('Invalid Input Schema JSON formatting.');
|
|
return;
|
|
}
|
|
|
|
let parsedHeaders = undefined;
|
|
if (newServerType === 'remote_http' && formHeadersText.trim()) {
|
|
try {
|
|
parsedHeaders = JSON.parse(formHeadersText);
|
|
} catch (err) {
|
|
alert('Invalid Headers JSON format.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const tools: ToolDefinition[] = [];
|
|
if (newServerType === 'custom_script') {
|
|
tools.push({
|
|
name: formToolName.trim().replace(/\s+/g, '_').toLowerCase(),
|
|
description: formToolDesc,
|
|
category: formCategory,
|
|
inputSchema: parsedSchema as any,
|
|
});
|
|
} else if (newServerType === 'webhook') {
|
|
tools.push({
|
|
name: formName.trim().replace(/\s+/g, '_').toLowerCase() + '_tool',
|
|
description: formDescription,
|
|
category: formCategory,
|
|
inputSchema: parsedSchema as any,
|
|
});
|
|
}
|
|
|
|
const payload: Partial<MCPServerDefinition> = {
|
|
name: formName,
|
|
description: formDescription,
|
|
type: newServerType,
|
|
category: formCategory,
|
|
enabled: true,
|
|
endpointUrl: newServerType === 'remote_http' ? formEndpointUrl : undefined,
|
|
headers: parsedHeaders,
|
|
webhookConfig:
|
|
newServerType === 'webhook'
|
|
? {
|
|
url: formWebhookUrl,
|
|
method: formWebhookMethod,
|
|
bodyTemplate: formWebhookMethod !== 'GET' ? formWebhookBody : undefined,
|
|
}
|
|
: undefined,
|
|
customScript: newServerType === 'custom_script' ? formCustomScript : undefined,
|
|
tools,
|
|
};
|
|
|
|
const res = await fetch('/api/v1/mcp/servers', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (res.ok) {
|
|
setIsAddModalOpen(false);
|
|
setFormName('');
|
|
setFormDescription('');
|
|
fetchServers();
|
|
if (onRefreshStats) onRefreshStats();
|
|
} else {
|
|
const err = await res.json();
|
|
alert(err.error || 'Failed to create MCP server');
|
|
}
|
|
} catch (err: any) {
|
|
alert('Error creating server: ' + err.message);
|
|
}
|
|
};
|
|
|
|
const handleInstallPreset = async (preset: {
|
|
name: string;
|
|
description: string;
|
|
type: 'webhook' | 'custom_script' | 'remote_http';
|
|
category: MCPServerDefinition['category'];
|
|
webhookConfig?: any;
|
|
customScript?: string;
|
|
tools: ToolDefinition[];
|
|
icon?: string;
|
|
}) => {
|
|
try {
|
|
const res = await fetch('/api/v1/mcp/servers', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
...preset,
|
|
enabled: true,
|
|
isPreset: true,
|
|
}),
|
|
});
|
|
|
|
if (res.ok) {
|
|
fetchServers();
|
|
setActiveTab('servers');
|
|
if (onRefreshStats) onRefreshStats();
|
|
} else {
|
|
alert('Failed to install preset MCP server');
|
|
}
|
|
} catch (err) {
|
|
console.error('Error installing preset:', err);
|
|
}
|
|
};
|
|
|
|
const handleImportJson = async () => {
|
|
try {
|
|
const parsed = JSON.parse(importJsonText);
|
|
const serversArray = Array.isArray(parsed) ? parsed : parsed.servers;
|
|
if (!Array.isArray(serversArray)) {
|
|
alert('Invalid format. Expected JSON array or { "servers": [...] } object.');
|
|
return;
|
|
}
|
|
|
|
const res = await fetch('/api/v1/mcp/servers/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ servers: serversArray }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
alert(`Successfully imported ${data.importedCount} MCP servers!`);
|
|
setIsImportModalOpen(false);
|
|
setImportJsonText('');
|
|
fetchServers();
|
|
if (onRefreshStats) onRefreshStats();
|
|
}
|
|
} catch (err: any) {
|
|
alert('Failed to parse or import JSON: ' + err.message);
|
|
}
|
|
};
|
|
|
|
const handleExportJson = () => {
|
|
const jsonStr = JSON.stringify({ servers }, null, 2);
|
|
const blob = new Blob([jsonStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `mcp-nexus-registry-${new Date().toISOString().slice(0, 10)}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const handleCopySnippet = (id: string, text: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
setCopiedConnectSnippet(id);
|
|
setTimeout(() => setCopiedConnectSnippet(null), 2000);
|
|
};
|
|
|
|
const handleTestExecute = async () => {
|
|
if (!testTool) return;
|
|
setIsExecuting(true);
|
|
setTestResult(null);
|
|
|
|
try {
|
|
let argsObj = {};
|
|
try {
|
|
argsObj = JSON.parse(testArgsJson);
|
|
} catch (e) {
|
|
alert('Invalid args JSON format');
|
|
setIsExecuting(false);
|
|
return;
|
|
}
|
|
|
|
const res = await fetch(`/api/v1/tools/${testTool.name}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(argsObj),
|
|
});
|
|
|
|
const data = await res.json();
|
|
setTestResult(data);
|
|
} catch (err: any) {
|
|
setTestResult({ success: false, error: err.message });
|
|
} finally {
|
|
setIsExecuting(false);
|
|
}
|
|
};
|
|
|
|
// Filter servers
|
|
const roadSignServers = servers.filter((s) => s.roadSign?.isRoadSign);
|
|
|
|
const filteredServers = servers.filter((s) => {
|
|
const matchesSearch =
|
|
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
s.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
s.roadSign?.signpostTitle?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
s.tools?.some((t) => t.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
|
const matchesCategory = filterCategory === 'all' || s.category === filterCategory;
|
|
const matchesType = filterType === 'all' || s.type === filterType;
|
|
return matchesSearch && matchesCategory && matchesType;
|
|
});
|
|
|
|
const getTypeBadge = (server: MCPServerDefinition) => {
|
|
if (server.roadSign?.isRoadSign) {
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-black bg-amber-100 text-amber-900 border border-amber-300">
|
|
<Compass className="w-3 h-3 mr-1 text-amber-700" />
|
|
Road Sign ({server.roadSign.osPlatform})
|
|
</span>
|
|
);
|
|
}
|
|
switch (server.type) {
|
|
case 'builtin':
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 border border-indigo-200">
|
|
<Cpu className="w-3 h-3 mr-1" /> Built-in Engine
|
|
</span>
|
|
);
|
|
case 'runner_process':
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-cyan-100 text-cyan-800 border border-cyan-200">
|
|
<Terminal className="w-3 h-3 mr-1" /> Runner ({server.runnerConfig?.packageManager || 'cli'})
|
|
</span>
|
|
);
|
|
case 'remote_http':
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800 border border-emerald-200">
|
|
<Globe className="w-3 h-3 mr-1" /> Remote HTTP
|
|
</span>
|
|
);
|
|
case 'webhook':
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 border border-amber-200">
|
|
<Webhook className="w-3 h-3 mr-1" /> Webhook Bridge
|
|
</span>
|
|
);
|
|
case 'custom_script':
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 border border-purple-200">
|
|
<Code2 className="w-3 h-3 mr-1" /> JS Executable
|
|
</span>
|
|
);
|
|
}
|
|
};
|
|
|
|
const getIconComponent = (iconName?: string, osPlatform?: string) => {
|
|
if (osPlatform === 'windows') return <span className="text-lg">🪟</span>;
|
|
if (osPlatform === 'macos') return <span className="text-lg">🍎</span>;
|
|
if (osPlatform === 'linux') return <span className="text-lg">🐧</span>;
|
|
if (osPlatform === 'docker') return <span className="text-lg">🐳</span>;
|
|
|
|
switch (iconName) {
|
|
case 'Compass':
|
|
return <Compass className="w-5 h-5 text-amber-500" />;
|
|
case 'Terminal':
|
|
return <Terminal className="w-5 h-5 text-cyan-600" />;
|
|
case 'Layers':
|
|
return <Layers className="w-5 h-5 text-indigo-600" />;
|
|
case 'CloudSun':
|
|
return <CloudSun className="w-5 h-5 text-amber-500" />;
|
|
case 'Coins':
|
|
return <Coins className="w-5 h-5 text-emerald-500" />;
|
|
case 'Newspaper':
|
|
return <Newspaper className="w-5 h-5 text-indigo-500" />;
|
|
case 'Code2':
|
|
return <Code2 className="w-5 h-5 text-purple-500" />;
|
|
case 'Github':
|
|
return <Github className="w-5 h-5 text-slate-800" />;
|
|
case 'MessageSquare':
|
|
return <MessageSquare className="w-5 h-5 text-pink-500" />;
|
|
case 'Database':
|
|
return <Database className="w-5 h-5 text-blue-500" />;
|
|
case 'Zap':
|
|
return <Zap className="w-5 h-5 text-amber-500" />;
|
|
default:
|
|
return <Server className="w-5 h-5 text-slate-600" />;
|
|
}
|
|
};
|
|
|
|
const originUrl = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000';
|
|
const sseUrl = `${originUrl}/mcp/sse`;
|
|
const autoSetupUrl = `${originUrl}/api/v1/agent/auto-setup`;
|
|
|
|
const claudeConfigSnippet = JSON.stringify(
|
|
{
|
|
mcpServers: {
|
|
'do-everything-nexus': {
|
|
url: sseUrl,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
);
|
|
|
|
// Preset Catalog Items
|
|
const presetCatalog: Array<{
|
|
name: string;
|
|
description: string;
|
|
type: 'remote_http' | 'custom_script' | 'webhook';
|
|
category: MCPServerDefinition['category'];
|
|
icon?: string;
|
|
webhookConfig?: any;
|
|
customScript?: string;
|
|
tools: ToolDefinition[];
|
|
}> = [
|
|
{
|
|
name: 'GitHub API & Code Repository MCP',
|
|
description: 'Search repositories, inspect commits, view user profiles, and fetch file contents directly from GitHub REST API.',
|
|
type: 'webhook',
|
|
category: 'code',
|
|
icon: 'Github',
|
|
webhookConfig: {
|
|
url: 'https://api.github.com/repos/{{owner}}/{{repo}}/contents/{{path}}',
|
|
method: 'GET',
|
|
headers: { 'User-Agent': 'DoEverythingMCP' },
|
|
},
|
|
tools: [
|
|
{
|
|
name: 'get_github_repo_file',
|
|
description: 'Fetch file content or directory listing from a public GitHub repository.',
|
|
category: 'code',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
owner: { type: 'string', description: 'Repository owner or organization name (e.g., facebook)' },
|
|
repo: { type: 'string', description: 'Repository name (e.g., react)' },
|
|
path: { type: 'string', description: 'File path within repo (e.g., package.json)' },
|
|
},
|
|
required: ['owner', 'repo', 'path'],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: 'Slack Notification Webhook MCP',
|
|
description: 'Bridge Slack incoming webhooks to enable connected AI agents to dispatch notifications to team channels.',
|
|
type: 'webhook',
|
|
category: 'network',
|
|
icon: 'MessageSquare',
|
|
webhookConfig: {
|
|
url: 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK',
|
|
method: 'POST',
|
|
bodyTemplate: '{\n "text": "🤖 *AI Agent Notification*:\\n{{message}}"\n}',
|
|
},
|
|
tools: [
|
|
{
|
|
name: 'send_slack_webhook_message',
|
|
description: 'Dispatch a formatted text message to team Slack channel via Webhook bridge.',
|
|
category: 'network',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
message: { type: 'string', description: 'Text message to post to Slack' },
|
|
},
|
|
required: ['message'],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: 'PostgreSQL Relational DB Query Bridge MCP',
|
|
description: 'Allows agents to execute query logic against SQL database connectors or REST database proxies.',
|
|
type: 'custom_script',
|
|
category: 'data',
|
|
icon: 'Database',
|
|
customScript: `
|
|
// Custom script proxy simulating SQL query parser
|
|
const sql = args.sql || '';
|
|
return {
|
|
query: sql,
|
|
rows: [
|
|
{ id: 1, name: "Sample Record A", status: "active", created_at: "2026-08-12" },
|
|
{ id: 2, name: "Sample Record B", status: "pending", created_at: "2026-08-11" }
|
|
],
|
|
rowCount: 2,
|
|
executionTimeMs: 12
|
|
};
|
|
`,
|
|
tools: [
|
|
{
|
|
name: 'execute_sql_query_proxy',
|
|
description: 'Execute read-only SQL queries or table metadata inspections.',
|
|
category: 'data',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
sql: { type: 'string', description: 'SQL query statement (SELECT ...)' },
|
|
},
|
|
required: ['sql'],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: 'Text Sentiment & Keyword Extractor MCP',
|
|
description: 'Standalone JavaScript script tool for fast client/server text analysis and keyphrase extraction.',
|
|
type: 'custom_script',
|
|
category: 'code',
|
|
icon: 'Zap',
|
|
customScript: `
|
|
const text = args.text || '';
|
|
const words = text.toLowerCase().match(/\\b[a-z]{4,}\\b/g) || [];
|
|
const freq = {};
|
|
words.forEach(w => freq[w] = (freq[w] || 0) + 1);
|
|
const topKeywords = Object.entries(freq).sort((a,b) => b[1] - a[1]).slice(0, 5).map(e => e[0]);
|
|
return {
|
|
textLength: text.length,
|
|
wordCount: text.split(/\\s+/).length,
|
|
topKeywords,
|
|
sentiment: text.includes('great') || text.includes('awesome') || text.includes('good') ? 'positive' : 'neutral'
|
|
};
|
|
`,
|
|
tools: [
|
|
{
|
|
name: 'extract_text_keywords_mcp',
|
|
description: 'Extract top keywords and estimate text sentiment.',
|
|
category: 'code',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
text: { type: 'string', description: 'Text string to analyze' },
|
|
},
|
|
required: ['text'],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Hero Header Banner with Central Source of Truth Metaphor */}
|
|
<div className="bg-gradient-to-r from-slate-900 via-indigo-950 to-slate-900 rounded-3xl p-6 sm:p-8 text-white shadow-2xl border border-slate-800 relative overflow-hidden">
|
|
<div className="absolute top-0 right-0 w-96 h-96 bg-indigo-500/15 rounded-full blur-3xl pointer-events-none" />
|
|
<div className="relative z-10 flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
|
|
<div className="space-y-3 max-w-2xl">
|
|
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full bg-indigo-500/20 text-indigo-300 text-xs font-semibold border border-indigo-500/30">
|
|
<Sparkles className="w-3.5 h-3.5" />
|
|
<span>Universal MCP Nexus & Central Source of Truth</span>
|
|
</div>
|
|
<h1 className="text-2xl sm:text-4xl font-black tracking-tight">
|
|
MCP Hub, Road Signs & Router Nexus
|
|
</h1>
|
|
<p className="text-slate-300 text-xs sm:text-sm leading-relaxed">
|
|
Install any MCP server via browser runners (<code className="text-indigo-300 font-mono">bunx</code>, <code className="text-indigo-300 font-mono">npx</code>, <code className="text-indigo-300 font-mono">uvx</code>, <code className="text-indigo-300 font-mono">python</code>). Point any AI model at the persistent SSE stream to gain instant access to all 27+ zero-key algorithmic tools, remote machine Road Signs, and intelligent workflows.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2.5">
|
|
<button
|
|
onClick={() => setIsPackageRunnerModalOpen(true)}
|
|
className="px-4 py-2.5 bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 text-white text-xs sm:text-sm font-bold rounded-xl shadow-lg shadow-indigo-600/30 transition flex items-center space-x-2"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
<span>Install MCP (bun/npx)</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
setEditingRoadSignServer(null);
|
|
setIsRoadSignModalOpen(true);
|
|
}}
|
|
className="px-4 py-2.5 bg-amber-500 hover:bg-amber-400 text-slate-950 text-xs sm:text-sm font-black rounded-xl shadow-lg shadow-amber-500/30 transition flex items-center space-x-2"
|
|
>
|
|
<Compass className="w-4 h-4" />
|
|
<span>Erect Road Sign</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setIsSkillGuideModalOpen(true)}
|
|
className="px-3.5 py-2.5 bg-slate-800 hover:bg-slate-700 text-indigo-300 text-xs sm:text-sm font-bold rounded-xl border border-slate-700 transition flex items-center space-x-2"
|
|
>
|
|
<Sparkles className="w-4 h-4 text-amber-400" />
|
|
<span>Creation Skill</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setIsAddModalOpen(true)}
|
|
className="px-3 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs sm:text-sm font-medium rounded-xl border border-slate-700 transition flex items-center space-x-1.5"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
<span>Custom</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleExportJson}
|
|
className="p-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl border border-slate-700 transition"
|
|
title="Export Registry"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Live SSE Stream & Auto-Setup Connect Bar */}
|
|
<div className="mt-6 pt-5 border-t border-slate-800/80 bg-slate-950/60 p-4 rounded-2xl border border-slate-800/90 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center space-x-2">
|
|
<Radio className="w-4 h-4 text-emerald-400 animate-pulse" />
|
|
<span className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
|
Live Model SSE Source of Truth Endpoint
|
|
</span>
|
|
</div>
|
|
<div className="font-mono text-xs text-indigo-300 bg-slate-900 px-3 py-1.5 rounded-lg border border-slate-800 flex items-center space-x-2">
|
|
<span>{sseUrl}</span>
|
|
<button
|
|
onClick={() => handleCopySnippet('sse-url', sseUrl)}
|
|
className="text-slate-400 hover:text-white transition"
|
|
title="Copy SSE URL"
|
|
>
|
|
{copiedConnectSnippet === 'sse-url' ? (
|
|
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
|
) : (
|
|
<Copy className="w-3.5 h-3.5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
onClick={() => handleCopySnippet('claude-config', claudeConfigSnippet)}
|
|
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-mono font-medium border border-slate-700 flex items-center space-x-1.5 transition"
|
|
>
|
|
{copiedConnectSnippet === 'claude-config' ? (
|
|
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
|
) : (
|
|
<Copy className="w-3.5 h-3.5 text-slate-400" />
|
|
)}
|
|
<span>Claude Desktop JSON</span>
|
|
</button>
|
|
|
|
<a
|
|
href={autoSetupUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="px-3 py-1.5 bg-indigo-900/60 hover:bg-indigo-900 text-indigo-300 rounded-lg text-xs font-mono font-medium border border-indigo-700/60 flex items-center space-x-1.5 transition"
|
|
>
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
<span>Auto-Setup API</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Hub Metrics Row */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t border-slate-850 text-xs sm:text-sm font-mono">
|
|
<div>
|
|
<span className="text-slate-400 block text-xs">Total MCP Servers</span>
|
|
<span className="text-xl font-black text-white">{servers.length}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400 block text-xs">Road Signs (Remote)</span>
|
|
<span className="text-xl font-black text-amber-400">{roadSignServers.length}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400 block text-xs">Live Aggregated Tools</span>
|
|
<span className="text-xl font-black text-indigo-300">
|
|
{servers.reduce((acc, s) => (s.enabled ? acc + (s.tools?.length || 0) : acc), 0)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400 block text-xs">Router Latency</span>
|
|
<span className="text-xl font-black text-emerald-400">
|
|
{stats?.avgLatencyMs || 0}ms
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation Filter Tabs */}
|
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 border-b border-slate-200 pb-4">
|
|
<div className="flex items-center space-x-2 bg-slate-100 p-1 rounded-xl">
|
|
<button
|
|
onClick={() => setActiveTab('servers')}
|
|
className={`px-4 py-2 rounded-lg text-xs sm:text-sm font-bold transition flex items-center space-x-2 ${
|
|
activeTab === 'servers'
|
|
? 'bg-white text-indigo-600 shadow-sm'
|
|
: 'text-slate-600 hover:text-slate-900'
|
|
}`}
|
|
>
|
|
<Layers className="w-4 h-4" />
|
|
<span>All Servers ({servers.length})</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setActiveTab('road_signs')}
|
|
className={`px-4 py-2 rounded-lg text-xs sm:text-sm font-bold transition flex items-center space-x-2 ${
|
|
activeTab === 'road_signs'
|
|
? 'bg-amber-500 text-slate-950 shadow-sm'
|
|
: 'text-slate-600 hover:text-slate-900'
|
|
}`}
|
|
>
|
|
<Compass className="w-4 h-4" />
|
|
<span>Road Signs ({roadSignServers.length})</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setActiveTab('marketplace')}
|
|
className={`px-4 py-2 rounded-lg text-xs sm:text-sm font-bold transition flex items-center space-x-2 ${
|
|
activeTab === 'marketplace'
|
|
? 'bg-white text-indigo-600 shadow-sm'
|
|
: 'text-slate-600 hover:text-slate-900'
|
|
}`}
|
|
>
|
|
<Sparkles className="w-4 h-4 text-amber-500" />
|
|
<span>Presets & Templates</span>
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab !== 'marketplace' && (
|
|
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
|
|
<div className="relative flex-1 sm:w-64">
|
|
<Search className="w-4 h-4 absolute left-3 top-2.5 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search servers, tools, road signs..."
|
|
className="w-full pl-9 pr-3 py-1.5 text-xs sm:text-sm bg-white border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
value={filterType}
|
|
onChange={(e) => setFilterType(e.target.value)}
|
|
className="py-1.5 px-3 text-xs sm:text-sm bg-white border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
>
|
|
<option value="all">All Types</option>
|
|
<option value="builtin">Built-in (Zero-Key)</option>
|
|
<option value="runner_process">Runners (bunx/npx)</option>
|
|
<option value="remote_http">Remote HTTP</option>
|
|
<option value="webhook">Webhook Bridge</option>
|
|
<option value="custom_script">Custom JS</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tab View: Road Signs Dedicated Signposts View */}
|
|
{activeTab === 'road_signs' && (
|
|
<div className="space-y-6">
|
|
<div className="bg-gradient-to-r from-amber-500 via-amber-400 to-yellow-500 rounded-2xl p-5 text-slate-950 shadow-md border-2 border-amber-600 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center space-x-2 text-xs font-black uppercase tracking-widest">
|
|
<Compass className="w-4 h-4" />
|
|
<span>Road Signs Metaphor & Remote Host Credentials</span>
|
|
</div>
|
|
<h2 className="text-lg sm:text-xl font-black mt-0.5">
|
|
"LOOK HERE" Signposts for Remote Machines
|
|
</h2>
|
|
<p className="text-xs sm:text-sm text-slate-900 font-medium max-w-2xl mt-1">
|
|
Erect signposts on your Nexus pointing models to remote Windows, macOS, Linux, or GPU hosts. Inject API keys and credentials seamlessly on every tool call.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => {
|
|
setEditingRoadSignServer(null);
|
|
setIsRoadSignModalOpen(true);
|
|
}}
|
|
className="px-5 py-2.5 bg-slate-950 hover:bg-slate-900 text-amber-300 font-black rounded-xl shadow-md text-xs sm:text-sm flex items-center space-x-2 shrink-0 transition"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
<span>Erect New Road Sign</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
{roadSignServers.map((server) => (
|
|
<div
|
|
key={server.id}
|
|
className="bg-white rounded-2xl border-2 border-amber-300 p-5 space-y-4 shadow-sm hover:shadow-md transition relative overflow-hidden"
|
|
>
|
|
{/* Highway Sign Header */}
|
|
<div className="bg-amber-500 text-slate-950 -mx-5 -mt-5 p-4 border-b-2 border-amber-600 flex items-center justify-between">
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-xl">
|
|
{server.roadSign?.osPlatform === 'windows' ? '🪟' : server.roadSign?.osPlatform === 'macos' ? '🍎' : '🐧'}
|
|
</span>
|
|
<div>
|
|
<span className="text-[10px] font-black uppercase tracking-widest opacity-80 block">
|
|
ROAD SIGN • {server.roadSign?.osPlatform?.toUpperCase()}
|
|
</span>
|
|
<h3 className="text-sm sm:text-base font-black truncate">
|
|
{server.roadSign?.signpostTitle || server.name}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<span className="text-xs font-mono font-bold bg-amber-200/90 px-2 py-0.5 rounded text-slate-900">
|
|
{server.lastPingMs !== undefined ? `${server.lastPingMs}ms` : 'Healthy'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-mono bg-slate-900 text-amber-300 p-2.5 rounded-xl flex items-center justify-between">
|
|
<span className="truncate">Host: {server.roadSign?.targetHostLocation || server.endpointUrl}</span>
|
|
<span className="text-slate-400 text-[10px]">MCP JSON-RPC</span>
|
|
</div>
|
|
|
|
<p className="text-xs text-slate-600 leading-relaxed">
|
|
{server.roadSign?.directionsInstructions || server.description}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Credentials & API Keys configured */}
|
|
<div className="bg-slate-50 p-3 rounded-xl border border-slate-200 space-y-1.5 text-xs">
|
|
<div className="flex items-center justify-between font-bold text-slate-700">
|
|
<span className="flex items-center space-x-1">
|
|
<Key className="w-3.5 h-3.5 text-amber-600" />
|
|
<span>Injected Auth Headers ({Object.keys(server.roadSign?.credentialKeys || {}).length})</span>
|
|
</span>
|
|
<span className="text-[10px] text-emerald-700 bg-emerald-100 px-2 py-0.5 rounded-full">
|
|
Auto-Injected
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1 pt-1">
|
|
{Object.keys(server.roadSign?.credentialKeys || {}).map((headerKey) => (
|
|
<span
|
|
key={headerKey}
|
|
className="px-2 py-0.5 rounded bg-white text-slate-700 border border-slate-300 font-mono text-[11px]"
|
|
>
|
|
{headerKey}: ••••••••
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tools exposed */}
|
|
<div className="space-y-1">
|
|
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-400 block">
|
|
Remote Tools Exposed
|
|
</span>
|
|
<div className="flex flex-wrap gap-1">
|
|
{server.tools?.map((tool) => (
|
|
<span
|
|
key={tool.name}
|
|
className="px-2 py-0.5 rounded bg-indigo-50 text-indigo-700 border border-indigo-100 font-mono text-xs"
|
|
>
|
|
{tool.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-between pt-3 border-t border-slate-100">
|
|
<button
|
|
onClick={() => handlePingServer(server.id)}
|
|
className="text-xs text-slate-600 hover:text-indigo-600 flex items-center space-x-1 font-semibold"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${pingingServerId === server.id ? 'animate-spin' : ''}`} />
|
|
<span>Ping Host</span>
|
|
</button>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<button
|
|
onClick={() => {
|
|
setEditingRoadSignServer(server);
|
|
setIsRoadSignModalOpen(true);
|
|
}}
|
|
className="px-3 py-1.5 bg-amber-50 hover:bg-amber-100 text-amber-900 border border-amber-300 rounded-lg text-xs font-bold transition flex items-center space-x-1"
|
|
>
|
|
<Edit className="w-3.5 h-3.5" />
|
|
<span>Edit Keys & Signpost</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => handleDeleteServer(server.id)}
|
|
className="p-1.5 text-slate-400 hover:text-red-500"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab View: Installed Servers */}
|
|
{activeTab === 'servers' && (
|
|
<div className="space-y-4">
|
|
{loading ? (
|
|
<div className="py-12 text-center text-slate-500 flex flex-col items-center justify-center space-y-3">
|
|
<RefreshCw className="w-8 h-8 animate-spin text-indigo-500" />
|
|
<p>Loading registered MCP servers...</p>
|
|
</div>
|
|
) : filteredServers.length === 0 ? (
|
|
<div className="bg-slate-50 border border-slate-200 rounded-2xl p-12 text-center space-y-4">
|
|
<Server className="w-12 h-12 text-slate-400 mx-auto" />
|
|
<div>
|
|
<h3 className="text-base font-semibold text-slate-800">No MCP Servers Found</h3>
|
|
<p className="text-sm text-slate-500 max-w-md mx-auto mt-1">
|
|
No registered MCP servers matched your search criteria. Add a custom MCP server or browse the preset marketplace catalog!
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsAddModalOpen(true)}
|
|
className="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-xl hover:bg-indigo-700 transition"
|
|
>
|
|
Add Your First MCP Server
|
|
</button>
|
|
</div>
|
|
) : (
|
|
filteredServers.map((server) => {
|
|
const isExpanded = expandedServerId === server.id;
|
|
return (
|
|
<div
|
|
key={server.id}
|
|
className={`bg-white rounded-2xl border transition shadow-sm overflow-hidden ${
|
|
server.enabled
|
|
? server.roadSign?.isRoadSign
|
|
? 'border-amber-300 hover:border-amber-400'
|
|
: 'border-slate-200 hover:border-slate-300'
|
|
: 'border-slate-200 opacity-60 bg-slate-50'
|
|
}`}
|
|
>
|
|
<div className="p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div className="flex items-start space-x-4">
|
|
<div className="p-3 bg-slate-100 rounded-2xl border border-slate-200 mt-1">
|
|
{getIconComponent(server.icon, server.roadSign?.osPlatform)}
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<div className="flex items-center space-x-2 flex-wrap gap-y-1">
|
|
<h3 className="text-base font-bold text-slate-900">
|
|
{server.roadSign?.signpostTitle || server.name}
|
|
</h3>
|
|
{getTypeBadge(server)}
|
|
|
|
{server.enabled ? (
|
|
<span className="inline-flex items-center text-xs text-emerald-600 font-medium bg-emerald-50 px-2 py-0.5 rounded-full border border-emerald-200">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1.5" />
|
|
Active
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center text-xs text-slate-500 font-medium bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200">
|
|
Disabled
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-xs sm:text-sm text-slate-600 leading-normal max-w-2xl">
|
|
{server.description}
|
|
</p>
|
|
|
|
<div className="flex items-center space-x-4 text-xs text-slate-500 font-mono pt-1">
|
|
<span>{server.tools?.length || 0} Tools Exposed</span>
|
|
<span>•</span>
|
|
<span>
|
|
Ping:{' '}
|
|
{server.lastPingMs !== undefined ? `${server.lastPingMs}ms` : 'Not tested'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2 self-end sm:self-center">
|
|
<button
|
|
onClick={() => handlePingServer(server.id)}
|
|
disabled={pingingServerId === server.id}
|
|
title="Ping MCP Health"
|
|
className="p-2 text-slate-600 hover:text-indigo-600 hover:bg-slate-100 rounded-xl transition"
|
|
>
|
|
<RefreshCw
|
|
className={`w-4 h-4 ${pingingServerId === server.id ? 'animate-spin text-indigo-600' : ''}`}
|
|
/>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => handleToggleServer(server.id, server.enabled)}
|
|
title={server.enabled ? 'Disable MCP Server' : 'Enable MCP Server'}
|
|
className={`p-2 rounded-xl transition ${
|
|
server.enabled
|
|
? 'text-emerald-600 hover:bg-emerald-50'
|
|
: 'text-slate-400 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
<Power className="w-4 h-4" />
|
|
</button>
|
|
|
|
{!server.isPreset && (
|
|
<button
|
|
onClick={() => handleDeleteServer(server.id)}
|
|
title="Remove Server Registration"
|
|
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-xl transition"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => setExpandedServerId(isExpanded ? null : server.id)}
|
|
className="p-2 text-slate-500 hover:text-slate-800 hover:bg-slate-100 rounded-xl transition flex items-center space-x-1 text-xs font-medium"
|
|
>
|
|
<span>{isExpanded ? 'Hide Tools' : 'View Tools'}</span>
|
|
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Tools Details */}
|
|
{isExpanded && (
|
|
<div className="border-t border-slate-100 bg-slate-50/70 p-5 space-y-4">
|
|
{server.type === 'remote_http' && server.endpointUrl && (
|
|
<div className="text-xs font-mono bg-slate-900 text-slate-200 p-3 rounded-xl flex items-center justify-between">
|
|
<span>Endpoint: {server.endpointUrl}</span>
|
|
<span className="text-slate-400">JSON-RPC / SSE</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500">
|
|
Exposed MCP Tools ({server.tools?.length || 0})
|
|
</h4>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{server.tools?.map((tool) => (
|
|
<div
|
|
key={tool.name}
|
|
className="bg-white p-3.5 rounded-xl border border-slate-200 space-y-2 shadow-2xs"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-mono font-bold text-xs text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg border border-indigo-100">
|
|
{tool.name}
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
setTestTool({ name: tool.name, schema: tool.inputSchema });
|
|
setTestArgsJson('{}');
|
|
setTestResult(null);
|
|
}}
|
|
className="text-xs text-slate-600 hover:text-indigo-600 flex items-center space-x-1 bg-slate-100 hover:bg-slate-200 px-2 py-1 rounded-lg transition"
|
|
>
|
|
<Play className="w-3 h-3" />
|
|
<span>Test Call</span>
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-slate-600 line-clamp-2">{tool.description}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab View: Marketplace Catalog */}
|
|
{activeTab === 'marketplace' && (
|
|
<div className="space-y-6">
|
|
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-4 text-amber-900 text-xs sm:text-sm flex items-start space-x-3">
|
|
<Sparkles className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
|
<div>
|
|
<strong className="font-bold">Ready-to-Install MCP Server Templates</strong>
|
|
<p className="mt-0.5 text-amber-800">
|
|
Click "1-Click Install" on any template to instantly add its tools to your server hub registry.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{presetCatalog.map((preset) => (
|
|
<div
|
|
key={preset.name}
|
|
className="bg-white rounded-2xl border border-slate-200 p-6 space-y-4 hover:shadow-md transition flex flex-col justify-between"
|
|
>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="p-2.5 bg-slate-100 rounded-xl border border-slate-200">
|
|
{getIconComponent(preset.icon)}
|
|
</div>
|
|
{getTypeBadge(preset as any)}
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-base font-bold text-slate-900">{preset.name}</h3>
|
|
<p className="text-xs text-slate-600 mt-1 leading-relaxed">{preset.description}</p>
|
|
</div>
|
|
|
|
<div className="space-y-1 pt-2 border-t border-slate-100">
|
|
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block">
|
|
Tools Provided
|
|
</span>
|
|
{preset.tools.map((t) => (
|
|
<span
|
|
key={t.name}
|
|
className="inline-block text-xs font-mono bg-slate-100 text-slate-700 px-2 py-0.5 rounded-lg mr-1.5 mb-1 border border-slate-200"
|
|
>
|
|
{t.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => handleInstallPreset(preset)}
|
|
className="w-full mt-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold rounded-xl shadow-sm transition flex items-center justify-center space-x-2"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
<span>1-Click Install to Hub</span>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ROAD SIGN MODAL */}
|
|
<RoadSignModal
|
|
isOpen={isRoadSignModalOpen}
|
|
onClose={() => setIsRoadSignModalOpen(false)}
|
|
onSaveRoadSign={handleSaveRoadSign}
|
|
existingServer={editingRoadSignServer}
|
|
/>
|
|
|
|
{/* PACKAGE RUNNER MODAL */}
|
|
<PackageRunnerModal
|
|
isOpen={isPackageRunnerModalOpen}
|
|
onClose={() => setIsPackageRunnerModalOpen(false)}
|
|
onInstalled={() => {
|
|
fetchServers();
|
|
if (onRefreshStats) onRefreshStats();
|
|
}}
|
|
/>
|
|
|
|
{/* MCP SKILL GUIDE MODAL */}
|
|
<MCPSkillGuideModal
|
|
isOpen={isSkillGuideModalOpen}
|
|
onClose={() => setIsSkillGuideModalOpen(false)}
|
|
onSendToStudio={(p) => {
|
|
if (onNavigateToStudio) onNavigateToStudio(p);
|
|
}}
|
|
/>
|
|
|
|
{/* MODAL: Add New MCP Server */}
|
|
{isAddModalOpen && (
|
|
<div className="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-4 overflow-y-auto">
|
|
<div className="bg-white rounded-3xl max-w-xl w-full p-6 space-y-6 shadow-2xl border border-slate-200 my-8">
|
|
<div className="flex items-center justify-between border-b border-slate-100 pb-4">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-slate-900">Register New MCP Server or Tool</h3>
|
|
<p className="text-xs text-slate-500">Configure remote HTTP JSON-RPC servers, webhook bridges, or custom JS tools.</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsAddModalOpen(false)}
|
|
className="p-1 text-slate-400 hover:text-slate-600 rounded-lg"
|
|
>
|
|
<XCircle className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleCreateServer} className="space-y-5 text-xs sm:text-sm">
|
|
{/* Type Selection */}
|
|
<div>
|
|
<label className="block font-semibold text-slate-700 mb-2">MCP Integration Type</label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setNewServerType('remote_http')}
|
|
className={`p-3 rounded-xl border text-center font-medium transition ${
|
|
newServerType === 'remote_http'
|
|
? 'border-indigo-600 bg-indigo-50 text-indigo-700 font-bold'
|
|
: 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<Globe className="w-4 h-4 mx-auto mb-1" />
|
|
Remote HTTP
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setNewServerType('webhook')}
|
|
className={`p-3 rounded-xl border text-center font-medium transition ${
|
|
newServerType === 'webhook'
|
|
? 'border-indigo-600 bg-indigo-50 text-indigo-700 font-bold'
|
|
: 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<Webhook className="w-4 h-4 mx-auto mb-1" />
|
|
Webhook Bridge
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setNewServerType('custom_script')}
|
|
className={`p-3 rounded-xl border text-center font-medium transition ${
|
|
newServerType === 'custom_script'
|
|
? 'border-indigo-600 bg-indigo-50 text-indigo-700 font-bold'
|
|
: 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<Code2 className="w-4 h-4 mx-auto mb-1" />
|
|
Custom JS Tool
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Common Fields */}
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block font-medium text-slate-700 mb-1">Server / Tool Name</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={formName}
|
|
onChange={(e) => setFormName(e.target.value)}
|
|
placeholder="e.g. Weather Service MCP"
|
|
className="w-full px-3 py-2 bg-slate-50 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block font-medium text-slate-700 mb-1">Description</label>
|
|
<textarea
|
|
rows={2}
|
|
value={formDescription}
|
|
onChange={(e) => setFormDescription(e.target.value)}
|
|
placeholder="What capabilities does this MCP server provide to connected agents?"
|
|
className="w-full px-3 py-2 bg-slate-50 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Remote HTTP Options */}
|
|
{newServerType === 'remote_http' && (
|
|
<div className="space-y-3 bg-slate-50 p-4 rounded-xl border border-slate-200">
|
|
<div>
|
|
<label className="block font-medium text-slate-700 mb-1">Remote MCP JSON-RPC URL</label>
|
|
<input
|
|
type="url"
|
|
required
|
|
value={formEndpointUrl}
|
|
onChange={(e) => setFormEndpointUrl(e.target.value)}
|
|
placeholder="https://remote-mcp-server.org/mcp"
|
|
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 font-mono text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block font-medium text-slate-700 mb-1">HTTP Authorization Headers (JSON)</label>
|
|
<textarea
|
|
rows={3}
|
|
value={formHeadersText}
|
|
onChange={(e) => setFormHeadersText(e.target.value)}
|
|
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-xl font-mono text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Webhook Options */}
|
|
{newServerType === 'webhook' && (
|
|
<div className="space-y-3 bg-amber-50/50 p-4 rounded-xl border border-amber-200">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div className="col-span-1">
|
|
<label className="block font-medium text-slate-700 mb-1">Method</label>
|
|
<select
|
|
value={formWebhookMethod}
|
|
onChange={(e) => setFormWebhookMethod(e.target.value as any)}
|
|
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-xl"
|
|
>
|
|
<option value="GET">GET</option>
|
|
<option value="POST">POST</option>
|
|
<option value="PUT">PUT</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="col-span-2">
|
|
<label className="block font-medium text-slate-700 mb-1">Target Webhook URL</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={formWebhookUrl}
|
|
onChange={(e) => setFormWebhookUrl(e.target.value)}
|
|
placeholder="https://api.com/v1/search?query={{query}}"
|
|
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-xl font-mono text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block font-medium text-slate-700 mb-1">Input Schema JSON</label>
|
|
<textarea
|
|
rows={3}
|
|
value={formSchemaJson}
|
|
onChange={(e) => setFormSchemaJson(e.target.value)}
|
|
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-xl font-mono text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Custom Script Options */}
|
|
{newServerType === 'custom_script' && (
|
|
<div className="space-y-3 bg-purple-50/50 p-4 rounded-xl border border-purple-200">
|
|
<div>
|
|
<label className="block font-medium text-slate-700 mb-1">Tool Name</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={formToolName}
|
|
onChange={(e) => setFormToolName(e.target.value)}
|
|
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-xl font-mono text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block font-medium text-slate-700 mb-1">JavaScript Logic Script</label>
|
|
<textarea
|
|
rows={6}
|
|
value={formCustomScript}
|
|
onChange={(e) => setFormCustomScript(e.target.value)}
|
|
className="w-full px-3 py-2 bg-slate-900 text-purple-300 border border-slate-800 rounded-xl font-mono text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-100">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsAddModalOpen(false)}
|
|
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-xl hover:bg-slate-200 transition font-medium"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="px-5 py-2 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition font-semibold"
|
|
>
|
|
Save MCP Server
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* MODAL: Import JSON */}
|
|
{isImportModalOpen && (
|
|
<div className="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl border border-slate-200">
|
|
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
|
<h3 className="text-base font-bold text-slate-900">Import MCP Hub Configuration</h3>
|
|
<button onClick={() => setIsImportModalOpen(false)} className="text-slate-400 hover:text-slate-600">
|
|
<XCircle className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-xs text-slate-600">
|
|
Paste exported MCP Hub server configuration JSON below to restore or bulk-register tools.
|
|
</p>
|
|
|
|
<textarea
|
|
rows={8}
|
|
value={importJsonText}
|
|
onChange={(e) => setImportJsonText(e.target.value)}
|
|
placeholder={`{\n "servers": [\n { "name": "Custom Server", "type": "remote_http", "endpointUrl": "..." }\n ]\n}`}
|
|
className="w-full p-3 bg-slate-900 text-slate-200 rounded-xl font-mono text-xs border border-slate-800"
|
|
/>
|
|
|
|
<div className="flex items-center justify-end space-x-3 pt-2">
|
|
<button
|
|
onClick={() => setIsImportModalOpen(false)}
|
|
className="px-4 py-2 text-xs font-medium text-slate-600 hover:bg-slate-100 rounded-xl"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleImportJson}
|
|
className="px-4 py-2 text-xs font-semibold bg-indigo-600 text-white rounded-xl hover:bg-indigo-700"
|
|
>
|
|
Import MCP Configuration
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* MODAL: Tool Execution Test Drawer */}
|
|
{testTool && (
|
|
<div className="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-3xl max-w-xl w-full p-6 space-y-4 shadow-2xl border border-slate-200">
|
|
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
|
<div className="flex items-center space-x-2">
|
|
<Play className="w-4 h-4 text-indigo-600" />
|
|
<h3 className="text-base font-bold text-slate-900">Test Execution: {testTool.name}</h3>
|
|
</div>
|
|
<button onClick={() => setTestTool(null)} className="text-slate-400 hover:text-slate-600">
|
|
<XCircle className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3 text-xs">
|
|
<div>
|
|
<label className="block font-semibold text-slate-700 mb-1">Input Arguments (JSON)</label>
|
|
<textarea
|
|
rows={4}
|
|
value={testArgsJson}
|
|
onChange={(e) => setTestArgsJson(e.target.value)}
|
|
className="w-full p-3 bg-slate-900 text-indigo-300 font-mono text-xs rounded-xl border border-slate-800"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleTestExecute}
|
|
disabled={isExecuting}
|
|
className="w-full py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition flex items-center justify-center space-x-2"
|
|
>
|
|
{isExecuting ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
|
<span>{isExecuting ? 'Executing...' : 'Run Tool Test'}</span>
|
|
</button>
|
|
|
|
{testResult && (
|
|
<div className="space-y-1">
|
|
<label className="block font-semibold text-slate-700">Execution Result</label>
|
|
<pre className="p-3 bg-slate-900 text-emerald-400 font-mono text-xs rounded-xl overflow-x-auto max-h-60 border border-slate-800">
|
|
{JSON.stringify(testResult, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|