431 lines
17 KiB
TypeScript
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'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>
|
|
);
|
|
}
|