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.
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.
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.
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.- A bgzipped VCF (
.vcf.gz), or several. No.tbiindex file is required — vcf-mcp builds the index in memory at registration time. (If a.tbialready 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).
cargo build --releaseThe binary lands at target/release/vcf-mcp (or .exe on Windows).
If
CARGO_TARGET_DIRis set globally, the project's.cargo/config.tomltries 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 targeton the command line.
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 --checkvcf-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.gzThis 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.
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 -ForceThen 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).
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.
| 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):
- Path canonicalizes (resolves symlinks)
- Path exists, is a regular file, readable
- Filename ends
.vcf.gz(case-insensitive) - First 4 bytes match BGZF magic
1F 8B 08 04(rejects plain gzip) - Tabix index is acquired:
.tbiis loaded from disk if present, otherwise built in memory — no.tbiis ever written next to the VCF noodles_vcf::io::IndexedReaderopens the file with the acquired index- Header has
#CHROMline, ≥1 sample column, ≥1##contig - 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 ..."}
]
}| 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 ..."}
]
}| 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.
No arguments.
[
{"name": "me", "build": "GRCh38", "description": "...", "vcf_path": "..."},
{"name": "spouse", "build": "GRCh38", "description": "...", "vcf_path": "..."}
]| 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"
}
]
}altis comma-separated for multi-allelic records.- gVCF reference-call records have
alt: "."andgenotype: "0/0". filteris"PASS"for variants that pass all filters,"."for unfiltered records, or a semicolon-joined filter name list.
| 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.
| 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?
| 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}.
src/main.rs: clap CLI; theservesubcommand 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 relativevcf_pathagainst the config file's directory.src/vcf.rs: query implementations. All blocking VCF I/O happens insidetokio::task::spawn_blockingwith a 30s timeout. TheRsidCacheisArc<RwLock<HashMap<sample, Arc<RsidIndex>>>>for lock-free reads after the per-sample cache is warm.src/genes.rs: embedded gene tables viainclude_str!, parsed lazily throughOnceLockon first lookup per build.src/tools.rs: rmcp tool definitions. Each tool is anasyncmethod onVcfServerannotated with#[tool]; the#[tool_router]and#[tool_handler]macros wire them into theServerHandlerimpl.
Built on:
rmcp— official Rust MCP SDK.noodles— bioinformatics I/O (VCF, bgzf, tabix, csi).tokio— async runtime.
cargo test --release
cargo clippy --release --all-targets --all-features -- -D warnings
cargo fmt --all -- --checkSmart 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 intarget/debug/build/.../build-script-buildmay be blocked, which breakscargo testwithout--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/index_vcf.rs— build a tabix.tbiindex for an existing bgzipped VCF.examples/slice_vcf.rs— extract a subregion of a bgzipped VCF (+ its index) usingnoodles.examples/annotate_rsids.rs— rewrite a VCF's ID column with synthetic deterministic rsids (used to build alookup_rsidstest fixture on hosts where SAC permits it).
The committed data/genes_grch3{7,8}.tsv files are pre-built from Ensembl
release 115 / 87. To regenerate from a different release:
- Download fresh GTFs into
data/ensembl/(gitignored). The URLs encoded in the source comments are stable. - 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.
- Gene coordinates: Ensembl release 115 (GRCh38) and the GRCh37 archive release 87. Protein-coding genes only.
- Test fixture: GIAB NA12878 / HG001 v4.2.1 benchmark VCF, sliced to chr17:50100000-50300000 (COL1A1 region).
Open an issue on the repository, or email NahButch@users.noreply.github.com.
MIT.