rosetta-mcp-server
Version:
MCP server for Rosetta/PyRosetta functions and properties
1,215 lines (1,101 loc) • 64.7 kB
JavaScript
#!/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();
this.setupMCPHandlers();
}
isToolAllowed(name) {
const toName = (s) => (s || '').split(',').map(x => x.trim().toLowerCase()).filter(Boolean);
const allowList = toName(process.env.MCP_TOOLS);
const denyList = toName(process.env.MCP_TOOLS_DENY);
const lname = String(name || '').toLowerCase();
if (allowList.length > 0) {
return allowList.includes(lname);
}
if (denyList.length > 0) {
return !denyList.includes(lname);
}
return true;
}
setupMCPHandlers() {
// Handle MCP protocol messages (line-delimited JSON)
process.stdin.setEncoding('utf8');
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
rl.on('line', async (line) => {
const trimmed = line && line.trim();
if (!trimmed) return;
try {
const message = JSON.parse(trimmed);
await this.handleMCPMessage(message);
} catch (e) {
this.sendError('Failed to parse message', e.message || String(e));
}
});
}
async handleMCPMessage(message) {
const { id, method, params } = message;
try {
let result;
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: {
tools: { list: true, call: true },
resources: { list: true, read: true }
},
serverInfo: {
name: 'rosetta-mcp-server',
version: '1.0.0'
}
};
break;
}
case 'tools/list': {
const allTools = [
{
name: 'get_rosetta_info',
description: 'Get comprehensive Rosetta information',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'get_rosetta_help',
description: 'Get help for specific Rosetta topics',
inputSchema: {
type: 'object',
properties: {
topic: {
type: 'string',
description: 'Topic to get help for'
}
}
}
},
{
name: 'validate_xml',
description: 'Validate XML protocol file',
inputSchema: {
type: 'object',
properties: {
xml_content: {
type: 'string',
description: 'XML content to validate'
}
},
required: ['xml_content']
}
},
{
name: 'list_functions',
description: 'List available Rosetta functions',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'run_rosetta_scripts',
description: 'Run RosettaScripts with a given XML and input PDB',
inputSchema: {
type: 'object',
properties: {
exe_path: { type: 'string', description: 'Path to rosetta_scripts executable (optional if on PATH)' },
xml_path: { type: 'string', description: 'Path to Rosetta XML protocol' },
input_pdb: { type: 'string', description: 'Path to input PDB' },
out_dir: { type: 'string', description: 'Output directory' },
extra_flags: { type: 'array', items: { type: 'string' }, description: 'Additional command-line flags' }
},
required: ['xml_path', 'input_pdb', 'out_dir']
}
},
{
name: 'pyrosetta_score',
description: 'Score a PDB using PyRosetta (requires PyRosetta installed)',
inputSchema: {
type: 'object',
properties: {
pdb_path: { type: 'string', description: 'Path to input PDB' },
scorefxn: { type: 'string', description: 'Score function name (optional)' }
},
required: ['pdb_path']
}
},
{
name: 'pyrosetta_introspect',
description: 'Search PyRosetta API classes (move