Skip to content

Commit d022291

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

1 file changed

Lines changed: 197 additions & 0 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#!/usr/bin/env node
2+
3+
import { spawn } from 'node:child_process';
4+
import { existsSync, 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+
import { execFileSync } from 'node:child_process';
9+
import { fileURLToPath } from 'node:url';
10+
11+
const SERVER = { name: 'rna-figure', version: '0.1.0' };
12+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
13+
const SCRIPT = path.join(ROOT, 'scripts', 'rna-figure.R');
14+
const TYPES = ['volcano', 'pca', 'heatmap', 'expression-boxplot', 'enrichment-dotplot'];
15+
16+
const TOOLS = [
17+
{
18+
name: 'rna_figure_status',
19+
description: 'Check the local R plotting runtime and required packages without reading research data.',
20+
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
21+
},
22+
{
23+
name: 'rna_figure_create',
24+
description: 'Create a publication-oriented RNA result figure locally as PNG and PDF. The tool never uploads the input. After creation, display the returned PNG path in the conversation.',
25+
inputSchema: {
26+
type: 'object', additionalProperties: false,
27+
properties: {
28+
plot_type: { type: 'string', enum: TYPES },
29+
input_path: { type: 'string', description: 'Absolute path to a CSV/TSV table explicitly selected by the user.' },
30+
output_dir: { type: 'string', description: 'Existing or creatable local output directory.' },
31+
output_name: { type: 'string', pattern: '^[A-Za-z0-9._-]+$', default: 'rna-figure' },
32+
metadata_path: { type: 'string', description: 'Optional PCA metadata CSV/TSV with sample and group columns.' },
33+
columns: {
34+
type: 'object', additionalProperties: false,
35+
properties: {
36+
gene: { type: 'string' }, x: { type: 'string' }, y: { type: 'string' }, label: { type: 'string' },
37+
group: { type: 'string' }, facet: { type: 'string' }, term: { type: 'string' }, size: { type: 'string' }, color: { type: 'string' }, sample: { type: 'string' },
38+
},
39+
},
40+
options: {
41+
type: 'object', additionalProperties: false,
42+
properties: {
43+
alpha: { type: 'number', exclusiveMinimum: 0, maximum: 1, default: 0.05 },
44+
lfc: { type: 'number', minimum: 0, default: 1 },
45+
top_n: { type: 'integer', minimum: 1, maximum: 500, default: 30 },
46+
label_n: { type: 'integer', minimum: 0, maximum: 50, default: 10 },
47+
width: { type: 'number', minimum: 3, maximum: 20, default: 7 },
48+
height: { type: 'number', minimum: 3, maximum: 20, default: 5.5 },
49+
dpi: { type: 'integer', minimum: 150, maximum: 1200, default: 300 },
50+
transform: { type: 'string', enum: ['auto', 'none', 'log2'], default: 'auto' },
51+
},
52+
},
53+
},
54+
required: ['plot_type', 'input_path', 'output_dir'],
55+
},
56+
},
57+
];
58+
59+
function send(message) { process.stdout.write(`${JSON.stringify(message)}\n`); }
60+
function result(id, value) { send({ jsonrpc: '2.0', id, result: value }); }
61+
function error(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
62+
function textResult(value, isError = false) {
63+
return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }], ...(isError ? { isError: true } : {}) };
64+
}
65+
66+
function commandPath(name) {
67+
try {
68+
const command = process.platform === 'win32' ? 'where.exe' : 'which';
69+
return execFileSync(command, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split(/\r?\n/).map((x) => x.trim()).find(Boolean) || null;
70+
} catch { return null; }
71+
}
72+
73+
function registryR() {
74+
if (process.platform !== 'win32') return null;
75+
for (const key of ['HKCU\\SOFTWARE\\R-core\\R', 'HKLM\\SOFTWARE\\R-core\\R']) {
76+
try {
77+
const value = execFileSync('reg.exe', ['query', key, '/v', 'InstallPath'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
78+
const match = value.match(/InstallPath\s+REG_SZ\s+(.+)$/mi);
79+
if (match) {
80+
const candidate = path.join(match[1].trim(), 'bin', 'Rscript.exe');
81+
if (existsSync(candidate)) return candidate;
82+
}
83+
} catch { /* continue */ }
84+
}
85+
return null;
86+
}
87+
88+
function driveRoots() {
89+
if (process.platform !== 'win32') return ['/'];
90+
try {
91+
return execFileSync('powershell.exe', ['-NoProfile', '-Command', '(Get-PSDrive -PSProvider FileSystem).Root'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
92+
.split(/\r?\n/).map((x) => x.trim()).filter((x) => /^[A-Za-z]:\\$/.test(x));
93+
} catch { return ['C:\\']; }
94+
}
95+
96+
function versionedR() {
97+
for (const root of driveRoots()) {
98+
for (const parent of [path.join(root, 'AI_IDE'), path.join(root, 'Program Files', 'R')]) {
99+
try {
100+
const candidates = readdirSync(parent, { withFileTypes: true })
101+
.filter((entry) => entry.isDirectory() && /^R[-_]?\d/i.test(entry.name))
102+
.map((entry) => path.join(parent, entry.name, 'bin', 'Rscript.exe'));
103+
const hit = candidates.find((candidate) => existsSync(candidate));
104+
if (hit) return hit;
105+
} catch { /* continue */ }
106+
}
107+
}
108+
return null;
109+
}
110+
111+
function rscriptPath() {
112+
const candidates = [process.env.RSCRIPT_EXE, commandPath('Rscript'), registryR(), versionedR()].filter(Boolean);
113+
return candidates.find((candidate) => existsSync(candidate)) || null;
114+
}
115+
116+
function run(exe, args, timeout = 180000) {
117+
return new Promise((resolve, reject) => {
118+
const child = spawn(exe, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
119+
let stdout = ''; let stderr = '';
120+
const timer = setTimeout(() => { child.kill(); reject(new Error(`Process timed out after ${timeout} ms`)); }, timeout);
121+
child.stdout.on('data', (chunk) => { stdout += chunk; });
122+
child.stderr.on('data', (chunk) => { stderr += chunk; });
123+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
124+
child.on('close', (code) => {
125+
clearTimeout(timer);
126+
if (code === 0) resolve({ stdout, stderr });
127+
else reject(new Error((stderr || stdout || `Process exited with ${code}`).slice(-4000)));
128+
});
129+
});
130+
}
131+
132+
async function status() {
133+
const exe = rscriptPath();
134+
if (!exe) return { ready: false, rscript: null, script: existsSync(SCRIPT), packages: {}, reason: 'Rscript not found. Set RSCRIPT_EXE or add Rscript to PATH.' };
135+
const probe = await run(exe, ['--vanilla', '-e', "p<-c('jsonlite','ggplot2','pheatmap','ggrepel');cat(paste(p,vapply(p,requireNamespace,logical(1),quietly=TRUE),sep='='),sep='\\n')"], 30000);
136+
const packages = Object.fromEntries(probe.stdout.split(/\r?\n/).filter((line) => line.includes('=')).map((line) => { const [k, v] = line.trim().split('='); return [k, v === 'TRUE']; }));
137+
return { ready: Boolean(packages.jsonlite && packages.ggplot2 && packages.pheatmap), rscript: exe, script: existsSync(SCRIPT), packages };
138+
}
139+
140+
function absoluteExistingFile(value, label) {
141+
if (typeof value !== 'string' || !path.isAbsolute(value) || !existsSync(value)) throw new Error(`${label} must be an existing absolute path`);
142+
if (!/\.(csv|tsv|txt)$/i.test(value)) throw new Error(`${label} must be CSV, TSV, or TXT`);
143+
return path.resolve(value);
144+
}
145+
146+
async function createFigure(args) {
147+
const runtime = await status();
148+
if (!runtime.ready) throw new Error(`RNA plotting runtime is not ready: ${JSON.stringify(runtime)}`);
149+
if (!TYPES.includes(args?.plot_type)) throw new Error(`Unsupported plot_type: ${args?.plot_type}`);
150+
const inputPath = absoluteExistingFile(args.input_path, 'input_path');
151+
const outputDir = path.resolve(String(args.output_dir || ''));
152+
if (!path.isAbsolute(outputDir)) throw new Error('output_dir must be an absolute path');
153+
const outputName = args.output_name || 'rna-figure';
154+
if (!/^[A-Za-z0-9._-]+$/.test(outputName)) throw new Error('output_name contains unsupported characters');
155+
const metadataPath = args.metadata_path ? absoluteExistingFile(args.metadata_path, 'metadata_path') : null;
156+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'bio-rna-'));
157+
const configPath = path.join(tmp, 'config.json');
158+
const config = {
159+
plot_type: args.plot_type, input_path: inputPath, output_dir: outputDir, output_name: outputName,
160+
metadata_path: metadataPath, columns: args.columns || {}, options: args.options || {},
161+
};
162+
writeFileSync(configPath, JSON.stringify(config), 'utf8');
163+
try {
164+
const executed = await run(runtime.rscript, ['--vanilla', SCRIPT, configPath]);
165+
const lines = executed.stdout.split(/\r?\n/).filter(Boolean);
166+
const payloadLine = [...lines].reverse().find((line) => line.startsWith('RNA_FIGURE_RESULT='));
167+
if (!payloadLine) throw new Error(`R did not return a result manifest: ${(executed.stderr || executed.stdout).slice(-2000)}`);
168+
const payload = JSON.parse(payloadLine.slice('RNA_FIGURE_RESULT='.length));
169+
for (const key of ['png', 'pdf', 'plot_data']) if (!existsSync(payload[key])) throw new Error(`Expected output missing: ${payload[key]}`);
170+
return { ...payload, localOnly: true, previewInstruction: `Display the PNG inline: ![RNA figure](${payload.png})`, runtime: { rscript: runtime.rscript, packages: runtime.packages } };
171+
} finally {
172+
rmSync(tmp, { recursive: true, force: true });
173+
}
174+
}
175+
176+
async function callTool(name, args) {
177+
try {
178+
if (name === 'rna_figure_status') return textResult(await status());
179+
if (name === 'rna_figure_create') return textResult(await createFigure(args || {}));
180+
return textResult(`Unknown tool: ${name}`, true);
181+
} catch (err) { return textResult(err instanceof Error ? err.message : String(err), true); }
182+
}
183+
184+
async function handle(message) {
185+
if (!message || message.jsonrpc !== '2.0' || message.id == null) return;
186+
const { id, method, params } = message;
187+
if (method === 'initialize') result(id, { protocolVersion: params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: SERVER });
188+
else if (method === 'ping') result(id, {});
189+
else if (method === 'tools/list') result(id, { tools: TOOLS });
190+
else if (method === 'tools/call') result(id, await callTool(params?.name, params?.arguments));
191+
else error(id, -32601, `Method not found: ${method}`);
192+
}
193+
194+
readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on('line', (line) => {
195+
if (!line.trim()) return;
196+
try { void handle(JSON.parse(line)); } catch (err) { error(null, -32700, err instanceof Error ? err.message : String(err)); }
197+
});

0 commit comments

Comments
 (0)