2026-02-06 23:01:30 -05:00

431 lines
17 KiB
TypeScript

'use client';
import React, { useState, useCallback, useRef } from 'react';
interface Tool {
name: string;
description: string;
method: string;
endpoint: string;
groupName?: string;
inputSchema?: object;
enabled: boolean;
}
interface GeneratedFile {
path: string;
content: string;
preview?: string;
}
type Step = 'input' | 'analyzing' | 'review' | 'generating' | 'done';
export default function BuildPage() {
const [step, setStep] = useState<Step>('input');
const [specInput, setSpecInput] = useState('');
const [serviceName, setServiceName] = useState('');
const [tools, setTools] = useState<Tool[]>([]);
const [analysis, setAnalysis] = useState<any>(null);
const [files, setFiles] = useState<GeneratedFile[]>([]);
const [progress, setProgress] = useState('');
const [error, setError] = useState('');
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const handleAnalyze = useCallback(async () => {
setError('');
setStep('analyzing');
setTools([]);
setProgress('Starting analysis...');
try {
const isUrl = specInput.startsWith('http');
const res = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(isUrl ? { specUrl: specInput } : { specContent: specInput }),
});
if (!res.body) throw new Error('No response body');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
let eventType = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7);
} else if (line.startsWith('data: ') && eventType) {
try {
const data = JSON.parse(line.slice(6));
if (eventType === 'progress') {
setProgress(data.step);
} else if (eventType === 'tool_found') {
setTools(prev => [...prev, { ...data, enabled: true }]);
} else if (eventType === 'complete') {
setAnalysis(data.analysis);
if (data.analysis?.toolGroups) {
const allTools: Tool[] = [];
for (const group of data.analysis.toolGroups) {
for (const tool of group.tools || []) {
allTools.push({ ...tool, groupName: group.name, enabled: true });
}
}
if (allTools.length > 0) setTools(allTools);
}
setStep('review');
} else if (eventType === 'error') {
setError(data.message);
setStep('input');
}
} catch {}
eventType = '';
}
}
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Analysis failed');
setStep('input');
}
}, [specInput]);
const handleGenerate = useCallback(async () => {
setError('');
setStep('generating');
setFiles([]);
setProgress('Generating MCP server...');
try {
const enabledTools = tools.filter(t => t.enabled);
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
analysis,
tools: enabledTools,
serviceName: serviceName || analysis?.service || 'custom',
}),
});
if (!res.body) throw new Error('No response body');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
let eventType = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7);
} else if (line.startsWith('data: ') && eventType) {
try {
const data = JSON.parse(line.slice(6));
if (eventType === 'progress') {
setProgress(data.step);
} else if (eventType === 'file_ready') {
setFiles(prev => [...prev, data]);
} else if (eventType === 'complete') {
setFiles(data.files || []);
if (data.files?.length > 0) {
setSelectedFile(data.files[0].path);
}
setStep('done');
} else if (eventType === 'error') {
setError(data.message);
setStep('review');
}
} catch {}
eventType = '';
}
}
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Generation failed');
setStep('review');
}
}, [tools, analysis, serviceName]);
const toggleTool = (index: number) => {
setTools(prev => prev.map((t, i) => i === index ? { ...t, enabled: !t.enabled } : t));
};
const downloadFiles = () => {
const content = files.map(f => `// === ${f.path} ===\n\n${f.content}`).join('\n\n\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${serviceName || 'mcp-server'}.txt`;
a.click();
URL.revokeObjectURL(url);
};
const methodColors: Record<string, string> = {
GET: 'bg-emerald-500/20 text-emerald-400',
POST: 'bg-blue-500/20 text-blue-400',
PUT: 'bg-amber-500/20 text-amber-400',
PATCH: 'bg-amber-500/20 text-amber-400',
DELETE: 'bg-red-500/20 text-red-400',
};
return (
<div className="min-h-screen bg-[#0A0F1E]">
{/* Header */}
<header className="border-b border-gray-800 bg-gray-900/80 backdrop-blur-xl sticky top-0 z-50">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="/" className="text-xl font-bold">
<span className="text-indigo-400">MCP</span>
<span className="text-gray-100">Engine</span>
<span className="text-gray-500 text-sm ml-2">Studio</span>
</a>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className={`w-2 h-2 rounded-full ${step === 'done' ? 'bg-emerald-500' : step === 'input' ? 'bg-gray-500' : 'bg-indigo-500 animate-pulse'}`} />
{step === 'input' && 'Ready'}
{step === 'analyzing' && 'Analyzing...'}
{step === 'review' && `${tools.filter(t => t.enabled).length} tools found`}
{step === 'generating' && 'Generating...'}
{step === 'done' && `${files.length} files generated`}
</div>
</div>
</header>
<main className="max-w-5xl mx-auto px-6 py-12">
{/* Step 1: Input */}
{step === 'input' && (
<div className="space-y-8">
<div className="text-center space-y-3">
<h1 className="text-4xl font-bold">
<span className="bg-gradient-to-r from-indigo-400 to-emerald-400 bg-clip-text text-transparent">
Build an MCP Server
</span>
</h1>
<p className="text-gray-400 text-lg">Paste an OpenAPI spec or URL we&apos;ll analyze it and generate a production MCP server.</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Server Name</label>
<input
type="text"
value={serviceName}
onChange={e => setServiceName(e.target.value)}
placeholder="e.g., trello, stripe, my-api"
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-gray-100 placeholder:text-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">OpenAPI Spec (URL or paste content)</label>
<textarea
value={specInput}
onChange={e => setSpecInput(e.target.value)}
placeholder={"Paste an OpenAPI/Swagger spec here (JSON or YAML)...\n\nOr paste a URL like:\nhttps://petstore3.swagger.io/api/v3/openapi.json"}
rows={12}
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-gray-100 placeholder:text-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 outline-none font-mono text-sm"
/>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-red-400 text-sm">
{error}
</div>
)}
<button
onClick={handleAnalyze}
disabled={!specInput.trim()}
className="w-full bg-indigo-500 hover:bg-indigo-400 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold py-3 px-6 rounded-lg transition-colors"
>
Analyze Spec
</button>
</div>
</div>
)}
{/* Step 2: Analyzing */}
{step === 'analyzing' && (
<div className="space-y-8">
<div className="text-center space-y-3">
<h2 className="text-2xl font-bold text-gray-100">Analyzing API...</h2>
<p className="text-gray-400">{progress}</p>
<div className="w-full bg-gray-800 rounded-full h-2 overflow-hidden">
<div className="bg-indigo-500 h-2 rounded-full animate-pulse" style={{ width: '60%' }} />
</div>
</div>
{tools.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Discovered Tools</h3>
<div className="space-y-2">
{tools.map((tool, i) => (
<div key={i} className="bg-gray-800 border border-gray-700 rounded-lg p-3 flex items-center gap-3 animate-[fadeIn_0.3s_ease-out]">
<span className={`px-2 py-0.5 rounded text-xs font-mono font-bold ${methodColors[tool.method] || 'bg-gray-600 text-gray-300'}`}>
{tool.method}
</span>
<span className="text-gray-100 font-medium">{tool.name}</span>
<span className="text-gray-500 text-sm truncate">{tool.description}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Step 3: Review Tools */}
{step === 'review' && (
<div className="space-y-8">
<div className="text-center space-y-3">
<h2 className="text-2xl font-bold text-gray-100">
Found {tools.length} tools
</h2>
<p className="text-gray-400">Toggle tools on/off, then generate your MCP server.</p>
</div>
<div className="space-y-2">
{tools.map((tool, i) => (
<div
key={i}
className={`bg-gray-800 border rounded-lg p-4 flex items-center gap-4 transition-all cursor-pointer ${
tool.enabled ? 'border-gray-700 hover:border-gray-600' : 'border-gray-800 opacity-50'
}`}
onClick={() => toggleTool(i)}
>
<input
type="checkbox"
checked={tool.enabled}
onChange={() => toggleTool(i)}
className="w-4 h-4 rounded border-gray-600 text-indigo-500 focus:ring-indigo-500 bg-gray-700"
/>
<span className={`px-2 py-0.5 rounded text-xs font-mono font-bold shrink-0 ${methodColors[tool.method] || 'bg-gray-600 text-gray-300'}`}>
{tool.method}
</span>
<div className="min-w-0">
<span className="text-gray-100 font-medium">{tool.name}</span>
{tool.groupName && (
<span className="text-gray-600 text-xs ml-2">({tool.groupName})</span>
)}
<p className="text-gray-500 text-sm truncate">{tool.description}</p>
</div>
</div>
))}
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-red-400 text-sm">
{error}
</div>
)}
<div className="flex gap-4">
<button
onClick={() => { setStep('input'); setError(''); }}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-gray-200 font-semibold py-3 px-6 rounded-lg transition-colors"
>
Back
</button>
<button
onClick={handleGenerate}
disabled={tools.filter(t => t.enabled).length === 0}
className="flex-1 bg-emerald-500 hover:bg-emerald-400 disabled:opacity-50 text-white font-semibold py-3 px-6 rounded-lg transition-colors"
>
Generate Server ({tools.filter(t => t.enabled).length} tools)
</button>
</div>
</div>
)}
{/* Step 4: Generating */}
{step === 'generating' && (
<div className="space-y-8 text-center">
<h2 className="text-2xl font-bold text-gray-100">Generating MCP Server...</h2>
<p className="text-gray-400">{progress}</p>
<div className="w-full bg-gray-800 rounded-full h-2 overflow-hidden">
<div className="bg-emerald-500 h-2 rounded-full animate-pulse" style={{ width: '50%' }} />
</div>
{files.length > 0 && (
<div className="text-left space-y-2">
{files.map((f, i) => (
<div key={i} className="bg-gray-800 border border-gray-700 rounded-lg p-3 text-sm">
<span className="text-emerald-400 font-mono"> {f.path}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Step 5: Done */}
{step === 'done' && (
<div className="space-y-8">
<div className="text-center space-y-3">
<div className="text-5xl">🎉</div>
<h2 className="text-2xl font-bold text-gray-100">
MCP Server Generated!
</h2>
<p className="text-gray-400">{files.length} files ready click to view, or download all.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* File list */}
<div className="space-y-2">
{files.map((f, i) => (
<button
key={i}
onClick={() => setSelectedFile(f.path)}
className={`w-full text-left px-4 py-3 rounded-lg text-sm font-mono transition-colors ${
selectedFile === f.path
? 'bg-indigo-500/20 border border-indigo-500/30 text-indigo-300'
: 'bg-gray-800 border border-gray-700 text-gray-300 hover:border-gray-600'
}`}
>
{f.path}
</button>
))}
</div>
{/* File preview */}
<div className="lg:col-span-2 bg-gray-900 border border-gray-700 rounded-xl overflow-hidden">
<div className="bg-gray-800 px-4 py-2 border-b border-gray-700 text-sm text-gray-400 font-mono">
{selectedFile || 'Select a file'}
</div>
<pre className="p-4 overflow-auto max-h-[500px] text-sm text-gray-300 font-mono whitespace-pre-wrap">
{files.find(f => f.path === selectedFile)?.content || 'Select a file to preview'}
</pre>
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => { setStep('input'); setFiles([]); setTools([]); setAnalysis(null); setError(''); }}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-gray-200 font-semibold py-3 px-6 rounded-lg transition-colors"
>
Build Another
</button>
<button
onClick={downloadFiles}
className="flex-1 bg-emerald-500 hover:bg-emerald-400 text-white font-semibold py-3 px-6 rounded-lg transition-colors"
>
Download All Files
</button>
</div>
</div>
)}
</main>
</div>
);
}