| import subprocess |
| import tempfile |
| from pathlib import Path |
| from typing import List, Optional |
|
|
| |
| |
| def tool(func): |
| """A dummy decorator to stand in for @mcp.tool().""" |
| return func |
|
|
| mcp = type("mcp", (), {"tool": tool}) |
|
|
|
|
| from mcp.server.fastmcp import FastMCP |
|
|
| SERVER_NAME = 'local_bedtools' |
| mcp = FastMCP(SERVER_NAME) |
|
|
| @mcp.tool() |
| def intersect( |
| a: Path, |
| b: List[Path], |
| output: Optional[Path] = None, |
| wa: bool = False, |
| wb: bool = False, |
| loj: bool = False, |
| wo: bool = False, |
| wao: bool = False, |
| u: bool = False, |
| c: bool = False, |
| v: bool = False, |
| ubam: bool = False, |
| s: bool = False, |
| S: bool = False, |
| f: float = 1e-9, |
| F: float = 1e-9, |
| r: bool = False, |
| e: bool = False, |
| split: bool = False, |
| g: Optional[Path] = None, |
| header: bool = False, |
| bed: bool = False, |
| sorted: bool = False, |
| names: Optional[str] = None, |
| filenames: bool = False, |
| nonamecheck: bool = False, |
| ): |
| """ |
| Find overlapping intervals in two or more BED/GFF/VCF/BAM files. |
| |
| This tool allows one to screen for overlaps between two sets of genomic features. |
| """ |
| |
| if not a.exists(): |
| raise FileNotFoundError(f"Input file -a does not exist: {a}") |
| for b_file in b: |
| if not b_file.exists(): |
| raise FileNotFoundError(f"Input file in -b list does not exist: {b_file}") |
| if g and not g.exists(): |
| raise FileNotFoundError(f"Genome file -g does not exist: {g}") |
|
|
| exclusive_flags = [u, c, v, wo, wao] |
| if sum(exclusive_flags) > 1: |
| raise ValueError("Options -u, -c, -v, -wo, -wao are mutually exclusive.") |
| if s and S: |
| raise ValueError("Options -s and -S are mutually exclusive.") |
| if f < 0.0 or f > 1.0: |
| raise ValueError("-f (fraction) must be between 0.0 and 1.0.") |
| if F < 0.0 or F > 1.0: |
| raise ValueError("-F (fraction) must be between 0.0 and 1.0.") |
| if sorted and not g: |
| raise ValueError("The -sorted option requires a genome file (-g).") |
|
|
| |
| cmd = ["bedtools", "intersect", "-a", str(a), "-b"] |
| cmd.extend([str(p) for p in b]) |
|
|
| if wa: cmd.append("-wa") |
| if wb: cmd.append("-wb") |
| if loj: cmd.append("-loj") |
| if wo: cmd.append("-wo") |
| if wao: cmd.append("-wao") |
| if u: cmd.append("-u") |
| if c: cmd.append("-c") |
| if v: cmd.append("-v") |
| if ubam: cmd.append("-ubam") |
| if s: cmd.append("-s") |
| if S: cmd.append("-S") |
| if f != 1e-9: cmd.extend(["-f", str(f)]) |
| if F != 1e-9: cmd.extend(["-F", str(F)]) |
| if r: cmd.append("-r") |
| if e: cmd.append("-e") |
| if split: cmd.append("-split") |
| if g: cmd.extend(["-g", str(g)]) |
| if header: cmd.append("-header") |
| if bed: cmd.append("-bed") |
| if sorted: cmd.append("-sorted") |
| if names: cmd.extend(["-names", names]) |
| if filenames: cmd.append("-filenames") |
| if nonamecheck: cmd.append("-nonamecheck") |
|
|
| |
| command_executed = " ".join(cmd) |
| output_files = [] |
| stdout_capture, stderr_capture = "", "" |
|
|
| try: |
| if output: |
| output_files.append(str(output)) |
| with open(output, "w") as f_out: |
| result = subprocess.run( |
| cmd, check=True, text=True, stdout=f_out, stderr=subprocess.PIPE |
| ) |
| stderr_capture = result.stderr |
| else: |
| result = subprocess.run( |
| cmd, check=True, text=True, capture_output=True |
| ) |
| stdout_capture = result.stdout |
| stderr_capture = result.stderr |
| except subprocess.CalledProcessError as e: |
| return { |
| "command_executed": command_executed, |
| "stdout": e.stdout or "", |
| "stderr": e.stderr or "CalledProcessError with no stderr.", |
| "output_files": [], |
| "return_code": e.returncode, |
| } |
|
|
| return { |
| "command_executed": command_executed, |
| "stdout": stdout_capture, |
| "stderr": stderr_capture, |
| "output_files": output_files, |
| } |
|
|
|
|
| @mcp.tool() |
| def merge( |
| i: Path, |
| output: Optional[Path] = None, |
| s: bool = False, |
| S: Optional[str] = None, |
| d: int = 0, |
| c: Optional[str] = None, |
| o: Optional[str] = None, |
| header: bool = False, |
| delim: str = ";", |
| n: bool = False, |
| nms: bool = False, |
| scores: Optional[str] = None, |
| bed: bool = False, |
| prec: int = 5, |
| ): |
| """ |
| Merge overlapping features in a BED/GFF/VCF file. |
| |
| This tool combines overlapping or "book-ended" features into a single feature. |
| """ |
| |
| if not i.exists(): |
| raise FileNotFoundError(f"Input file -i does not exist: {i}") |
| if S and S not in ["+", "-"]: |
| raise ValueError("Option -S must be either '+' or '-'.") |
| if s and S: |
| raise ValueError("Options -s and -S are mutually exclusive.") |
| if d < 0: |
| raise ValueError("Option -d (distance) must be a non-negative integer.") |
| if (c and not o) or (o and not c): |
| raise ValueError("Options -c and -o must be used together.") |
| if c and o: |
| if len(c.split(',')) != len(o.split(',')): |
| raise ValueError("The number of columns in -c must match the number of operations in -o.") |
|
|
| |
| cmd = ["bedtools", "merge", "-i", str(i)] |
|
|
| if s: cmd.append("-s") |
| if S: cmd.extend(["-S", S]) |
| if d > 0: cmd.extend(["-d", str(d)]) |
| if c: cmd.extend(["-c", c]) |
| if o: cmd.extend(["-o", o]) |
| if header: cmd.append("-header") |
| if delim != ";": cmd.extend(["-delim", delim]) |
| if n: cmd.append("-n") |
| if nms: cmd.append("-nms") |
| if scores: cmd.extend(["-scores", scores]) |
| if bed: cmd.append("-bed") |
| if prec != 5: cmd.extend(["-prec", str(prec)]) |
|
|
| |
| command_executed = " ".join(cmd) |
| output_files = [] |
| stdout_capture, stderr_capture = "", "" |
|
|
| try: |
| if output: |
| output_files.append(str(output)) |
| with open(output, "w") as f_out: |
| result = subprocess.run( |
| cmd, check=True, text=True, stdout=f_out, stderr=subprocess.PIPE |
| ) |
| stderr_capture = result.stderr |
| else: |
| result = subprocess.run( |
| cmd, check=True, text=True, capture_output=True |
| ) |
| stdout_capture = result.stdout |
| stderr_capture = result.stderr |
| except subprocess.CalledProcessError as e: |
| return { |
| "command_executed": command_executed, |
| "stdout": e.stdout or "", |
| "stderr": e.stderr or "CalledProcessError with no stderr.", |
| "output_files": [], |
| "return_code": e.returncode, |
| } |
|
|
| return { |
| "command_executed": command_executed, |
| "stdout": stdout_capture, |
| "stderr": stderr_capture, |
| "output_files": output_files, |
| } |
|
|
|
|
| @mcp.tool() |
| def subtract( |
| a: Path, |
| b: List[Path], |
| output: Optional[Path] = None, |
| f: float = 1e-9, |
| F: float = 1e-9, |
| r: bool = False, |
| e: bool = False, |
| s: bool = False, |
| S: bool = False, |
| A: bool = False, |
| B: bool = False, |
| N: bool = False, |
| header: bool = False, |
| g: Optional[Path] = None, |
| ): |
| """ |
| Remove overlapping intervals from a BED/GFF/VCF file. |
| |
| This tool removes portions of features in file A that are overlapped by features in file(s) B. |
| """ |
| |
| if not a.exists(): |
| raise FileNotFoundError(f"Input file -a does not exist: {a}") |
| for b_file in b: |
| if not b_file.exists(): |
| raise FileNotFoundError(f"Input file in -b list does not exist: {b_file}") |
| if g and not g.exists(): |
| raise FileNotFoundError(f"Genome file -g does not exist: {g}") |
| if A and B: |
| raise ValueError("Options -A and -B are mutually exclusive.") |
| if s and S: |
| raise ValueError("Options -s and -S are mutually exclusive.") |
|
|
| |
| cmd = ["bedtools", "subtract", "-a", str(a), "-b"] |
| cmd.extend([str(p) for p in b]) |
|
|
| if f != 1e-9: cmd.extend(["-f", str(f)]) |
| if F != 1e-9: cmd.extend(["-F", str(F)]) |
| if r: cmd.append("-r") |
| if e: cmd.append("-e") |
| if s: cmd.append("-s") |
| if S: cmd.append("-S") |
| if A: cmd.append("-A") |
| if B: cmd.append("-B") |
| if N: cmd.append("-N") |
| if header: cmd.append("-header") |
| if g: cmd.extend(["-g", str(g)]) |
|
|
| |
| command_executed = " ".join(cmd) |
| output_files = [] |
| stdout_capture, stderr_capture = "", "" |
|
|
| try: |
| if output: |
| output_files.append(str(output)) |
| with open(output, "w") as f_out: |
| result = subprocess.run( |
| cmd, check=True, text=True, stdout=f_out, stderr=subprocess.PIPE |
| ) |
| stderr_capture = result.stderr |
| else: |
| result = subprocess.run( |
| cmd, check=True, text=True, capture_output=True |
| ) |
| stdout_capture = result.stdout |
| stderr_capture = result.stderr |
| except subprocess.CalledProcessError as e: |
| return { |
| "command_executed": command_executed, |
| "stdout": e.stdout or "", |
| "stderr": e.stderr or "CalledProcessError with no stderr.", |
| "output_files": [], |
| "return_code": e.returncode, |
| } |
|
|
| return { |
| "command_executed": command_executed, |
| "stdout": stdout_capture, |
| "stderr": stderr_capture, |
| "output_files": output_files, |
| } |
|
|
|
|
| @mcp.tool() |
| def slop( |
| i: Path, |
| g: Path, |
| output: Optional[Path] = None, |
| b: int = 0, |
| l: int = 0, |
| r: int = 0, |
| s: bool = False, |
| pct: bool = False, |
| header: bool = False, |
| ): |
| """ |
| Increase the size of features in a BED/GFF/VCF file. |
| |
| This tool will increase the size of each feature in a feature file by a user-defined number of bases. |
| """ |
| |
| if not i.exists(): |
| raise FileNotFoundError(f"Input file -i does not exist: {i}") |
| if not g.exists(): |
| raise FileNotFoundError(f"Genome file -g does not exist: {g}") |
| if b != 0 and (l != 0 or r != 0): |
| raise ValueError("Option -b cannot be used with -l or -r.") |
|
|
| |
| cmd = ["bedtools", "slop", "-i", str(i), "-g", str(g)] |
|
|
| if b != 0: cmd.extend(["-b", str(b)]) |
| if l != 0: cmd.extend(["-l", str(l)]) |
| if r != 0: cmd.extend(["-r", str(r)]) |
| if s: cmd.append("-s") |
| if pct: cmd.append("-pct") |
| if header: cmd.append("-header") |
|
|
| |
| command_executed = " ".join(cmd) |
| output_files = [] |
| stdout_capture, stderr_capture = "", "" |
|
|
| try: |
| if output: |
| output_files.append(str(output)) |
| with open(output, "w") as f_out: |
| result = subprocess.run( |
| cmd, check=True, text=True, stdout=f_out, stderr=subprocess.PIPE |
| ) |
| stderr_capture = result.stderr |
| else: |
| result = subprocess.run( |
| cmd, check=True, text=True, capture_output=True |
| ) |
| stdout_capture = result.stdout |
| stderr_capture = result.stderr |
| except subprocess.CalledProcessError as e: |
| return { |
| "command_executed": command_executed, |
| "stdout": e.stdout or "", |
| "stderr": e.stderr or "CalledProcessError with no stderr.", |
| "output_files": [], |
| "return_code": e.returncode, |
| } |
|
|
| return { |
| "command_executed": command_executed, |
| "stdout": stdout_capture, |
| "stderr": stderr_capture, |
| "output_files": output_files, |
| } |
|
|
|
|
| @mcp.tool() |
| def getfasta( |
| fi: Path, |
| bed: Path, |
| fo: Optional[Path] = None, |
| s: bool = False, |
| split: bool = False, |
| name: bool = False, |
| name_plus: bool = False, |
| tab: bool = False, |
| full_header: bool = False, |
| ): |
| """ |
| Extract DNA sequences from a FASTA file based on BED/GFF/VCF coordinates. |
| """ |
| |
| if not fi.exists(): |
| raise FileNotFoundError(f"FASTA input file -fi does not exist: {fi}") |
| if not bed.exists(): |
| raise FileNotFoundError(f"BED/GFF/VCF file -bed does not exist: {bed}") |
| if name and name_plus: |
| raise ValueError("Options -name and -name+ are mutually exclusive.") |
|
|
| |
| cmd = ["bedtools", "getfasta", "-fi", str(fi), "-bed", str(bed)] |
|
|
| if s: cmd.append("-s") |
| if split: cmd.append("-split") |
| if name: cmd.append("-name") |
| if name_plus: cmd.append("-name+") |
| if tab: cmd.append("-tab") |
| if full_header: cmd.append("-fullHeader") |
| if fo: cmd.extend(["-fo", str(fo)]) |
|
|
| |
| command_executed = " ".join(cmd) |
| output_files = [] |
| stdout_capture, stderr_capture = "", "" |
|
|
| try: |
| |
| |
| result = subprocess.run( |
| cmd, check=True, text=True, capture_output=True |
| ) |
| stdout_capture = result.stdout |
| stderr_capture = result.stderr |
| if fo: |
| output_files.append(str(fo)) |
|
|
| except subprocess.CalledProcessError as e: |
| return { |
| "command_executed": command_executed, |
| "stdout": e.stdout or "", |
| "stderr": e.stderr or "CalledProcessError with no stderr.", |
| "output_files": [], |
| "return_code": e.returncode, |
| } |
|
|
| return { |
| "command_executed": command_executed, |
| "stdout": stdout_capture, |
| "stderr": stderr_capture, |
| "output_files": output_files, |
| } |
|
|
|
|
| @mcp.tool() |
| def sort( |
| i: Path, |
| output: Optional[Path] = None, |
| header: bool = False, |
| g: Optional[Path] = None, |
| faidx: Optional[Path] = None, |
| sizeA: bool = False, |
| sizeD: bool = False, |
| chrThenSizeA: bool = False, |
| chrThenSizeD: bool = False, |
| chrThenScoreA: bool = False, |
| chrThenScoreD: bool = False, |
| ): |
| """ |
| Sort a BED/GFF/VCF file by chromosome and then by start position. |
| """ |
| |
| if not i.exists(): |
| raise FileNotFoundError(f"Input file -i does not exist: {i}") |
| if g and not g.exists(): |
| raise FileNotFoundError(f"Genome file -g does not exist: {g}") |
| if faidx and not faidx.exists(): |
| raise FileNotFoundError(f"FASTA index file -faidx does not exist: {faidx}") |
|
|
| sort_flags = [sizeA, sizeD, chrThenSizeA, chrThenSizeD, chrThenScoreA, chrThenScoreD] |
| if sum(sort_flags) > 1: |
| raise ValueError("Multiple sorting order flags were provided, but only one is allowed.") |
|
|
| |
| cmd = ["bedtools", "sort", "-i", str(i)] |
|
|
| if header: cmd.append("-header") |
| if g: cmd.extend(["-g", str(g)]) |
| if faidx: cmd.extend(["-faidx", str(faidx)]) |
| if sizeA: cmd.append("-sizeA") |
| if sizeD: cmd.append("-sizeD") |
| if chrThenSizeA: cmd.append("-chrThenSizeA") |
| if chrThenSizeD: cmd.append("-chrThenSizeD") |
| if chrThenScoreA: cmd.append("-chrThenScoreA") |
| if chrThenScoreD: cmd.append("-chrThenScoreD") |
|
|
| |
| command_executed = " ".join(cmd) |
| output_files = [] |
| stdout_capture, stderr_capture = "", "" |
|
|
| try: |
| if output: |
| output_files.append(str(output)) |
| with open(output, "w") as f_out: |
| result = subprocess.run( |
| cmd, check=True, text=True, stdout=f_out, stderr=subprocess.PIPE |
| ) |
| stderr_capture = result.stderr |
| else: |
| result = subprocess.run( |
| cmd, check=True, text=True, capture_output=True |
| ) |
| stdout_capture = result.stdout |
| stderr_capture = result.stderr |
| except subprocess.CalledProcessError as e: |
| return { |
| "command_executed": command_executed, |
| "stdout": e.stdout or "", |
| "stderr": e.stderr or "CalledProcessError with no stderr.", |
| "output_files": [], |
| "return_code": e.returncode, |
| } |
|
|
| return { |
| "command_executed": command_executed, |
| "stdout": stdout_capture, |
| "stderr": stderr_capture, |
| "output_files": output_files, |
| } |
|
|
|
|
| @mcp.tool() |
| def coverage( |
| a: Path, |
| b: List[Path], |
| output: Optional[Path] = None, |
| s: bool = False, |
| S: bool = False, |
| f: float = 1e-9, |
| F: float = 1e-9, |
| r: bool = False, |
| e: bool = False, |
| split: bool = False, |
| d: bool = False, |
| counts: bool = False, |
| hist: bool = False, |
| mean: bool = False, |
| g: Optional[Path] = None, |
| header: bool = False, |
| sorted: bool = False, |
| ): |
| """ |
| Compute the coverage of features in file A on features in file(s) B. |
| """ |
| |
| if not a.exists(): |
| raise FileNotFoundError(f"Input file -a does not exist: {a}") |
| for b_file in b: |
| if not b_file.exists(): |
| raise FileNotFoundError(f"Input file in -b list does not exist: {b_file}") |
| if g and not g.exists(): |
| raise FileNotFoundError(f"Genome file -g does not exist: {g}") |
| if s and S: |
| raise ValueError("Options -s and -S are mutually exclusive.") |
| if sorted and not g: |
| raise ValueError("The -sorted option requires a genome file (-g).") |
|
|
| |
| cmd = ["bedtools", "coverage", "-a", str(a), "-b"] |
| cmd.extend([str(p) for p in b]) |
|
|
| if s: cmd.append("-s") |
| if S: cmd.append("-S") |
| if f != 1e-9: cmd.extend(["-f", str(f)]) |
| if F != 1e-9: cmd.extend(["-F", str(F)]) |
| if r: cmd.append("-r") |
| if e: cmd.append("-e") |
| if split: cmd.append("-split") |
| if d: cmd.append("-d") |
| if counts: cmd.append("-counts") |
| if hist: cmd.append("-hist") |
| if mean: cmd.append("-mean") |
| if g: cmd.extend(["-g", str(g)]) |
| if header: cmd.append("-header") |
| if sorted: cmd.append("-sorted") |
|
|
| |
| command_executed = " ".join(cmd) |
| output_files = [] |
| stdout_capture, stderr_capture = "", "" |
|
|
| try: |
| if output: |
| output_files.append(str(output)) |
| with open(output, "w") as f_out: |
| result = subprocess.run( |
| cmd, check=True, text=True, stdout=f_out, stderr=subprocess.PIPE |
| ) |
| stderr_capture = result.stderr |
| else: |
| result = subprocess.run( |
| cmd, check=True, text=True, capture_output=True |
| ) |
| stdout_capture = result.stdout |
| stderr_capture = result.stderr |
| except subprocess.CalledProcessError as e: |
| return { |
| "command_executed": command_executed, |
| "stdout": e.stdout or "", |
| "stderr": e.stderr or "CalledProcessError with no stderr.", |
| "output_files": [], |
| "return_code": e.returncode, |
| } |
|
|
| return { |
| "command_executed": command_executed, |
| "stdout": stdout_capture, |
| "stderr": stderr_capture, |
| "output_files": output_files, |
| } |
|
|
|
|
| @mcp.tool() |
| def genomecov( |
| i: Path, |
| g: Path, |
| output: Optional[Path] = None, |
| d: bool = False, |
| dz: bool = False, |
| bga: bool = False, |
| bg: bool = False, |
| scale: float = 1.0, |
| pc: bool = False, |
| fs: bool = False, |
| split: bool = False, |
| strand: Optional[str] = None, |
| max: int = 0, |
| trackline: bool = False, |
| trackopts: Optional[str] = None, |
| ): |
| """ |
| Compute genome-wide coverage of a feature file. |
| """ |
| |
| if not i.exists(): |
| raise FileNotFoundError(f"Input file -i does not exist: {i}") |
| if not g.exists(): |
| raise FileNotFoundError(f"Genome file -g does not exist: {g}") |
| if strand and strand not in ["+", "-"]: |
| raise ValueError("Option -strand must be either '+' or '-'.") |
| if max < 0: |
| raise ValueError("Option -max must be a non-negative integer.") |
| output_modes = [d, dz, bga, bg] |
| if sum(output_modes) > 1: |
| raise ValueError("Output format flags (-d, -dz, -bga, -bg) are mutually exclusive.") |
|
|
| |
| cmd = ["bedtools", "genomecov", "-i", str(i), "-g", str(g)] |
|
|
| if d: cmd.append("-d") |
| if dz: cmd.append("-dz") |
| if bga: cmd.append("-bga") |
| if bg: cmd.append("-bg") |
| if scale != 1.0: cmd.extend(["-scale", str(scale)]) |
| if pc: cmd.append("-pc") |
| if fs: cmd.append("-fs") |
| if split: cmd.append("-split") |
| if strand: cmd.extend(["-strand", strand]) |
| if max > 0: cmd.extend(["-max", str(max)]) |
| if trackline: cmd.append("-trackline") |
| if trackopts: cmd.extend(["-trackopts", trackopts]) |
|
|
| |
| command_executed = " ".join(cmd) |
| output_files = [] |
| stdout_capture, stderr_capture = "", "" |
|
|
| try: |
| if output: |
| output_files.append(str(output)) |
| with open(output, "w") as f_out: |
| result = subprocess.run( |
| cmd, check=True, text=True, stdout=f_out, stderr=subprocess.PIPE |
| ) |
| stderr_capture = result.stderr |
| else: |
| result = subprocess.run( |
| cmd, check=True, text=True, capture_output=True |
| ) |
| stdout_capture = result.stdout |
| stderr_capture = result.stderr |
| except subprocess.CalledProcessError as e: |
| return { |
| "command_executed": command_executed, |
| "stdout": e.stdout or "", |
| "stderr": e.stderr or "CalledProcessError with no stderr.", |
| "output_files": [], |
| "return_code": e.returncode, |
| } |
|
|
| return { |
| "command_executed": command_executed, |
| "stdout": stdout_capture, |
| "stderr": stderr_capture, |
| "output_files": output_files, |
| } |
|
|
| if __name__ == "__main__": |
| mcp.run(transport="stdio") |
|
|