diff --git a/packages/cli/package.json b/packages/cli/package.json index ad95be06..2c8d9fd9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,7 @@ { "name": "@mcp-use/cli", "version": "2.1.9", + "type": "module", "description": "Build tool for MCP UI widgets - bundles React components into standalone HTML pages for Model Context Protocol servers", "author": "mcp-use, Inc.", "license": "MIT", @@ -36,17 +37,20 @@ "dev": "tsc --watch" }, "dependencies": { - "mcp-use": "workspace:*", "@mcp-use/inspector": "workspace:*", "commander": "^11.0.0", "esbuild": "^0.19.0", "globby": "^14.0.0", + "mcp-use": "workspace:*", "open": "^10.0.0", + "terminal-link": "^5.0.0", "tsx": "^4.0.0" }, "devDependencies": { "@types/node": "^20.0.0", - "typescript": "^5.0.0" + "@vitejs/plugin-react": "^5.0.4", + "typescript": "^5.0.0", + "vite": "^7.1.10" }, "publishConfig": { "access": "public" diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index cfa68cef..4c664d36 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs' import path from 'node:path' -import { build, context } from 'esbuild' +import { build } from 'esbuild' import { globby } from 'globby' const ROUTE_PREFIX = '/mcp-use/widgets' @@ -97,7 +97,7 @@ async function buildWidget(entry: string, projectPath: string, minify = true) { return { baseName, route } } -export async function buildWidgets(projectPath: string, watch = false) { +export async function buildWidgets(projectPath: string) { const srcDir = path.join(projectPath, SRC_DIR) const outDir = path.join(projectPath, OUT_DIR) @@ -107,88 +107,15 @@ export async function buildWidgets(projectPath: string, watch = false) { // Find all TSX entries const entries = await globby([`${srcDir}/**/*.tsx`]) - if (!watch) { - console.log(`Building ${entries.length} widget files...`) - } + console.log(`Building ${entries.length} widget files...`) - if (watch) { - // Watch mode - create contexts for each entry but don't log individual watching messages - const contexts = [] - - for (const entry of entries) { - const relativePath = path.relative(projectPath, entry) - const route = toRoute(relativePath) - const pageOutDir = path.join(projectPath, outDirForRoute(route)) - const baseName = path.parse(entry).name - - const ctx = await context({ - entryPoints: [entry], - bundle: true, - splitting: true, - format: 'esm', - platform: 'browser', - target: 'es2018', - sourcemap: true, - minify: false, - outdir: path.join(pageOutDir, 'assets'), - logLevel: 'silent', - loader: { - '.svg': 'file', - '.png': 'file', - '.jpg': 'file', - '.jpeg': 'file', - '.gif': 'file', - '.css': 'css', - }, - entryNames: `[name]-[hash]`, - chunkNames: `chunk-[hash]`, - assetNames: `asset-[hash]`, - define: { - 'process.env.NODE_ENV': '"development"', - }, - plugins: [{ - name: 'html-writer', - setup(buildPlugin) { - buildPlugin.onEnd(async () => { - try { - const files = await fs.readdir(path.join(pageOutDir, 'assets')) - const mainJs = files.find(f => f.startsWith(`${baseName}-`) && f.endsWith('.js')) - if (mainJs) { - await fs.mkdir(pageOutDir, { recursive: true }) - await fs.writeFile( - path.join(pageOutDir, 'index.html'), - htmlTemplate({ - title: baseName, - scriptPath: `./assets/${mainJs}`, - }), - 'utf8', - ) - } - } catch (err) { - console.error(`Error writing HTML for ${baseName}:`, err) - } - }) - }, - }], - }) - - contexts.push(ctx) - } - - // Start watching all contexts - for (const ctx of contexts) { - await ctx.watch() - } - } - else { - // Build once - for (const entry of entries) { - const { baseName, route } = await buildWidget(entry, projectPath) - console.log(`\x1b[32m✓\x1b[0m Built ${baseName} -> ${route}`) - } - - console.log('Build complete!') + // Build once (production mode) + for (const entry of entries) { + const { baseName, route } = await buildWidget(entry, projectPath) + console.log(`\x1b[32m✓\x1b[0m Built ${baseName} -> ${route}`) } + + console.log('Build complete!') } diff --git a/packages/cli/src/dev-server.ts b/packages/cli/src/dev-server.ts new file mode 100644 index 00000000..50f6b3a3 --- /dev/null +++ b/packages/cli/src/dev-server.ts @@ -0,0 +1,120 @@ +import { createServer, type ViteDevServer } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'node:path' +import { promises as fs } from 'node:fs' +import { globby } from 'globby' + +const ROUTE_PREFIX = '/mcp-use/widgets' +const SRC_DIR = 'resources' + +interface WidgetEntry { + name: string + path: string + route: string +} + +async function discoverWidgets(projectPath: string): Promise { + const srcDir = path.join(projectPath, SRC_DIR) + const entries = await globby([`${srcDir}/**/*.tsx`]) + + return entries.map(entry => { + const relativePath = path.relative(path.join(projectPath, SRC_DIR), entry) + const name = path.parse(entry).name + const route = `${ROUTE_PREFIX}/${relativePath.replace(/\.tsx?$/, '')}` + + return { name, path: entry, route } + }) +} + +function htmlTemplate(scriptPath: string, title: string) { + return ` + + + + + ${title} Widget + + + +
+ + +` +} + +export async function startDevServer(projectPath: string, port: number = 5173): Promise<{ server: ViteDevServer; port: number }> { + const widgets = await discoverWidgets(projectPath) + + const server = await createServer({ + root: projectPath, + server: { + port, + strictPort: false, + cors: true, + hmr: { + overlay: true, + }, + }, + plugins: [ + react(), + { + name: 'mcp-widget-html', + apply: 'serve', + configureServer(server) { + // Serve widget HTML pages + server.middlewares.use(async (req, res, next) => { + // Early check before processing + if (!req.url?.startsWith(ROUTE_PREFIX)) { + return next() + } + + // Find matching widget (strip query string for matching) + const urlWithoutQuery = req.url?.split('?')[0] + const widget = widgets.find(w => urlWithoutQuery === w.route) + + if (!widget) { + return next() + } + + // Serve HTML with the widget entry point + const relativeWidgetPath = '/' + path.relative(projectPath, widget.path) + const html = htmlTemplate(relativeWidgetPath, widget.name) + + // Transform HTML to inject Vite's HMR client + const transformedHtml = await server.transformIndexHtml(req.url, html) + + res.setHeader('Content-Type', 'text/html') + res.end(transformedHtml) + }) + }, + }, + ], + optimizeDeps: { + include: ['react', 'react-dom', 'react/jsx-runtime'], + }, + }) + + await server.listen() + + const actualPort = server.config.server.port || port + + console.log(`\x1b[32m✓\x1b[0m Vite dev server with HMR started on port ${actualPort}`) + console.log(`\x1b[90m ${widgets.length} widget(s) available:\x1b[0m`) + for (const widget of widgets) { + console.log(`\x1b[90m - http://localhost:${actualPort}${widget.route}\x1b[0m`) + } + + return { server, port: actualPort } +} + diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a4a61b55..0690c09f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,13 +1,56 @@ #!/usr/bin/env node import { Command } from 'commander'; -import { buildWidgets } from './build'; +import { buildWidgets } from './build.js'; +import { startDevServer } from './dev-server.js'; import { spawn } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { access } from 'node:fs/promises'; +import { networkInterfaces } from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import open from 'open'; +import terminalLink from 'terminal-link'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Handle ExitPromptError from Commander.js gracefully +process.on('uncaughtException', (error) => { + if (error instanceof Error && error.name === 'ExitPromptError') { + console.log('\n👋 Until next time!'); + process.exit(0); + } else { + // Rethrow unknown errors + throw error; + } +}); + const program = new Command(); +// Render logo as ASCII art +function renderLogo(): void { + console.log('\x1b[36m▛▛▌▛▘▛▌▄▖▌▌▛▘█▌\x1b[0m'); + console.log('\x1b[36m▌▌▌▙▖▙▌ ▙▌▄▌▙▖\x1b[0m'); + console.log('\x1b[36m ▌ \x1b[0m'); +} + +// Get local network IP +function getNetworkIP(): string { + const nets = networkInterfaces(); + for (const name of Object.keys(nets)) { + const netInterface = nets[name]; + if (!netInterface) continue; + + for (const net of netInterface) { + // Skip internal and non-ipv4 addresses + if (net.family === 'IPv4' && !net.internal) { + return net.address; + } + } + } + return 'localhost'; +} + const packageContent = readFileSync(path.join(__dirname, '../package.json'), 'utf-8') const packageJson = JSON.parse(packageContent) @@ -83,7 +126,9 @@ program try { const projectPath = path.resolve(options.path); - console.log(`\x1b[36m\x1b[1mmcp-use\x1b[0m \x1b[90mVersion: ${packageJson.version}\x1b[0m\n`); + console.log(''); + renderLogo(); + console.log(`\x1b[90mVersion: ${packageJson.version}\x1b[0m\n`); // Run tsc first console.log('Building TypeScript...'); @@ -91,7 +136,7 @@ program console.log('\x1b[32m✓\x1b[0m TypeScript build complete!'); // Then build widgets - await buildWidgets(projectPath, false); + await buildWidgets(projectPath); } catch (error) { console.error('Build failed:', error); process.exit(1); @@ -109,7 +154,9 @@ program const projectPath = path.resolve(options.path); let port = parseInt(options.port, 10); - console.log(`\x1b[36m\x1b[1mmcp-use\x1b[0m \x1b[90mVersion: ${packageJson.version}\x1b[0m\n`); + console.log(''); + renderLogo(); + console.log(`\x1b[90mVersion: ${packageJson.version}\x1b[0m\n`); // Check if port is available, find alternative if needed if (!(await isPortAvailable(port))) { @@ -135,29 +182,113 @@ program cwd: projectPath, stdio: 'pipe', shell: false, + // Create a new process group on Unix to properly handle signals + detached: false, }); + + // Filter out npm warnings from tsc output tscProc.stdout?.on('data', (data) => { const output = data.toString(); if (output.includes('Watching for file changes')) { console.log('\x1b[32m✓\x1b[0m TypeScript compiler watching...'); } }); + + tscProc.stderr?.on('data', (data) => { + const output = data.toString(); + // Filter out npm warnings and tsx cleanup messages + if (!output.includes('npm warn') && + !output.includes('[tsx]') && + !output.includes('Force killing')) { + process.stderr.write(output); + } + }); + processes.push(tscProc); - // 2. Widget builder watch - run in background - buildWidgets(projectPath, true).catch((error) => { - console.error('Widget builder failed:', error); - }); + // 2. Start Vite dev server for widgets with HMR + const vitePort = 5173; + let viteServer: any; + try { + const result = await startDevServer(projectPath, vitePort); + viteServer = result.server; + } catch (error) { + console.error('Failed to start Vite dev server:', error); + process.exit(1); + } // Wait a bit for initial builds - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, 1000)); - // 3. Server with tsx + // 3. Server with tsx - pipe output to filter duplicates const serverProc = spawn('npx', ['tsx', 'watch', serverFile], { cwd: projectPath, - stdio: 'inherit', + stdio: 'pipe', shell: false, - env: { ...process.env, PORT: String(port) }, + env: { + ...process.env, + PORT: String(port), + VITE_DEV_SERVER: `http://localhost:${vitePort}`, + MCP_USE_DEV_MODE: 'true', + }, + // Create a new process group on Unix to properly handle signals + detached: false, + }); + + // Track seen log lines to avoid duplicates and shutdown state + const seenLogs = new Set(); + const logTimeout = new Map(); + let isShuttingDown = false; + + serverProc.stdout?.on('data', (data) => { + const lines = data.toString().split('\n'); + lines.forEach((line: string) => { + if (!line.trim()) return; + + // Filter out server startup messages since CLI provides better formatted output + if (line.includes('[MCP] Server mounted') || + line.includes('[SERVER] Listening') || + line.includes('[MCP] Endpoints:') || + line.includes('[INSPECTOR] UI available')) { + return; + } + + // Create a normalized key for the log line + const normalizedLine = line.replace(/\[\d{2}:\d{2}:\d{2}\.\d{3}\]/g, '[TIME]'); + + // If we've seen this line recently, skip it + if (seenLogs.has(normalizedLine)) return; + + // Add to seen logs and set a timeout to remove it + seenLogs.add(normalizedLine); + + // Clear existing timeout if any + if (logTimeout.has(normalizedLine)) { + clearTimeout(logTimeout.get(normalizedLine)!); + } + + // Remove from seen logs after 1 second + logTimeout.set(normalizedLine, setTimeout(() => { + seenLogs.delete(normalizedLine); + logTimeout.delete(normalizedLine); + }, 1000)); + + console.log(line); + }); + }); + + serverProc.stderr?.on('data', (data) => { + // Suppress all output during shutdown + if (isShuttingDown) return; + + const output = data.toString(); + // Filter out npm warnings about pnpm/yarn config and tsx cleanup messages + if (!output.includes('npm warn') && + !output.includes('[tsx]') && + !output.includes('Force killing') && + !output.includes('Previous process')) { + process.stderr.write(output); + } }); processes.push(serverProc); @@ -167,27 +298,85 @@ program const startTime = Date.now(); const ready = await waitForServer(port); if (ready) { + const networkIP = getNetworkIP(); const mcpUrl = `http://localhost:${port}/mcp`; const inspectorUrl = `http://localhost:${port}/inspector?autoConnect=${encodeURIComponent(mcpUrl)}`; const readyTime = Date.now() - startTime; - console.log(`\n\x1b[32m✓\x1b[0m Ready in ${readyTime}ms`); - console.log(`Local: http://localhost:${port}`); - console.log(`Network: http://localhost:${port}`); - console.log(`MCP: ${mcpUrl}`); - console.log(`Inspector: ${inspectorUrl}\n`); + const networkMcpUrl = `http://${networkIP}:${port}/mcp`; + console.log(`\n\x1b[32m✓\x1b[0m \x1b[1mReady in ${readyTime}ms\x1b[0m\n`); + console.log(`\x1b[1m🌐 MCP Endpoints:\x1b[0m`); + console.log(` Local: \x1b[36m${mcpUrl}\x1b[0m`); + console.log(` Network: \x1b[36m${networkMcpUrl}\x1b[0m`); + console.log(` Inspector: \x1b[36m${inspectorUrl}\x1b[0m`); + console.log(''); + + // Create clickable links with fallback + const docsLink = terminalLink('\x1b[1mhttps://docs.mcp-use.com\x1b[0m', 'https://docs.mcp-use.com', { + fallback: (text, url) => `${text} \x1b[90m${url}\x1b[0m` + }); + const githubLink = terminalLink('\x1b[1mhttps://github.com/mcp-use/mcp-use\x1b[0m', 'https://github.com/mcp-use/mcp-use', { + fallback: (text, url) => `${text} \x1b[90m${url}\x1b[0m` + }); + const websiteLink = terminalLink('https://mcp-use.com', 'https://mcp-use.com', { + fallback: (text, url) => `${text} \x1b[90m${url}\x1b[0m` + }); + + console.log(`📚 ${docsLink}`); + console.log(`💬 Feedback & bug reports: ${githubLink} or ${websiteLink}`); + console.log(''); await open(inspectorUrl); } } // Handle cleanup - const cleanup = () => { - console.log('\n\nShutting down...'); - processes.forEach(proc => proc.kill()); - process.exit(0); + const cleanup = async (signal?: string) => { + if (isShuttingDown) return; + isShuttingDown = true; + + console.log('\n\n👋 Shutting down...'); + + // Immediately suppress all stderr to prevent tsx cleanup messages + process.stderr.write = (() => true) as any; + + // Remove all listeners from child process streams to prevent any buffered output + processes.forEach(proc => { + try { + proc.stdout?.removeAllListeners(); + proc.stderr?.removeAllListeners(); + } catch (error) { + // Ignore errors when removing listeners + } + }); + + // Close Vite server and await it to ensure proper cleanup + if (viteServer) { + try { + await viteServer.close(); + } catch (error) { + // Ignore errors when closing Vite + } + } + + // Kill all child processes with SIGKILL for immediate termination + processes.forEach(proc => { + try { + if (!proc.killed) { + proc.kill('SIGKILL'); + } + } catch (error) { + // Ignore errors when killing processes + } + }); + + // Give a bit more time for processes to clean up, then force exit + setTimeout(() => { + process.exit(0); + }, 200); }; process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); + process.on('SIGHUP', cleanup); // Keep the process running await new Promise(() => {}); @@ -207,7 +396,9 @@ program const projectPath = path.resolve(options.path); const port = parseInt(options.port, 10); - console.log(`\x1b[36m\x1b[1mmcp-use\x1b[0m \x1b[90mVersion: ${packageJson.version}\x1b[0m\n`); + console.log(''); + renderLogo(); + console.log(`\x1b[90mVersion: ${packageJson.version}\x1b[0m\n`); // Find the built server file let serverFile = 'dist/index.js'; @@ -225,17 +416,41 @@ program }); // Handle cleanup + let isShuttingDown = false; const cleanup = () => { - console.log('\n\nShutting down...'); - serverProc.kill(); - process.exit(0); + if (isShuttingDown) return; + isShuttingDown = true; + + console.log('\n\n👋 Shutting down...'); + + try { + if (!serverProc.killed) { + serverProc.kill('SIGTERM'); + // Force kill after 1 second if still running + setTimeout(() => { + if (!serverProc.killed) { + serverProc.kill('SIGKILL'); + } + }, 1000); + } + } catch (error) { + // Ignore errors when killing process + } + + // Exit after giving process time to clean up + setTimeout(() => { + process.exit(0); + }, 1500); }; process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); + process.on('SIGHUP', cleanup); serverProc.on('exit', (code) => { - process.exit(code || 0); + if (!isShuttingDown) { + process.exit(code || 0); + } }); } catch (error) { console.error('Start failed:', error); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 72805bd0..ab06421e 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "composite": true, "target": "ES2022", - "module": "commonjs", + "module": "node16", + "moduleResolution": "node16", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 00000000..674cde1c --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node18', + external: [ + 'vite', + '@vitejs/plugin-react', + 'esbuild' + ], + dts: false, + splitting: false, + sourcemap: false, + clean: false, + shims: false, + bundle: true, +}) + diff --git a/packages/create-mcp-use-app/package.json b/packages/create-mcp-use-app/package.json index 30397397..86c499a4 100644 --- a/packages/create-mcp-use-app/package.json +++ b/packages/create-mcp-use-app/package.json @@ -47,11 +47,14 @@ "dependencies": { "commander": "^11.0.0", "chalk": "^5.3.0", - "fs-extra": "^11.2.0" + "fs-extra": "^11.2.0", + "ora": "^8.0.0", + "inquirer": "^9.2.0" }, "devDependencies": { "@types/node": "^20.0.0", "@types/fs-extra": "^11.0.4", + "@types/inquirer": "^9.0.0", "typescript": "^5.0.0", "vitest": "^1.0.0" }, diff --git a/packages/create-mcp-use-app/src/index.ts b/packages/create-mcp-use-app/src/index.ts index 3ade1897..d9dd5f33 100644 --- a/packages/create-mcp-use-app/src/index.ts +++ b/packages/create-mcp-use-app/src/index.ts @@ -1,17 +1,29 @@ #!/usr/bin/env node -import { execSync } from 'node:child_process' +import { exec } from 'node:child_process' +import { promisify } from 'node:util' import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { createInterface } from 'node:readline' import { Command } from 'commander' +import chalk from 'chalk' +import ora from 'ora' +import inquirer from 'inquirer' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) +const execAsync = promisify(exec) + const program = new Command() +// Render logo as ASCII art +function renderLogo(): void { + console.log(chalk.cyan('▛▛▌▛▘▛▌▄▖▌▌▛▘█▌')) + console.log(chalk.cyan('▌▌▌▙▖▙▌ ▙▌▄▌▙▖')) + console.log(chalk.cyan(' ▌ ')) +} + const packageJson = JSON.parse( readFileSync(join(__dirname, '../package.json'), 'utf-8') ) @@ -59,8 +71,12 @@ function getCurrentPackageVersions() { ) versions['@mcp-use/inspector'] = inspectorPackage.version } catch (error) { - console.warn('⚠️ Could not read workspace package versions, using defaults') - console.warn(` Error: ${error}`) + // Silently use defaults when not in workspace (normal for published package) + // Only log in development mode + if (process.env.NODE_ENV === 'development') { + console.warn('⚠️ Could not read workspace package versions, using defaults') + console.warn(` Error: ${error}`) + } } return versions @@ -99,27 +115,29 @@ program .description('Create a new MCP server project') .version(packageJson.version) .argument('[project-name]', 'Name of the MCP server project') - .option('-t, --template