@neuralbridge/bio-pipelines-mcp 0.0.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -3
- package/dist/src/context.js +23 -0
- package/dist/src/index.js +9 -0
- package/dist/src/inspect.js +100 -0
- package/dist/src/parse.js +144 -0
- package/dist/src/paths.js +108 -0
- package/dist/src/plan.js +168 -0
- package/dist/src/reply.js +25 -0
- package/dist/src/runner.js +95 -0
- package/dist/src/runs.js +239 -0
- package/dist/src/server.js +88 -0
- package/dist/src/store.js +126 -0
- package/package.json +27 -5
package/README.md
CHANGED
|
@@ -1,6 +1,98 @@
|
|
|
1
1
|
# @neuralbridge/bio-pipelines-mcp
|
|
2
2
|
|
|
3
|
-
MCP server
|
|
3
|
+
MCP server for bioinformatics pipelines written for Nextflow, WDL (run with miniwdl) and Snakemake. It is the server
|
|
4
|
+
behind the `nb-bio-pipelines` power of NeuralBridge IDE, and works with any MCP client over stdio.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
## Tools
|
|
7
|
+
|
|
8
|
+
| Tool | What it does | Changes |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `bio_detect` | Which of `nextflow`, `miniwdl`, `snakemake` are installed and their versions; with `dir`, lists `main.nf`, `nextflow.config`, `.wdl` files and Snakefiles | nothing |
|
|
11
|
+
| `bio_lint` | `nextflow lint -o json`, `miniwdl check`, `snakemake --lint json`; findings as `file:line:column: message` | nothing (Snakemake evaluates the Snakefile) |
|
|
12
|
+
| `bio_plan` | Dry run: Nextflow `run -preview -with-dag` (processes of the DAG), Snakemake `--dry-run` (jobs per rule), miniwdl `check` + required inputs + `run --json`; saves the exact command and file hashes as a plan | tool metadata (`.nextflow`, `.snakemake`) in the launch directory |
|
|
13
|
+
| `bio_run` | Starts exactly the planned command in the background, once, and returns a run id | **runs the pipeline**: computes, writes results, may overwrite outputs |
|
|
14
|
+
| `bio_run_status` | Running or finished, exit code, duration, progress, last 100 lines of the log | nothing |
|
|
15
|
+
| `bio_run_stop_plan` | Shows what is running and for how long; saves a stop plan | a plan file in the data folder |
|
|
16
|
+
| `bio_run_stop_apply` | Sends SIGTERM to the planned run, once | **stops the run** |
|
|
17
|
+
| `bio_runs` | Runs started through this server, newest first | nothing |
|
|
18
|
+
|
|
19
|
+
Read-only are only `bio_detect`, `bio_run_status` and `bio_runs`. `bio_lint` is not marked read-only because
|
|
20
|
+
`snakemake --lint` executes the Snakefile, which is Python code; `bio_plan` evaluates the pipeline code of all three tools.
|
|
21
|
+
`bio_run` and `bio_run_stop_apply` are marked destructive.
|
|
22
|
+
|
|
23
|
+
### Plan, then run
|
|
24
|
+
|
|
25
|
+
`bio_run` takes only a plan id. Before starting, it checks that the program still resolves to the same binary, that the
|
|
26
|
+
launch directory exists and that the pipeline file, the `nextflow.config` files (pipeline and launch directory), the
|
|
27
|
+
params file, the Snakemake config and profile files and the WDL inputs file have the same sha256 as at planning time.
|
|
28
|
+
Then it runs exactly the saved arguments and removes the plan. Plans expire after 24 hours.
|
|
29
|
+
|
|
30
|
+
The run is a detached process: its stdout and stderr go to `runs/<id>.log`, the PID and start time to `runs/<id>.json`.
|
|
31
|
+
While the server is alive, it records the exit code when the process ends. If the server was restarted during a run,
|
|
32
|
+
`bio_run_status` checks the PID (on Linux also its command line) and guesses the outcome from the end of the log. Records
|
|
33
|
+
of finished runs, their logs and Nextflow reports are removed after 7 days.
|
|
34
|
+
|
|
35
|
+
| Tool | Command started by `bio_run` |
|
|
36
|
+
|---|---|
|
|
37
|
+
| Nextflow | `nextflow run <main.nf> [-params-file F] [-profile P] [-work-dir D] [-resume] -with-report <data>/runs/<id>-report.html` |
|
|
38
|
+
| Snakemake | `snakemake [targets] --snakefile S --directory D --nocolor --cores N [--configfile …] [--profile P] [--software-deployment-method …]` |
|
|
39
|
+
| miniwdl | `miniwdl run <file.wdl> [-i inputs.json] [--task T] --dir <outputDir> --no-color` (a timestamp-named run directory is created under it) |
|
|
40
|
+
|
|
41
|
+
Stopping sends SIGTERM to the program itself; Nextflow, Snakemake and miniwdl then cancel their running tasks and exit.
|
|
42
|
+
|
|
43
|
+
## Local pipelines only, and what the tools download
|
|
44
|
+
|
|
45
|
+
The server installs and downloads nothing. It starts only programs already installed, found on `PATH` or through
|
|
46
|
+
`NB_BIO_NEXTFLOW`, `NB_BIO_MINIWDL`, `NB_BIO_SNAKEMAKE`.
|
|
47
|
+
|
|
48
|
+
- **Pipelines must be local.** `pipeline` is an absolute path to `main.nf` (or the directory that contains it), a
|
|
49
|
+
Snakefile (or its project directory with `Snakefile` / `workflow/Snakefile`), or a `.wdl` file. Names like
|
|
50
|
+
`owner/repo`, which Nextflow would download from a git hosting service, and URLs are not accepted. Clone the pipeline
|
|
51
|
+
first and point to the local copy.
|
|
52
|
+
- **Nextflow** downloads plugins and, on the very first start of its launcher, its own runtime into `~/.nextflow`. With
|
|
53
|
+
`offline: true` the server sets `NXF_OFFLINE=true`: Nextflow downloads no pipelines and no plugins (since 24.04), so the
|
|
54
|
+
plugins must already be installed. Tasks run with the executor configured in `nextflow.config`.
|
|
55
|
+
- **Container images and Conda environments** used by the processes, rules or tasks are pulled or created by Nextflow,
|
|
56
|
+
Snakemake (only with `softwareDeployment`) and the container runtime of miniwdl when the run starts. miniwdl also
|
|
57
|
+
downloads input files given as URIs. The plan says so in its answer.
|
|
58
|
+
- Snakemake software deployment (`--software-deployment-method conda|apptainer|env-modules`) is off unless requested.
|
|
59
|
+
|
|
60
|
+
## Environment
|
|
61
|
+
|
|
62
|
+
| Variable | Meaning |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `NB_BIO_NEXTFLOW`, `NB_BIO_MINIWDL`, `NB_BIO_SNAKEMAKE` | Path of the program; default — the name on `PATH`. Each program is optional |
|
|
65
|
+
| `PLUGIN_DATA` | Data folder for plans and runs; default — `nb-bio-pipelines` in the system temp folder. Mode 0700, files 0600 |
|
|
66
|
+
|
|
67
|
+
Programs run without a shell, with closed stdin, `NO_COLOR=1` and a time limit (lint 5 minutes, plan 30 minutes, the
|
|
68
|
+
run itself has none). Nextflow also gets `NXF_ANSI_LOG=false`, `NXF_DISABLE_CHECK_LATEST=true` (no check for a newer
|
|
69
|
+
version) and `NXF_AGENT_MODE=false` (keeps the normal log format the progress parser reads). Credentials stay where the
|
|
70
|
+
tools read them (their config files and the environment of the server). Answers mask passwords in URLs (`user:***@`),
|
|
71
|
+
`password=` / `token:` pairs and bearer tokens; params files are shown by key names only. Logs of the server go to
|
|
72
|
+
stderr.
|
|
73
|
+
|
|
74
|
+
## Versions
|
|
75
|
+
|
|
76
|
+
- `nextflow lint` needs Nextflow 25.04 or newer; with older versions `bio_lint` skips Nextflow files with a note.
|
|
77
|
+
`-preview` needs Nextflow 22.10 or newer.
|
|
78
|
+
- `softwareDeployment` needs Snakemake 8 or newer. Dry-run job statistics are read from the `Job stats:` table.
|
|
79
|
+
- miniwdl: `check`, `input-template` and `run --json` of current releases (1.x).
|
|
80
|
+
|
|
81
|
+
## Run
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx -y @neuralbridge/bio-pipelines-mcp@1.0.0
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Requires Node.js 20+.
|
|
88
|
+
|
|
89
|
+
## Develop
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm install
|
|
93
|
+
npm test # build + unit and end-to-end tests with fake nextflow, miniwdl and snakemake programs
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
Apache-2.0. See `LICENSE` and `NOTICE`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Copyright (c) NeuralBridge. Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
2
|
+
// Состояние сервера на время его жизни: окружение, кэш найденных программ и исход прогонов, запущенных этим
|
|
3
|
+
// процессом сервера (код выхода известен сразу, до записи на диск). Используют все обработчики.
|
|
4
|
+
import { PROGRAMS, resolveProgram } from './runner.js';
|
|
5
|
+
export function createContext(env) {
|
|
6
|
+
return { env, programs: {}, exits: new Map(), children: new Map() };
|
|
7
|
+
}
|
|
8
|
+
/** Программа или `undefined`; неудачный поиск не кэшируется — пользователь может поставить программу без перезапуска. */
|
|
9
|
+
export async function findProgram(ctx, kind) {
|
|
10
|
+
ctx.programs[kind] ??= resolveProgram(kind, ctx.env);
|
|
11
|
+
const program = await ctx.programs[kind];
|
|
12
|
+
if (!program) {
|
|
13
|
+
delete ctx.programs[kind];
|
|
14
|
+
}
|
|
15
|
+
return program;
|
|
16
|
+
}
|
|
17
|
+
export async function programOf(ctx, kind) {
|
|
18
|
+
const program = await findProgram(ctx, kind);
|
|
19
|
+
if (!program) {
|
|
20
|
+
throw new Error(PROGRAMS[kind].notFound);
|
|
21
|
+
}
|
|
22
|
+
return program;
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) NeuralBridge. Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
3
|
+
// Точка входа пакета `@neuralbridge/bio-pipelines-mcp`: сервер `server.ts` на транспорте stdio. Логи — только в
|
|
4
|
+
// stderr: stdout занят протоколом.
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { createServer } from './server.js';
|
|
7
|
+
const server = createServer();
|
|
8
|
+
await server.connect(new StdioServerTransport());
|
|
9
|
+
process.stderr.write('nb-bio-pipelines MCP server ready\n');
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Copyright (c) NeuralBridge. Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
2
|
+
// Обработчики `bio_detect` и `bio_lint`: какие программы установлены, какие пайплайны лежат в каталоге, и статическая
|
|
3
|
+
// проверка `nextflow lint`, `miniwdl check`, `snakemake --lint`. Регистрация в MCP — `server.ts`.
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
import { findProgram } from './context.js';
|
|
7
|
+
import { parseMiniwdlCheck, parseNextflowLint, parseSnakemakeLint } from './parse.js';
|
|
8
|
+
import { existingPath, findPipelineFiles, pipelineOf } from './paths.js';
|
|
9
|
+
import { compareVersions, failure, MINUTE, PROGRAM_KINDS, PROGRAMS, programEnv, run } from './runner.js';
|
|
10
|
+
/** `nextflow lint` появился в Nextflow 25.04. */
|
|
11
|
+
export const NEXTFLOW_LINT_SINCE = '25.04.0';
|
|
12
|
+
const LINT_TIMEOUT = 5 * MINUTE;
|
|
13
|
+
const MAX_WDL = 20;
|
|
14
|
+
const MAX_SNAKEFILES = 5;
|
|
15
|
+
export async function detect(ctx, args) {
|
|
16
|
+
const lines = [];
|
|
17
|
+
for (const kind of PROGRAM_KINDS) {
|
|
18
|
+
const program = await findProgram(ctx, kind);
|
|
19
|
+
lines.push(program ? `${kind} ${program.version} (${program.path})` : `${kind}: not found. ${PROGRAMS[kind].notFound}`);
|
|
20
|
+
}
|
|
21
|
+
if (args.dir) {
|
|
22
|
+
const root = await existingPath(args.dir, '"dir"', 'dir');
|
|
23
|
+
const found = await findPipelineFiles(root);
|
|
24
|
+
const list = (title, files) => files.length ? [`${title}:`, ...files.map(f => ` ${f}`)] : [];
|
|
25
|
+
const files = [...list('Nextflow (main.nf, nextflow.config)', found.nextflow), ...list('WDL', found.wdl), ...list('Snakemake', found.snakemake)];
|
|
26
|
+
lines.push('', files.length ? `Pipelines under ${root}:\n${files.join('\n')}` : `No main.nf, nextflow.config, .wdl or Snakefile under ${root}.`);
|
|
27
|
+
}
|
|
28
|
+
return lines.join('\n');
|
|
29
|
+
}
|
|
30
|
+
/** Что проверять: файл — по имени; папка — все найденные пайплайны (Nextflow — папкой целиком). */
|
|
31
|
+
async function lintTargets(path, only) {
|
|
32
|
+
const stat = await fs.stat(path);
|
|
33
|
+
const targets = [];
|
|
34
|
+
if (stat.isFile()) {
|
|
35
|
+
const name = basename(path);
|
|
36
|
+
const kind = /\.(nf|config)$/.test(name) ? 'nextflow' : name.endsWith('.wdl') ? 'miniwdl' : /^[Ss]nakefile$|\.smk$/.test(name) ? 'snakemake' : undefined;
|
|
37
|
+
if (!kind) {
|
|
38
|
+
throw new Error(`Cannot tell the pipeline language of ${path}: expected .nf, .config, .wdl, Snakefile or .smk.`);
|
|
39
|
+
}
|
|
40
|
+
targets.push({ kind, path, cwd: dirname(path) });
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const found = await findPipelineFiles(path);
|
|
44
|
+
if (found.nextflow.length) {
|
|
45
|
+
targets.push({ kind: 'nextflow', path, cwd: path });
|
|
46
|
+
}
|
|
47
|
+
for (const file of found.wdl.slice(0, MAX_WDL)) {
|
|
48
|
+
targets.push({ kind: 'miniwdl', path: join(path, file), cwd: dirname(join(path, file)) });
|
|
49
|
+
}
|
|
50
|
+
for (const file of found.snakemake.filter(f => !/(^|[\\/])rules[\\/]/.test(f)).slice(0, MAX_SNAKEFILES)) {
|
|
51
|
+
const pipeline = await pipelineOf('snakemake', join(path, file));
|
|
52
|
+
targets.push({ kind: 'snakemake', path: pipeline.file, cwd: pipeline.root });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return only ? targets.filter(t => t.kind === only) : targets;
|
|
56
|
+
}
|
|
57
|
+
async function lintOne(program, target, env) {
|
|
58
|
+
const options = { cwd: target.cwd, timeoutMs: LINT_TIMEOUT, env: programEnv(program.kind, env, { offline: true }) };
|
|
59
|
+
if (program.kind === 'nextflow') {
|
|
60
|
+
if (compareVersions(program.version, NEXTFLOW_LINT_SINCE) < 0) {
|
|
61
|
+
return `nextflow lint ${target.path}: skipped — "nextflow lint" needs Nextflow 25.04 or newer, found ${program.version}. Use bio_plan (nextflow -preview) to check that the script compiles.`;
|
|
62
|
+
}
|
|
63
|
+
const result = await run(program.path, ['lint', '-o', 'json', target.path], options);
|
|
64
|
+
const findings = parseNextflowLint(result.stdout);
|
|
65
|
+
if (!findings) {
|
|
66
|
+
throw failure('nextflow lint', result);
|
|
67
|
+
}
|
|
68
|
+
return `nextflow lint ${target.path}: ${findings.length ? `${findings.length} finding(s)\n${findings.join('\n')}` : 'no errors or warnings.'}`;
|
|
69
|
+
}
|
|
70
|
+
if (program.kind === 'miniwdl') {
|
|
71
|
+
const result = await run(program.path, ['check', target.path], options);
|
|
72
|
+
const { findings } = parseMiniwdlCheck(target.path, result.stdout, result.stderr);
|
|
73
|
+
const head = result.code === 0 ? 'parsed and type-checked' : 'FAILED to parse or type-check';
|
|
74
|
+
const extra = result.code !== 0 && !findings.some(f => f.startsWith('error:')) ? `\n${(result.stderr || result.stdout).trim()}` : '';
|
|
75
|
+
return `miniwdl check ${target.path}: ${head}${findings.length ? `, ${findings.length} finding(s)\n${findings.join('\n')}` : '.'}${extra}`;
|
|
76
|
+
}
|
|
77
|
+
const result = await run(program.path, ['--lint', 'json', '--snakefile', target.path, '--directory', target.cwd, '--nocolor'], options);
|
|
78
|
+
const findings = parseSnakemakeLint(result.stdout);
|
|
79
|
+
if (!findings) {
|
|
80
|
+
throw failure('snakemake --lint', result);
|
|
81
|
+
}
|
|
82
|
+
return `snakemake --lint ${target.path}: ${findings.length ? `${findings.length} finding(s)\n${findings.join('\n')}` : 'no lints.'}`;
|
|
83
|
+
}
|
|
84
|
+
export async function lint(ctx, args) {
|
|
85
|
+
const path = await existingPath(args.path, '"path"');
|
|
86
|
+
const targets = await lintTargets(path, args.program);
|
|
87
|
+
if (!targets.length) {
|
|
88
|
+
return `Nothing to lint under ${path}${args.program ? ` for ${args.program}` : ''}.`;
|
|
89
|
+
}
|
|
90
|
+
const sections = [];
|
|
91
|
+
for (const target of targets) {
|
|
92
|
+
const program = await findProgram(ctx, target.kind);
|
|
93
|
+
if (!program) {
|
|
94
|
+
sections.push(`${target.path}: skipped — ${PROGRAMS[target.kind].notFound}`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
sections.push(await lintOne(program, target, ctx.env).catch((error) => `${target.path}: ${error.message}`));
|
|
98
|
+
}
|
|
99
|
+
return sections.join('\n\n');
|
|
100
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** JSON из вывода, перед которым программа могла напечатать что-то ещё. */
|
|
2
|
+
function jsonOf(text) {
|
|
3
|
+
const start = text.indexOf('{');
|
|
4
|
+
if (start < 0) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(text.slice(start));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** `nextflow lint -o json`: `{ errors: [...], warnings: [...] }` с полями filename, startLine, startColumn, message. */
|
|
15
|
+
export function parseNextflowLint(stdout) {
|
|
16
|
+
const parsed = jsonOf(stdout);
|
|
17
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const line = (severity, item) => `${severity}: ${String(item.filename ?? '?')}:${Number(item.startLine ?? 0)}:${Number(item.startColumn ?? 0)}: ${String(item.message ?? '').replace(/\s+/g, ' ').trim()}`;
|
|
21
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
22
|
+
return [...list(parsed.errors).map(e => line('error', e)), ...list(parsed.warnings).map(w => line('warning', w))];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `miniwdl check`: в stdout — схема документа с замечаниями «(Ln 2, Col 7) UnusedDeclaration, …» и строками `call`;
|
|
26
|
+
* ошибки разбора и типов — в stderr «(file.wdl Ln 3 Col 5) …».
|
|
27
|
+
*/
|
|
28
|
+
export function parseMiniwdlCheck(file, stdout, stderr) {
|
|
29
|
+
const findings = [];
|
|
30
|
+
let calls = 0;
|
|
31
|
+
for (const line of stdout.split('\n')) {
|
|
32
|
+
const warning = /^\s*\(Ln (\d+), Col (\d+)\)\s*(.*)$/.exec(line);
|
|
33
|
+
if (warning) {
|
|
34
|
+
findings.push(`warning: ${file}:${warning[1]}:${warning[2]}: ${warning[3].trim()}`);
|
|
35
|
+
}
|
|
36
|
+
else if (/^\s+call\s+\S/.test(line)) {
|
|
37
|
+
calls++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const line of stderr.split('\n')) {
|
|
41
|
+
const error = /^\((.+?) Ln (\d+) Col (\d+)\)\s*(.*)$/.exec(line.trim());
|
|
42
|
+
if (error) {
|
|
43
|
+
findings.push(`error: ${error[1]}:${error[2]}:${error[3]}: ${error[4].trim()}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { calls, findings };
|
|
47
|
+
}
|
|
48
|
+
/** `snakemake --lint json`: `{ snakefiles: [{ for: { snakefile }, lints }], rules: [{ for: { rule, line, snakefile }, lints }] }`. */
|
|
49
|
+
export function parseSnakemakeLint(stdout) {
|
|
50
|
+
const parsed = jsonOf(stdout);
|
|
51
|
+
if (!parsed || typeof parsed !== 'object' || (!('snakefiles' in parsed) && !('rules' in parsed))) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const out = [];
|
|
55
|
+
const groups = (value) => Array.isArray(value) ? value : [];
|
|
56
|
+
for (const group of [...groups(parsed.snakefiles), ...groups(parsed.rules)]) {
|
|
57
|
+
const where = `${String(group.for?.snakefile ?? '?')}${group.for?.line !== undefined && group.for.line !== null ? `:${Number(group.for.line)}` : ''}`;
|
|
58
|
+
const rule = group.for?.rule ? ` rule ${String(group.for.rule)}:` : '';
|
|
59
|
+
for (const lint of Array.isArray(group.lints) ? group.lints : []) {
|
|
60
|
+
out.push(`lint: ${where}:${rule} ${String(lint.title ?? '')} — ${String(lint.body ?? '').replace(/\s+/g, ' ').trim()}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** Таблица «Job stats» сухого прогона Snakemake: число заданий по правилам и итог. */
|
|
66
|
+
export function parseSnakemakeJobStats(text) {
|
|
67
|
+
const lines = text.split('\n');
|
|
68
|
+
const start = lines.findIndex(line => line.trim() === 'Job stats:');
|
|
69
|
+
if (start < 0) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const rules = [];
|
|
73
|
+
for (const line of lines.slice(start + 1)) {
|
|
74
|
+
const row = /^\s*(\S+)\s+(\d+)\s*$/.exec(line);
|
|
75
|
+
if (row) {
|
|
76
|
+
if (row[1] === 'total') {
|
|
77
|
+
return { total: Number(row[2]), rules };
|
|
78
|
+
}
|
|
79
|
+
rules.push({ name: row[1], count: Number(row[2]) });
|
|
80
|
+
}
|
|
81
|
+
else if (line.trim() && !/^[-\s]+$/.test(line) && !/^\s*job\s+count\s*$/.test(line)) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return rules.length ? { total: rules.reduce((sum, r) => sum + r.count, 0), rules } : undefined;
|
|
86
|
+
}
|
|
87
|
+
/** Процессы в DAG Nextflow в формате Mermaid: узлы процессов — `v3(["NAME"])`; операторы без `-verbose` — `(( ))`. */
|
|
88
|
+
export function parseNextflowDag(mermaid) {
|
|
89
|
+
const names = new Set();
|
|
90
|
+
for (const match of mermaid.matchAll(/\b[a-z]?\d+\(\["?([^"\]]+)"?\]\)/g)) {
|
|
91
|
+
names.add(match[1].trim());
|
|
92
|
+
}
|
|
93
|
+
return [...names];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Прогресс Nextflow по логу без ANSI: строки «[ab/123456] Submitted process > NAME (1)», «Cached process >», а в
|
|
97
|
+
* ANSI-сводке «[ab/123456] process > NAME (3) [100%] 3 of 3 ✔» — берётся последняя строка каждого процесса.
|
|
98
|
+
*/
|
|
99
|
+
export function nextflowProgress(log) {
|
|
100
|
+
const ansi = new Map();
|
|
101
|
+
const counts = new Map();
|
|
102
|
+
let executor = '';
|
|
103
|
+
for (const line of log.split('\n')) {
|
|
104
|
+
const exec = /^executor\s*>\s*(.+)$/.exec(line.trim());
|
|
105
|
+
if (exec) {
|
|
106
|
+
executor = `executor > ${exec[1].trim()}`;
|
|
107
|
+
}
|
|
108
|
+
const bar = /\[[0-9a-f-]{2}\/[0-9a-f-]{6}\]\s+process\s*>\s*(\S+)(.*\[\s*\d+%\].*)$/.exec(line);
|
|
109
|
+
if (bar) {
|
|
110
|
+
ansi.set(bar[1], `${bar[1]}${bar[2]}`.replace(/\s+/g, ' ').trim());
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const plain = /\[[0-9a-f]{2}\/[0-9a-f]{6}\]\s+(Submitted|Re-submitted|Cached) process\s*>\s*(\S+)/.exec(line);
|
|
114
|
+
if (plain) {
|
|
115
|
+
const entry = counts.get(plain[2]) ?? { submitted: 0, cached: 0 };
|
|
116
|
+
if (plain[1] === 'Cached') {
|
|
117
|
+
entry.cached++;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
entry.submitted++;
|
|
121
|
+
}
|
|
122
|
+
counts.set(plain[2], entry);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const rows = ansi.size
|
|
126
|
+
? [...ansi.values()]
|
|
127
|
+
: [...counts.entries()].map(([name, c]) => `${name}: ${c.submitted} submitted${c.cached ? `, ${c.cached} cached` : ''}`);
|
|
128
|
+
return executor && rows.length ? [executor, ...rows] : rows;
|
|
129
|
+
}
|
|
130
|
+
/** Прогресс Snakemake: последняя строка «3 of 5 steps (60%) done». */
|
|
131
|
+
export function snakemakeProgress(log) {
|
|
132
|
+
const matches = [...log.matchAll(/^\s*(\d+ of \d+ steps \(\d+%\) done)/gm)];
|
|
133
|
+
return matches.length ? [matches[matches.length - 1][1]] : [];
|
|
134
|
+
}
|
|
135
|
+
/** Исход по хвосту лога, когда код выхода неизвестен (сервер перезапускался во время прогона). */
|
|
136
|
+
export function inferOutcome(program, log) {
|
|
137
|
+
const patterns = {
|
|
138
|
+
nextflow: { ok: /^Succeeded\s*:|Workflow completed successfully|^Completed at:/m, fail: /^ERROR ~|Execution aborted|Workflow failed/m },
|
|
139
|
+
snakemake: { ok: /\(100%\) done|Nothing to be done/, fail: /Exiting because a job execution failed|^Error in rule|WorkflowError/m },
|
|
140
|
+
miniwdl: { ok: /"outputs"\s*:/, fail: /\bfailed\b.*\btask\b|"error"\s*:|Traceback/i },
|
|
141
|
+
};
|
|
142
|
+
const { ok, fail } = patterns[program];
|
|
143
|
+
return fail.test(log) ? 'failed' : ok.test(log) ? 'succeeded' : undefined;
|
|
144
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Copyright (c) NeuralBridge. Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
2
|
+
// Пути проекта: проверка абсолютных путей, поиск файлов пайплайнов (`main.nf`, `nextflow.config`, `*.wdl`, `Snakefile`),
|
|
3
|
+
// выбор файла пайплайна по программе и sha256 файлов, по которым план сверяется перед запуском.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
|
+
/** Абсолютный путь к существующему файлу или папке. */
|
|
8
|
+
export async function existingPath(path, what, kind) {
|
|
9
|
+
if (!isAbsolute(path)) {
|
|
10
|
+
throw new Error(`${what} must be an absolute local path, got "${path}".`);
|
|
11
|
+
}
|
|
12
|
+
const full = resolve(path);
|
|
13
|
+
const stat = await fs.stat(full).catch(() => undefined);
|
|
14
|
+
if (!stat || (kind === 'file' && !stat.isFile()) || (kind === 'dir' && !stat.isDirectory())) {
|
|
15
|
+
throw new Error(`${what} not found: ${full}`);
|
|
16
|
+
}
|
|
17
|
+
return full;
|
|
18
|
+
}
|
|
19
|
+
export async function isFile(path) {
|
|
20
|
+
return (await fs.stat(path).catch(() => undefined))?.isFile() === true;
|
|
21
|
+
}
|
|
22
|
+
export function sha256(data) {
|
|
23
|
+
return createHash('sha256').update(data).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
/** sha256 каждого файла; отсутствующий файл получает пометку `missing`. */
|
|
26
|
+
export async function hashFiles(files) {
|
|
27
|
+
const hashes = {};
|
|
28
|
+
for (const file of [...new Set(files)].sort()) {
|
|
29
|
+
const data = await fs.readFile(file).catch(() => undefined);
|
|
30
|
+
hashes[file] = data ? sha256(data) : 'missing';
|
|
31
|
+
}
|
|
32
|
+
return hashes;
|
|
33
|
+
}
|
|
34
|
+
/** Файлы, которые изменились, появились или исчезли с момента плана. */
|
|
35
|
+
export function changedFiles(before, after) {
|
|
36
|
+
const names = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
37
|
+
return [...names].filter(name => before[name] !== after[name]).sort();
|
|
38
|
+
}
|
|
39
|
+
const SKIPPED = new Set(['.git', 'node_modules', 'work', '.nextflow', '.snakemake', '_LAST', '__pycache__']);
|
|
40
|
+
/** Файлы пайплайнов под `root`, не глубже `depth`, не больше `limit` каждого вида; пути — относительно `root`. */
|
|
41
|
+
export async function findPipelineFiles(root, depth = 5, limit = 100) {
|
|
42
|
+
const found = { nextflow: [], wdl: [], snakemake: [] };
|
|
43
|
+
const walk = async (dir, level) => {
|
|
44
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
45
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
46
|
+
const full = join(dir, entry.name);
|
|
47
|
+
const rel = relative(root, full);
|
|
48
|
+
if (entry.isFile()) {
|
|
49
|
+
if ((entry.name === 'main.nf' || entry.name === 'nextflow.config') && found.nextflow.length < limit) {
|
|
50
|
+
found.nextflow.push(rel);
|
|
51
|
+
}
|
|
52
|
+
else if (entry.name.endsWith('.wdl') && found.wdl.length < limit) {
|
|
53
|
+
found.wdl.push(rel);
|
|
54
|
+
}
|
|
55
|
+
else if (/^[Ss]nakefile$/.test(entry.name) && found.snakemake.length < limit) {
|
|
56
|
+
found.snakemake.push(rel);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (entry.isDirectory() && level < depth && !SKIPPED.has(entry.name) && !entry.name.startsWith('.')) {
|
|
60
|
+
await walk(full, level + 1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
await walk(root, 0);
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Файл пайплайна по программе. Принимается только локальный абсолютный путь: имя вида `owner/repo` Nextflow
|
|
69
|
+
* скачал бы с git-хостинга, URL Snakefile — с веба. Папка: `main.nf` / `Snakefile`, `workflow/Snakefile`.
|
|
70
|
+
*/
|
|
71
|
+
export async function pipelineOf(kind, path) {
|
|
72
|
+
const full = await existingPath(path, '"pipeline"');
|
|
73
|
+
const stat = await fs.stat(full);
|
|
74
|
+
if (kind === 'nextflow') {
|
|
75
|
+
const file = stat.isDirectory() ? join(full, 'main.nf') : full;
|
|
76
|
+
if (!file.endsWith('.nf') || !await isFile(file)) {
|
|
77
|
+
throw new Error(`No Nextflow script at ${file}. Pass the absolute path of a local main.nf or of the pipeline directory that contains it; remote pipeline names are not accepted.`);
|
|
78
|
+
}
|
|
79
|
+
return { file, root: dirname(file) };
|
|
80
|
+
}
|
|
81
|
+
if (kind === 'miniwdl') {
|
|
82
|
+
if (!stat.isFile() || !full.endsWith('.wdl')) {
|
|
83
|
+
throw new Error(`"pipeline" must be the absolute path of a local .wdl file, got ${full}.`);
|
|
84
|
+
}
|
|
85
|
+
return { file: full, root: dirname(full) };
|
|
86
|
+
}
|
|
87
|
+
let file = full;
|
|
88
|
+
if (stat.isDirectory()) {
|
|
89
|
+
const candidates = ['Snakefile', 'snakefile', join('workflow', 'Snakefile'), join('workflow', 'snakefile')].map(name => join(full, name));
|
|
90
|
+
const found = [];
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
if (await isFile(candidate)) {
|
|
93
|
+
found.push(candidate);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!found[0]) {
|
|
97
|
+
throw new Error(`No Snakefile or workflow/Snakefile in ${full}.`);
|
|
98
|
+
}
|
|
99
|
+
file = found[0];
|
|
100
|
+
}
|
|
101
|
+
const parent = dirname(file);
|
|
102
|
+
return { file, root: basename(parent) === 'workflow' ? dirname(parent) : parent };
|
|
103
|
+
}
|
|
104
|
+
/** Каталог существует и в нём что-то есть: запуск может перезаписать файлы. */
|
|
105
|
+
export async function nonEmptyDir(path) {
|
|
106
|
+
const entries = await fs.readdir(path).catch(() => undefined);
|
|
107
|
+
return entries !== undefined && entries.length > 0;
|
|
108
|
+
}
|
package/dist/src/plan.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Copyright (c) NeuralBridge. Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
2
|
+
// Обработчик `bio_plan`: сухой прогон (`nextflow run -preview -with-dag`, `snakemake --dry-run`, `miniwdl check` + сверка
|
|
3
|
+
// входов) и сохранённый план — ровно те аргументы, которые получит программа в `bio_run`, и sha256 файлов пайплайна.
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
6
|
+
import { programOf } from './context.js';
|
|
7
|
+
import { parseMiniwdlCheck, parseNextflowDag, parseSnakemakeJobStats } from './parse.js';
|
|
8
|
+
import { existingPath, hashFiles, isFile, nonEmptyDir, pipelineOf } from './paths.js';
|
|
9
|
+
import { failure, MINUTE, programEnv, run } from './runner.js';
|
|
10
|
+
import { newId, plansDir, runPaths, savePlan } from './store.js';
|
|
11
|
+
const PLAN_TIMEOUT = 30 * MINUTE;
|
|
12
|
+
const optionalFile = async (path, what) => path ? existingPath(path, what, 'file') : undefined;
|
|
13
|
+
/** Ключи и `outdir` из файла параметров: значения могут быть секретами и не показываются. */
|
|
14
|
+
async function paramsSummary(file) {
|
|
15
|
+
const text = await fs.readFile(file, 'utf8');
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(text);
|
|
18
|
+
return { keys: Object.keys(parsed), ...typeof parsed['outdir'] === 'string' ? { outdir: parsed['outdir'] } : {} };
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
const keys = [...text.matchAll(/^([A-Za-z_][\w-]*)\s*:/gm)].map(m => m[1]);
|
|
22
|
+
const outdir = /^outdir\s*:\s*["']?([^"'\n#]+?)["']?\s*(#.*)?$/m.exec(text)?.[1];
|
|
23
|
+
return { keys, ...outdir ? { outdir } : {} };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const nextflow = async (program, a, c) => {
|
|
27
|
+
const paramsFile = await optionalFile(a.paramsFile, '"paramsFile"');
|
|
28
|
+
const workDir = a.workDir ? await existingPath(a.workDir, '"workDir"', 'dir') : undefined;
|
|
29
|
+
const common = [...paramsFile ? ['-params-file', paramsFile] : [], ...a.profile ? ['-profile', a.profile] : [], ...workDir ? ['-work-dir', workDir] : [], ...a.resume ? ['-resume'] : []];
|
|
30
|
+
const dag = join(await plansDir(c.env), `${c.id}-dag.mmd`);
|
|
31
|
+
const preview = await run(program.path, ['run', c.file, '-preview', '-with-dag', dag, ...common], { cwd: c.launchDir, timeoutMs: PLAN_TIMEOUT, env: programEnv('nextflow', c.env, { offline: a.offline }) });
|
|
32
|
+
const processes = parseNextflowDag(await fs.readFile(dag, 'utf8').catch(() => ''));
|
|
33
|
+
await fs.rm(dag, { force: true });
|
|
34
|
+
if (preview.code !== 0) {
|
|
35
|
+
throw failure('nextflow run -preview', preview);
|
|
36
|
+
}
|
|
37
|
+
const configs = [join(c.root, 'nextflow.config'), join(c.launchDir, 'nextflow.config')];
|
|
38
|
+
const files = [c.file, ...paramsFile ? [paramsFile] : []];
|
|
39
|
+
for (const config of configs) {
|
|
40
|
+
if (await isFile(config)) {
|
|
41
|
+
files.push(config);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const params = paramsFile ? await paramsSummary(paramsFile) : undefined;
|
|
45
|
+
const report = (await runPaths(c.env, c.id)).report;
|
|
46
|
+
const outputs = `${params?.outdir ? `published results: ${params.outdir} (params.outdir)` : 'published results: where the pipeline publishes them (commonly params.outdir)'}; intermediate files: ${workDir ?? join(c.launchDir, 'work')}`;
|
|
47
|
+
return {
|
|
48
|
+
args: ['run', c.file, ...common, '-with-report', report],
|
|
49
|
+
files,
|
|
50
|
+
outputs,
|
|
51
|
+
report,
|
|
52
|
+
summary: [
|
|
53
|
+
processes.length ? `Processes in the pipeline DAG: ${processes.length} (${processes.join(', ')}). The number of tasks depends on the input data.` : 'The preview did not report the processes of the pipeline.',
|
|
54
|
+
...params ? [`Parameters from ${paramsFile}: ${params.keys.join(', ') || 'none'} (values not shown).`] : [],
|
|
55
|
+
`Profile: ${a.profile ?? 'default'}. Tasks run with the executor configured in nextflow.config.`,
|
|
56
|
+
'Container images, Conda environments and plugins that the pipeline uses are downloaded by Nextflow when the run starts, unless they are already cached.',
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
const snakemake = async (program, a, c) => {
|
|
61
|
+
if (!a.cores) {
|
|
62
|
+
throw new Error('"cores" is required for Snakemake: the maximum number of CPU cores the run may use.');
|
|
63
|
+
}
|
|
64
|
+
const configFiles = await Promise.all((a.configFiles ?? []).map(f => existingPath(f, '"configFiles"', 'file')));
|
|
65
|
+
const profileDir = a.profile && /^([A-Za-z]:)?[\\/]/.test(a.profile) ? await existingPath(a.profile, '"profile"') : undefined;
|
|
66
|
+
const deploy = a.softwareDeployment ?? [];
|
|
67
|
+
const common = [...a.targets ?? [], '--snakefile', c.file, '--directory', c.launchDir, '--nocolor', '--cores', String(a.cores),
|
|
68
|
+
...configFiles.length ? ['--configfile', ...configFiles] : [], ...a.profile ? ['--profile', a.profile] : [],
|
|
69
|
+
...deploy.length ? ['--software-deployment-method', ...deploy] : []];
|
|
70
|
+
const dry = await run(program.path, [...common, '--dry-run'], { cwd: c.launchDir, timeoutMs: PLAN_TIMEOUT, env: programEnv('snakemake', c.env) });
|
|
71
|
+
if (dry.code !== 0) {
|
|
72
|
+
throw failure('snakemake --dry-run', dry);
|
|
73
|
+
}
|
|
74
|
+
const text = `${dry.stdout}\n${dry.stderr}`;
|
|
75
|
+
const stats = parseSnakemakeJobStats(text);
|
|
76
|
+
const files = [c.file, ...configFiles];
|
|
77
|
+
if (profileDir) {
|
|
78
|
+
const stat = await fs.stat(profileDir);
|
|
79
|
+
const names = stat.isDirectory() ? (await fs.readdir(profileDir)).map(n => join(profileDir, n)) : [profileDir];
|
|
80
|
+
for (const name of names) {
|
|
81
|
+
if (await isFile(name)) {
|
|
82
|
+
files.push(name);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
args: common,
|
|
88
|
+
files,
|
|
89
|
+
outputs: `${c.launchDir} (Snakemake working directory; output paths of the rules are relative to it)`,
|
|
90
|
+
summary: [
|
|
91
|
+
stats ? `Jobs to run: ${stats.total} (${stats.rules.map(r => `${r.name}: ${r.count}`).join(', ')}).` : /Nothing to be done/i.test(text) ? 'Nothing to be done: all requested outputs are present and up to date.' : 'The dry run did not print job statistics.',
|
|
92
|
+
`Cores: ${a.cores}.${a.profile ? ` Profile: ${a.profile}.` : ''}`,
|
|
93
|
+
deploy.length ? `Software deployment: ${deploy.join(', ')}. Snakemake creates Conda environments and pulls container images for the rules when the run starts.` : 'No software deployment method: rules run with the tools already on PATH.',
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
const miniwdl = async (program, a, c) => {
|
|
98
|
+
const inputs = await optionalFile(a.inputs, '"inputs"');
|
|
99
|
+
const task = a.task ? ['--task', a.task] : [];
|
|
100
|
+
const env = programEnv('miniwdl', c.env);
|
|
101
|
+
const options = { cwd: c.launchDir, timeoutMs: PLAN_TIMEOUT, env };
|
|
102
|
+
const check = await run(program.path, ['check', c.file], options);
|
|
103
|
+
const checked = parseMiniwdlCheck(c.file, check.stdout, check.stderr);
|
|
104
|
+
if (check.code !== 0) {
|
|
105
|
+
throw new Error(`miniwdl check failed:\n${checked.findings.join('\n') || (check.stderr || check.stdout).trim()}`);
|
|
106
|
+
}
|
|
107
|
+
const template = await run(program.path, ['input-template', c.file, ...task], options);
|
|
108
|
+
if (template.code !== 0) {
|
|
109
|
+
throw failure('miniwdl input-template', template);
|
|
110
|
+
}
|
|
111
|
+
const required = Object.keys(JSON.parse(template.stdout.slice(Math.max(0, template.stdout.indexOf('{')))));
|
|
112
|
+
const given = inputs ? Object.keys(JSON.parse(await fs.readFile(inputs, 'utf8'))) : [];
|
|
113
|
+
const missing = required.filter(key => !given.includes(key));
|
|
114
|
+
if (missing.length) {
|
|
115
|
+
throw new Error(`Required inputs are missing${inputs ? ` in ${inputs}` : ' (no "inputs" file given)'}: ${missing.join(', ')}.`);
|
|
116
|
+
}
|
|
117
|
+
const validated = await run(program.path, ['run', c.file, ...inputs ? ['-i', inputs] : [], ...task, '--json'], options);
|
|
118
|
+
if (validated.code !== 0) {
|
|
119
|
+
throw failure('miniwdl run --json (input check)', validated);
|
|
120
|
+
}
|
|
121
|
+
const outputDir = a.outputDir ?? c.launchDir;
|
|
122
|
+
return {
|
|
123
|
+
args: ['run', c.file, ...inputs ? ['-i', inputs] : [], ...task, '--dir', outputDir, '--no-color'],
|
|
124
|
+
files: [c.file, ...inputs ? [inputs] : []],
|
|
125
|
+
makeDir: outputDir,
|
|
126
|
+
outputs: `a new timestamp-named subdirectory under ${outputDir}`,
|
|
127
|
+
summary: [
|
|
128
|
+
`Calls in the workflow: ${checked.calls}${checked.calls ? ' (scatters may run more tasks)' : ''}. Inputs: ${given.length} given, ${required.length} required, all required present.`,
|
|
129
|
+
...checked.findings.length ? [`miniwdl check warnings: ${checked.findings.length}`] : [],
|
|
130
|
+
'Container images of the tasks are pulled by the container runtime configured for miniwdl, and input URIs are downloaded, when the run starts.',
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
const PREPARE = { nextflow, snakemake, miniwdl };
|
|
135
|
+
export async function plan(ctx, a) {
|
|
136
|
+
const program = await programOf(ctx, a.program);
|
|
137
|
+
const { file, root } = await pipelineOf(a.program, a.pipeline);
|
|
138
|
+
const launchDir = a.launchDir ? await existingPath(a.launchDir, '"launchDir"', 'dir') : root;
|
|
139
|
+
if (a.outputDir && a.program !== 'miniwdl') {
|
|
140
|
+
throw new Error('"outputDir" applies to miniwdl only. Snakemake writes outputs relative to "launchDir"; Nextflow publishes where the pipeline says (for example "outdir" in "paramsFile").');
|
|
141
|
+
}
|
|
142
|
+
if (a.outputDir && !isAbsolute(a.outputDir)) {
|
|
143
|
+
throw new Error(`"outputDir" must be an absolute local path, got "${a.outputDir}".`);
|
|
144
|
+
}
|
|
145
|
+
const outputDir = a.outputDir ? resolve(a.outputDir) : undefined;
|
|
146
|
+
const id = newId();
|
|
147
|
+
const prepared = await PREPARE[a.program](program, { ...a, ...outputDir ? { outputDir } : {} }, { id, file, root, launchDir, env: ctx.env });
|
|
148
|
+
const record = {
|
|
149
|
+
id, type: 'run', createdAt: new Date().toISOString(), program: a.program, binary: program.path, pipeline: file, launchDir,
|
|
150
|
+
args: prepared.args, offline: a.offline === true, hashes: await hashFiles(prepared.files), outputs: prepared.outputs,
|
|
151
|
+
...prepared.makeDir ? { makeDir: prepared.makeDir } : {}, ...prepared.report ? { report: prepared.report } : {},
|
|
152
|
+
};
|
|
153
|
+
await savePlan(record, ctx.env);
|
|
154
|
+
const overwrite = (a.program === 'snakemake' && await nonEmptyDir(launchDir)) || (a.program === 'nextflow' && a.resume === true)
|
|
155
|
+
? 'Existing files in the output location may be overwritten or reused.' : '';
|
|
156
|
+
return [
|
|
157
|
+
`Plan for ${a.program} ${program.version}: ${file}`,
|
|
158
|
+
`Launch directory: ${launchDir}`,
|
|
159
|
+
...prepared.summary,
|
|
160
|
+
`Outputs: ${prepared.outputs}.`,
|
|
161
|
+
...overwrite ? [overwrite] : [],
|
|
162
|
+
...a.program === 'nextflow' ? [`Network: ${a.offline ? 'offline (NXF_OFFLINE=true): Nextflow will not download pipelines or plugins; plugins must already be installed' : 'Nextflow may download plugins and the images or environments named above'}.`] : [],
|
|
163
|
+
`Command: ${a.program} ${prepared.args.join(' ')}`,
|
|
164
|
+
'',
|
|
165
|
+
`Plan id: ${id}`,
|
|
166
|
+
'To start exactly this run after the user approves it, call bio_run with this id. The run starts in the background; follow it with bio_run_status.',
|
|
167
|
+
].join('\n');
|
|
168
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Copyright (c) NeuralBridge. Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
2
|
+
// Ответ инструмента: текст или ошибка тем же текстом с `isError`, без падения сервера. Любой ответ проходит маскировку
|
|
3
|
+
// секретов (пароли в URL, пары `password=` / `token:`, заголовки авторизации) и обрезку. Используют `server.ts` и тесты.
|
|
4
|
+
export const MASK = '***';
|
|
5
|
+
/** Обрезка длинного вывода: модели хватает начала, хвост помечается. */
|
|
6
|
+
export function clip(text, max = 20_000) {
|
|
7
|
+
return text.length <= max ? text : `${text.slice(0, max)}\n… [output truncated: ${text.length - max} more characters]`;
|
|
8
|
+
}
|
|
9
|
+
const SENSITIVE_KEY = /([A-Za-z0-9_.-]*(?:password|passwd|passphrase|token|secret|api[_-]?key|access[_-]?key|private[_-]?key|credentials?))(["']?\s*[=:]\s*)(["']?)([^\s"',;&}]+)/gi;
|
|
10
|
+
/** Маскировка свободного текста: логи прогонов, вывод программ, ошибки. */
|
|
11
|
+
export function redactText(text) {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/)([^\s:/@]+):([^\s@/]+)@/gi, `$1$2:${MASK}@`)
|
|
14
|
+
.replace(/\b(Authorization\s*[:=]\s*)(Bearer|Basic|Token)\s+[^\s"']+/gi, `$1$2 ${MASK}`)
|
|
15
|
+
.replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]{16,}/g, `$1 ${MASK}`)
|
|
16
|
+
.replace(SENSITIVE_KEY, (all, key, sep, quote, value) => value === MASK || value.startsWith('<') ? all : `${key}${sep}${quote}${MASK}`);
|
|
17
|
+
}
|
|
18
|
+
export async function reply(work) {
|
|
19
|
+
try {
|
|
20
|
+
return { content: [{ type: 'text', text: clip(redactText(await work())) }] };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return { content: [{ type: 'text', text: clip(redactText(error instanceof Error ? error.message : String(error))) }], isError: true };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Copyright (c) NeuralBridge. Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
2
|
+
// Запуск nextflow, miniwdl и snakemake: поиск программы (переменные NB_BIO_*, затем PATH), версия, неинтерактивное
|
|
3
|
+
// окружение каждой программы и исполнение без оболочки с тайм-аутом. Используют все обработчики инструментов.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
export const MINUTE = 60_000;
|
|
6
|
+
export const PROGRAM_KINDS = ['nextflow', 'miniwdl', 'snakemake'];
|
|
7
|
+
export const PROGRAMS = {
|
|
8
|
+
nextflow: {
|
|
9
|
+
envVar: 'NB_BIO_NEXTFLOW',
|
|
10
|
+
// `-version` печатает баннер: «version 25.04.6 build 5954».
|
|
11
|
+
versionArgs: ['-version'],
|
|
12
|
+
parseVersion: out => /\bversion\s+(\d+\.\d+\.\d+[\w.-]*)/i.exec(out)?.[1],
|
|
13
|
+
notFound: 'nextflow was not found on PATH. Install Nextflow (https://www.nextflow.io/docs/latest/install.html), or set the NB_BIO_NEXTFLOW environment variable to its path and restart the IDE.',
|
|
14
|
+
},
|
|
15
|
+
miniwdl: {
|
|
16
|
+
envVar: 'NB_BIO_MINIWDL',
|
|
17
|
+
// `--version` печатает «miniwdl v1.12.1» и строки плагинов.
|
|
18
|
+
versionArgs: ['--version'],
|
|
19
|
+
parseVersion: out => /^miniwdl v?(\S+)/m.exec(out)?.[1],
|
|
20
|
+
notFound: 'miniwdl was not found on PATH. Install miniwdl (https://miniwdl.readthedocs.io, for example "pip install miniwdl"), or set the NB_BIO_MINIWDL environment variable to its path and restart the IDE.',
|
|
21
|
+
},
|
|
22
|
+
snakemake: {
|
|
23
|
+
envVar: 'NB_BIO_SNAKEMAKE',
|
|
24
|
+
// `--version` печатает номер версии одной строкой.
|
|
25
|
+
versionArgs: ['--version'],
|
|
26
|
+
parseVersion: out => /^\s*(\d+\.\d+[\w.+-]*)/.exec(out)?.[1],
|
|
27
|
+
notFound: 'snakemake was not found on PATH. Install Snakemake (https://snakemake.readthedocs.io/en/stable/getting_started/installation.html), or set the NB_BIO_SNAKEMAKE environment variable to its path and restart the IDE.',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Окружение запуска: без цвета. Nextflow — без ANSI-лога и без проверки новой версии; режим вывода для агентов
|
|
32
|
+
* (Nextflow 26.04+, включается сам при переменной AGENT) выключен, чтобы лог прогона был в обычном формате.
|
|
33
|
+
* `offline` — NXF_OFFLINE: Nextflow не скачивает пайплайны и плагины.
|
|
34
|
+
*/
|
|
35
|
+
export function programEnv(kind, base, options = {}) {
|
|
36
|
+
const env = { ...base, NO_COLOR: '1' };
|
|
37
|
+
if (kind === 'nextflow') {
|
|
38
|
+
env['NXF_ANSI_LOG'] = 'false';
|
|
39
|
+
env['NXF_DISABLE_CHECK_LATEST'] = 'true';
|
|
40
|
+
env['NXF_AGENT_MODE'] = 'false';
|
|
41
|
+
if (options.offline) {
|
|
42
|
+
env['NXF_OFFLINE'] = 'true';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return env;
|
|
46
|
+
}
|
|
47
|
+
export function run(binary, args, options) {
|
|
48
|
+
return new Promise(resolve => {
|
|
49
|
+
const child = execFile(binary, [...args], {
|
|
50
|
+
cwd: options.cwd,
|
|
51
|
+
timeout: options.timeoutMs,
|
|
52
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
env: options.env,
|
|
55
|
+
}, (error, stdout, stderr) => {
|
|
56
|
+
const failure = error;
|
|
57
|
+
const code = failure ? (typeof failure.code === 'number' ? failure.code : -1) : 0;
|
|
58
|
+
const missing = failure?.code === 'ENOENT' || failure?.code === 'EACCES';
|
|
59
|
+
resolve({
|
|
60
|
+
code,
|
|
61
|
+
stdout: String(stdout ?? ''),
|
|
62
|
+
stderr: missing ? `${binary}: command not found` : String(stderr ?? ''),
|
|
63
|
+
timedOut: failure?.killed === true && failure.signal === 'SIGTERM',
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
// Вопросы в stdin ответа не получат: поток закрыт сразу.
|
|
67
|
+
child.stdin?.end();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Программа из переменной окружения или с PATH; `undefined` — не найдена или не отвечает на запрос версии. */
|
|
71
|
+
export async function resolveProgram(kind, env) {
|
|
72
|
+
const spec = PROGRAMS[kind];
|
|
73
|
+
const path = env[spec.envVar]?.trim() || kind;
|
|
74
|
+
// Запуск лаунчера Nextflow в первый раз скачивает его runtime: тайм-аут с запасом.
|
|
75
|
+
const result = await run(path, spec.versionArgs, { timeoutMs: 3 * MINUTE, env: programEnv(kind, env) });
|
|
76
|
+
const version = result.code === 0 ? spec.parseVersion(`${result.stdout}\n${result.stderr}`) : undefined;
|
|
77
|
+
return version ? { kind, path, version } : undefined;
|
|
78
|
+
}
|
|
79
|
+
/** Сравнение версий вида 25.04.6: отрицательное — `a` раньше `b`, ноль — равны. */
|
|
80
|
+
export function compareVersions(a, b) {
|
|
81
|
+
const pa = a.split(/[.-]/).map(n => Number.parseInt(n, 10) || 0);
|
|
82
|
+
const pb = b.split(/[.-]/).map(n => Number.parseInt(n, 10) || 0);
|
|
83
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
84
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
85
|
+
if (diff !== 0) {
|
|
86
|
+
return diff;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
/** Текст неудачного запуска: код, тайм-аут, вывод. */
|
|
92
|
+
export function failure(step, result) {
|
|
93
|
+
const reason = result.timedOut ? 'timed out' : `exited with code ${result.code}`;
|
|
94
|
+
return new Error(`${step} ${reason}.\n${(result.stderr || result.stdout).trim()}`);
|
|
95
|
+
}
|
package/dist/src/runs.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Copyright (c) NeuralBridge. Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
2
|
+
// Прогоны: `bio_run` — сверка плана и запуск в фоне (отвязанный процесс, лог 0600, PID в записи прогона), состояние
|
|
3
|
+
// и хвост лога, план и применение остановки (SIGTERM), список прогонов. Регистрация в MCP — `server.ts`.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { closeSync, openSync, promises as fs } from 'node:fs';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { programOf } from './context.js';
|
|
8
|
+
import { inferOutcome, nextflowProgress, snakemakeProgress } from './parse.js';
|
|
9
|
+
import { changedFiles, existingPath, hashFiles } from './paths.js';
|
|
10
|
+
import { programEnv } from './runner.js';
|
|
11
|
+
import { dropPlan, dropStaleRuns, listRuns, loadPlan, loadRun, newId, runPaths, saveRun, savePlan, updateRun } from './store.js';
|
|
12
|
+
const TAIL_LINES = 100;
|
|
13
|
+
const TAIL_BYTES = 256 * 1024;
|
|
14
|
+
/** Сколько ждать события `exit` у своего прогона, который уже не жив. */
|
|
15
|
+
const EXIT_WAIT_MS = 3000;
|
|
16
|
+
/** Процесс жив; на Linux заодно проверяется, что PID не занят чужим процессом (в командной строке — файл пайплайна). */
|
|
17
|
+
export async function isAlive(record) {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(record.pid, 0);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code !== 'EPERM') {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const cmdline = await fs.readFile(`/proc/${record.pid}/cmdline`, 'utf8').catch(() => undefined);
|
|
27
|
+
return cmdline === undefined || cmdline.includes(record.pipeline);
|
|
28
|
+
}
|
|
29
|
+
function exitText(exit) {
|
|
30
|
+
const how = exit.code === 0 ? 'succeeded (exit code 0)' : exit.code !== null ? `failed (exit code ${exit.code})` : `stopped by signal ${exit.signal ?? 'unknown'}`;
|
|
31
|
+
return { running: false, text: `${how}, finished ${exit.at}` };
|
|
32
|
+
}
|
|
33
|
+
/** Исход своего прогона, если событие `exit` придёт за `EXIT_WAIT_MS`. */
|
|
34
|
+
async function awaitExit(ctx, id) {
|
|
35
|
+
const pending = ctx.children.get(id);
|
|
36
|
+
if (!pending) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const stop = new AbortController();
|
|
40
|
+
try {
|
|
41
|
+
return await Promise.race([pending, delay(EXIT_WAIT_MS, undefined, { signal: stop.signal }).catch(() => undefined)]);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
stop.abort();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Состояние прогона: сначала исход, известный этому процессу сервера, затем запись на диске, затем PID и лог. */
|
|
48
|
+
async function stateOf(ctx, record, log) {
|
|
49
|
+
const exit = ctx.exits.get(record.id) ?? (record.finishedAt ? { code: record.exitCode ?? null, signal: record.signal ?? null, at: record.finishedAt } : undefined);
|
|
50
|
+
if (exit) {
|
|
51
|
+
return exitText(exit);
|
|
52
|
+
}
|
|
53
|
+
if (await isAlive(record)) {
|
|
54
|
+
return { running: true, text: `running (PID ${record.pid})${record.stopRequestedAt ? `, stop requested ${record.stopRequestedAt}` : ''}` };
|
|
55
|
+
}
|
|
56
|
+
// Свой прогон уже снят, а `exit` ещё не пришёл (сразу после SIGTERM или быстрого падения) — подождать его.
|
|
57
|
+
const late = await awaitExit(ctx, record.id);
|
|
58
|
+
if (late) {
|
|
59
|
+
return exitText(late);
|
|
60
|
+
}
|
|
61
|
+
const guess = inferOutcome(record.program, log);
|
|
62
|
+
return { running: false, text: `finished; exit code unknown (the server restarted during the run)${guess ? `, the log suggests it ${guess}` : ''}` };
|
|
63
|
+
}
|
|
64
|
+
async function tailOf(file) {
|
|
65
|
+
const handle = await fs.open(file, 'r').catch(() => undefined);
|
|
66
|
+
if (!handle) {
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const { size } = await handle.stat();
|
|
71
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
72
|
+
const buffer = Buffer.alloc(length);
|
|
73
|
+
await handle.read(buffer, 0, length, size - length);
|
|
74
|
+
return buffer.toString('utf8');
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
await handle.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function duration(from, to = new Date().toISOString()) {
|
|
81
|
+
const seconds = Math.max(0, Math.round((Date.parse(to) - Date.parse(from)) / 1000));
|
|
82
|
+
const h = Math.floor(seconds / 3600);
|
|
83
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
84
|
+
return `${h ? `${h}h ` : ''}${h || m ? `${m}m ` : ''}${seconds % 60}s`;
|
|
85
|
+
}
|
|
86
|
+
export async function startRun(ctx, args) {
|
|
87
|
+
const plan = await loadPlan(args.planId, 'run', ctx.env);
|
|
88
|
+
const program = await programOf(ctx, plan.program);
|
|
89
|
+
const problems = [];
|
|
90
|
+
if (program.path !== plan.binary) {
|
|
91
|
+
problems.push(`${plan.program} now resolves to ${program.path}, the plan was made with ${plan.binary}`);
|
|
92
|
+
}
|
|
93
|
+
await existingPath(plan.launchDir, 'Launch directory', 'dir').catch((e) => problems.push(e.message));
|
|
94
|
+
const changed = changedFiles(plan.hashes, await hashFiles(Object.keys(plan.hashes)));
|
|
95
|
+
if (changed.length) {
|
|
96
|
+
problems.push(`files changed since the plan: ${changed.join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
// План одноразовый: и после запуска, и после отказа он больше не годится.
|
|
99
|
+
await dropPlan(plan.id, ctx.env);
|
|
100
|
+
if (problems.length) {
|
|
101
|
+
throw new Error(`The plan no longer matches the project, nothing was started: ${problems.join('; ')}. Run bio_plan again and show the new plan to the user.`);
|
|
102
|
+
}
|
|
103
|
+
await cleanupRuns(ctx);
|
|
104
|
+
if (plan.makeDir) {
|
|
105
|
+
await fs.mkdir(plan.makeDir, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
const paths = await runPaths(ctx.env, plan.id);
|
|
108
|
+
const fd = openSync(paths.log, 'a', 0o600);
|
|
109
|
+
await fs.chmod(paths.log, 0o600).catch(() => undefined);
|
|
110
|
+
const child = spawn(plan.binary, [...plan.args], {
|
|
111
|
+
cwd: plan.launchDir,
|
|
112
|
+
env: programEnv(plan.program, ctx.env, { offline: plan.offline }),
|
|
113
|
+
detached: true,
|
|
114
|
+
stdio: ['ignore', fd, fd],
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
});
|
|
117
|
+
// Обработчик выхода — сразу: короткий прогон может завершиться раньше, чем запись прогона ляжет на диск.
|
|
118
|
+
let saved = false;
|
|
119
|
+
let settle = () => undefined;
|
|
120
|
+
ctx.children.set(plan.id, new Promise(resolve => { settle = resolve; }));
|
|
121
|
+
child.on('exit', (code, signal) => {
|
|
122
|
+
const at = new Date().toISOString();
|
|
123
|
+
ctx.exits.set(plan.id, { code, signal, at });
|
|
124
|
+
ctx.children.delete(plan.id);
|
|
125
|
+
settle({ code, signal, at });
|
|
126
|
+
if (saved) {
|
|
127
|
+
void updateRun(plan.id, { finishedAt: at, exitCode: code, signal }, ctx.env).catch(() => undefined);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
await new Promise((resolve, reject) => {
|
|
132
|
+
child.once('spawn', resolve);
|
|
133
|
+
child.once('error', reject);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
ctx.children.delete(plan.id);
|
|
138
|
+
throw new Error(`Could not start ${plan.binary}: ${error.message}`);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
closeSync(fd);
|
|
142
|
+
}
|
|
143
|
+
// Поздние ошибки процесса не должны ронять сервер.
|
|
144
|
+
child.on('error', () => undefined);
|
|
145
|
+
const record = {
|
|
146
|
+
id: plan.id, program: plan.program, pipeline: plan.pipeline, launchDir: plan.launchDir, args: plan.args, outputs: plan.outputs,
|
|
147
|
+
pid: child.pid, startedAt: new Date().toISOString(), log: paths.log, ...plan.report ? { report: plan.report } : {},
|
|
148
|
+
};
|
|
149
|
+
await saveRun(record, ctx.env);
|
|
150
|
+
saved = true;
|
|
151
|
+
const early = ctx.exits.get(plan.id);
|
|
152
|
+
if (early) {
|
|
153
|
+
await updateRun(plan.id, { finishedAt: early.at, exitCode: early.code, signal: early.signal }, ctx.env);
|
|
154
|
+
}
|
|
155
|
+
child.unref();
|
|
156
|
+
return [
|
|
157
|
+
`Started ${plan.program} in the background (PID ${record.pid}).`,
|
|
158
|
+
`Run id: ${record.id}`,
|
|
159
|
+
`Log: ${paths.log}`,
|
|
160
|
+
...plan.report ? [`Execution report (written when the run ends): ${plan.report}`] : [],
|
|
161
|
+
`Outputs: ${plan.outputs}.`,
|
|
162
|
+
'Call bio_run_status with the run id to follow progress.',
|
|
163
|
+
].join('\n');
|
|
164
|
+
}
|
|
165
|
+
export async function runStatus(ctx, args) {
|
|
166
|
+
const record = await loadRun(args.runId, ctx.env);
|
|
167
|
+
const log = await tailOf(record.log);
|
|
168
|
+
const state = await stateOf(ctx, record, log);
|
|
169
|
+
const progress = record.program === 'nextflow' ? nextflowProgress(log) : record.program === 'snakemake' ? snakemakeProgress(log) : [];
|
|
170
|
+
const tail = log.split('\n').slice(-TAIL_LINES - 1).join('\n').trim();
|
|
171
|
+
return [
|
|
172
|
+
`Run ${record.id}: ${record.program} ${record.pipeline}`,
|
|
173
|
+
`State: ${state.text}`,
|
|
174
|
+
`Started: ${record.startedAt} (${duration(record.startedAt, record.finishedAt)} ${state.running ? 'so far' : 'total'})`,
|
|
175
|
+
`Outputs: ${record.outputs}.`,
|
|
176
|
+
...record.report ? [`Report: ${record.report}`] : [],
|
|
177
|
+
...progress.length ? ['Progress:', ...progress.map(line => ` ${line}`)] : [],
|
|
178
|
+
'',
|
|
179
|
+
`Last ${TAIL_LINES} lines of ${record.log}:`,
|
|
180
|
+
tail || '(log is empty)',
|
|
181
|
+
].join('\n');
|
|
182
|
+
}
|
|
183
|
+
export async function stopPlan(ctx, args) {
|
|
184
|
+
const record = await loadRun(args.runId, ctx.env);
|
|
185
|
+
const state = await stateOf(ctx, record, await tailOf(record.log));
|
|
186
|
+
if (!state.running) {
|
|
187
|
+
throw new Error(`Run ${record.id} is not running: ${state.text}.`);
|
|
188
|
+
}
|
|
189
|
+
const plan = { id: newId(), type: 'stop', createdAt: new Date().toISOString(), runId: record.id, pid: record.pid, startedAt: record.startedAt };
|
|
190
|
+
await savePlan(plan, ctx.env);
|
|
191
|
+
const progress = record.program === 'nextflow' ? nextflowProgress(await tailOf(record.log)) : snakemakeProgress(await tailOf(record.log));
|
|
192
|
+
return [
|
|
193
|
+
`Stop ${record.program} run ${record.id}: ${record.pipeline}`,
|
|
194
|
+
`PID ${record.pid}, running for ${duration(record.startedAt)} (since ${record.startedAt}).`,
|
|
195
|
+
...progress.length ? ['Progress:', ...progress.map(line => ` ${line}`)] : [],
|
|
196
|
+
`bio_run_stop_apply sends SIGTERM to this process. ${record.program === 'nextflow' ? 'Nextflow cancels its running tasks before it exits' : 'The program stops its running jobs'}; work already done is lost unless the pipeline can resume.`,
|
|
197
|
+
'',
|
|
198
|
+
`Plan id: ${plan.id}`,
|
|
199
|
+
'Call bio_run_stop_apply with this id only after the user confirms.',
|
|
200
|
+
].join('\n');
|
|
201
|
+
}
|
|
202
|
+
export async function stopApply(ctx, args) {
|
|
203
|
+
const plan = await loadPlan(args.planId, 'stop', ctx.env);
|
|
204
|
+
await dropPlan(plan.id, ctx.env);
|
|
205
|
+
const record = await loadRun(plan.runId, ctx.env);
|
|
206
|
+
if (record.pid !== plan.pid || record.startedAt !== plan.startedAt) {
|
|
207
|
+
throw new Error(`Run ${record.id} is not the process the stop plan was made for. Nothing was stopped.`);
|
|
208
|
+
}
|
|
209
|
+
const state = await stateOf(ctx, record, await tailOf(record.log));
|
|
210
|
+
if (!state.running) {
|
|
211
|
+
throw new Error(`Run ${record.id} is no longer running: ${state.text}. Nothing was stopped.`);
|
|
212
|
+
}
|
|
213
|
+
process.kill(record.pid, 'SIGTERM');
|
|
214
|
+
await updateRun(record.id, { stopRequestedAt: new Date().toISOString() }, ctx.env);
|
|
215
|
+
return `SIGTERM sent to ${record.program} (PID ${record.pid}). It may take a while to cancel running tasks; check bio_run_status with run id ${record.id}.`;
|
|
216
|
+
}
|
|
217
|
+
/** Чистка записей старше недели; возвращает id идущих прогонов. */
|
|
218
|
+
async function cleanupRuns(ctx) {
|
|
219
|
+
const running = new Set();
|
|
220
|
+
for (const record of await listRuns(ctx.env)) {
|
|
221
|
+
if (!ctx.exits.has(record.id) && !record.finishedAt && await isAlive(record)) {
|
|
222
|
+
running.add(record.id);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
await dropStaleRuns(ctx.env, record => running.has(record.id));
|
|
226
|
+
return running;
|
|
227
|
+
}
|
|
228
|
+
export async function runsList(ctx) {
|
|
229
|
+
const running = await cleanupRuns(ctx);
|
|
230
|
+
const records = await listRuns(ctx.env);
|
|
231
|
+
if (!records.length) {
|
|
232
|
+
return 'No runs recorded by this server (records of finished runs are kept for 7 days).';
|
|
233
|
+
}
|
|
234
|
+
const rows = await Promise.all(records.map(async (record) => {
|
|
235
|
+
const state = await stateOf(ctx, record, running.has(record.id) ? '' : await tailOf(record.log));
|
|
236
|
+
return `${record.id} ${record.program} ${state.text} started ${record.startedAt} ${record.pipeline}`;
|
|
237
|
+
}));
|
|
238
|
+
return rows.join('\n');
|
|
239
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Copyright (c) NeuralBridge. Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
2
|
+
// MCP-сервер power `nb-bio-pipelines`: инструменты Nextflow, miniwdl и Snakemake с описаниями и пометками для клиента.
|
|
3
|
+
// «Только чтение» — у поиска программ, состояния и списка прогонов; lint Snakemake исполняет Snakefile, план — код пайплайна.
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { createContext } from './context.js';
|
|
7
|
+
import { detect, lint } from './inspect.js';
|
|
8
|
+
import { plan } from './plan.js';
|
|
9
|
+
import { reply } from './reply.js';
|
|
10
|
+
import { runsList, runStatus, startRun, stopApply, stopPlan } from './runs.js';
|
|
11
|
+
export const SERVER_NAME = 'nb-bio-pipelines';
|
|
12
|
+
export const SERVER_VERSION = '1.0.0';
|
|
13
|
+
const program = z.enum(['nextflow', 'snakemake', 'miniwdl']);
|
|
14
|
+
const absolute = (what) => z.string().min(1).describe(`Absolute local path of ${what}.`);
|
|
15
|
+
const planId = z.string().describe('Plan id returned by the matching plan tool.');
|
|
16
|
+
const runId = z.string().describe('Run id returned by bio_run.');
|
|
17
|
+
export function createServer(env = process.env) {
|
|
18
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
19
|
+
const ctx = createContext(env);
|
|
20
|
+
server.registerTool('bio_detect', {
|
|
21
|
+
title: 'Detect pipeline tools and pipelines',
|
|
22
|
+
description: 'Report which of nextflow, miniwdl and snakemake are installed and their versions. With "dir", also list main.nf, nextflow.config, .wdl files and Snakefiles under it. Read-only. The very first call of the nextflow launcher downloads the Nextflow runtime into ~/.nextflow; that is how Nextflow installs itself.',
|
|
23
|
+
inputSchema: { dir: absolute('the project directory to scan').optional() },
|
|
24
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
25
|
+
}, args => reply(() => detect(ctx, args)));
|
|
26
|
+
server.registerTool('bio_lint', {
|
|
27
|
+
title: 'Lint pipelines',
|
|
28
|
+
description: 'Static checks with file and line: "nextflow lint" (Nextflow 25.04 or newer; skipped with a note for older versions), "miniwdl check" for each .wdl file, "snakemake --lint" for each Snakefile. "path" is a file or a directory. Does not run any task, but Snakemake evaluates the Snakefile (Python code of the project) to lint it.',
|
|
29
|
+
inputSchema: {
|
|
30
|
+
path: absolute('a pipeline file or directory'),
|
|
31
|
+
program: program.optional().describe('Lint only files of this tool.'),
|
|
32
|
+
},
|
|
33
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
34
|
+
}, args => reply(() => lint(ctx, args)));
|
|
35
|
+
server.registerTool('bio_plan', {
|
|
36
|
+
title: 'Dry-run and plan a pipeline run',
|
|
37
|
+
description: 'Check a run without executing tasks and save it as a plan: Nextflow "run -preview -with-dag" (processes in the DAG), Snakemake "--dry-run" (jobs per rule), miniwdl "check" plus a check of the inputs JSON against the required inputs. Returns what will run, where outputs go, what the tools will download, the exact command and a plan id for bio_run. Only local pipelines: "pipeline" must be an absolute path, remote pipeline names are not accepted. Evaluates the pipeline code and may write tool metadata (.nextflow, .snakemake) in the launch directory; runs no tasks.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
program,
|
|
40
|
+
pipeline: absolute('main.nf or its directory (Nextflow), a Snakefile or its project directory (Snakemake), or a .wdl file (miniwdl)'),
|
|
41
|
+
launchDir: absolute('the directory to run in (Snakemake: --directory, where outputs are written). Default: the pipeline directory').optional(),
|
|
42
|
+
paramsFile: absolute('a JSON or YAML params file (Nextflow -params-file)').optional(),
|
|
43
|
+
profile: z.string().regex(/^[A-Za-z0-9_./\\:][A-Za-z0-9_.,/\\:-]*$/, 'not a profile name').optional().describe('Nextflow: -profile name(s), comma-separated. Snakemake: --profile name or absolute path.'),
|
|
44
|
+
workDir: absolute('the Nextflow work directory (-work-dir). Default: work/ in the launch directory').optional(),
|
|
45
|
+
resume: z.boolean().optional().describe('Nextflow: -resume, reuse cached results of a previous run.'),
|
|
46
|
+
offline: z.boolean().optional().describe('Nextflow: set NXF_OFFLINE=true, so Nextflow downloads no pipelines or plugins (plugins must already be installed).'),
|
|
47
|
+
configFiles: z.array(absolute('a Snakemake config file')).optional().describe('Snakemake: --configfile files.'),
|
|
48
|
+
cores: z.number().int().min(1).optional().describe('Snakemake: --cores, required for Snakemake.'),
|
|
49
|
+
softwareDeployment: z.array(z.enum(['conda', 'apptainer', 'env-modules'])).optional().describe('Snakemake 8+: --software-deployment-method. Off unless the user asks: Snakemake then creates Conda environments or pulls images.'),
|
|
50
|
+
targets: z.array(z.string().regex(/^[^-\s]\S*$/, 'not a target')).optional().describe('Snakemake: rules or files to build. Default: the first rule.'),
|
|
51
|
+
inputs: absolute('the inputs JSON (miniwdl -i)').optional(),
|
|
52
|
+
outputDir: absolute('the directory under which miniwdl creates the run directory (--dir); miniwdl only. Default: the launch directory').optional(),
|
|
53
|
+
task: z.string().regex(/^[A-Za-z_][\w]*$/, 'not a task name').optional().describe('miniwdl: --task, for documents with several tasks and no workflow.'),
|
|
54
|
+
},
|
|
55
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
56
|
+
}, args => reply(() => plan(ctx, args)));
|
|
57
|
+
server.registerTool('bio_run', {
|
|
58
|
+
title: 'Start a reviewed pipeline run',
|
|
59
|
+
description: 'Start exactly the run saved by bio_plan, in the background, and return a run id at once. Checks first that the pipeline, config, params and inputs files are unchanged. Runs long computations, writes results and may overwrite existing outputs; container images and Conda environments are pulled by the tool when needed, and tasks run with the executor configured for the pipeline. Call only after showing the plan to the user and getting explicit approval. A plan id works once.',
|
|
60
|
+
inputSchema: { planId },
|
|
61
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
62
|
+
}, args => reply(() => startRun(ctx, args)));
|
|
63
|
+
server.registerTool('bio_run_status', {
|
|
64
|
+
title: 'Run status',
|
|
65
|
+
description: 'State of a run started by bio_run: running or finished, exit code, duration, progress (Nextflow processes, Snakemake steps) and the last 100 lines of its log with secrets masked. Read-only.',
|
|
66
|
+
inputSchema: { runId },
|
|
67
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
68
|
+
}, args => reply(() => runStatus(ctx, args)));
|
|
69
|
+
server.registerTool('bio_run_stop_plan', {
|
|
70
|
+
title: 'Plan stopping a run',
|
|
71
|
+
description: 'Show what is running for a run id and for how long, and save a stop plan. Stops nothing.',
|
|
72
|
+
inputSchema: { runId },
|
|
73
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
74
|
+
}, args => reply(() => stopPlan(ctx, args)));
|
|
75
|
+
server.registerTool('bio_run_stop_apply', {
|
|
76
|
+
title: 'Stop a run',
|
|
77
|
+
description: 'Send SIGTERM to the run saved in a stop plan; the tool cancels its running tasks and exits. Call only after showing the plan to the user and getting explicit approval. A plan id works once.',
|
|
78
|
+
inputSchema: { planId },
|
|
79
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
80
|
+
}, args => reply(() => stopApply(ctx, args)));
|
|
81
|
+
server.registerTool('bio_runs', {
|
|
82
|
+
title: 'List runs',
|
|
83
|
+
description: 'Runs started through this server with their state, newest first. Records of finished runs and their logs are kept for 7 days. Read-only.',
|
|
84
|
+
inputSchema: {},
|
|
85
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
86
|
+
}, () => reply(() => runsList(ctx)));
|
|
87
|
+
return server;
|
|
88
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Copyright (c) NeuralBridge. Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
2
|
+
// Данные сервера в `PLUGIN_DATA` (или во временной папке): планы `plans/<id>.json` — одноразовые, живут сутки;
|
|
3
|
+
// прогоны `runs/<id>.json` с логом `runs/<id>.log` и отчётом — неделю после завершения. Папки 0700, файлы 0600.
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
export const PLAN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
export const RUN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
10
|
+
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
11
|
+
export function dataRoot(env) {
|
|
12
|
+
return env['PLUGIN_DATA'] || join(tmpdir(), 'nb-bio-pipelines');
|
|
13
|
+
}
|
|
14
|
+
/** Папка только владельцу, даже если уже была создана с другими правами. */
|
|
15
|
+
async function ensureDir(path) {
|
|
16
|
+
await fs.mkdir(path, { recursive: true, mode: 0o700 });
|
|
17
|
+
await fs.chmod(path, 0o700).catch(() => undefined);
|
|
18
|
+
return path;
|
|
19
|
+
}
|
|
20
|
+
export async function plansDir(env) {
|
|
21
|
+
await ensureDir(dataRoot(env));
|
|
22
|
+
return ensureDir(join(dataRoot(env), 'plans'));
|
|
23
|
+
}
|
|
24
|
+
export async function runsDir(env) {
|
|
25
|
+
await ensureDir(dataRoot(env));
|
|
26
|
+
return ensureDir(join(dataRoot(env), 'runs'));
|
|
27
|
+
}
|
|
28
|
+
export function newId() {
|
|
29
|
+
return randomUUID();
|
|
30
|
+
}
|
|
31
|
+
/** Старые планы и временные файлы планов (`<id>*`) удаляются при каждом новом плане. */
|
|
32
|
+
async function dropStalePlans(dir, now) {
|
|
33
|
+
const names = await fs.readdir(dir).catch(() => []);
|
|
34
|
+
await Promise.all(names.filter(name => ID.test(name.slice(0, 36))).map(async (name) => {
|
|
35
|
+
const stat = await fs.stat(join(dir, name)).catch(() => undefined);
|
|
36
|
+
if (stat && now - stat.mtimeMs > PLAN_TTL_MS) {
|
|
37
|
+
await fs.rm(join(dir, name), { force: true, recursive: true });
|
|
38
|
+
}
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
export async function savePlan(plan, env, now = Date.now()) {
|
|
42
|
+
const dir = await plansDir(env);
|
|
43
|
+
await dropStalePlans(dir, now);
|
|
44
|
+
await fs.writeFile(join(dir, `${plan.id}.json`), JSON.stringify(plan, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
45
|
+
}
|
|
46
|
+
const TOOL_OF = { run: 'bio_plan', stop: 'bio_run_stop_plan' };
|
|
47
|
+
/** План по id и виду: план остановки нельзя передать в запуск и наоборот. */
|
|
48
|
+
export async function loadPlan(id, type, env, now = Date.now()) {
|
|
49
|
+
if (!ID.test(id)) {
|
|
50
|
+
throw new Error(`Unknown plan id "${id}".`);
|
|
51
|
+
}
|
|
52
|
+
const file = join(await plansDir(env), `${id}.json`);
|
|
53
|
+
const raw = await fs.readFile(file, 'utf8').catch(() => undefined);
|
|
54
|
+
const plan = raw ? JSON.parse(raw) : undefined;
|
|
55
|
+
if (!plan || now - Date.parse(plan.createdAt) > PLAN_TTL_MS) {
|
|
56
|
+
await fs.rm(file, { force: true });
|
|
57
|
+
throw new Error(`Plan "${id}" was not found (plans are used once and expire after 24 hours). Run ${TOOL_OF[type]} first.`);
|
|
58
|
+
}
|
|
59
|
+
if (plan.type !== type) {
|
|
60
|
+
throw new Error(`Plan "${id}" was made by ${TOOL_OF[plan.type]}, not by ${TOOL_OF[type]}.`);
|
|
61
|
+
}
|
|
62
|
+
return plan;
|
|
63
|
+
}
|
|
64
|
+
export async function dropPlan(id, env) {
|
|
65
|
+
const dir = await plansDir(env);
|
|
66
|
+
const names = await fs.readdir(dir).catch(() => []);
|
|
67
|
+
await Promise.all(names.filter(name => name.startsWith(id)).map(name => fs.rm(join(dir, name), { force: true, recursive: true })));
|
|
68
|
+
}
|
|
69
|
+
export async function runPaths(env, id) {
|
|
70
|
+
const dir = await runsDir(env);
|
|
71
|
+
return { json: join(dir, `${id}.json`), log: join(dir, `${id}.log`), report: join(dir, `${id}-report.html`) };
|
|
72
|
+
}
|
|
73
|
+
/** Запись прогона целиком: во временный файл и переименованием — читатель не увидит наполовину записанный файл. */
|
|
74
|
+
export async function saveRun(record, env) {
|
|
75
|
+
const { json } = await runPaths(env, record.id);
|
|
76
|
+
const tmp = `${json}.${randomUUID()}.tmp`;
|
|
77
|
+
try {
|
|
78
|
+
await fs.writeFile(tmp, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
79
|
+
await fs.rename(tmp, json);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
await fs.rm(tmp, { force: true });
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Правки одной записи по очереди: выход процесса и запрос остановки не затирают друг друга. */
|
|
87
|
+
const updates = new Map();
|
|
88
|
+
export async function loadRun(id, env) {
|
|
89
|
+
if (!ID.test(id)) {
|
|
90
|
+
throw new Error(`Unknown run id "${id}".`);
|
|
91
|
+
}
|
|
92
|
+
const { json } = await runPaths(env, id);
|
|
93
|
+
const raw = await fs.readFile(json, 'utf8').catch(() => undefined);
|
|
94
|
+
if (!raw) {
|
|
95
|
+
throw new Error(`Run "${id}" was not found. Records of finished runs are kept for 7 days; see bio_runs.`);
|
|
96
|
+
}
|
|
97
|
+
return JSON.parse(raw);
|
|
98
|
+
}
|
|
99
|
+
export async function updateRun(id, patch, env) {
|
|
100
|
+
const next = (updates.get(id) ?? Promise.resolve()).then(async () => {
|
|
101
|
+
const record = await loadRun(id, env).catch(() => undefined);
|
|
102
|
+
if (record) {
|
|
103
|
+
await saveRun({ ...record, ...patch }, env);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
const tail = next.catch(() => undefined);
|
|
107
|
+
updates.set(id, tail);
|
|
108
|
+
void tail.then(() => updates.get(id) === tail && updates.delete(id));
|
|
109
|
+
return next;
|
|
110
|
+
}
|
|
111
|
+
export async function listRuns(env) {
|
|
112
|
+
const dir = await runsDir(env);
|
|
113
|
+
const names = (await fs.readdir(dir).catch(() => [])).filter(name => ID.test(name.slice(0, 36)) && name.length === 41 && name.endsWith('.json'));
|
|
114
|
+
const records = await Promise.all(names.map(name => loadRun(name.slice(0, 36), env).catch(() => undefined)));
|
|
115
|
+
return records.filter((r) => r !== undefined).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
116
|
+
}
|
|
117
|
+
/** Записи завершённых прогонов старше недели удаляются вместе с логом и отчётом; идущие прогоны не трогаются. */
|
|
118
|
+
export async function dropStaleRuns(env, isRunning, now = Date.now()) {
|
|
119
|
+
for (const record of await listRuns(env)) {
|
|
120
|
+
const age = now - Date.parse(record.finishedAt ?? record.startedAt);
|
|
121
|
+
if (age > RUN_TTL_MS && !isRunning(record)) {
|
|
122
|
+
const paths = await runPaths(env, record.id);
|
|
123
|
+
await Promise.all([paths.json, paths.log, paths.report].map(file => fs.rm(file, { force: true })));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neuralbridge/bio-pipelines-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "MCP server
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for bioinformatics pipelines with Nextflow, miniwdl and Snakemake: detect, lint, dry-run plan, start a reviewed run in the background, follow and stop it.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"author": "NeuralBridge
|
|
6
|
+
"author": "NeuralBridge",
|
|
7
7
|
"homepage": "https://neuralbridge.ru",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
@@ -12,14 +12,36 @@
|
|
|
12
12
|
},
|
|
13
13
|
"keywords": [
|
|
14
14
|
"mcp",
|
|
15
|
+
"nextflow",
|
|
16
|
+
"snakemake",
|
|
17
|
+
"miniwdl",
|
|
18
|
+
"wdl",
|
|
19
|
+
"bioinformatics",
|
|
15
20
|
"neuralbridge"
|
|
16
21
|
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"nb-bio-pipelines-mcp": "dist/src/index.js"
|
|
25
|
+
},
|
|
17
26
|
"files": [
|
|
27
|
+
"dist/src",
|
|
18
28
|
"README.md",
|
|
19
29
|
"LICENSE",
|
|
20
30
|
"NOTICE"
|
|
21
31
|
],
|
|
22
|
-
"
|
|
23
|
-
"
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.json",
|
|
37
|
+
"test": "npm run build && node --test \"dist/test/*.test.js\""
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "1.32.2",
|
|
41
|
+
"zod": "4.6.7"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "26.6.6",
|
|
45
|
+
"typescript": "7.0.2"
|
|
24
46
|
}
|
|
25
47
|
}
|