import { mkdirSync, writeFileSync, accessSync, existsSync, statSync, constants } from 'fs'; import { join, resolve, dirname, basename } from 'path'; import { homedir } from 'os'; import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import type { WebSocketManager } from '../connection.js'; import { ScaffoldProjectInputSchema } from '../types.js'; import type { ScaffoldProjectInput } from '../types.js'; import { determineEnvironment } from './sceneEnvironment.js'; import type { SceneEnvironment } from './sceneEnvironment.js'; export const scaffoldProjectSchema = ScaffoldProjectInputSchema; export type { ScaffoldProjectInput }; export const scaffoldProjectTool: Tool = { name: 'scaffold_project', description: 'Create an entire new R3F project from a natural language description. ' + 'IMPORTANT WORKFLOW: Generate all component code FIRST, then pass it in the ' + '"components" array so everything is written in a single tool call. ' + 'Do NOT call this tool and then try to write the same files again — ' + 'all files are written to the user\'s local filesystem and exist immediately.', inputSchema: { type: 'object', properties: { description: { type: 'string', description: 'What to build, e.g. "a space shooter game"' }, directory: { type: 'string', description: 'Where to create the project (supports ~/...)' }, template: { type: 'string', enum: ['game', 'showcase', 'portfolio', 'visualization', 'experience'], description: 'Project archetype', }, features: { type: 'array', items: { type: 'string' }, description: 'e.g. ["physics","postprocessing"]', }, components: { type: 'array', description: 'Pre-generated component files. Generate ALL component code before calling this tool ' + 'so the entire project is written in one call.', items: { type: 'object', properties: { name: { type: 'string', description: 'PascalCase component name' }, description: { type: 'string', description: 'What this component does' }, code: { type: 'string', description: 'Complete TSX source (default-exported function)' }, }, required: ['name', 'code'], }, }, }, required: ['description', 'directory'], }, }; // ─── Path resolution ────────────────────────────────────────────────────────── function resolveDir(raw: string): string { const expanded = raw.startsWith('~') ? join(homedir(), raw.slice(1)) : raw; return resolve(expanded); } // ─── File size helper ───────────────────────────────────────────────────────── function fmtSize(bytes: number): string { return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes} B`; } // ─── File templates ─────────────────────────────────────────────────────────── function packageJson(name: string, features: string[]): string { const hasPhysics = features.includes('physics'); const hasPost = features.includes('postprocessing'); // Peer-dependency chains (verified against npm registry as of v0.4.2): // // @react-three/fiber ^9 → react ^19, react-dom ^19, three >=0.156 // @react-three/drei ^10 → @react-three/fiber ^9, three >=0.159 // @react-three/postprocessing ^3 → @react-three/fiber ^9, react ^19, three >=0.156 // postprocessing ^6.36 → three >=0.168.0 <0.185.0 ← the binding constraint // @react-three/rapier ^2 → @react-three/fiber ^9.0.4, react ^19, three >=0.159 // @dimforge/rapier3d-compat → Rapier WASM runtime peer of @react-three/rapier // r3f-mcp → @react-three/fiber, three, react, react-dom // // three is pinned to ^0.184.0 (latest within postprocessing's >=0.168 <0.185 range). return JSON.stringify({ name, version: '0.1.0', private: true, type: 'module', scripts: { dev: 'vite', build: 'tsc && vite build', preview: 'vite preview' }, dependencies: { // Core — always included 'react': '^19.0.0', 'react-dom': '^19.0.0', 'three': '^0.184.0', '@react-three/fiber': '^9.0.0', '@react-three/drei': '^10.0.0', 'r3f-mcp': '^0.5.0', // Postprocessing — "postprocessing" is the direct peer of @react-three/postprocessing // and the source of ToneMappingMode, BlendFunction, KernelSize, etc. ...(hasPost ? { '@react-three/postprocessing': '^3.0.0', 'postprocessing': '^6.36.0', } : {}), // Physics — @dimforge/rapier3d-compat ships the WASM runtime ...(hasPhysics ? { '@react-three/rapier': '^2.0.0', '@dimforge/rapier3d-compat': '^0.19.0', } : {}), }, devDependencies: { '@types/react': '^19.0.0', '@types/react-dom': '^19.0.0', '@types/three': '^0.184.0', '@vitejs/plugin-react': '^4.2.0', 'typescript': '^5.4.0', 'vite': '^5.0.0', }, }, null, 2); } const VITE_CONFIG = `import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()] }); `; const TSCONFIG = JSON.stringify({ compilerOptions: { target: 'ES2020', module: 'ESNext', moduleResolution: 'bundler', jsx: 'react-jsx', strict: true, skipLibCheck: true, lib: ['ES2020', 'DOM'], noEmit: true, }, include: ['src'], }, null, 2); const INDEX_HTML = ` R3F App
`; const MAIN_TSX = `import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App'; createRoot(document.getElementById('root')!).render( , ); `; function appTsx( description: string, hasPhysics: boolean, components: Array<{ name: string; description: string }>, env: SceneEnvironment, ): string { const physicsImport = hasPhysics ? `import { Physics } from '@react-three/rapier';\n` : ''; const physicsOpen = hasPhysics ? '\n ' : ''; const physicsClose = hasPhysics ? '\n ' : ''; const compImports = components.length ? '\n' + components.map(c => `import ${c.name} from './components/${c.name}';`).join('\n') : ''; const compUsage = components.length ? components.map(c => ` <${c.name} /> {/* ${c.description} */}`).join('\n') : ` {/* Add components here — use inject_code or add files to src/components/ */}`; const dreiNames = [...new Set(env.dreiImports)].join(', '); const fogJsx = env.fog ? `\n ` : ''; const groundBlock = env.groundJsx ? '\n\n' + env.groundJsx : ''; const envBlock = env.environmentJsx ? '\n\n {/* Environment */}\n' + env.environmentJsx : ''; const atmosphereBlock = env.atmosphereJsx ? '\n\n {/* Atmosphere */}\n' + env.atmosphereJsx : ''; const postNote = env.postProcessingNote ? `\n {/* Post-processing: ${env.postProcessingNote} */}` : ''; return `import { Canvas } from '@react-three/fiber'; import { ${dreiNames} } from '@react-three/drei'; import { MCPProvider } from 'r3f-mcp'; ${physicsImport}${compImports} // ${description} // Scene type: ${env.label} // ${env.notes} function SceneContent() { return ( {/* Lighting */} ${env.lightingJsx}${atmosphereBlock}${envBlock}${physicsOpen} ${compUsage}${physicsClose}${groundBlock}${postNote} ${env.controlsJsx} ); } export function App() { return ( ${fogJsx} ); } `; } // ─── Handler ────────────────────────────────────────────────────────────────── type ScaffoldContent = { content: Array<{ type: 'text'; text: string }> }; export async function handleScaffoldProject( args: ScaffoldProjectInput, _connection: WebSocketManager, ): Promise { // ── 1. Resolve absolute path, expand ~ ──────────────────────────────────── const dir = resolveDir(args.directory); const slug = basename(dir) || 'r3f-app'; const features = args.features ?? []; const components = args.components ?? []; const hasPhysics = features.includes('physics'); // Determine the appropriate scene environment from the project description const env = determineEnvironment(args.description); // ── 2. Permission check on parent ───────────────────────────────────────── const parentDir = dirname(dir); try { accessSync(parentDir, constants.W_OK); } catch { throw new Error( `Cannot write to "${parentDir}" — check the path exists and is writable.`, ); } // ── 3. Create directory tree ─────────────────────────────────────────────── try { mkdirSync(join(dir, 'src', 'components'), { recursive: true }); } catch (err) { throw new Error( `Failed to create "${dir}": ${err instanceof Error ? err.message : String(err)}`, ); } // ── 4. Build file list (boilerplate + user components) ──────────────────── const files: Array<{ relPath: string; content: string }> = [ { relPath: 'package.json', content: packageJson(slug, features) }, { relPath: 'vite.config.ts', content: VITE_CONFIG }, { relPath: 'tsconfig.json', content: TSCONFIG }, { relPath: 'index.html', content: INDEX_HTML }, { relPath: 'src/main.tsx', content: MAIN_TSX }, { relPath: 'src/App.tsx', content: appTsx(args.description, hasPhysics, components, env) }, ...components.map(c => ({ relPath: `src/components/${c.name}.tsx`, content: c.code, })), ]; // ── 5. Write all files, collecting results ──────────────────────────────── type FileResult = { relPath: string; absPath: string; size: number }; const written: FileResult[] = []; const failed: string[] = []; for (const file of files) { const absPath = join(dir, file.relPath); try { mkdirSync(dirname(absPath), { recursive: true }); writeFileSync(absPath, file.content, 'utf8'); written.push({ relPath: file.relPath, absPath, size: statSync(absPath).size }); } catch (err) { failed.push(`${file.relPath}: ${err instanceof Error ? err.message : String(err)}`); } } if (failed.length === files.length) { throw new Error(`All writes failed:\n` + failed.map(f => ` • ${f}`).join('\n')); } // ── 6. Verify key files exist and record sizes ──────────────────────────── const keyPaths = ['package.json', 'src/App.tsx']; const verification = keyPaths.map(p => { const abs = join(dir, p); if (!existsSync(abs)) return ` ✗ ${abs} — MISSING`; const size = statSync(abs).size; return ` ✓ ${abs} (${fmtSize(size)})`; }).join('\n'); // ── 7. Build response ───────────────────────────────────────────────────── const fileList = written .map(f => ` ${f.relPath} (${fmtSize(f.size)})`) .join('\n'); const failNote = failed.length ? `\n⚠️ ${failed.length} file(s) could not be written:\n${failed.map(f => ` • ${f}`).join('\n')}\n` : ''; const text = [ `✅ Project successfully created on the user's local machine.`, ``, `Directory: ${dir}`, `Scene type: ${env.label} (background: ${env.background})`, `Files created: ${written.length}`, ``, fileList, failNote, `Verified on disk:`, verification, ``, `To run the project, the user should open their terminal and run:`, ` cd ${dir}`, ` npm install`, ` npm run dev`, ``, `IMPORTANT: All files have been written directly to the user's local filesystem.`, `Do NOT attempt to recreate, rewrite, or present these files in chat.`, `They already exist at the paths listed above.`, `Your next step should be to tell the user to run:`, ` cd ${dir} && npm install && npm run dev`, `— nothing else is needed.`, ].join('\n'); return { content: [{ type: 'text', text }] }; }