Skip to content

NahButch/vcf_mcp

Repository files navigation

vcf-mcp

A Model Context Protocol server that lets an AI assistant query bgzipped VCF files — a personal genome, a cohort sample, anything in VCF format — through natural-language prompts. No .tbi index file required; the server builds one in memory at registration time. Built in Rust on top of noodles for VCF I/O and rmcp for the MCP protocol.

Disclaimer

vcf-mcp is research / engineering tooling. It is not a medical device and must not be used for clinical diagnosis, treatment decisions, or any other purpose involving patient care. Output may be incomplete, incorrect, or out-of-date relative to the underlying VCF data. Always verify results against the source files and authoritative annotations before drawing biological conclusions. Use of this software is at the user's own risk; see the LICENSE for the full warranty disclaimer.

What it does

Exposes ten tools to an MCP client:

Tool Description
add_sample Register a single VCF file at runtime. Validates BGZF magic, header structure, and runs a tabix probe before accepting. Auto-detects genome build from the VCF header; auto-derives sample name from the filename. By default also scans the directory for variant-class siblings (see below).
add_samples_from_folder Scan a folder for *.vcf.gz and register each that passes validation. Optional recursive walk. Default cap 50 files, hard cap 200.
remove_sample Unregister a sample by name (doesn't delete the file).
reset_samples Drop every registered sample and its caches; rewrites the state file empty. Destructive — requires confirm: true.
list_samples List currently registered samples.
server_info Report vcf-mcp version, embedded Ensembl release per build, and registered-sample count.
query_region Return variant calls overlapping a chromosomal region.
lookup_rsids Look up variants by dbSNP rsid; lazy per-sample cache.
query_gene Return variants in a gene's coordinates using the embedded Ensembl 115/87 gene table.
compare_samples Run the same query across multiple samples and merge per-sample results.

The tools return structured JSON. An MCP client like Claude Desktop calls them on demand as the model reasons about the prompts. Zero-config startup is supported — point Claude Desktop at the binary, then in a chat paste a VCF path (or a folder of VCFs) and ask Claude to register them. The server auto-detects genome build and persists registrations to a state file so they survive restarts.

Quick start

git clone <repo> vcf-mcp && cd vcf-mcp
cargo build --release
# (Optional) generate a tabix index if the VCF doesn't have one:
cargo run --release --example index_vcf -- /path/to/sample.vcf.gz
# Wire the binary into the MCP client (see "Claude Desktop" below).
# No config file needed — point the MCP client at the binary and supply a VCF path
# when prompted; the server registers it via add_sample.

Prerequisites

  • A bgzipped VCF (.vcf.gz), or several. No .tbi index file is required — vcf-mcp builds the index in memory at registration time. (If a .tbi already sits alongside the file it's used as a fast-load shortcut, but it's optional.)
  • Rust ≥ 1.85 (transitive deps use Rust 2024 edition features). Install from rustup.rs.
  • An MCP-capable client. Tested with Claude Desktop on Windows.

On Windows, either the MSVC build tools or the GNU toolchain is needed (rustup target add x86_64-pc-windows-msvc is the default).

Build

cargo build --release

The binary lands at target/release/vcf-mcp (or .exe on Windows).

If CARGO_TARGET_DIR is set globally, the project's .cargo/config.toml tries to override it back to ./target/. Cargo's env var still wins over the config file though, so in a shell that has the env set, either clear it (unset CARGO_TARGET_DIR / $env:CARGO_TARGET_DIR='') or pass --target-dir target on the command line.

Configure (optional)

There are three ways to register samples; pick whichever fits the workflow:

1. In chat (recommended) — say "add this VCF: D:\path\file.vcf.gz" and Claude calls add_sample. The server validates the file and remembers it. Registrations persist across server restarts via a small auto-managed state file:

OS Default state file
Linux ~/.local/share/vcf-mcp/state.json
macOS ~/Library/Application Support/vcf-mcp/state.json
Windows %LOCALAPPDATA%\vcf-mcp\data\state.json

This file is written by the server, not edited directly. Override the location with --state-file <path> or disable persistence entirely with --ephemeral.

2. TOML bootstrap — for an existing setup, or to seed several samples at once. Pass --config /path/to/config.toml; the file is imported into the registry on startup. After import, normal add_sample / remove_sample operations still work and persist to the state file. Example (also see config.example.toml):

[[samples]]
name = "me"                                    # short identifier used in prompts
vcf_path = "person_genome_001.snp-indel.vcf.gz"  # absolute, or relative to this config file
build = "GRCh38"                               # or "GRCh37"
description = "Whole-genome sequencing, 2025-06-24"

3. Folder scan — say "register everything in D:\cohort" and Claude calls add_samples_from_folder. Default cap 50 files, hard cap 200; see the tool reference below.

Whichever path is chosen, each VCF must be bgzipped (.vcf.gz). No .tbi is required — the server builds the index in memory when the sample is registered.

To validate startup without serving:

vcf-mcp serve --check

Building a .tbi index (optional)

vcf-mcp builds the tabix index in memory on first registration when a .tbi isn't present, so pre-indexing is usually unnecessary. To write a .tbi to disk anyway (e.g. for use with tabix or bcftools):

cargo run --release --example index_vcf -- /path/to/sample.vcf.gz

This streams the file and writes <sample.vcf.gz>.tbi. Throughput is roughly 3 million records/second on a typical desktop; a 30× WGS file with ~12M variants indexes in under 5 seconds. vcf-mcp will use that file directly on next add_sample rather than rebuilding.

Wire into Claude Desktop

Locate claude_desktop_config.json. The cleanest way is Settings → Developer → Edit Config from inside Claude Desktop — that button opens the file at whatever path the install actually uses (it differs between the standalone download and the Microsoft Store / MSIX build on Windows).

Add the vcf-mcp entry under mcpServers (merge with any existing entries — don't replace the whole file):

{
  "mcpServers": {
    "vcf-mcp": {
      "command": "/absolute/path/to/vcf-mcp",
      "args": ["serve"]
    }
  }
}

The minimal serve form uses state-file persistence — registrations made via chat survive restarts. To bootstrap from a TOML config, add "--config", "/path/to/config.toml" to the args. To run entirely without persistence (each session blank), add "--ephemeral".

On Windows, escape backslashes ("D:\\code\\vcf-mcp\\target\\release\\vcf-mcp.exe") or use forward slashes.

Restart Claude Desktop fully — right-click the tray icon → Quit. Just closing the window leaves the process running, and the running process won't pick up the config change. On the Windows Store build, it may be necessary to manually kill any lingering claude.exe processes:

Get-Process -Name claude -ErrorAction SilentlyContinue |
  Where-Object { $_.Path -like '*WindowsApps*' } |
  Stop-Process -Force

Then open a new chat and try:

What samples are configured?
What variants do I have at chr19:44905000-44910000?
Look up rs429358 and rs7412 for me.
What variants do I have in the APOE gene?
What's my ApoE genotype?

The last prompt is interesting — it requires composing tool output with biological knowledge (the absence of rs429358 / rs7412 in the VCF implies homozygous reference at both, which is the ε3/ε3 genotype).

Tool reference

All tools take a sample argument (the name from the config) and return JSON in the text field of an MCP CallToolResult. Coordinate conventions are 1-based inclusive throughout, matching the VCF spec and command-line tools like tabix.

add_sample

Arg Type Required Notes
path string Absolute path to a bgzipped VCF (.vcf.gz)
name string Auto-derived from filename if omitted
build string "GRCh37" or "GRCh38"; auto-detected from VCF header if omitted
description string Human-readable note
scan_adjacent bool Scan the directory for variant-class siblings. Default true

Validation chain (short-circuits on the first failure, cheapest first):

  1. Path canonicalizes (resolves symlinks)
  2. Path exists, is a regular file, readable
  3. Filename ends .vcf.gz (case-insensitive)
  4. First 4 bytes match BGZF magic 1F 8B 08 04 (rejects plain gzip)
  5. Tabix index is acquired: .tbi is loaded from disk if present, otherwise built in memory — no .tbi is ever written next to the VCF
  6. noodles_vcf::io::IndexedReader opens the file with the acquired index
  7. Header has #CHROM line, ≥1 sample column, ≥1 ##contig
  8. Tabix probe on the first indexed contig proves data ↔ index consistency

The in-memory tabix index is cached on the server (per sample) for the lifetime of the process, so subsequent queries don't rebuild. Indexes are NOT persisted across restarts — they're rebuilt on first use against each sample, which is fast (~3.5 s for a 30× WGS file; rsID caches build in a similar few seconds). To keep even that small cost off the first user-facing query, the server warms every registered sample's caches in the background at startup, and warms the rsID cache for newly added samples in the background after add_sample. See KNOWN_ISSUES.md for measured timings and the OneDrive-hydration caveat.

Build detection cascade: ##reference= substring → ##contig=<...assembly=...> field → chr1 length heuristic (GRCh38: 248,956,422; GRCh37: 249,250,621).

Re-adding the same canonical path without an explicit name returns the existing entry (idempotent). Re-adding with an explicit name registers a new entry — useful for viewing the same file under multiple labels.

Adjacent-file auto-detection (scan_adjacent, default true): variant callers often emit one cohort as several files sharing a stem and differing only by a variant-class token — e.g. person.snp-indel.genome.vcf.gz, person.cnv.vcf.gz, person.sv.vcf.gz, person.mitochondrial.vcf.gz. After the primary file registers, vcf-mcp strips the recognized class token to find the stem and looks in the same directory for siblings in the other classes. Each sibling goes through the full validation chain above and registers as a separate sample named {primary_name}-{class} (-cnv, -sv, -mt). The scan is non-fatal: a sibling that fails validation never blocks the primary.

Recognized class tokens (case-insensitive, stripped before .vcf.gz): .snp-indel.genome, .snp-indel, .snv, .small-variants (treated as the primary class, no suffix), .cnv-cnv, .sv / .structural-sv, .mitochondrial / .mt-mt. If the primary's name ends in none of these tokens, no scan runs. The scan is idempotent — already-registered siblings are not duplicated. Pass scan_adjacent: false to register only the single file.

The response extends the primary's summary with two arrays — adjacent (the siblings that registered) and adjacent_skipped (siblings found but rejected, each with a reason). Both are empty when nothing applies:

{
  "name": "person", "build": "GRCh38", "description": "", "vcf_path": "...person.snp-indel.genome.vcf.gz",
  "variant_class": "small-variants",
  "adjacent": [
    {"name": "person-cnv", "build": "GRCh38", "variant_class": "cnv", "vcf_path": "...person.cnv.vcf.gz"},
    {"name": "person-sv",  "build": "GRCh38", "variant_class": "sv",  "vcf_path": "...person.sv.vcf.gz"}
  ],
  "adjacent_skipped": [
    {"path": "...person.mitochondrial.vcf.gz", "reason": "BGZF magic mismatch ..."}
  ]
}

add_samples_from_folder

Arg Type Required Notes
folder string Absolute path to a directory
recursive bool Walk subdirectories. Default false
max_files integer Max files to register; default 50, hard cap 200

If the folder contains more .vcf.gz files than max_files, the call errors with a hint telling the caller exactly which max_files value would let the scan proceed. Per-file validation failures don't abort the scan — they collect in a skipped array with the per-file reason:

{
  "folder": "D:\\cohort",
  "scanned": 12,
  "registered": [
    {"name": "person_genome_001", "build": "GRCh38", "description": "", "vcf_path": "..."}
  ],
  "skipped": [
    {"path": "D:\\cohort\\malformed.vcf.gz", "reason": "BGZF magic mismatch ..."}
  ]
}

remove_sample

Arg Type Required Notes
name string Registered sample name

Drops the sample from the registry (and from the state file). Returns the removed sample's summary, or {"removed": false, "name": "..."} if no such sample was registered.

list_samples

No arguments.

[
  {"name": "me", "build": "GRCh38", "description": "...", "vcf_path": "..."},
  {"name": "spouse", "build": "GRCh38", "description": "...", "vcf_path": "..."}
]

query_region

Arg Type Required Notes
sample string Configured sample name
chrom string With or without chr prefix; server normalizes
start integer 1-based inclusive
end integer 1-based inclusive

Maximum 10 Mb region. Result is capped at 500 records; when truncated, the truncated flag is true.

{
  "sample": "me",
  "chrom": "chr19",
  "start": 44905000,
  "end": 44910000,
  "count": 10,
  "truncated": false,
  "variants": [
    {
      "chrom": "19",
      "pos": 44905579,
      "ref": "T",
      "alt": "G",
      "rsid": "rs405509",
      "genotype": "1/1",
      "genotype_alleles": "GG",
      "depth": 33,
      "gq": 73,
      "filter": "PASS"
    }
  ]
}
  • alt is comma-separated for multi-allelic records.
  • gVCF reference-call records have alt: "." and genotype: "0/0".
  • filter is "PASS" for variants that pass all filters, "." for unfiltered records, or a semicolon-joined filter name list.

lookup_rsids

Arg Type Required Notes
sample string
rsids array of string 1–100 entries; blank/whitespace entries are rejected

A blank or whitespace-only rsid is treated as a caller mistake and returns an input error (rather than silently yielding found: false, which would look like a real not-in-sample result).

First call against a sample triggers a one-time scan of the VCF to build an in-memory rsid → position cache (a few seconds on a 30× WGS file). Subsequent calls reuse the cache for O(1) lookup. The cache lives for the lifetime of the server process.

{
  "sample": "me",
  "count": 3,
  "found_count": 2,
  "results": [
    {"rsid": "rs405509", "found": true, "variant": {/* same shape as query_region */}},
    {"rsid": "rs9999999", "found": false},
    {"rsid": "rs440446", "found": true, "variant": {...}}
  ]
}

Results are in input order. Entries not found yield {rsid, found: false}.

Note on "not found": VCFs typically carry records only at sites where the sample differs from reference. An rsid not present usually means the sample is homozygous reference at that position — not that the lookup is broken. Common variants like rs429358 / rs7412 are absent for everyone with the common haplotype.

query_gene

Arg Type Required Notes
sample string
gene string HGNC symbol, case-insensitive
flank_bp integer Bases added to each side of the gene's coords. Default 0.

Gene coordinates come from the embedded Ensembl release pinned to the sample's build: release 115 for GRCh38, release 87 (the GRCh37 archive freeze) for GRCh37. Protein-coding genes only. Symbols that resolve to more than one Ensembl ID are dropped to avoid ambiguity.

{
  "gene": "APOE",
  "ensembl_id": "ENSG00000130203",
  "chrom": "19",
  "start": 44903787,
  "end": 44909396,
  "build": "GRCh38",
  "flank_bp": 0,
  "count": 9,
  "truncated": false,
  "variants": [/* same shape as query_region */]
}

Unknown symbols return an error with Levenshtein-1 distance suggestions:

gene "BRCA22" not found in GRCh38. Did you mean: BRCA2?

compare_samples

Arg Type Required Notes
samples array of string Minimum 2 configured sample names
query object Either {"rsids": [...]} or {"chrom": ..., "start": ..., "end": ...}

Composes lookup_rsids or query_region across multiple samples and merges the per-sample results into one position-keyed table.

{
  "samples": ["me", "spouse"],
  "query_type": "rsids",
  "count": 1,
  "truncated": false,
  "results": [
    {
      "rsid": "rs405509",
      "chrom": "19",
      "pos": 44905579,
      "ref": "T",
      "alt": "G",
      "results": [
        {"sample": "me",     "found": true, "genotype": "1/1", "genotype_alleles": "GG", "depth": 33, "gq": 73, "filter": "PASS"},
        {"sample": "spouse", "found": false}
      ]
    }
  ]
}

Each merged entry's results array contains one item per input sample, in input order. Samples without a matching (chrom, pos, ref, alt) get {found: false}.

Architecture

  • src/main.rs: clap CLI; the serve subcommand bootstraps tracing (stderr only — stdout is reserved for MCP JSON-RPC) and runs the MCP server over stdio.
  • src/config.rs: TOML config loader; validates VCF + index existence at startup; resolves relative vcf_path against the config file's directory.
  • src/vcf.rs: query implementations. All blocking VCF I/O happens inside tokio::task::spawn_blocking with a 30s timeout. The RsidCache is Arc<RwLock<HashMap<sample, Arc<RsidIndex>>>> for lock-free reads after the per-sample cache is warm.
  • src/genes.rs: embedded gene tables via include_str!, parsed lazily through OnceLock on first lookup per build.
  • src/tools.rs: rmcp tool definitions. Each tool is an async method on VcfServer annotated with #[tool]; the #[tool_router] and #[tool_handler] macros wire them into the ServerHandler impl.

Built on:

  • rmcp — official Rust MCP SDK.
  • noodles — bioinformatics I/O (VCF, bgzf, tabix, csi).
  • tokio — async runtime.

Development

cargo test --release
cargo clippy --release --all-targets --all-features -- -D warnings
cargo fmt --all -- --check

Smart App Control on Windows 11: SAC can silently block freshly-compiled unsigned exes. Release binaries placed in target/release/ are typically allowed; debug-mode build scripts in target/debug/build/.../build-script-build may be blocked, which breaks cargo test without --release. Either run tests in release mode (the CI does this implicitly) or turn SAC off — note that disabling SAC is a one-way change without an OS reinstall.

Examples

  • examples/index_vcf.rs — build a tabix .tbi index for an existing bgzipped VCF.
  • examples/slice_vcf.rs — extract a subregion of a bgzipped VCF (+ its index) using noodles.
  • examples/annotate_rsids.rs — rewrite a VCF's ID column with synthetic deterministic rsids (used to build a lookup_rsids test fixture on hosts where SAC permits it).

Regenerating the gene tables

The committed data/genes_grch3{7,8}.tsv files are pre-built from Ensembl release 115 / 87. To regenerate from a different release:

  1. Download fresh GTFs into data/ensembl/ (gitignored). The URLs encoded in the source comments are stable.
  2. Run the preprocessor:
    cargo test --release -- --ignored generate_gene_tables

This re-emits data/genes_grch3{7,8}.tsv, which is then embedded into the binary via include_str! at the next build.

Data sources

Contact

Open an issue on the repository, or email NahButch@users.noreply.github.com.

License

MIT.

About

MCP Connector for VCF Genetic Data File(s). Single, multiple, comparison, data setting, etc.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages