UNPKG

@bottobot/td-mcp

Version:

TouchDesigner Documentation MCP Server v2.3 - Pure MCP server for VS Code/Codium with comprehensive operator documentation for 629 operators + 7 tutorials across all categories (TOP, CHOP, SOP, DAT, MAT, COMP, POP). Features experimental POP operators wit

763 lines (656 loc) 28.2 kB
/** * Wiki Website Server - Local web interface for TouchDesigner documentation * Provides browsable interface with search functionality * Mirrors docs.derivative.ca URL structure for seamless offline browsing */ import express from 'express'; import hbs from 'hbs'; import { promises as fs } from 'fs'; import { join, dirname, extname } from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // TouchDesigner documentation directory path const TD_DOCS_PATH = 'C:/Program Files/Derivative/TouchDesigner/Documentation'; export class WikiWebServer { constructor(wikiSystem, options = {}) { this.wikiSystem = wikiSystem; this.options = { port: options.port || 3000, host: options.host || 'localhost', autoStart: options.autoStart !== false, tdDocsPath: options.tdDocsPath || TD_DOCS_PATH, ...options }; this.app = express(); this.server = null; this.isRunning = false; // Category mappings for URL routing this.categoryMappings = { 'TOP': 'Texture Operators', 'CHOP': 'Channel Operators', 'SOP': 'Surface Operators', 'DAT': 'Data Operators', 'MAT': 'Material Operators', 'COMP': 'Component Operators', 'POP': 'Particle Operators' }; this.setupMiddleware(); this.setupRoutes(); this.setupErrorHandling(); } /** * Setup Express middleware */ setupMiddleware() { // Setup Handlebars template engine with hbs this.app.set('view engine', 'hbs'); this.app.set('views', join(__dirname, '..', 'templates', 'pages')); // Register partials directory hbs.registerPartials(join(__dirname, '..', 'templates', 'partials')); // Register the main layout as a partial hbs.registerPartial('layout', join(__dirname, '..', 'templates', 'layouts', 'main.hbs')); // Register Handlebars helpers hbs.registerHelper('formatCategory', (category) => { return this.categoryMappings[category] || category; }); hbs.registerHelper('getCategoryLink', (category) => { // Return docs.derivative.ca style category links return `/${category}`; }); hbs.registerHelper('getOperatorLink', (operatorName) => { // Return docs.derivative.ca style operator links return `/${operatorName}`; }); hbs.registerHelper('formatParamType', (type) => { return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown'; }); hbs.registerHelper('truncate', (str, length = 150) => { if (!str || str.length <= length) return str; return str.substring(0, length) + '...'; }); hbs.registerHelper('json', (context) => { return JSON.stringify(context); }); hbs.registerHelper('notEmpty', (value) => { if (Array.isArray(value)) return value.length > 0; return value && value.length > 0; }); hbs.registerHelper('ifEquals', function(arg1, arg2, options) { return (arg1 === arg2) ? options.fn(this) : options.inverse(this); }); hbs.registerHelper('gt', function(a, b) { return a > b; }); hbs.registerHelper('subtract', function(a, b) { return a - b; }); hbs.registerHelper('buildBreadcrumbs', (operator) => { const breadcrumbs = [ { text: 'Home', url: '/' } ]; if (operator && operator.category) { breadcrumbs.push({ text: this.categoryMappings[operator.category] || operator.category, url: `/${operator.category}` }); if (operator.name) { breadcrumbs.push({ text: operator.name, url: `/${operator.name}` }); } } return breadcrumbs; }); // Static files from wiki static directory this.app.use('/static', express.static(join(__dirname, '..', 'static'))); // Serve TouchDesigner documentation static resources (images, etc.) if (this.options.tdDocsPath) { this.app.use('/docs/images', express.static(join(this.options.tdDocsPath, 'images'))); this.app.use('/docs/resources', express.static(join(this.options.tdDocsPath, 'resources'))); } // JSON parsing for API routes this.app.use(express.json()); this.app.use(express.urlencoded({ extended: true })); // CORS for API requests this.app.use('/api', (req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); next(); }); } /** * Setup Express routes matching docs.derivative.ca URL patterns */ setupRoutes() { // Home page this.app.get('/', this.handleHomePage.bind(this)); // Search functionality this.app.get('/search', this.handleSearchPage.bind(this)); // Special documentation pages (Python, Release Notes, etc.) this.app.get('/Python/:className?', this.handlePythonDocs.bind(this)); this.app.get('/Release_Notes/:version?', this.handleReleaseNotes.bind(this)); this.app.get('/TouchDesigner', this.handleTouchDesignerPage.bind(this)); // Category pages (TOP, CHOP, SOP, etc.) - direct URLs like docs.derivative.ca this.app.get('/TOP', this.handleCategoryPageDirect.bind(this, 'TOP')); this.app.get('/CHOP', this.handleCategoryPageDirect.bind(this, 'CHOP')); this.app.get('/SOP', this.handleCategoryPageDirect.bind(this, 'SOP')); this.app.get('/DAT', this.handleCategoryPageDirect.bind(this, 'DAT')); this.app.get('/MAT', this.handleCategoryPageDirect.bind(this, 'MAT')); this.app.get('/COMP', this.handleCategoryPageDirect.bind(this, 'COMP')); this.app.get('/POP', this.handleCategoryPageDirect.bind(this, 'POP')); // Operator pages - direct URLs matching docs.derivative.ca pattern // Examples: /Noise_TOP, /Box_SOP, /Text_DAT this.app.get('/:operatorName([A-Za-z0-9_]+_(TOP|CHOP|SOP|DAT|MAT|COMP|POP))', this.handleOperatorPageDirect.bind(this)); // Legacy routes for backward compatibility this.app.get('/operator/:name', this.handleOperatorPage.bind(this)); this.app.get('/category/:category', this.handleCategoryPage.bind(this)); // API routes this.app.get('/api/operators', this.handleApiOperators.bind(this)); this.app.get('/api/operator/:name', this.handleApiOperator.bind(this)); this.app.get('/api/search', this.handleApiSearch.bind(this)); this.app.get('/api/categories', this.handleApiCategories.bind(this)); this.app.get('/api/stats', this.handleApiStats.bind(this)); this.app.get('/api/suggest/:operatorName', this.handleApiSuggest.bind(this)); // Health check this.app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), version: '2.2.0', wiki: this.wikiSystem.getSystemStats(), routes: { docs_style: true, python_docs: '/Python/*', release_notes: '/Release_Notes/*', operators: '/:operatorName_(TOP|CHOP|SOP|DAT|MAT|COMP|POP)', categories: '/(TOP|CHOP|SOP|DAT|MAT|COMP|POP)' } }); }); } /** * Setup error handling */ setupErrorHandling() { // 404 handler this.app.use((req, res) => { res.status(404).render('error', { title: 'Page Not Found', message: 'The page you requested could not be found.', statusCode: 404 }); }); // General error handler this.app.use((err, req, res, next) => { console.error('[Wiki Server] Error:', err); res.status(500).render('error', { title: 'Server Error', message: 'An unexpected error occurred.', statusCode: 500 }); }); } /** * Start the web server */ async start() { if (this.isRunning) return; return new Promise((resolve, reject) => { // Find available port this.findAvailablePort(this.options.port) .then(port => { this.server = this.app.listen(port, this.options.host, () => { this.isRunning = true; this.actualPort = port; console.log(`[Wiki Server] Started on http://${this.options.host}:${port}`); resolve({ host: this.options.host, port }); }); this.server.on('error', reject); }) .catch(reject); }); } /** * Stop the web server */ async stop() { if (!this.isRunning || !this.server) return; return new Promise((resolve) => { this.server.close(() => { this.isRunning = false; console.log('[Wiki Server] Stopped'); resolve(); }); }); } /** * Find available port starting from specified port */ async findAvailablePort(startPort) { const net = await import('net'); return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(startPort, (err) => { if (err) { // Port in use, try next one server.close(); if (startPort < 65535) { this.findAvailablePort(startPort + 1).then(resolve).catch(reject); } else { reject(new Error('No available ports found')); } } else { const port = server.address().port; server.close(() => resolve(port)); } }); }); } // Web Interface Route Handlers async handleHomePage(req, res) { try { const stats = this.wikiSystem.getSystemStats(); const categories = this.wikiSystem.getAvailableCategories(); // Get recent/featured operators for homepage const featured = await this.wikiSystem.listOperators({ limit: 12 }); res.render('home', { title: 'TouchDesigner Documentation Wiki', stats, categories: categories.map(cat => ({ name: cat, count: stats.categoryCounts[cat] || 0 })), featured: featured.operators }); } catch (error) { console.error('[Wiki Server] Home page error:', error); res.status(500).render('error', { title: 'Error Loading Home Page', message: error.message }); } } async handleOperatorPage(req, res) { try { const operatorName = decodeURIComponent(req.params.name); const operator = await this.wikiSystem.getOperator(operatorName, { show_parameters: true, show_examples: true, show_tips: true }); if (!operator) { return res.status(404).render('error', { title: 'Operator Not Found', message: `The operator "${operatorName}" could not be found.` }); } // Build breadcrumbs const breadcrumbs = [ { text: 'Home', url: '/' }, { text: this.categoryMappings[operator.category] || operator.category, url: `/${operator.category}` }, { text: operator.name, url: `/${operator.name}` } ]; // Get related operators for suggestions const related = await this.wikiSystem.suggestWorkflow(operatorName, { limit: 6 }); res.render('operator', { title: `${operator.name} - TouchDesigner Documentation`, operator, related: related.suggestions, breadcrumbs }); } catch (error) { console.error('[Wiki Server] Operator page error:', error); res.status(500).render('error', { title: 'Error Loading Operator', message: error.message }); } } /** * Handle direct operator page requests (docs.derivative.ca style) */ async handleOperatorPageDirect(req, res) { try { const operatorName = decodeURIComponent(req.params.operatorName); const operator = await this.wikiSystem.getOperator(operatorName, { show_parameters: true, show_examples: true, show_tips: true }); if (!operator) { // Try to find similar operators for suggestions const searchResults = await this.wikiSystem.search(operatorName, { limit: 10 }); return res.status(404).render('error', { title: 'Operator Not Found', message: `The operator "${operatorName}" could not be found.`, suggestions: searchResults.results || [] }); } // Build breadcrumbs const breadcrumbs = [ { text: 'Home', url: '/' }, { text: this.categoryMappings[operator.category] || operator.category, url: `/${operator.category}` }, { text: operator.name } ]; // Get related operators for suggestions const related = await this.wikiSystem.suggestWorkflow(operatorName, { limit: 6 }); res.render('operator', { title: `${operator.name} - TouchDesigner Documentation`, operator, related: related.suggestions, breadcrumbs, docsStyleUrl: true }); } catch (error) { console.error('[Wiki Server] Direct operator page error:', error); res.status(500).render('error', { title: 'Error Loading Operator', message: error.message }); } } async handleSearchPage(req, res) { try { const query = req.query.q || ''; const category = req.query.category || ''; let results = []; if (query.trim()) { results = await this.wikiSystem.search(query, { category: category || undefined, limit: 50 }); } const categories = this.wikiSystem.getAvailableCategories(); res.render('search', { title: query ? `Search: ${query}` : 'Search TouchDesigner Documentation', query, selectedCategory: category, categories, results: results.results || [], total: results.total || 0, hasResults: results.results && results.results.length > 0 }); } catch (error) { console.error('[Wiki Server] Search page error:', error); res.status(500).render('error', { title: 'Search Error', message: error.message }); } } async handleCategoryPage(req, res) { try { const category = req.params.category.toUpperCase(); const operators = await this.wikiSystem.listOperators({ category: category, limit: 200 }); if (!operators.operators.length) { return res.status(404).render('error', { title: 'Category Not Found', message: `The category "${category}" could not be found.` }); } // Build breadcrumbs const breadcrumbs = [ { text: 'Home', url: '/' }, { text: this.categoryMappings[category] || category } ]; res.render('category', { title: `${category} Operators - TouchDesigner Documentation`, category, operators: operators.operators, total: operators.total, breadcrumbs }); } catch (error) { console.error('[Wiki Server] Category page error:', error); res.status(500).render('error', { title: 'Error Loading Category', message: error.message }); } } /** * Handle direct category page requests (docs.derivative.ca style) */ async handleCategoryPageDirect(category, req, res) { try { const operators = await this.wikiSystem.listOperators({ category: category, limit: 500 }); if (!operators.operators || !operators.operators.length) { return res.status(404).render('error', { title: 'Category Not Found', message: `The category "${category}" could not be found or has no operators.` }); } // Sort operators alphabetically operators.operators.sort((a, b) => a.name.localeCompare(b.name)); // Build breadcrumbs const breadcrumbs = [ { text: 'Home', url: '/' }, { text: this.categoryMappings[category] || category } ]; res.render('category', { title: `${this.categoryMappings[category]} - TouchDesigner Documentation`, category, categoryName: this.categoryMappings[category], operators: operators.operators, total: operators.total, breadcrumbs, docsStyleUrl: true }); } catch (error) { console.error('[Wiki Server] Direct category page error:', error); res.status(500).render('error', { title: 'Error Loading Category', message: error.message }); } } /** * Handle Python documentation pages */ async handlePythonDocs(req, res) { try { const className = req.params.className; // Build breadcrumbs const breadcrumbs = [ { text: 'Home', url: '/' }, { text: 'Python', url: '/Python' } ]; if (className) { breadcrumbs.push({ text: className }); // Try to get Python class documentation const pythonDoc = await this.wikiSystem.getOperator(`Python_${className}`, { show_parameters: true, show_examples: true }); if (pythonDoc) { return res.render('operator', { title: `${className} - Python Documentation`, operator: pythonDoc, breadcrumbs, isPythonDoc: true }); } } // Show Python documentation index res.render('python-index', { title: 'Python Documentation - TouchDesigner', breadcrumbs, className }); } catch (error) { console.error('[Wiki Server] Python docs error:', error); res.status(500).render('error', { title: 'Error Loading Python Documentation', message: error.message }); } } /** * Handle Release Notes pages */ async handleReleaseNotes(req, res) { try { const version = req.params.version; // Build breadcrumbs const breadcrumbs = [ { text: 'Home', url: '/' }, { text: 'Release Notes', url: '/Release_Notes' } ]; if (version) { breadcrumbs.push({ text: version }); } // For now, show a placeholder or redirect to official docs res.render('release-notes', { title: version ? `Release Notes ${version}` : 'Release Notes - TouchDesigner', breadcrumbs, version, message: 'Release notes documentation is available in the official TouchDesigner documentation.' }); } catch (error) { console.error('[Wiki Server] Release notes error:', error); res.status(500).render('error', { title: 'Error Loading Release Notes', message: error.message }); } } /** * Handle TouchDesigner main documentation page */ async handleTouchDesignerPage(req, res) { try { const stats = this.wikiSystem.getSystemStats(); res.render('touchdesigner', { title: 'TouchDesigner Documentation', stats, categories: Object.entries(this.categoryMappings).map(([key, name]) => ({ key, name, count: stats.categoryCounts[key] || 0 })) }); } catch (error) { console.error('[Wiki Server] TouchDesigner page error:', error); res.status(500).render('error', { title: 'Error Loading Documentation', message: error.message }); } } // API Route Handlers async handleApiOperators(req, res) { try { const category = req.query.category; const limit = parseInt(req.query.limit) || 100; const operators = await this.wikiSystem.listOperators({ category, limit }); res.json(operators); } catch (error) { console.error('[Wiki Server] API operators error:', error); res.status(500).json({ error: error.message }); } } async handleApiOperator(req, res) { try { const operatorName = decodeURIComponent(req.params.name); const options = { show_parameters: req.query.parameters === 'true', show_examples: req.query.examples === 'true', show_tips: req.query.tips === 'true' }; const operator = await this.wikiSystem.getOperator(operatorName, options); if (!operator) { return res.status(404).json({ error: 'Operator not found' }); } res.json(operator); } catch (error) { console.error('[Wiki Server] API operator error:', error); res.status(500).json({ error: error.message }); } } async handleApiSearch(req, res) { try { const query = req.query.q || ''; const category = req.query.category; const limit = parseInt(req.query.limit) || 20; if (!query.trim()) { return res.json({ results: [], total: 0 }); } const results = await this.wikiSystem.search(query, { category: category || undefined, limit }); res.json(results); } catch (error) { console.error('[Wiki Server] API search error:', error); res.status(500).json({ error: error.message }); } } async handleApiCategories(req, res) { try { const categories = this.wikiSystem.getAvailableCategories(); const stats = this.wikiSystem.getSystemStats(); const categoryData = categories.map(cat => ({ name: cat, count: stats.categoryCounts[cat] || 0 })); res.json({ categories: categoryData }); } catch (error) { console.error('[Wiki Server] API categories error:', error); res.status(500).json({ error: error.message }); } } async handleApiStats(req, res) { try { const stats = this.wikiSystem.getSystemStats(); res.json(stats); } catch (error) { console.error('[Wiki Server] API stats error:', error); res.status(500).json({ error: error.message }); } } /** * Handle API suggest endpoint for workflow suggestions */ async handleApiSuggest(req, res) { try { const operatorName = decodeURIComponent(req.params.operatorName); const limit = parseInt(req.query.limit) || 10; const suggestions = await this.wikiSystem.suggestWorkflow(operatorName, { limit }); res.json(suggestions); } catch (error) { console.error('[Wiki Server] API suggest error:', error); res.status(500).json({ error: error.message }); } } /** * Get server info */ getServerInfo() { return { isRunning: this.isRunning, host: this.options.host, port: this.actualPort || this.options.port, url: this.isRunning ? `http://${this.options.host}:${this.actualPort}` : null, routes: { home: '/', operators: '/:operatorName_(TOP|CHOP|SOP|DAT|MAT|COMP|POP)', categories: '/(TOP|CHOP|SOP|DAT|MAT|COMP|POP)', python: '/Python/:className', releaseNotes: '/Release_Notes/:version', search: '/search' } }; } } export default WikiWebServer;