UNPKG

rosetta-mcp-server

Version:

MCP server for Rosetta/PyRosetta functions and properties

1,229 lines (1,111 loc) 69.5 kB
#!/usr/bin/env node /** * Rosetta MCP Server Wrapper * Makes the Python Rosetta server compatible with MCP protocol */ const { spawn } = require('child_process'); const path = require('path'); const readline = require('readline'); class RosettaMCPServer { constructor() { this.pythonPath = process.env.PYTHON_BIN || 'python3'; this.serverPath = path.join(__dirname, 'rosetta_mcp_server.py'); } async pythonEnvInfo() { return new Promise((resolve) => { const py = this.pythonPath; const { spawn } = require('child_process'); console.error('\x1b[36m[Python] Gathering environment information...\x1b[0m'); const script = ` import json, sys, subprocess info = { 'python_executable': sys.executable, 'python_version': sys.version, } try: out = subprocess.check_output([sys.executable, '-m', 'pip', 'list', '--format', 'json'], stderr=subprocess.STDOUT, text=True) info['pip_list'] = json.loads(out) except Exception as e: info['pip_error'] = str(e) print(json.dumps(info)) `; const proc = spawn(py, ['-c', script]); let output = ''; let error = ''; proc.stdout.on('data', d => { output += d.toString(); }); proc.stderr.on('data', d => { error += d.toString(); }); proc.on('close', () => { try { const result = JSON.parse(output.trim() || '{}'); console.error(`\x1b[32m[Python] ✅ Environment info gathered (${result.pip_list ? result.pip_list.length : 0} packages)\x1b[0m`); resolve(result); } catch (_) { console.error(`\x1b[31m[Python] ❌ Failed to gather environment info: ${error}\x1b[0m`); resolve({ stdout: output, stderr: error }); } }); }); } async checkPyRosetta() { return new Promise((resolve) => { const py = this.pythonPath; const { spawn } = require('child_process'); console.error('\x1b[36m[PyRosetta] Checking if PyRosetta is available...\x1b[0m'); const script = ` import json resp = {'available': False} try: import pyrosetta resp['available'] = True resp['version'] = getattr(pyrosetta, '__version__', None) except Exception as e: resp['error'] = str(e) print(json.dumps(resp)) `; const proc = spawn(py, ['-c', script]); let output = ''; let error = ''; proc.stdout.on('data', d => { output += d.toString(); }); proc.stderr.on('data', d => { error += d.toString(); }); proc.on('close', () => { try { const result = JSON.parse(output.trim() || '{}'); if (result.available) { console.error(`\x1b[32m[PyRosetta] ✅ Available${result.version ? ` (v${result.version})` : ''}\x1b[0m`); } else { console.error(`\x1b[31m[PyRosetta] ❌ Not available: ${result.error || 'Unknown error'}\x1b[0m`); } resolve(result); } catch (_) { console.error(`\x1b[31m[PyRosetta] ❌ Check failed: ${error}\x1b[0m`); resolve({ stdout: output, stderr: error }); } }); }); } async installPyRosettaViaInstaller({ silent = true } = {}) { return new Promise((resolve) => { const py = this.pythonPath; const { spawn } = require('child_process'); // Show warning about long installation time console.error('\x1b[33m⚠️ WARNING: PyRosetta installation can take 10-30 minutes on first run!\x1b[0m'); console.error('\x1b[33m This involves downloading and compiling large scientific libraries.\x1b[0m'); console.error('\x1b[33m Please be patient and do not interrupt the process.\x1b[0m\n'); const cmd = `${silent ? 'silent=True' : 'silent=False'}`; const script = ` import json, sys, subprocess, time result = {'ok': False, 'progress': []} def log_progress(message): result['progress'].append({'time': time.time(), 'message': message}) print(json.dumps({'type': 'progress', 'message': message})) try: log_progress('Upgrading pip, setuptools, and wheel...') subprocess.check_call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'pip', 'setuptools', 'wheel']) log_progress('Installing pyrosetta-installer...') subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyrosetta-installer']) log_progress('Importing pyrosetta-installer...') import pyrosetta_installer as I log_progress('Starting PyRosetta installation (this will take 10-30 minutes)...') I.install_pyrosetta(${cmd}, skip_if_installed=False) log_progress('PyRosetta installation completed successfully!') result['ok'] = True except Exception as e: result['error'] = str(e) log_progress(f'Installation failed: {str(e)}') print(json.dumps({'type': 'final', 'result': result})) `; const proc = spawn(py, ['-c', script]); let output = ''; let error = ''; proc.stdout.on('data', (d) => { const data = d.toString(); output += data; // Parse progress messages try { const lines = data.split('\n').filter(line => line.trim()); for (const line of lines) { try { const parsed = JSON.parse(line); if (parsed.type === 'progress') { console.error(`\x1b[36m[PyRosetta Install] ${parsed.message}\x1b[0m`); } else if (parsed.type === 'final') { // Final result, don't log here } } catch (e) { // Not JSON, ignore } } } catch (e) { // Ignore parsing errors } }); proc.stderr.on('data', (d) => { error += d.toString(); // Show stderr output in real-time console.error(`\x1b[31m[PyRosetta Install Error] ${d.toString()}\x1b[0m`); }); proc.on('close', () => { try { // Extract the final result from the last line const lines = output.split('\n').filter(line => line.trim()); for (let i = lines.length - 1; i >= 0; i--) { try { const parsed = JSON.parse(lines[i]); if (parsed.type === 'final') { resolve(parsed.result); return; } } catch (e) { // Continue searching } } // Fallback to old parsing method resolve(JSON.parse(output.trim() || '{}')); } catch (e) { resolve({ stdout: output, stderr: error }); } }); }); } async findRosettaScripts({ exe_path } = {}) { try { const resolved = this.resolveRosettaScriptsPath(exe_path); return { resolved }; } catch (e) { return { error: e.message }; } } async searchPyRosettaWheels({ directory }) { const fs = require('fs'); const path = require('path'); const results = []; try { const files = fs.readdirSync(directory || '.'); for (const f of files) { if (/pyrosetta.*\.whl$/i.test(f) || /PyRosetta.*\.whl$/i.test(f)) { results.push(path.join(directory, f)); } } } catch (e) { return { error: e.message }; } return { matches: results }; } resolveRosettaScriptsPath(preferredPath) { const fs = require('fs'); const resolveFrom = (p) => { if (!p || !p.length) return null; try { const stat = fs.existsSync(p) ? fs.statSync(p) : null; if (stat && stat.isFile()) return p; if (stat && stat.isDirectory()) { const files = fs.readdirSync(p); const match = files.find(f => f.startsWith('rosetta_scripts')); if (match) return path.join(p, match); } } catch (_) {} return null; }; // 1) Preferred explicit path or directory const fromPreferred = resolveFrom(preferredPath); if (fromPreferred) return fromPreferred; // 2) Environment override: file or directory const fromEnv = resolveFrom(process.env.ROSETTA_BIN || ''); if (fromEnv) return fromEnv; // 3) Known candidate directories const candidateDirs = [ // Typical source build locations path.join(process.env.HOME || '', 'rosetta', 'main', 'source', 'bin'), '/opt/rosetta/main/source/bin', '/usr/local/rosetta/main/source/bin', '/Users/arielben-sasson/dev/open_repos/rosetta/main/source/bin' ].filter(Boolean); for (const dir of candidateDirs) { try { const files = fs.readdirSync(dir); const match = files.find(f => f.startsWith('rosetta_scripts') && fs.existsSync(path.join(dir, f))); if (match) { return path.join(dir, match); } } catch (_) { // ignore } } // Fallback to assuming it's on PATH return 'rosetta_scripts'; } async getRosettaInfo() { return new Promise((resolve, reject) => { const process = spawn(this.pythonPath, [this.serverPath, 'info']); let output = ''; let error = ''; process.stdout.on('data', (data) => { output += data.toString(); }); process.stderr.on('data', (data) => { error += data.toString(); }); process.on('close', (code) => { if (code === 0) { try { const result = JSON.parse(output); resolve(result); } catch (e) { reject(new Error('Failed to parse server output')); } } else { reject(new Error(`Server exited with code ${code}: ${error}`)); } }); }); } async getRosettaHelp(topic = null) { return new Promise((resolve, reject) => { const args = [this.serverPath, 'help']; if (topic) args.push(topic); const process = spawn(this.pythonPath, args); let output = ''; let error = ''; process.stdout.on('data', (data) => { output += data.toString(); }); process.stderr.on('data', (data) => { error += data.toString(); }); process.on('close', (code) => { if (code === 0) { resolve(output.trim()); } else { reject(new Error(`Server exited with code ${code}: ${error}`)); } }); }); } async validateXML(xmlContent) { return new Promise((resolve, reject) => { // Write XML content to temporary file const fs = require('fs'); const tmpFile = path.join(__dirname, 'temp_validation.xml'); try { fs.writeFileSync(tmpFile, xmlContent); const process = spawn(this.pythonPath, [this.serverPath, 'validate', tmpFile]); let output = ''; let error = ''; process.stdout.on('data', (data) => { output += data.toString(); }); process.stderr.on('data', (data) => { error += data.toString(); }); process.on('close', (code) => { // Clean up temp file try { fs.unlinkSync(tmpFile); } catch (e) { // Ignore cleanup errors } if (code === 0) { try { const result = JSON.parse(output); resolve(result); } catch (e) { reject(new Error('Failed to parse validation result')); } } else { reject(new Error(`Validation failed with code ${code}: ${error}`)); } }); } catch (e) { reject(new Error(`Failed to create temporary file: ${e.message}`)); } }); } async runRosettaScripts({ exe_path, xml_path, input_pdb, out_dir, extra_flags }) { return new Promise((resolve, reject) => { if (!xml_path || !input_pdb || !out_dir) { reject(new Error('xml_path, input_pdb, and out_dir are required')); return; } const rosettaExe = this.resolveRosettaScriptsPath(exe_path); const fs = require('fs'); try { if (!fs.existsSync(out_dir)) { fs.mkdirSync(out_dir, { recursive: true }); } } catch (e) { reject(new Error(`Failed to ensure out_dir exists: ${e.message}`)); return; } const args = [ '-in:file:s', input_pdb, '-out:path:all', out_dir, '-parser:protocol', xml_path ]; if (Array.isArray(extra_flags)) { for (const flag of extra_flags) { if (typeof flag === 'string' && flag.trim().length > 0) { // Split on whitespace to allow flags with values, e.g., "-nstruct 5" const parts = flag.split(/\s+/).filter(Boolean); args.push(...parts); } } } const proc = spawn(rosettaExe, args, { env: process.env }); let stdout = ''; let stderr = ''; proc.stdout.on('data', d => { stdout += d.toString(); }); proc.stderr.on('data', d => { stderr += d.toString(); }); proc.on('error', (e) => { reject(new Error(`Failed to start rosetta_scripts: ${e.message}`)); }); proc.on('close', (code) => { resolve({ exit_code: code, stdout, stderr, out_dir }); }); }); } async pyrosettaScore({ pdb_path, scorefxn }) { return new Promise(async (resolve, reject) => { if (!pdb_path) { reject(new Error('pdb_path is required')); return; } // Check if PyRosetta is available const pyrosettaStatus = await this.checkPyRosetta(); if (!pyrosettaStatus.available) { // Auto-install PyRosetta if not available console.error('\x1b[33m⚠️ PyRosetta not found. Auto-installing...\x1b[0m'); console.error('\x1b[33m This will take 10-30 minutes. Please be patient.\x1b[0m\n'); try { const installResult = await this.installPyRosettaViaInstaller({ silent: false }); if (!installResult.ok) { reject(new Error(`PyRosetta installation failed: ${installResult.error || 'Unknown error'}`)); return; } console.error('\x1b[32m✅ PyRosetta installed successfully! Retrying score operation...\x1b[0m\n'); } catch (installError) { reject(new Error(`PyRosetta installation failed: ${installError.message}`)); return; } } const py = this.pythonPath; const script = ` import json try: import pyrosetta except Exception as e: print(json.dumps({"error": f"PyRosetta not available: {str(e)}"})) raise SystemExit(0) pyrosetta.init('-mute all') from pyrosetta import pose_from_pdb from pyrosetta.rosetta.core.scoring import get_score_function pose = pose_from_pdb(r'''${pdb_path.replace(/'/g, "'\''")}''') sfxn = get_score_function() score = sfxn(pose) print(json.dumps({"score": float(score)})) `; const proc = spawn(py, ['-c', script]); let output = ''; let error = ''; proc.stdout.on('data', d => { output += d.toString(); }); proc.stderr.on('data', d => { error += d.toString(); }); proc.on('close', (code) => { try { const parsed = JSON.parse(output.trim() || '{}'); if (parsed && typeof parsed === 'object') { resolve(parsed); } else { resolve({ exit_code: code, stdout: output, stderr: error }); } } catch (e) { resolve({ exit_code: code, stdout: output, stderr: error }); } }); }); } async pyrosettaIntrospect({ query, kind, max_results }) { return new Promise(async (resolve) => { const py = this.pythonPath; // Check if PyRosetta is available const pyrosettaStatus = await this.checkPyRosetta(); if (!pyrosettaStatus.available) { // Auto-install PyRosetta if not available console.error('\x1b[33m⚠️ PyRosetta not found. Auto-installing...\x1b[0m'); console.error('\x1b[33m This will take 10-30 minutes. Please be patient.\x1b[0m\n'); try { const installResult = await this.installPyRosettaViaInstaller({ silent: false }); if (!installResult.ok) { resolve({ error: `PyRosetta installation failed: ${installResult.error || 'Unknown error'}` }); return; } console.error('\x1b[32m✅ PyRosetta installed successfully! Retrying introspect operation...\x1b[0m\n'); } catch (installError) { resolve({ error: `PyRosetta installation failed: ${installError.message}` }); return; } } const safeQuery = (typeof query === 'string' ? query : '').replace(/`/g, ''); const limit = Number.isInteger(max_results) && max_results > 0 ? max_results : 50; const script = ` import json, sys, importlib, inspect try: import pyrosetta except Exception as e: print(json.dumps({"error": f"PyRosetta not available: {str(e)}"})) raise SystemExit(0) namespaces = [ 'pyrosetta.rosetta.protocols.moves', 'pyrosetta.rosetta.protocols.simple_moves', 'pyrosetta.rosetta.protocols.minimization_packing', 'pyrosetta.rosetta.protocols.relax', 'pyrosetta.rosetta.protocols.rosetta_scripts', 'pyrosetta.rosetta.protocols.simple_filters', 'pyrosetta.rosetta.protocols.filters', 'pyrosetta.rosetta.core.select.residue_selector', 'pyrosetta.rosetta.core.pack.task.operation', ] q = '${safeQuery}'.lower() kind = '${(kind||'').toString().toLowerCase()}' def kind_allows(module_name: str) -> bool: if not kind: return True m = module_name.lower() if kind in ('mover','movers'): return '.protocols.' in m and ('moves' in m or 'simple_moves' in m or 'relax' in m or 'minimization_packing' in m) if kind in ('filter','filters'): return '.protocols.' in m and ('filters' in m or 'simple_filters' in m) if kind in ('selector','residue_selector','residue_selectors'): return 'residue_selector' in m if kind in ('task','task_operation','task_operations'): return '.task.operation' in m return True results = [] for mod_name in namespaces: try: m = importlib.import_module(mod_name) except Exception: continue try: for name, obj in inspect.getmembers(m, inspect.isclass): if q and q not in name.lower(): continue if not kind_allows(getattr(obj, '__module__', '')): continue entry = { 'name': name, 'module': getattr(obj, '__module__', ''), 'bases': [b.__name__ for b in getattr(obj, '__mro__', [])[1:3] if hasattr(b,'__name__')], } try: doc = inspect.getdoc(obj) if doc: entry['doc'] = doc[:2000] else: # Try to get more info if no doc try: if hasattr(obj, '__init__'): sig = inspect.signature(obj.__init__) entry['doc'] = f"Constructor signature: {sig}" else: entry['doc'] = f"Class {name} from {mod_name}" except: entry['doc'] = f"Class {name} from {mod_name}" except Exception: entry['doc'] = f"Class {name} from {mod_name}" try: sig = str(inspect.signature(obj.__init__)) entry['init'] = sig except Exception: pass results.append(entry) if len(results) >= ${limit}: raise StopIteration except StopIteration: break print(json.dumps({'results': results, 'count': len(results)})) `; const proc = spawn(py, ['-c', script]); let output = ''; let error = ''; proc.stdout.on('data', d => { output += d.toString(); }); proc.stderr.on('data', d => { error += d.toString(); }); proc.on('close', () => { try { const parsed = JSON.parse(output.trim() || '{}'); resolve(parsed); } catch (_) { resolve({ stdout: output, stderr: error }); } }); }); } async rosettaScriptsSchema({ exe_path, cache_dir, extract_elements }) { return new Promise((resolve, reject) => { const fs = require('fs'); const os = require('os'); const rosettaExe = this.resolveRosettaScriptsPath(exe_path); const baseDir = cache_dir && cache_dir.length ? cache_dir : path.join(process.cwd(), 'docs_cache'); try { if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true }); } catch (e) { reject(new Error(`Failed to ensure cache_dir exists: ${e.message}`)); return; } const schemaPath = path.join(baseDir, 'rosetta_scripts_schema.xsd'); const proc = spawn(rosettaExe, ['-parser:output_schema', schemaPath]); let stdout = ''; let stderr = ''; proc.stdout.on('data', d => { stdout += d.toString(); }); proc.stderr.on('data', d => { stderr += d.toString(); }); proc.on('error', (e) => reject(new Error(`Failed to start rosetta_scripts: ${e.message}`))); proc.on('close', (code) => { if (code !== 0) { resolve({ exit_code: code, stdout, stderr }); return; } try { const content = fs.readFileSync(schemaPath, 'utf8'); let elements = undefined; if (extract_elements) { const matches = [...content.matchAll(/\bname=\"([^\"]+)\"/g)].map(m => m[1]); // de-duplicate and filter common XML names const skip = new Set(['schema','annotation','element','complexType','sequence','choice','attribute','documentation']); elements = Array.from(new Set(matches)).filter(n => !skip.has(n)).slice(0, 1000); } resolve({ exit_code: 0, schema_path: schemaPath, size: content.length, elements }); } catch (e) { resolve({ exit_code: 0, error: `Schema generated but could not be read: ${e.message}`, schema_path: schemaPath }); } }); }); } async cacheCliDocs({ exe_path, cache_dir }) { return new Promise((resolve, reject) => { const fs = require('fs'); const rosettaExe = this.resolveRosettaScriptsPath(exe_path); const baseDir = cache_dir && cache_dir.length ? cache_dir : path.join(process.cwd(), 'docs_cache'); try { if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true }); } catch (e) { reject(new Error(`Failed to ensure cache_dir exists: ${e.message}`)); return; } const runHelp = (args, outfile) => new Promise((res) => { const proc = spawn(rosettaExe, args); let output = ''; let error = ''; proc.stdout.on('data', d => { output += d.toString(); }); proc.stderr.on('data', d => { error += d.toString(); }); proc.on('close', () => { try { fs.writeFileSync(path.join(baseDir, outfile), output + (error ? `\nSTDERR:\n${error}` : '')); } catch (_) {} res({ stdout: output, stderr: error }); }); }); (async () => { const r1 = await runHelp(['-help'], 'help.txt'); // Best-effort: try additional parser info flags; ignore failures const r2 = await runHelp(['-parser:info'], 'parser_info.txt'); resolve({ saved: [path.join(baseDir, 'help.txt'), path.join(baseDir, 'parser_info.txt')] }); })(); }); } async getCachedDocs({ cache_dir, query, max_lines }) { return new Promise((resolve, reject) => { const fs = require('fs'); const baseDir = cache_dir && cache_dir.length ? cache_dir : path.join(process.cwd(), 'docs_cache'); const files = ['help.txt', 'parser_info.txt'].map(f => path.join(baseDir, f)); const q = (query || '').toLowerCase(); try { let results = []; for (const file of files) { if (!fs.existsSync(file)) continue; const content = fs.readFileSync(file, 'utf8'); const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!q || line.toLowerCase().includes(q)) { results.push({ file, line: i + 1, text: line }); } } } const limit = Number.isInteger(max_lines) && max_lines > 0 ? max_lines : 200; resolve({ count: results.length, matches: results.slice(0, limit) }); } catch (e) { reject(new Error(`Failed to search cache: ${e.message}`)); } }); } // Simple HTTPS fetch utility with redirect support async fetchUrl(url, maxRedirects = 5) { const https = require('https'); const http = require('http'); const fetchWithRedirect = (url, redirectCount = 0) => { return new Promise((resolve, reject) => { if (redirectCount >= maxRedirects) { reject(new Error(`Too many redirects (${redirectCount})`)); return; } const isHttps = url.startsWith('https://'); const client = isHttps ? https : http; try { const req = client.get(url, { headers: { 'User-Agent': 'rosetta-mcp/1.0 (+https://npmjs.com/package/rosetta-mcp-server)' } }, (res) => { // Handle redirects if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { const newUrl = res.headers.location.startsWith('http') ? res.headers.location : `${isHttps ? 'https' : 'http'}://${new URL(url).host}${res.headers.location}`; resolve(fetchWithRedirect(newUrl, redirectCount + 1)); return; } let body = ''; res.on('data', (d) => { body += d.toString(); }); res.on('end', () => { resolve({ status: res.statusCode, headers: res.headers, body }); }); }); req.on('error', (err) => reject(err)); } catch (e) { reject(e); } }); }; return fetchWithRedirect(url); } stripHtml(html) { if (!html) return ''; // Remove scripts/styles then tags, collapse whitespace return html .replace(/<script[\s\S]*?<\/script>/gi, '') .replace(/<style[\s\S]*?<\/style>/gi, '') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); } async searchRosettaWebDocs({ query, max_results }) { const max = Number.isInteger(max_results) && max_results > 0 ? max_results : 3; const q = encodeURIComponent(`site:rosettacommons.org/docs ${query || ''}`.trim()); // Use DuckDuckGo HTML endpoint (no JS) for simple scraping const searchUrl = `https://duckduckgo.com/html/?q=${q}`; try { const resp = await this.fetchUrl(searchUrl); if (resp.status !== 200) return { error: `Search failed with status ${resp.status}` }; const html = resp.body || ''; // Extract anchors; prefer links pointing to rosettacommons.org const linkRegex = /<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi; const results = []; let m; while ((m = linkRegex.exec(html)) !== null && results.length < max * 3) { let href = m[1]; const text = this.stripHtml(m[2]); // DuckDuckGo may wrap URLs with '/l/?kh=-1&uddg=<ENCODED>' const uddgMatch = href.match(/[?&]uddg=([^&]+)/); if (uddgMatch) { try { href = decodeURIComponent(uddgMatch[1]); } catch (_) {} } if (/rosettacommons\.org\/.*/i.test(href)) { results.push({ title: text.slice(0, 150), url: href }); } } // De-duplicate and cap to max const seen = new Set(); const deduped = []; for (const r of results) { const key = r.url.split('#')[0]; if (seen.has(key)) continue; seen.add(key); deduped.push(r); if (deduped.length >= max) break; } return { query, results: deduped, count: deduped.length }; } catch (e) { return { error: `Search error: ${e.message}` }; } } async getRosettaWebDoc({ url, max_chars }) { const limit = Number.isInteger(max_chars) && max_chars > 0 ? max_chars : 4000; if (!url || typeof url !== 'string') return { error: 'url is required' }; try { const resp = await this.fetchUrl(url); if (resp.status !== 200) { let suggestion = 'Check if the URL is correct and accessible'; if (resp.status >= 300 && resp.status < 400) { suggestion = 'URL may have redirected - try the search tool first'; } else if (resp.status === 403) { suggestion = 'Access denied - try using the search tool to find accessible URLs'; } else if (resp.status === 404) { suggestion = 'Page not found - URL may be outdated, try the search tool'; } return { error: `Fetch failed with status ${resp.status}`, url, suggestion }; } const html = resp.body || ''; const titleMatch = html.match(/<title>([\s\S]*?)<\/title>/i); const title = titleMatch ? this.stripHtml(titleMatch[1]).slice(0, 200) : undefined; const text = this.stripHtml(html).slice(0, limit); return { url, title, text, length: text.length }; } catch (e) { return { error: `Fetch error: ${e.message}`, url }; } } async xmlToPyRosetta({ xml_content, include_comments = true, output_format = 'python' }) { return new Promise((resolve) => { const py = this.pythonPath; const includeCommentsPython = include_comments ? 'True' : 'False'; const script = ` import json import xml.etree.ElementTree as ET try: # Parse XML xml_content = '''${xml_content.replace(/'/g, "'\\''")}''' root = ET.fromstring(xml_content) # Extract components movers = [] filters = [] residue_selectors = [] task_operations = [] for elem in root.iter(): tag = elem.tag if tag == 'FastRelax': movers.append(('FastRelax', elem.attrib)) elif tag == 'MinMover': movers.append(('MinMover', elem.attrib)) elif tag == 'PackRotamersMover': movers.append(('PackRotamersMover', elem.attrib)) elif tag == 'ScoreType': filters.append(('ScoreType', elem.attrib)) elif tag == 'Chain': residue_selectors.append(('Chain', elem.attrib)) elif tag == 'RestrictToRepacking': task_operations.append(('RestrictToRepacking', elem.attrib)) # Generate Python code python_code = [] if ${includeCommentsPython}: python_code.append('# Generated PyRosetta code from RosettaScripts XML') python_code.append('# ==================================================') python_code.append('') # Import statements python_code.append('import pyrosetta') python_code.append('from pyrosetta import pose_from_pdb') python_code.append('from pyrosetta.rosetta.protocols.moves import *') python_code.append('from pyrosetta.rosetta.protocols.simple_moves import *') python_code.append('from pyrosetta.rosetta.protocols.filters import *') python_code.append('from pyrosetta.rosetta.core.select.residue_selector import *') python_code.append('from pyrosetta.rosetta.core.pack.task.operation import *') python_code.append('from pyrosetta.rosetta.core.scoring import get_score_function') python_code.append('') # Initialize PyRosetta python_code.append('# Initialize PyRosetta') python_code.append('pyrosetta.init("-mute all")') python_code.append('') # Load pose python_code.append('# Load your PDB file') python_code.append('pose = pose_from_pdb("your_protein.pdb")') python_code.append('') # Create movers if movers: if ${includeCommentsPython}: python_code.append('# Create movers') for mover_name, attrs in movers: if mover_name == 'FastRelax': python_code.append('fast_relax = FastRelax()') if 'scorefxn' in attrs: python_code.append(f'fast_relax.score_function = get_score_function("{attrs["scorefxn"]}")') elif mover_name == 'MinMover': python_code.append('min_mover = MinMover()') if 'scorefxn' in attrs: python_code.append(f'min_mover.score_function = get_score_function("{attrs["scorefxn"]}")') elif mover_name == 'PackRotamersMover': python_code.append('pack_rotamers = PackRotamersMover()') if 'scorefxn' in attrs: python_code.append(f'pack_rotamers.score_function = get_score_function("{attrs["scorefxn"]}")') python_code.append('') # Create filters if filters: if ${includeCommentsPython}: python_code.append('# Create filters') for filter_name, attrs in filters: if filter_name == 'ScoreType': python_code.append('score_filter = ScoreType()') if 'score_type' in attrs: python_code.append(f'score_filter.score_type = "{attrs["score_type"]}"') if 'threshold' in attrs: python_code.append(f'score_filter.threshold = {attrs["threshold"]}') python_code.append('') # Create residue selectors if residue_selectors: if ${includeCommentsPython}: python_code.append('# Create residue selectors') for selector_name, attrs in residue_selectors: if selector_name == 'Chain': python_code.append('chain_selector = Chain()') if 'chains' in attrs: python_code.append(f'chain_selector.chain = "{attrs["chains"]}"') python_code.append('') # Create task operations if task_operations: if ${includeCommentsPython}: python_code.append('# Create task operations') for op_name, attrs in task_operations: if op_name == 'RestrictToRepacking': python_code.append('restrict_repack = RestrictToRepacking()') python_code.append('') # Apply movers if movers: if ${includeCommentsPython}: python_code.append('# Apply movers to pose') for mover_name, _ in movers: if mover_name == 'FastRelax': python_code.append('fast_relax.apply(pose)') elif mover_name == 'MinMover': python_code.append('min_mover.apply(pose)') elif mover_name == 'PackRotamersMover': python_code.append('pack_rotamers.apply(pose)') python_code.append('') # Save result python_code.append('# Save the result') python_code.append('pose.dump_pdb("output.pdb")') python_code.append('') # Print score python_code.append('# Print final score') python_code.append('score = pose.energies().total_energy()') python_code.append('print(f"Final score: {score}")') # If no components found, provide a basic template if not movers and not filters and not residue_selectors and not task_operations: python_code = [ '# Generated PyRosetta code from RosettaScripts XML', '# ==================================================', '#', '# No specific components found in XML, providing basic template:', '', 'import pyrosetta', 'from pyrosetta import pose_from_pdb', 'from pyrosetta.rosetta.protocols.moves import *', 'from pyrosetta.rosetta.protocols.simple_moves import *', 'from pyrosetta.rosetta.protocols.filters import *', 'from pyrosetta.rosetta.core.select.residue_selector import *', 'from pyrosetta.rosetta.core.pack.task.operation import *', '', '# Initialize PyRosetta', 'pyrosetta.init("-mute all")', '', '# Load your PDB file', 'pose = pose_from_pdb("your_protein.pdb")', '', '# Add your PyRosetta code here based on the XML', '# Example:', '# fast_relax = FastRelax()', '# fast_relax.apply(pose)', '', '# Save the result', 'pose.dump_pdb("output.pdb")', '', '# Print final score', 'score = pose.energies().total_energy()', 'print(f"Final score: {score}")' ] result = { 'success': True, 'python_code': '\\n'.join(python_code), 'components_found': { 'movers': len(movers), 'filters': len(filters), 'residue_selectors': len(residue_selectors), 'task_operations': len(task_operations) }, 'xml_parsed': True, 'xml_content': xml_content[:200] + '...' if len(xml_content) > 200 else xml_content } except Exception as e: result = { 'success': False, 'error': str(e), 'xml_parsed': False, 'xml_content': xml_content[:200] + '...' if len(xml_content) > 200 else xml_content } print(json.dumps(result)) `; const proc = spawn(py, ['-c', script]); let output = ''; let error = ''; proc.stdout.on('data', (d) => { output += d.toString(); }); proc.stderr.on('data', (d) => { error += d.toString(); }); proc.on('close', () => { try { const result = JSON.parse(output.trim() || '{}'); resolve(result); } catch (e) { resolve({ success: false, error: 'Failed to parse translation result', stdout: output, stderr: error }); } }); }); } async listAvailableFunctions() { const info = await this.getRosettaInfo(); return { score_functions: info.score_functions, movers: info.common_movers, filters: info.common_filters, residue_selectors: info.residue_selectors, task_operations: info.task_operations, parameters: info.common_parameters, command_line_options: info.command_line_options }; } async getRosettaPath() { const info = await this.getRosettaInfo(); return info.rosetta_path; } async isPyRosettaAvailable() { const info = await this.getRosettaInfo(); return info.pyrosetta_available; } } // MCP Server implementation class RosettaMCPServerMCP { constructor() { this.rosettaServer = new RosettaMCPServer(); // Detect whether the client uses Content-Length framing (LSP-style) // or newline-delimited JSON. Default to newline; switch to headers if detected. this.useHeaders = false; this.serverVersion = this.readPackageVersion(); this.debug = String(process.env.MCP_DEBUG || '').trim().length > 0; this.setupMCPHandlers(); } isToolAllowed(name) { // Reverted to always allow tools (restore 1.1.1 behavior) return true; } getToolFilterState() { const normalize = (s) => String(s || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, ''); const parseList = (s) => (s || '') .split(',') .map(x => x.trim()) .filter(Boolean) .map(normalize); return { allowList: parseList(process.env.MCP_TOOLS), denyList: parseList(process.env.MCP_TOOLS_DENY) }; } readPackageVersion() { try { const fs = require('fs'); const path = require('path'); const pkgPath = path.join(__dirname, 'package.json'); if (fs.existsSync(pkgPath)) { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); if (pkg && typeof pkg.version === 'string') return pkg.version; } } catch (_) {} return 'unknown'; } setupMCPHandlers() { // Support BOTH newline-delimited JSON and Content-Length framed JSON process.stdin.setEncoding('utf8'); let buffer = ''; const tryProcessBuffer = async () => { // If headers mode detected, parse Content-Length frames if (this.useHeaders) { while (true) { const headerEnd = buffer.indexOf('\r\n\r\n'); if (headerEnd === -1) break; const header = buffer.slice(0, headerEnd); const lengthMatch = header.match(/Content-Length:\s*(\d+)/i); if (!lengthMatch) { // If no content-length, drop until next potential header separator buffer = buffer.slice(headerEnd + 4); continue; } const contentLength = parseInt(lengthMatch[1], 10); const totalNeeded = headerEnd + 4 + contentLength; if (buffer.length < totalNeeded) break; // wait for more data const jsonPayload = buffer.slice(headerEnd + 4, totalNeeded); buffer = buffer.slice(totalNeeded); try { const message = JSON.parse(jsonPayload); if (this.debug) console.error(`[mcp] <- headers method=${message && message.method}`); await this.handleMCPMessage(message); } catch (e) { if (this.debug) console.error(`[mcp] parse error (headers): ${e && e.message}`); this.sendError('Failed to parse message', e.message || String(e)); } } return; } // Newline-delimited JSON fallback (one message per line) let newlineIndex; while ((newlineIndex = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, newlineIndex).trim(); buffer = buffer.slice(newlineIndex + 1); if (!line) continue; // If we detect a Content-Length header, switch modes if (/^Content-Length:\s*\d+\s*$/i.test(line)) { // Put the header back with a CRLF and switch to headers mode this.useHeaders = true; buffer = line + '\r\n' + buffer; // reconstruct header start if (this.debug) console.error('[mcp] switching to headers mode'); await tryProcessBuffer(); return; } try { const message = JSON.parse(line); if (this.debug) console.error(`[mcp] <- newline method=${message && message.method}`); await this.handleMCPMessage(message); } catch (e) { if (this.debug) console.error(`[mcp] parse error (newline): ${e && e.message}`); this.sendError('Failed to parse message', e.message || String(e)); } } }; process.stdin.on('data', async (chunk) => { buffer += chunk; await tryProcessBuffer(); }); } async handleMCPMessage(message) { const { id, method, params } = message; try { let result; if (this.debug) console.error(`[mcp] handling method=${method}`); switch (method) { // Ignore notifications (no response expected) case undefined: return; default: if (typeof method === 'string' && method.startsWith('notifications/')) { return; } // fallthrough } switch (method) { case 'initialize': { const requestedProtocol = (params && params.protocolVersion) ? params.protocolVersion : '2024-11-05'; result = { protocolVersion: requestedProtocol, capabilities: { // Advertise both canonical and alternate capability names tools: { list: true, call: true, // Some clients expect these names listTools: true, callTool: true }, resources: { list: true, read: true, // Some clients expect these names listResources: true, readResource: true } }, ser