Skip to content

Commit 304e449

Browse files
committed
Add plugin bio-research-forge
1 parent e643f6b commit 304e449

1 file changed

Lines changed: 192 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
#!/usr/bin/env node
2+
3+
import { execFileSync, spawn } from 'node:child_process';
4+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
5+
import os from 'node:os';
6+
import path from 'node:path';
7+
import readline from 'node:readline';
8+
9+
const SERVER = { name: 'local-bio-tools', version: '0.1.0' };
10+
const TOOL_INFO = {
11+
pymol: { name: 'PyMOL', mode: 'headless-render', formats: ['pdb', 'cif', 'mmcif', 'mol2', 'sdf', 'pse'] },
12+
snapgene: { name: 'SnapGene', mode: 'open-existing-file', formats: ['dna', 'gb', 'gbk', 'genbank', 'fasta', 'fa', 'ape'] },
13+
cytoscape: { name: 'Cytoscape', mode: 'open-existing-file', formats: ['cys', 'xgmml', 'sif', 'graphml', 'cyjs'] },
14+
fiji: { name: 'Fiji/ImageJ', mode: 'open-existing-file', formats: ['tif', 'tiff', 'png', 'jpg', 'jpeg', 'lif', 'czi', 'nd2', 'ome'] },
15+
};
16+
17+
const TOOLS = [
18+
{
19+
name: 'local_bio_tool_status',
20+
description: 'Detect the selected local biology tools (PyMOL, SnapGene, Cytoscape, Fiji) without installing or launching them.',
21+
inputSchema: { type: 'object', additionalProperties: false, properties: { tool: { type: 'string', enum: Object.keys(TOOL_INFO) } } },
22+
},
23+
{
24+
name: 'pymol_render',
25+
description: 'Render an existing local molecular structure to a PNG with a safe preset. No arbitrary PyMOL command or script is accepted.',
26+
inputSchema: {
27+
type: 'object', additionalProperties: false,
28+
properties: {
29+
input_path: { type: 'string' }, output_path: { type: 'string' },
30+
representation: { type: 'string', enum: ['cartoon', 'surface', 'sticks', 'cartoon-and-sticks'], default: 'cartoon-and-sticks' },
31+
color: { type: 'string', enum: ['spectrum', 'chain', 'secondary-structure'], default: 'spectrum' },
32+
background: { type: 'string', enum: ['white', 'black', 'transparent'], default: 'white' },
33+
width: { type: 'integer', minimum: 400, maximum: 5000, default: 1800 },
34+
height: { type: 'integer', minimum: 400, maximum: 5000, default: 1400 },
35+
},
36+
required: ['input_path', 'output_path'],
37+
},
38+
},
39+
{
40+
name: 'local_bio_open',
41+
description: 'Open one existing, format-compatible local file in SnapGene, Cytoscape, or Fiji. Use only when the user explicitly asks to open the desktop application.',
42+
inputSchema: {
43+
type: 'object', additionalProperties: false,
44+
properties: { tool: { type: 'string', enum: ['snapgene', 'cytoscape', 'fiji'] }, file_path: { type: 'string' } },
45+
required: ['tool', 'file_path'],
46+
},
47+
},
48+
];
49+
50+
function send(message) { process.stdout.write(`${JSON.stringify(message)}\n`); }
51+
function rpcResult(id, value) { send({ jsonrpc: '2.0', id, result: value }); }
52+
function rpcError(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
53+
function textResult(value, isError = false) { return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }], ...(isError ? { isError: true } : {}) }; }
54+
55+
function commandPath(name) {
56+
try {
57+
const command = process.platform === 'win32' ? 'where.exe' : 'which';
58+
return execFileSync(command, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split(/\r?\n/).map((x) => x.trim()).find(Boolean) || null;
59+
} catch { return null; }
60+
}
61+
62+
function driveRoots() {
63+
if (process.platform !== 'win32') return ['/'];
64+
try {
65+
return execFileSync('powershell.exe', ['-NoProfile', '-Command', '(Get-PSDrive -PSProvider FileSystem).Root'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
66+
.split(/\r?\n/).map((x) => x.trim()).filter((x) => /^[A-Za-z]:\\$/.test(x));
67+
} catch { return ['C:\\']; }
68+
}
69+
70+
function firstExisting(candidates) { return candidates.filter(Boolean).find((candidate) => existsSync(candidate)) || null; }
71+
function childrenMatching(parent, fileName) {
72+
try { return readdirSync(parent, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => path.join(parent, x.name, fileName)); }
73+
catch { return []; }
74+
}
75+
76+
function discover() {
77+
const roots = driveRoots();
78+
const pf = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA].filter(Boolean);
79+
const pymol = firstExisting([
80+
process.env.PYMOL_EXE, commandPath('pymol'), commandPath('PyMOLWin.exe'),
81+
...roots.flatMap((root) => [
82+
path.join(root, 'conda', 'miconda', 'envs', 'pymol', 'Scripts', 'pymol.exe'),
83+
path.join(root, 'miniconda3', 'envs', 'pymol', 'Scripts', 'pymol.exe'),
84+
path.join(root, 'anaconda3', 'envs', 'pymol', 'Scripts', 'pymol.exe'),
85+
]),
86+
...pf.flatMap((root) => [path.join(root, 'PyMOL', 'PyMOLWin.exe'), path.join(root, 'Schrodinger', 'PyMOL2', 'PyMOLWin.exe')]),
87+
]);
88+
const snapgene = firstExisting([
89+
process.env.SNAPGENE_EXE, commandPath('SnapGene.exe'),
90+
...roots.map((root) => path.join(root, 'Tools', 'SnapGene', 'SnapGene.exe')),
91+
...pf.map((root) => path.join(root, 'SnapGene', 'SnapGene.exe')),
92+
]);
93+
const cytoscape = firstExisting([
94+
process.env.CYTOSCAPE_EXE, commandPath('Cytoscape.exe'),
95+
...roots.flatMap((root) => childrenMatching(path.join(root, 'Tools', 'cytoscape'), 'Cytoscape.exe')),
96+
...pf.flatMap((root) => childrenMatching(root, path.join('Cytoscape', 'Cytoscape.exe'))),
97+
]);
98+
const fiji = firstExisting([
99+
process.env.FIJI_EXE, commandPath('ImageJ-win64.exe'), commandPath('ImageJ'),
100+
...roots.map((root) => path.join(root, 'Tools', 'Fiji.app', 'ImageJ-win64.exe')),
101+
...pf.flatMap((root) => [path.join(root, 'Fiji.app', 'ImageJ-win64.exe'), path.join(root, 'Fiji', 'ImageJ-win64.exe')]),
102+
]);
103+
return { pymol, snapgene, cytoscape, fiji };
104+
}
105+
106+
function status(tool) {
107+
const found = discover();
108+
const ids = tool ? [tool] : Object.keys(TOOL_INFO);
109+
return {
110+
generatedAt: new Date().toISOString(), localOnly: true, installsSoftware: false,
111+
tools: ids.map((id) => ({ id, ...TOOL_INFO[id], installed: Boolean(found[id]), executable: found[id] })),
112+
};
113+
}
114+
115+
function existingCompatibleFile(value, tool) {
116+
if (typeof value !== 'string' || !path.isAbsolute(value) || !existsSync(value)) throw new Error('file path must be an existing absolute path');
117+
const ext = path.extname(value).slice(1).toLowerCase();
118+
if (!TOOL_INFO[tool].formats.includes(ext)) throw new Error(`${TOOL_INFO[tool].name} does not accept .${ext} through this bridge`);
119+
return path.resolve(value);
120+
}
121+
function runProcess(exe, args, timeout = 180000) {
122+
return new Promise((resolve, reject) => {
123+
const child = spawn(exe, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
124+
let stdout = ''; let stderr = '';
125+
const timer = setTimeout(() => { child.kill(); reject(new Error(`Process timed out after ${timeout} ms`)); }, timeout);
126+
child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
127+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
128+
child.on('close', (code) => { clearTimeout(timer); code === 0 ? resolve({ stdout, stderr }) : reject(new Error((stderr || stdout || `Process exited with ${code}`).slice(-4000))); });
129+
});
130+
}
131+
132+
async function pymolRender(args) {
133+
const exe = discover().pymol;
134+
if (!exe) throw new Error('PyMOL was not found. Set PYMOL_EXE or add it to PATH. No installation was attempted.');
135+
const input = existingCompatibleFile(args.input_path, 'pymol');
136+
if (typeof args.output_path !== 'string' || !path.isAbsolute(args.output_path) || !/\.png$/i.test(args.output_path)) throw new Error('output_path must be an absolute .png path');
137+
const output = path.resolve(args.output_path);
138+
const representation = args.representation || 'cartoon-and-sticks';
139+
const color = args.color || 'spectrum'; const background = args.background || 'white';
140+
const width = Math.max(400, Math.min(5000, Number(args.width) || 1800)); const height = Math.max(400, Math.min(5000, Number(args.height) || 1400));
141+
mkdirSync(path.dirname(output), { recursive: true });
142+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'bio-pymol-'));
143+
const script = path.join(tmp, 'render.pml');
144+
const commands = ['reinitialize', 'python', `cmd.load(${JSON.stringify(input)}, "structure")`, 'python end', 'hide everything, all'];
145+
if (representation === 'cartoon') commands.push('show cartoon, polymer.protein');
146+
if (representation === 'surface') commands.push('show surface, all');
147+
if (representation === 'sticks') commands.push('show sticks, all');
148+
if (representation === 'cartoon-and-sticks') commands.push('show cartoon, polymer.protein', 'show sticks, organic');
149+
if (color === 'spectrum') commands.push('spectrum count, rainbow, all');
150+
if (color === 'chain') commands.push('util.cbc("all")');
151+
if (color === 'secondary-structure') commands.push('color marine, ss h', 'color gold, ss s', 'color grey70, ss l');
152+
commands.push(`bg_color ${background === 'transparent' ? 'white' : background}`);
153+
commands.push(`set ray_opaque_background, ${background === 'transparent' ? 'off' : 'on'}`, 'set antialias, 2', 'orient all', `ray ${width}, ${height}`, 'python', `cmd.png(${JSON.stringify(output)}, dpi=300)`, 'python end', 'quit');
154+
writeFileSync(script, `${commands.join('\n')}\n`, 'utf8');
155+
try {
156+
const executed = await runProcess(exe, ['-cq', '-r', script]);
157+
if (!existsSync(output)) throw new Error(`PyMOL completed without producing the requested PNG. Output: ${(executed.stderr || executed.stdout).slice(-1500)}`);
158+
return { tool: 'pymol', input, png: output, representation, color, background, localOnly: true, previewInstruction: `Display the PNG inline: ![PyMOL render](${output})` };
159+
} finally { rmSync(tmp, { recursive: true, force: true }); }
160+
}
161+
162+
function openLocal(args) {
163+
const id = args?.tool; if (!['snapgene', 'cytoscape', 'fiji'].includes(id)) throw new Error('tool must be snapgene, cytoscape, or fiji');
164+
const exe = discover()[id]; if (!exe) throw new Error(`${TOOL_INFO[id].name} was not found. No installation was attempted.`);
165+
const file = existingCompatibleFile(args.file_path, id);
166+
const child = spawn(exe, [file], { detached: true, stdio: 'ignore', windowsHide: true }); child.unref();
167+
return { tool: id, launched: true, file, localOnly: true, note: 'The existing file was opened; the bridge did not edit or export it.' };
168+
}
169+
170+
async function callTool(name, args) {
171+
try {
172+
if (name === 'local_bio_tool_status') return textResult(status(args?.tool));
173+
if (name === 'pymol_render') return textResult(await pymolRender(args || {}));
174+
if (name === 'local_bio_open') return textResult(openLocal(args || {}));
175+
return textResult(`Unknown tool: ${name}`, true);
176+
} catch (err) { return textResult(err instanceof Error ? err.message : String(err), true); }
177+
}
178+
179+
async function handle(message) {
180+
if (!message || message.jsonrpc !== '2.0' || message.id == null) return;
181+
const { id, method, params } = message;
182+
if (method === 'initialize') rpcResult(id, { protocolVersion: params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: SERVER });
183+
else if (method === 'ping') rpcResult(id, {});
184+
else if (method === 'tools/list') rpcResult(id, { tools: TOOLS });
185+
else if (method === 'tools/call') rpcResult(id, await callTool(params?.name, params?.arguments));
186+
else rpcError(id, -32601, `Method not found: ${method}`);
187+
}
188+
189+
readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on('line', (line) => {
190+
if (!line.trim()) return;
191+
try { void handle(JSON.parse(line)); } catch (err) { rpcError(null, -32700, err instanceof Error ? err.message : String(err)); }
192+
});

0 commit comments

Comments
 (0)